"use server"; import { db } from "@/db"; import { submissions, answers } from "@/db/schema"; import { eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; import { requireAdmin, requireDiscordId, requireFormAccess } from "@/lib/auth"; import { sse } from "@/lib/sse"; import { decodeCheckboxAnswer, encodeAnswerValue, formatStoredAnswer, type FormAnswerValue, } from "@/lib/form-answer-values"; import { stripHtml } from "@/lib/utils"; export async function submitForm( formId: string, answersList: { questionId: string; value: FormAnswerValue }[] ) { const session = await getServerSession(authOptions); if (!session?.user) throw new Error("Not authenticated"); const discordId = await requireDiscordId(); const userName = session.user.name ?? null; const form = await requireFormAccess(formId, discordId); if (!form.isOpen) throw new Error("This form is disabled for now"); const questionsList = await db.query.questions.findMany({ where: (question, { eq }) => eq(question.formId, formId), orderBy: (question, { asc }) => asc(question.displayOrder), }); if (questionsList.length === 0) { throw new Error("แบบฟอร์มนี้ยังไม่มีคำถาม"); } const questionsById = new Map(questionsList.map((question) => [question.id, question])); const submittedQuestionIds = new Set(); for (const answer of answersList) { if (!questionsById.has(answer.questionId) || submittedQuestionIds.has(answer.questionId)) { throw new Error("ข้อมูลคำตอบไม่ตรงกับแบบฟอร์ม"); } submittedQuestionIds.add(answer.questionId); } const submittedAnswers = new Map( answersList.map((answer) => [answer.questionId, answer.value]) ); const normalizedAnswers = questionsList.map((question) => { const rawValue = submittedAnswers.get(question.id); if (question.type === "checkbox") { if ( rawValue !== undefined && typeof rawValue !== "string" && (!Array.isArray(rawValue) || rawValue.some((value) => typeof value !== "string")) ) { throw new Error(`รูปแบบคำตอบของ “${stripHtml(question.label)}” ไม่ถูกต้อง`); } const selectedValues = [...new Set(decodeCheckboxAnswer(rawValue))]; if ( selectedValues.some( (value) => !value.trim() || value === "__OTHER__" ) ) { throw new Error(`โปรดระบุคำตอบสำหรับ “${stripHtml(question.label)}”`); } if (question.required && selectedValues.length === 0) { throw new Error(`จำเป็นต้องตอบ “${stripHtml(question.label)}”`); } const standardOptions = question.options ?? []; const customValues = selectedValues.filter( (value) => !standardOptions.includes(value) ); if ( (!question.allowOther && customValues.length > 0) || customValues.length > 1 ) { throw new Error(`ตัวเลือกของ “${stripHtml(question.label)}” ไม่ถูกต้อง`); } return { questionId: question.id, value: encodeAnswerValue(selectedValues), displayValue: selectedValues.join(", "), }; } if (rawValue !== undefined && typeof rawValue !== "string") { throw new Error(`รูปแบบคำตอบของ “${stripHtml(question.label)}” ไม่ถูกต้อง`); } const value = rawValue ?? ""; if (value === "__OTHER__") { throw new Error(`โปรดระบุคำตอบสำหรับ “${stripHtml(question.label)}”`); } if (question.required && !value.trim()) { throw new Error(`จำเป็นต้องตอบ “${stripHtml(question.label)}”`); } if ( question.type === "radio" && value && !(question.options ?? []).includes(value) && !question.allowOther ) { throw new Error(`ตัวเลือกของ “${stripHtml(question.label)}” ไม่ถูกต้อง`); } return { questionId: question.id, value, displayValue: value, }; }); const result = await db.transaction(async (tx) => { const existingSubmission = await tx.query.submissions.findFirst({ where: (submission, { eq, and }) => and(eq(submission.formId, formId), eq(submission.userDiscordId, discordId)), }); let submissionId = ""; const isUpdate = Boolean(existingSubmission); let oldAnswersDict: Record = {}; if (existingSubmission) { submissionId = existingSubmission.id; const oldAnswersList = await tx.query.answers.findMany({ where: (answer, { eq }) => eq(answer.submissionId, submissionId), }); oldAnswersDict = oldAnswersList.reduce>((acc, curr) => { acc[curr.questionId] = curr.value; return acc; }, {}); const history = Array.isArray(existingSubmission.editHistory) ? [...existingSubmission.editHistory] : []; history.push({ editedAt: new Date().toISOString(), oldAnswers: oldAnswersDict, }); await tx .update(submissions) .set({ editHistory: history }) .where(eq(submissions.id, submissionId)); await tx.delete(answers).where(eq(answers.submissionId, submissionId)); } else { const [inserted] = await tx .insert(submissions) .values({ formId, userDiscordId: discordId, userName, }) .returning(); submissionId = inserted.id; } if (normalizedAnswers.length > 0) { await tx.insert(answers).values( normalizedAnswers.map((answer) => ({ submissionId, questionId: answer.questionId, value: answer.value, })) ); } return { submissionId, isUpdate, oldAnswersDict }; }); // Send Discord Webhook const templateToUse = result.isUpdate ? form.discordWebhookUpdateTemplate : form.discordWebhookTemplate; if (form.discordWebhookUrl && templateToUse) { try { const questionOrderMap = questionsList.reduce((acc, q, index) => { acc[q.id] = String(index + 1); return acc; }, {} as Record); const answersDict = normalizedAnswers.reduce((acc, answer) => { const idx = questionOrderMap[answer.questionId]; if (idx) { acc[idx] = answer.displayValue; } return acc; }, {} as Record); let updatesText = ""; if (result.isUpdate) { const changed: string[] = []; for (const answer of normalizedAnswers) { const question = questionsById.get(answer.questionId); const oldValue = result.oldAnswersDict[answer.questionId]; const oldDisplayValue = question ? formatStoredAnswer(oldValue, question.type) : oldValue; if (oldDisplayValue !== answer.displayValue) { const questionTitle = stripHtml(question?.label || "Question"); changed.push( `**${questionTitle}**: \`${oldDisplayValue || "Empty"}\` ➡️ \`${answer.displayValue || "Empty"}\`` ); } } updatesText = changed.length > 0 ? changed.join("\n") : "No changes made."; } let message = templateToUse; // Replace {1}, {2}, etc. with value, and new variables message = message.replace(/\{([^}]+)\}/g, (match: string, p1: string) => { const key = p1.toLowerCase(); if (key === "username" || key === "displayname") { return session.user?.name || "Unknown User"; } if (key === "id") { return discordId || "Unknown ID"; } if (key === "update") { return updatesText; } return answersDict[p1] || "Not Provided"; }); await fetch(form.discordWebhookUrl, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ content: message, }), }); } catch (e) { console.error("Failed to send webhook", e); } } void sse.submissions.pub("update", { formId, submissionId: result.submissionId, action: result.isUpdate ? "updated" : "created", }); revalidatePath(`/admin/form/${formId}/result`); return { ok: true }; } export async function deleteSubmission(id: string) { await requireAdmin(); const submission = await db.query.submissions.findFirst({ where: (submission, { eq }) => eq(submission.id, id), columns: { formId: true }, }); await db.delete(submissions).where(eq(submissions.id, id)); if (submission) { void sse.submissions.pub("update", { formId: submission.formId, submissionId: id, action: "deleted", }); } revalidatePath("/admin/form"); return { ok: true }; } export async function deleteAllSubmissions(formId: string) { await requireAdmin(); await db.delete(submissions).where(eq(submissions.formId, formId)); void sse.submissions.pub("update", { formId, action: "deleted", }); revalidatePath(`/admin/form/${formId}/result`); return { ok: true }; } export async function deleteOwnSubmission(formId: string) { const discordId = await requireDiscordId(); const { and } = await import("drizzle-orm"); await db.delete(submissions).where( and( eq(submissions.formId, formId), eq(submissions.userDiscordId, discordId) ) ); void sse.submissions.pub("update", { formId, action: "deleted", }); revalidatePath(`/form`); revalidatePath(`/admin/form/${formId}/result`); return { ok: true }; }