refactor(forms): isolate submission persistence

This commit is contained in:
2026-08-09 21:13:20 +07:00 Unverified
parent 59140488f6
commit 8756a2ce8a
5 changed files with 169 additions and 112 deletions
+10 -110
View File
@@ -1,8 +1,8 @@
"use server"; "use server";
import { db } from "@/db"; import { db } from "@/db";
import { submissions, answers } from "@/db/schema"; import { submissions } from "@/db/schema";
import { and, count, eq, lt, lte, or } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { getServerSession } from "next-auth"; import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth/auth-options"; 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 type { FormAnswerPayload } from "@/lib/form/form-answer-values";
import { import {
normalizeSubmissionAnswers, normalizeSubmissionAnswers,
type StoredAnswerSnapshot,
} from "@/lib/form/submission-intake"; } from "@/lib/form/submission-intake";
import { writeSubmission } from "@/lib/form/submission-write";
import { submissionWebhookMessage } from "@/lib/form/submission-webhook"; import { submissionWebhookMessage } from "@/lib/form/submission-webhook";
import { isFormAcceptingSubmissions } from "@/lib/form/form-availability"; import { isFormAcceptingSubmissions } from "@/lib/form/form-availability";
@@ -64,113 +64,13 @@ export async function submitForm(
} }
} }
const result = await db.transaction(async (tx) => { const result = await writeSubmission({
const existingSubmission = form.allowMultipleSubmissions formId,
? targetSubmissionId discordId,
? await tx.query.submissions.findFirst({ userName,
where: (submission, { eq, and }) => allowMultipleSubmissions: form.allowMultipleSubmissions,
and( normalizedAnswers,
eq(submission.id, targetSubmissionId), 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<string, StoredAnswerSnapshot> = {};
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<Record<string, StoredAnswerSnapshot>>((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 // Send Discord Webhook
+5 -2
View File
@@ -59,6 +59,7 @@ import {
import { isImageOnlyResponse } from "@/lib/form/question-response-type"; import { isImageOnlyResponse } from "@/lib/form/question-response-type";
import { import {
buildFormSchema, buildFormSchema,
formatQueuePosition,
parseStoredDraft, parseStoredDraft,
prepareInitialState, prepareInitialState,
type FormFillerClientProps, type FormFillerClientProps,
@@ -451,7 +452,9 @@ export function FormFillerClient({
<span>{questions.length} คำถาม</span> <span>{questions.length} คำถาม</span>
<span>{totalAnswers} คำตอบทั้งหมด</span> <span>{totalAnswers} คำตอบทั้งหมด</span>
{queueNumber !== null && ( {queueNumber !== null && (
<Badge variant="outline">ลำดับของคุณ #{queueNumber + 1}</Badge> <Badge variant="outline">
ลำดับของคุณ {formatQueuePosition(queueNumber)}
</Badge>
)} )}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@@ -996,7 +999,7 @@ export function FormFillerClient({
ส่งคำตอบแล้ว ส่งคำตอบแล้ว
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
บันทึกคำตอบของคุณเรียบร้อย คำตอบของคุณอยู่ลำดับที่ #{(queueNumber ?? 0) + 1} จากทั้งหมด{" "} บันทึกคำตอบของคุณเรียบร้อย คำตอบของคุณอยู่ลำดับที่ {formatQueuePosition(queueNumber ?? 1)} จากทั้งหมด{" "}
{totalAnswers} คำตอบ คุณสามารถแก้ไขได้คำตอบตอนไหนก็ได้ที่แบบฟอร์มยังเปิดอยู่ {totalAnswers} คำตอบ คุณสามารถแก้ไขได้คำตอบตอนไหนก็ได้ที่แบบฟอร์มยังเปิดอยู่
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
+4
View File
@@ -45,6 +45,10 @@ export type FormFillerClientProps = {
export type FormValues = Record<string, string | string[] | undefined>; export type FormValues = Record<string, string | string[] | undefined>;
export function formatQueuePosition(queueNumber: number) {
return `#${queueNumber}`;
}
export type StoredDraft = { export type StoredDraft = {
version: 1; version: 1;
answers: FormValues; answers: FormValues;
+9
View File
@@ -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");
});
});
+141
View File
@@ -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<string, StoredAnswerSnapshot>;
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<SubmissionWriteResult> {
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<string, StoredAnswerSnapshot> = {};
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<Record<string, StoredAnswerSnapshot>>(
(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,
};
});
}