Skip to content

Commit 3c2d379

Browse files
authored
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
1 parent 79f9049 commit 3c2d379

12 files changed

Lines changed: 778 additions & 21 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "Webhook" ADD COLUMN "domainIds" INTEGER[] DEFAULT ARRAY[]::INTEGER[];

apps/web/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,7 @@ enum WebhookCallStatus {
469469
model Webhook {
470470
id String @id @default(cuid())
471471
teamId Int
472+
domainIds Int[] @default([])
472473
url String
473474
description String?
474475
secret String

apps/web/src/app/(dashboard)/campaigns/schedule-campaign.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,10 @@ export const ScheduleCampaign: React.FC<{
3535
const [scheduleInput, setScheduleInput] = useState<string>(
3636
initialScheduledAtDate
3737
? format(initialScheduledAtDate, "yyyy-MM-dd HH:mm")
38-
: ""
38+
: "",
3939
);
4040
const [selectedDate, setSelectedDate] = useState<Date | null>(
41-
initialScheduledAtDate ?? new Date()
41+
initialScheduledAtDate ?? new Date(),
4242
);
4343
const [isConfirmNow, setIsConfirmNow] = useState(false);
4444
const [error, setError] = useState<string | null>(null);
@@ -86,7 +86,7 @@ export const ScheduleCampaign: React.FC<{
8686
onError: (error) => {
8787
setError(error.message || "Failed to schedule campaign");
8888
},
89-
}
89+
},
9090
);
9191
};
9292

apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { Webhook, WebhookCallStatus } from "@prisma/client";
3+
import { WebhookCallStatus, type Webhook } from "@prisma/client";
44
import { formatDistanceToNow } from "date-fns";
55
import { Copy, Eye, EyeOff } from "lucide-react";
66
import { useState } from "react";
@@ -20,6 +20,7 @@ export function WebhookInfo({ webhook }: { webhook: Webhook }) {
2020
webhookId: webhook.id,
2121
limit: 50,
2222
});
23+
const domainsQuery = api.domain.domains.useQuery();
2324

