refactor(forms): isolate submission persistence
This commit is contained in:
+8
-108
@@ -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
|
|
||||||
? 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<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,
|
formId,
|
||||||
userDiscordId: discordId,
|
discordId,
|
||||||
userName,
|
userName,
|
||||||
})
|
allowMultipleSubmissions: form.allowMultipleSubmissions,
|
||||||
.returning();
|
normalizedAnswers,
|
||||||
submissionId = inserted.id;
|
targetSubmissionId,
|
||||||
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
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user