feat: in-app support form with email + Gitea issue integration (v0.49.0)
Users can now report bugs, suggestions, or questions from a new /support page. Each submission sends a confirmation email and, when GITEA_URL/ GITEA_TOKEN/GITEA_REPO are configured in admin Settings, opens a labeled issue on that repo automatically (best-effort — failure doesn't block the ticket). Admins get a Support section to triage status and retry failed Gitea issue creation. New support_tickets table, gitea.ts client, site-settings entries for the three new secrets, and OpenAPI docs for the two user-facing endpoints. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { getSiteSetting } from "@/lib/site-settings";
|
||||
|
||||
const LABELS: Record<string, string[]> = {
|
||||
bug: ["bug"],
|
||||
suggestion: ["enhancement"],
|
||||
question: ["question"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens an issue on the configured Gitea repo for a support ticket. Returns
|
||||
* the issue's HTML URL on success, or null if Gitea isn't configured or the
|
||||
* request fails — callers must treat this as best-effort, not block on it,
|
||||
* since the ticket itself is already saved regardless.
|
||||
*/
|
||||
export async function createGiteaIssue(opts: {
|
||||
type: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}): Promise<{ url: string | null; error: string | null }> {
|
||||
const [baseUrl, token, repo] = await Promise.all([
|
||||
getSiteSetting("GITEA_URL"),
|
||||
getSiteSetting("GITEA_TOKEN"),
|
||||
getSiteSetting("GITEA_REPO"),
|
||||
]);
|
||||
|
||||
if (!baseUrl || !token || !repo) {
|
||||
return { url: null, error: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl.replace(/\/$/, "")}/api/v1/repos/${repo}/issues`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `token ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: opts.title,
|
||||
body: opts.body,
|
||||
labels: LABELS[opts.type] ?? [],
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
return { url: null, error: `Gitea returned ${res.status}: ${text.slice(0, 300)}` };
|
||||
}
|
||||
|
||||
const issue = (await res.json()) as { html_url?: string };
|
||||
return { url: issue.html_url ?? null, error: issue.html_url ? null : "Gitea response had no html_url" };
|
||||
} catch (err) {
|
||||
return { url: null, error: err instanceof Error ? err.message : "Unknown error" };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user