From 1d518226ff69726cf7875cd8b9a3f136f4689af0 Mon Sep 17 00:00:00 2001 From: Musiker15 Date: Sun, 21 Jun 2026 20:47:18 +0200 Subject: [PATCH] feat(dashboard): delete a form (with confirmation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a red "Delete" button next to Edit in the forms list (managers only), guarded by a confirm dialog. - api: `DELETE /api/guilds/[guildId]/forms/[formId]` (owner/admin, form ∈ guild). Cascade removes versions, submissions, events, files and form status defs; stored file objects are purged from object storage best-effort afterwards. - ui: `DeleteFormButton` client component (confirm → DELETE → refresh); shown only to managers (forms page now resolves canManageForms). - i18n DE/EN (deleteForm / deleteFormConfirm / deleteFormFailed). --- .../guilds/[guildId]/forms/[formId]/route.ts | 38 +++++++++++++++++ .../app/dashboard/[guildId]/forms/page.tsx | 17 +++++++- .../dashboard/delete-form-button.tsx | 41 +++++++++++++++++++ apps/web/src/i18n/dictionaries.ts | 2 + 4 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/dashboard/delete-form-button.tsx diff --git a/apps/web/src/app/api/guilds/[guildId]/forms/[formId]/route.ts b/apps/web/src/app/api/guilds/[guildId]/forms/[formId]/route.ts index 1d6dea3..ecbd193 100644 --- a/apps/web/src/app/api/guilds/[guildId]/forms/[formId]/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/forms/[formId]/route.ts @@ -4,6 +4,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { getCurrentUser } from "@/lib/auth"; import { formInputSchema } from "@/lib/form-input"; import { canManageForms } from "@/lib/guild"; +import { deleteObject } from "@/lib/s3"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -59,3 +60,40 @@ export async function PATCH( throw err; } } + +/** + * Delete a form and everything under it (versions, submissions, events, files, + * status defs all cascade). Owner/admin only, form ∈ guild. Stored file objects + * are purged from object storage best-effort after the row is gone. + */ +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ guildId: string; formId: string }> }, +) { + const { guildId, formId } = await params; + + const user = await getCurrentUser(); + if (!user) return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); + if (!(await canManageForms(guildId, user.id))) { + return NextResponse.json({ error: "Forbidden." }, { status: 403 }); + } + + const existing = await prisma.form.findUnique({ + where: { id: formId }, + select: { guildId: true }, + }); + if (!existing || existing.guildId !== guildId) { + return NextResponse.json({ error: "Form not found." }, { status: 404 }); + } + + // Collect stored object keys before the cascade removes the rows. + const files = await prisma.fileUpload.findMany({ + where: { submission: { formId } }, + select: { storageKey: true }, + }); + + await prisma.form.delete({ where: { id: formId } }); + await Promise.all(files.map((f) => deleteObject(f.storageKey))); + + return NextResponse.json({ ok: true }); +} diff --git a/apps/web/src/app/dashboard/[guildId]/forms/page.tsx b/apps/web/src/app/dashboard/[guildId]/forms/page.tsx index 3fbcc3a..3a9920d 100644 --- a/apps/web/src/app/dashboard/[guildId]/forms/page.tsx +++ b/apps/web/src/app/dashboard/[guildId]/forms/page.tsx @@ -3,9 +3,11 @@ import type { Route } from "next"; import Link from "next/link"; import QRCode from "qrcode"; +import { DeleteFormButton } from "@/components/dashboard/delete-form-button"; import { ShareButton } from "@/components/dashboard/share-button"; +import { requireUser } from "@/lib/auth"; import { appBaseUrl } from "@/lib/url"; -import { getGuildForms } from "@/lib/guild"; +import { canManageForms, getGuildForms } from "@/lib/guild"; import { getDict } from "@/i18n"; export const runtime = "nodejs"; @@ -24,7 +26,11 @@ export default async function GuildFormsPage({ params: Promise<{ guildId: string }>; }) { const { guildId } = await params; - const forms = await getGuildForms(guildId); + const user = await requireUser(`/dashboard/${guildId}/forms`); + const [forms, canManage] = await Promise.all([ + getGuildForms(guildId), + canManageForms(guildId, user.id), + ]); const base = appBaseUrl(); const dict = await getDict(); const t = dict.dashboard; @@ -110,6 +116,13 @@ export default async function GuildFormsPage({ > {t.edit} + {canManage && ( + + )} {form.status === "live" && qr && ( diff --git a/apps/web/src/components/dashboard/delete-form-button.tsx b/apps/web/src/components/dashboard/delete-form-button.tsx new file mode 100644 index 0000000..8465971 --- /dev/null +++ b/apps/web/src/components/dashboard/delete-form-button.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +export function DeleteFormButton({ + guildId, + formId, + t, +}: { + guildId: string; + formId: string; + t: { delete: string; confirm: string; failed: string }; +}) { + const router = useRouter(); + const [busy, setBusy] = useState(false); + + async function onDelete() { + if (!window.confirm(t.confirm)) return; + setBusy(true); + try { + const res = await fetch(`/api/guilds/${guildId}/forms/${formId}`, { method: "DELETE" }); + if (!res.ok) throw new Error(); + router.refresh(); + } catch { + window.alert(t.failed); + setBusy(false); + } + } + + return ( + + ); +} diff --git a/apps/web/src/i18n/dictionaries.ts b/apps/web/src/i18n/dictionaries.ts index bb7214b..5aecd07 100644 --- a/apps/web/src/i18n/dictionaries.ts +++ b/apps/web/src/i18n/dictionaries.ts @@ -97,6 +97,7 @@ const en = { "Connecting a guild happens through the Discord bot invite. That flow lands in a later slice. For now, an admin can add you to a guild.", countForm: "form", countForms: "forms", countSubmissions: "submissions", newForm: "New form", noForms: "No forms yet. Create your first one.", edit: "Edit", exportCsv: "Export CSV", + deleteForm: "Delete", deleteFormConfirm: "Delete this form? All its submissions, files and activity will be permanently removed. This cannot be undone.", deleteFormFailed: "Could not delete the form.", colApplicant: "Applicant", colForm: "Form", colDate: "Date", colStatus: "Status", anonymous: "Anonymous", noSubmissions: "No submissions yet.", open: "Open", mySubmissionsTitle: "My submissions", noMySubmissions: "You haven’t submitted any forms yet.", @@ -286,6 +287,7 @@ const de: Dictionary = { "Eine Guild wird über die Discord-Bot-Einladung verbunden. Dieser Ablauf kommt in einem späteren Slice. Bis dahin kann dich ein Admin zu einer Guild hinzufügen.", countForm: "Formular", countForms: "Formulare", countSubmissions: "Einreichungen", newForm: "Neues Formular", noForms: "Noch keine Formulare. Erstelle dein erstes.", edit: "Bearbeiten", exportCsv: "CSV-Export", + deleteForm: "Löschen", deleteFormConfirm: "Dieses Formular löschen? Alle Einreichungen, Dateien und Aktivitäten werden unwiderruflich entfernt. Das kann nicht rückgängig gemacht werden.", deleteFormFailed: "Formular konnte nicht gelöscht werden.", colApplicant: "Bewerber", colForm: "Formular", colDate: "Datum", colStatus: "Status", anonymous: "Anonym", noSubmissions: "Noch keine Einreichungen.", open: "Öffnen", mySubmissionsTitle: "Meine Einreichungen", noMySubmissions: "Du hast noch keine Formulare abgeschickt.",