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
+5
View File
@@ -2,6 +2,11 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
## 0.49.1 — 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.
## 0.49.0 — 2026-07-18 12:30 ## 0.49.0 — 2026-07-18 12:30
### Added ### Added
+11 -5
View File
@@ -4,6 +4,7 @@ import { auth } from "@/lib/auth/server";
import { db, supportTickets, eq, desc } from "@epicure/db"; import { db, supportTickets, eq, desc } from "@epicure/db";
import { SupportManager } from "@/components/support/support-manager"; import { SupportManager } from "@/components/support/support-manager";
import { getMessages } from "@/lib/i18n/server"; import { getMessages } from "@/lib/i18n/server";
import { getPublicUrl } from "@/lib/storage";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
@@ -12,11 +13,11 @@ export default async function SupportPage() {
if (!session) return null; if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale); const m = getMessages((session.user as { locale?: string }).locale);
const rows = await db const rows = await db.query.supportTickets.findMany({
.select() where: eq(supportTickets.userId, session.user.id),
.from(supportTickets) orderBy: desc(supportTickets.createdAt),
.where(eq(supportTickets.userId, session.user.id)) with: { attachments: true },
.orderBy(desc(supportTickets.createdAt)); });
return ( return (
<div className="space-y-8 max-w-3xl"> <div className="space-y-8 max-w-3xl">
@@ -33,6 +34,11 @@ export default async function SupportPage() {
status: r.status, status: r.status,
giteaIssueUrl: r.giteaIssueUrl, giteaIssueUrl: r.giteaIssueUrl,
createdAt: r.createdAt.toISOString(), createdAt: r.createdAt.toISOString(),
attachments: r.attachments.map((a) => ({
id: a.id,
contentType: a.contentType,
url: getPublicUrl(a.storageKey),
})),
}))} }))}
/> />
</div> </div>
+26 -18
View File
@@ -1,26 +1,18 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { db, users, supportTickets, eq, desc } from "@epicure/db"; import { db, supportTickets, desc } from "@epicure/db";
import { AdminSupportManager } from "@/components/admin/admin-support-manager"; import { AdminSupportManager } from "@/components/admin/admin-support-manager";
import { getPublicUrl } from "@/lib/storage";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
export default async function AdminSupportPage() { export default async function AdminSupportPage() {
const rows = await db const rows = await db.query.supportTickets.findMany({
.select({ orderBy: desc(supportTickets.createdAt),
id: supportTickets.id, with: {
userEmail: users.email, attachments: true,
username: users.username, user: { columns: { email: true, username: true } },
type: supportTickets.type, },
title: supportTickets.title, });
description: supportTickets.description,
status: supportTickets.status,
giteaIssueUrl: supportTickets.giteaIssueUrl,
giteaError: supportTickets.giteaError,
createdAt: supportTickets.createdAt,
})
.from(supportTickets)
.innerJoin(users, eq(supportTickets.userId, users.id))
.orderBy(desc(supportTickets.createdAt));
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -31,7 +23,23 @@ export default async function AdminSupportPage() {
</p> </p>
</div> </div>
<AdminSupportManager <AdminSupportManager
initialTickets={rows.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() }))} initialTickets={rows.map((r) => ({
id: r.id,
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.toISOString(),
attachments: r.attachments.map((a) => ({
id: a.id,
contentType: a.contentType,
url: getPublicUrl(a.storageKey),
})),
}))}
/> />
</div> </div>
); );
+26 -20
View File
@@ -1,29 +1,35 @@
import { NextResponse } from "next/server"; 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 { requireAdmin } from "@/lib/api-auth";
import { getPublicUrl } from "@/lib/storage";
export async function GET() { export async function GET() {
const { response } = await requireAdmin(); const { response } = await requireAdmin();
if (response) return response; if (response) return response;
const rows = await db const rows = await db.query.supportTickets.findMany({
.select({ orderBy: desc(supportTickets.createdAt),
id: supportTickets.id, with: {
userId: supportTickets.userId, attachments: true,
userEmail: users.email, user: { columns: { email: true, username: true } },
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));
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) })),
}))
);
} }
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { createPresignedUploadPost } from "@/lib/storage";
import { applyRateLimit } from "@/lib/rate-limit";
const ALLOWED_TYPES = [
"image/jpeg",
"image/png",
"image/webp",
"image/avif",
"image/gif",
"application/pdf",
"text/plain",
"application/json",
"application/zip",
] as const;
type AllowedType = (typeof ALLOWED_TYPES)[number];
const MAX_FILE_SIZE = 10 * 1024 * 1024;
const Schema = z.object({
contentType: z.string().refine((t): t is AllowedType => (ALLOWED_TYPES as readonly string[]).includes(t), {
message: "Unsupported file type",
}),
fileSize: z.number().int().positive().max(MAX_FILE_SIZE, "File exceeds 10MB limit"),
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const rateLimitResponse = await applyRateLimit(`rl:support-attachment:${session!.user.id}`, 20, 3600);
if (rateLimitResponse) return rateLimitResponse;
const body = (await req.json()) as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const { contentType, fileSize } = parsed.data;
const ext = contentType.split("/")[1] ?? "bin";
const key = `support/${session!.user.id}/${crypto.randomUUID()}.${ext}`;
const { url, fields } = await createPresignedUploadPost(key, contentType, MAX_FILE_SIZE);
return NextResponse.json({ url, fields, key, contentType, fileSize });
}
+59 -9
View File
@@ -1,28 +1,44 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto"; import crypto from "node:crypto";
import { z } from "zod"; import { z } from "zod";
import { db, supportTickets, eq, desc } from "@epicure/db"; import { db, supportTickets, supportTicketAttachments, eq, desc } from "@epicure/db";
import { requireSession } from "@/lib/api-auth"; import { requireSession } from "@/lib/api-auth";
import { sendEmail, supportTicketReceivedHtml } from "@/lib/email"; import { sendEmail, supportTicketReceivedHtml } from "@/lib/email";
import { createGiteaIssue } from "@/lib/gitea"; import { createGiteaIssue } from "@/lib/gitea";
import { getPublicUrl, isOwnedSupportAttachmentKey } from "@/lib/storage";
const MAX_ATTACHMENTS = 5;
const CreateTicketBody = z.object({ const CreateTicketBody = z.object({
type: z.enum(["bug", "suggestion", "question"]), type: z.enum(["bug", "suggestion", "question"]),
title: z.string().trim().min(3).max(200), title: z.string().trim().min(3).max(200),
description: z.string().trim().min(10).max(5000), description: z.string().trim().min(10).max(5000),
attachments: z
.array(z.object({
key: z.string().min(1).max(500),
contentType: z.string().min(1).max(100),
sizeBytes: z.number().int().positive(),
}))
.max(MAX_ATTACHMENTS)
.default([]),
}); });
export async function GET() { export async function GET() {
const { session, response } = await requireSession(); const { session, response } = await requireSession();
if (response) return response; if (response) return response;
const rows = await db const rows = await db.query.supportTickets.findMany({
.select() where: eq(supportTickets.userId, session!.user.id),
.from(supportTickets) orderBy: desc(supportTickets.createdAt),
.where(eq(supportTickets.userId, session!.user.id)) with: { attachments: true },
.orderBy(desc(supportTickets.createdAt)); });
return NextResponse.json(rows); return NextResponse.json(
rows.map((r) => ({
...r,
attachments: r.attachments.map((a) => ({ ...a, url: getPublicUrl(a.storageKey) })),
}))
);
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
@@ -38,14 +54,28 @@ export async function POST(req: NextRequest) {
); );
} }
const { type, title, description } = parsed.data; const { type, title, description, attachments } = parsed.data;
for (const a of attachments) {
if (!isOwnedSupportAttachmentKey(a.key, session!.user.id)) {
return NextResponse.json({ error: "Invalid attachment" }, { status: 400 });
}
}
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const now = new Date(); const now = new Date();
const attachmentLinks = attachments.map((a) => getPublicUrl(a.key));
const giteaBody = [
description,
attachmentLinks.length > 0 ? `\n**Attachments:**\n${attachmentLinks.map((u) => `- ${u}`).join("\n")}` : "",
`\n---\nReported via Epicure support form by user \`${session!.user.id}\`.`,
].join("\n");
const { url: giteaIssueUrl, error: giteaError } = await createGiteaIssue({ const { url: giteaIssueUrl, error: giteaError } = await createGiteaIssue({
type, type,
title, title,
body: `${description}\n\n---\nReported via Epicure support form by user \`${session!.user.id}\`.`, body: giteaBody,
}); });
await db.insert(supportTickets).values({ await db.insert(supportTickets).values({
@@ -61,6 +91,19 @@ export async function POST(req: NextRequest) {
updatedAt: now, updatedAt: now,
}); });
if (attachments.length > 0) {
await db.insert(supportTicketAttachments).values(
attachments.map((a) => ({
id: crypto.randomUUID(),
ticketId: id,
storageKey: a.key,
contentType: a.contentType,
sizeBytes: a.sizeBytes,
createdAt: now,
}))
);
}
const baseUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"; const baseUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000";
const ticketUrl = `${baseUrl}/support`; const ticketUrl = `${baseUrl}/support`;
if (session!.user.email) { if (session!.user.email) {
@@ -87,6 +130,13 @@ export async function POST(req: NextRequest) {
giteaError, giteaError,
createdAt: now.toISOString(), createdAt: now.toISOString(),
updatedAt: now.toISOString(), updatedAt: now.toISOString(),
attachments: attachments.map((a) => ({
id: "",
storageKey: a.key,
contentType: a.contentType,
sizeBytes: a.sizeBytes,
url: getPublicUrl(a.key),
})),
}, },
{ status: 201 } { status: 201 }
); );
@@ -1,9 +1,10 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { toast } from "sonner"; import { toast } from "sonner";
import { ExternalLink } from "lucide-react"; import { ExternalLink, FileText } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
@@ -16,6 +17,8 @@ import {
type TicketStatus = "open" | "triaged" | "closed"; type TicketStatus = "open" | "triaged" | "closed";
type Attachment = { id: string; contentType: string; url: string };
type Ticket = { type Ticket = {
id: string; id: string;
userEmail: string; userEmail: string;
@@ -27,8 +30,13 @@ type Ticket = {
giteaIssueUrl: string | null; giteaIssueUrl: string | null;
giteaError: string | null; giteaError: string | null;
createdAt: string; createdAt: string;
attachments: Attachment[];
}; };
function isImage(contentType: string) {
return contentType.startsWith("image/");
}
function formatDateTime(iso: string) { function formatDateTime(iso: string) {
return new Date(iso).toLocaleString(undefined, { return new Date(iso).toLocaleString(undefined, {
month: "short", month: "short",
@@ -113,6 +121,27 @@ export function AdminSupportManager({ initialTickets }: { initialTickets: Ticket
</Select> </Select>
</div> </div>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{ticket.description}</p> <p className="text-sm text-muted-foreground whitespace-pre-wrap">{ticket.description}</p>
{ticket.attachments.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{ticket.attachments.map((a) => (
<Link
key={a.id}
href={a.url}
target="_blank"
rel="noopener noreferrer"
className="relative h-12 w-12 rounded-md border overflow-hidden block"
>
{isImage(a.contentType) ? (
<Image src={a.url} unoptimized alt="Attachment" fill className="object-cover" />
) : (
<div className="h-full w-full flex items-center justify-center bg-muted/40">
<FileText className="h-4 w-4 text-muted-foreground" />
</div>
)}
</Link>
))}
</div>
)}
<div className="flex items-center gap-2 text-xs flex-wrap pt-1"> <div className="flex items-center gap-2 text-xs flex-wrap pt-1">
<Badge variant="outline" className="text-xs">{ticket.type}</Badge> <Badge variant="outline" className="text-xs">{ticket.type}</Badge>
<Badge variant={statusVariant[ticket.status]} className="text-xs">{ticket.status}</Badge> <Badge variant={statusVariant[ticket.status]} className="text-xs">{ticket.status}</Badge>
+157 -4
View File
@@ -1,10 +1,11 @@
"use client"; "use client";
import { useState } from "react"; import { useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { toast } from "sonner"; import { toast } from "sonner";
import { ExternalLink } from "lucide-react"; import { ExternalLink, Paperclip, X, FileText } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -17,10 +18,13 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { uploadToPresignedPost } from "@/lib/upload-client";
type TicketType = "bug" | "suggestion" | "question"; type TicketType = "bug" | "suggestion" | "question";
type TicketStatus = "open" | "triaged" | "closed"; type TicketStatus = "open" | "triaged" | "closed";
type Attachment = { id: string; contentType: string; url: string };
type Ticket = { type Ticket = {
id: string; id: string;
type: TicketType; type: TicketType;
@@ -29,8 +33,21 @@ type Ticket = {
status: TicketStatus; status: TicketStatus;
giteaIssueUrl: string | null; giteaIssueUrl: string | null;
createdAt: string; createdAt: string;
attachments: Attachment[];
}; };
type PendingAttachment = {
key: string;
contentType: string;
sizeBytes: number;
preview: string | null;
name: string;
};
const MAX_ATTACHMENTS = 5;
const MAX_FILE_SIZE = 10 * 1024 * 1024;
const ACCEPT = "image/jpeg,image/png,image/webp,image/avif,image/gif,application/pdf,text/plain,application/json,application/zip";
function formatDate(iso: string) { function formatDate(iso: string) {
return new Date(iso).toLocaleDateString(undefined, { return new Date(iso).toLocaleDateString(undefined, {
year: "numeric", year: "numeric",
@@ -39,6 +56,10 @@ function formatDate(iso: string) {
}); });
} }
function isImage(contentType: string) {
return contentType.startsWith("image/");
}
export function SupportManager({ initialTickets }: { initialTickets: Ticket[] }) { export function SupportManager({ initialTickets }: { initialTickets: Ticket[] }) {
const t = useTranslations("support"); const t = useTranslations("support");
const [tickets, setTickets] = useState<Ticket[]>(initialTickets); const [tickets, setTickets] = useState<Ticket[]>(initialTickets);
@@ -46,6 +67,54 @@ export function SupportManager({ initialTickets }: { initialTickets: Ticket[] })
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [uploadingCount, setUploadingCount] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
async function handleFiles(files: FileList) {
const room = MAX_ATTACHMENTS - pendingAttachments.length;
const toUpload = Array.from(files).slice(0, Math.max(0, room));
if (files.length > toUpload.length) {
toast.error(t("attachmentLimitReached", { count: MAX_ATTACHMENTS }));
}
setUploadingCount((c) => c + toUpload.length);
for (const file of toUpload) {
if (file.size > MAX_FILE_SIZE) {
toast.error(t("attachmentTooLarge", { name: file.name }));
setUploadingCount((c) => c - 1);
continue;
}
try {
const res = await fetch("/api/v1/support/attachments/presign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: file.type, fileSize: file.size }),
});
if (!res.ok) throw new Error();
const { url, fields, key } = (await res.json()) as { url: string; fields: Record<string, string>; key: string };
const uploaded = await uploadToPresignedPost(url, fields, file);
if (!uploaded) throw new Error();
setPendingAttachments((prev) => [
...prev,
{
key,
contentType: file.type,
sizeBytes: file.size,
preview: isImage(file.type) ? URL.createObjectURL(file) : null,
name: file.name,
},
]);
} catch {
toast.error(t("attachmentUploadFailed", { name: file.name }));
} finally {
setUploadingCount((c) => c - 1);
}
}
}
function removePending(key: string) {
setPendingAttachments((prev) => prev.filter((a) => a.key !== key));
}
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -55,7 +124,16 @@ export function SupportManager({ initialTickets }: { initialTickets: Ticket[] })
const res = await fetch("/api/v1/support", { const res = await fetch("/api/v1/support", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type, title: title.trim(), description: description.trim() }), body: JSON.stringify({
type,
title: title.trim(),
description: description.trim(),
attachments: pendingAttachments.map((a) => ({
key: a.key,
contentType: a.contentType,
sizeBytes: a.sizeBytes,
})),
}),
}); });
if (!res.ok) { if (!res.ok) {
const data = (await res.json()) as { error?: string }; const data = (await res.json()) as { error?: string };
@@ -66,6 +144,7 @@ export function SupportManager({ initialTickets }: { initialTickets: Ticket[] })
setTitle(""); setTitle("");
setDescription(""); setDescription("");
setType("bug"); setType("bug");
setPendingAttachments([]);
toast.success(t("submitSuccess")); toast.success(t("submitSuccess"));
} catch (err) { } catch (err) {
toast.error(err instanceof Error ? err.message : t("submitFailed")); toast.error(err instanceof Error ? err.message : t("submitFailed"));
@@ -122,7 +201,60 @@ export function SupportManager({ initialTickets }: { initialTickets: Ticket[] })
/> />
</div> </div>
<Button type="submit" disabled={submitting || title.trim().length < 3 || description.trim().length < 10}> <div className="space-y-2">
<Label>{t("attachmentsLabel")}</Label>
<div className="flex flex-wrap gap-3">
{pendingAttachments.map((a) => (
<div key={a.key} className="relative group h-16 w-16">
{a.preview ? (
<Image
src={a.preview}
unoptimized
alt={a.name}
fill
className="rounded-lg object-cover border"
/>
) : (
<div className="h-full w-full rounded-lg border flex flex-col items-center justify-center gap-0.5 bg-muted/40 px-1">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="text-[9px] text-muted-foreground truncate w-full text-center">{a.name}</span>
</div>
)}
<button
type="button"
onClick={() => removePending(a.key)}
className="absolute -top-1.5 -right-1.5 rounded-full bg-destructive text-destructive-foreground p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
title={t("removeAttachment")}
>
<X className="h-3 w-3" />
</button>
</div>
))}
{pendingAttachments.length < MAX_ATTACHMENTS && (
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploadingCount > 0}
className="h-16 w-16 rounded-lg border-2 border-dashed border-muted-foreground/25 hover:border-muted-foreground/50 flex flex-col items-center justify-center gap-1 text-muted-foreground transition-colors disabled:opacity-50"
>
<Paperclip className="h-4 w-4" />
<span className="text-[10px]">{uploadingCount > 0 ? t("attachmentUploading") : t("attachmentAdd")}</span>
</button>
)}
</div>
<p className="text-xs text-muted-foreground">{t("attachmentsHint", { count: MAX_ATTACHMENTS })}</p>
<input
ref={inputRef}
type="file"
accept={ACCEPT}
multiple
className="hidden"
onChange={(e) => e.target.files && handleFiles(e.target.files)}
/>
</div>
<Button type="submit" disabled={submitting || uploadingCount > 0 || title.trim().length < 3 || description.trim().length < 10}>
{submitting ? t("submitting") : t("submitButton")} {submitting ? t("submitting") : t("submitButton")}
</Button> </Button>
</form> </form>
@@ -142,6 +274,27 @@ export function SupportManager({ initialTickets }: { initialTickets: Ticket[] })
</Badge> </Badge>
</div> </div>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{ticket.description}</p> <p className="text-sm text-muted-foreground whitespace-pre-wrap">{ticket.description}</p>
{ticket.attachments.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{ticket.attachments.map((a) => (
<Link
key={a.id}
href={a.url}
target="_blank"
rel="noopener noreferrer"
className="relative h-12 w-12 rounded-md border overflow-hidden block"
>
{isImage(a.contentType) ? (
<Image src={a.url} unoptimized alt="Attachment" fill className="object-cover" />
) : (
<div className="h-full w-full flex items-center justify-center bg-muted/40">
<FileText className="h-4 w-4 text-muted-foreground" />
</div>
)}
</Link>
))}
</div>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground pt-1"> <div className="flex items-center gap-2 text-xs text-muted-foreground pt-1">
<Badge variant="outline" className="text-xs">{t(`type.${ticket.type}`)}</Badge> <Badge variant="outline" className="text-xs">{t(`type.${ticket.type}`)}</Badge>
<span>{formatDate(ticket.createdAt)}</span> <span>{formatDate(ticket.createdAt)}</span>
+8 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together. // 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 = { export type ChangelogEntry = {
version: string; version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
}; };
export const CHANGELOG: 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", version: "0.49.0",
date: "2026-07-18 12:30", 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 --- // --- Support: bug reports, suggestions, and questions submitted in-app ---
const SupportTicketTypeEnum = z.enum(["bug", "suggestion", "question"]); const SupportTicketTypeEnum = z.enum(["bug", "suggestion", "question"]);
const SupportTicketStatusEnum = z.enum(["open", "triaged", "closed"]); 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({ const SupportTicketRef = registry.register("SupportTicket", z.object({
id: z.string(), userId: z.string(), type: SupportTicketTypeEnum, title: z.string(), description: z.string(), 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(), 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({ const CreateSupportTicketRef = registry.register("CreateSupportTicket", z.object({
type: SupportTicketTypeEnum, title: z.string().min(3).max(200), description: z.string().min(10).max(5000), 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: "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 --- // --- Conversations: 1:1 direct messages ---
const ConversationSummaryRef = registry.register("ConversationSummary", z.object({ 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 { export function isOwnedAvatarKey(key: string, userId: string): boolean {
return key.startsWith(`user-avatars/${userId}/`); 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}/`);
}
+8
View File
@@ -462,6 +462,14 @@
"titlePlaceholder": "Short summary of the issue or idea", "titlePlaceholder": "Short summary of the issue or idea",
"descriptionLabel": "Description", "descriptionLabel": "Description",
"descriptionPlaceholder": "What happened, what you expected, and steps to reproduce (if a bug) — or details on your suggestion/question.", "descriptionPlaceholder": "What happened, what you expected, and steps to reproduce (if a bug) — or details on your suggestion/question.",
"attachmentsLabel": "Attachments",
"attachmentsHint": "Screenshots or files, up to {count}. Images, PDF, text, JSON, or zip — 10MB max each.",
"attachmentAdd": "Add file",
"attachmentUploading": "Uploading…",
"removeAttachment": "Remove attachment",
"attachmentLimitReached": "You can attach up to {count} files.",
"attachmentTooLarge": "\"{name}\" exceeds the 10MB limit.",
"attachmentUploadFailed": "Failed to upload \"{name}\".",
"submitButton": "Submit", "submitButton": "Submit",
"submitting": "Submitting…", "submitting": "Submitting…",
"submitSuccess": "Thanks! We received your message.", "submitSuccess": "Thanks! We received your message.",
+8
View File
@@ -462,6 +462,14 @@
"titlePlaceholder": "Résumé court du problème ou de l'idée", "titlePlaceholder": "Résumé court du problème ou de l'idée",
"descriptionLabel": "Description", "descriptionLabel": "Description",
"descriptionPlaceholder": "Ce qui s'est passé, ce que vous attendiez, et les étapes pour reproduire (si bug) — ou les détails de votre suggestion/question.", "descriptionPlaceholder": "Ce qui s'est passé, ce que vous attendiez, et les étapes pour reproduire (si bug) — ou les détails de votre suggestion/question.",
"attachmentsLabel": "Pièces jointes",
"attachmentsHint": "Captures d'écran ou fichiers, jusqu'à {count}. Images, PDF, texte, JSON ou zip — 10 Mo max chacun.",
"attachmentAdd": "Ajouter un fichier",
"attachmentUploading": "Envoi…",
"removeAttachment": "Retirer la pièce jointe",
"attachmentLimitReached": "Vous pouvez joindre jusqu'à {count} fichiers.",
"attachmentTooLarge": "\"{name}\" dépasse la limite de 10 Mo.",
"attachmentUploadFailed": "Échec de l'envoi de \"{name}\".",
"submitButton": "Envoyer", "submitButton": "Envoyer",
"submitting": "Envoi…", "submitting": "Envoi…",
"submitSuccess": "Merci ! Nous avons reçu votre message.", "submitSuccess": "Merci ! Nous avons reçu votre message.",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@epicure/web", "name": "@epicure/web",
"version": "0.49.0", "version": "0.49.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "epicure", "name": "epicure",
"version": "0.49.0", "version": "0.49.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "pnpm --filter web dev", "dev": "pnpm --filter web dev",
@@ -0,0 +1,11 @@
CREATE TABLE "support_ticket_attachments" (
"id" text PRIMARY KEY NOT NULL,
"ticket_id" text NOT NULL,
"storage_key" text NOT NULL,
"content_type" text NOT NULL,
"size_bytes" integer NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "support_ticket_attachments" ADD CONSTRAINT "support_ticket_attachments_ticket_id_support_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."support_tickets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "support_ticket_attachments_ticket_idx" ON "support_ticket_attachments" USING btree ("ticket_id");
File diff suppressed because it is too large Load Diff
@@ -323,6 +323,13 @@
"when": 1784366396485, "when": 1784366396485,
"tag": "0045_overjoyed_moondragon", "tag": "0045_overjoyed_moondragon",
"breakpoints": true "breakpoints": true
},
{
"idx": 46,
"version": "7",
"when": 1784369252969,
"tag": "0046_curly_sabretooth",
"breakpoints": true
} }
] ]
} }
+18 -2
View File
@@ -1,4 +1,4 @@
import { pgTable, text, timestamp, index, pgEnum } from "drizzle-orm/pg-core"; import { pgTable, text, timestamp, integer, index, pgEnum } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { users } from "./users"; import { users } from "./users";
@@ -21,6 +21,22 @@ export const supportTickets = pgTable("support_tickets", {
index("support_tickets_status_idx").on(t.status, t.createdAt), index("support_tickets_status_idx").on(t.status, t.createdAt),
]); ]);
export const supportTicketsRelations = relations(supportTickets, ({ one }) => ({ export const supportTicketAttachments = pgTable("support_ticket_attachments", {
id: text("id").primaryKey(),
ticketId: text("ticket_id").notNull().references(() => supportTickets.id, { onDelete: "cascade" }),
storageKey: text("storage_key").notNull(),
contentType: text("content_type").notNull(),
sizeBytes: integer("size_bytes").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
}, (t) => [
index("support_ticket_attachments_ticket_idx").on(t.ticketId),
]);
export const supportTicketsRelations = relations(supportTickets, ({ one, many }) => ({
user: one(users, { fields: [supportTickets.userId], references: [users.id] }), user: one(users, { fields: [supportTickets.userId], references: [users.id] }),
attachments: many(supportTicketAttachments),
}));
export const supportTicketAttachmentsRelations = relations(supportTicketAttachments, ({ one }) => ({
ticket: one(supportTickets, { fields: [supportTicketAttachments.ticketId], references: [supportTickets.id] }),
})); }));