feat: file/screenshot attachments on support tickets (v0.49.1)

Support form now accepts up to 5 attachments (images, PDF, text, JSON,
zip; 10MB each) via the existing S3/MinIO presigned-upload flow. New
support_ticket_attachments table; attachment links get folded into the
Gitea issue body and shown as thumbnails in both the user's ticket
history and the admin support view.

New presign endpoint (/api/v1/support/attachments/presign, rate-limited
20/hr) scoped to the uploading user rather than a ticket id, since the
ticket doesn't exist yet at upload time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-18 12:09:40 +02:00
parent 811d4cad42
commit 12c2ec213a
19 changed files with 5855 additions and 64 deletions
+26 -20
View File
@@ -1,29 +1,35 @@
import { NextResponse } from "next/server";
import { db, users, supportTickets, eq, desc } from "@epicure/db";
import { db, supportTickets, desc } from "@epicure/db";
import { requireAdmin } from "@/lib/api-auth";
import { getPublicUrl } from "@/lib/storage";
export async function GET() {
const { response } = await requireAdmin();
if (response) return response;
const rows = await db
.select({
id: supportTickets.id,
userId: supportTickets.userId,
userEmail: users.email,
username: users.username,
type: supportTickets.type,
title: supportTickets.title,
description: supportTickets.description,
status: supportTickets.status,
giteaIssueUrl: supportTickets.giteaIssueUrl,
giteaError: supportTickets.giteaError,
createdAt: supportTickets.createdAt,
updatedAt: supportTickets.updatedAt,
})
.from(supportTickets)
.innerJoin(users, eq(supportTickets.userId, users.id))
.orderBy(desc(supportTickets.createdAt));
const rows = await db.query.supportTickets.findMany({
orderBy: desc(supportTickets.createdAt),
with: {
attachments: true,
user: { columns: { email: true, username: true } },
},
});
return NextResponse.json(rows);
return NextResponse.json(
rows.map((r) => ({
id: r.id,
userId: r.userId,
userEmail: r.user.email,
username: r.user.username,
type: r.type,
title: r.title,
description: r.description,
status: r.status,
giteaIssueUrl: r.giteaIssueUrl,
giteaError: r.giteaError,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
attachments: r.attachments.map((a) => ({ ...a, url: getPublicUrl(a.storageKey) })),
}))
);
}