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:
@@ -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
@@ -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({
|
||||
|
||||
@@ -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}/`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user