Files
erika/app/actions/submissions.ts
T

310 lines
9.8 KiB
TypeScript

"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<string>();
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<string, string> = {};
if (existingSubmission) {
submissionId = existingSubmission.id;
const oldAnswersList = await tx.query.answers.findMany({
where: (answer, { eq }) => eq(answer.submissionId, submissionId),
});
oldAnswersDict = oldAnswersList.reduce<Record<string, string>>((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<string, string>);
const answersDict = normalizedAnswers.reduce((acc, answer) => {
const idx = questionOrderMap[answer.questionId];
if (idx) {
acc[idx] = answer.displayValue;
}
return acc;
}, {} as Record<string, string>);
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 };
}