diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index aa013f999..c9e98ecef 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -46,6 +46,7 @@ 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 { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { recomputeBucketsForMeasurement, collapseToTypeDayKeys, @@ -484,6 +485,17 @@ async function postBatch(request: NextRequest): Promise { for (const k of insertedKeys) { await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt); } + + // v1.8.0 — drop the cached per-metric assessment rows the ingested + // types dirty so the next mount / nightly warm pass regenerates + // them against the new data instead of serving stale text. + // Fire-and-forget: never a blocker on the user's ingest. + invalidateStatusInsightsForTypes( + user.id, + insertedKeys.map((k) => k.type), + ).catch((err) => { + console.warn("[measurements] status-insight invalidate failed", err); + }); } catch (err) { console.warn("[measurements] rollup recompute failed", err); } diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 97e3dd57f..7331f81c8 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -28,6 +28,7 @@ import { } from "@/lib/measurements/drain-per-sample-cumulative"; import { withIdempotency } from "@/lib/idempotency"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; +import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { recomputeBucketsForMeasurement, collapseToTypeDayKeys, @@ -664,6 +665,16 @@ async function postMeasurement(request: NextRequest) { for (const k of keys) { await recomputeBucketsForMeasurement(user.id, k.type, k.measuredAt); } + + // v1.8.0 — drop the cached per-metric assessment rows the ingested + // types dirty so the next mount / nightly warm pass regenerates + // them against the new data. Fire-and-forget: never blocks ingest. + invalidateStatusInsightsForTypes( + user.id, + keys.map((k) => k.type), + ).catch((err) => { + console.warn("[measurements] status-insight invalidate failed", err); + }); } catch (err) { console.warn("[measurements] rollup recompute failed", err); } @@ -768,5 +779,12 @@ async function postMeasurement(request: NextRequest) { console.warn("[measurements] rollup recompute failed", err); } + // v1.8.0 — drop the cached per-metric assessment rows this type + // dirties so the next mount / nightly warm pass regenerates against + // the new row. Fire-and-forget: never blocks the user's write. + invalidateStatusInsightsForTypes(user.id, [measurement.type]).catch((err) => { + console.warn("[measurements] status-insight invalidate failed", err); + }); + return apiSuccess(measurement, 201); } diff --git a/src/lib/insights/__tests__/invalidate-status-insights.test.ts b/src/lib/insights/__tests__/invalidate-status-insights.test.ts new file mode 100644 index 000000000..90c68a940 --- /dev/null +++ b/src/lib/insights/__tests__/invalidate-status-insights.test.ts @@ -0,0 +1,84 @@ +/** + * v1.8.0 — measurement-driven per-metric assessment invalidation. + * + * A fresh measurement of a given type must drop the cached + * `insights.-status.` rows for the scopes that reading + * dirties, so the next mount / nightly warm pass regenerates them + * against the new data instead of serving the pre-measurement text for + * the rest of the day. These tests pin the type → scope mapping and the + * delete shape without a live DB. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const deleteMany = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + auditLog: { deleteMany: (...a: unknown[]) => deleteMany(...a) }, + }, +})); + +import { invalidateStatusInsightsForTypes } from "../comprehensive-generate"; + +beforeEach(() => { + vi.clearAllMocks(); + deleteMany.mockResolvedValue({ count: 0 }); +}); + +function deletedScopes(): string[] { + const arg = deleteMany.mock.calls[0][0]; + // Each OR clause is `{ action: { startsWith: "insights.-status." } }`. + return (arg.where.OR as Array<{ action: { startsWith: string } }>) + .map((c) => c.action.startsWith.replace(/^insights\.(.+)-status\.$/, "$1")) + .sort(); +} + +describe("invalidateStatusInsightsForTypes", () => { + it("maps WEIGHT to weight + bmi + general", async () => { + await invalidateStatusInsightsForTypes("u1", ["WEIGHT"]); + expect(deleteMany).toHaveBeenCalledTimes(1); + expect(deletedScopes()).toEqual(["bmi", "general", "weight"]); + expect(deleteMany.mock.calls[0][0].where.userId).toBe("u1"); + }); + + it("maps both blood-pressure components to blood-pressure + general", async () => { + await invalidateStatusInsightsForTypes("u1", [ + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + ]); + expect(deletedScopes()).toEqual(["blood-pressure", "general"]); + }); + + it("maps PULSE and RESTING_HEART_RATE to pulse + general", async () => { + await invalidateStatusInsightsForTypes("u1", [ + "PULSE", + "RESTING_HEART_RATE", + ]); + expect(deletedScopes()).toEqual(["general", "pulse"]); + }); + + it("maps an unmapped metric to general only", async () => { + await invalidateStatusInsightsForTypes("u1", ["BLOOD_GLUCOSE"]); + expect(deletedScopes()).toEqual(["general"]); + }); + + it("dedupes scopes across a mixed batch", async () => { + await invalidateStatusInsightsForTypes("u1", [ + "WEIGHT", + "PULSE", + "BLOOD_GLUCOSE", + ]); + // weight + bmi + general (WEIGHT) ∪ pulse + general (PULSE) ∪ general + expect(deletedScopes()).toEqual([ + "bmi", + "general", + "pulse", + "weight", + ]); + }); + + it("is a no-op for an empty type set (no DB call)", async () => { + await invalidateStatusInsightsForTypes("u1", []); + expect(deleteMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/withings/sync-activity.ts b/src/lib/withings/sync-activity.ts index 0a2428242..d9e032af3 100644 --- a/src/lib/withings/sync-activity.ts +++ b/src/lib/withings/sync-activity.ts @@ -43,6 +43,7 @@ import { collapseToTypeDayKeys, recomputeBucketsForMeasurement, } from "@/lib/rollups/measurement-rollups"; +import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { hasActivityScope } from "./client"; import { @@ -371,6 +372,17 @@ export async function syncUserActivity( for (const k of keys) { await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt); } + + // v1.8.0 — drop the per-metric assessment caches the synced types + // dirty (steps feed the general overview). Fire-and-forget. + invalidateStatusInsightsForTypes( + userId, + keys.map((k) => k.type), + ).catch((err) => { + getEvent()?.addWarning( + `withings activity: status-insight invalidate failed for ${userId}: ${err}`, + ); + }); } catch (err) { getEvent()?.addWarning( `withings activity: rollup recompute failed for ${userId}: ${err}`, diff --git a/src/lib/withings/sync-sleep.ts b/src/lib/withings/sync-sleep.ts index a94eb9322..231d94f6b 100644 --- a/src/lib/withings/sync-sleep.ts +++ b/src/lib/withings/sync-sleep.ts @@ -41,6 +41,7 @@ import { collapseToTypeDayKeys, recomputeBucketsForMeasurement, } from "@/lib/rollups/measurement-rollups"; +import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { hasActivityScope } from "./client"; import { @@ -314,6 +315,18 @@ export async function syncUserSleep( for (const k of keys) { await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt); } + + // v1.8.0 — drop the per-metric assessment caches the synced types + // dirty (sleep + resting-HR feed the pulse / general cards). + // Fire-and-forget. + invalidateStatusInsightsForTypes( + userId, + keys.map((k) => k.type), + ).catch((err) => { + getEvent()?.addWarning( + `withings sleep: status-insight invalidate failed for ${userId}: ${err}`, + ); + }); } catch (err) { getEvent()?.addWarning( `withings sleep: rollup recompute failed for ${userId}: ${err}`, diff --git a/src/lib/withings/sync.ts b/src/lib/withings/sync.ts index 8491b630a..5679565ac 100644 --- a/src/lib/withings/sync.ts +++ b/src/lib/withings/sync.ts @@ -28,6 +28,7 @@ import { collapseToTypeDayKeys, recomputeBucketsForMeasurement, } from "@/lib/rollups/measurement-rollups"; +import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; /** * Build the callback URL handed to Withings at `Notify.subscribe` time. @@ -250,6 +251,19 @@ export async function syncUserMeasurements( for (const k of keys) { await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt); } + + // v1.8.0 — a Withings sync that lands new weight / BP / pulse / + // body-comp rows dirties the matching per-metric assessment caches. + // Drop them so the next mount / nightly warm pass regenerates + // against the new data. Fire-and-forget: never fails the sync. + invalidateStatusInsightsForTypes( + userId, + keys.map((k) => k.type), + ).catch((err) => { + getEvent()?.addWarning( + `withings: status-insight invalidate failed for ${userId}: ${err}`, + ); + }); } catch (err) { getEvent()?.addWarning( `withings: rollup recompute failed for ${userId}: ${err}`,