From 49e7690b7b1b800b9190248c98017c9c5fe0b617 Mon Sep 17 00:00:00 2001 From: Gunshiz Date: Thu, 28 May 2026 17:46:31 +0700 Subject: [PATCH] feat: implement form submission system with Drizzle ORM and admin dashboard --- .dockerignore | 10 +- .gitignore | 12 +- .vscode/settings.json | 3 + app/actions/form.ts | 59 ++ app/actions/questions.ts | 83 +++ app/actions/submissions.ts | 106 ++++ app/actions/upload.ts | 32 ++ app/api/auth/[...nextauth]/route.ts | 41 ++ app/form/admin/admin-sidebar.tsx | 72 +++ app/form/admin/delete-form-button.tsx | 71 +++ app/form/admin/form-editor-client.tsx | 387 +++++++++++++ app/form/admin/layout.tsx | 33 ++ app/form/admin/page.tsx | 39 ++ app/form/admin/result/[id]/page.tsx | 61 +++ .../result/[id]/submission-detail-client.tsx | 175 ++++++ .../admin/result/delete-buttons-client.tsx | 106 ++++ app/form/admin/result/page.tsx | 92 ++++ app/form/form-filler-client.tsx | 241 +++++++++ app/form/layout-client.tsx | 36 ++ app/form/layout.tsx | 32 ++ app/form/login-dialog.tsx | 40 ++ app/form/page.tsx | 57 ++ app/layout.tsx | 7 +- app/page.tsx | 6 +- components/ui/alert-dialog.tsx | 199 +++++++ components/ui/spinner.tsx | 10 + db/index.ts | 6 + db/schema.ts | 119 ++++ drizzle/0000_big_marvex.sql | 77 +++ drizzle/0001_wooden_ravenous.sql | 1 + drizzle/meta/0000_snapshot.json | 501 +++++++++++++++++ drizzle/meta/0001_snapshot.json | 508 ++++++++++++++++++ drizzle/meta/_journal.json | 20 + hooks/use-mobile.ts | 19 + middleware.ts | 10 - next.config.ts | 10 +- package.json | 4 +- proxy.ts | 11 + .../49b49544-58bb-4aee-b301-80a6eecd8976.webp | Bin 0 -> 46044 bytes 39 files changed, 3263 insertions(+), 33 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 app/actions/form.ts create mode 100644 app/actions/questions.ts create mode 100644 app/actions/submissions.ts create mode 100644 app/actions/upload.ts create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 app/form/admin/admin-sidebar.tsx create mode 100644 app/form/admin/delete-form-button.tsx create mode 100644 app/form/admin/form-editor-client.tsx create mode 100644 app/form/admin/layout.tsx create mode 100644 app/form/admin/page.tsx create mode 100644 app/form/admin/result/[id]/page.tsx create mode 100644 app/form/admin/result/[id]/submission-detail-client.tsx create mode 100644 app/form/admin/result/delete-buttons-client.tsx create mode 100644 app/form/admin/result/page.tsx create mode 100644 app/form/form-filler-client.tsx create mode 100644 app/form/layout-client.tsx create mode 100644 app/form/layout.tsx create mode 100644 app/form/login-dialog.tsx create mode 100644 app/form/page.tsx create mode 100644 components/ui/alert-dialog.tsx create mode 100644 components/ui/spinner.tsx create mode 100644 db/index.ts create mode 100644 db/schema.ts create mode 100644 drizzle/0000_big_marvex.sql create mode 100644 drizzle/0001_wooden_ravenous.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 hooks/use-mobile.ts delete mode 100644 middleware.ts create mode 100644 proxy.ts create mode 100644 public/form-uploads/49b49544-58bb-4aee-b301-80a6eecd8976.webp diff --git a/.dockerignore b/.dockerignore index 6d6c72d..9ff273c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,12 +12,4 @@ yarn-error.log *.db-shm *.db-wal tsconfig.tsbuildinfo -.chunk-cache - - -db/ -hooks/ -app/actions -app/api/ -app/auth -app/form +.chunk-cache \ No newline at end of file diff --git a/.gitignore b/.gitignore index 974fea3..c10d7a7 100644 --- a/.gitignore +++ b/.gitignore @@ -41,9 +41,9 @@ yarn-error.log* next-env.d.ts -db/ -hooks/ -app/actions -app/api/ -app/auth -app/form +# db/ +# hooks/ +# app/actions +# app/api/ +# app/auth +# app/form diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..027b61a --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "remote.autoForwardPortsFallback": 0 +} \ No newline at end of file diff --git a/app/actions/form.ts b/app/actions/form.ts new file mode 100644 index 0000000..c5ae77a --- /dev/null +++ b/app/actions/form.ts @@ -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"); +} diff --git a/app/actions/questions.ts b/app/actions/questions.ts new file mode 100644 index 0000000..ac2072b --- /dev/null +++ b/app/actions/questions.ts @@ -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"); +} diff --git a/app/actions/submissions.ts b/app/actions/submissions.ts new file mode 100644 index 0000000..7b5687c --- /dev/null +++ b/app/actions/submissions.ts @@ -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); + + // 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 }; +} diff --git a/app/actions/upload.ts b/app/actions/upload.ts new file mode 100644 index 0000000..5877738 --- /dev/null +++ b/app/actions/upload.ts @@ -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 { + 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}`; +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..86c247f --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,41 @@ +import NextAuth, { type NextAuthOptions } from "next-auth"; +import DiscordProvider from "next-auth/providers/discord"; +import { DrizzleAdapter } from "@auth/drizzle-adapter"; +import { db } from "@/db"; +import { accounts, sessions, users, verificationTokens } from "@/db/schema"; + +export const authOptions: NextAuthOptions = { + adapter: DrizzleAdapter(db, { + usersTable: users, + accountsTable: accounts, + sessionsTable: sessions, + verificationTokensTable: verificationTokens, + }), + providers: [ + DiscordProvider({ + clientId: process.env.DISCORD_CLIENT_ID!, + clientSecret: process.env.DISCORD_CLIENT_SECRET!, + }), + ], + callbacks: { + async session({ session, user }) { + if (session.user) { + (session.user as any).id = user.id; + // Attach discordId from accounts table + const acct = await db.query.accounts.findFirst({ + where: (a, { eq, and }) => + and(eq(a.userId, user.id), eq(a.provider, "discord")), + }); + if (acct) { + (session.user as typeof session.user & { discordId: string }).discordId = + acct.providerAccountId; + } + } + return session; + }, + }, + secret: process.env.NEXTAUTH_SECRET, +}; + +const handler = NextAuth(authOptions); +export { handler as GET, handler as POST }; diff --git a/app/form/admin/admin-sidebar.tsx b/app/form/admin/admin-sidebar.tsx new file mode 100644 index 0000000..0cd8ffd --- /dev/null +++ b/app/form/admin/admin-sidebar.tsx @@ -0,0 +1,72 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarHeader, + SidebarFooter, +} from "@/components/ui/sidebar"; +import { LayoutDashboard, ClipboardList, ArrowLeft } from "lucide-react"; + +export function AdminSidebar() { + const pathname = usePathname(); + + return ( + + + + Erika Admin + + + + + + Navigation + + + + + + + Admin Dashboard + + + + + + + + + Result Page + + + + + + + + + + + + + + + Exit Admin + + + + + + + ); +} diff --git a/app/form/admin/delete-form-button.tsx b/app/form/admin/delete-form-button.tsx new file mode 100644 index 0000000..56a271b --- /dev/null +++ b/app/form/admin/delete-form-button.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Trash2, Loader2 } from "lucide-react"; +import { deleteForm } from "@/app/actions/form"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +interface DeleteFormButtonProps { + formId: string; +} + +export default function DeleteFormButton({ formId }: DeleteFormButtonProps) { + const [open, setOpen] = useState(false); + const [deleting, setDeleting] = useState(false); + + const handleDelete = async () => { + setDeleting(true); + try { + await deleteForm(formId); + toast.success("Form deleted successfully!"); + setOpen(false); + } catch (err: any) { + toast.error(err.message || "Failed to delete form."); + } finally { + setDeleting(false); + } + }; + + return ( + + + + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete the form, all of its questions, and all submissions/answers. + + + + + + + + + ); +} diff --git a/app/form/admin/form-editor-client.tsx b/app/form/admin/form-editor-client.tsx new file mode 100644 index 0000000..b8081fe --- /dev/null +++ b/app/form/admin/form-editor-client.tsx @@ -0,0 +1,387 @@ +"use client"; + +import React, { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { toast } from "sonner"; +import { updateForm } from "@/app/actions/form"; +import { + createQuestion, + updateQuestion, + deleteQuestion, + reorderQuestions, +} from "@/app/actions/questions"; +import { uploadImage } from "@/app/actions/upload"; +import { Plus, Trash, ArrowUp, ArrowDown, Loader2, Save, Image as ImageIcon, X } from "lucide-react"; + +interface Question { + id: string; + formId: string; + type: "text" | "textarea" | "radio" | "checkbox"; + label: string; + imageUrl: string | null; + displayOrder: number; + required: boolean; + options: string[] | null; +} + +interface FormEditorClientProps { + form: { + id: string; + title: string; + description: string | null; + }; + initialQuestions: Question[]; +} + +export default function FormEditorClient({ form, initialQuestions }: FormEditorClientProps) { + const router = useRouter(); + const [formTitle, setFormTitle] = useState(form.title); + const [formDescription, setFormDescription] = useState(form.description || ""); + const [questions, setQuestions] = useState(initialQuestions); + const [uploadingId, setUploadingId] = useState(null); + const [isFormSaving, startFormSaveTransition] = useTransition(); + + const saveFormMetadata = () => { + if (!formTitle.trim()) { + toast.error("Form title is required."); + return; + } + + startFormSaveTransition(async () => { + try { + await updateForm(form.id, { + title: formTitle, + description: formDescription, + }); + toast.success("Form metadata saved!"); + router.refresh(); + } catch (err: any) { + toast.error(err.message || "Failed to update form."); + } + }); + }; + + const handleAddQuestion = async () => { + try { + const displayOrder = questions.length; + await createQuestion(form.id, { + type: "text", + label: `Untitled Question ${questions.length + 1}`, + required: false, + displayOrder, + options: [], + }); + toast.success("Question added!"); + router.refresh(); + } catch (err: any) { + toast.error(err.message || "Failed to add question."); + } + }; + + const handleUpdateQuestion = async (id: string, updates: Partial) => { + try { + setQuestions((prev) => + prev.map((q) => (q.id === id ? { ...q, ...updates } as Question : q)) + ); + + const formattedUpdates = { + ...updates, + options: updates.options === null ? [] : updates.options, + }; + + await updateQuestion(id, form.id, formattedUpdates); + router.refresh(); + } catch (err: any) { + toast.error(err.message || "Failed to update question."); + } + }; + + const handleDeleteQuestion = async (id: string) => { + try { + await deleteQuestion(id, form.id); + toast.success("Question deleted!"); + router.refresh(); + } catch (err: any) { + toast.error(err.message || "Failed to delete question."); + } + }; + + const handleMove = async (index: number, direction: "up" | "down") => { + const newQuestions = [...questions]; + const targetIndex = direction === "up" ? index - 1 : index + 1; + if (targetIndex < 0 || targetIndex >= newQuestions.length) return; + + const temp = newQuestions[index]; + newQuestions[index] = newQuestions[targetIndex]; + newQuestions[targetIndex] = temp; + + setQuestions(newQuestions); + + try { + await reorderQuestions(form.id, newQuestions.map((q) => q.id)); + toast.success("Order updated!"); + router.refresh(); + } catch (err: any) { + toast.error(err.message || "Failed to reorder questions."); + } + }; + + const handleImageUpload = async (questionId: string, e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const formData = new FormData(); + formData.append("file", file); + + setUploadingId(questionId); + try { + const imageUrl = await uploadImage(formData); + await handleUpdateQuestion(questionId, { imageUrl }); + toast.success("Image uploaded!"); + router.refresh(); + } catch (err: any) { + toast.error(err.message || "Failed to upload image."); + } finally { + setUploadingId(null); + } + }; + + const handleRemoveImage = async (questionId: string) => { + await handleUpdateQuestion(questionId, { imageUrl: null }); + toast.success("Image removed."); + router.refresh(); + }; + + const handleAddOption = async (question: Question) => { + const options = question.options ? [...question.options] : []; + options.push(`Option ${options.length + 1}`); + await handleUpdateQuestion(question.id, { options }); + }; + + const handleUpdateOption = async (question: Question, optIndex: number, val: string) => { + if (!question.options) return; + const options = [...question.options]; + options[optIndex] = val; + await handleUpdateQuestion(question.id, { options }); + }; + + const handleRemoveOption = async (question: Question, optIndex: number) => { + if (!question.options) return; + const options = question.options.filter((_, idx) => idx !== optIndex); + await handleUpdateQuestion(question.id, { options }); + }; + + React.useEffect(() => { + setQuestions(initialQuestions); + }, [initialQuestions]); + + return ( +
+ + + Form Settings + Configure the main title and description of your form. + + +
+ + setFormTitle(e.target.value)} + placeholder="e.g. Untitled Form" + /> +
+
+ +