fix(rollups): fold measurement tier on /api/import + admin backup restore

Both ingest paths wrote into `measurements` without firing the rollup
write hook, leaving the dashboard chart's `source=rollup` fast-path
blind to the imported days. The mood / medication-compliance rollup
tiers already had post-batch hooks on these routes; the measurement
tier had been the gap.

- `/api/import`: tracks every touched `(type, measuredAt)` written
  during the batch, collapses to distinct `(type, day)` via
  `collapseToTypeDayKeys`, and folds once at the end. A 10 000-row
  CSV restore now pays at most ~N (type, day) recomputes rather than
  10 000 per-row hooks.
- Admin backup restore: the transaction wipes the user's
  `measurement_rollups` partition alongside the existing
  `mood_entry_rollups` wipe, and the post-transaction step kicks a
  `recomputeUserRollups(ownerId)` so the restored dataset paints
  fresh tiles instead of carrying the previous owner's daily means
  forward.
- Best-effort posture preserved in both paths: a populator hiccup
  surfaces via `annotate` but never undoes the restore / import.

Test coverage extends the existing `/api/import` route suite with a
regression that pins the per-`(type, day)` collapse and a duplicate-
write case (`P2002`) that proves the hook is skipped when nothing
was actually written.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 09:01:46 +02:00
1 parent ad22fa2e9d
commit a2b0ab19e5
3 files changed
+193 -1

No files matched your search

@@ -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
+119
View File
@@ -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();
});
});
+42 -1
View File
@@ -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),