2425
const calls = callsQuery.data?.items ?? [];
2526
const last7DaysCalls = calls.filter(
@@ -38,6 +39,13 @@ export function WebhookInfo({ webhook }: { webhook: Webhook }) {
3839
c.status === WebhookCallStatus.IN_PROGRESS,
3940
).length;
4041

42+
const domainNameById = new Map(
43+
(domainsQuery.data ?? []).map((domain) => [domain.id, domain.name]),
44+
);
45+
const selectedDomainLabels = (webhook.domainIds ?? []).map(
46+
(domainId) => domainNameById.get(domainId) ?? `Domain #${domainId}`,
47+
);
48+
4149
const handleCopySecret = () => {
4250
navigator.clipboard.writeText(webhook.secret);
4351
toast.success("Secret copied to clipboard");
@@ -66,6 +74,27 @@ export function WebhookInfo({ webhook }: { webhook: Webhook }) {
6674
)}
6775
</div>
6876
</div>
77+
<div className="flex flex-col gap-1">
78+
<span className="text-sm text-muted-foreground">Domains</span>
79+
<div className="flex items-center gap-1 flex-wrap text-sm">
80+
{(webhook.domainIds ?? []).length === 0 ? (
81+
<span className="text-sm">All domains</span>
82+
) : (
83+
<>
84+
{selectedDomainLabels.slice(0, 2).map((domainName, index) => (
85+
<Badge key={`${domainName}-${index}`} variant="outline">
86+
{domainName}
87+
</Badge>
88+
))}
89+
{(webhook.domainIds ?? []).length > 2 && (
90+
<span className="text-xs text-muted-foreground">
91+
+{(webhook.domainIds ?? []).length - 2} more
92+
</span>
93+
)}
94+
</>
95+
)}
96+
</div>
97+
</div>
6998
<div className="flex flex-col gap-1">
7099
<span className="text-sm text-muted-foreground">Status</span>
71100
<div className="flex items-center">

apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ const webhookSchema = z.object({
5050
eventTypes: z.array(EVENT_TYPES_ENUM, {
5151
required_error: "Select at least one event",
5252
}),
53+
domainIds: z.array(z.number().int().positive()),
5354
});
5455

5556
type WebhookFormValues = z.infer<typeof webhookSchema>;
@@ -67,6 +68,7 @@ export function AddWebhook() {
6768
const [open, setOpen] = useState(false);
6869
const [allEventsSelected, setAllEventsSelected] = useState(false);
6970
const createWebhookMutation = api.webhook.create.useMutation();
71+
const domainsQuery = api.domain.domains.useQuery();
7072
const limitsQuery = api.limits.get.useQuery({ type: LimitReason.WEBHOOK });
7173
const { openModal } = useUpgradeModalStore((s) => s.action);
7274

@@ -77,6 +79,7 @@ export function AddWebhook() {
7779
defaultValues: {
7880
url: "",
7981
eventTypes: [],
82+
domainIds: [],
8083
},
8184
});
8285

@@ -106,13 +109,15 @@ export function AddWebhook() {
106109
{
107110
url: values.url,
108111
eventTypes: allEventsSelected ? [] : selectedEvents,
112+
domainIds: values.domainIds,
109113
},
110114
{
111115
onSuccess: async () => {
112116
await utils.webhook.list.invalidate();
113117
form.reset({
114118
url: "",
115119
eventTypes: [],
120+
domainIds: [],
116121
});
117122
setAllEventsSelected(false);
118123
setOpen(false);
@@ -315,6 +320,85 @@ export function AddWebhook() {
315320
);
316321
}}
317322
/>
323+
<FormField
324+
control={form.control}
325+
name="domainIds"
326+
render={({ field }) => {
327+
const selectedDomainIds = field.value ?? [];
328+
const selectedDomains =
329+
domainsQuery.data?.filter((domain) =>
330+
selectedDomainIds.includes(domain.id),
331+
) ?? [];
332+
333+
const selectedDomainsLabel =
334+
selectedDomainIds.length === 0
335+
? "All domains"
336+
: selectedDomainIds.length === 1
337+
? (selectedDomains[0]?.name ?? "1 domain selected")
338+
: `${selectedDomainIds.length} domains selected`;
339+
340+
const handleToggleDomain = (domainId: number) => {
341+
const exists = selectedDomainIds.includes(domainId);
342+
const next = exists
343+
? selectedDomainIds.filter((id) => id !== domainId)
344+
: [...selectedDomainIds, domainId];
345+
field.onChange(next);
346+
};
347+
348+
return (
349+
<FormItem>
350+
<FormLabel>Domains</FormLabel>
351+
<FormControl>
352+
<DropdownMenu>
353+
<DropdownMenuTrigger asChild>
354+
<Button
355+
type="button"
356+
variant="outline"
357+
className="mt-3 inline-flex w-full items-center justify-between"
358+
>
359+
<span className="truncate text-left text-sm">
360+
{selectedDomainsLabel}
361+
</span>
362+
<ChevronDown className="ml-2 h-4 w-4 shrink-0" />
363+
</Button>
364+
</DropdownMenuTrigger>
365+
<DropdownMenuContent className="max-h-[30vh] w-[--radix-dropdown-menu-trigger-width] overflow-y-auto">
366+
<div className="space-y-3">
367+
<DropdownMenuCheckboxItem
368+
checked={selectedDomainIds.length === 0}
369+
onCheckedChange={() => field.onChange([])}
370+
onSelect={(event) => event.preventDefault()}
371+
className="mb-2 px-2 font-medium"
372+
>
373+
All domains
374+
</DropdownMenuCheckboxItem>
375+
{domainsQuery.data?.map((domain) => (
376+
<DropdownMenuCheckboxItem
377+
key={domain.id}
378+
checked={selectedDomainIds.includes(
379+
domain.id,
380+
)}
381+
onCheckedChange={() =>
382+
handleToggleDomain(domain.id)
383+
}
384+
onSelect={(event) => event.preventDefault()}
385+
className="pl-3 pr-2"
386+
>
387+
{domain.name}
388+
</DropdownMenuCheckboxItem>
389+
))}
390+
</div>
391+
</DropdownMenuContent>
392+
</DropdownMenu>
393+
</FormControl>
394+
<FormDescription>
395+
Leave this as all domains to receive events from every
396+
domain.
397+
</FormDescription>
398+
</FormItem>
399+
);
400+
}}
401+
/>
318402
<div className="flex justify-end">
319403
<Button
320404
className="w-[120px]"

apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const editWebhookSchema = z.object({
4848
eventTypes: z.array(EVENT_TYPES_ENUM, {
4949
required_error: "Select at least one event",
5050
}),
51+
domainIds: z.array(z.number().int().positive()),
5152
});
5253

5354
type EditWebhookFormValues = z.infer<typeof editWebhookSchema>;
@@ -71,6 +72,7 @@ export function EditWebhookDialog({
7172
onOpenChange: (open: boolean) => void;
7273
}) {
7374
const updateWebhook = api.webhook.update.useMutation();
75+
const domainsQuery = api.domain.domains.useQuery();
7476
const utils = api.useUtils();
7577
const initialHasAllEvents =
7678
(webhook.eventTypes as WebhookEventType[]).length === 0;
@@ -84,6 +86,7 @@ export function EditWebhookDialog({
8486
eventTypes: initialHasAllEvents
8587
? []
8688
: (webhook.eventTypes as WebhookEventType[]),
89+
domainIds: webhook.domainIds ?? [],
8790
},
8891
});
8992

@@ -96,6 +99,7 @@ export function EditWebhookDialog({
9699
eventTypes: hasAllEvents
97100
? []
98101
: (webhook.eventTypes as WebhookEventType[]),
102+
domainIds: webhook.domainIds ?? [],
99103
});
100104
setAllEventsSelected(hasAllEvents);
101105
}
@@ -114,6 +118,7 @@ export function EditWebhookDialog({
114118
id: webhook.id,
115119
url: values.url,
116120
eventTypes: allEventsSelected ? [] : selectedEvents,
121+
domainIds: values.domainIds,
117122
},
118123
{
119124
onSuccess: async () => {
@@ -308,6 +313,85 @@ export function EditWebhookDialog({
308313
);
309314
}}
310315
/>
316+
<FormField
317+
control={form.control}
318+
name="domainIds"
319+
render={({ field }) => {
320+
const selectedDomainIds = field.value ?? [];
321+
const selectedDomains =
322+
domainsQuery.data?.filter((domain) =>
323+
selectedDomainIds.includes(domain.id),
324+
) ?? [];
325+
326+
const selectedDomainsLabel =
327+
selectedDomainIds.length === 0
328+
? "All domains"
329+
: selectedDomainIds.length === 1
330+
? (selectedDomains[0]?.name ?? "1 domain selected")
331+
: `${selectedDomainIds.length} domains selected`;
332+
333+
const handleToggleDomain = (domainId: number) => {
334+
const exists = selectedDomainIds.includes(domainId);
335+
const next = exists
336+
? selectedDomainIds.filter((id) => id !== domainId)
337+
: [...selectedDomainIds, domainId];
338+
field.onChange(next);
339+
};
340+
341+
return (
342+
<FormItem>
343+
<FormLabel>Domains</FormLabel>
344+
<FormControl>
345+
<DropdownMenu>
346+
<DropdownMenuTrigger asChild>
347+
<Button
348+
type="button"
349+
variant="outline"
350+
className="mt-3 inline-flex w-full items-center justify-between"
351+
>
352+
<span className="truncate text-left text-sm">
353+
{selectedDomainsLabel}
354+
</span>
355+
<ChevronDown className="ml-2 h-4 w-4 shrink-0" />
356+
</Button>
357+
</DropdownMenuTrigger>
358+
<DropdownMenuContent className="max-h-[30vh] w-[--radix-dropdown-menu-trigger-width] overflow-y-auto">
359+
<div className="space-y-3">
360+
<DropdownMenuCheckboxItem
361+
checked={selectedDomainIds.length === 0}
362+
onCheckedChange={() => field.onChange([])}
363+
onSelect={(event) => event.preventDefault()}
364+
className="mb-2 px-2 font-medium"
365+
>
366+
All domains
367+
</DropdownMenuCheckboxItem>
368+
{domainsQuery.data?.map((domain) => (
369+
<DropdownMenuCheckboxItem
370+
key={domain.id}
371+
checked={selectedDomainIds.includes(
372+
domain.id,
373+
)}
374+
onCheckedChange={() =>
375+
handleToggleDomain(domain.id)
376+
}
377+
onSelect={(event) => event.preventDefault()}
378+
className="pl-3 pr-2"
379+
>
380+
{domain.name}
381+
</DropdownMenuCheckboxItem>
382+
))}
383+
</div>
384+
</DropdownMenuContent>
385+
</DropdownMenu>
386+
</FormControl>
387+
<FormDescription>
388+
Leave this as all domains to receive events from every
389+
domain.
390+
</FormDescription>
391+
</FormItem>
392+
);
393+
}}
394+
/>
311395
<div className="flex justify-end">
312396
<Button
313397
className="w-[120px]"

0 commit comments

Comments
 (0)