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:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Webhook" ADD COLUMN "domainIds" INTEGER[] DEFAULT ARRAY[]::INTEGER[];
|
||||
@@ -469,6 +469,7 @@ enum WebhookCallStatus {
|
||||
model Webhook {
|
||||
id String @id @default(cuid())
|
||||
teamId Int
|
||||
domainIds Int[] @default([])
|
||||
url String
|
||||
description String?
|
||||
secret String
|
||||
|
||||
@@ -35,10 +35,10 @@ export const ScheduleCampaign: React.FC<{
|
||||
const [scheduleInput, setScheduleInput] = useState<string>(
|
||||
initialScheduledAtDate
|
||||
? format(initialScheduledAtDate, "yyyy-MM-dd HH:mm")
|
||||
: ""
|
||||
: "",
|
||||
);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(
|
||||
initialScheduledAtDate ?? new Date()
|
||||
initialScheduledAtDate ?? new Date(),
|
||||
);
|
||||
const [isConfirmNow, setIsConfirmNow] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -86,7 +86,7 @@ export const ScheduleCampaign: React.FC<{
|
||||
onError: (error) => {
|
||||
setError(error.message || "Failed to schedule campaign");
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -20,6 +20,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 +39,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 +74,27 @@ export function WebhookInfo({ webhook }: { webhook: Webhook }) {
|
||||
)}
|
||||
</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">
|
||||
<span className="text-sm text-muted-foreground">Status</span>
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -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<typeof webhookSchema>;
|
||||
@@ -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() {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<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">
|
||||
<Button
|
||||
className="w-[120px]"
|
||||
|
||||
@@ -48,6 +48,7 @@ const editWebhookSchema = z.object({
|
||||
eventTypes: z.array(EVENT_TYPES_ENUM, {
|
||||
required_error: "Select at least one event",
|
||||
}),
|
||||
domainIds: z.array(z.number().int().positive()),
|
||||
});
|
||||
|
||||
type EditWebhookFormValues = z.infer<typeof editWebhookSchema>;
|
||||
@@ -71,6 +72,7 @@ export function EditWebhookDialog({
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const updateWebhook = api.webhook.update.useMutation();
|
||||
const domainsQuery = api.domain.domains.useQuery();
|
||||
const utils = api.useUtils();
|
||||
const initialHasAllEvents =
|
||||
(webhook.eventTypes as WebhookEventType[]).length === 0;
|
||||
@@ -84,6 +86,7 @@ export function EditWebhookDialog({
|
||||
eventTypes: initialHasAllEvents
|
||||
? []
|
||||
: (webhook.eventTypes as WebhookEventType[]),
|
||||
domainIds: webhook.domainIds ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -96,6 +99,7 @@ export function EditWebhookDialog({
|
||||
eventTypes: hasAllEvents
|
||||
? []
|
||||
: (webhook.eventTypes as WebhookEventType[]),
|
||||
domainIds: webhook.domainIds ?? [],
|
||||
});
|
||||
setAllEventsSelected(hasAllEvents);
|
||||
}
|
||||
@@ -114,6 +118,7 @@ export function EditWebhookDialog({
|
||||
id: webhook.id,
|
||||
url: values.url,
|
||||
eventTypes: allEventsSelected ? [] : selectedEvents,
|
||||
domainIds: values.domainIds,
|
||||
},
|
||||
{
|
||||
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">
|
||||
<Button
|
||||
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(),
|
||||
description: z.string().optional(),
|
||||
eventTypes: z.array(EVENT_TYPES_ENUM),
|
||||
domainIds: z.array(z.number().int().positive()).optional(),
|
||||
secret: z.string().min(16).optional(),
|
||||
}),
|
||||
)
|
||||
@@ -36,6 +37,7 @@ export const webhookRouter = createTRPCRouter({
|
||||
url: input.url,
|
||||
description: input.description,
|
||||
eventTypes: input.eventTypes,
|
||||
domainIds: input.domainIds,
|
||||
secret: input.secret,
|
||||
});
|
||||
}),
|
||||
@@ -47,6 +49,7 @@ export const webhookRouter = createTRPCRouter({
|
||||
url: z.string().url().optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
eventTypes: z.array(EVENT_TYPES_ENUM).optional(),
|
||||
domainIds: z.array(z.number().int().positive()).optional(),
|
||||
rotateSecret: z.boolean().optional(),
|
||||
secret: z.string().min(16).optional(),
|
||||
}),
|
||||
@@ -58,6 +61,7 @@ export const webhookRouter = createTRPCRouter({
|
||||
url: input.url,
|
||||
description: input.description,
|
||||
eventTypes: input.eventTypes,
|
||||
domainIds: input.domainIds,
|
||||
rotateSecret: input.rotateSecret,
|
||||
secret: input.secret,
|
||||
});
|
||||
|
||||
@@ -386,7 +386,9 @@ async function getDmarcRecord(domain: string) {
|
||||
|
||||
async function emitDomainEvent(domain: Domain, type: DomainWebhookEventType) {
|
||||
try {
|
||||
await WebhookService.emit(domain.teamId, type, buildDomainPayload(domain));
|
||||
await WebhookService.emit(domain.teamId, type, buildDomainPayload(domain), {
|
||||
domainId: domain.id,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, domainId: domain.id, type },
|
||||
|
||||
@@ -128,16 +128,19 @@ export async function parseSesHook(data: SesEvent) {
|
||||
|
||||
// Get the actual affected recipients from the event data
|
||||
let recipientEmails: string[] = [];
|
||||
|
||||
|
||||
if (isHardBounced && data.bounce?.bouncedRecipients) {
|
||||
// For bounces, only add the recipients that actually bounced
|
||||
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
|
||||
recipientEmails = data.complaint.complainedRecipients.map(
|
||||
(recipient) => recipient.emailAddress
|
||||
(recipient) => recipient.emailAddress,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -318,6 +321,9 @@ export async function parseSesHook(data: SesEvent) {
|
||||
eventData: mailData,
|
||||
metadata,
|
||||
}),
|
||||
{
|
||||
domainId: email.domainId ?? null,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
|
||||
@@ -90,22 +90,46 @@ export class WebhookService {
|
||||
teamId: number,
|
||||
type: 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({
|
||||
where: {
|
||||
teamId,
|
||||
status: WebhookStatus.ACTIVE,
|
||||
OR: [
|
||||
AND: [
|
||||
{
|
||||
eventTypes: {
|
||||
has: type,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventTypes: {
|
||||
isEmpty: true,
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
eventTypes: {
|
||||
has: type,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventTypes: {
|
||||
isEmpty: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
...(domainFilter ? [domainFilter] : []),
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -229,6 +253,7 @@ export class WebhookService {
|
||||
url: string;
|
||||
description?: string;
|
||||
eventTypes: string[];
|
||||
domainIds?: number[];
|
||||
secret?: string;
|
||||
}) {
|
||||
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();
|
||||
|
||||
return db.webhook.create({
|
||||
data: {
|
||||
teamId: params.teamId,
|
||||
domainIds: normalizedDomainIds,
|
||||
url: params.url,
|
||||
description: params.description,
|
||||
secret,
|
||||
@@ -263,6 +300,7 @@ export class WebhookService {
|
||||
url?: string;
|
||||
description?: string | null;
|
||||
eventTypes?: string[];
|
||||
domainIds?: number[];
|
||||
rotateSecret?: boolean;
|
||||
secret?: string;
|
||||
}) {
|
||||
@@ -282,6 +320,18 @@ export class WebhookService {
|
||||
? WebhookService.generateSecret()
|
||||
: 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({
|
||||
where: { id: webhook.id },
|
||||
data: {
|
||||
@@ -291,11 +341,44 @@ export class WebhookService {
|
||||
? webhook.description
|
||||
: (params.description ?? null),
|
||||
eventTypes: params.eventTypes ?? webhook.eventTypes,
|
||||
domainIds: normalizedDomainIds ?? webhook.domainIds,
|
||||
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: {
|
||||
id: string;
|
||||
teamId: number;
|
||||
|
||||
@@ -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,4 +387,339 @@ 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", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user