feat: add multi-domain filters to webhooks (#361)

* feat(webhooks): add multi-domain endpoint filtering

* test(webhooks): add domain filter router coverage

* fix(webhooks): apply domain filters only to domain-scoped events

* stuff

* stuff
This commit is contained in:
KM Koushik
2026-03-08 09:01:39 +11:00
committed by GitHub
parent 79f9049e40
commit 3c2d37906e
12 changed files with 778 additions and 21 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Webhook" ADD COLUMN "domainIds" INTEGER[] DEFAULT ARRAY[]::INTEGER[];
+1
View File
@@ -469,6 +469,7 @@ enum WebhookCallStatus {
model Webhook { model Webhook {
id String @id @default(cuid()) id String @id @default(cuid())
teamId Int teamId Int
domainIds Int[] @default([])
url String url String
description String? description String?
secret String secret String
@@ -35,10 +35,10 @@ export const ScheduleCampaign: React.FC<{
const [scheduleInput, setScheduleInput] = useState<string>( const [scheduleInput, setScheduleInput] = useState<string>(
initialScheduledAtDate initialScheduledAtDate
? format(initialScheduledAtDate, "yyyy-MM-dd HH:mm") ? format(initialScheduledAtDate, "yyyy-MM-dd HH:mm")
: "" : "",
); );
const [selectedDate, setSelectedDate] = useState<Date | null>( const [selectedDate, setSelectedDate] = useState<Date | null>(
initialScheduledAtDate ?? new Date() initialScheduledAtDate ?? new Date(),
); );
const [isConfirmNow, setIsConfirmNow] = useState(false); const [isConfirmNow, setIsConfirmNow] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -86,7 +86,7 @@ export const ScheduleCampaign: React.FC<{
onError: (error) => { onError: (error) => {
setError(error.message || "Failed to schedule campaign"); setError(error.message || "Failed to schedule campaign");
}, },
} },
); );
}; };
@@ -1,6 +1,6 @@
"use client"; "use client";
import { Webhook, WebhookCallStatus } from "@prisma/client"; import { WebhookCallStatus, type Webhook } from "@prisma/client";
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from "date-fns";
import { Copy, Eye, EyeOff } from "lucide-react"; import { Copy, Eye, EyeOff } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
@@ -20,6 +20,7 @@ export function WebhookInfo({ webhook }: { webhook: Webhook }) {
webhookId: webhook.id, webhookId: webhook.id,
limit: 50, limit: 50,
}); });
const domainsQuery = api.domain.domains.useQuery();
const calls = callsQuery.data?.items ?? []; const calls = callsQuery.data?.items ?? [];
const last7DaysCalls = calls.filter( const last7DaysCalls = calls.filter(
@@ -38,6 +39,13 @@ export function WebhookInfo({ webhook }: { webhook: Webhook }) {
c.status === WebhookCallStatus.IN_PROGRESS, c.status === WebhookCallStatus.IN_PROGRESS,
).length; ).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 = () => { const handleCopySecret = () => {
navigator.clipboard.writeText(webhook.secret); navigator.clipboard.writeText(webhook.secret);
toast.success("Secret copied to clipboard"); toast.success("Secret copied to clipboard");
@@ -66,6 +74,27 @@ export function WebhookInfo({ webhook }: { webhook: Webhook }) {
)} )}
</div> </div>
</div> </div>
<div className="flex flex-col gap-1">
<span className="text-sm text-muted-foreground">Domains</span>
<div className="flex items-center gap-1 flex-wrap text-sm">
{(webhook.domainIds ?? []).length === 0 ? (
<span className="text-sm">All domains</span>
) : (
<>
{selectedDomainLabels.slice(0, 2).map((domainName, index) => (
<Badge key={`${domainName}-${index}`} variant="outline">
{domainName}
</Badge>
))}
{(webhook.domainIds ?? []).length > 2 && (
<span className="text-xs text-muted-foreground">
+{(webhook.domainIds ?? []).length - 2} more
</span>
)}
</>
)}
</div>
</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-sm text-muted-foreground">Status</span> <span className="text-sm text-muted-foreground">Status</span>
<div className="flex items-center"> <div className="flex items-center">
@@ -50,6 +50,7 @@ const webhookSchema = z.object({
eventTypes: z.array(EVENT_TYPES_ENUM, { eventTypes: z.array(EVENT_TYPES_ENUM, {
required_error: "Select at least one event", required_error: "Select at least one event",
}), }),
domainIds: z.array(z.number().int().positive()),
}); });
type WebhookFormValues = z.infer<typeof webhookSchema>; type WebhookFormValues = z.infer<typeof webhookSchema>;
@@ -67,6 +68,7 @@ export function AddWebhook() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [allEventsSelected, setAllEventsSelected] = useState(false); const [allEventsSelected, setAllEventsSelected] = useState(false);
const createWebhookMutation = api.webhook.create.useMutation(); const createWebhookMutation = api.webhook.create.useMutation();
const domainsQuery = api.domain.domains.useQuery();
const limitsQuery = api.limits.get.useQuery({ type: LimitReason.WEBHOOK }); const limitsQuery = api.limits.get.useQuery({ type: LimitReason.WEBHOOK });
const { openModal } = useUpgradeModalStore((s) => s.action); const { openModal } = useUpgradeModalStore((s) => s.action);
@@ -77,6 +79,7 @@ export function AddWebhook() {
defaultValues: { defaultValues: {
url: "", url: "",
eventTypes: [], eventTypes: [],
domainIds: [],
}, },
}); });
@@ -106,6 +109,7 @@ export function AddWebhook() {
{ {
url: values.url, url: values.url,
eventTypes: allEventsSelected ? [] : selectedEvents, eventTypes: allEventsSelected ? [] : selectedEvents,
domainIds: values.domainIds,
}, },
{ {
onSuccess: async () => { onSuccess: async () => {
@@ -113,6 +117,7 @@ export function AddWebhook() {
form.reset({ form.reset({
url: "", url: "",
eventTypes: [], eventTypes: [],
domainIds: [],
}); });
setAllEventsSelected(false); setAllEventsSelected(false);
setOpen(false); setOpen(false);
@@ -315,6 +320,85 @@ export function AddWebhook() {
); );
}} }}
/> />
<FormField
control={form.control}
name="domainIds"
render={({ field }) => {
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 (
<FormItem>
<FormLabel>Domains</FormLabel>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className="mt-3 inline-flex w-full items-center justify-between"
>
<span className="truncate text-left text-sm">
{selectedDomainsLabel}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-[30vh] w-[--radix-dropdown-menu-trigger-width] overflow-y-auto">
<div className="space-y-3">
<DropdownMenuCheckboxItem
checked={selectedDomainIds.length === 0}
onCheckedChange={() => field.onChange([])}
onSelect={(event) => event.preventDefault()}
className="mb-2 px-2 font-medium"
>
All domains
</DropdownMenuCheckboxItem>
{domainsQuery.data?.map((domain) => (
<DropdownMenuCheckboxItem
key={domain.id}
checked={selectedDomainIds.includes(
domain.id,
)}
onCheckedChange={() =>
handleToggleDomain(domain.id)
}
onSelect={(event) => event.preventDefault()}
className="pl-3 pr-2"
>
{domain.name}
</DropdownMenuCheckboxItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<FormDescription>
Leave this as all domains to receive events from every
domain.
</FormDescription>
</FormItem>
);
}}
/>
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
className="w-[120px]" className="w-[120px]"
@@ -48,6 +48,7 @@ const editWebhookSchema = z.object({
eventTypes: z.array(EVENT_TYPES_ENUM, { eventTypes: z.array(EVENT_TYPES_ENUM, {
required_error: "Select at least one event", required_error: "Select at least one event",
}), }),
domainIds: z.array(z.number().int().positive()),
}); });
type EditWebhookFormValues = z.infer<typeof editWebhookSchema>; type EditWebhookFormValues = z.infer<typeof editWebhookSchema>;
@@ -71,6 +72,7 @@ export function EditWebhookDialog({
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}) { }) {
const updateWebhook = api.webhook.update.useMutation(); const updateWebhook = api.webhook.update.useMutation();
const domainsQuery = api.domain.domains.useQuery();
const utils = api.useUtils(); const utils = api.useUtils();
const initialHasAllEvents = const initialHasAllEvents =
(webhook.eventTypes as WebhookEventType[]).length === 0; (webhook.eventTypes as WebhookEventType[]).length === 0;
@@ -84,6 +86,7 @@ export function EditWebhookDialog({
eventTypes: initialHasAllEvents eventTypes: initialHasAllEvents
? [] ? []
: (webhook.eventTypes as WebhookEventType[]), : (webhook.eventTypes as WebhookEventType[]),
domainIds: webhook.domainIds ?? [],
}, },
}); });
@@ -96,6 +99,7 @@ export function EditWebhookDialog({
eventTypes: hasAllEvents eventTypes: hasAllEvents
? [] ? []
: (webhook.eventTypes as WebhookEventType[]), : (webhook.eventTypes as WebhookEventType[]),
domainIds: webhook.domainIds ?? [],
}); });
setAllEventsSelected(hasAllEvents); setAllEventsSelected(hasAllEvents);
} }
@@ -114,6 +118,7 @@ export function EditWebhookDialog({
id: webhook.id, id: webhook.id,
url: values.url, url: values.url,
eventTypes: allEventsSelected ? [] : selectedEvents, eventTypes: allEventsSelected ? [] : selectedEvents,
domainIds: values.domainIds,
}, },
{ {
onSuccess: async () => { onSuccess: async () => {
@@ -308,6 +313,85 @@ export function EditWebhookDialog({
); );
}} }}
/> />
<FormField
control={form.control}
name="domainIds"
render={({ field }) => {
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 (
<FormItem>
<FormLabel>Domains</FormLabel>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="outline"
className="mt-3 inline-flex w-full items-center justify-between"
>
<span className="truncate text-left text-sm">
{selectedDomainsLabel}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-[30vh] w-[--radix-dropdown-menu-trigger-width] overflow-y-auto">
<div className="space-y-3">
<DropdownMenuCheckboxItem
checked={selectedDomainIds.length === 0}
onCheckedChange={() => field.onChange([])}
onSelect={(event) => event.preventDefault()}
className="mb-2 px-2 font-medium"
>
All domains
</DropdownMenuCheckboxItem>
{domainsQuery.data?.map((domain) => (
<DropdownMenuCheckboxItem
key={domain.id}
checked={selectedDomainIds.includes(
domain.id,
)}
onCheckedChange={() =>
handleToggleDomain(domain.id)
}
onSelect={(event) => event.preventDefault()}
className="pl-3 pr-2"
>
{domain.name}
</DropdownMenuCheckboxItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<FormDescription>
Leave this as all domains to receive events from every
domain.
</FormDescription>
</FormItem>
);
}}
/>
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
className="w-[120px]" className="w-[120px]"
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockDb, mockWebhookService } = vi.hoisted(() => ({
mockDb: {
teamUser: {
findFirst: vi.fn(),
},
},
mockWebhookService: {
listWebhooks: vi.fn(),
getWebhook: vi.fn(),
createWebhook: vi.fn(),
updateWebhook: vi.fn(),
setWebhookStatus: vi.fn(),
deleteWebhook: vi.fn(),
testWebhook: vi.fn(),
listWebhookCalls: vi.fn(),
getWebhookCall: vi.fn(),
retryCall: vi.fn(),
},
}));
vi.mock("~/server/db", () => ({
db: mockDb,
}));
vi.mock("~/server/auth", () => ({
getServerAuthSession: vi.fn(),
}));
vi.mock("~/server/service/webhook-service", () => ({
WebhookService: mockWebhookService,
}));
import { createCallerFactory } from "~/server/api/trpc";
import { webhookRouter } from "~/server/api/routers/webhook";
const createCaller = createCallerFactory(webhookRouter);
function getContext() {
return {
db: mockDb,
headers: new Headers(),
session: {
user: {
id: 42,
email: "owner@example.com",
isWaitlisted: false,
isAdmin: false,
isBetaUser: true,
},
},
} as any;
}
describe("webhookRouter domain filters", () => {
beforeEach(() => {
mockDb.teamUser.findFirst.mockReset();
mockWebhookService.createWebhook.mockReset();
mockWebhookService.updateWebhook.mockReset();
mockDb.teamUser.findFirst.mockResolvedValue({
teamId: 10,
userId: 42,
role: "ADMIN",
team: { id: 10, name: "Acme" },
});
mockWebhookService.createWebhook.mockResolvedValue({
id: "wh_1",
});
mockWebhookService.updateWebhook.mockResolvedValue({
id: "wh_1",
});
});
it("passes selected domainIds on webhook creation", async () => {
const caller = createCaller(getContext());
await caller.create({
url: "https://example.com/webhook",
eventTypes: ["email.sent"],
domainIds: [1, 2, 3],
});
expect(mockWebhookService.createWebhook).toHaveBeenCalledWith({
teamId: 10,
userId: 42,
url: "https://example.com/webhook",
description: undefined,
eventTypes: ["email.sent"],
domainIds: [1, 2, 3],
secret: undefined,
});
});
it("passes selected domainIds on webhook update", async () => {
const caller = createCaller(getContext());
await caller.update({
id: "wh_1",
domainIds: [5, 6],
});
expect(mockWebhookService.updateWebhook).toHaveBeenCalledWith({
id: "wh_1",
teamId: 10,
url: undefined,
description: undefined,
eventTypes: undefined,
domainIds: [5, 6],
rotateSecret: undefined,
secret: undefined,
});
});
});
@@ -26,6 +26,7 @@ export const webhookRouter = createTRPCRouter({
url: z.string().url(), url: z.string().url(),
description: z.string().optional(), description: z.string().optional(),
eventTypes: z.array(EVENT_TYPES_ENUM), eventTypes: z.array(EVENT_TYPES_ENUM),
domainIds: z.array(z.number().int().positive()).optional(),
secret: z.string().min(16).optional(), secret: z.string().min(16).optional(),
}), }),
) )
@@ -36,6 +37,7 @@ export const webhookRouter = createTRPCRouter({
url: input.url, url: input.url,
description: input.description, description: input.description,
eventTypes: input.eventTypes, eventTypes: input.eventTypes,
domainIds: input.domainIds,
secret: input.secret, secret: input.secret,
}); });
}), }),
@@ -47,6 +49,7 @@ export const webhookRouter = createTRPCRouter({
url: z.string().url().optional(), url: z.string().url().optional(),
description: z.string().nullable().optional(), description: z.string().nullable().optional(),
eventTypes: z.array(EVENT_TYPES_ENUM).optional(), eventTypes: z.array(EVENT_TYPES_ENUM).optional(),
domainIds: z.array(z.number().int().positive()).optional(),
rotateSecret: z.boolean().optional(), rotateSecret: z.boolean().optional(),
secret: z.string().min(16).optional(), secret: z.string().min(16).optional(),
}), }),
@@ -58,6 +61,7 @@ export const webhookRouter = createTRPCRouter({
url: input.url, url: input.url,
description: input.description, description: input.description,
eventTypes: input.eventTypes, eventTypes: input.eventTypes,
domainIds: input.domainIds,
rotateSecret: input.rotateSecret, rotateSecret: input.rotateSecret,
secret: input.secret, secret: input.secret,
}); });
@@ -386,7 +386,9 @@ async function getDmarcRecord(domain: string) {
async function emitDomainEvent(domain: Domain, type: DomainWebhookEventType) { async function emitDomainEvent(domain: Domain, type: DomainWebhookEventType) {
try { try {
await WebhookService.emit(domain.teamId, type, buildDomainPayload(domain)); await WebhookService.emit(domain.teamId, type, buildDomainPayload(domain), {
domainId: domain.id,
});
} catch (error) { } catch (error) {
logger.error( logger.error(
{ error, domainId: domain.id, type }, { error, domainId: domain.id, type },
@@ -132,12 +132,15 @@ export async function parseSesHook(data: SesEvent) {
if (isHardBounced && data.bounce?.bouncedRecipients) { if (isHardBounced && data.bounce?.bouncedRecipients) {
// For bounces, only add the recipients that actually bounced // For bounces, only add the recipients that actually bounced
recipientEmails = data.bounce.bouncedRecipients.map( recipientEmails = data.bounce.bouncedRecipients.map(
(recipient) => recipient.emailAddress (recipient) => recipient.emailAddress,
); );
} else if (mailStatus === EmailStatus.COMPLAINED && data.complaint?.complainedRecipients) { } else if (
mailStatus === EmailStatus.COMPLAINED &&
data.complaint?.complainedRecipients
) {
// For complaints, only add the recipients that actually complained // For complaints, only add the recipients that actually complained
recipientEmails = data.complaint.complainedRecipients.map( recipientEmails = data.complaint.complainedRecipients.map(
(recipient) => recipient.emailAddress (recipient) => recipient.emailAddress,
); );
} }
@@ -318,6 +321,9 @@ export async function parseSesHook(data: SesEvent) {
eventData: mailData, eventData: mailData,
metadata, metadata,
}), }),
{
domainId: email.domainId ?? null,
},
); );
} catch (error) { } catch (error) {
logger.error( logger.error(
+92 -9
View File
@@ -90,22 +90,46 @@ export class WebhookService {
teamId: number, teamId: number,
type: TType, type: TType,
payload: WebhookEventInput<TType>, payload: WebhookEventInput<TType>,
options?: { domainId?: number | null },
) { ) {
const domainFilter =
options?.domainId == null
? undefined
: {
OR: [
{
domainIds: {
isEmpty: true,
},
},
{
domainIds: {
has: options.domainId,
},
},
],
};
const activeWebhooks = await db.webhook.findMany({ const activeWebhooks = await db.webhook.findMany({
where: { where: {
teamId, teamId,
status: WebhookStatus.ACTIVE, status: WebhookStatus.ACTIVE,
OR: [ AND: [
{ {
eventTypes: { OR: [
has: type, {
}, eventTypes: {
}, has: type,
{ },
eventTypes: { },
isEmpty: true, {
}, eventTypes: {
isEmpty: true,
},
},
],
}, },
...(domainFilter ? [domainFilter] : []),
], ],
}, },
}); });
@@ -229,6 +253,7 @@ export class WebhookService {
url: string; url: string;
description?: string; description?: string;
eventTypes: string[]; eventTypes: string[];
domainIds?: number[];
secret?: string; secret?: string;
}) { }) {
const { isLimitReached, reason } = await LimitService.checkWebhookLimit( const { isLimitReached, reason } = await LimitService.checkWebhookLimit(
@@ -242,11 +267,23 @@ export class WebhookService {
}); });
} }
const normalizedDomainIds = WebhookService.normalizeDomainIds(
params.domainIds,
);
if (normalizedDomainIds.length > 0) {
await WebhookService.assertDomainsBelongToTeam(
normalizedDomainIds,
params.teamId,
);
}
const secret = params.secret ?? WebhookService.generateSecret(); const secret = params.secret ?? WebhookService.generateSecret();
return db.webhook.create({ return db.webhook.create({
data: { data: {
teamId: params.teamId, teamId: params.teamId,
domainIds: normalizedDomainIds,
url: params.url, url: params.url,
description: params.description, description: params.description,
secret, secret,
@@ -263,6 +300,7 @@ export class WebhookService {
url?: string; url?: string;
description?: string | null; description?: string | null;
eventTypes?: string[]; eventTypes?: string[];
domainIds?: number[];
rotateSecret?: boolean; rotateSecret?: boolean;
secret?: string; secret?: string;
}) { }) {
@@ -282,6 +320,18 @@ export class WebhookService {
? WebhookService.generateSecret() ? WebhookService.generateSecret()
: params.secret; : params.secret;
const normalizedDomainIds =
params.domainIds === undefined
? undefined
: WebhookService.normalizeDomainIds(params.domainIds);
if (normalizedDomainIds && normalizedDomainIds.length > 0) {
await WebhookService.assertDomainsBelongToTeam(
normalizedDomainIds,
params.teamId,
);
}
return db.webhook.update({ return db.webhook.update({
where: { id: webhook.id }, where: { id: webhook.id },
data: { data: {
@@ -291,11 +341,44 @@ export class WebhookService {
? webhook.description ? webhook.description
: (params.description ?? null), : (params.description ?? null),
eventTypes: params.eventTypes ?? webhook.eventTypes, eventTypes: params.eventTypes ?? webhook.eventTypes,
domainIds: normalizedDomainIds ?? webhook.domainIds,
secret: secret ?? webhook.secret, secret: secret ?? webhook.secret,
}, },
}); });
} }
private static normalizeDomainIds(domainIds?: number[]) {
if (!domainIds) {
return [];
}
return Array.from(new Set(domainIds));
}
private static async assertDomainsBelongToTeam(
domainIds: number[],
teamId: number,
) {
const matchingDomains = await db.domain.findMany({
where: {
id: {
in: domainIds,
},
teamId,
},
select: {
id: true,
},
});
if (matchingDomains.length !== domainIds.length) {
throw new UnsendApiError({
code: "NOT_FOUND",
message: "One or more domains were not found",
});
}
}
public static async setWebhookStatus(params: { public static async setWebhookStatus(params: {
id: string; id: string;
teamId: number; teamId: number;
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const { const {
capturedProcessWebhookCall, capturedProcessWebhookCall,
mockDb, mockDb,
mockLimitService,
mockLogger, mockLogger,
mockQueueAdd, mockQueueAdd,
mockRedis, mockRedis,
@@ -14,6 +15,9 @@ const {
}, },
mockDb: { mockDb: {
$transaction: vi.fn(), $transaction: vi.fn(),
domain: {
findMany: vi.fn(),
},
webhook: { webhook: {
create: vi.fn(), create: vi.fn(),
delete: vi.fn(), delete: vi.fn(),
@@ -29,6 +33,9 @@ const {
update: vi.fn(), update: vi.fn(),
}, },
}, },
mockLimitService: {
checkWebhookLimit: vi.fn(),
},
mockLogger: { mockLogger: {
debug: vi.fn(), debug: vi.fn(),
error: vi.fn(), error: vi.fn(),
@@ -61,9 +68,7 @@ vi.mock("~/server/logger/log", () => ({
})); }));
vi.mock("~/server/service/limit-service", () => ({ vi.mock("~/server/service/limit-service", () => ({
LimitService: { LimitService: mockLimitService,
checkWebhookLimit: vi.fn(),
},
})); }));
vi.mock("~/server/queue/bullmq-context", () => ({ vi.mock("~/server/queue/bullmq-context", () => ({
@@ -119,6 +124,7 @@ async function invokeProcessWebhookCall(attemptsMade = 0) {
describe("WebhookService documented behavior", () => { describe("WebhookService documented behavior", () => {
beforeEach(() => { beforeEach(() => {
mockDb.domain.findMany.mockReset();
mockDb.webhook.create.mockReset(); mockDb.webhook.create.mockReset();
mockDb.webhook.delete.mockReset(); mockDb.webhook.delete.mockReset();
mockDb.webhook.findFirst.mockReset(); mockDb.webhook.findFirst.mockReset();
@@ -136,11 +142,16 @@ describe("WebhookService documented behavior", () => {
mockLogger.error.mockReset(); mockLogger.error.mockReset();
mockLogger.info.mockReset(); mockLogger.info.mockReset();
mockLogger.warn.mockReset(); mockLogger.warn.mockReset();
mockLimitService.checkWebhookLimit.mockReset();
mockQueueAdd.mockReset(); mockQueueAdd.mockReset();
mockRedis.eval.mockReset(); mockRedis.eval.mockReset();
mockRedis.set.mockReset(); mockRedis.set.mockReset();
mockTxWebhookUpdate.mockReset(); mockTxWebhookUpdate.mockReset();
mockLimitService.checkWebhookLimit.mockResolvedValue({
isLimitReached: false,
reason: null,
});
mockRedis.set.mockResolvedValue("OK"); mockRedis.set.mockResolvedValue("OK");
mockRedis.eval.mockResolvedValue(1); mockRedis.eval.mockResolvedValue(1);
mockQueueAdd.mockResolvedValue(undefined); mockQueueAdd.mockResolvedValue(undefined);
@@ -376,4 +387,339 @@ describe("WebhookService documented behavior", () => {
{ jobId: "call_test_1" }, { 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", () => {
beforeEach(() => {
mockDb.webhook.findMany.mockReset();
mockDb.webhookCall.create.mockReset();
mockQueueAdd.mockReset();
mockDb.webhookCall.create.mockImplementation(async ({ data }: any) => ({
id: `call_${data.webhookId}`,
}));
mockQueueAdd.mockResolvedValue(undefined);
});
it("does not apply domain filtering when the event has no domain context", 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, "contact.created", {
id: "contact_1",
email: "test@example.com",
contactBookId: 1,
subscribed: true,
properties: {},
firstName: null,
lastName: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
expect(mockDb.webhook.findMany).toHaveBeenCalledWith({
where: {
teamId: 10,
status: WebhookStatus.ACTIVE,
AND: [
{
OR: [
{
eventTypes: {
has: "contact.created",
},
},
{
eventTypes: {
isEmpty: true,
},
},
],
},
],
},
});
expect(mockDb.webhookCall.create).toHaveBeenCalledTimes(2);
expect(mockQueueAdd).toHaveBeenCalledTimes(2);
});
it("filters webhooks by domain when the event has a domain context", async () => {
mockDb.webhook.findMany.mockResolvedValue([
{ id: "wh_global", 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: 42,
} as never,
{ domainId: 42 },
);
expect(mockDb.webhook.findMany).toHaveBeenCalledWith({
where: {
teamId: 10,
status: WebhookStatus.ACTIVE,
AND: [
{
OR: [
{
eventTypes: {
has: "email.delivered",
},
},
{
eventTypes: {
isEmpty: true,
},
},
],
},
{
OR: [
{
domainIds: {
isEmpty: true,
},
},
{
domainIds: {
has: 42,
},
},
],
},
],
},
});
expect(mockDb.webhookCall.create).toHaveBeenCalledTimes(1);
expect(mockQueueAdd).toHaveBeenCalledWith(
"call_wh_global",
{
callId: "call_wh_global",
teamId: 10,
},
{ 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);
});
}); });