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 {
|
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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user