diff --git a/CHANGELOG.md b/CHANGELOG.md index aaf417289..9cf8c4eea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +## [1.4.38.7] — 2026-05-18 — Rollup recompute observability + admin trigger + +The v1.4.38.5 / v1.4.38.6 chain promised a fast-path recovery for +power-user accounts but gave the operator no way to confirm whether +the boot-time discovery had actually found a stranded user — the +success log only fired when `enqueued > 0 || skipped > 0`, so the +silent "found nothing" case looked identical to the silent "boot hook +never ran" case. And once the worker was up there was no way to +re-trigger discovery short of bouncing the container. + +### Added + +- **`POST /api/admin/rollups/recompute`** — operator-triggered ad-hoc + rollup recompute. Body `{ userId: string }` synchronously folds + one user (`recomputeUserRollups` awaited inside the request). Body + `{}` re-runs `enqueueBootTimeRollupBackfill()` to kick the + boot-discovery loop across every user. Admin gate via + `requireAdmin()` (cookie-only, never Bearer). +- **`fallback_reason` + `missing_types` annotate** on the + `slim_summaries` live-fallback path. When the rollup-fast-path + declines, the wide-event now reports exactly which measurement + types are missing DAY-bucket coverage so the operator can match + a slow `/api/analytics` cold-mount to the responsible type without + touching the DB. + +### Changed + +- **Boot-backfill discovery now logs every result, including the + silent "no users to backfill" case.** The line cost is one row per + worker boot; the operator gain is the ability to tell discovery + ran cleanly from discovery silently no-op'd. + +### Operator notes + +- No new migration. No env-var change. + ## [1.4.38.6] — 2026-05-18 — Boot-backfill discovery SQL fix The v1.4.38.5 discovery rewrite filtered the LEFT JOIN with diff --git a/package.json b/package.json index 45536d17b..18a978ffe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.38.6", + "version": "1.4.38.7", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/src/app/api/admin/rollups/recompute/route.ts b/src/app/api/admin/rollups/recompute/route.ts new file mode 100644 index 000000000..3b4fee9b1 --- /dev/null +++ b/src/app/api/admin/rollups/recompute/route.ts @@ -0,0 +1,111 @@ +/** + * `POST /api/admin/rollups/recompute` — operator-triggered ad-hoc + * rollup recompute. + * + * Surface added in v1.4.38.7 after the boot-time backfill could not + * be re-triggered without a deploy. A power-user account whose + * `isFullyCovered(coverage)` probe returned `false` (typically + * because a brand-new measurement type landed between the last fold + * and the next analytics read) gets stranded on the live SQL + * aggregator until the next worker boot. This endpoint lets the + * operator force a fold for one user or kick the boot-discovery loop + * across every user without bouncing the worker container. + * + * Body: + * { userId?: string } + * + * - `userId` — single user mode. Synchronously calls + * `recomputeUserRollups(userId)` and returns the row count when + * the fold completes. The call is awaited inside the request, so + * the operator sees the duration in the response. + * - `userId` omitted — calls `enqueueBootTimeRollupBackfill()` to + * re-run the worker-boot discovery (`{ enqueued, skipped }` shape). + * + * Admin gate via `requireAdmin()` (cookie-only — Bearer never elevates + * to admin per the v1.4.25 security boundary). + */ +import type { NextRequest } from "next/server"; + +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiError, + apiSuccess, + getClientIp, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { + enqueueBootTimeRollupBackfill, + recomputeUserRollups, +} from "@/lib/measurements/rollups"; + +interface RecomputeBody { + userId?: string; +} + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAdmin(); + annotate({ action: { name: "admin.rollups.recompute" } }); + + const { data: rawBody, error: jsonError } = + await safeJson(request); + if (jsonError) return jsonError; + + const body: RecomputeBody = + rawBody && typeof rawBody === "object" ? rawBody : {}; + const userId = + typeof body.userId === "string" && body.userId.length > 0 + ? body.userId + : undefined; + + if (userId) { + await auditLog("admin.rollups.recompute.start", { + userId: user.id, + ipAddress: getClientIp(request), + details: { targetUserId: userId, mode: "single" }, + }); + + let result: Awaited>; + try { + result = await recomputeUserRollups(userId); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return apiError(message, 500); + } + + await auditLog("admin.rollups.recompute.complete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { targetUserId: userId, ...result }, + }); + + return apiSuccess({ + mode: "single", + targetUserId: userId, + rowsUpserted: result.rowsUpserted, + durationMs: result.durationMs, + }); + } + + await auditLog("admin.rollups.recompute.start", { + userId: user.id, + ipAddress: getClientIp(request), + details: { mode: "boot-discovery" }, + }); + + const discovery = await enqueueBootTimeRollupBackfill(); + + await auditLog("admin.rollups.recompute.complete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { mode: "boot-discovery", ...discovery }, + }); + + return apiSuccess({ + mode: "boot-discovery", + enqueued: discovery.enqueued, + skipped: discovery.skipped, + error: discovery.error, + }); +}); diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index d1e15bcfa..20015e50d 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -217,6 +217,26 @@ export async function computeSummariesSlice( if (isFullyCovered(coverage)) { return computeFromRollups(userId); } + // v1.4.38.7 — annotate the live fallback with the per-type + // coverage map so the operator can see WHICH type stranded the + // request on the live aggregator. Without this the wide-event only + // says `path:"live"`, and "why" requires shell access to the DB. + const missing: string[] = []; + for (const [type, hasBuckets] of coverage.entries()) { + if (!hasBuckets) missing.push(type); + } + annotate({ + meta: { + analytics: { + slim_summaries: { + fallback_reason: "partial_rollup_coverage", + missing_types: missing, + covered_count: coverage.size - missing.length, + total_types: coverage.size, + }, + }, + }, + }); return computeFromLiveAggregate(userId); } diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index a8f77f234..c763421be 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -1960,7 +1960,13 @@ export async function startReminderWorker() { "error", `[rollup-full-backfill] boot discovery failed: ${error}`, ); - } else if (enqueued > 0 || skipped > 0) { + } else { + // v1.4.38.7 — log the discovery result on every boot, including + // the silent `enqueued=0 skipped=0` case. Without this, an + // operator chasing "analytics is slow" cannot tell whether the + // discovery query ran successfully (and found nothing to fold) + // vs. silently no-op'd. The line is one row per worker boot, so + // the log cost is negligible. workerLog( "info", `[rollup-full-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,