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 (
{t.intro}
+ {t.lastDelivery}:{" "} + {hook.lastDelivery ? ( + + {hook.lastDelivery.status === "success" + ? t.deliverySuccess + : hook.lastDelivery.status === "failed" + ? t.deliveryFailed + : t.deliveryPending} + {hook.lastDelivery.error ? ` (${hook.lastDelivery.error})` : ""} + + ) : ( + t.deliveryNone + )} +
{/* The signing secret only applies to generic JSON hooks; a Discord webhook does not verify a signature. */} {hook.format !== "discord" && ( diff --git a/apps/web/src/i18n/dictionaries.ts b/apps/web/src/i18n/dictionaries.ts index d171868..bd5f51a 100644 --- a/apps/web/src/i18n/dictionaries.ts +++ b/apps/web/src/i18n/dictionaries.ts @@ -363,6 +363,8 @@ const en = { events: "Events", eventCreated: "Submission created", eventStatusChanged: "Status changed", format: "Delivery format", formatJson: "Generic JSON (signed)", formatDiscord: "Discord webhook", + formScope: "Form scope", formScopeHint: "Limit this webhook to one form, or send events from every form.", scopeAll: "All forms", + lastDelivery: "Last delivery", deliverySuccess: "Delivered", deliveryFailed: "Failed", deliveryPending: "Pending", deliveryNone: "No deliveries yet.", discordUrlPlaceholder: "https://discord.com/api/webhooks/…", discordHint: "Paste a Discord channel webhook URL (Server Settings, Integrations, Webhooks). New submissions are posted there as an embed, so you can log them into any Discord server.", managedBadge: "Integration", managedHint: "Created by an integration — manage it from Zapier/Make.", @@ -917,6 +919,8 @@ const de: Dictionary = { events: "Ereignisse", eventCreated: "Einreichung erstellt", eventStatusChanged: "Status geändert", format: "Zustellformat", formatJson: "Generisches JSON (signiert)", formatDiscord: "Discord-Webhook", + formScope: "Formular-Bereich", formScopeHint: "Diesen Webhook auf ein Formular begrenzen oder Ereignisse aller Formulare senden.", scopeAll: "Alle Formulare", + lastDelivery: "Letzte Zustellung", deliverySuccess: "Zugestellt", deliveryFailed: "Fehlgeschlagen", deliveryPending: "Ausstehend", deliveryNone: "Noch keine Zustellungen.", discordUrlPlaceholder: "https://discord.com/api/webhooks/…", discordHint: "Füge eine Discord-Kanal-Webhook-URL ein (Servereinstellungen, Integrationen, Webhooks). Neue Einreichungen werden dort als Embed gepostet, du kannst sie also in jeden Discord-Server loggen.", managedBadge: "Integration", managedHint: "Von einer Integration erstellt — verwalte ihn in Zapier/Make.", @@ -1469,6 +1473,8 @@ const hu: Dictionary = { events: "Események", eventCreated: "Beküldés létrehozva", eventStatusChanged: "Állapot megváltozott", format: "Kézbesítési formátum", formatJson: "Általános JSON (aláírt)", formatDiscord: "Discord webhook", + formScope: "Űrlap-hatókör", formScopeHint: "Korlátozd ezt a webhookot egy űrlapra, vagy küldd minden űrlap eseményeit.", scopeAll: "Minden űrlap", + lastDelivery: "Utolsó kézbesítés", deliverySuccess: "Kézbesítve", deliveryFailed: "Sikertelen", deliveryPending: "Függőben", deliveryNone: "Még nincs kézbesítés.", discordUrlPlaceholder: "https://discord.com/api/webhooks/…", discordHint: "Illessz be egy Discord csatorna-webhook URL-t (Szerverbeállítások, Integrációk, Webhookok). Az új beküldések ott embedként jelennek meg, így bármelyik Discord szerverre naplózhatod őket.", managedBadge: "Integráció", managedHint: "Integráció hozta létre — a Zapier/Make-ből kezeld.", @@ -2021,6 +2027,8 @@ const fr: Dictionary = { events: "Événements", eventCreated: "Soumission créée", eventStatusChanged: "Statut changé", format: "Format de livraison", formatJson: "JSON générique (signé)", formatDiscord: "Webhook Discord", + formScope: "Portée du formulaire", formScopeHint: "Limitez ce webhook à un formulaire, ou envoyez les événements de tous les formulaires.", scopeAll: "Tous les formulaires", + lastDelivery: "Dernière livraison", deliverySuccess: "Livré", deliveryFailed: "Échec", deliveryPending: "En attente", deliveryNone: "Aucune livraison pour le moment.", discordUrlPlaceholder: "https://discord.com/api/webhooks/…", discordHint: "Collez une URL de webhook de salon Discord (Paramètres du serveur, Intégrations, Webhooks). Les nouvelles soumissions y sont publiées sous forme d'embed, vous pouvez donc les journaliser sur n'importe quel serveur Discord.", managedBadge: "Intégration", managedHint: "Créé par une intégration — gérez-le depuis Zapier/Make.", @@ -2574,6 +2582,8 @@ const es: Dictionary = { events: "Eventos", eventCreated: "Envío creado", eventStatusChanged: "Estado cambiado", format: "Formato de entrega", formatJson: "JSON genérico (firmado)", formatDiscord: "Webhook de Discord", + formScope: "Alcance del formulario", formScopeHint: "Limita este webhook a un formulario, o envía los eventos de todos los formularios.", scopeAll: "Todos los formularios", + lastDelivery: "Última entrega", deliverySuccess: "Entregado", deliveryFailed: "Fallido", deliveryPending: "Pendiente", deliveryNone: "Aún no hay entregas.", discordUrlPlaceholder: "https://discord.com/api/webhooks/…", discordHint: "Pega una URL de webhook de canal de Discord (Ajustes del servidor, Integraciones, Webhooks). Los nuevos envíos se publican allí como un embed, así que puedes registrarlos en cualquier servidor de Discord.", managedBadge: "Integración", managedHint: "Creado por una integración — adminístralo desde Zapier/Make.", @@ -3127,6 +3137,8 @@ const pt: Dictionary = { events: "Eventos", eventCreated: "Candidatura criada", eventStatusChanged: "Status alterado", format: "Formato de entrega", formatJson: "JSON genérico (assinado)", formatDiscord: "Webhook do Discord", + formScope: "Escopo do formulário", formScopeHint: "Limite este webhook a um formulário, ou envie os eventos de todos os formulários.", scopeAll: "Todos os formulários", + lastDelivery: "Última entrega", deliverySuccess: "Entregue", deliveryFailed: "Falhou", deliveryPending: "Pendente", deliveryNone: "Ainda sem entregas.", discordUrlPlaceholder: "https://discord.com/api/webhooks/…", discordHint: "Cole uma URL de webhook de canal do Discord (Configurações do servidor, Integrações, Webhooks). Novas candidaturas são publicadas ali como um embed, então você pode registrá-las em qualquer servidor do Discord.", managedBadge: "Integração", managedHint: "Criado por uma integração — gerencie-o pelo Zapier/Make.", @@ -3680,6 +3692,8 @@ const pl: Dictionary = { events: "Zdarzenia", eventCreated: "Zgłoszenie utworzone", eventStatusChanged: "Status zmieniony", format: "Format dostarczania", formatJson: "Ogólny JSON (podpisany)", formatDiscord: "Webhook Discord", + formScope: "Zakres formularza", formScopeHint: "Ogranicz ten webhook do jednego formularza lub wysyłaj zdarzenia ze wszystkich formularzy.", scopeAll: "Wszystkie formularze", + lastDelivery: "Ostatnia dostawa", deliverySuccess: "Dostarczono", deliveryFailed: "Niepowodzenie", deliveryPending: "Oczekuje", deliveryNone: "Brak dostaw.", discordUrlPlaceholder: "https://discord.com/api/webhooks/…", discordHint: "Wklej URL webhooka kanału Discord (Ustawienia serwera, Integracje, Webhooki). Nowe zgłoszenia są tam publikowane jako embed, więc możesz je logować na dowolnym serwerze Discord.", managedBadge: "Integracja", managedHint: "Utworzone przez integrację — zarządzaj nim w Zapier/Make.", diff --git a/packages/db/prisma/migrations/20260709160000_webhook_form_scope/migration.sql b/packages/db/prisma/migrations/20260709160000_webhook_form_scope/migration.sql new file mode 100644 index 0000000..80ed999 --- /dev/null +++ b/packages/db/prisma/migrations/20260709160000_webhook_form_scope/migration.sql @@ -0,0 +1,14 @@ +-- Optional per-form scope for a webhook: when form_id is set, the webhook only +-- fires for that form's events; NULL (default) means every form in the guild. +ALTER TABLE "webhooks" ADD COLUMN "form_id" UUID; + +ALTER TABLE "webhooks" + ADD CONSTRAINT "webhooks_form_id_fkey" + FOREIGN KEY ("form_id") REFERENCES "forms"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE INDEX "webhooks_form_id_idx" ON "webhooks" ("form_id"); + +-- Support the dashboard "latest delivery per webhook" lookup. +CREATE INDEX "webhook_deliveries_webhook_id_created_at_idx" + ON "webhook_deliveries" ("webhook_id", "created_at" DESC); + diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 7f61ad6..2210145 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -185,6 +185,7 @@ model Form { formStatuses FormStatusDef[] reviewers FormReviewer[] experimentStats ExperimentStat[] + webhooks Webhook[] @@index([guildId, status]) @@index([guildId, categoryId]) @@ -362,12 +363,17 @@ model Webhook { /// Delivery format: "json" (generic HMAC-signed POST) or "discord" (a Discord /// channel webhook receiving a formatted embed — log submissions to any server). format String @default("json") + /// Optional form scope: when set, the webhook only fires for this form's + /// events; null (default) means every form in the guild. + formId String? @map("form_id") @db.Uuid createdAt DateTime @default(now()) @map("created_at") guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + form Form? @relation(fields: [formId], references: [id], onDelete: Cascade) deliveries WebhookDelivery[] @@index([guildId]) + @@index([formId]) @@map("webhooks") } @@ -389,5 +395,6 @@ model WebhookDelivery { webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade) @@index([status, nextAttemptAt]) + @@index([webhookId, createdAt(sort: Desc)]) @@map("webhook_deliveries") } diff --git a/packages/db/src/webhooks.ts b/packages/db/src/webhooks.ts index fdd8b2b..412dbad 100644 --- a/packages/db/src/webhooks.ts +++ b/packages/db/src/webhooks.ts @@ -6,8 +6,10 @@ type Db = typeof prisma | Prisma.TransactionClient; /** * Queue webhook deliveries for every active webhook in `guildId` that subscribes * to `event`: one WebhookDelivery row per webhook, picked up by the bot's outbox - * poller. Pass a transaction client (`tx`) to make enqueuing atomic with the - * change that triggered it. No-op when no endpoint subscribes. + * poller. A form-scoped webhook (non-null `formId`) only fires for its own form, + * matched against `payload.formId`; guild-wide webhooks (null `formId`) always + * fire. Pass a transaction client (`tx`) to make enqueuing atomic with the change + * that triggered it. No-op when no endpoint subscribes. * * Event name strings mirror `WEBHOOK_EVENTS` in `@msk-forms/shared` (not imported * here to keep `@msk-forms/db` free of the shared dependency). @@ -18,8 +20,15 @@ export async function enqueueWebhooks( event: string, payload: Record