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
+17 -2
View File
@@ -585,17 +585,32 @@ export function generateOpenApiSpec(): object {
// --- Support: bug reports, suggestions, and questions submitted in-app ---
const SupportTicketTypeEnum = z.enum(["bug", "suggestion", "question"]);
const SupportTicketStatusEnum = z.enum(["open", "triaged", "closed"]);
const SupportAttachmentRef = registry.register("SupportAttachment", z.object({
id: z.string(), contentType: z.string(), url: z.string(),
}));
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(),
createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
attachments: z.array(SupportAttachmentRef), createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
}));
const CreateSupportTicketRef = registry.register("CreateSupportTicket", z.object({
type: SupportTicketTypeEnum, title: z.string().min(3).max(200), description: z.string().min(10).max(5000),
attachments: z.array(z.object({
key: z.string().describe("Storage key returned by POST /api/v1/support/attachments/presign"),
contentType: z.string(), sizeBytes: z.number().int().positive(),
})).max(5).default([]),
}));
const PresignSupportAttachmentRef = registry.register("PresignSupportAttachment", z.object({
contentType: z.string(), fileSize: z.number().int().positive().max(10 * 1024 * 1024),
}));
const PresignedPostRef = registry.register("PresignedSupportPost", z.object({
url: z.string(), fields: z.record(z.string(), z.string()), key: z.string(),
contentType: z.string(), fileSize: z.number().int(),
}));
registry.registerPath({ method: "get", path: "/api/v1/support", summary: "List your support tickets", security, responses: { 200: { description: "Tickets, newest first", content: { "application/json": { schema: z.array(SupportTicketRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
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.", 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", 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 } } } } });
// --- Conversations: 1:1 direct messages ---
const ConversationSummaryRef = registry.register("ConversationSummary", z.object({