Files
HealthLog/src/app/api/admin/data/route.ts
T
Marc-André BombeckandClaude Opus 4.7 c3ca8614a6 fix(admin): preserve audit trail across the data wipe
Two related corrections to the "Wipe all data" admin action:

- Stop deleting the audit log inside the transaction. The whole point
  of the audit trail is to outlive the data it documents, and this is
  the single feature where that property matters most. AuditLog rows
  now survive the wipe.
- Audit-log the *intent* (`admin.data.clear.start`) before the
  transaction begins, so even if the wipe crashes mid-flight there is
  a record of who initiated it.
- The i18n copy and the success toast both undersold the scope: they
  only mentioned measurements/medications/intakes, but the wipe also
  clears API tokens, Withings connections, and any open auth
  challenges. EN + DE strings now enumerate every cleared category and
  call out that user accounts AND the audit log are preserved.
- Mutation result type widened so the toast can render the full
  count tuple.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 01:32:29 +02:00

94 lines
3.1 KiB
TypeScript

import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextRequest, NextResponse } from "next/server";
export const dynamic = "force-dynamic";
/**
* Admin-only global wipe of all health/integration data.
* Keeps users/passkeys so access to the app remains possible.
*/
export const DELETE = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request);
const rl = await checkRateLimit(`admin-data-delete:${ip}`, 5, 60 * 1000);
if (!rl.allowed) {
return NextResponse.json(
{ data: null, error: "Rate limit exceeded" },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
const { user } = await requireAdmin();
annotate({ action: { name: "admin.data.delete" } });
let confirm = "";
try {
const body = await request.json();
confirm = typeof body?.confirm === "string" ? body.confirm : "";
} catch {
return apiError("Invalid request", 422);
}
if (confirm !== "DELETE ALL") {
return apiError("Confirmation missing", 422);
}
// Audit-log the *intent* before the transaction begins so the trail
// describing "an admin is about to wipe data" survives even if the
// operation crashes midway. AuditLog rows are intentionally NOT
// deleted as part of the wipe — the entire point of an audit trail
// is to outlive the data it documents.
await auditLog("admin.data.clear.start", {
userId: user.id,
ipAddress: getClientIp(request),
details: { confirm: "DELETE ALL" },
});
const result = await prisma.$transaction(async (tx) => {
const measurements = await tx.measurement.deleteMany({});
const intakeEvents = await tx.medicationIntakeEvent.deleteMany({});
const medications = await tx.medication.deleteMany({});
const apiTokens = await tx.apiToken.deleteMany({});
const withingsConnections = await tx.withingsConnection.deleteMany({});
const authChallenges = await tx.authChallenge.deleteMany({});
await tx.user.updateMany({
data: {
heightCm: null,
dateOfBirth: null,
gender: null,
insightsPrivacyMode: "aggregated",
insightsCachedAt: null,
insightsCachedText: null,
telegramBotToken: null,
telegramChatId: null,
telegramEnabled: false,
withingsClientIdEncrypted: null,
withingsClientSecretEncrypted: null,
onboardingCompletedAt: null,
},
});
return {
measurements: measurements.count,
intakeEvents: intakeEvents.count,
medications: medications.count,
apiTokens: apiTokens.count,
withingsConnections: withingsConnections.count,
authChallenges: authChallenges.count,
};
});
await auditLog("admin.data.clear", {
userId: user.id,
ipAddress: getClientIp(request),
details: result,
});
return apiSuccess({ cleared: true, ...result });
});