mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
merge: remove the bug-report integration for v1.23
# Conflicts: # messages/de.json # messages/en.json # messages/es.json # messages/fr.json # messages/it.json # messages/pl.json # src/app/admin/[section]/renderer.tsx
This commit is contained in:
commit
b271a39fe4
68 files changed
+115
-3344
No files matched your search
@@ -137,7 +137,6 @@ const mockData = {
|
||||
passkeyLoginCount: 0,
|
||||
passwordLoginCount: 0,
|
||||
loginDayStreak: 0,
|
||||
bugReportCount: 0,
|
||||
moodEntryCount: 0,
|
||||
moodDayStreak: 0,
|
||||
moodImprovementHit: 0,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* PATCH /api/admin/feedback/[id] — change status or admin note.
|
||||
* DELETE /api/admin/feedback/[id] — archive (soft: sets status to ARCHIVED).
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
getClientIp,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { updateFeedbackSchema } from "@/lib/validations/feedback";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
|
||||
const { user } = await requireAdmin();
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = updateFeedbackSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const before = await prisma.feedback.findUnique({ where: { id } });
|
||||
if (!before) return apiError("Feedback not found", 404);
|
||||
|
||||
const updated = await prisma.feedback.update({
|
||||
where: { id },
|
||||
data: parsed.data,
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
adminNote: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("admin.feedback.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
feedbackId: id,
|
||||
before: { status: before.status, adminNote: before.adminNote },
|
||||
after: parsed.data,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.feedback.update" },
|
||||
meta: { feedback_id: id },
|
||||
});
|
||||
|
||||
return apiSuccess(updated);
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, ctx: { params: Promise<{ id: string }> }) => {
|
||||
const { user } = await requireAdmin();
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const updated = await prisma.feedback.update({
|
||||
where: { id },
|
||||
data: { status: "ARCHIVED" },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
|
||||
await auditLog("admin.feedback.archive", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { feedbackId: id },
|
||||
});
|
||||
|
||||
return apiSuccess(updated);
|
||||
},
|
||||
);
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Admin feedback inbox. Lists all feedback with status filter.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { NextRequest } from "next/server";
|
||||
import type { FeedbackStatus } from "@/generated/prisma/client";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
await requireAdmin();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const status = url.searchParams.get("status") as FeedbackStatus | null;
|
||||
const limit = Math.min(
|
||||
100,
|
||||
Math.max(1, Number(url.searchParams.get("limit") ?? 50)),
|
||||
);
|
||||
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0));
|
||||
|
||||
const where = status ? { status } : {};
|
||||
|
||||
const [items, total, counts] = await Promise.all([
|
||||
prisma.feedback.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
email: true,
|
||||
category: true,
|
||||
subject: true,
|
||||
description: true,
|
||||
status: true,
|
||||
adminNote: true,
|
||||
gitHubIssueUrl: true,
|
||||
metadata: true,
|
||||
screenshotBase64: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
}),
|
||||
prisma.feedback.count({ where }),
|
||||
prisma.feedback.groupBy({
|
||||
by: ["status"],
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
annotate({
|
||||
action: { name: "admin.feedback.list" },
|
||||
meta: { total, filtered_status: status },
|
||||
});
|
||||
|
||||
const countsByStatus = Object.fromEntries(
|
||||
counts.map((c) => [c.status, c._count._all]),
|
||||
) as Record<FeedbackStatus, number>;
|
||||
|
||||
return apiSuccess({
|
||||
items,
|
||||
meta: { total, limit, offset, countsByStatus },
|
||||
});
|
||||
});
|
||||
@@ -98,7 +98,6 @@ describe("GET /api/admin/settings", () => {
|
||||
expect(body.data.registrationEnabled).toBe(true);
|
||||
expect(body.data.defaultLocale).toBe("de");
|
||||
expect(body.data.webPushVapidConfigured).toBe(false);
|
||||
expect(body.data.bugReportConfigured).toBe(false);
|
||||
expect(body.data.glitchtipEnabled).toBe(false);
|
||||
});
|
||||
|
||||
@@ -120,8 +119,6 @@ describe("GET /api/admin/settings", () => {
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: "production",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "token-blob",
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
@@ -129,15 +126,12 @@ describe("GET /api/admin/settings", () => {
|
||||
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
// The two configured-flags must be true …
|
||||
// The configured flag must be true …
|
||||
expect(body.data.webPushVapidConfigured).toBe(true);
|
||||
expect(body.data.bugReportConfigured).toBe(true);
|
||||
// … but raw encrypted blobs must never reach the client.
|
||||
const serialized = JSON.stringify(body.data);
|
||||
expect(serialized).not.toContain("secret-blob");
|
||||
expect(serialized).not.toContain("token-blob");
|
||||
expect(body.data).not.toHaveProperty("webPushVapidPrivateKeyEncrypted");
|
||||
expect(body.data).not.toHaveProperty("githubIssueTokenEncrypted");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -185,8 +179,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: "production",
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
@@ -227,8 +219,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
@@ -257,8 +247,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
@@ -292,8 +280,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnabled: true,
|
||||
glitchtipDsn: "https://abc@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "staging",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "enc(gh-token)",
|
||||
reminderLateMinutes: 30,
|
||||
reminderMissedMinutes: 90,
|
||||
moodLogGlobal: false,
|
||||
@@ -317,8 +303,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
umamiWebsiteId: "site-uuid",
|
||||
glitchtipDsn: "https://abc@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "staging",
|
||||
bugReportRepo: "owner/repo",
|
||||
bugReportToken: "gh-token",
|
||||
reminderLateMinutes: 30,
|
||||
reminderMissedMinutes: 90,
|
||||
}),
|
||||
@@ -336,8 +320,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
umamiWebsiteId: "site-uuid",
|
||||
glitchtipDsn: "https://abc@glitchtip.example.com/1",
|
||||
glitchtipEnvironment: "staging",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "enc(gh-token)",
|
||||
webPushVapidPublicKey: "vapid-pub",
|
||||
webPushVapidSubject: "mailto:ops@healthlog.dev",
|
||||
webPushVapidPrivateKeyEncrypted: "enc(secret)",
|
||||
@@ -345,7 +327,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
reminderMissedMinutes: 90,
|
||||
});
|
||||
expect(encrypt).toHaveBeenCalledWith("secret");
|
||||
expect(encrypt).toHaveBeenCalledWith("gh-token");
|
||||
});
|
||||
|
||||
it("clears nullable string fields when an empty string is submitted", async () => {
|
||||
@@ -366,8 +347,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
@@ -381,7 +360,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
umamiWebsiteId: "",
|
||||
glitchtipDsn: "",
|
||||
glitchtipEnvironment: "",
|
||||
bugReportRepo: "",
|
||||
}),
|
||||
);
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
@@ -392,39 +370,9 @@ describe("PUT /api/admin/settings", () => {
|
||||
umamiWebsiteId: null,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the encrypted bug-report token when clear flag is set", async () => {
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
id: "singleton",
|
||||
registrationEnabled: true,
|
||||
defaultLocale: "en",
|
||||
telegramGlobal: true,
|
||||
ntfyGlobal: true,
|
||||
webPushGlobal: true,
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidSubject: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
apiGlobal: true,
|
||||
umamiEnabled: false,
|
||||
umamiScriptUrl: null,
|
||||
umamiWebsiteId: null,
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
} as never);
|
||||
await PUT(jsonReq({ clearBugReportToken: true }));
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
|
||||
expect(upsert?.update).toEqual({ githubIssueTokenEncrypted: null });
|
||||
});
|
||||
|
||||
it("returns 415 when content-type is not JSON", async () => {
|
||||
const r = new NextRequest("http://localhost/api/admin/settings", {
|
||||
method: "PUT",
|
||||
@@ -454,9 +402,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
bugReportEnabled: true,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
@@ -486,9 +431,6 @@ describe("PUT /api/admin/settings", () => {
|
||||
glitchtipEnabled: false,
|
||||
glitchtipDsn: null,
|
||||
glitchtipEnvironment: null,
|
||||
githubIssueRepo: null,
|
||||
githubIssueTokenEncrypted: null,
|
||||
bugReportEnabled: true,
|
||||
reminderLateMinutes: 120,
|
||||
reminderMissedMinutes: 240,
|
||||
moodLogGlobal: true,
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
invalidateServerDefaultTimezone,
|
||||
isValidTimezone,
|
||||
} from "@/lib/tz/resolver";
|
||||
import { invalidateAppSettings } from "@/lib/cache/invalidate";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -48,11 +47,6 @@ export const GET = apiHandler(async () => {
|
||||
glitchtipEnabled: settings?.glitchtipEnabled ?? false,
|
||||
glitchtipDsn: settings?.glitchtipDsn ?? null,
|
||||
glitchtipEnvironment: settings?.glitchtipEnvironment ?? "production",
|
||||
bugReportRepo: settings?.githubIssueRepo ?? null,
|
||||
bugReportConfigured: Boolean(
|
||||
settings?.githubIssueRepo && settings?.githubIssueTokenEncrypted,
|
||||
),
|
||||
bugReportEnabled: settings?.bugReportEnabled ?? true,
|
||||
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
|
||||
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
|
||||
moodLogGlobal: settings?.moodLogGlobal ?? true,
|
||||
@@ -92,7 +86,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
"umamiEnabled",
|
||||
"glitchtipEnabled",
|
||||
"moodLogGlobal",
|
||||
"bugReportEnabled",
|
||||
] as const;
|
||||
for (const field of booleanFields) {
|
||||
if (data[field] !== undefined) {
|
||||
@@ -171,24 +164,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
auditDetails.glitchtipEnvironment = value || null;
|
||||
}
|
||||
|
||||
// Bug report
|
||||
if (data.bugReportRepo !== undefined) {
|
||||
const repo = data.bugReportRepo.trim();
|
||||
updates.githubIssueRepo = repo || null;
|
||||
auditDetails.bugReportRepo = repo || null;
|
||||
}
|
||||
if (data.bugReportToken !== undefined) {
|
||||
const token = data.bugReportToken.trim();
|
||||
if (token) {
|
||||
updates.githubIssueTokenEncrypted = encrypt(token);
|
||||
auditDetails.bugReportTokenUpdated = true;
|
||||
}
|
||||
}
|
||||
if (data.clearBugReportToken === true) {
|
||||
updates.githubIssueTokenEncrypted = null;
|
||||
auditDetails.bugReportTokenUpdated = false;
|
||||
}
|
||||
|
||||
// Numeric thresholds
|
||||
if (data.reminderLateMinutes !== undefined) {
|
||||
updates.reminderLateMinutes = data.reminderLateMinutes;
|
||||
@@ -233,11 +208,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
invalidateServerDefaultTimezone();
|
||||
}
|
||||
|
||||
// v1.4.34 IW-G — bust the bug-report status cache (global singleton)
|
||||
// so the next read reflects the new GitHub-token / bug-report-enabled
|
||||
// shape. Cheap call — the cache holds at most 10 entries.
|
||||
invalidateAppSettings();
|
||||
|
||||
await auditLog("admin.settings.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
@@ -264,11 +234,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
glitchtipEnabled: settings.glitchtipEnabled,
|
||||
glitchtipDsn: settings.glitchtipDsn,
|
||||
glitchtipEnvironment: settings.glitchtipEnvironment ?? "production",
|
||||
bugReportRepo: settings.githubIssueRepo,
|
||||
bugReportConfigured: Boolean(
|
||||
settings.githubIssueRepo && settings.githubIssueTokenEncrypted,
|
||||
),
|
||||
bugReportEnabled: settings.bugReportEnabled,
|
||||
reminderLateMinutes: settings.reminderLateMinutes,
|
||||
reminderMissedMinutes: settings.reminderMissedMinutes,
|
||||
moodLogGlobal: settings.moodLogGlobal,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { invalidateAppSettings } from "@/lib/cache/invalidate";
|
||||
import { z } from "zod/v4";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -114,8 +113,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
});
|
||||
|
||||
invalidateAppSettings();
|
||||
|
||||
await auditLog("admin.web_push_vapid.generate", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@@ -89,11 +89,10 @@ describe("GET /api/admin/status (integration health summary)", () => {
|
||||
umami: null,
|
||||
glitchtip: null,
|
||||
webPush: null,
|
||||
bugReport: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("aggregates configured + enabled flags for umami / glitchtip / web-push / bugReport", async () => {
|
||||
it("aggregates configured + enabled flags for umami / glitchtip / web-push", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
umamiScriptUrl: "https://x/script.js",
|
||||
umamiWebsiteId: "site-1",
|
||||
@@ -103,8 +102,6 @@ describe("GET /api/admin/status (integration health summary)", () => {
|
||||
webPushVapidPublicKey: "pub",
|
||||
webPushVapidPrivateKeyEncrypted: "secret-blob",
|
||||
webPushVapidSubject: "mailto:a@b.com",
|
||||
githubIssueRepo: "owner/repo",
|
||||
githubIssueTokenEncrypted: "token-blob",
|
||||
} as never);
|
||||
|
||||
const res = await GET();
|
||||
@@ -121,7 +118,6 @@ describe("GET /api/admin/status (integration health summary)", () => {
|
||||
enabled: false,
|
||||
});
|
||||
expect(body.data.integrations.webPush).toEqual({ configured: true });
|
||||
expect(body.data.integrations.bugReport).toEqual({ configured: true });
|
||||
|
||||
// Regression: encrypted blobs MUST never appear in the response envelope.
|
||||
const serialized = JSON.stringify(body);
|
||||
|
||||
@@ -56,10 +56,6 @@ export const GET = apiHandler(async () => {
|
||||
appSettings?.webPushVapidPrivateKeyEncrypted &&
|
||||
appSettings?.webPushVapidSubject,
|
||||
);
|
||||
const bugReportConfigured = Boolean(
|
||||
appSettings?.githubIssueRepo && appSettings?.githubIssueTokenEncrypted,
|
||||
);
|
||||
|
||||
const workerStatus = getWorkerStatus();
|
||||
|
||||
return apiSuccess({
|
||||
@@ -89,7 +85,6 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
: null,
|
||||
webPush: webPushConfigured ? { configured: true } : null,
|
||||
bugReport: bugReportConfigured ? { configured: true } : null,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/bugreport.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: (s: string) => s.replace(/^enc-/, ""),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
|
||||
function postReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/bugreport", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
githubIssueTokenEncrypted: "enc-token",
|
||||
githubIssueRepo: "owner/repo",
|
||||
bugReportEnabled: true,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("POST /api/bugreport — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// description too short + screenshot doesn't match data-URL regex.
|
||||
const res = await POST(
|
||||
postReq({ description: "tiny", screenshot: "not-a-data-url" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces multiple simultaneous validation errors (≥ 2)", async () => {
|
||||
// Schema has only two knobs (description, screenshot). Pin contract
|
||||
// on ≥ 2.
|
||||
const res = await POST(postReq({ description: 123, screenshot: 999 }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
// Strict image/* data URL (no SVG — it can carry inline JS).
|
||||
const SCREENSHOT_DATA_URL =
|
||||
/^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/;
|
||||
|
||||
const bugReportSchema = z.object({
|
||||
description: z.string().min(10, "Description too short").max(5000),
|
||||
screenshot: z
|
||||
.string()
|
||||
.max(7_000_000, "Screenshot too large (max 5 MB)")
|
||||
.regex(
|
||||
SCREENSHOT_DATA_URL,
|
||||
"Screenshot must be a base64-encoded PNG/JPEG/WEBP/GIF data URL",
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`bugreport:${user.id}`, 3, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 3 bug reports per hour", 429);
|
||||
}
|
||||
|
||||
annotate({ action: { name: "bugreport.submit" } });
|
||||
|
||||
const appSettings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
githubIssueTokenEncrypted: true,
|
||||
githubIssueRepo: true,
|
||||
bugReportEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
// v1.4.3: explicit toggle. When the admin disables bug reports we
|
||||
// return 503 immediately — the client already renders a "not
|
||||
// configured" UI for that status, so the report button gracefully
|
||||
// disappears without needing additional client-side wiring.
|
||||
if (appSettings && appSettings.bugReportEnabled === false) {
|
||||
return apiError("Bug reports are disabled by the administrator", 503);
|
||||
}
|
||||
|
||||
let configuredToken: string | null = null;
|
||||
if (appSettings?.githubIssueTokenEncrypted) {
|
||||
try {
|
||||
configuredToken = decrypt(appSettings.githubIssueTokenEncrypted);
|
||||
} catch {
|
||||
getEvent()?.addWarning("Failed to decrypt GitHub issue token");
|
||||
}
|
||||
}
|
||||
|
||||
const ghToken = configuredToken || process.env.GITHUB_ISSUE_TOKEN;
|
||||
const ghRepo = appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO; // e.g. "owner/repo"
|
||||
|
||||
if (!ghToken || !ghRepo) {
|
||||
// 503 instead of 500: the client keys off status to render the "not
|
||||
// configured" screen. The user also sees a clear message rather than
|
||||
// "Internal server error".
|
||||
return apiError(
|
||||
"Bug report not configured (GitHub issue token/repository missing in admin settings)",
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 8 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = bugReportSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { description, screenshot } = parsed.data;
|
||||
|
||||
// GitHub issues are maintainer-facing and the repo's primary language is English.
|
||||
// Use an ISO-8601 timestamp so titles are unambiguous and sortable.
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().replace("T", " ").slice(0, 16) + " UTC";
|
||||
const title = `Bug Report – ${dateStr}`;
|
||||
|
||||
// Sanitize user-provided content for GitHub markdown. Strip HTML tags
|
||||
// entirely (prevents `<img onerror=>` etc.) and neutralise markdown link
|
||||
// targets that start with dangerous schemes like javascript: or data:.
|
||||
const safeUsername = user.username.replace(/[`*_~<>\[\]|]/g, "");
|
||||
const safeDescription = description
|
||||
.replace(/<[^>]*>/g, "") // strip HTML tags
|
||||
.replace(/\]\(\s*(javascript|data|vbscript|file):/gi, "](about:blank#") // neutralise dangerous markdown link schemes
|
||||
.replace(/```/g, "\\`\\`\\`")
|
||||
.slice(0, 5000);
|
||||
|
||||
// Wrap the user's description in a fenced code block so any residual
|
||||
// markdown renders literally rather than as executable markup.
|
||||
let issueBody = `**Reported by:** ${safeUsername}\n`;
|
||||
issueBody += `**Date:** ${dateStr}\n\n`;
|
||||
issueBody += `## Description\n\n\`\`\`\n${safeDescription}\n\`\`\`\n`;
|
||||
issueBody += `\n---\n*Created via HealthLog bug report*`;
|
||||
|
||||
// If screenshot provided, upload to GitHub as a gist or include as base64
|
||||
// For simplicity, we'll note there's a screenshot but GitHub issues don't support inline base64
|
||||
if (screenshot) {
|
||||
issueBody += `\n\n**Screenshot:** A screenshot was attached (see comment).`;
|
||||
}
|
||||
|
||||
// Create the issue
|
||||
const issueRes = await safeFetch(
|
||||
`https://api.github.com/repos/${ghRepo}/issues`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${ghToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: `[Bug] ${title}`,
|
||||
body: issueBody,
|
||||
labels: ["bug", "user-reported"],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!issueRes.ok) {
|
||||
const errBody = await issueRes.text();
|
||||
getEvent()?.addWarning("GitHub issue creation failed: " + errBody);
|
||||
return apiError("Failed to create issue", 500);
|
||||
}
|
||||
|
||||
const issue = (await issueRes.json()) as {
|
||||
number: number;
|
||||
html_url: string;
|
||||
};
|
||||
|
||||
// If screenshot, upload as a comment
|
||||
if (screenshot) {
|
||||
// Extract just the base64 part
|
||||
const base64Data = screenshot.replace(/^data:image\/\w+;base64,/, "");
|
||||
const ext = screenshot.startsWith("data:image/png") ? "png" : "jpg";
|
||||
|
||||
// Upload screenshot as issue comment with embedded image
|
||||
// GitHub doesn't support base64 in comments, so we note the limitation
|
||||
const commentBody = `### Screenshot\n\n}...)\n\n*Note: The full screenshot was truncated due to size limits. Please contact the user directly.*`;
|
||||
|
||||
await safeFetch(
|
||||
`https://api.github.com/repos/${ghRepo}/issues/${issue.number}/comments`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${ghToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
},
|
||||
body: JSON.stringify({ body: commentBody }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await auditLog("bugreport.submit", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
issueNumber: issue.number,
|
||||
hasScreenshot: Boolean(screenshot),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({ meta: { issue_number: issue.number } });
|
||||
|
||||
return apiSuccess({
|
||||
issueNumber: issue.number,
|
||||
issueUrl: issue.html_url,
|
||||
});
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Returns whether bug report submission is configured (GitHub token + repo).
|
||||
*
|
||||
* The bug report page uses this to gate its UI so users don't hit a silent
|
||||
* 500 when the admin hasn't set up the GitHub integration. Only two booleans
|
||||
* escape: a single `configured` flag and `isAdmin` so the page can render
|
||||
* the admin-specific copy + admin link. Token/repo granularity is kept
|
||||
* server-side (log annotation only) so we don't hand regular users a probe.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
|
||||
interface BugreportStatusCacheValue {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
hasToken: boolean;
|
||||
hasRepo: boolean;
|
||||
}
|
||||
|
||||
async function buildBugreportStatusValue(): Promise<BugreportStatusCacheValue> {
|
||||
const appSettings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
githubIssueTokenEncrypted: true,
|
||||
githubIssueRepo: true,
|
||||
bugReportEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const hasToken = Boolean(
|
||||
appSettings?.githubIssueTokenEncrypted || process.env.GITHUB_ISSUE_TOKEN,
|
||||
);
|
||||
const hasRepo = Boolean(
|
||||
appSettings?.githubIssueRepo || process.env.GITHUB_ISSUE_REPO,
|
||||
);
|
||||
const configured = hasToken && hasRepo;
|
||||
// Default ON when the column has never been written.
|
||||
const enabled = appSettings?.bugReportEnabled !== false;
|
||||
|
||||
return { configured, enabled, hasToken, hasRepo };
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// No per-request rate limit: the payload rides the singleton server
|
||||
// cache below, so a cache hit costs zero Postgres round-trips — while
|
||||
// the former rate-limit bucket was an unconditional Postgres UPSERT
|
||||
// per request, i.e. the limiter WAS the per-request DB load it claimed
|
||||
// to prevent. The route stays authenticated; the only uncached work is
|
||||
// one `appSettings` read per 10 min (the bucket's TTL) process-wide.
|
||||
|
||||
// Cache the global app-settings shape on a singleton key (per blueprint §3).
|
||||
// `isAdmin` lives outside the cache because it varies per request. The
|
||||
// `invalidateAppSettings()` helper called from
|
||||
// `PATCH /api/admin/app-settings` evicts this entry when bug-report
|
||||
// configuration changes.
|
||||
const status = await cached(
|
||||
caches.bugreportStatus as ServerCache<BugreportStatusCacheValue>,
|
||||
"singleton",
|
||||
() => buildBugreportStatusValue(),
|
||||
annotate,
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "bugreport.status" },
|
||||
meta: {
|
||||
configured: status.configured,
|
||||
enabled: status.enabled,
|
||||
has_token: status.hasToken,
|
||||
has_repo: status.hasRepo,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
configured: status.configured,
|
||||
enabled: status.enabled,
|
||||
isAdmin: user.role === "ADMIN",
|
||||
});
|
||||
});
|
||||
@@ -1,155 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1", email: "test@example.com", role: "USER" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
feedback: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
function jsonRequest(body: unknown): Request {
|
||||
return new Request("http://localhost/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/feedback bug-report toggle gate", () => {
|
||||
it("returns 503 when bugReportEnabled is false", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
bugReportEnabled: false,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "test",
|
||||
description: "Anything at least ten characters long.",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(503);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toMatch(/disabled/i);
|
||||
expect(prisma.feedback.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates feedback when bugReportEnabled is true", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
bugReportEnabled: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.feedback.create).mockResolvedValue({
|
||||
id: "fb-1",
|
||||
createdAt: new Date(),
|
||||
category: "BUG",
|
||||
status: "NEW",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "test",
|
||||
description: "Anything at least ten characters long.",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.feedback.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("creates feedback when bugReportEnabled has never been written (default ON)", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.feedback.create).mockResolvedValue({
|
||||
id: "fb-2",
|
||||
createdAt: new Date(),
|
||||
category: "BUG",
|
||||
status: "NEW",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "test",
|
||||
description: "Anything at least ten characters long.",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.feedback.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/feedback — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
bugReportEnabled: true,
|
||||
} as never);
|
||||
// subject too short + description too short.
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "x",
|
||||
description: "short",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
error: string;
|
||||
details: {
|
||||
issues: Array<{ path: string; code: string; message: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data).toBeNull();
|
||||
expect(body.error).toBe("Validation failed");
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
|
||||
for (const issue of body.details.issues) {
|
||||
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
bugReportEnabled: true,
|
||||
} as never);
|
||||
// bad subject + bad description + bad screenshot.
|
||||
const res = await POST(
|
||||
jsonRequest({
|
||||
category: "BUG",
|
||||
subject: "x",
|
||||
description: "short",
|
||||
screenshot: "not-a-data-url",
|
||||
}) as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
};
|
||||
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
/**
|
||||
* POST /api/feedback — user submits feedback. Always works, no GitHub needed.
|
||||
* GET /api/feedback — user's own feedback history (paginated).
|
||||
*
|
||||
* Rate-limited at 5 per hour per user. Screenshots are stored as base64 in
|
||||
* the database (≤5 MB each).
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
getClientIp,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createFeedbackSchema } from "@/lib/validations/feedback";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`feedback:${user.id}`, 5, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many submissions — please try again later", 429);
|
||||
}
|
||||
|
||||
// The admin "Bug reports" toggle gates *every* feedback submission, not
|
||||
// just the GitHub-promotion path. Previously the gate only lived in the
|
||||
// legacy `/api/bugreport` route which the form does not even call —
|
||||
// so flipping the toggle off had no effect on user-visible behaviour.
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { bugReportEnabled: true },
|
||||
});
|
||||
if (settings && settings.bugReportEnabled === false) {
|
||||
return apiError("Bug reports are disabled by the administrator", 503);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = createFeedbackSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
// v1.4.43 W6 — multi-issue 422.
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const feedback = await prisma.feedback.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
email: user.email ?? null,
|
||||
category: parsed.data.category,
|
||||
subject: parsed.data.subject,
|
||||
description: parsed.data.description,
|
||||
screenshotBase64: parsed.data.screenshot ?? null,
|
||||
metadata:
|
||||
parsed.data.metadata !== undefined
|
||||
? (parsed.data.metadata as Prisma.InputJsonValue)
|
||||
: Prisma.JsonNull,
|
||||
},
|
||||
select: { id: true, createdAt: true, category: true, status: true },
|
||||
});
|
||||
|
||||
await auditLog("feedback.submit", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
feedbackId: feedback.id,
|
||||
category: feedback.category,
|
||||
hasScreenshot: Boolean(parsed.data.screenshot),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "feedback.submit" },
|
||||
meta: {
|
||||
feedback_id: feedback.id,
|
||||
category: feedback.category,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(feedback, 201);
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const limit = Math.min(
|
||||
50,
|
||||
Math.max(1, Number(url.searchParams.get("limit") ?? 25)),
|
||||
);
|
||||
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0));
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.feedback.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
category: true,
|
||||
subject: true,
|
||||
status: true,
|
||||
adminNote: true,
|
||||
gitHubIssueUrl: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.feedback.count({ where: { userId: user.id } }),
|
||||
]);
|
||||
|
||||
return apiSuccess({ items, meta: { total, limit, offset } });
|
||||
});
|
||||
@@ -190,7 +190,7 @@ describe("GET /api/gamification/achievements — hidden achievement redaction",
|
||||
for (const entry of hiddenLocked) {
|
||||
// Trigger semantics scrubbed
|
||||
expect(entry.metric ?? "").not.toMatch(
|
||||
/(nightOwl|earlyBird|leapDay|doctorPdf|localeFlip|bugReport)Count/,
|
||||
/(nightOwl|earlyBird|leapDay|doctorPdf|localeFlip)Count/,
|
||||
);
|
||||
// i18n keys scrubbed
|
||||
expect(entry.titleKey ?? "").not.toContain("hiddenNightOwl");
|
||||
|
||||
@@ -66,12 +66,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
// Destroy all sessions first
|
||||
await destroyAllSessions(userId);
|
||||
|
||||
// Audit V3 NEW-V3-2 / GDPR Art. 17 fix: Feedback and AuditLog rows have
|
||||
// `onDelete: SetNull` in the schema, which keeps PII (free-text symptom
|
||||
// descriptions, IP addresses, login city geo) attached to the record after
|
||||
// the user is deleted. We explicitly purge them inside the same logical
|
||||
// operation so account deletion is genuinely complete erasure.
|
||||
await prisma.feedback.deleteMany({ where: { userId } });
|
||||
// Audit V3 NEW-V3-2 / GDPR Art. 17 fix: AuditLog rows have
|
||||
// `onDelete: SetNull` in the schema, which keeps PII (IP addresses, login
|
||||
// city geo) attached to the record after the user is deleted. We explicitly
|
||||
// purge them inside the same logical operation so account deletion is
|
||||
// genuinely complete erasure.
|
||||
await prisma.auditLog.deleteMany({ where: { userId } });
|
||||
|
||||
// Delete user — all other related data is removed via onDelete: Cascade
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>{" "}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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,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)", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Vendored
-13
@@ -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("");
|
||||
}
|
||||
Vendored
+1
-6
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -41,6 +41,4 @@ export const settingsKeys = {
|
||||
shareLinks: () => ["share-links"] as const,
|
||||
|
||||
featureFlags: () => ["feature-flags"] as const,
|
||||
|
||||
bugreportStatus: () => ["bugreport", "status"] as const,
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>;
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user