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.",