diff --git a/apps/web/src/app/api/guilds/[guildId]/webhooks/route.ts b/apps/web/src/app/api/guilds/[guildId]/webhooks/route.ts index 95a7721..3ad6a1c 100644 --- a/apps/web/src/app/api/guilds/[guildId]/webhooks/route.ts +++ b/apps/web/src/app/api/guilds/[guildId]/webhooks/route.ts @@ -37,6 +37,7 @@ export async function GET( events: true, active: true, format: true, + formId: true, createdAt: true, }, }); @@ -71,6 +72,16 @@ export async function POST( return NextResponse.json({ error: "Too many webhooks." }, { status: 422 }); } + // A form-scoped webhook must target a form that belongs to this guild. + const formId = parsed.data.formId ?? null; + if (formId) { + const form = await prisma.form.findFirst({ + where: { id: formId, guildId }, + select: { id: true }, + }); + if (!form) return NextResponse.json({ error: "Form not found." }, { status: 422 }); + } + const webhook = await prisma.webhook.create({ data: { guildId, @@ -78,6 +89,7 @@ export async function POST( events: parsed.data.events, active: parsed.data.active, format: parsed.data.format, + formId, secret: randomBytes(24).toString("hex"), }, select: { @@ -87,6 +99,7 @@ export async function POST( events: true, active: true, format: true, + formId: true, createdAt: true, }, }); diff --git a/apps/web/src/app/dashboard/[guildId]/webhooks/page.tsx b/apps/web/src/app/dashboard/[guildId]/webhooks/page.tsx index 0a27f37..de7b45a 100644 --- a/apps/web/src/app/dashboard/[guildId]/webhooks/page.tsx +++ b/apps/web/src/app/dashboard/[guildId]/webhooks/page.tsx @@ -52,11 +52,51 @@ export default async function WebhooksPage({ ); } - const webhooks = (await prisma.webhook.findMany({ - where: { guildId }, - orderBy: { createdAt: "asc" }, - select: { id: true, url: true, secret: true, events: true, active: true, source: true, format: true }, - })) as WebhookRow[]; + const [rawWebhooks, forms] = await Promise.all([ + prisma.webhook.findMany({ + where: { guildId }, + orderBy: { createdAt: "asc" }, + select: { + id: true, + url: true, + secret: true, + events: true, + active: true, + source: true, + format: true, + formId: true, + }, + }), + prisma.form.findMany({ + where: { guildId }, + orderBy: { createdAt: "desc" }, + select: { id: true, title: true }, + }), + ]); + + // Latest delivery per webhook, so the dashboard can show whether events are + // actually getting through (the top reason a "test submission didn't log"). + const latest = await prisma.webhookDelivery.findMany({ + where: { webhookId: { in: rawWebhooks.map((w) => w.id) } }, + orderBy: { createdAt: "desc" }, + distinct: ["webhookId"], + select: { webhookId: true, status: true, lastError: true, createdAt: true, deliveredAt: true }, + }); + const lastByHook = new Map(latest.map((d) => [d.webhookId, d])); + + const webhooks: WebhookRow[] = rawWebhooks.map((w) => { + const d = lastByHook.get(w.id); + return { + ...w, + lastDelivery: d + ? { + status: d.status, + error: d.lastError, + at: (d.deliveredAt ?? d.createdAt).toISOString(), + } + : null, + }; + }); return (
@@ -64,7 +104,7 @@ export default async function WebhooksPage({

{t.title}

{t.intro}

- + ); } diff --git a/apps/web/src/components/webhooks/webhooks-manager.tsx b/apps/web/src/components/webhooks/webhooks-manager.tsx index 4d5f22d..e649bf0 100644 --- a/apps/web/src/components/webhooks/webhooks-manager.tsx +++ b/apps/web/src/components/webhooks/webhooks-manager.tsx @@ -17,6 +17,10 @@ export interface WebhookRow { source: string; /** "json" (generic signed POST) or "discord" (Discord embed). */ format: string; + /** Form this hook is scoped to, or null for every form in the guild. */ + formId: string | null; + /** Outcome of the most recent delivery attempt, if any. */ + lastDelivery: { status: string; error: string | null; at: string } | null; } /** Badge label for an integration-managed hook; brand names stay verbatim. */ @@ -38,16 +42,21 @@ function eventLabel(event: string, t: WebhooksDict): string { export function WebhooksManager({ guildId, initial, + forms, t, }: { guildId: string; initial: WebhookRow[]; + forms: { id: string; title: string }[]; t: WebhooksDict; }) { const [webhooks, setWebhooks] = useState(initial); const [url, setUrl] = useState(""); const [format, setFormat] = useState("json"); + // "" = every form; otherwise a specific form id. + const [formId, setFormId] = useState(""); const [events, setEvents] = useState>(new Set(WEBHOOK_EVENTS)); + const formTitle = (id: string | null) => forms.find((f) => f.id === id)?.title ?? id; const [adding, setAdding] = useState(false); const [error, setError] = useState(null); const [revealed, setRevealed] = useState>(new Set()); @@ -71,16 +80,22 @@ export function WebhooksManager({ const res = await fetch(`/api/guilds/${guildId}/webhooks`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url: url.trim(), events: [...events], format }), + body: JSON.stringify({ + url: url.trim(), + events: [...events], + format, + formId: formId || null, + }), }); const data = (await res.json().catch(() => null)) as | { webhook?: WebhookRow; error?: string } | null; if (!res.ok || !data?.webhook) throw new Error(data?.error ?? t.errAdd); - // Hooks added here are always manually managed. - setWebhooks((prev) => [...prev, { ...data.webhook!, source: "manual" }]); + // Hooks added here are always manually managed and have no deliveries yet. + setWebhooks((prev) => [...prev, { ...data.webhook!, source: "manual", lastDelivery: null }]); setUrl(""); setFormat("json"); + setFormId(""); setEvents(new Set(WEBHOOK_EVENTS)); } catch (err) { setError(err instanceof Error ? err.message : t.errAdd); @@ -166,6 +181,18 @@ export function WebhooksManager({ ))} + {forms.length > 0 && ( + +