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
+8 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.49.0";
export const APP_VERSION = "0.49.1";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.49.1",
date: "2026-07-18 13:10",
added: [
"Support form now accepts attachments — screenshots or files (images, PDF, text, JSON, zip; up to 5 files, 10MB each). They're linked in the confirmation email's Gitea issue and shown as thumbnails on your ticket history and in the admin support view.",
],
},
{
version: "0.49.0",
date: "2026-07-18 12:30",
+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({
+6
View File
@@ -87,3 +87,9 @@ export function isOwnedReviewPhotoKey(key: string, recipeId: string, userId: str
export function isOwnedAvatarKey(key: string, userId: string): boolean {
return key.startsWith(`user-avatars/${userId}/`);
}
/** Same rationale as isOwnedRecipePhotoKey — a support ticket doesn't exist yet
* at upload time, so ownership is scoped to the uploading user, not a ticket id. */
export function isOwnedSupportAttachmentKey(key: string, userId: string): boolean {
return key.startsWith(`support/${userId}/`);
}