-
-
Notifications
You must be signed in to change notification settings - Fork 349
feat: add multi-domain filters to webhooks #361
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4735cb1
b3c1373
efbba79
b740c95
508e3d0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| -- AlterTable | ||
| ALTER TABLE "Webhook" ADD COLUMN "domainIds" INTEGER[] DEFAULT ARRAY[]::INTEGER[]; | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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,13 +109,15 @@ export function AddWebhook() { | |||||||||||||||||||
| { | ||||||||||||||||||||
| url: values.url, | ||||||||||||||||||||
| eventTypes: allEventsSelected ? [] : selectedEvents, | ||||||||||||||||||||
| domainIds: values.domainIds, | ||||||||||||||||||||
| }, | ||||||||||||||||||||
| { | ||||||||||||||||||||
| onSuccess: async () => { | ||||||||||||||||||||
| await utils.webhook.list.invalidate(); | ||||||||||||||||||||
| 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> | ||||||||||||||||||||
|
Comment on lines
+394
to
+397
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Clarify that this only filters domain-aware events. The backend only narrows dispatch when the emitted event carries a ✏️ Suggested copy- Leave this as all domains to receive events from every
- domain.
+ Leave this as all domains to receive events from every
+ domain. Domain filters apply only to events that include
+ a domain.📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| </FormItem> | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| }} | ||||||||||||||||||||
| /> | ||||||||||||||||||||
| <div className="flex justify-end"> | ||||||||||||||||||||
| <Button | ||||||||||||||||||||
| className="w-[120px]" | ||||||||||||||||||||
|
|
||||||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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> | ||||||||||||||||||||
|
Comment on lines
+387
to
+390
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update FormDescription to match add-webhook.tsx clarification. Per the past review comment on ✏️ Suggested copy <FormDescription>
- Leave this as all domains to receive events from every
- domain.
+ Leave this as all domains to receive events from every
+ domain. Domain filters apply only to events that include
+ a domain.
</FormDescription>📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| </FormItem> | ||||||||||||||||||||
| ); | ||||||||||||||||||||
| }} | ||||||||||||||||||||
| /> | ||||||||||||||||||||
| <div className="flex justify-end"> | ||||||||||||||||||||
| <Button | ||||||||||||||||||||
| className="w-[120px]" | ||||||||||||||||||||
|
|
||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: usesend/useSend
Length of output: 1825
🏁 Script executed:
cat -n apps/web/src/server/service/webhook-service.ts | head -100Repository: usesend/useSend
Length of output: 3476
🏁 Script executed:
rg "domainIds" apps/web/src/server/service/webhook-service.ts -B 2 -A 2Repository: usesend/useSend
Length of output: 1715
🏁 Script executed:
rg "isEmpty|\.has" apps/web/src/server/service/ -B 2 -A 2 --type ts --max-count 20Repository: usesend/useSend
Length of output: 5309
Add
NOT NULLconstraint to match the Prisma model definition.The Prisma schema declares
domainIdsas a required field (Int[]without?) with default[], but the migration allows NULL at the database level. This mismatch means if NULL ever enters the column (via raw SQL, data corruption, or other means), the filter logic usingisEmptyandhaswould not behave as expected, potentially silencing webhooks from domain filtering.🛠️ Suggested migration change
📝 Committable suggestion
🤖 Prompt for AI Agents