feat: two-way sync between support tickets and Gitea issues (v0.63.0)

Outbound (already existed one-way: ticket create -> Gitea issue) now
also mirrors status changes: closing/reopening a ticket in the admin
UI closes/reopens the linked Gitea issue, and replies posted from
Epicure (by the ticket owner or an admin) post as a comment on the
issue. Captures and stores the Gitea issue number at creation time
to address these follow-up calls without re-parsing the issue URL.

Inbound: new webhook receiver at /api/webhooks/gitea, verified via
HMAC-SHA256 signature (X-Gitea-Signature) with a configurable
GITEA_WEBHOOK_SECRET site setting, deduped by delivery id the same
way the existing Stripe receiver dedupes events. Handles "issues"
(closed/reopened -> ticket status) and "issue_comment" (created ->
appends to the ticket's comment thread), skipping comments Epicura
already posted itself (matched by Gitea comment id) to avoid loops.

New support_ticket_comments table backs a lightweight conversation
thread on both the user-facing support page and the admin support
manager, each comment tagged by author (user/admin/gitea).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-20 23:34:39 +02:00
parent 274c50c2f6
commit b17984fbef
22 changed files with 6642 additions and 17 deletions
+8 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.62.0";
export const APP_VERSION = "0.63.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.63.0",
date: "2026-07-21 00:15",
added: [
"Support tickets now sync two-way with their linked Gitea issue: closing/reopening the issue in Gitea updates the ticket's status, and comments on either side (Epicure or Gitea) show up in both places. Configure a webhook secret (GITEA_WEBHOOK_SECRET) on the Gitea repo's webhook, subscribed to Issues and Issue Comment events, pointed at /api/webhooks/gitea.",
],
},
{
version: "0.62.0",
date: "2026-07-20 23:45",
+93 -6
View File
@@ -1,3 +1,4 @@
import crypto from "node:crypto";
import { getSiteSetting } from "@/lib/site-settings";
import { getPublicUrl } from "@/lib/storage";
@@ -58,7 +59,7 @@ export async function createGiteaIssue(opts: {
type: string;
title: string;
body: string;
}): Promise<{ url: string | null; error: string | null }> {
}): Promise<{ url: string | null; number: number | null; error: string | null }> {
const [rawBaseUrl, token, repo] = await Promise.all([
getSiteSetting("GITEA_URL"),
getSiteSetting("GITEA_TOKEN"),
@@ -66,7 +67,7 @@ export async function createGiteaIssue(opts: {
]);
if (!rawBaseUrl || !token || !repo) {
return { url: null, error: null };
return { url: null, number: null, error: null };
}
const baseUrl = rawBaseUrl.replace(/\/$/, "");
@@ -90,11 +91,15 @@ export async function createGiteaIssue(opts: {
if (!res.ok) {
const text = await res.text().catch(() => "");
return { url: null, error: `Gitea returned ${res.status}: ${text.slice(0, 300)}` };
return { url: null, number: 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" };
const issue = (await res.json()) as { html_url?: string; number?: number };
return {
url: issue.html_url ?? null,
number: issue.number ?? null,
error: issue.html_url ? null : "Gitea response had no html_url",
};
} catch (err) {
// A thrown fetch error (DNS failure, connection refused, timeout, TLS
// error) never reaches the res.ok branch above, so its real cause would
@@ -105,6 +110,88 @@ export async function createGiteaIssue(opts: {
const cause = err instanceof Error && err.cause instanceof Error ? err.cause.message : undefined;
const message = err instanceof Error ? err.message : "Unknown error";
console.error("[gitea] createGiteaIssue failed", { baseUrl, repo, message, cause });
return { url: null, error: cause ? `${message}: ${cause}` : message };
return { url: null, number: null, error: cause ? `${message}: ${cause}` : message };
}
}
async function giteaCreds(): Promise<{ baseUrl: string; token: string; repo: string } | null> {
const [rawBaseUrl, token, repo] = await Promise.all([
getSiteSetting("GITEA_URL"),
getSiteSetting("GITEA_TOKEN"),
getSiteSetting("GITEA_REPO"),
]);
if (!rawBaseUrl || !token || !repo) return null;
return { baseUrl: rawBaseUrl.replace(/\/$/, ""), token, repo };
}
/** Posts a comment on the linked Gitea issue. Best-effort — callers must not
* block ticket/comment saving on this succeeding. */
export async function postGiteaComment(
issueNumber: number,
body: string
): Promise<{ id: number | null; error: string | null }> {
const creds = await giteaCreds();
if (!creds) return { id: null, error: null };
try {
const res = await fetch(`${creds.baseUrl}/api/v1/repos/${creds.repo}/issues/${issueNumber}/comments`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `token ${creds.token}` },
body: JSON.stringify({ body }),
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
return { id: null, error: `Gitea returned ${res.status}: ${text.slice(0, 300)}` };
}
const comment = (await res.json()) as { id?: number };
return { id: comment.id ?? null, error: null };
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
console.error("[gitea] postGiteaComment failed", { issueNumber, message });
return { id: null, error: message };
}
}
/** Opens or closes the linked Gitea issue to mirror a ticket status change.
* Best-effort, same reasoning as postGiteaComment. */
export async function setGiteaIssueState(
issueNumber: number,
state: "open" | "closed"
): Promise<{ ok: boolean; error: string | null }> {
const creds = await giteaCreds();
if (!creds) return { ok: false, error: null };
try {
const res = await fetch(`${creds.baseUrl}/api/v1/repos/${creds.repo}/issues/${issueNumber}`, {
method: "PATCH",
headers: { "Content-Type": "application/json", Authorization: `token ${creds.token}` },
body: JSON.stringify({ state }),
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
return { ok: false, error: `Gitea returned ${res.status}: ${text.slice(0, 300)}` };
}
return { ok: true, error: null };
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
console.error("[gitea] setGiteaIssueState failed", { issueNumber, state, message });
return { ok: false, error: message };
}
}
/** Gitea signs webhook deliveries with HMAC-SHA256 hex digest of the raw
* body (X-Gitea-Signature) — no timestamp prefix, unlike Stripe. */
export function verifyGiteaSignature(rawBody: string, signature: string, secret: string): boolean {
const expected = crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
const expectedBuf = Buffer.from(expected, "hex");
let providedBuf: Buffer;
try {
providedBuf = Buffer.from(signature, "hex");
} catch {
return false;
}
if (providedBuf.length !== expectedBuf.length) return false;
return crypto.timingSafeEqual(expectedBuf, providedBuf);
}
+16 -2
View File
@@ -646,7 +646,8 @@ export function generateOpenApiSpec(): object {
}));
const SupportTicketRef = registry.register("SupportTicket", z.object({
id: z.string(), userId: z.string(), type: SupportTicketTypeEnum, title: z.string(), description: z.string(),
status: SupportTicketStatusEnum, giteaIssueUrl: z.string().nullable(), giteaError: z.string().nullable(),
status: SupportTicketStatusEnum, giteaIssueUrl: z.string().nullable(), giteaIssueNumber: z.number().nullable().optional(),
giteaError: z.string().nullable(),
attachments: z.array(SupportAttachmentRef), createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
}));
const CreateSupportTicketRef = registry.register("CreateSupportTicket", z.object({
@@ -668,6 +669,17 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "post", path: "/api/v1/support", summary: "Submit a bug report, suggestion, or question", description: "Best-effort opens a matching issue on the configured Gitea repo (see giteaIssueUrl/giteaError) — this never blocks ticket creation. Sends a confirmation email. Attach files by presigning each one first via POST /api/v1/support/attachments/presign, uploading to the returned URL, then passing the keys here.", security, request: { body: { content: { "application/json": { schema: CreateSupportTicketRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: SupportTicketRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/support/attachments/presign", summary: "Get a presigned upload URL for a support ticket attachment", description: "Rate-limited: 20 req/hour. Accepts images, PDF, plain text, JSON, or zip, up to 10MB. Upload the file as multipart form data to the returned url+fields, then include the returned key when submitting the ticket.", security, request: { body: { content: { "application/json": { schema: PresignSupportAttachmentRef } }, required: true } }, responses: { 200: { description: "Presigned upload", content: { "application/json": { schema: PresignedPostRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
const SupportTicketCommentRef = registry.register("SupportTicketComment", z.object({
id: z.string(), ticketId: z.string(), authorType: z.enum(["user", "admin", "gitea"]),
authorId: z.string().nullable(), body: z.string(), createdAt: z.string().datetime(),
}));
const CreateSupportTicketCommentRef = registry.register("CreateSupportTicketComment", z.object({
body: z.string().min(1).max(3000),
}));
registry.registerPath({ method: "get", path: "/api/v1/support/{id}/comments", summary: "List a ticket's conversation", description: "Owner only. Includes replies posted from Epicure and, if the ticket has a linked Gitea issue, comments made directly on that issue (author type \"gitea\").", security, request: { params: idParam }, responses: { 200: { description: "Comments, oldest first", content: { "application/json": { schema: z.array(SupportTicketCommentRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/support/{id}/comments", summary: "Reply to your own support ticket", description: "Owner only. Best-effort mirrors the reply onto the linked Gitea issue as a comment if one exists.", security, request: { params: idParam, body: { content: { "application/json": { schema: CreateSupportTicketCommentRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: SupportTicketCommentRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
const AdminSupportTicketRef = registry.register("AdminSupportTicket", z.object({
id: z.string(), userId: z.string(), userEmail: z.string(), username: z.string().nullable(),
type: SupportTicketTypeEnum, title: z.string(), description: z.string(),
@@ -879,7 +891,9 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "get", path: "/api/v1/admin/reports", summary: "List pending reports (up to 100, newest first)", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Reports", content: { "application/json": { schema: z.object({ reports: z.array(AdminReportRef) }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/admin/support", summary: "List all support tickets, newest first", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Tickets", content: { "application/json": { schema: z.array(AdminSupportTicketRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "patch", path: "/api/v1/admin/support/{id}", summary: "Update a support ticket's status, or retry opening its Gitea issue", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: UpdateAdminSupportTicketRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "patch", path: "/api/v1/admin/support/{id}", summary: "Update a support ticket's status, or retry opening its Gitea issue", description: "Admin only. A status change to/from \"closed\" also best-effort closes/reopens the linked Gitea issue.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: UpdateAdminSupportTicketRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/admin/support/{id}/comments", summary: "List a support ticket's conversation", description: "Admin only.", security: adminSecurity, request: { params: idParam }, responses: { 200: { description: "Comments, oldest first", content: { "application/json": { schema: z.array(SupportTicketCommentRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/admin/support/{id}/comments", summary: "Reply to a support ticket as an admin", description: "Admin only. Best-effort mirrors the reply onto the linked Gitea issue as a comment if one exists.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: CreateSupportTicketCommentRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: SupportTicketCommentRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/admin/webhooks", summary: "List site-wide ops webhooks", description: "Admin only. Site-wide events (new signups, support tickets, reports) — distinct from the per-user webhooks under /api/v1/webhooks. The signing secret is never included — it is only ever returned once, at creation time.", security: adminSecurity, responses: { 200: { description: "Webhooks", content: { "application/json": { schema: z.array(AdminWebhookRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "post", path: "/api/v1/admin/webhooks", summary: "Create a site-wide ops webhook", description: "Admin only. Validates the URL against SSRF targets. Generates a signing secret returned once in this response only.", security: adminSecurity, request: { body: { content: { "application/json": { schema: CreateAdminWebhookRef } }, required: true } }, responses: { 201: { description: "Created — includes the signing secret (write-once)", content: { "application/json": { schema: CreatedAdminWebhookRef } } }, 400: { description: "Validation error or blocked URL", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
+4 -1
View File
@@ -21,7 +21,8 @@ export type SiteSettingKey =
| "AI_TOOL_CALLING_ENABLED"
| "GITEA_URL"
| "GITEA_TOKEN"
| "GITEA_REPO";
| "GITEA_REPO"
| "GITEA_WEBHOOK_SECRET";
const SECRET_KEYS: SiteSettingKey[] = [
"OPENAI_API_KEY",
@@ -29,6 +30,7 @@ const SECRET_KEYS: SiteSettingKey[] = [
"OPENROUTER_API_KEY",
"VAPID_PRIVATE_KEY",
"GITEA_TOKEN",
"GITEA_WEBHOOK_SECRET",
];
export function isSecretKey(key: SiteSettingKey): boolean {
@@ -71,6 +73,7 @@ export async function getAllSiteSettings(): Promise<Record<string, { value: stri
"GITEA_URL",
"GITEA_TOKEN",
"GITEA_REPO",
"GITEA_WEBHOOK_SECRET",
];
const result: Record<string, { value: string | null; isSecret: boolean; fromDb: boolean }> = {};