"use server"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { z } from "zod"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { clientIp, rateLimit } from "@/lib/rate-limit"; import { moderateOrThrow } from "@/lib/services/moderation"; const ticketSchema = z.object({ title: z.string().min(1, "Title is required").max(255), content: z.string().min(1, "Content is required").max(5000), }); type TicketOutcome = | "created" | "invalid" | "moderated" | "ratelimit" | "error"; function ticketsRedirect(outcome: TicketOutcome): never { if (outcome === "created") redirect("/help/tickets?created=1"); redirect(`/help/tickets?error=${outcome}`); } function isNextRedirect(e: unknown): boolean { return ( !!e && typeof e === "object" && "digest" in e && typeof (e as { digest?: unknown }).digest === "string" && (e as { digest: string }).digest.startsWith("NEXT_REDIRECT") ); } export async function createTicket(formData: FormData): Promise { let outcome: TicketOutcome = "error"; try { // Re-read the session user id server-side; never trust a form-supplied id. const session = await auth(); const userId = Number(session?.user?.id); if (!Number.isInteger(userId) || userId <= 0) { redirect("/login"); } await clientIp(); if (!(await rateLimit(`ticket:${userId}`, 3, 60_000)).ok) { outcome = "ratelimit"; } else { const raw = { title: String(formData.get("title") ?? "") .normalize("NFC") .trim() .slice(0, 255), content: String(formData.get("content") ?? "") .normalize("NFC") .trim() .slice(0, 5000), }; const parsed = ticketSchema.safeParse(raw); if (!parsed.success) { outcome = "invalid"; } else { const { title, content } = parsed.data; let moderated = false; try { await moderateOrThrow(`${title} ${content}`); } catch { moderated = true; outcome = "moderated"; } if (!moderated) { const now = new Date(); await prisma.websiteHelpCenterTickets.create({ data: { userId, title, content, open: true, createdAt: now, updatedAt: now, }, }); outcome = "created"; } } } } catch (e) { if (isNextRedirect(e)) throw e; outcome = "error"; } revalidatePath("/help/tickets"); ticketsRedirect(outcome); }