chore(release): v1.4.30

Server-side prep for the next iOS TestFlight build. Five surfaces land together so the iOS engineer can pick them up in one cut-over: a locked externalId shape for daily-aggregated cumulative HealthKit rows, the SyncMode foundation columns + handshake + bulk-backfill endpoints, a first-class MoodEntry.note column that replaces the legacy tag-based workaround, a cross-source workout dedup helper, and two new MeasurementType enums plus the shared category overlay that drives the iOS permission picker and the future Insights nav.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-16 12:37:55 +02:00
1 parent 945b556664
commit 3ece95f192
49 files changed
+4744 -32

No files matched your search

@@ -0,0 +1,93 @@
/**
* `POST /api/admin/drain-per-sample-cumulative` — operator-triggered
* variant of `scripts/drain-per-sample-cumulative.ts`. Collapses
* pre-Option-A per-sample APPLE_HEALTH cumulative rows into one row
* per day per cumulative type. See `06-ios-responsibilities.md`
* §"Cumulative metrics: daily aggregation on iOS".
*
* Body:
* { userId?: string, dryRun?: boolean }
*
* - `userId` — single user mode. Omit to drain every user.
* - `dryRun` — preview-only; no DB writes. Default: `true`. Set to
* `false` to commit.
*
* Response shape (always 200 on success):
* { processed, inserted (always 0), totals: { ... }, buckets: [...] }
*
* 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 { prisma } from "@/lib/db";
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 { drainPerSampleCumulative } from "@/lib/measurements/drain-per-sample-cumulative";
interface DrainBody {
userId?: string;
dryRun?: boolean;
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.drain.cumulative" } });
const { data: rawBody, error: jsonError } = await safeJson<DrainBody>(request);
if (jsonError) return jsonError;
const body: DrainBody = (rawBody && typeof rawBody === "object") ? rawBody : {};
const userId =
typeof body.userId === "string" && body.userId.length > 0
? body.userId
: undefined;
// Default to dryRun = true so a malformed body never silently writes.
// Operator must explicitly send `dryRun: false` to commit.
const dryRun = body.dryRun === false ? false : true;
await auditLog("admin.drain.cumulative.start", {
userId: user.id,
ipAddress: getClientIp(request),
details: { targetUserId: userId ?? "ALL", dryRun },
});
const lines: string[] = [];
const summary = await drainPerSampleCumulative(prisma, {
userId,
dryRun,
log: (line) => lines.push(line),
});
if (!dryRun) {
await auditLog("admin.drain.cumulative.commit", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
targetUserId: userId ?? "ALL",
...summary.totals,
},
});
}
if (summary.totals.bucketsCollapsed === 0 && summary.totals.usersScanned === 0 && userId) {
return apiError("User not found", 404);
}
return apiSuccess({
dryRun,
totals: summary.totals,
bucketCount: summary.buckets.length,
// Truncate to the first 50 buckets so the response stays
// bounded — operators inspect the CLI for the full output.
bucketsPreview: summary.buckets.slice(0, 50),
logTail: lines.slice(-20),
});
});