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

@@ -137,7 +137,6 @@ const mockData = {
passkeyLoginCount: 0,
passwordLoginCount: 0,
loginDayStreak: 0,
bugReportCount: 0,
moodEntryCount: 0,
moodDayStreak: 0,
moodImprovementHit: 0,
-10
View File
@@ -26,7 +26,6 @@ import { AppLogPreviewSection } from "@/components/admin/app-log-preview-section
import { BackupsSection } from "@/components/admin/backups-section";
import { DangerZoneSection } from "@/components/admin/danger-zone-section";
import { EncryptionSection } from "@/components/admin/encryption-section";
import { FeedbackInboxSection } from "@/components/admin/feedback-inbox-section";
import { GeneralSettingsSection } from "@/components/admin/general-settings-section";
import { IntegrationsGroupSection } from "@/components/admin/integrations-group-section";
import { LoginOverviewSection } from "@/components/admin/login-overview-section";
@@ -121,15 +120,6 @@ export function AdminSectionRenderer({
<ModuleAvailabilitySection />
</SectionFrame>
);
case "feedback":
return (
<SectionFrame
title={t("admin.section.feedback.title")}
subtitle={t("admin.section.feedback.subtitle")}
>
<FeedbackInboxSection />
</SectionFrame>
);
case "reminders":
return (
<SectionFrame
@@ -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,
},
});
});
@@ -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);
});
});
-193
View File
@@ -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![Screenshot](data:image/${ext};base64,${base64Data.slice(0, 100)}...)\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,
});
});
-83
View File
@@ -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);
});
});
-123
View File
@@ -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");
+5 -6
View File
@@ -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
-247
View File
@@ -1,247 +0,0 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2, CheckCircle2, Bug, GitPullRequest, Info } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
interface BugReportStatus {
configured: boolean;
enabled: boolean;
isAdmin: boolean;
}
const CATEGORIES = [
{ value: "BUG", labelKey: "bugreport.categoryBug" },
{ value: "FEATURE_REQUEST", labelKey: "bugreport.categoryFeature" },
{ value: "QUESTION", labelKey: "bugreport.categoryQuestion" },
{ value: "OTHER", labelKey: "bugreport.categoryOther" },
] as const;
type CategoryValue = (typeof CATEGORIES)[number]["value"];
export default function BugReportPage() {
const { isAuthenticated } = useAuth();
const { t, locale } = useTranslations();
const [category, setCategory] = useState<CategoryValue>("BUG");
const [subject, setSubject] = useState("");
const [description, setDescription] = useState("");
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<{
type: "success" | "error";
message: string;
} | null>(null);
// Status is now informational only — submission always works.
const { data: status } = useQuery({
queryKey: queryKeys.bugreportStatus(),
queryFn: async () => {
return apiGet<BugReportStatus>("/api/bugreport/status");
},
enabled: isAuthenticated,
});
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setResult(null);
setLoading(true);
try {
await apiPost("/api/feedback", {
category,
subject: subject || description.slice(0, 60),
description,
metadata: {
locale,
userAgent:
typeof navigator !== "undefined" ? navigator.userAgent : null,
url: typeof window !== "undefined" ? window.location.pathname : null,
},
});
setResult({ type: "success", message: t("bugreport.success") });
setSubject("");
setDescription("");
} catch (err) {
setResult({
type: "error",
message:
err instanceof ApiError
? err.message || t("bugreport.errorCreating")
: t("common.networkError"),
});
} finally {
setLoading(false);
}
}
if (!isAuthenticated) {
return (
<div className="w-full space-y-6">
<h1 className="text-2xl font-bold tracking-tight">
{t("bugreport.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("bugreport.loginRequired")}
</p>
</div>
);
}
if (status && status.enabled === false) {
return (
<div className="w-full space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("bugreport.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("bugreport.subtitle")}
</p>
</div>
<div className="bg-card border-border border-l-warning flex gap-2 rounded-lg border-l-4 p-3 text-sm">
<Info className="text-warning mt-0.5 h-4 w-4 shrink-0" />
<div>
<p className="text-foreground font-medium">
{t("bugreport.disabledTitle")}
</p>
<p className="text-muted-foreground mt-1">
{t("bugreport.disabledBody")}
</p>
</div>
</div>
</div>
);
}
return (
<div className="mx-auto w-full max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("bugreport.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("bugreport.subtitle")}
</p>
</div>
{status?.configured && (
<div className="bg-card border-border border-l-info flex gap-2 rounded-lg border-l-4 p-3 text-sm">
<GitPullRequest className="text-info mt-0.5 h-4 w-4 shrink-0" />
<p className="text-muted-foreground">
{t("bugreport.githubEscalationNote")}
</p>
</div>
)}
{!status?.configured && (
<div className="bg-card border-border border-l-dose-accent flex gap-2 rounded-lg border-l-4 p-3 text-sm">
<Info className="text-dose-accent mt-0.5 h-4 w-4 shrink-0" />
<p className="text-muted-foreground">
{t("bugreport.internalOnlyNote")}
</p>
</div>
)}
<div className="bg-card border-border w-full rounded-xl border p-6">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-[200px_1fr]">
<div className="space-y-2">
<Label htmlFor="category">{t("bugreport.category")}</Label>
<Select
value={category}
onValueChange={(v) => setCategory(v as CategoryValue)}
>
<SelectTrigger id="category">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATEGORIES.map((c) => (
<SelectItem key={c.value} value={c.value}>
{t(c.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="subject">{t("bugreport.bugTitle")}</Label>
<Input
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={t("bugreport.bugTitlePlaceholder")}
maxLength={200}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="bug-desc">{t("bugreport.description")}</Label>
<Textarea
id="bug-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
required
minLength={10}
maxLength={5000}
rows={8}
/>
</div>
{result && (
<div
className={`rounded-lg p-3 text-sm ${
result.type === "success"
? "bg-success/10 text-success"
: "bg-destructive/10 text-destructive"
}`}
>
{result.type === "success" && (
<CheckCircle2 className="mr-2 inline h-4 w-4" />
)}
{result.message}
</div>
)}
<div className="flex items-center justify-end pt-2">
<Button
type="submit"
disabled={loading}
aria-busy={loading || undefined}
>
{/*
v1.4.33 IW9 — swap the icon on loading (Loader2 takes
the same 16x16 slot as the Bug icon) so the button
width is stable across the disabled flip. The previous
`{loading && <Loader2/>}<Bug/>` rendered both icons
during the in-flight request and grew the button by
~24 px — the CLS regression flagged in the v1.4.33
Lighthouse punch list (0.186 on
`div.space-y-2 > Button > Loader-Circle`).
*/}
{loading ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Bug className="h-4 w-4" />
)}
{t("bugreport.submit")}
</Button>
</div>
</form>
</div>
</div>
);
}
+1 -1
View File
@@ -133,7 +133,7 @@ export default function NotificationsPage() {
{t("notifications.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("bugreport.loginRequired")}
{t("notifications.loginRequired")}
</p>
</div>
);
+1 -1
View File
@@ -1581,7 +1581,7 @@ export default function PrivacyPage() {
<span className="text-foreground font-medium">
Nutzer-Inhalte
</span>{" "}
(Notizen zu Messungen, Stimmungsnotizen, Bug-Reports).
(Notizen zu Messungen, Stimmungsnotizen).
</li>
<li>
<span className="text-foreground font-medium">Kennungen</span>{" "}
+1 -1
View File
@@ -6,7 +6,7 @@ React components. The UI primitives are shadcn/ui (new-york style, zinc base) ov
- **`ui/`** — shadcn/ui primitives (button, dialog, sheet, table, …). Generated by the shadcn CLI; keep edits minimal and on-style.
- **`layout/`** — the app shell: sidebar, topbar, bottom nav.
- **`providers.tsx`, `app-settings-provider.tsx`, `version-poller.tsx`** — top-level context providers and the self-healing stale-shell poller.
- **`providers.tsx`, `version-poller.tsx`** — top-level context providers and the self-healing stale-shell poller.
- **`i18n/`** — translation context + locale switcher.
## Feature groups
+11 -21
View File
@@ -1,24 +1,14 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
// `<ErrorDetails>` is a client component used by every Next.js
// `error.tsx` boundary. It surfaces a "Report bug" link whose
// visibility must mirror the admin's `bugReportEnabled` toggle —
// otherwise the user lands on a `/bugreport` page that immediately
// renders a "Bug reports are disabled" notice.
const mockSettingsRef = { value: { bugReportEnabled: true } };
vi.mock("@/components/app-settings-provider", () => ({
useAppSettings: () => mockSettingsRef.value,
}));
// `<ErrorDetails>` is a client component used by every Next.js `error.tsx`
// boundary. It surfaces the error message plus a copy-to-clipboard diagnostic
// payload — and, since v1.23, no longer links out to a bug-report surface.
import { I18nProvider } from "@/lib/i18n/context";
import { ErrorDetails } from "../error-details";
function render({
bugReportEnabled = true,
}: { bugReportEnabled?: boolean } = {}) {
mockSettingsRef.value = { bugReportEnabled };
function render() {
const error = Object.assign(new Error("test failure"), {
digest: "abc123",
});
@@ -29,14 +19,14 @@ function render({
);
}
describe("<ErrorDetails> bug-report toggle", () => {
it("shows the Report bug button when the admin flag is enabled", () => {
const html = render({ bugReportEnabled: true });
expect(html).toContain('href="/bugreport"');
describe("<ErrorDetails>", () => {
it("renders the error message", () => {
const html = render();
expect(html).toContain("test failure");
});
it("hides the Report bug button when the admin flag is disabled", () => {
const html = render({ bugReportEnabled: false });
it("does not link out to a bug-report surface", () => {
const html = render();
expect(html).not.toContain('href="/bugreport"');
});
});
@@ -46,7 +46,6 @@ import { AiQualitySection } from "../ai-quality-section";
import { ApiTokenOverviewSection } from "../api-token-overview-section";
import { BackupsSection } from "../backups-section";
import { DangerZoneSection } from "../danger-zone-section";
import { FeedbackInboxSection } from "../feedback-inbox-section";
import { GeneralSettingsSection } from "../general-settings-section";
import { IntegrationsGroupSection } from "../integrations-group-section";
import { LoginOverviewSection } from "../login-overview-section";
@@ -89,22 +88,13 @@ describe("admin sections — SSR smoke", () => {
expect(html).toContain("Telegram");
});
it("<IntegrationsGroupSection> renders all four sub-sections", () => {
it("<IntegrationsGroupSection> renders all sub-sections", () => {
const html = render(<IntegrationsGroupSection />);
expect(html).toContain("Umami");
expect(html).toContain("GlitchTip");
expect(html).toContain("Web Push");
});
it("<FeedbackInboxSection> renders", () => {
const html = render(<FeedbackInboxSection />);
// F-08 dedupe: card no longer carries a duplicate "Feedback"
// header. Anchor on the empty-state copy + status tabs instead.
expect(html).toContain("All caught up");
expect(html).toContain("Open");
expect(html).toContain("Resolved");
});
it("<RemindersSection> renders", () => {
const html = render(<RemindersSection />);
expect(html).toContain("Medication Reminders");
+3 -52
View File
@@ -60,7 +60,6 @@ export interface SystemStatus {
umami: { configured: boolean; enabled: boolean } | null;
glitchtip: { configured: boolean; enabled: boolean } | null;
webPush: { configured: boolean } | null;
bugReport: { configured: boolean } | null;
};
}
@@ -80,9 +79,6 @@ export interface AdminSettings {
glitchtipEnabled: boolean;
glitchtipDsn: string | null;
glitchtipEnvironment: string | null;
bugReportRepo: string | null;
bugReportConfigured: boolean;
bugReportEnabled: boolean;
reminderLateMinutes: number;
reminderMissedMinutes: number;
// v1.4.25 W7 — null means "fall back to Europe/Berlin in the resolver".
@@ -116,54 +112,9 @@ export interface ApiTokenInfo {
user: { id: string; username: string };
}
export type FeedbackStatusType =
| "OPEN"
| "ACKNOWLEDGED"
| "RESOLVED"
| "ARCHIVED";
export type FeedbackCategoryType =
| "BUG"
| "FEATURE_REQUEST"
| "QUESTION"
| "OTHER";
export interface FeedbackItem {
id: string;
userId: string | null;
email: string | null;
category: FeedbackCategoryType;
subject: string;
description: string;
status: FeedbackStatusType;
adminNote: string | null;
gitHubIssueUrl: string | null;
metadata: Record<string, unknown> | null;
screenshotBase64: string | null;
createdAt: string;
updatedAt: string;
user: { username: string } | null;
}
export interface FeedbackListResponse {
items: FeedbackItem[];
meta: {
total: number;
limit: number;
offset: number;
countsByStatus: Partial<Record<FeedbackStatusType, number>>;
};
}
export const FEEDBACK_STATUS_TABS: FeedbackStatusType[] = [
"OPEN",
"ACKNOWLEDGED",
"RESOLVED",
"ARCHIVED",
];
/** Shared green "configured" pill — the GlitchTip, Umami, bug-report,
* and Web-Push VAPID cards all surface the same state; one component
* keeps the tint and copy in lockstep. */
/** Shared green "configured" pill — the GlitchTip, Umami, and Web-Push
* VAPID cards all surface the same state; one component keeps the tint
* and copy in lockstep. */
export function ConfiguredBadge() {
const { t } = useTranslations();
return (
-6
View File
@@ -23,7 +23,6 @@ import {
FileText,
Info,
KeyRound,
Inbox,
Plug,
Radio,
ScrollText,
@@ -90,11 +89,6 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [
titleKey: "admin.section.module-availability.title",
icon: Blocks,
},
{
slug: "feedback",
titleKey: "admin.section.feedback.title",
icon: Inbox,
},
{
slug: "reminders",
titleKey: "admin.section.reminders.title",
-142
View File
@@ -1,142 +0,0 @@
"use client";
import { useState } from "react";
import { Bug, Loader2 } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import {
ConfiguredBadge,
PasswordInput,
useAdminSettings,
useUpdateSettings,
} from "./_shared";
export function BugReportSection() {
const { t } = useTranslations();
const { data: settings } = useAdminSettings();
const updateSettings = useUpdateSettings();
const [bugReportRepoDraft, setBugReportRepoDraft] = useState<string | null>(
null,
);
const [bugReportTokenDraft, setBugReportTokenDraft] = useState("");
const bugReportRepoValue =
bugReportRepoDraft ?? settings?.bugReportRepo ?? "";
const configured = settings?.bugReportConfigured ?? false;
const enabled = settings?.bugReportEnabled ?? true;
function saveBugReportSettings() {
const payload: Record<string, unknown> = {
bugReportRepo: bugReportRepoValue,
};
if (bugReportTokenDraft.trim().length > 0) {
payload.bugReportToken = bugReportTokenDraft.trim();
}
updateSettings.mutate(payload, {
onSuccess: () => {
setBugReportRepoDraft(null);
setBugReportTokenDraft("");
},
});
}
function toggleEnabled(next: boolean) {
updateSettings.mutate({ bugReportEnabled: next });
}
return (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={Bug}
title={t("admin.bugReportGithub")}
titleAccessory={
// v1.18.10 (W7) — the feedback / bug-report feature is deprecated.
// The badge + note signal the upcoming removal without pulling the
// functionality yet, so existing operators keep the surface working.
<Badge
variant="outline"
data-testid="bug-report-deprecated-badge"
className="border-amber-500/50 text-amber-600 dark:text-amber-400"
>
{t("admin.bugReportDeprecatedBadge")}
</Badge>
}
description={
<>
<p>{t("admin.bugReportGithubDescription")}</p>
<p className="text-amber-600 dark:text-amber-400">
{t("admin.bugReportDeprecatedNote")}
</p>
</>
}
status={configured ? <ConfiguredBadge /> : null}
/>
<div className="bg-muted/40 mt-4 ml-7 flex items-center justify-between gap-3 rounded-lg p-3">
<div>
<p className="text-sm font-medium">
{t("admin.bugReportEnabledLabel")}
</p>
<p className="text-muted-foreground text-xs">
{t("admin.bugReportEnabledDescription")}
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={toggleEnabled}
disabled={updateSettings.isPending}
aria-label={t("admin.bugReportEnabledLabel")}
/>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="admin-bugreport-repo" className="text-xs">
{t("admin.bugReportRepo")}
</Label>
<Input
id="admin-bugreport-repo"
value={bugReportRepoValue}
onChange={(event) => setBugReportRepoDraft(event.target.value)}
placeholder={t("admin.bugReportRepoPlaceholder")}
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
disabled={updateSettings.isPending}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="admin-bugreport-token" className="text-xs">
{t("admin.bugReportToken")}
</Label>
<PasswordInput
id="admin-bugreport-token"
value={bugReportTokenDraft}
onChange={(event) => setBugReportTokenDraft(event.target.value)}
placeholder={t("admin.bugReportTokenPlaceholder")}
disabled={updateSettings.isPending}
/>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
size="sm"
onClick={saveBugReportSettings}
disabled={updateSettings.isPending}
>
{updateSettings.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</div>
</div>
);
}
@@ -1,517 +0,0 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
CheckCircle2,
ExternalLink,
GitPullRequest,
Inbox,
Loader2,
MessageSquare,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
FEEDBACK_STATUS_TABS,
type FeedbackCategoryType,
type FeedbackItem,
type FeedbackListResponse,
type FeedbackStatusType,
useSystemStatus,
} from "./_shared";
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
export function FeedbackInboxSection() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { data: status } = useSystemStatus();
const githubConfigured = Boolean(status?.integrations?.bugReport?.configured);
const [activeStatus, setActiveStatus] = useState<FeedbackStatusType>("OPEN");
const [selected, setSelected] = useState<FeedbackItem | null>(null);
const { data, isLoading } = useQuery({
queryKey: queryKeys.adminFeedback(activeStatus),
queryFn: async () => {
return apiGet<FeedbackListResponse>(
`/api/admin/feedback?status=${activeStatus}&limit=100`,
);
},
});
const counts = data?.meta?.countsByStatus ?? {};
function refresh() {
queryClient.invalidateQueries({ queryKey: queryKeys.adminFeedbackRoot() });
}
return (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
{/* v1.4.19 A8 / F-08 + F-20: this is the only card on
`/admin/feedback`, so the page header already provides the
title + description — the card-level repetition was just
visual noise. The icon stays as a low-key card affordance. */}
<Inbox className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<Tabs
value={activeStatus}
onValueChange={(v) => setActiveStatus(v as FeedbackStatusType)}
className="mt-4"
>
{/* v1.4.27 MB7 / CF-55 — wrap the TabsList in an
overflow-x-auto strip so the 4 status pills + their count
badges scroll horizontally on Pixel 5 / Galaxy Fold
instead of squashing the badge text below the legibility
floor. Hide the scrollbar visually but keep the gesture
working (matches the Insights tab-strip pattern). */}
<div className="-mx-1 [scrollbar-width:none] overflow-x-auto px-1 [&::-webkit-scrollbar]:hidden">
<TabsList>
{FEEDBACK_STATUS_TABS.map((s) => (
<TabsTrigger key={s} value={s}>
<span>
{t(
`admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`,
)}
</span>
<Badge variant="secondary" className="ml-1.5 text-xs">
{counts[s] ?? 0}
</Badge>
</TabsTrigger>
))}
</TabsList>
</div>
{FEEDBACK_STATUS_TABS.map((s) => (
<TabsContent key={s} value={s} className="mt-4">
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.feedback.loading")}
</span>
</div>
) : !data?.items?.length ? (
// v1.4.15 phase-C5: per-tab empty state. Compact size so
// it sits naturally under the tab strip without dwarfing
// a typical 1-2-row inbox once entries arrive.
<EmptyState
size="compact"
icon={<Inbox className="size-5" />}
title={t("admin.feedback.emptyTitle")}
description={t("admin.feedback.emptyDescription")}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("admin.feedback.createdAt")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.feedback.category")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.feedback.subject")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("admin.feedback.user")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("admin.feedback.actions")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{data.items.map((item, i) => (
<tr
key={item.id}
className={`hover:bg-muted/40 cursor-pointer ${i % 2 === 0 ? "bg-muted/30" : ""}`}
onClick={() => setSelected(item)}
>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{formatDateTime(item.createdAt)}
</td>
<td className="px-3 py-2">
<FeedbackCategoryBadge category={item.category} />
</td>
<td className="max-w-[28ch] truncate px-3 py-2 font-medium">
{item.subject}
</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{item.user?.username ?? t("admin.feedback.anonymous")}
</td>
<td className="px-3 py-2 text-right">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={(e) => {
e.stopPropagation();
setSelected(item);
}}
>
{t("admin.feedback.details")}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</TabsContent>
))}
</Tabs>
{selected && (
<FeedbackDetailDialog
item={selected}
open={!!selected}
onOpenChange={(open) => !open && setSelected(null)}
githubConfigured={githubConfigured}
onMutated={refresh}
/>
)}
</div>
);
}
function FeedbackCategoryBadge({
category,
}: {
category: FeedbackCategoryType;
}) {
const { t } = useTranslations();
const map: Record<
FeedbackCategoryType,
{ label: string; className: string }
> = {
BUG: {
label: t("admin.feedback.categoryBug"),
// Bug = something that's broken → semantic `destructive` token instead
// of raw `dracula-red`. Visually identical in dark mode (both resolve
// to #ff5555); light mode now picks up the AA-contrast `#cb3a2a`.
className: "bg-destructive/15 text-destructive border-destructive/30",
},
FEATURE_REQUEST: {
label: t("admin.feedback.categoryFeature"),
className:
"bg-dracula-purple/15 text-dracula-purple border-dracula-purple/30",
},
QUESTION: {
label: t("admin.feedback.categoryQuestion"),
className: "bg-dracula-cyan/15 text-dracula-cyan border-dracula-cyan/30",
},
OTHER: {
label: t("admin.feedback.categoryOther"),
className: "bg-muted text-muted-foreground border-border",
},
};
const cfg = map[category];
return (
<Badge className={`border text-xs ${cfg.className}`}>{cfg.label}</Badge>
);
}
function FeedbackDetailDialog({
item,
open,
onOpenChange,
githubConfigured,
onMutated,
}: {
item: FeedbackItem;
open: boolean;
onOpenChange: (open: boolean) => void;
githubConfigured: boolean;
onMutated: () => void;
}) {
const { t } = useTranslations();
const [note, setNote] = useState(item.adminNote ?? "");
const [issueUrl, setIssueUrl] = useState(item.gitHubIssueUrl);
const update = useMutation({
mutationFn: async (payload: {
status?: FeedbackStatusType;
adminNote?: string | null;
}) => {
await apiPatch(`/api/admin/feedback/${item.id}`, payload);
},
onSuccess: (_, vars) => {
onMutated();
if (vars.adminNote !== undefined) {
toast.success(t("admin.feedback.noteSaved"));
}
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t("admin.feedback.updateFailed"),
);
},
});
const archive = useMutation({
mutationFn: async () => {
await apiDelete(`/api/admin/feedback/${item.id}`);
},
onSuccess: () => {
onMutated();
onOpenChange(false);
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t("admin.feedback.updateFailed"),
);
},
});
const publish = useMutation({
mutationFn: async () => {
return apiPost<{ issueUrl: string }>(
`/api/admin/feedback/${item.id}/github`,
);
},
onSuccess: (data) => {
setIssueUrl(data.issueUrl);
toast.success(t("admin.feedback.publishSuccess"));
onMutated();
},
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t("admin.feedback.publishFailed"),
);
},
});
const meta = item.metadata ?? {};
const url = typeof meta.url === "string" ? meta.url : null;
const locale = typeof meta.locale === "string" ? meta.locale : null;
const userAgent = typeof meta.userAgent === "string" ? meta.userAgent : null;
const appVersion =
typeof meta.appVersion === "string" ? meta.appVersion : null;
return (
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={
<span className="flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
{item.subject}
</span>
}
description={
<span className="flex flex-wrap items-center gap-2 text-xs">
<FeedbackCategoryBadge category={item.category} />
<span>
{t("admin.feedback.submittedBy")}:{" "}
{item.user?.username ?? t("admin.feedback.anonymous")}
</span>
<span>·</span>
<span>{formatDateTime(item.createdAt)}</span>
{issueUrl && (
<a
href={issueUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary inline-flex items-center gap-1"
>
<GitPullRequest className="h-3 w-3" />
{t("admin.feedback.viewIssue")}
<ExternalLink className="h-3 w-3" />
</a>
)}
</span>
}
className="sm:max-w-2xl"
footer={
<div className="flex w-full flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={update.isPending || item.status === "ACKNOWLEDGED"}
onClick={() => update.mutate({ status: "ACKNOWLEDGED" })}
>
<CheckCircle2 className="h-3.5 w-3.5" />
{t("admin.feedback.actionAcknowledge")}
</Button>
<Button
size="sm"
disabled={update.isPending || item.status === "RESOLVED"}
onClick={() => update.mutate({ status: "RESOLVED" })}
>
<CheckCircle2 className="h-3.5 w-3.5" />
{t("admin.feedback.actionResolve")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="destructive"
disabled={archive.isPending || item.status === "ARCHIVED"}
>
<Trash2 className="h-3.5 w-3.5" />
{t("admin.feedback.actionArchive")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.feedback.archiveConfirm")}
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => archive.mutate()}
variant="destructive"
disabled={archive.isPending}
aria-busy={archive.isPending || undefined}
>
{archive.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("admin.feedback.actionArchive")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{githubConfigured && !issueUrl && (
<Button
size="sm"
variant="outline"
disabled={publish.isPending}
onClick={() => publish.mutate()}
className="ml-auto"
>
{publish.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<GitPullRequest className="h-3.5 w-3.5" />
)}
{t("admin.feedback.actionPublishGithub")}
</Button>
)}
{issueUrl && (
<Badge className="border-success/30 bg-success/15 text-success ml-auto">
{t("admin.feedback.publishedToGithub")}
</Badge>
)}
</div>
}
>
<div className="space-y-4 text-sm">
<div className="bg-muted/40 rounded-md p-3 whitespace-pre-wrap">
{item.description}
</div>
{(url || locale || userAgent || appVersion) && (
<div>
{/* v1.4.33 IW9 — h4 -> h3 so the sheet outline stays
sequential beneath the ResponsiveSheet's h2 title. */}
<h3 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
{t("admin.feedback.metadataHeading")}
</h3>
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
{url && (
<>
<dt className="text-muted-foreground">
{t("admin.feedback.metaUrl")}
</dt>
<dd className="font-mono break-all">{url}</dd>
</>
)}
{locale && (
<>
<dt className="text-muted-foreground">
{t("admin.feedback.metaLocale")}
</dt>
<dd className="font-mono">{locale}</dd>
</>
)}
{appVersion && (
<>
<dt className="text-muted-foreground">
{t("admin.feedback.metaAppVersion")}
</dt>
<dd className="font-mono">{appVersion}</dd>
</>
)}
{userAgent && (
<>
<dt className="text-muted-foreground">
{t("admin.feedback.metaUserAgent")}
</dt>
<dd className="font-mono break-all">{userAgent}</dd>
</>
)}
</dl>
</div>
)}
{item.screenshotBase64 && (
<div>
<h3 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
{t("admin.feedback.screenshotHeading")}
</h3>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.screenshotBase64}
alt="Screenshot"
className="border-border max-h-72 rounded-md border"
/>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="admin-feedback-note" className="text-xs">
{t("admin.feedback.adminNote")}
</Label>
<Textarea
id="admin-feedback-note"
value={note}
onChange={(e) => setNote(e.target.value)}
rows={3}
maxLength={5000}
placeholder={t("admin.feedback.adminNotePlaceholder")}
/>
<div className="flex justify-end">
<Button
size="sm"
variant="outline"
disabled={update.isPending || note === (item.adminNote ?? "")}
onClick={() => update.mutate({ adminNote: note || null })}
>
{update.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("admin.feedback.saveNote")}
</Button>
</div>
</div>
</div>
</ResponsiveSheet>
);
}
@@ -3,13 +3,11 @@
/**
* `<IntegrationsGroupSection>` — composite admin route at `/admin/integrations`.
*
* Wraps the four standalone integration sub-sections (Umami, GlitchTip, Web
* Push, Bug Report) under one route so each existing component stays the
* source of truth for its own settings while admins still get a single
* landing page mirroring the v1.4 layout.
* Wraps the standalone integration sub-sections (Umami, GlitchTip, Web Push)
* under one route so each existing component stays the source of truth for its
* own settings while admins still get a single landing page.
*/
import { BugReportSection } from "./bug-report-section";
import { GlitchtipSection } from "./glitchtip-section";
import { UmamiSection } from "./umami-section";
import { WebPushVapidSection } from "./web-push-vapid-section";
@@ -20,7 +18,6 @@ export function IntegrationsGroupSection() {
<UmamiSection />
<GlitchtipSection />
<WebPushVapidSection />
<BugReportSection />
</div>
);
}
-1
View File
@@ -25,7 +25,6 @@ export const ADMIN_SECTION_SLUGS = [
// (was stacked under Coach). It gates every module, not just the coach,
// so it reads better next to Coach than buried inside it.
"module-availability",
"feedback",
"reminders",
"users",
// v1.16.0 — registration invites get their own section. They started
@@ -6,7 +6,6 @@ import {
AlertTriangle,
Bell,
BellRing,
Bug,
Clock,
Cog,
Database,
@@ -160,14 +159,6 @@ export function SystemStatusSection() {
className="text-dracula-green"
/>
)}
{status.integrations.bugReport && (
<StatusItem
icon={Bug}
label={t("admin.integrationBugReport")}
value={t("admin.configured")}
className="text-dracula-green"
/>
)}
{/* v1.4.27 R5 — offline GeoLite2 availability. Renders only
when /api/version returns the new field so legacy
responses do not produce a placeholder row. */}
-81
View File
@@ -1,81 +0,0 @@
"use client";
/**
* Client-side feature-flag context — exposes admin-managed app settings to
* layout components so nav surfaces can hide entries when the admin flips a
* toggle.
*
* Today this only carries the `bugReportEnabled` flag (the "Bug Report"
* sidebar entry, the bottom-nav, the topbar dropdown, and the user-facing
* "Report bug" buttons inside `<ErrorDetails>` all gate on it). Add new
* flags here when more admin-managed feature toggles need to reach the
* client.
*
* Source: `GET /api/bugreport/status` already returns `{ enabled }` for any
* authenticated user. We piggy-back on that endpoint instead of standing up
* a parallel `/api/app-settings` route — the data is identical and the
* caching / rate-limit story is already solved upstream.
*
* Defaults to "all flags ON" so unauthenticated layouts (login, register)
* and the brief window before the query resolves don't flicker the entry
* out from underneath the user. The admin's "off" state only takes effect
* once the data has actually arrived.
*/
import { createContext, useContext, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
export interface AppSettings {
/** Whether the admin has enabled in-app bug-report / feedback submission. */
bugReportEnabled: boolean;
}
const DEFAULT_SETTINGS: AppSettings = {
bugReportEnabled: true,
};
const AppSettingsContext = createContext<AppSettings>(DEFAULT_SETTINGS);
export function useAppSettings(): AppSettings {
return useContext(AppSettingsContext);
}
interface BugReportStatusPayload {
configured: boolean;
enabled: boolean;
isAdmin: boolean;
}
/**
* Fetches `bugReportEnabled` once per session and exposes it via context. The
* provider lives inside `Providers` so anything beneath the React tree —
* including the global nav surfaces — can observe the flag without
* each component re-fetching the same status payload.
*/
export function AppSettingsProvider({ children }: { children: ReactNode }) {
const { isAuthenticated } = useAuth();
const { data } = useQuery({
queryKey: queryKeys.bugreportStatus(),
enabled: isAuthenticated,
queryFn: async () => {
return apiGet<BugReportStatusPayload>("/api/bugreport/status");
},
staleTime: 5 * 60 * 1000,
});
// Default to ON when the query is still loading, has errored, or the user
// is unauthenticated — the nav must not flicker the entry out under the
// user's cursor on a slow network.
const settings: AppSettings = {
bugReportEnabled: data?.enabled ?? true,
};
return (
<AppSettingsContext.Provider value={settings}>
{children}
</AppSettingsContext.Provider>
);
}
+1 -15
View File
@@ -1,11 +1,9 @@
"use client";
import { useState } from "react";
import { Copy, CheckCircle2, RotateCcw, Bug } from "lucide-react";
import Link from "next/link";
import { Copy, CheckCircle2, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { useAppSettings } from "@/components/app-settings-provider";
export interface ErrorDetailsProps {
/** The error object, whether it's a runtime Error or a typed API error. */
@@ -16,8 +14,6 @@ export interface ErrorDetailsProps {
requestId?: string | null;
/** Extra fields to include in the diagnostic payload. */
context?: Record<string, unknown>;
/** Override the "Report bug" link target. */
reportHref?: string;
}
/**
@@ -30,10 +26,8 @@ export function ErrorDetails({
reset,
requestId,
context,
reportHref = "/bugreport",
}: ErrorDetailsProps) {
const { t, locale } = useTranslations();
const { bugReportEnabled } = useAppSettings();
const [copied, setCopied] = useState(false);
async function handleCopy() {
@@ -102,14 +96,6 @@ export function ErrorDetails({
)}
{copied ? t("common.copied") : t("common.copyDetails")}
</Button>
{bugReportEnabled && (
<Button asChild variant="outline">
<Link href={reportHref}>
<Bug className="h-4 w-4" />
{t("common.reportIssue")}
</Link>
</Button>
)}
</div>
</div>
);
@@ -74,28 +74,14 @@ describe("nav-model utility tail (N-1 — one shared list)", () => {
}
});
it("lists the shared utility surfaces (Bug Report, Settings, Notifications)", () => {
it("lists the shared utility surfaces (Settings, Notifications)", () => {
const hrefs = NAV_UTILITY_DESTINATIONS.map((d) => d.href);
expect(hrefs).toContain("/bugreport");
expect(hrefs).toContain("/settings/account");
expect(hrefs).toContain("/notifications");
});
it("drops the bug-report entry unless the operator flag is on", () => {
const off = visibleUtilityDestinations(false).map((d) => d.href);
const on = visibleUtilityDestinations(true).map((d) => d.href);
expect(off).not.toContain("/bugreport");
expect(on).toContain("/bugreport");
// Non-gated entries are present either way.
expect(off).toContain("/settings/account");
expect(off).toContain("/notifications");
});
it("keeps a stable order (Bug Report → Settings → Notifications)", () => {
const hrefs = visibleUtilityDestinations(true).map((d) => d.href);
expect(hrefs.indexOf("/bugreport")).toBeLessThan(
hrefs.indexOf("/settings/account"),
);
it("keeps a stable order (Settings → Notifications)", () => {
const hrefs = visibleUtilityDestinations().map((d) => d.href);
expect(hrefs.indexOf("/settings/account")).toBeLessThan(
hrefs.indexOf("/notifications"),
);
@@ -178,16 +164,13 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
it("is exactly the visible feature destinations minus the primary slots, then the utility tail", () => {
const opts = {
modules: { cycle: false } as const,
bugReportEnabled: false,
};
const hub = mobileMoreHubDestinations(opts).map((d) => d.href);
const expectedFeatures = visibleNavDestinations(opts.modules)
.map((d) => d.href)
.filter((href) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(href));
const expectedTail = visibleUtilityDestinations(opts.bugReportEnabled).map(
(d) => d.href,
);
const expectedTail = visibleUtilityDestinations().map((d) => d.href);
expect(hub).toEqual([...expectedFeatures, ...expectedTail]);
});
@@ -195,7 +178,6 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
it("never contains a primary slot (Home / Meds / Insights)", () => {
const hub = mobileMoreHubDestinations({
modules: { cycle: true },
bugReportEnabled: true,
}).map((d) => d.href);
for (const slot of BOTTOM_NAV_PRIMARY_SLOT_HREFS) {
expect(hub).not.toContain(slot);
@@ -205,33 +187,27 @@ describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => {
it("keeps the feature destinations and the utility tail reachable in the hub", () => {
const hub = mobileMoreHubDestinations({
modules: { cycle: false },
bugReportEnabled: true,
}).map((d) => d.href);
// Feature surfaces that left the always-visible strip stay reachable.
expect(hub).toContain("/measurements");
expect(hub).toContain("/mood");
expect(hub).toContain("/checkups");
// The shared utility tail rides at the end.
expect(hub).toContain("/bugreport");
expect(hub).toContain("/settings/account");
expect(hub).toContain("/notifications");
});
it("gates Cycle by the module map and Bug Report by the operator flag, same as the sidebar", () => {
it("gates Cycle by the module map, same as the sidebar", () => {
const off = mobileMoreHubDestinations({
modules: { cycle: false, mood: false },
bugReportEnabled: false,
}).map((d) => d.href);
const on = mobileMoreHubDestinations({
modules: { cycle: true, mood: true },
bugReportEnabled: true,
}).map((d) => d.href);
expect(off).not.toContain("/cycle");
expect(off).not.toContain("/mood");
expect(off).not.toContain("/bugreport");
expect(on).toContain("/cycle");
expect(on).toContain("/mood");
expect(on).toContain("/bugreport");
});
});
@@ -43,13 +43,6 @@ vi.mock("@/components/providers", () => ({
}),
}));
// The bug-report flag — each test mutates `mockSettingsRef.value` before
// rendering so the sidebar reads the desired state.
const mockSettingsRef = { value: { bugReportEnabled: true } };
vi.mock("@/components/app-settings-provider", () => ({
useAppSettings: () => mockSettingsRef.value,
}));
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { SidebarNav } from "../sidebar-nav";
@@ -58,17 +51,14 @@ import { visibleUtilityDestinations } from "../nav-model";
function render({
pathname = "/",
bugReportEnabled = true,
role = "USER" as "USER" | "ADMIN",
modules = {} as Record<string, boolean>,
}: {
pathname?: string;
bugReportEnabled?: boolean;
role?: "USER" | "ADMIN";
modules?: Record<string, boolean>;
} = {}) {
mockPathnameRef.value = pathname;
mockSettingsRef.value = { bugReportEnabled };
mockUserRef.value = { ...mockUserRef.value, role, modules };
return renderToStaticMarkup(
// The nav reads `useQueryClient()` for the medications intent
@@ -82,21 +72,6 @@ function render({
);
}
describe("<SidebarNav> bug-report toggle", () => {
it("renders the Bug Report entry when the admin flag is enabled", () => {
const html = render({ bugReportEnabled: true });
expect(html).toContain('href="/bugreport"');
// i18n string for the entry — sanity check that the link is the actual
// bug-report nav entry, not e.g. an error-page report-bug button.
expect(html).toContain("Bug Report");
});
it("hides the Bug Report entry when the admin flag is disabled", () => {
const html = render({ bugReportEnabled: false });
expect(html).not.toContain('href="/bugreport"');
});
});
describe("<SidebarNav> cycle entry gate (v1.15.0 / v1.18.0 module map)", () => {
it("renders the Cycle entry when the cycle module is enabled", () => {
const html = render({ modules: { cycle: true } });
@@ -212,28 +187,17 @@ describe("<SidebarNav> shared utility tail (v1.17.1 N-1 parity guard)", () => {
// therefore the shared list minus `/notifications`. Asserting they all
// render straight from the shared list is the parity net that keeps the
// sidebar footer from drifting away from the mobile More-hub tail.
const footerUtilities = (bugReportEnabled: boolean) =>
visibleUtilityDestinations(bugReportEnabled).filter(
(d) => d.href !== "/notifications",
);
const footerUtilities = () =>
visibleUtilityDestinations().filter((d) => d.href !== "/notifications");
it("renders every footer utility destination straight from the shared list", () => {
const html = render({ bugReportEnabled: true });
for (const dest of footerUtilities(true)) {
const html = render();
for (const dest of footerUtilities()) {
expect(html).toContain(`href="${dest.href}"`);
}
// Bug Report and Settings are the footer utilities; both are present.
expect(html).toContain('href="/bugreport"');
// Settings is the footer utility entry; it is present.
expect(html).toContain('href="/settings/account"');
});
it("drops the bug-report utility entry when the operator flag is off", () => {
const html = render({ bugReportEnabled: false });
for (const dest of footerUtilities(false)) {
expect(html).toContain(`href="${dest.href}"`);
}
expect(html).not.toContain('href="/bugreport"');
});
});
describe("<SidebarNav> admin entry mirrors Settings (no sub-item expansion)", () => {
+2 -2
View File
@@ -261,8 +261,8 @@ export function AuthShell({
>
{/*
v1.4.33 IW9 — container normalised on `max-w-screen-xl`
(1280 px) so dashboard / settings / admin / bugreport all
hit the same content frame. Pre-v1.4.33 the dashboard
(1280 px) so dashboard / settings / admin all hit the
same content frame. Pre-v1.4.33 the dashboard
shell capped at `max-w-[76.8rem]` (1228 px) while the
settings/admin shells used `max-w-screen-xl`, producing a
52 px lateral wobble on every route switch. Same audit
+3 -6
View File
@@ -18,7 +18,6 @@ import {
} from "@/components/layout/nav-model";
import type { ModuleKey } from "@/lib/modules/registry";
import { useMemo, useState } from "react";
import { useAppSettings } from "@/components/app-settings-provider";
import { useAuth } from "@/hooks/use-auth";
import {
Sheet,
@@ -83,21 +82,19 @@ export function BottomNav() {
// v1.16.7 — touch intent on the medications tab starts the list
// request before the navigation commits (see sidebar-nav).
const medsIntent = medicationsPrefetchIntentProps(queryClient);
const { bugReportEnabled } = useAppSettings();
const [moreOpen, setMoreOpen] = useState(false);
const [captureOpen, setCaptureOpen] = useState(false);
// v1.17.1 (F-1) — the More hub is the model-computed hub: every visible
// feature destination that isn't a primary slot, plus the shared utility
// tail. Cycle + Bug Report are gated by the same flags the sidebar uses,
// so the two surfaces gate identically and cannot drift.
// tail. Cycle is gated by the same flag the sidebar uses, so the two
// surfaces gate identically and cannot drift.
const moreHub = useMemo<ReadonlyArray<NavLink>>(
() =>
mobileMoreHubDestinations({
modules: user?.modules,
bugReportEnabled,
}),
[user?.modules, bugReportEnabled],
[user?.modules],
);
// v1.18.0 — drop a module-gated primary slot (Insights) when the account
+17 -34
View File
@@ -1,7 +1,6 @@
import {
Activity,
Bell,
Bug,
Droplets,
FlaskConical,
Home,
@@ -168,17 +167,15 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
/**
* v1.17.1 (F-1 residue) — the shared UTILITY tail.
*
* Bug Report, Settings and Notifications are reachable on both bars but are
* not feature destinations: on desktop they live in the sidebar footer +
* avatar menu, on mobile at the tail of the "More" hub. They used to be a
* second hand-curated list on each bar — the exact drift the one-model
* contract above set out to kill, just pushed down a level. This list is
* the single source both bars consume for the tail, so the two surfaces
* can no longer disagree on which utility links exist or in which order.
* Settings and Notifications are reachable on both bars but are not feature
* destinations: on desktop they live in the sidebar footer + avatar menu, on
* mobile at the tail of the "More" hub. They used to be a second hand-curated
* list on each bar — the exact drift the one-model contract above set out to
* kill, just pushed down a level. This list is the single source both bars
* consume for the tail, so the two surfaces can no longer disagree on which
* utility links exist or in which order.
*
* Order is the footer/hub order: Bug Report (gated) → Settings →
* Notifications. `bugReportGated` entries drop when the operator flag is
* off, exactly as the feature list drops cycle-gated entries.
* Order is the footer/hub order: Settings → Notifications.
*
* Admin is intentionally NOT here: it is a role-gated, desktop-sidebar-only
* surface (the mobile bar never exposes it), so it is not a shared tail
@@ -189,33 +186,20 @@ export interface NavUtilityDestination {
/** i18n key under the `nav.*` namespace. */
tKey: string;
icon: LucideIcon;
/** Drop the entry unless the operator's bug-report flag is on. */
bugReportGated?: boolean;
}
export const NAV_UTILITY_DESTINATIONS: ReadonlyArray<NavUtilityDestination> = [
{
href: "/bugreport",
tKey: "nav.bugreport",
icon: Bug,
bugReportGated: true,
},
{ href: "/settings/account", tKey: "nav.settings", icon: Settings },
{ href: "/notifications", tKey: "nav.notifications", icon: Bell },
];
/**
* The utility tail visible to this account — drops the bug-report entry
* when the operator flag is off. Both bars consume this for their tail
* (the sidebar footer + avatar menu, the bottom-nav More hub) so the two
* The utility tail visible to this account. Both bars consume this for their
* tail (the sidebar footer + avatar menu, the bottom-nav More hub) so the two
* surfaces share one definition of the utility links.
*/
export function visibleUtilityDestinations(
bugReportEnabled: boolean | undefined,
): NavUtilityDestination[] {
return NAV_UTILITY_DESTINATIONS.filter(
(d) => !d.bugReportGated || bugReportEnabled === true,
);
export function visibleUtilityDestinations(): NavUtilityDestination[] {
return [...NAV_UTILITY_DESTINATIONS];
}
/**
@@ -276,19 +260,18 @@ export interface MobileMoreHubEntry {
/**
* The ordered "More" hub for the mobile bottom-nav: every visible feature
* destination that isn't a primary slot, in model order, followed by the
* shared utility tail (Bug Report behind its flag, Settings, Notifications).
* The desktop sidebar renders the same feature list inline and consumes the
* same utility tail in its footer + avatar menu, so the two bars cannot
* drift into two hand-curated lists.
* shared utility tail (Settings, Notifications). The desktop sidebar renders
* the same feature list inline and consumes the same utility tail in its
* footer + avatar menu, so the two bars cannot drift into two hand-curated
* lists.
*/
export function mobileMoreHubDestinations(opts: {
modules: ModuleVisibilityMap | undefined;
bugReportEnabled: boolean | undefined;
}): MobileMoreHubEntry[] {
const features = visibleNavDestinations(opts.modules)
.filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href))
.map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon }));
const tail = visibleUtilityDestinations(opts.bugReportEnabled).map((d) => ({
const tail = visibleUtilityDestinations().map((d) => ({
href: d.href,
tKey: d.tKey,
icon: d.icon,
+2 -6
View File
@@ -26,7 +26,6 @@ import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
import { useAuth, useLogout } from "@/hooks/use-auth";
import { useTheme } from "@/components/providers";
import { useAppSettings } from "@/components/app-settings-provider";
import { useTranslations } from "@/lib/i18n/context";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
@@ -229,7 +228,6 @@ export function SidebarNav() {
// list request before the navigation commits, so the due-time cells
// hydrate from a warm cache instead of serialising behind the chunk.
const medsIntent = medicationsPrefetchIntentProps(queryClient);
const { bugReportEnabled } = useAppSettings();
const isAdmin = user?.role === "ADMIN";
// Match `/admin` exactly or any `/admin/...` sub-route for active-link
// styling. Plain `startsWith("/admin")` would also flip for a
@@ -276,10 +274,8 @@ export function SidebarNav() {
// role-gated, sidebar-only surface and is inserted separately below.
const footerUtilityItems = useMemo(
() =>
visibleUtilityDestinations(bugReportEnabled).filter(
(d) => d.href !== "/notifications",
),
[bugReportEnabled],
visibleUtilityDestinations().filter((d) => d.href !== "/notifications"),
[],
);
function isUtilityActive(href: string) {
+6 -9
View File
@@ -17,7 +17,6 @@ import {
import { I18nProvider } from "@/lib/i18n/context";
import type { Locale } from "@/lib/i18n/config";
import { Toaster } from "@/components/ui/sonner";
import { AppSettingsProvider } from "@/components/app-settings-provider";
import { VersionPoller } from "@/components/version-poller";
import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar";
import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag";
@@ -211,14 +210,12 @@ export function Providers({
initialLocale={initialLocale}
initialMessages={initialMessages}
>
<AppSettingsProvider>
<QueryPersistenceBridge />
<DashboardSnapshotPreloader />
{children}
<Toaster position="bottom-right" richColors />
<VersionPoller />
<ServiceWorkerRegistrar />
</AppSettingsProvider>
<QueryPersistenceBridge />
<DashboardSnapshotPreloader />
{children}
<Toaster position="bottom-right" richColors />
<VersionPoller />
<ServiceWorkerRegistrar />
</I18nProvider>
</ThemeProvider>
</QueryClientProvider>
-13
View File
@@ -8,7 +8,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { __resetAllCachesForTests, cached, caches } from "../server-cache";
import {
invalidateAppSettings,
invalidateUserDashboardWidgets,
invalidateUserDashboardSnapshot,
invalidateUserInsights,
@@ -95,8 +94,6 @@ async function primeAllCaches(): Promise<void> {
dashboardSnapshotCacheKey(USER_B),
async () => ({ snap: 2 }),
);
await cached(caches.bugreportStatus, "singleton", async () => ({ s: 1 }));
}
describe("invalidateUserMeasurements", () => {
@@ -347,13 +344,3 @@ describe("invalidateUserProfile", () => {
expect(caches.medications.get(USER_A)).not.toBeNull();
});
});
describe("invalidateAppSettings", () => {
it("evicts the entire bug-report status cache (singleton row)", async () => {
await primeAllCaches();
invalidateAppSettings();
expect(caches.bugreportStatus.get("singleton")).toBeNull();
// Per-user caches untouched.
expect(caches.analytics.get(`${USER_A}|default`)).not.toBeNull();
});
});
-1
View File
@@ -616,7 +616,6 @@ describe("global cache registry", () => {
expect(caches.medications).toBeDefined();
expect(caches.achievements).toBeDefined();
expect(caches.dashboardWidgets).toBeDefined();
expect(caches.bugreportStatus).toBeDefined();
expect(caches.workouts).toBeDefined();
expect(caches.medicationsIntake).toBeDefined();
expect(caches.moodAnalytics).toBeDefined();
-13
View File
@@ -288,16 +288,3 @@ export function invalidateAllCaches(): void {
cache.deleteByPrefix("");
}
}
/**
* Global eviction of the bug-report-status cache.
*
* The cache is keyed on the singleton `"singleton"` slot because the
* underlying data is a row in `AppSettings`. Any admin write to the
* GitHub-token / bug-report-enabled fields invalidates the entire
* cache; the per-user `isAdmin` flag is layered on at request time, so
* the cache itself only stores role-agnostic shape.
*/
export function invalidateAppSettings(): void {
caches.bugreportStatus.deleteByPrefix("");
}
+1 -6
View File
@@ -428,7 +428,7 @@ export class ServerCache<T> {
*
* The blueprint's full eight-cache roster is provisioned so future
* rounds can wire the remaining routes (`/api/medications`,
* `/api/dashboard/widgets`, `/api/bugreport/status`, `/api/workouts`,
* `/api/dashboard/widgets`, `/api/workouts`,
* `/api/mood/analytics`) without churning the helper module.
*/
export const caches = {
@@ -511,11 +511,6 @@ export const caches = {
maxEntries: 500,
ttlMs: 300_000,
}),
bugreportStatus: new ServerCache<unknown>({
name: "bugreportStatus",
maxEntries: 10,
ttlMs: 600_000,
}),
workouts: new ServerCache<unknown>({
name: "workouts",
maxEntries: 1000,
-1
View File
@@ -90,7 +90,6 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
field: "webPushVapidPrivateKeyEncrypted",
kind: "string",
},
{ model: "AppSettings", field: "githubIssueTokenEncrypted", kind: "string" },
// ───── Custom labels (mood + cycle) ─────
{ model: "MoodTag", field: "labelEncrypted", kind: "string" },
-10
View File
@@ -156,16 +156,6 @@ async function ensureAppSettingsSchema() {
ALTER TABLE "app_settings"
ADD COLUMN IF NOT EXISTS "glitchtip_environment" TEXT;
`);
await prisma.$executeRawUnsafe(`
ALTER TABLE "app_settings"
ADD COLUMN IF NOT EXISTS "github_issue_token_encrypted" TEXT;
`);
await prisma.$executeRawUnsafe(`
ALTER TABLE "app_settings"
ADD COLUMN IF NOT EXISTS "github_issue_repo" TEXT;
`);
}
async function ensureMedicationSchema() {
-174
View File
@@ -1,174 +0,0 @@
/**
* GitHub publisher for feedback items.
*
* Centralises the GitHub Issues API interaction so /api/bugreport (legacy
* direct-submit) and /api/admin/feedback/[id]/github (admin-promotes-feedback)
* share one implementation. Reads the configured token + repo from AppSettings
* (encrypted) with env-var fallback.
*/
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
export interface FeedbackInput {
category: string;
subject: string;
description: string;
username: string;
metadata?: unknown;
}
export interface PublishResult {
issueNumber: number;
issueUrl: string;
}
export class GithubPublishError extends Error {
constructor(
message: string,
public status: number,
) {
super(message);
this.name = "GithubPublishError";
}
}
/**
* Resolves the configured GitHub token + repo from AppSettings (or env).
* Returns null if no configuration is present (caller should treat as 503).
*/
async function getGithubConfig(): Promise<{
token: string;
repo: string;
} | null> {
const appSettings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
githubIssueTokenEncrypted: true,
githubIssueRepo: true,
},
});
let configuredToken: string | null = null;
if (appSettings?.githubIssueTokenEncrypted) {
try {
configuredToken = decrypt(appSettings.githubIssueTokenEncrypted);
} catch {
getEvent()?.addWarning("Failed to decrypt GitHub issue token");
}
}
const token = configuredToken || process.env.GITHUB_ISSUE_TOKEN || "";
const repo =
appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO || "";
if (!token || !repo) return null;
return { token, repo };
}
/**
* Sanitise a free-text field for GitHub markdown (strip control chars,
* neutralise fenced code blocks, hard-cap length).
*/
function sanitize(text: string, max = 5000): string {
return text.replace(/```/g, "\\`\\`\\`").slice(0, max);
}
/**
* Escape a string for safe inclusion in a `RegExp(..)` constructor — the
* standard "don't let an attacker turn input into regex metacharacters"
* helper. Used to redact secrets from logged error bodies.
*/
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* Publishes a feedback item as a GitHub Issue. Returns issue number + URL.
* Throws GithubPublishError on API failure.
*/
export async function publishFeedbackToGithub(
input: FeedbackInput,
): Promise<PublishResult> {
const config = await getGithubConfig();
if (!config) {
throw new GithubPublishError(
"GitHub publishing is not configured (missing token or repository)",
503,
);
}
const now = new Date();
const dateStr = now.toISOString().replace("T", " ").slice(0, 16) + " UTC";
const safeUsername = input.username.replace(/[`*_~<>\[\]|]/g, "");
const safeSubject = sanitize(input.subject, 200);
const safeDescription = sanitize(input.description);
const categoryLabelMap: Record<string, string> = {
BUG: "bug",
FEATURE_REQUEST: "enhancement",
QUESTION: "question",
OTHER: "feedback",
};
const categoryLabel = categoryLabelMap[input.category] ?? "feedback";
let body = `**Reported by:** ${safeUsername}\n`;
body += `**Category:** ${input.category}\n`;
body += `**Date:** ${dateStr}\n\n`;
body += `## Description\n\n${safeDescription}\n`;
if (input.metadata && typeof input.metadata === "object") {
const meta = input.metadata as Record<string, unknown>;
const lines: string[] = [];
for (const [key, value] of Object.entries(meta)) {
if (value == null) continue;
const v = String(value).slice(0, 200);
lines.push(`- **${key}:** ${v.replace(/[`<>]/g, "")}`);
}
if (lines.length > 0) {
body += `\n## Context\n\n${lines.join("\n")}\n`;
}
}
body += `\n\n---\n*Created via HealthLog feedback*`;
const title = `[${categoryLabel}] ${safeSubject || `Feedback – ${dateStr}`}`;
const res = await safeFetch(
`https://api.github.com/repos/${config.repo}/issues`,
{
method: "POST",
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
Accept: "application/vnd.github.v3+json",
},
body: JSON.stringify({
title,
body,
labels: [categoryLabel, "user-reported"],
}),
},
);
if (!res.ok) {
const errBody = await res.text();
// Strip the GitHub PAT before logging — GitHub error responses can echo
// request headers in some failure modes, and Wide Events flow to Loki
// unredacted. Keep the response shape useful for debugging without
// leaking the credential.
const sanitisedErr = errBody
.replace(new RegExp(escapeRegex(config.token), "g"), "[REDACTED]")
.slice(0, 500);
getEvent()?.addWarning("GitHub issue creation failed: " + sanitisedErr);
throw new GithubPublishError(
`Failed to create GitHub issue (HTTP ${res.status})`,
res.status === 401 || res.status === 403 ? res.status : 502,
);
}
const issue = (await res.json()) as { number: number; html_url: string };
return { issueNumber: issue.number, issueUrl: issue.html_url };
}
@@ -25,7 +25,6 @@ const FULL_METRICS: AchievementMetrics = {
passkeyLoginCount: 1000,
passwordLoginCount: 1000,
loginDayStreak: 1000,
bugReportCount: 1000,
moodEntryCount: 1000,
moodDayStreak: 1000,
moodImprovementHit: 1000,
@@ -91,8 +90,8 @@ describe("gamification achievements", () => {
).toBe(3);
});
it("ships the expanded definition list (65 total — v1.4.18 + v1.16.1 care routine)", () => {
expect(ACHIEVEMENT_DEFINITIONS).toHaveLength(65);
it("ships the expanded definition list (63 total — v1.4.18 + v1.16.1 care routine)", () => {
expect(ACHIEVEMENT_DEFINITIONS).toHaveLength(63);
});
it("includes mood and hidden categories in the render order", () => {
@@ -103,8 +102,8 @@ describe("gamification achievements", () => {
it("evaluates unlocked achievements and points when all metrics maxed", () => {
const result = evaluateAchievementsWithCompletionDates(FULL_METRICS, {});
expect(result.summary.unlockedCount).toBe(65);
expect(result.summary.totalCount).toBe(65);
expect(result.summary.unlockedCount).toBe(63);
expect(result.summary.totalCount).toBe(63);
expect(result.summary.nextAchievement).toBeNull();
expect(result.summary.earnedPoints).toBe(result.summary.totalPoints);
});
@@ -598,7 +598,6 @@ export async function buildAchievementsResult(
in: [
"auth.login.passkey",
"auth.login.password",
"bugreport.submit",
// v1.4.18 — hidden Easter-egg triggers
"doctor-report.generate",
"doctor-report.pdf.generate",
@@ -700,9 +699,6 @@ export async function buildAchievementsResult(
const passwordLoginDates = auditEvents
.filter((event) => event.action === "auth.login.password")
.map((event) => event.createdAt);
const bugReportDates = auditEvents
.filter((event) => event.action === "bugreport.submit")
.map((event) => event.createdAt);
const loginDaySeries = getEventDaySeries([
...passkeyLoginDates,
...passwordLoginDates,
@@ -800,7 +796,6 @@ export async function buildAchievementsResult(
passkeyLoginCount: passkeyLoginDates.length,
passwordLoginCount: passwordLoginDates.length,
loginDayStreak: calculateLongestStreak(freeze(loginDaySeries.dayKeys)),
bugReportCount: bugReportDates.length,
...expansionValues,
// v1.16.1 — care-routine metrics.
missFreeDayStreak: calculateLongestStreak(freeze(missFreeSeries.dayKeys)),
@@ -848,15 +843,6 @@ export async function buildAchievementsResult(
continue;
}
if (definition.metric === "bugReportCount") {
const completedAt = findCountCompletionDate(
bugReportDates,
definition.target,
);
if (completedAt) completionDates[definition.id] = completedAt;
continue;
}
if (definition.metric === "overIntakeCount") {
const completedAt = findCountCompletionDate(
intakeIssueMetrics.overIntakeDates,
+2 -27
View File
@@ -15,7 +15,6 @@ export type AchievementMetricKey =
| "passkeyLoginCount"
| "passwordLoginCount"
| "loginDayStreak"
| "bugReportCount"
// v1.4.18 expansion ─ mood
| "moodEntryCount"
| "moodDayStreak"
@@ -84,7 +83,7 @@ export interface AchievementDefinition {
* v1.18.0 — module ownership for an achievement metric. Returns the
* `ModuleKey` whose enable/disable state governs the badge, or `null`
* when the badge belongs to a core domain (vitals, medications) or to
* an account-wide capability (security, login, bug reports) that has no
* an account-wide capability (security, login) that has no
* toggleable module behind it.
*
* The string literals here intentionally mirror `MODULE_KEYS` in
@@ -148,7 +147,6 @@ function categoryForMetric(metric: AchievementMetricKey): AchievementCategory {
case "passwordLoginCount":
return "security";
case "loginDayStreak":
case "bugReportCount":
case "consistentMonthCount":
case "entryDayStreak":
case "weekendStreakCount":
@@ -192,7 +190,6 @@ export interface AchievementMetrics {
passkeyLoginCount: number;
passwordLoginCount: number;
loginDayStreak: number;
bugReportCount: number;
moodEntryCount: number;
moodDayStreak: number;
moodImprovementHit: number;
@@ -386,16 +383,6 @@ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
tDescription: "achievements.badges.passwordLogin1.description",
format: "count",
}),
define({
id: "bugreport-1",
metric: "bugReportCount",
target: 1,
points: 30,
icon: "Bug",
tTitle: "achievements.badges.bugReport1.title",
tDescription: "achievements.badges.bugReport1.description",
format: "count",
}),
define({
id: "login-streak-7",
metric: "loginDayStreak",
@@ -668,17 +655,6 @@ export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
format: "count",
isHidden: true,
}),
define({
id: "hidden-bug-buddy",
metric: "bugReportCount",
target: 5,
points: 60,
icon: "Bug",
tTitle: "achievements.badges.hiddenBugBuddy.title",
tDescription: "achievements.badges.hiddenBugBuddy.description",
format: "count",
isHidden: true,
}),
// ─── v1.16.1 ─ care routine (6) ─────────────────────────
// Quiet, clinically meaningful milestones: therapy adherence without
// a single auto-missed dose, sustained measurement consistency, a
@@ -932,7 +908,7 @@ export function evaluateAchievementsWithCompletionDates(
* 2. Hidden Easter-eggs always stay (the "?" placeholder is the
* whole point).
* 3. Achievements without a metric-data precondition (security,
* bugreport, login-streaks) stay because the precondition is
* login-streaks) stay because the precondition is
* "the user has an account", which is implicit.
*/
export function applyDiscoveryFilter(
@@ -977,7 +953,6 @@ function isEarnable(
case "passkeyLoginCount":
case "passwordLoginCount":
case "loginDayStreak":
case "bugReportCount":
case "consistentMonthCount":
case "entryDayStreak":
case "weekendStreakCount":
-2
View File
@@ -41,6 +41,4 @@ export const settingsKeys = {
shareLinks: () => ["share-links"] as const,
featureFlags: () => ["feature-flags"] as const,
bugreportStatus: () => ["bugreport", "status"] as const,
};
-14
View File
@@ -62,20 +62,6 @@ export const adminSettingsSchema = z
)
.optional(),
glitchtipEnvironment: z.string().optional(),
bugReportRepo: z
.string()
.refine(
(v) => {
const trimmed = v.trim();
if (!trimmed) return true;
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(trimmed);
},
{ message: "GitHub repository must be in owner/repo format" },
)
.optional(),
bugReportToken: z.string().optional(),
clearBugReportToken: z.boolean().optional(),
bugReportEnabled: z.boolean().optional(),
reminderLateMinutes: z.number().int().min(15).max(480).optional(),
reminderMissedMinutes: z.number().int().min(30).max(720).optional(),
moodLogGlobal: z.boolean().optional(),
-44
View File
@@ -1,44 +0,0 @@
import { z } from "zod/v4";
export const feedbackCategoryEnum = z.enum([
"BUG",
"FEATURE_REQUEST",
"QUESTION",
"OTHER",
]);
export const feedbackStatusEnum = z.enum([
"OPEN",
"ACKNOWLEDGED",
"RESOLVED",
"ARCHIVED",
]);
// Strictly PNG / JPEG / WEBP / GIF data URLs. SVG intentionally excluded —
// it can carry inline JS that would execute when the admin inbox renders the
// preview.
const SCREENSHOT_DATA_URL =
/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/;
export const createFeedbackSchema = z.object({
category: feedbackCategoryEnum.default("BUG"),
subject: z.string().min(3).max(200),
description: z.string().min(10).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(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
export const updateFeedbackSchema = z.object({
status: feedbackStatusEnum.optional(),
adminNote: z.string().max(5000).nullable().optional(),
});
export type CreateFeedbackPayload = z.infer<typeof createFeedbackSchema>;
export type UpdateFeedbackPayload = z.infer<typeof updateFeedbackSchema>;
-2
View File
@@ -143,8 +143,6 @@ const LEGACY_ADMIN_ANCHORS: Record<string, string> = {
"/admin/section-admin-umami": "/admin/integrations",
"/admin/section-admin-glitchtip": "/admin/integrations",
"/admin/section-admin-webpush": "/admin/integrations",
"/admin/section-admin-bugreport": "/admin/integrations",
"/admin/section-admin-feedback": "/admin/feedback",
"/admin/section-admin-reminders": "/admin/reminders",
"/admin/section-user-management": "/admin/users",
"/admin/section-api-tokens": "/admin/api-tokens",