mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
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;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/bugreport.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: (s: string) => s.replace(/^enc-/, ""),
|
||||
}));
|
||||
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 { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/bugreport", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
githubIssueTokenEncrypted: "enc-token",
|
||||
githubIssueRepo: "owner/repo",
|
||||
bugReportEnabled: true,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/bugreport — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// description too short + screenshot doesn't match data-URL regex.
|
||||
const res = await POST(
|
||||
postReq({ description: "tiny", screenshot: "not-a-data-url" }),
|
||||
);
|
||||
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 () => {
|
||||
// Schema has only two knobs (description, screenshot). Pin contract
|
||||
// on ≥ 2.
|
||||
const res = await POST(postReq({ description: 123, screenshot: 999 }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
// Strict image/* data URL (no SVG — it can carry inline JS).
|
||||
const SCREENSHOT_DATA_URL =
|
||||
/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/;
|
||||
|
||||
const bugReportSchema = z.object({
|
||||
description: z.string().min(10, "Description too short").max(5000),
|
||||
screenshot: z
|
||||
.string()
|
||||
.max(7_000_000, "Screenshot too large (max 5 MB)")
|
||||
.regex(
|
||||
SCREENSHOT_DATA_URL,
|
||||
"Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL",
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`bugreport:${user.id}`, 3, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 3 bug reports per hour", 429);
|
||||
}
|
||||
|
||||
annotate({ action: { name: "bugreport.submit" } });
|
||||
|
||||
const appSettings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
githubIssueTokenEncrypted: true,
|
||||
githubIssueRepo: true,
|
||||
bugReportEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
// v1.4.3: explicit toggle. When the admin disables bug reports we
|
||||
// return 503 immediately — the client already renders a "not
|
||||
// configured" UI for that status, so the report button gracefully
|
||||
// disappears without needing additional client-side wiring.
|
||||
if (appSettings && appSettings.bugReportEnabled === false) {
|
||||
return apiError("Bug reports are disabled by the administrator", 503);
|
||||
}
|
||||
|
||||
let configuredToken: string | null = null;
|
||||
if (appSettings?.githubIssueTokenEncrypted) {
|
||||
try {
|
||||
configuredToken = decrypt(appSettings.githubIssueTokenEncrypted);
|
||||
} catch {
|
||||
getEvent()?.addWarning("Failed to decrypt GitHub issue token");
|
||||
}
|
||||
}
|
||||
|
||||
const ghToken = configuredToken || process.env.GITHUB_ISSUE_TOKEN;
|
||||
const ghRepo = appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO; // e.g. "owner/repo"
|
||||
|
||||
if (!ghToken || !ghRepo) {
|
||||
// 503 instead of 500: the client keys off status to render the "not
|
||||
// configured" screen. The user also sees a clear message rather than
|
||||
// "Internal server error".
|
||||
return apiError(
|
||||
"Bug report not configured (GitHub issue token/repository missing in admin settings)",
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 8 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = bugReportSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { description, screenshot } = parsed.data;
|
||||
|
||||
// GitHub issues are maintainer-facing and the repo's primary language is English.
|
||||
// Use an ISO-8601 timestamp so titles are unambiguous and sortable.
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||
const title = `Bug Report – ${dateStr}`;
|
||||
|
||||
// Sanitize user-provided content for GitHub markdown. Strip HTML tags
|
||||
// entirely (prevents `<img onerror=>` etc.) and neutralise markdown link
|
||||
// targets that start with dangerous schemes like javascript: or data:.
|
||||
const safeUsername = user.username.replace(/[`*_~<>\[\]|]/g, "");
|
||||
const safeDescription = description
|
||||
.replace(/<[^>]*>/g, "") // strip HTML tags
|
||||
.replace(/\]\(\s*(javascript|data|vbscript|file):/gi, "](about:blank#") // neutralise dangerous markdown link schemes
|
||||
.replace(/```/g, "\\`\\`\\`")
|
||||
.slice(0, 5000);
|
||||
|
||||
// Wrap the user's description in a fenced code block so any residual
|
||||
// markdown renders literally rather than as executable markup.
|
||||
let issueBody = `**Reported by:** ${safeUsername}\n`;
|
||||
issueBody += `**Date:** ${dateStr}\n\n`;
|
||||
issueBody += `## Description\n\n\`\`\`\n${safeDescription}\n\`\`\`\n`;
|
||||
issueBody += `\n---\n*Created via HealthLog bug report*`;
|
||||
|
||||
// If screenshot provided, upload to GitHub as a gist or include as base64
|
||||
// For simplicity, we'll note there's a screenshot but GitHub issues don't support inline base64
|
||||
if (screenshot) {
|
||||
issueBody += `\n\n**Screenshot:** A screenshot was attached (see comment).`;
|
||||
}
|
||||
|
||||
// Create the issue
|
||||
const issueRes = await safeFetch(
|
||||
`https://api.github.com/repos/${ghRepo}/issues`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${ghToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: `[Bug] ${title}`,
|
||||
body: issueBody,
|
||||
labels: ["bug", "user-reported"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!issueRes.ok) {
|
||||
const errBody = await issueRes.text();
|
||||
getEvent()?.addWarning("GitHub issue creation failed: " + errBody);
|
||||
return apiError("Failed to create issue", 500);
|
||||
}
|
||||
|
||||
const issue = (await issueRes.json()) as {
|
||||
number: number;
|
||||
html_url: string;
|
||||
};
|
||||
|
||||
// If screenshot, upload as a comment
|
||||
if (screenshot) {
|
||||
// Extract just the base64 part
|
||||
const base64Data = screenshot.replace(/^data:image\/\w+;base64,/, "");
|
||||
const ext = screenshot.startsWith("data:image/png") ? "png" : "jpg";
|
||||
|
||||
// Upload screenshot as issue comment with embedded image
|
||||
// GitHub doesn't support base64 in comments, so we note the limitation
|
||||
const commentBody = `### Screenshot\n\n}...)\n\n*Note: The full screenshot was truncated due to size limits. Please contact the user directly.*`;
|
||||
|
||||
await safeFetch(
|
||||
`https://api.github.com/repos/${ghRepo}/issues/${issue.number}/comments`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${ghToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
body: JSON.stringify({ body: commentBody }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await auditLog("bugreport.submit", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
issueNumber: issue.number,
|
||||
hasScreenshot: Boolean(screenshot),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({ meta: { issue_number: issue.number } });
|
||||
|
||||
return apiSuccess({
|
||||
issueNumber: issue.number,
|
||||
issueUrl: issue.html_url,
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Returns whether bug report submission is configured (GitHub token + repo).
|
||||
*
|
||||
* The bug report page uses this to gate its UI so users don't hit a silent
|
||||
* 500 when the admin hasn't set up the GitHub integration. Only two booleans
|
||||
* escape: a single `configured` flag and `isAdmin` so the page can render
|
||||
* the admin-specific copy + admin link. Token/repo granularity is kept
|
||||
* server-side (log annotation only) so we don't hand regular users a probe.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
|
||||
interface BugreportStatusCacheValue {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
hasToken: boolean;
|
||||
hasRepo: boolean;
|
||||
}
|
||||
|
||||
async function buildBugreportStatusValue(): Promise<BugreportStatusCacheValue> {
|
||||
const appSettings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
githubIssueTokenEncrypted: true,
|
||||
githubIssueRepo: true,
|
||||
bugReportEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const hasToken = Boolean(
|
||||
appSettings?.githubIssueTokenEncrypted || process.env.GITHUB_ISSUE_TOKEN,
|
||||
);
|
||||
const hasRepo = Boolean(
|
||||
appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO,
|
||||
);
|
||||
const configured = hasToken && hasRepo;
|
||||
// Default ON when the column has never been written.
|
||||
const enabled = appSettings?.bugReportEnabled !== false;
|
||||
|
||||
return { configured, enabled, hasToken, hasRepo };
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// No per-request rate limit: the payload rides the singleton server
|
||||
// cache below, so a cache hit costs zero Postgres round-trips — while
|
||||
// the former rate-limit bucket was an unconditional Postgres UPSERT
|
||||
// per request, i.e. the limiter WAS the per-request DB load it claimed
|
||||
// to prevent. The route stays authenticated; the only uncached work is
|
||||
// one `appSettings` read per 10 min (the bucket's TTL) process-wide.
|
||||
|
||||
// Cache the global app-settings shape on a singleton key (per blueprint §3).
|
||||
// `isAdmin` lives outside the cache because it varies per request. The
|
||||
// `invalidateAppSettings()` helper called from
|
||||
// `PATCH /api/admin/app-settings` evicts this entry when bug-report
|
||||
// configuration changes.
|
||||
const status = await cached(
|
||||
caches.bugreportStatus as ServerCache<BugreportStatusCacheValue>,
|
||||
"singleton",
|
||||
() => buildBugreportStatusValue(),
|
||||
annotate,
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "bugreport.status" },
|
||||
meta: {
|
||||
configured: status.configured,
|
||||
enabled: status.enabled,
|
||||
has_token: status.hasToken,
|
||||
has_repo: status.hasRepo,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
configured: status.configured,
|
||||
enabled: status.enabled,
|
||||
isAdmin: user.role === "ADMIN",
|
||||
});
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1", email: "test@example.com", role: "USER" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
feedback: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
function jsonRequest(body: unknown): Request {
|
||||
return new Request("http://localhost/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/feedback bug-report toggle gate", () => {
|
||||
it("returns 503 when bugReportEnabled is false", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
bugReportEnabled: false,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "test",
|
||||
description: "Anything at least ten characters long.",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/disabled/i);
|
||||
expect(prisma.feedback.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates feedback when bugReportEnabled is true", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
bugReportEnabled: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.feedback.create).mockResolvedValue({
|
||||
id: "fb-1",
|
||||
createdAt: new Date(),
|
||||
category: "BUG",
|
||||
status: "NEW",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "test",
|
||||
description: "Anything at least ten characters long.",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.feedback.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("creates feedback when bugReportEnabled has never been written (default ON)", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.feedback.create).mockResolvedValue({
|
||||
id: "fb-2",
|
||||
createdAt: new Date(),
|
||||
category: "BUG",
|
||||
status: "NEW",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "test",
|
||||
description: "Anything at least ten characters long.",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.feedback.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/feedback — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
bugReportEnabled: true,
|
||||
} as never);
|
||||
// subject too short + description too short.
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "x",
|
||||
description: "short",
|
||||
}) as never,
|
||||
);
|
||||
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 THREE simultaneous validation errors", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
bugReportEnabled: true,
|
||||
} as never);
|
||||
// bad subject + bad description + bad screenshot.
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "x",
|
||||
description: "short",
|
||||
screenshot: "not-a-data-url",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* POST /api/feedback — user submits feedback. Always works, no GitHub needed.
|
||||
* GET /api/feedback — user's own feedback history (paginated).
|
||||
*
|
||||
* Rate-limited at 5 per hour per user. Screenshots are stored as base64 in
|
||||
* the database (≤5 MB each).
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
getClientIp,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createFeedbackSchema } from "@/lib/validations/feedback";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`feedback:${user.id}`, 5, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many submissions — please try again later", 429);
|
||||
}
|
||||
|
||||
// The admin "Bug reports" toggle gates *every* feedback submission, not
|
||||
// just the GitHub-promotion path. Previously the gate only lived in the
|
||||
// legacy `/api/bugreport` route which the form does not even call —
|
||||
// so flipping the toggle off had no effect on user-visible behaviour.
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { bugReportEnabled: true },
|
||||
});
|
||||
if (settings && settings.bugReportEnabled === false) {
|
||||
return apiError("Bug reports are disabled by the administrator", 503);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = createFeedbackSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const feedback = await prisma.feedback.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
email: user.email ?? null,
|
||||
category: parsed.data.category,
|
||||
subject: parsed.data.subject,
|
||||
description: parsed.data.description,
|
||||
screenshotBase64: parsed.data.screenshot ?? null,
|
||||
metadata:
|
||||
parsed.data.metadata !== undefined
|
||||
? (parsed.data.metadata as Prisma.InputJsonValue)
|
||||
: Prisma.JsonNull,
|
||||
},
|
||||
select: { id: true, createdAt: true, category: true, status: true },
|
||||
});
|
||||
|
||||
await auditLog("feedback.submit", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
feedbackId: feedback.id,
|
||||
category: feedback.category,
|
||||
hasScreenshot: Boolean(parsed.data.screenshot),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "feedback.submit" },
|
||||
meta: {
|
||||
feedback_id: feedback.id,
|
||||
category: feedback.category,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(feedback, 201);
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const limit = Math.min(
|
||||
50,
|
||||
Math.max(1, Number(url.searchParams.get("limit") ?? 25)),
|
||||
);
|
||||
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0));
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.feedback.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
category: true,
|
||||
subject: true,
|
||||
status: true,
|
||||
adminNote: true,
|
||||
gitHubIssueUrl: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.feedback.count({ where: { userId: user.id } }),
|
||||
]);
|
||||
|
||||
return apiSuccess({ items, meta: { total, limit, offset } });
|
||||
});
|
||||
@@ -190,7 +190,7 @@ describe("GET /api/gamification/achievements — hidden achievement redaction",
|
||||
for (const entry of hiddenLocked) {
|
||||
// Trigger semantics scrubbed
|
||||
expect(entry.metric ?? "").not.toMatch(
|
||||
/(nightOwl|earlyBird|leapDay|doctorPdf|localeFlip|bugReport)Count/,
|
||||
/(nightOwl|earlyBird|leapDay|doctorPdf|localeFlip)Count/,
|
||||
);
|
||||
// i18n keys scrubbed
|
||||
expect(entry.titleKey ?? "").not.toContain("hiddenNightOwl");
|
||||
|
||||
@@ -66,12 +66,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
// Destroy all sessions first
|
||||
await destroyAllSessions(userId);
|
||||
|
||||
// Audit V3 NEW-V3-2 / GDPR Art. 17 fix: Feedback and AuditLog rows have
|
||||
// `onDelete: SetNull` in the schema, which keeps PII (free-text symptom
|
||||
// descriptions, IP addresses, login city geo) attached to the record after
|
||||
// the user is deleted. We explicitly purge them inside the same logical
|
||||
// operation so account deletion is genuinely complete erasure.
|
||||
await prisma.feedback.deleteMany({ where: { userId } });
|
||||
// Audit V3 NEW-V3-2 / GDPR Art. 17 fix: AuditLog rows have
|
||||
// `onDelete: SetNull` in the schema, which keeps PII (IP addresses, login
|
||||
// city geo) attached to the record after the user is deleted. We explicitly
|
||||
// purge them inside the same logical operation so account deletion is
|
||||
// genuinely complete erasure.
|
||||
await prisma.auditLog.deleteMany({ where: { userId } });
|
||||
|
||||
// Delete user — all other related data is removed via onDelete: Cascade
|
||||
|
||||
Reference in new issue
Block a user