Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions apps/web/src/app/api/guilds/[guildId]/forms/[formId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
}
17 changes: 15 additions & 2 deletions apps/web/src/app/dashboard/[guildId]/forms/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -110,6 +116,13 @@ export default async function GuildFormsPage({
>
{t.edit}
</Link>
{canManage && (
<DeleteFormButton
guildId={guildId}
formId={form.id}
t={{ delete: t.deleteForm, confirm: t.deleteFormConfirm, failed: t.deleteFormFailed }}
/>
)}
</div>
</div>
{form.status === "live" && qr && (
Expand Down
41 changes: 41 additions & 0 deletions apps/web/src/components/dashboard/delete-form-button.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
type="button"
onClick={onDelete}
disabled={busy}
className="rounded-md border border-destructive/40 px-3 py-1.5 text-sm font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:opacity-50"
>
{t.delete}
</button>
);
}
2 changes: 2 additions & 0 deletions apps/web/src/i18n/dictionaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down