From 4735cb1c3ac5a7c1fef0394b8f67df64f8aad7a0 Mon Sep 17 00:00:00 2001 From: KMKoushik Date: Fri, 27 Feb 2026 04:04:49 +0000 Subject: [PATCH 1/5] feat(webhooks): add multi-domain endpoint filtering --- apps/web/prisma/schema.prisma | 1 + .../webhooks/[webhookId]/webhook-info.tsx | 33 +++++- .../app/(dashboard)/webhooks/add-webhook.tsx | 84 ++++++++++++++ .../webhooks/webhook-update-dialog.tsx | 87 ++++++++++++++- apps/web/src/server/api/routers/webhook.ts | 4 + apps/web/src/server/service/domain-service.ts | 4 +- .../web/src/server/service/ses-hook-parser.ts | 14 ++- .../web/src/server/service/webhook-service.ts | 105 ++++++++++++++++-- 8 files changed, 316 insertions(+), 16 deletions(-) diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 58e26f68..1492adf8 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -469,6 +469,7 @@ enum WebhookCallStatus { model Webhook { id String @id @default(cuid()) teamId Int + domainIds Int[] @default([]) url String description String? secret String diff --git a/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx index a5185d1f..c4201c36 100644 --- a/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx +++ b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx @@ -10,7 +10,9 @@ import { api } from "~/trpc/react"; import { Badge } from "@usesend/ui/src/badge"; import { WebhookStatusBadge } from "../webhook-status-badge"; -export function WebhookInfo({ webhook }: { webhook: Webhook }) { +type WebhookWithDomainIds = Webhook & { domainIds?: number[] }; + +export function WebhookInfo({ webhook }: { webhook: WebhookWithDomainIds }) { const [showSecret, setShowSecret] = useState(false); const sevenDaysAgo = new Date(); @@ -20,6 +22,7 @@ export function WebhookInfo({ webhook }: { webhook: Webhook }) { webhookId: webhook.id, limit: 50, }); + const domainsQuery = api.domain.domains.useQuery(); const calls = callsQuery.data?.items ?? []; const last7DaysCalls = calls.filter( @@ -38,6 +41,13 @@ export function WebhookInfo({ webhook }: { webhook: Webhook }) { c.status === WebhookCallStatus.IN_PROGRESS, ).length; + const domainNameById = new Map( + (domainsQuery.data ?? []).map((domain) => [domain.id, domain.name]), + ); + const selectedDomainLabels = (webhook.domainIds ?? []).map( + (domainId) => domainNameById.get(domainId) ?? `Domain #${domainId}`, + ); + const handleCopySecret = () => { navigator.clipboard.writeText(webhook.secret); toast.success("Secret copied to clipboard"); @@ -66,6 +76,27 @@ export function WebhookInfo({ webhook }: { webhook: Webhook }) { )} +
+ Domains +
+ {(webhook.domainIds ?? []).length === 0 ? ( + All domains + ) : ( + <> + {selectedDomainLabels.slice(0, 2).map((domainName, index) => ( + + {domainName} + + ))} + {(webhook.domainIds ?? []).length > 2 && ( + + +{(webhook.domainIds ?? []).length - 2} more + + )} + + )} +
+
Status
diff --git a/apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx b/apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx index 26dc26de..e22639f5 100644 --- a/apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx +++ b/apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx @@ -50,6 +50,7 @@ const webhookSchema = z.object({ eventTypes: z.array(EVENT_TYPES_ENUM, { required_error: "Select at least one event", }), + domainIds: z.array(z.number().int().positive()), }); type WebhookFormValues = z.infer; @@ -67,6 +68,7 @@ export function AddWebhook() { const [open, setOpen] = useState(false); const [allEventsSelected, setAllEventsSelected] = useState(false); const createWebhookMutation = api.webhook.create.useMutation(); + const domainsQuery = api.domain.domains.useQuery(); const limitsQuery = api.limits.get.useQuery({ type: LimitReason.WEBHOOK }); const { openModal } = useUpgradeModalStore((s) => s.action); @@ -77,6 +79,7 @@ export function AddWebhook() { defaultValues: { url: "", eventTypes: [], + domainIds: [], }, }); @@ -106,6 +109,7 @@ export function AddWebhook() { { url: values.url, eventTypes: allEventsSelected ? [] : selectedEvents, + domainIds: values.domainIds, }, { onSuccess: async () => { @@ -113,6 +117,7 @@ export function AddWebhook() { form.reset({ url: "", eventTypes: [], + domainIds: [], }); setAllEventsSelected(false); setOpen(false); @@ -315,6 +320,85 @@ export function AddWebhook() { ); }} /> + { + const selectedDomainIds = field.value ?? []; + const selectedDomains = + domainsQuery.data?.filter((domain) => + selectedDomainIds.includes(domain.id), + ) ?? []; + + const selectedDomainsLabel = + selectedDomainIds.length === 0 + ? "All domains" + : selectedDomainIds.length === 1 + ? (selectedDomains[0]?.name ?? "1 domain selected") + : `${selectedDomainIds.length} domains selected`; + + const handleToggleDomain = (domainId: number) => { + const exists = selectedDomainIds.includes(domainId); + const next = exists + ? selectedDomainIds.filter((id) => id !== domainId) + : [...selectedDomainIds, domainId]; + field.onChange(next); + }; + + return ( + + Domains + + + + + + +
+ field.onChange([])} + onSelect={(event) => event.preventDefault()} + className="mb-2 px-2 font-medium" + > + All domains + + {domainsQuery.data?.map((domain) => ( + + handleToggleDomain(domain.id) + } + onSelect={(event) => event.preventDefault()} + className="pl-3 pr-2" + > + {domain.name} + + ))} +
+
+
+
+ + Leave this as all domains to receive events from every + domain. + +
+ ); + }} + />
+ + +
+ field.onChange([])} + onSelect={(event) => event.preventDefault()} + className="mb-2 px-2 font-medium" + > + All domains + + {domainsQuery.data?.map((domain) => ( + + handleToggleDomain(domain.id) + } + onSelect={(event) => event.preventDefault()} + className="pl-3 pr-2" + > + {domain.name} + + ))} +
+
+ + + + Leave this as all domains to receive events from every + domain. + + + ); + }} + />
- +
- +
Date: Sun, 8 Mar 2026 08:56:40 +1100 Subject: [PATCH 5/5] stuff --- .../webhooks/[webhookId]/webhook-info.tsx | 6 +- .../webhooks/webhook-update-dialog.tsx | 3 +- .../service/webhook-service.unit.test.ts | 227 +++++++++++++++++- 3 files changed, 227 insertions(+), 9 deletions(-) diff --git a/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx index c4201c36..5ab787f7 100644 --- a/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx +++ b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx @@ -1,6 +1,6 @@ "use client"; -import { Webhook, WebhookCallStatus } from "@prisma/client"; +import { WebhookCallStatus, type Webhook } from "@prisma/client"; import { formatDistanceToNow } from "date-fns"; import { Copy, Eye, EyeOff } from "lucide-react"; import { useState } from "react"; @@ -10,9 +10,7 @@ import { api } from "~/trpc/react"; import { Badge } from "@usesend/ui/src/badge"; import { WebhookStatusBadge } from "../webhook-status-badge"; -type WebhookWithDomainIds = Webhook & { domainIds?: number[] }; - -export function WebhookInfo({ webhook }: { webhook: WebhookWithDomainIds }) { +export function WebhookInfo({ webhook }: { webhook: Webhook }) { const [showSecret, setShowSecret] = useState(false); const sevenDaysAgo = new Date(); diff --git a/apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx b/apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx index 320c130a..d173015e 100644 --- a/apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx +++ b/apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx @@ -52,7 +52,6 @@ const editWebhookSchema = z.object({ }); type EditWebhookFormValues = z.infer; -type WebhookWithDomainIds = Webhook & { domainIds?: number[] }; const eventGroups: { label: string; @@ -68,7 +67,7 @@ export function EditWebhookDialog({ open, onOpenChange, }: { - webhook: WebhookWithDomainIds; + webhook: Webhook; open: boolean; onOpenChange: (open: boolean) => void; }) { diff --git a/apps/web/src/server/service/webhook-service.unit.test.ts b/apps/web/src/server/service/webhook-service.unit.test.ts index 7bdc0a47..73fd9feb 100644 --- a/apps/web/src/server/service/webhook-service.unit.test.ts +++ b/apps/web/src/server/service/webhook-service.unit.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const { capturedProcessWebhookCall, mockDb, + mockLimitService, mockLogger, mockQueueAdd, mockRedis, @@ -14,6 +15,9 @@ const { }, mockDb: { $transaction: vi.fn(), + domain: { + findMany: vi.fn(), + }, webhook: { create: vi.fn(), delete: vi.fn(), @@ -29,6 +33,9 @@ const { update: vi.fn(), }, }, + mockLimitService: { + checkWebhookLimit: vi.fn(), + }, mockLogger: { debug: vi.fn(), error: vi.fn(), @@ -61,9 +68,7 @@ vi.mock("~/server/logger/log", () => ({ })); vi.mock("~/server/service/limit-service", () => ({ - LimitService: { - checkWebhookLimit: vi.fn(), - }, + LimitService: mockLimitService, })); vi.mock("~/server/queue/bullmq-context", () => ({ @@ -119,6 +124,7 @@ async function invokeProcessWebhookCall(attemptsMade = 0) { describe("WebhookService documented behavior", () => { beforeEach(() => { + mockDb.domain.findMany.mockReset(); mockDb.webhook.create.mockReset(); mockDb.webhook.delete.mockReset(); mockDb.webhook.findFirst.mockReset(); @@ -136,11 +142,16 @@ describe("WebhookService documented behavior", () => { mockLogger.error.mockReset(); mockLogger.info.mockReset(); mockLogger.warn.mockReset(); + mockLimitService.checkWebhookLimit.mockReset(); mockQueueAdd.mockReset(); mockRedis.eval.mockReset(); mockRedis.set.mockReset(); mockTxWebhookUpdate.mockReset(); + mockLimitService.checkWebhookLimit.mockResolvedValue({ + isLimitReached: false, + reason: null, + }); mockRedis.set.mockResolvedValue("OK"); mockRedis.eval.mockResolvedValue(1); mockQueueAdd.mockResolvedValue(undefined); @@ -376,6 +387,168 @@ describe("WebhookService documented behavior", () => { { jobId: "call_test_1" }, ); }); + + it("dedupes and validates domainIds on webhook creation", async () => { + mockDb.domain.findMany.mockResolvedValue([{ id: 1 }, { id: 2 }]); + mockDb.webhook.create.mockResolvedValue({ + id: "wh_created", + }); + + await expect( + WebhookService.createWebhook({ + teamId: 77, + userId: 42, + url: "https://example.com/webhook", + eventTypes: ["email.sent"], + domainIds: [1, 1, 2], + secret: "whsec_test_create", + }), + ).resolves.toMatchObject({ id: "wh_created" }); + + expect(mockDb.domain.findMany).toHaveBeenCalledWith({ + where: { + id: { + in: [1, 2], + }, + teamId: 77, + }, + select: { + id: true, + }, + }); + expect(mockDb.webhook.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + teamId: 77, + createdByUserId: 42, + url: "https://example.com/webhook", + eventTypes: ["email.sent"], + domainIds: [1, 2], + secret: "whsec_test_create", + status: WebhookStatus.ACTIVE, + }), + }); + }); + + it("rejects webhook creation when one or more domainIds do not belong to the team", async () => { + mockDb.domain.findMany.mockResolvedValue([{ id: 1 }]); + + await expect( + WebhookService.createWebhook({ + teamId: 77, + userId: 42, + url: "https://example.com/webhook", + eventTypes: ["email.sent"], + domainIds: [1, 2], + secret: "whsec_test_create", + }), + ).rejects.toThrow("One or more domains were not found"); + + expect(mockDb.webhook.create).not.toHaveBeenCalled(); + }); + + it("preserves existing domainIds on webhook update when domainIds are omitted", async () => { + mockDb.webhook.findFirst.mockResolvedValue({ + id: "wh_123", + teamId: 77, + url: "https://old.example.com/webhook", + description: "Old webhook", + eventTypes: ["email.sent"], + domainIds: [7, 8], + secret: "whsec_existing", + }); + mockDb.webhook.update.mockResolvedValue({ + id: "wh_123", + }); + + await expect( + WebhookService.updateWebhook({ + id: "wh_123", + teamId: 77, + url: "https://new.example.com/webhook", + }), + ).resolves.toMatchObject({ id: "wh_123" }); + + expect(mockDb.domain.findMany).not.toHaveBeenCalled(); + expect(mockDb.webhook.update).toHaveBeenCalledWith({ + where: { id: "wh_123" }, + data: { + url: "https://new.example.com/webhook", + description: "Old webhook", + eventTypes: ["email.sent"], + domainIds: [7, 8], + secret: "whsec_existing", + }, + }); + }); + + it("dedupes and validates provided domainIds on webhook update", async () => { + mockDb.webhook.findFirst.mockResolvedValue({ + id: "wh_123", + teamId: 77, + url: "https://old.example.com/webhook", + description: "Old webhook", + eventTypes: ["email.sent"], + domainIds: [7, 8], + secret: "whsec_existing", + }); + mockDb.domain.findMany.mockResolvedValue([{ id: 5 }, { id: 6 }]); + mockDb.webhook.update.mockResolvedValue({ + id: "wh_123", + }); + + await expect( + WebhookService.updateWebhook({ + id: "wh_123", + teamId: 77, + domainIds: [5, 5, 6], + }), + ).resolves.toMatchObject({ id: "wh_123" }); + + expect(mockDb.domain.findMany).toHaveBeenCalledWith({ + where: { + id: { + in: [5, 6], + }, + teamId: 77, + }, + select: { + id: true, + }, + }); + expect(mockDb.webhook.update).toHaveBeenCalledWith({ + where: { id: "wh_123" }, + data: { + url: "https://old.example.com/webhook", + description: "Old webhook", + eventTypes: ["email.sent"], + domainIds: [5, 6], + secret: "whsec_existing", + }, + }); + }); + + it("rejects webhook update when one or more provided domainIds do not belong to the team", async () => { + mockDb.webhook.findFirst.mockResolvedValue({ + id: "wh_123", + teamId: 77, + url: "https://old.example.com/webhook", + description: "Old webhook", + eventTypes: ["email.sent"], + domainIds: [7, 8], + secret: "whsec_existing", + }); + mockDb.domain.findMany.mockResolvedValue([{ id: 5 }]); + + await expect( + WebhookService.updateWebhook({ + id: "wh_123", + teamId: 77, + domainIds: [5, 6], + }), + ).rejects.toThrow("One or more domains were not found"); + + expect(mockDb.webhook.update).not.toHaveBeenCalled(); + }); }); describe("WebhookService.emit domain filters", () => { @@ -501,4 +674,52 @@ describe("WebhookService.emit domain filters", () => { { jobId: "call_wh_global" }, ); }); + + it("does not apply domain filtering when the domain context is explicitly null", async () => { + mockDb.webhook.findMany.mockResolvedValue([ + { id: "wh_global", teamId: 10, status: WebhookStatus.ACTIVE }, + { id: "wh_scoped", teamId: 10, status: WebhookStatus.ACTIVE }, + ]); + + await WebhookService.emit( + 10, + "email.delivered", + { + id: "email_1", + status: "delivered", + from: "from@example.com", + to: ["to@example.com"], + occurredAt: new Date().toISOString(), + subject: "Hello", + metadata: {}, + domainId: null, + } as never, + { domainId: null }, + ); + + expect(mockDb.webhook.findMany).toHaveBeenCalledWith({ + where: { + teamId: 10, + status: WebhookStatus.ACTIVE, + AND: [ + { + OR: [ + { + eventTypes: { + has: "email.delivered", + }, + }, + { + eventTypes: { + isEmpty: true, + }, + }, + ], + }, + ], + }, + }); + expect(mockDb.webhookCall.create).toHaveBeenCalledTimes(2); + expect(mockQueueAdd).toHaveBeenCalledTimes(2); + }); });