feat: implement form submission system with Drizzle ORM and admin dashboard
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { forms } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
|
||||
function requireAdmin() {
|
||||
// No async check needed here — callers should check session before sensitive ops
|
||||
}
|
||||
|
||||
export async function createForm(formData: FormData) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const adminIds = (process.env.ADMIN_DISCORD_IDS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const discordId = (session?.user as { discordId?: string } | undefined)?.discordId;
|
||||
if (!discordId || !adminIds.includes(discordId)) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
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 ?? null })
|
||||
.returning();
|
||||
|
||||
revalidatePath("/form/admin");
|
||||
redirect(`/form/admin/${form.id}/edit`);
|
||||
}
|
||||
|
||||
export async function updateForm(id: string, data: { title: string; description?: string }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const adminIds = (process.env.ADMIN_DISCORD_IDS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const discordId = (session?.user as { discordId?: string } | undefined)?.discordId;
|
||||
if (!discordId || !adminIds.includes(discordId)) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
await db.update(forms).set(data).where(eq(forms.id, id));
|
||||
revalidatePath("/form");
|
||||
revalidatePath("/form/admin");
|
||||
}
|
||||
|
||||
export async function deleteForm(id: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const adminIds = (process.env.ADMIN_DISCORD_IDS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const discordId = (session?.user as { discordId?: string } | undefined)?.discordId;
|
||||
if (!discordId || !adminIds.includes(discordId)) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
await db.delete(forms).where(eq(forms.id, id));
|
||||
revalidatePath("/form/admin");
|
||||
redirect("/form/admin");
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { questions } from "@/db/schema";
|
||||
import type { QuestionType } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
|
||||
async function assertAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
const adminIds = (process.env.ADMIN_DISCORD_IDS ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
const discordId = (session?.user as { discordId?: string } | undefined)?.discordId;
|
||||
if (!discordId || !adminIds.includes(discordId)) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createQuestion(
|
||||
formId: string,
|
||||
data: {
|
||||
type: QuestionType;
|
||||
label: string;
|
||||
required: boolean;
|
||||
displayOrder: number;
|
||||
options?: string[];
|
||||
}
|
||||
) {
|
||||
await assertAdmin();
|
||||
await db.insert(questions).values({
|
||||
formId,
|
||||
type: data.type,
|
||||
label: data.label,
|
||||
required: data.required,
|
||||
displayOrder: data.displayOrder,
|
||||
options: data.options ?? [],
|
||||
});
|
||||
revalidatePath("/form");
|
||||
revalidatePath("/form/admin");
|
||||
}
|
||||
|
||||
export async function updateQuestion(
|
||||
id: string,
|
||||
formId: string,
|
||||
data: Partial<{
|
||||
label: string;
|
||||
type: QuestionType;
|
||||
required: boolean;
|
||||
displayOrder: number;
|
||||
options: string[];
|
||||
imageUrl: string | null;
|
||||
}>
|
||||
) {
|
||||
await assertAdmin();
|
||||
await db.update(questions).set(data).where(eq(questions.id, id));
|
||||
revalidatePath("/form");
|
||||
revalidatePath("/form/admin");
|
||||
}
|
||||
|
||||
export async function deleteQuestion(id: string, formId: string) {
|
||||
await assertAdmin();
|
||||
await db.delete(questions).where(eq(questions.id, id));
|
||||
revalidatePath("/form");
|
||||
revalidatePath("/form/admin");
|
||||
}
|
||||
|
||||
export async function reorderQuestions(
|
||||
formId: string,
|
||||
orderedIds: string[]
|
||||
) {
|
||||
await assertAdmin();
|
||||
await Promise.all(
|
||||
orderedIds.map((id, index) =>
|
||||
db
|
||||
.update(questions)
|
||||
.set({ displayOrder: index })
|
||||
.where(eq(questions.id, id))
|
||||
)
|
||||
);
|
||||
revalidatePath("/form");
|
||||
revalidatePath("/form/admin");
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { submissions, answers } from "@/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
|
||||
export async function submitForm(
|
||||
formId: string,
|
||||
answersList: { questionId: string; value: string }[]
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) throw new Error("Not authenticated");
|
||||
|
||||
const discordId = (session.user as { discordId?: string }).discordId;
|
||||
if (!discordId) throw new Error("No Discord ID found");
|
||||
|
||||
const existingSubmission = await db.query.submissions.findFirst({
|
||||
where: (s, { eq, and }) =>
|
||||
and(eq(s.formId, formId), eq(s.userDiscordId, discordId)),
|
||||
});
|
||||
|
||||
let submissionId = "";
|
||||
|
||||
if (existingSubmission) {
|
||||
submissionId = existingSubmission.id;
|
||||
|
||||
// Fetch old answers
|
||||
const oldAnswersList = await db.query.answers.findMany({
|
||||
where: (a, { eq }) => eq(a.submissionId, submissionId),
|
||||
});
|
||||
|
||||
const oldAnswersDict = oldAnswersList.reduce((acc, curr) => {
|
||||
acc[curr.questionId] = curr.value;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
// Ensure editHistory is an array
|
||||
let history = existingSubmission.editHistory;
|
||||
if (!Array.isArray(history)) {
|
||||
history = [];
|
||||
}
|
||||
|
||||
// Push the old answers to history
|
||||
history.push({
|
||||
editedAt: new Date().toISOString(),
|
||||
oldAnswers: oldAnswersDict,
|
||||
});
|
||||
|
||||
// Update submission record
|
||||
await db
|
||||
.update(submissions)
|
||||
.set({
|
||||
editHistory: history,
|
||||
})
|
||||
.where(eq(submissions.id, submissionId));
|
||||
|
||||
// Delete old answers
|
||||
await db.delete(answers).where(eq(answers.submissionId, submissionId));
|
||||
} else {
|
||||
// Insert new submission
|
||||
const [inserted] = await db
|
||||
.insert(submissions)
|
||||
.values({
|
||||
formId,
|
||||
userDiscordId: discordId,
|
||||
userName: session.user.name ?? null,
|
||||
})
|
||||
.returning();
|
||||
submissionId = inserted.id;
|
||||
}
|
||||
|
||||
// Insert new/updated answers
|
||||
if (answersList.length > 0) {
|
||||
await db.insert(answers).values(
|
||||
answersList.map((a) => ({
|
||||
submissionId,
|
||||
questionId: a.questionId,
|
||||
value: a.value,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
revalidatePath("/form/admin/result");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteSubmission(id: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) throw new Error("Not authenticated");
|
||||
|
||||
await db.delete(submissions).where(eq(submissions.id, id));
|
||||
revalidatePath("/form/admin/result");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteAllSubmissions() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) throw new Error("Not authenticated");
|
||||
|
||||
await db.delete(submissions);
|
||||
revalidatePath("/form/admin/result");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use server";
|
||||
|
||||
import { writeFile, mkdir } from "fs/promises";
|
||||
import path from "path";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/route";
|
||||
|
||||
export async function uploadImage(formData: FormData): Promise<string> {
|
||||
const session = await getServerSession(authOptions);
|
||||
const adminIds = (process.env.ADMIN_DISCORD_IDS ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const discordId = (session?.user as { discordId?: string } | undefined)
|
||||
?.discordId;
|
||||
if (!discordId || !adminIds.includes(discordId)) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const file = formData.get("file") as File;
|
||||
if (!file) throw new Error("No file provided");
|
||||
|
||||
const ext = file.name.split(".").pop() ?? "png";
|
||||
const filename = `${crypto.randomUUID()}.${ext}`;
|
||||
const uploadDir = path.join(process.cwd(), "public", "form-uploads");
|
||||
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(path.join(uploadDir, filename), buffer);
|
||||
|
||||
return `/form-uploads/${filename}`;
|
||||
}
|
||||
Reference in New Issue
Block a user