214 lines
5.8 KiB
TypeScript
214 lines
5.8 KiB
TypeScript
"use server";
|
|
|
|
import { db } from "@/db";
|
|
import { forms, submissions } from "@/db/schema";
|
|
import { and, asc, count, eq, ilike, or } from "drizzle-orm";
|
|
import { revalidatePath } from "next/cache";
|
|
import { redirect } from "next/navigation";
|
|
import { requireAdmin } from "@/lib/auth/auth";
|
|
import { sanitizeHtml } from "@/lib/sanitize-html";
|
|
import { sse } from "@/lib/realtime/sse";
|
|
import { isValidDiscordWebhookUrl } from "@/lib/discord/discord-webhook-settings";
|
|
|
|
const RESPONSE_NAVIGATION_PAGE_SIZE = 50;
|
|
|
|
export async function getResponseNavigation(
|
|
formId: string,
|
|
page = 1,
|
|
query = "",
|
|
) {
|
|
await requireAdmin();
|
|
const safeQuery = query.trim().slice(0, 100);
|
|
const requestedPage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
|
|
const where = and(
|
|
eq(submissions.formId, formId),
|
|
safeQuery
|
|
? or(
|
|
ilike(submissions.userName, `%${safeQuery}%`),
|
|
ilike(submissions.userDiscordId, `%${safeQuery}%`),
|
|
)
|
|
: undefined,
|
|
);
|
|
const [{ total }] = await db
|
|
.select({ total: count() })
|
|
.from(submissions)
|
|
.where(where);
|
|
const pageCount = Math.max(
|
|
1,
|
|
Math.ceil(total / RESPONSE_NAVIGATION_PAGE_SIZE),
|
|
);
|
|
const currentPage = Math.min(requestedPage, pageCount);
|
|
const rows = await db
|
|
.select({
|
|
id: submissions.id,
|
|
userName: submissions.userName,
|
|
userDiscordId: submissions.userDiscordId,
|
|
})
|
|
.from(submissions)
|
|
.where(where)
|
|
.orderBy(asc(submissions.submittedAt), asc(submissions.id))
|
|
.limit(RESPONSE_NAVIGATION_PAGE_SIZE)
|
|
.offset((currentPage - 1) * RESPONSE_NAVIGATION_PAGE_SIZE);
|
|
|
|
return {
|
|
items: rows.map((row, index) => ({
|
|
id: row.id,
|
|
displayName: row.userName || row.userDiscordId || "Anonymous",
|
|
queueNumber:
|
|
(currentPage - 1) * RESPONSE_NAVIGATION_PAGE_SIZE + index + 1,
|
|
})),
|
|
page: currentPage,
|
|
pageCount,
|
|
total,
|
|
};
|
|
}
|
|
|
|
export async function createForm(formData: FormData) {
|
|
await requireAdmin();
|
|
|
|
const title = formData.get("title") as string;
|
|
const description = formData.get("description") as string | null;
|
|
|
|
const [form] = await db
|
|
.insert(forms)
|
|
.values({ title, description: description ? sanitizeHtml(description) : null })
|
|
.returning();
|
|
|
|
void sse.forms.pub("update", {
|
|
formId: form.id,
|
|
entity: "form",
|
|
action: "created",
|
|
});
|
|
revalidatePath("/admin/form");
|
|
redirect(`/admin/form/${form.id}`);
|
|
}
|
|
|
|
export async function createDefaultForm() {
|
|
await requireAdmin();
|
|
|
|
const [form] = await db
|
|
.insert(forms)
|
|
.values({ title: "New Form", description: "" })
|
|
.returning();
|
|
|
|
void sse.forms.pub("update", {
|
|
formId: form.id,
|
|
entity: "form",
|
|
action: "created",
|
|
});
|
|
revalidatePath("/admin/form");
|
|
redirect(`/admin/form/${form.id}`);
|
|
}
|
|
|
|
export async function updateForm(id: string, data: { title?: string; description?: string | null; isOpen?: boolean; closesAt?: string | null; allowMultipleSubmissions?: boolean; discordWebhookUrl?: string | null; discordWebhookTemplate?: string | null; discordWebhookUpdateTemplate?: string | null; allowedRoles?: string[]; deniedRoles?: string[] }) {
|
|
await requireAdmin();
|
|
if (data.title !== undefined && !data.title.trim()) {
|
|
throw new Error("Form title is required.");
|
|
}
|
|
if (
|
|
data.discordWebhookUrl &&
|
|
!isValidDiscordWebhookUrl(data.discordWebhookUrl)
|
|
) {
|
|
throw new Error("Enter a valid Discord webhook URL.");
|
|
}
|
|
|
|
const closesAt = data.closesAt === undefined
|
|
? undefined
|
|
: data.closesAt === null
|
|
? null
|
|
: new Date(data.closesAt);
|
|
if (closesAt instanceof Date && Number.isNaN(closesAt.getTime())) {
|
|
throw new Error("Enter a valid closing date and time.");
|
|
}
|
|
|
|
const nextData = {
|
|
...data,
|
|
closesAt,
|
|
description: data.description === undefined
|
|
? undefined
|
|
: data.description
|
|
? sanitizeHtml(data.description)
|
|
: data.description,
|
|
};
|
|
|
|
await db.update(forms).set(nextData).where(eq(forms.id, id));
|
|
void sse.forms.pub("update", {
|
|
formId: id,
|
|
entity: "form",
|
|
action: "updated",
|
|
});
|
|
revalidatePath("/form");
|
|
revalidatePath("/admin/form");
|
|
}
|
|
|
|
export async function updateSubmissionAdminNote(
|
|
formId: string,
|
|
submissionId: string,
|
|
note: string
|
|
) {
|
|
await requireAdmin();
|
|
|
|
if (
|
|
typeof formId !== "string" ||
|
|
!formId ||
|
|
typeof submissionId !== "string" ||
|
|
!submissionId ||
|
|
typeof note !== "string" ||
|
|
note.length > 10_000
|
|
) {
|
|
throw new Error("Invalid admin note.");
|
|
}
|
|
|
|
const [updatedSubmission] = await db
|
|
.update(submissions)
|
|
.set({ adminNote: note.trim() || null })
|
|
.where(
|
|
and(
|
|
eq(submissions.id, submissionId),
|
|
eq(submissions.formId, formId)
|
|
)
|
|
)
|
|
.returning({ id: submissions.id });
|
|
|
|
if (!updatedSubmission) {
|
|
throw new Error("Submission not found.");
|
|
}
|
|
|
|
revalidatePath(`/admin/form/${formId}/result`);
|
|
revalidatePath(`/admin/form/${formId}/result/${submissionId}`);
|
|
return { ok: true };
|
|
}
|
|
|
|
export async function testDiscordWebhook(url: string, content: string) {
|
|
await requireAdmin();
|
|
const trimmedUrl = url.trim();
|
|
if (!isValidDiscordWebhookUrl(trimmedUrl)) {
|
|
throw new Error("Enter a valid Discord webhook URL.");
|
|
}
|
|
if (!content.trim()) {
|
|
throw new Error("The test message is empty.");
|
|
}
|
|
|
|
const response = await fetch(trimmedUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ content: content.slice(0, 2000) }),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Discord rejected the test message (${response.status}).`);
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
export async function deleteForm(id: string) {
|
|
await requireAdmin();
|
|
|
|
await db.delete(forms).where(eq(forms.id, id));
|
|
void sse.forms.pub("update", {
|
|
formId: id,
|
|
entity: "form",
|
|
action: "deleted",
|
|
});
|
|
revalidatePath("/admin/form");
|
|
}
|