import { NextRequest, NextResponse } from "next/server"; import crypto from "node:crypto"; import { z } from "zod"; import { db, supportTickets, supportTicketAttachments, eq, desc } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; import { sendEmail, supportTicketReceivedHtml } from "@/lib/email"; import { createGiteaIssue, buildGiteaIssueBody } from "@/lib/gitea"; import { getPublicUrl, isOwnedSupportAttachmentKey } from "@/lib/storage"; import { dispatchAdminWebhook } from "@/lib/admin-webhooks"; const MAX_ATTACHMENTS = 5; const CreateTicketBody = z.object({ type: z.enum(["bug", "suggestion", "question"]), title: z.string().trim().min(3).max(200), 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() { const { session, response } = await requireSession(); if (response) return response; const rows = await db.query.supportTickets.findMany({ where: eq(supportTickets.userId, session!.user.id), orderBy: desc(supportTickets.createdAt), with: { attachments: true }, }); return NextResponse.json( rows.map((r) => ({ ...r, attachments: r.attachments.map((a) => ({ ...a, url: getPublicUrl(a.storageKey) })), })) ); } export async function POST(req: NextRequest) { const { session, response } = await requireSession(); if (response) return response; const body = (await req.json()) as unknown; const parsed = CreateTicketBody.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Validation error", issues: parsed.error.issues }, { status: 400 } ); } 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 now = new Date(); const { url: giteaIssueUrl, number: giteaIssueNumber, error: giteaError } = await createGiteaIssue({ type, title, body: buildGiteaIssueBody({ description, userId: session!.user.id, attachments }), }); await db.insert(supportTickets).values({ id, userId: session!.user.id, type, title, description, status: "open", giteaIssueUrl, giteaIssueNumber, giteaError, createdAt: now, updatedAt: now, }); void dispatchAdminWebhook("support_ticket.created", { id, userId: session!.user.id, type, title }); 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 ticketUrl = `${baseUrl}/support`; if (session!.user.email) { try { await sendEmail({ to: session!.user.email, subject: "We got your message — Epicure", html: supportTicketReceivedHtml({ type, title, ticketUrl, giteaIssueUrl }), }); } catch { // Ticket is saved either way — email failure shouldn't fail the request. } } return NextResponse.json( { id, userId: session!.user.id, type, title, description, status: "open" as const, giteaIssueUrl, giteaError, createdAt: 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 } ); }