mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user