merge: remove the bug-report integration for v1.23

# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/pl.json
#	src/app/admin/[section]/renderer.tsx
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 20:47:24 +02:00
commit b271a39fe4
68 files changed
+115 -3344

No files matched your search

@@ -1,93 +0,0 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on PATCH /api/admin/feedback/[id].
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
feedback: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PATCH } from "../route";
import { getSession } from "@/lib/auth/session";
const SESSION_ADMIN = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" as const },
};
function patchReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/admin/feedback/f1", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const ROUTE_CTX = { params: Promise.resolve({ id: "f1" }) };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_ADMIN as never);
});
describe("PATCH /api/admin/feedback/[id] — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad `status` enum + adminNote too long.
const res = await PATCH(
patchReq({ status: "junk", adminNote: "x".repeat(10000) }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces multiple simultaneous validation errors (≥ 2)", async () => {
// The schema has only two knobs (status + adminNote), so the strict
// 3-issue ceiling isn't reachable. Multi-issue contract is pinned at
// ≥ 2 by the case above.
const res = await PATCH(
patchReq({ status: 999, adminNote: 123 }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
});
});
@@ -1,96 +0,0 @@
/**
* POST /api/admin/feedback/[id]/github — promote feedback to a GitHub Issue.
*
* Reuses publishFeedbackToGithub() so the issue body matches the legacy
* /api/bugreport flow. Idempotent: if gitHubIssueUrl is already set we return
* the existing URL instead of creating a duplicate.
*/
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import {
publishFeedbackToGithub,
GithubPublishError,
} from "@/lib/feedback/publish-github";
import type { NextRequest } from "next/server";
export const POST = apiHandler(
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
const { user: admin } = await requireAdmin();
const { id } = await ctx.params;
const feedback = await prisma.feedback.findUnique({
where: { id },
select: {
id: true,
category: true,
subject: true,
description: true,
gitHubIssueUrl: true,
screenshotBase64: true,
metadata: true,
user: { select: { username: true } },
},
});
if (!feedback) return apiError("Feedback not found", 404);
if (feedback.gitHubIssueUrl) {
return apiError(`Already published: ${feedback.gitHubIssueUrl}`, 409);
}
// Atomic claim to prevent concurrent admin clicks from creating two
// duplicate GitHub issues. Only the first caller whose updateMany
// matches (gitHubIssueUrl still null) proceeds. We write a PENDING
// sentinel now and replace it with the real URL after the API call.
const PENDING = "pending://creating";
const claim = await prisma.feedback.updateMany({
where: { id, gitHubIssueUrl: null },
data: { gitHubIssueUrl: PENDING },
});
if (claim.count !== 1) {
return apiError("Already being published by another request", 409);
}
try {
const result = await publishFeedbackToGithub({
category: feedback.category,
subject: feedback.subject,
description: feedback.description,
username: feedback.user?.username ?? "anonymous",
metadata: feedback.metadata,
});
await prisma.feedback.update({
where: { id },
data: { gitHubIssueUrl: result.issueUrl },
});
await auditLog("admin.feedback.github_publish", {
userId: admin.id,
ipAddress: getClientIp(request),
details: { feedbackId: id, issueNumber: result.issueNumber },
});
annotate({
action: { name: "admin.feedback.github_publish" },
meta: { feedback_id: id, issue_number: result.issueNumber },
});
return apiSuccess({ issueUrl: result.issueUrl });
} catch (err) {
// Release the PENDING claim so a retry can succeed.
await prisma.feedback
.updateMany({
where: { id, gitHubIssueUrl: PENDING },
data: { gitHubIssueUrl: null },
})
.catch(() => undefined);
if (err instanceof GithubPublishError) {
return apiError(err.message, err.status);
}
throw err;
}
},
);
-87
View File
@@ -1,87 +0,0 @@
/**
* PATCH /api/admin/feedback/[id] — change status or admin note.
* DELETE /api/admin/feedback/[id] — archive (soft: sets status to ARCHIVED).
*/
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
getClientIp,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { updateFeedbackSchema } from "@/lib/validations/feedback";
import type { NextRequest } from "next/server";
export const PATCH = apiHandler(
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
const { user } = await requireAdmin();
const { id } = await ctx.params;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateFeedbackSchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const before = await prisma.feedback.findUnique({ where: { id } });
if (!before) return apiError("Feedback not found", 404);
const updated = await prisma.feedback.update({
where: { id },
data: parsed.data,
select: {
id: true,
status: true,
adminNote: true,
updatedAt: true,
},
});
await auditLog("admin.feedback.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
feedbackId: id,
before: { status: before.status, adminNote: before.adminNote },
after: parsed.data,
},
});
annotate({
action: { name: "admin.feedback.update" },
meta: { feedback_id: id },
});
return apiSuccess(updated);
},
);
export const DELETE = apiHandler(
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
const { user } = await requireAdmin();
const { id } = await ctx.params;
const updated = await prisma.feedback.update({
where: { id },
data: { status: "ARCHIVED" },
select: { id: true, status: true },
});
await auditLog("admin.feedback.archive", {
userId: user.id,
ipAddress: getClientIp(request),
details: { feedbackId: id },
});
return apiSuccess(updated);
},
);
-67
View File
@@ -1,67 +0,0 @@
/**
* Admin feedback inbox. Lists all feedback with status filter.
*/
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import type { NextRequest } from "next/server";
import type { FeedbackStatus } from "@/generated/prisma/client";
export const GET = apiHandler(async (request: NextRequest) => {
await requireAdmin();
const url = new URL(request.url);
const status = url.searchParams.get("status") as FeedbackStatus | null;
const limit = Math.min(
100,
Math.max(1, Number(url.searchParams.get("limit") ?? 50)),
);
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0));
const where = status ? { status } : {};
const [items, total, counts] = await Promise.all([
prisma.feedback.findMany({
where,
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
select: {
id: true,
userId: true,
email: true,
category: true,
subject: true,
description: true,
status: true,
adminNote: true,
gitHubIssueUrl: true,
metadata: true,
screenshotBase64: true,
createdAt: true,
updatedAt: true,
user: { select: { username: true } },
},
}),
prisma.feedback.count({ where }),
prisma.feedback.groupBy({
by: ["status"],
_count: { _all: true },
}),
]);
annotate({
action: { name: "admin.feedback.list" },
meta: { total, filtered_status: status },
});
const countsByStatus = Object.fromEntries(
counts.map((c) => [c.status, c._count._all]),
) as Record<FeedbackStatus, number>;
return apiSuccess({
items,
meta: { total, limit, offset, countsByStatus },
});
});
@@ -98,7 +98,6 @@ describe("GET /api/admin/settings", () => {
expect(body.data.registrationEnabled).toBe(true);
expect(body.data.defaultLocale).toBe("de");
expect(body.data.webPushVapidConfigured).toBe(false);
expect(body.data.bugReportConfigured).toBe(false);
expect(body.data.glitchtipEnabled).toBe(false);
});
@@ -120,8 +119,6 @@ describe("GET /api/admin/settings", () => {
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: "production",
githubIssueRepo: "owner/repo",
githubIssueTokenEncrypted: "token-blob",
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
@@ -129,15 +126,12 @@ describe("GET /api/admin/settings", () => {
const res = await GET();
const body = (await res.json()) as { data: Record<string, unknown> };
// The two configured-flags must be true …
// The configured flag must be true …
expect(body.data.webPushVapidConfigured).toBe(true);
expect(body.data.bugReportConfigured).toBe(true);
// … but raw encrypted blobs must never reach the client.
const serialized = JSON.stringify(body.data);
expect(serialized).not.toContain("secret-blob");
expect(serialized).not.toContain("token-blob");
expect(body.data).not.toHaveProperty("webPushVapidPrivateKeyEncrypted");
expect(body.data).not.toHaveProperty("githubIssueTokenEncrypted");
});
});
@@ -185,8 +179,6 @@ describe("PUT /api/admin/settings", () => {
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: "production",
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
@@ -227,8 +219,6 @@ describe("PUT /api/admin/settings", () => {
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
@@ -257,8 +247,6 @@ describe("PUT /api/admin/settings", () => {
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
@@ -292,8 +280,6 @@ describe("PUT /api/admin/settings", () => {
glitchtipEnabled: true,
glitchtipDsn: "https://abc@glitchtip.example.com/1",
glitchtipEnvironment: "staging",
githubIssueRepo: "owner/repo",
githubIssueTokenEncrypted: "enc(gh-token)",
reminderLateMinutes: 30,
reminderMissedMinutes: 90,
moodLogGlobal: false,
@@ -317,8 +303,6 @@ describe("PUT /api/admin/settings", () => {
umamiWebsiteId: "site-uuid",
glitchtipDsn: "https://abc@glitchtip.example.com/1",
glitchtipEnvironment: "staging",
bugReportRepo: "owner/repo",
bugReportToken: "gh-token",
reminderLateMinutes: 30,
reminderMissedMinutes: 90,
}),
@@ -336,8 +320,6 @@ describe("PUT /api/admin/settings", () => {
umamiWebsiteId: "site-uuid",
glitchtipDsn: "https://abc@glitchtip.example.com/1",
glitchtipEnvironment: "staging",
githubIssueRepo: "owner/repo",
githubIssueTokenEncrypted: "enc(gh-token)",
webPushVapidPublicKey: "vapid-pub",
webPushVapidSubject: "mailto:ops@healthlog.dev",
webPushVapidPrivateKeyEncrypted: "enc(secret)",
@@ -345,7 +327,6 @@ describe("PUT /api/admin/settings", () => {
reminderMissedMinutes: 90,
});
expect(encrypt).toHaveBeenCalledWith("secret");
expect(encrypt).toHaveBeenCalledWith("gh-token");
});
it("clears nullable string fields when an empty string is submitted", async () => {
@@ -366,8 +347,6 @@ describe("PUT /api/admin/settings", () => {
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
@@ -381,7 +360,6 @@ describe("PUT /api/admin/settings", () => {
umamiWebsiteId: "",
glitchtipDsn: "",
glitchtipEnvironment: "",
bugReportRepo: "",
}),
);
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
@@ -392,39 +370,9 @@ describe("PUT /api/admin/settings", () => {
umamiWebsiteId: null,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
});
});
it("clears the encrypted bug-report token when clear flag is set", async () => {
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
id: "singleton",
registrationEnabled: true,
defaultLocale: "en",
telegramGlobal: true,
ntfyGlobal: true,
webPushGlobal: true,
webPushVapidPublicKey: null,
webPushVapidSubject: null,
webPushVapidPrivateKeyEncrypted: null,
apiGlobal: true,
umamiEnabled: false,
umamiScriptUrl: null,
umamiWebsiteId: null,
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
} as never);
await PUT(jsonReq({ clearBugReportToken: true }));
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
expect(upsert?.update).toEqual({ githubIssueTokenEncrypted: null });
});
it("returns 415 when content-type is not JSON", async () => {
const r = new NextRequest("http://localhost/api/admin/settings", {
method: "PUT",
@@ -454,9 +402,6 @@ describe("PUT /api/admin/settings", () => {
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
bugReportEnabled: true,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
@@ -486,9 +431,6 @@ describe("PUT /api/admin/settings", () => {
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
bugReportEnabled: true,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
-35
View File
@@ -16,7 +16,6 @@ import {
invalidateServerDefaultTimezone,
isValidTimezone,
} from "@/lib/tz/resolver";
import { invalidateAppSettings } from "@/lib/cache/invalidate";
export const dynamic = "force-dynamic";
@@ -48,11 +47,6 @@ export const GET = apiHandler(async () => {
glitchtipEnabled: settings?.glitchtipEnabled ?? false,
glitchtipDsn: settings?.glitchtipDsn ?? null,
glitchtipEnvironment: settings?.glitchtipEnvironment ?? "production",
bugReportRepo: settings?.githubIssueRepo ?? null,
bugReportConfigured: Boolean(
settings?.githubIssueRepo && settings?.githubIssueTokenEncrypted,
),
bugReportEnabled: settings?.bugReportEnabled ?? true,
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
moodLogGlobal: settings?.moodLogGlobal ?? true,
@@ -92,7 +86,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
"umamiEnabled",
"glitchtipEnabled",
"moodLogGlobal",
"bugReportEnabled",
] as const;
for (const field of booleanFields) {
if (data[field] !== undefined) {
@@ -171,24 +164,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
auditDetails.glitchtipEnvironment = value || null;
}
// Bug report
if (data.bugReportRepo !== undefined) {
const repo = data.bugReportRepo.trim();
updates.githubIssueRepo = repo || null;
auditDetails.bugReportRepo = repo || null;
}
if (data.bugReportToken !== undefined) {
const token = data.bugReportToken.trim();
if (token) {
updates.githubIssueTokenEncrypted = encrypt(token);
auditDetails.bugReportTokenUpdated = true;
}
}
if (data.clearBugReportToken === true) {
updates.githubIssueTokenEncrypted = null;
auditDetails.bugReportTokenUpdated = false;
}
// Numeric thresholds
if (data.reminderLateMinutes !== undefined) {
updates.reminderLateMinutes = data.reminderLateMinutes;
@@ -233,11 +208,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
invalidateServerDefaultTimezone();
}
// v1.4.34 IW-G — bust the bug-report status cache (global singleton)
// so the next read reflects the new GitHub-token / bug-report-enabled
// shape. Cheap call — the cache holds at most 10 entries.
invalidateAppSettings();
await auditLog("admin.settings.update", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -264,11 +234,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
glitchtipEnabled: settings.glitchtipEnabled,
glitchtipDsn: settings.glitchtipDsn,
glitchtipEnvironment: settings.glitchtipEnvironment ?? "production",
bugReportRepo: settings.githubIssueRepo,
bugReportConfigured: Boolean(
settings.githubIssueRepo && settings.githubIssueTokenEncrypted,
),
bugReportEnabled: settings.bugReportEnabled,
reminderLateMinutes: settings.reminderLateMinutes,
reminderMissedMinutes: settings.reminderMissedMinutes,
moodLogGlobal: settings.moodLogGlobal,
@@ -9,7 +9,6 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { invalidateAppSettings } from "@/lib/cache/invalidate";
import { z } from "zod/v4";
import { NextRequest } from "next/server";
@@ -114,8 +113,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
invalidateAppSettings();
await auditLog("admin.web_push_vapid.generate", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -89,11 +89,10 @@ describe("GET /api/admin/status (integration health summary)", () => {
umami: null,
glitchtip: null,
webPush: null,
bugReport: null,
});
});
it("aggregates configured + enabled flags for umami / glitchtip / web-push / bugReport", async () => {
it("aggregates configured + enabled flags for umami / glitchtip / web-push", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
umamiScriptUrl: "https://x/script.js",
umamiWebsiteId: "site-1",
@@ -103,8 +102,6 @@ describe("GET /api/admin/status (integration health summary)", () => {
webPushVapidPublicKey: "pub",
webPushVapidPrivateKeyEncrypted: "secret-blob",
webPushVapidSubject: "mailto:a@b.com",
githubIssueRepo: "owner/repo",
githubIssueTokenEncrypted: "token-blob",
} as never);
const res = await GET();
@@ -121,7 +118,6 @@ describe("GET /api/admin/status (integration health summary)", () => {
enabled: false,
});
expect(body.data.integrations.webPush).toEqual({ configured: true });
expect(body.data.integrations.bugReport).toEqual({ configured: true });
// Regression: encrypted blobs MUST never appear in the response envelope.
const serialized = JSON.stringify(body);
-5
View File
@@ -56,10 +56,6 @@ export const GET = apiHandler(async () => {
appSettings?.webPushVapidPrivateKeyEncrypted &&
appSettings?.webPushVapidSubject,
);
const bugReportConfigured = Boolean(
appSettings?.githubIssueRepo && appSettings?.githubIssueTokenEncrypted,
);
const workerStatus = getWorkerStatus();
return apiSuccess({
@@ -89,7 +85,6 @@ export const GET = apiHandler(async () => {
}
: null,
webPush: webPushConfigured ? { configured: true } : null,
bugReport: bugReportConfigured ? { configured: true } : null,
},
});
});