feat: improve form response experience

This commit is contained in:
2026-07-24 22:11:42 +07:00 Unverified
parent 29df784aeb
commit 7a9e67e283
12 changed files with 1279 additions and 349 deletions
+115 -16
View File
@@ -8,10 +8,17 @@ 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: string }[]
answersList: { questionId: string; value: FormAnswerValue }[]
) {
const session = await getServerSession(authOptions);
if (!session?.user) throw new Error("Not authenticated");
@@ -21,6 +28,96 @@ export async function submitForm(
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 }) =>
@@ -69,9 +166,9 @@ export async function submitForm(
submissionId = inserted.id;
}
if (answersList.length > 0) {
if (normalizedAnswers.length > 0) {
await tx.insert(answers).values(
answersList.map((answer) => ({
normalizedAnswers.map((answer) => ({
submissionId,
questionId: answer.questionId,
value: answer.value,
@@ -89,19 +186,15 @@ export async function submitForm(
if (form.discordWebhookUrl && templateToUse) {
try {
const questionsList = await db.query.questions.findMany({
where: (q, { eq }) => eq(q.formId, formId),
orderBy: (q, { asc }) => asc(q.displayOrder)
});
const questionMap = questionsList.reduce((acc, q, index) => {
const questionOrderMap = questionsList.reduce((acc, q, index) => {
acc[q.id] = String(index + 1);
return acc;
}, {} as Record<string, string>);
const answersDict = answersList.reduce((acc, a) => {
const idx = questionMap[a.questionId];
const answersDict = normalizedAnswers.reduce((acc, answer) => {
const idx = questionOrderMap[answer.questionId];
if (idx) {
acc[idx] = a.value;
acc[idx] = answer.displayValue;
}
return acc;
}, {} as Record<string, string>);
@@ -109,11 +202,17 @@ export async function submitForm(
let updatesText = "";
if (result.isUpdate) {
const changed: string[] = [];
for (const ans of answersList) {
const oldVal = result.oldAnswersDict[ans.questionId];
if (oldVal !== ans.value) {
const qTitle = questionsList.find(q => q.id === ans.questionId)?.label || `Question`;
changed.push(`**${qTitle}**: \`${oldVal || "Empty"}\` ➡️ \`${ans.value || "Empty"}\``);
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.";