diff --git a/app/actions/submissions.ts b/app/actions/submissions.ts index 30587e8..9eb9fce 100644 --- a/app/actions/submissions.ts +++ b/app/actions/submissions.ts @@ -1,8 +1,8 @@ "use server"; import { db } from "@/db"; -import { submissions, answers } from "@/db/schema"; -import { and, count, eq, lt, lte, or } from "drizzle-orm"; +import { submissions } from "@/db/schema"; +import { and, eq } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth/auth-options"; @@ -11,8 +11,8 @@ import { sse } from "@/lib/realtime/sse"; import type { FormAnswerPayload } from "@/lib/form/form-answer-values"; import { normalizeSubmissionAnswers, - type StoredAnswerSnapshot, } from "@/lib/form/submission-intake"; +import { writeSubmission } from "@/lib/form/submission-write"; import { submissionWebhookMessage } from "@/lib/form/submission-webhook"; import { isFormAcceptingSubmissions } from "@/lib/form/form-availability"; @@ -64,113 +64,13 @@ export async function submitForm( } } - 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, - }; + const result = await writeSubmission({ + formId, + discordId, + userName, + allowMultipleSubmissions: form.allowMultipleSubmissions, + normalizedAnswers, + targetSubmissionId, }); // Send Discord Webhook diff --git a/app/form/client.tsx b/app/form/client.tsx index 4230edb..22451ca 100644 --- a/app/form/client.tsx +++ b/app/form/client.tsx @@ -59,6 +59,7 @@ import { import { isImageOnlyResponse } from "@/lib/form/question-response-type"; import { buildFormSchema, + formatQueuePosition, parseStoredDraft, prepareInitialState, type FormFillerClientProps, @@ -451,7 +452,9 @@ export function FormFillerClient({ {questions.length} คำถาม {totalAnswers} คำตอบทั้งหมด {queueNumber !== null && ( - ลำดับของคุณ #{queueNumber + 1} + + ลำดับของคุณ {formatQueuePosition(queueNumber)} + )} @@ -996,7 +999,7 @@ export function FormFillerClient({ ส่งคำตอบแล้ว - บันทึกคำตอบของคุณเรียบร้อย คำตอบของคุณอยู่ลำดับที่ #{(queueNumber ?? 0) + 1} จากทั้งหมด{" "} + บันทึกคำตอบของคุณเรียบร้อย คำตอบของคุณอยู่ลำดับที่ {formatQueuePosition(queueNumber ?? 1)} จากทั้งหมด{" "} {totalAnswers} คำตอบ คุณสามารถแก้ไขได้คำตอบตอนไหนก็ได้ที่แบบฟอร์มยังเปิดอยู่ diff --git a/lib/form/form-client.ts b/lib/form/form-client.ts index 78ac7ea..8915536 100644 --- a/lib/form/form-client.ts +++ b/lib/form/form-client.ts @@ -45,6 +45,10 @@ export type FormFillerClientProps = { export type FormValues = Record; +export function formatQueuePosition(queueNumber: number) { + return `#${queueNumber}`; +} + export type StoredDraft = { version: 1; answers: FormValues; diff --git a/lib/form/submission-write.test.ts b/lib/form/submission-write.test.ts new file mode 100644 index 0000000..5bdf846 --- /dev/null +++ b/lib/form/submission-write.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, test } from "bun:test"; +import { formatQueuePosition } from "@/lib/form/form-client"; + +describe("submission queue position", () => { + test("keeps the database one-based position unchanged", () => { + expect(formatQueuePosition(1)).toBe("#1"); + expect(formatQueuePosition(12)).toBe("#12"); + }); +}); diff --git a/lib/form/submission-write.ts b/lib/form/submission-write.ts new file mode 100644 index 0000000..6e34d64 --- /dev/null +++ b/lib/form/submission-write.ts @@ -0,0 +1,141 @@ +import "server-only"; + +import { db } from "@/db"; +import { answers, submissions } from "@/db/schema"; +import { and, count, eq, lt, lte, or } from "drizzle-orm"; +import type { + NormalizedSubmissionAnswer, + StoredAnswerSnapshot, +} from "@/lib/form/submission-intake"; + +export type SubmissionWriteResult = { + submissionId: string; + isUpdate: boolean; + oldAnswersDict: Record; + totalAnswers: number; + queueNumber: number; +}; + +export async function writeSubmission({ + formId, + discordId, + userName, + allowMultipleSubmissions, + normalizedAnswers, + targetSubmissionId, +}: { + formId: string; + discordId: string; + userName: string | null; + allowMultipleSubmissions: boolean; + normalizedAnswers: NormalizedSubmissionAnswer[]; + targetSubmissionId?: string | null; +}): Promise { + return db.transaction(async (tx) => { + const existingSubmission = 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 (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, + }; + }); +}