"use server"; import { db } from "@/db"; import { submissions, answers } from "@/db/schema"; import { and, count, eq, lt, lte, or } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth-options"; import { requireAdmin, requireDiscordId, requireFormAccess } from "@/lib/auth"; import { sse } from "@/lib/sse"; import type { FormAnswerPayload } from "@/lib/form-answer-values"; import { normalizeSubmissionAnswers, type StoredAnswerSnapshot, } from "@/lib/submission-intake"; import { submissionWebhookMessage } from "@/lib/submission-webhook"; import { isFormAcceptingSubmissions } from "@/lib/form-availability"; function getSubmissionResultLink(formId: string, submissionId: string) { const baseUrl = process.env.BASE_URL || process.env.NEXTAUTH_URL; if (!baseUrl) return `/admin/form/${formId}/result/${submissionId}`; return new URL(`/admin/form/${formId}/result/${submissionId}`, baseUrl).toString(); } export async function submitForm( formId: string, answersList: FormAnswerPayload[], targetSubmissionId?: string | null ) { 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 (!isFormAcceptingSubmissions(form)) { throw new Error("This form is closed for submissions"); } 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 normalizedAnswers = normalizeSubmissionAnswers(questionsList, answersList); const imageCdnIds = [ ...new Set(normalizedAnswers.map((answer) => answer.imageCdnId).filter(Boolean)), ] as string[]; if (imageCdnIds.length > 0) { const ownedImages = await db.query.cdn.findMany({ where: (record, { and, eq, inArray }) => and( inArray(record.id, imageCdnIds), eq(record.ownerDiscordId, discordId) ), columns: { id: true }, }); if (ownedImages.length !== imageCdnIds.length) { throw new Error("ข้อมูลรูปภาพไม่ถูกต้อง"); } } const result = await db.transaction(async (tx) => { const existingSubmission = form.allowMultipleSubmissions ? targetSubmissionId ? await tx.query.submissions.findFirst({ where: (submission, { eq, and }) => and( eq(submission.id, targetSubmissionId), eq(submission.formId, formId), eq(submission.userDiscordId, discordId) ), }) : null : await tx.query.submissions.findFirst({ where: (submission, { eq, and }) => and(eq(submission.formId, formId), eq(submission.userDiscordId, discordId)), }); if (form.allowMultipleSubmissions && targetSubmissionId && !existingSubmission) { throw new Error("ไม่พบคำตอบที่ต้องการแก้ไข"); } let submissionId = ""; let submittedAt = new Date(); const isUpdate = Boolean(existingSubmission); let oldAnswersDict: Record = {}; if (existingSubmission) { submissionId = existingSubmission.id; submittedAt = existingSubmission.submittedAt; const oldAnswersList = await tx.query.answers.findMany({ where: (answer, { eq }) => eq(answer.submissionId, submissionId), }); oldAnswersDict = oldAnswersList.reduce>((acc, curr) => { acc[curr.questionId] = { value: curr.value, imageCdnId: curr.imageCdnId ?? null, }; 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; submittedAt = inserted.submittedAt; } if (normalizedAnswers.length > 0) { await tx.insert(answers).values( normalizedAnswers.map((answer) => ({ submissionId, questionId: answer.questionId, value: answer.value, imageCdnId: answer.imageCdnId, })) ); } const [submissionStats] = await tx .select({ totalAnswers: count() }) .from(submissions) .where(eq(submissions.formId, formId)); const [queueStats] = await tx .select({ queueNumber: count() }) .from(submissions) .where( and( eq(submissions.formId, formId), or( lt(submissions.submittedAt, submittedAt), and( eq(submissions.submittedAt, submittedAt), lte(submissions.id, submissionId) ) ) ) ); return { submissionId, isUpdate, oldAnswersDict, totalAnswers: submissionStats.totalAnswers, queueNumber: queueStats.queueNumber, }; }); // Send Discord Webhook const templateToUse = result.isUpdate ? form.discordWebhookUpdateTemplate : form.discordWebhookTemplate; if (form.discordWebhookUrl) { try { const submissionLink = getSubmissionResultLink(formId, result.submissionId); const message = submissionWebhookMessage({ template: templateToUse, isUpdate: result.isUpdate, userName: session.user.name, discordId, submissionLink, questions: questionsList, answers: normalizedAnswers, oldAnswersByQuestionId: result.oldAnswersDict, }); const response = await fetch(form.discordWebhookUrl, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ content: message, }), }); if (!response.ok) { const responseBody = await response.text(); console.error("Discord webhook rejected submission notification", { status: response.status, statusText: response.statusText, response: responseBody.slice(0, 500), }); } } 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, totalAnswers: result.totalAnswers, queueNumber: result.queueNumber, }; } 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, submissionId?: string | null) { const discordId = await requireDiscordId(); const form = await requireFormAccess(formId, discordId); if (form.allowMultipleSubmissions && !submissionId) { throw new Error("ไม่พบคำตอบที่ต้องการลบ"); } const where = form.allowMultipleSubmissions ? and( eq(submissions.id, submissionId!), eq(submissions.formId, formId), eq(submissions.userDiscordId, discordId) ) : and( eq(submissions.formId, formId), eq(submissions.userDiscordId, discordId) ); const deleted = await db.delete(submissions).where(where).returning({ id: submissions.id }); if (form.allowMultipleSubmissions && deleted.length === 0) { throw new Error("ไม่พบคำตอบที่ต้องการลบ"); } void sse.submissions.pub("update", { formId, submissionId: submissionId ?? undefined, action: "deleted", }); revalidatePath(`/form`); revalidatePath(`/admin/form/${formId}/result`); return { ok: true }; }