diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index e3c4e69f4..7126123b1 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -48,6 +48,7 @@ import { recomputeUserMedicationCompliance, MEDICATION_COMPLIANCE_BACKFILL_DAYS, } from "@/lib/medications/compliance-rollups"; +import { recomputeUserRollups } from "@/lib/measurements/rollups"; export const dynamic = "force-dynamic"; @@ -291,6 +292,14 @@ const handler = apiHandler( // pre-restore daily means. The fold below mints fresh rows // from the restored mood entries. await tx.moodEntryRollup.deleteMany({ where: { userId: ownerId } }); + // v1.4.39.1 — wipe the persisted measurement rollup partition + // for the same reason. Pre-fix the partition kept the previous + // owner's daily means even after the underlying measurements + // were replaced — the chart's `source=rollup` fast-path could + // surface a stale 30-day mean built from rows that no longer + // existed. The fold below mints fresh rows from the restored + // measurement set. + await tx.measurementRollup.deleteMany({ where: { userId: ownerId } }); const channels = await tx.notificationChannel.deleteMany({ where: { userId: ownerId }, }); @@ -420,6 +429,29 @@ const handler = apiHandler( return apiError("Restore failed", 500); } + // v1.4.39.1 — re-fold the persistent measurement rollup tier from + // the just-restored measurements. Pre-fix the restore left the + // rollup table empty for the owner, so the dashboard chart's + // `source=rollup` fast-path silently returned zero buckets until + // the next worker boot ran the backfill discovery — multi-hour + // window of empty charts for the operator-restored account. Runs + // outside the transaction (the 5-year fold would otherwise hold a + // long write lock) and is best-effort so a populator hiccup never + // undoes the restore. The boot-time backfill is the safety net. + if (payload.measurements.length > 0) { + try { + await recomputeUserRollups(ownerId); + } catch (err) { + annotate({ + meta: { + measurement_rollup_restore_failed: true, + measurement_rollup_restore_error: + err instanceof Error ? err.message : String(err), + }, + }); + } + } + // v1.4.39 W-MOOD — re-fold the mood rollup tier from the just- // restored entries. Runs outside the transaction so the (5-year) // fold can't hold a long write lock; best-effort so a populator diff --git a/src/app/api/import/__tests__/route.test.ts b/src/app/api/import/__tests__/route.test.ts index 346ad5933..272a8a77d 100644 --- a/src/app/api/import/__tests__/route.test.ts +++ b/src/app/api/import/__tests__/route.test.ts @@ -27,9 +27,29 @@ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), })); +// v1.4.39.1 — surface the rollup hook so the new regression test can +// assert that the import path now folds the persistent rollup tier +// for each touched (type, day). Mood rollup mock stays no-op because +// the existing v1.4.39 W-MOOD hook already covers the moodlog branch. +vi.mock("@/lib/measurements/rollups", async () => { + const actual = await vi.importActual< + typeof import("@/lib/measurements/rollups") + >("@/lib/measurements/rollups"); + return { + ...actual, + recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock("@/lib/mood/rollups", () => ({ + recomputeUserMoodRollups: vi.fn().mockResolvedValue(undefined), +})); + import { NextRequest } from "next/server"; import { POST } from "../route"; import { checkRateLimit } from "@/lib/rate-limit"; +import { prisma } from "@/lib/db"; +import { recomputeBucketsForMeasurement } from "@/lib/measurements/rollups"; beforeEach(() => { vi.resetAllMocks(); @@ -107,3 +127,102 @@ describe("POST /api/import — rate-limit guard", () => { expect(body.error).toBeTruthy(); }); }); + +// v1.4.39.1 — the import path used to write measurements without +// firing the rollup hook, so the dashboard chart's `source=rollup` +// fast-path silently under-counted any imported days until the next +// worker boot ran the backfill discovery. Cover the wiring with a +// regression test so a future refactor can't silently drop it again. +describe("POST /api/import — measurement rollup hook (v1.4.39.1)", () => { + it("folds the persistent rollup table for each (type, day) the import touched", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 4, + resetAt: new Date(Date.now() + 1000), + } as never); + vi.mocked(prisma.measurement.create).mockResolvedValue({} as never); + + const response = await POST( + new NextRequest("http://localhost/api/import", { + method: "POST", + body: JSON.stringify({ + measurements: [ + { + type: "BLOOD_PRESSURE_SYS", + value: 124, + unit: "mmHg", + measuredAt: "2026-05-12T07:30:00.000Z", + }, + { + type: "BLOOD_PRESSURE_SYS", + value: 121, + unit: "mmHg", + measuredAt: "2026-05-12T19:30:00.000Z", + }, + { + type: "BLOOD_PRESSURE_SYS", + value: 119, + unit: "mmHg", + measuredAt: "2026-05-14T08:00:00.000Z", + }, + { + type: "WEIGHT", + value: 82.4, + unit: "kg", + measuredAt: "2026-05-14T07:00:00.000Z", + }, + ], + }), + headers: { "content-type": "application/json" }, + }), + ); + + expect(response.status).toBeLessThan(400); + // Two BP rows on 2026-05-12 collapse to one rollup call; one BP + // row on 2026-05-14 is its own call; one WEIGHT row on 2026-05-14 + // is a separate (type, day) pair. Total = 3 distinct rollup folds. + expect(recomputeBucketsForMeasurement).toHaveBeenCalledTimes(3); + const calls = vi + .mocked(recomputeBucketsForMeasurement) + .mock.calls.map((c) => `${c[1]}|${(c[2] as Date).toISOString()}`); + expect(new Set(calls)).toEqual( + new Set([ + "BLOOD_PRESSURE_SYS|2026-05-12T00:00:00.000Z", + "BLOOD_PRESSURE_SYS|2026-05-14T00:00:00.000Z", + "WEIGHT|2026-05-14T00:00:00.000Z", + ]), + ); + }); + + it("does not call the rollup hook when no measurements were written", async () => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 4, + resetAt: new Date(Date.now() + 1000), + } as never); + vi.mocked(prisma.measurement.create).mockRejectedValue( + new Error("P2002 duplicate"), + ); + + const response = await POST( + new NextRequest("http://localhost/api/import", { + method: "POST", + body: JSON.stringify({ + measurements: [ + { + type: "WEIGHT", + value: 82.4, + unit: "kg", + measuredAt: "2026-05-14T07:00:00.000Z", + }, + ], + }), + headers: { "content-type": "application/json" }, + }), + ); + + expect(response.status).toBeLessThan(400); + // Every measurement raised a duplicate — no rollup fold needed. + expect(recomputeBucketsForMeasurement).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index 5911656c4..a37f4f221 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -18,6 +18,11 @@ import { glucoseContextEnum, } from "@/lib/validations/measurement"; import { recomputeUserMoodRollups } from "@/lib/mood/rollups"; +import { + collapseToTypeDayKeys, + recomputeBucketsForMeasurement, +} from "@/lib/measurements/rollups"; +import type { MeasurementType } from "@/generated/prisma/client"; // Derived from canonical enum so round-trip export → import covers every // type. Previous hardcoded subset silently dropped 4 of 11 types @@ -82,9 +87,19 @@ export const POST = apiHandler(async (request: NextRequest) => { const stats = { measurements: 0, moodEntries: 0, skipped: 0 }; // Import measurements + // v1.4.39.1 — track each (type, measuredAt) we wrote so we can re- + // fold the persistent rollup tier at the end. Pre-fix, the import + // path bypassed the rollup write hook entirely, so a CSV / JSON + // restore left the user's `source=rollup` chart paths under-counting + // the imported days until the next worker boot re-ran the backfill. + const touchedMeasurements: Array<{ + type: MeasurementType; + measuredAt: Date; + }> = []; if (data.measurements?.length) { for (const m of data.measurements) { try { + const measuredAt = new Date(m.measuredAt); await prisma.measurement.create({ data: { userId, @@ -92,11 +107,15 @@ export const POST = apiHandler(async (request: NextRequest) => { value: m.value, unit: m.unit, source: "IMPORT", - measuredAt: new Date(m.measuredAt), + measuredAt, notes: m.notes || null, glucoseContext: m.glucoseContext ?? null, }, }); + touchedMeasurements.push({ + type: m.type as MeasurementType, + measuredAt, + }); stats.measurements++; } catch { // Unique constraint violation — skip duplicate @@ -148,6 +167,28 @@ export const POST = apiHandler(async (request: NextRequest) => { } } + // v1.4.39.1 — refresh the persistent measurement rollup table for + // every distinct (type, day) the import touched. Collapsed so a + // 10 000-row CSV restore pays at most ~N (type, day) recomputes + // rather than 10 000 per-row hooks. Best-effort: a populator hiccup + // never fails the importer. + if (touchedMeasurements.length > 0) { + try { + const keys = collapseToTypeDayKeys(touchedMeasurements); + for (const k of keys) { + await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt); + } + } catch (err) { + annotate({ + meta: { + measurement_rollup_import_failed: true, + measurement_rollup_import_error: + err instanceof Error ? err.message : String(err), + }, + }); + } + } + await auditLog("import.upload", { userId, ipAddress: getClientIp(request),