"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 { positiveBigInt } from "@/lib/api"; import { moderateOrThrow } from "@/lib/services/moderation"; import { createOwnedTicketReply } from "@/lib/services/ticket-replies"; 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"; type TicketDetailOutcome = | "replied" | "closed" | "invalid" | "not_found" | "closed_ticket" | "moderated" | "ratelimit" | "error"; function ticketsRedirect(outcome: TicketOutcome): never { if (outcome === "created") redirect("/help/tickets?created=1"); redirect(`/help/tickets?error=${outcome}`); } function ticketDetailRedirect( ticketId: bigint | null, outcome: TicketDetailOutcome, ): never { if (!ticketId) redirect("/help/tickets?error=invalid"); const base = `/help/tickets/${ticketId}`; if (outcome === "replied") redirect(`${base}?replied=1`); if (outcome === "closed") redirect(`${base}?closed=1`); redirect(`${base}?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); } const replyContentSchema = z.object({ content: z.string().min(1).max(5000), }); export async function replyHelpTicket(formData: FormData): Promise { const ticketId = positiveBigInt(String(formData.get("ticketId") ?? "")); let outcome: TicketDetailOutcome = "error"; try { const session = await auth(); const userId = Number(session?.user?.id); if (!Number.isInteger(userId) || userId <= 0) { redirect("/login"); } if (!ticketId) { outcome = "invalid"; } else { await clientIp(); if (!(await rateLimit(`ticket-reply:${userId}`, 5, 60_000)).ok) { outcome = "ratelimit"; } else { const raw = { content: String(formData.get("content") ?? "") .normalize("NFC") .trim() .slice(0, 5000), }; const parsed = replyContentSchema.safeParse(raw); if (!parsed.success) { outcome = "invalid"; } else { const ticket = await prisma.websiteHelpCenterTickets.findUnique({ where: { id: ticketId }, select: { id: true, userId: true, open: true }, }); if (!ticket || ticket.userId !== userId) { outcome = "not_found"; } else if (!ticket.open) { outcome = "closed_ticket"; } else { let moderated = false; try { await moderateOrThrow(parsed.data.content); } catch { moderated = true; outcome = "moderated"; } if (!moderated) { const created = await prisma.$transaction((tx) => createOwnedTicketReply( { findTicket: (id) => tx.websiteHelpCenterTickets.findUnique({ where: { id }, select: { id: true, userId: true, open: true }, }), createReply: (data) => tx.websiteHelpCenterTicketReplies.create({ data, select: { id: true, userId: true, content: true, createdAt: true, }, }), touchTicket: (id, updatedAt) => tx.websiteHelpCenterTickets.update({ where: { id }, data: { updatedAt }, select: { id: true }, }), }, { ticketId, userId, content: parsed.data.content, }, ), ); outcome = created ? "replied" : "not_found"; } } } } } } catch (e) { if (isNextRedirect(e)) throw e; outcome = "error"; } if (ticketId) revalidatePath(`/help/tickets/${ticketId}`); revalidatePath("/help/tickets"); ticketDetailRedirect(ticketId, outcome); } export async function closeHelpTicket(formData: FormData): Promise { const ticketId = positiveBigInt(String(formData.get("ticketId") ?? "")); let outcome: TicketDetailOutcome = "error"; try { const session = await auth(); const userId = Number(session?.user?.id); if (!Number.isInteger(userId) || userId <= 0) { redirect("/login"); } if (!ticketId) { outcome = "invalid"; } else { await clientIp(); if (!(await rateLimit(`ticket-close:${userId}`, 10, 60_000)).ok) { outcome = "ratelimit"; } else { const ticket = await prisma.websiteHelpCenterTickets.findUnique({ where: { id: ticketId }, select: { id: true, userId: true, open: true }, }); if (!ticket || ticket.userId !== userId) { outcome = "not_found"; } else if (!ticket.open) { outcome = "closed_ticket"; } else { const now = new Date(); await prisma.websiteHelpCenterTickets.update({ where: { id: ticketId }, data: { open: false, updatedAt: now }, }); outcome = "closed"; } } } } catch (e) { if (isNextRedirect(e)) throw e; outcome = "error"; } if (ticketId) revalidatePath(`/help/tickets/${ticketId}`); revalidatePath("/help/tickets"); ticketDetailRedirect(ticketId, outcome); }