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
13 changes: 13 additions & 0 deletions apps/web/src/app/api/guilds/[guildId]/webhooks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export async function GET(
events: true,
active: true,
format: true,
formId: true,
createdAt: true,
},
});
Expand Down Expand Up @@ -71,13 +72,24 @@ 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,
url: parsed.data.url,
events: parsed.data.events,
active: parsed.data.active,
format: parsed.data.format,
formId,
secret: randomBytes(24).toString("hex"),
},
select: {
Expand All @@ -87,6 +99,7 @@ export async function POST(
events: true,
active: true,
format: true,
formId: true,
createdAt: true,
},
});
Expand Down
52 changes: 46 additions & 6 deletions apps/web/src/app/dashboard/[guildId]/webhooks/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,59 @@ 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 (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h2 className="font-heading text-xl font-semibold text-foreground">{t.title}</h2>
<p className="text-sm text-muted-foreground">{t.intro}</p>
</div>
<WebhooksManager guildId={guildId} initial={webhooks} t={t} />
<WebhooksManager guildId={guildId} initial={webhooks} forms={forms} t={t} />
</div>
);
}
61 changes: 57 additions & 4 deletions apps/web/src/components/webhooks/webhooks-manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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<WebhookRow[]>(initial);
const [url, setUrl] = useState("");
const [format, setFormat] = useState<WebhookFormat>("json");
// "" = every form; otherwise a specific form id.
const [formId, setFormId] = useState("");
const [events, setEvents] = useState<Set<WebhookEvent>>(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<string | null>(null);
const [revealed, setRevealed] = useState<Set<string>>(new Set());
Expand All @@ -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);
Expand Down Expand Up @@ -166,6 +181,18 @@ export function WebhooksManager({
))}
</div>
</Field>
{forms.length > 0 && (
<Field label={t.formScope} hint={t.formScopeHint}>
<Select
value={formId}
onChange={(e) => setFormId(e.target.value)}
options={[
{ value: "", label: t.scopeAll },
...forms.map((f) => ({ value: f.id, label: f.title })),
]}
/>
</Field>
)}
<div>
<Button type="button" onClick={add} disabled={adding || !url.trim() || events.size === 0}>
{adding ? t.adding : t.add}
Expand Down Expand Up @@ -211,7 +238,7 @@ export function WebhooksManager({
</span>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
<div className="flex flex-wrap items-center gap-1.5">
{hook.events.map((event) => (
<span
key={event}
Expand All @@ -220,7 +247,33 @@ export function WebhooksManager({
{eventLabel(event, t)}
</span>
))}
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{hook.formId ? formTitle(hook.formId) : t.scopeAll}
</span>
</div>
<p className="text-xs text-muted-foreground">
<span className="font-medium">{t.lastDelivery}:</span>{" "}
{hook.lastDelivery ? (
<span
className={
hook.lastDelivery.status === "success"
? "text-primary"
: hook.lastDelivery.status === "failed"
? "text-destructive"
: "text-muted-foreground"
}
>
{hook.lastDelivery.status === "success"
? t.deliverySuccess
: hook.lastDelivery.status === "failed"
? t.deliveryFailed
: t.deliveryPending}
{hook.lastDelivery.error ? ` (${hook.lastDelivery.error})` : ""}
</span>
) : (
t.deliveryNone
)}
</p>
{/* The signing secret only applies to generic JSON hooks; a
Discord webhook does not verify a signature. */}
{hook.format !== "discord" && (
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/i18n/dictionaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down
Original file line number Diff line number Diff line change
@@ -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);

7 changes: 7 additions & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ model Form {
formStatuses FormStatusDef[]
reviewers FormReviewer[]
experimentStats ExperimentStat[]
webhooks Webhook[]

@@index([guildId, status])
@@index([guildId, categoryId])
Expand Down Expand Up @@ -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")
}

Expand All @@ -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")
}
15 changes: 12 additions & 3 deletions packages/db/src/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -18,8 +20,15 @@ export async function enqueueWebhooks(
event: string,
payload: Record<string, unknown>,
): Promise<void> {
const formId = typeof payload.formId === "string" ? payload.formId : null;
const hooks = await db.webhook.findMany({
where: { guildId, active: true, events: { has: event } },
where: {
guildId,
active: true,
events: { has: event },
// Guild-wide hooks (formId null) always match; scoped hooks only their form.
OR: [{ formId: null }, ...(formId ? [{ formId }] : [])],
},
select: { id: true },
});
if (hooks.length === 0) return;
Expand Down
20 changes: 20 additions & 0 deletions packages/shared/src/webhooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,26 @@ describe("webhookInputSchema format", () => {
}).success,
).toBe(true);
});

it("rejects a Discord webhook URL left on the generic JSON format", () => {
// The common footgun: a Discord URL with format json would POST raw signed
// JSON that Discord rejects, so nothing logs. The schema catches it.
const res = webhookInputSchema.safeParse({
url: "https://discord.com/api/webhooks/123456789/AbC-tok_en",
events: ["submission.created"],
format: "json",
});
expect(res.success).toBe(false);
});

it("accepts an optional form scope", () => {
const parsed = webhookInputSchema.parse({
url: "https://example.com/hooks",
events: ["submission.created"],
formId: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
});
expect(parsed.formId).toBe("3f2504e0-4f89-41d3-9a0c-0305e82c3301");
});
});

describe("buildDiscordWebhookBody", () => {
Expand Down
Loading