"use server"; import { db } from "@/db"; import { forms, submissions } from "@/db/schema"; import { and, asc, count, eq, ilike, or } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { requireAdmin } from "@/lib/auth/auth"; import { sanitizeHtml } from "@/lib/sanitize-html"; import { sse } from "@/lib/realtime/sse"; import { isValidDiscordWebhookUrl } from "@/lib/discord/discord-webhook-settings"; const RESPONSE_NAVIGATION_PAGE_SIZE = 50; export async function getResponseNavigation( formId: string, page = 1, query = "", ) { await requireAdmin(); const safeQuery = query.trim().slice(0, 100); const requestedPage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1; const where = and( eq(submissions.formId, formId), safeQuery ? or( ilike(submissions.userName, `%${safeQuery}%`), ilike(submissions.userDiscordId, `%${safeQuery}%`), ) : undefined, ); const [{ total }] = await db .select({ total: count() }) .from(submissions) .where(where); const pageCount = Math.max( 1, Math.ceil(total / RESPONSE_NAVIGATION_PAGE_SIZE), ); const currentPage = Math.min(requestedPage, pageCount); const rows = await db .select({ id: submissions.id, userName: submissions.userName, userDiscordId: submissions.userDiscordId, }) .from(submissions) .where(where) .orderBy(asc(submissions.submittedAt), asc(submissions.id)) .limit(RESPONSE_NAVIGATION_PAGE_SIZE) .offset((currentPage - 1) * RESPONSE_NAVIGATION_PAGE_SIZE); return { items: rows.map((row, index) => ({ id: row.id, displayName: row.userName || row.userDiscordId || "Anonymous", queueNumber: (currentPage - 1) * RESPONSE_NAVIGATION_PAGE_SIZE + index + 1, })), page: currentPage, pageCount, total, }; } export async function createForm(formData: FormData) { await requireAdmin(); const title = formData.get("title") as string; const description = formData.get("description") as string | null; const [form] = await db .insert(forms) .values({ title, description: description ? sanitizeHtml(description) : null }) .returning(); void sse.forms.pub("update", { formId: form.id, entity: "form", action: "created", }); revalidatePath("/admin/form"); redirect(`/admin/form/${form.id}`); } export async function createDefaultForm() { await requireAdmin(); const [form] = await db .insert(forms) .values({ title: "New Form", description: "" }) .returning(); void sse.forms.pub("update", { formId: form.id, entity: "form", action: "created", }); revalidatePath("/admin/form"); redirect(`/admin/form/${form.id}`); } export async function updateForm(id: string, data: { title?: string; description?: string | null; isOpen?: boolean; closesAt?: string | null; allowMultipleSubmissions?: boolean; discordWebhookUrl?: string | null; discordWebhookTemplate?: string | null; discordWebhookUpdateTemplate?: string | null; allowedRoles?: string[]; deniedRoles?: string[] }) { await requireAdmin(); if (data.title !== undefined && !data.title.trim()) { throw new Error("Form title is required."); } if ( data.discordWebhookUrl && !isValidDiscordWebhookUrl(data.discordWebhookUrl) ) { throw new Error("Enter a valid Discord webhook URL."); } const closesAt = data.closesAt === undefined ? undefined : data.closesAt === null ? null : new Date(data.closesAt); if (closesAt instanceof Date && Number.isNaN(closesAt.getTime())) { throw new Error("Enter a valid closing date and time."); } const nextData = { ...data, closesAt, description: data.description === undefined ? undefined : data.description ? sanitizeHtml(data.description) : data.description, }; await db.update(forms).set(nextData).where(eq(forms.id, id)); void sse.forms.pub("update", { formId: id, entity: "form", action: "updated", }); revalidatePath("/form"); revalidatePath("/admin/form"); } export async function updateSubmissionAdminNote( formId: string, submissionId: string, note: string ) { await requireAdmin(); if ( typeof formId !== "string" || !formId || typeof submissionId !== "string" || !submissionId || typeof note !== "string" || note.length > 10_000 ) { throw new Error("Invalid admin note."); } const [updatedSubmission] = await db .update(submissions) .set({ adminNote: note.trim() || null }) .where( and( eq(submissions.id, submissionId), eq(submissions.formId, formId) ) ) .returning({ id: submissions.id }); if (!updatedSubmission) { throw new Error("Submission not found."); } revalidatePath(`/admin/form/${formId}/result`); revalidatePath(`/admin/form/${formId}/result/${submissionId}`); return { ok: true }; } export async function testDiscordWebhook(url: string, content: string) { await requireAdmin(); const trimmedUrl = url.trim(); if (!isValidDiscordWebhookUrl(trimmedUrl)) { throw new Error("Enter a valid Discord webhook URL."); } if (!content.trim()) { throw new Error("The test message is empty."); } const response = await fetch(trimmedUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: content.slice(0, 2000) }), }); if (!response.ok) { throw new Error(`Discord rejected the test message (${response.status}).`); } return { ok: true }; } export async function deleteForm(id: string) { await requireAdmin(); await db.delete(forms).where(eq(forms.id, id)); void sse.forms.pub("update", { formId: id, entity: "form", action: "deleted", }); revalidatePath("/admin/form"); }