293 lines
9.1 KiB
TypeScript
293 lines
9.1 KiB
TypeScript
"use server";
|
|
|
|
import { db } from "@/db";
|
|
import { submissions, answers } from "@/db/schema";
|
|
import { and, count, eq, lt, lte, or } from "drizzle-orm";
|
|
import { revalidatePath } from "next/cache";
|
|
import { getServerSession } from "next-auth";
|
|
import { authOptions } from "@/lib/auth-options";
|
|
import { requireAdmin, requireDiscordId, requireFormAccess } from "@/lib/auth";
|
|
import { sse } from "@/lib/sse";
|
|
import type { FormAnswerPayload } from "@/lib/form-answer-values";
|
|
import {
|
|
normalizeSubmissionAnswers,
|
|
type StoredAnswerSnapshot,
|
|
} from "@/lib/submission-intake";
|
|
import { submissionWebhookMessage } from "@/lib/submission-webhook";
|
|
import { isFormAcceptingSubmissions } from "@/lib/form-availability";
|
|
|
|
function getSubmissionResultLink(formId: string, submissionId: string) {
|
|
const baseUrl = process.env.BASE_URL || process.env.NEXTAUTH_URL;
|
|
if (!baseUrl) return `/admin/form/${formId}/result/${submissionId}`;
|
|
return new URL(`/admin/form/${formId}/result/${submissionId}`, baseUrl).toString();
|
|
}
|
|
|
|
export async function submitForm(
|
|
formId: string,
|
|
answersList: FormAnswerPayload[],
|
|
targetSubmissionId?: string | null
|
|
) {
|
|
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 (!isFormAcceptingSubmissions(form)) {
|
|
throw new Error("This form is closed for submissions");
|
|
}
|
|
|
|
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 normalizedAnswers = normalizeSubmissionAnswers(questionsList, answersList);
|
|
const imageCdnIds = [
|
|
...new Set(normalizedAnswers.map((answer) => answer.imageCdnId).filter(Boolean)),
|
|
] as string[];
|
|
|
|
if (imageCdnIds.length > 0) {
|
|
const ownedImages = await db.query.cdn.findMany({
|
|
where: (record, { and, eq, inArray }) =>
|
|
and(
|
|
inArray(record.id, imageCdnIds),
|
|
eq(record.ownerDiscordId, discordId)
|
|
),
|
|
columns: { id: true },
|
|
});
|
|
if (ownedImages.length !== imageCdnIds.length) {
|
|
throw new Error("ข้อมูลรูปภาพไม่ถูกต้อง");
|
|
}
|
|
}
|
|
|
|
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<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
|
|
const templateToUse = result.isUpdate
|
|
? form.discordWebhookUpdateTemplate
|
|
: form.discordWebhookTemplate;
|
|
|
|
if (form.discordWebhookUrl) {
|
|
try {
|
|
const submissionLink = getSubmissionResultLink(formId, result.submissionId);
|
|
const message = submissionWebhookMessage({
|
|
template: templateToUse,
|
|
isUpdate: result.isUpdate,
|
|
userName: session.user.name,
|
|
discordId,
|
|
submissionLink,
|
|
questions: questionsList,
|
|
answers: normalizedAnswers,
|
|
oldAnswersByQuestionId: result.oldAnswersDict,
|
|
});
|
|
|
|
const response = await fetch(form.discordWebhookUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
content: message,
|
|
}),
|
|
});
|
|
if (!response.ok) {
|
|
const responseBody = await response.text();
|
|
console.error("Discord webhook rejected submission notification", {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
response: responseBody.slice(0, 500),
|
|
});
|
|
}
|
|
} 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,
|
|
totalAnswers: result.totalAnswers,
|
|
queueNumber: result.queueNumber,
|
|
};
|
|
}
|
|
|
|
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, submissionId?: string | null) {
|
|
const discordId = await requireDiscordId();
|
|
|
|
const form = await requireFormAccess(formId, discordId);
|
|
if (form.allowMultipleSubmissions && !submissionId) {
|
|
throw new Error("ไม่พบคำตอบที่ต้องการลบ");
|
|
}
|
|
|
|
const where = form.allowMultipleSubmissions
|
|
? and(
|
|
eq(submissions.id, submissionId!),
|
|
eq(submissions.formId, formId),
|
|
eq(submissions.userDiscordId, discordId)
|
|
)
|
|
: and(
|
|
eq(submissions.formId, formId),
|
|
eq(submissions.userDiscordId, discordId)
|
|
);
|
|
|
|
const deleted = await db.delete(submissions).where(where).returning({ id: submissions.id });
|
|
if (form.allowMultipleSubmissions && deleted.length === 0) {
|
|
throw new Error("ไม่พบคำตอบที่ต้องการลบ");
|
|
}
|
|
void sse.submissions.pub("update", {
|
|
formId,
|
|
submissionId: submissionId ?? undefined,
|
|
action: "deleted",
|
|
});
|
|
revalidatePath(`/form`);
|
|
revalidatePath(`/admin/form/${formId}/result`);
|
|
return { ok: true };
|
|
}
|