Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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[];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Webhook model ==="
sed -n '/model Webhook/,/}/p' apps/web/prisma/schema.prisma

echo
echo "=== Migration ==="
sed -n '1,20p' apps/web/prisma/migrations/20260227040924_add_webhook_domain_filters/migration.sql

Repository: usesend/useSend

Length of output: 1825


🏁 Script executed:

cat -n apps/web/src/server/service/webhook-service.ts | head -100

Repository: usesend/useSend

Length of output: 3476


🏁 Script executed:

rg "domainIds" apps/web/src/server/service/webhook-service.ts -B 2 -A 2

Repository: usesend/useSend

Length of output: 1715


🏁 Script executed:

rg "isEmpty|\.has" apps/web/src/server/service/ -B 2 -A 2 --type ts --max-count 20

Repository: usesend/useSend

Length of output: 5309


Add NOT NULL constraint to match the Prisma model definition.

The Prisma schema declares domainIds as 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 using isEmpty and has would not behave as expected, potentially silencing webhooks from domain filtering.

🛠️ Suggested migration change
-ALTER TABLE "Webhook" ADD COLUMN     "domainIds" INTEGER[] DEFAULT ARRAY[]::INTEGER[];
+ALTER TABLE "Webhook" ADD COLUMN "domainIds" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ALTER TABLE "Webhook" ADD COLUMN "domainIds" INTEGER[] DEFAULT ARRAY[]::INTEGER[];
ALTER TABLE "Webhook" ADD COLUMN "domainIds" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/prisma/migrations/20260227040924_add_webhook_domain_filters/migration.sql`
at line 2, The migration adds the Webhook.domainIds column but leaves it
nullable; update the migration so the "domainIds" column on table "Webhook" is
created as NOT NULL with the same default empty integer array
(ARRAY[]::INTEGER[]), or add an immediate ALTER TABLE ... ALTER COLUMN ... SET
NOT NULL after creation; ensure the SQL references the "Webhook" table and
"domainIds" column exactly and preserves the default empty array to match the
Prisma model Int[] (required) semantics.

1 change: 1 addition & 0 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,7 @@ enum WebhookCallStatus {
model Webhook {
id String @id @default(cuid())
teamId Int
domainIds Int[] @default([])
url String
description String?
secret String
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/app/(dashboard)/campaigns/schedule-campaign.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -86,7 +86,7 @@ export const ScheduleCampaign: React.FC<{
onError: (error) => {
setError(error.message || "Failed to schedule campaign");
},
}
},
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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(
Expand All @@ -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");
Expand Down Expand Up @@ -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">
Expand Down
84 changes: 84 additions & 0 deletions apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand All @@ -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);

Expand All @@ -77,6 +79,7 @@ export function AddWebhook() {
defaultValues: {
url: "",
eventTypes: [],
domainIds: [],
},
});

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clarify that this only filters domain-aware events.

The backend only narrows dispatch when the emitted event carries a domainId, so this copy currently reads stronger than the actual behavior and can surprise users when team-level events still arrive. Please mirror the same wording in the edit dialog too.

✏️ 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<FormDescription>
Leave this as all domains to receive events from every
domain.
</FormDescription>
<FormDescription>
Leave this as all domains to receive events from every
domain. Domain filters apply only to events that include
a domain.
</FormDescription>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/`(dashboard)/webhooks/add-webhook.tsx around lines 394 -
397, Update the FormDescription copy in add-webhook.tsx to clarify that the
domain filter only applies to domain-aware events (those that include a
domainId) and does not prevent team- or global-level events; use similar wording
in the edit dialog so both UIs match. Locate the FormDescription node in this
file (and the corresponding edit dialog component) and replace the current text
with a succinct message stating that leaving it blank receives all events, while
specifying a domain only filters events that carry a domainId.

</FormItem>
);
}}
/>
<div className="flex justify-end">
<Button
className="w-[120px]"
Expand Down
84 changes: 84 additions & 0 deletions apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand All @@ -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;
Expand All @@ -84,6 +86,7 @@ export function EditWebhookDialog({
eventTypes: initialHasAllEvents
? []
: (webhook.eventTypes as WebhookEventType[]),
domainIds: webhook.domainIds ?? [],
},
});

Expand All @@ -96,6 +99,7 @@ export function EditWebhookDialog({
eventTypes: hasAllEvents
? []
: (webhook.eventTypes as WebhookEventType[]),
domainIds: webhook.domainIds ?? [],
});
setAllEventsSelected(hasAllEvents);
}
Expand All @@ -114,6 +118,7 @@ export function EditWebhookDialog({
id: webhook.id,
url: values.url,
eventTypes: allEventsSelected ? [] : selectedEvents,
domainIds: values.domainIds,
},
{
onSuccess: async () => {
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update FormDescription to match add-webhook.tsx clarification.

Per the past review comment on add-webhook.tsx, this text should clarify that domain filtering only applies to events that include a domainId. Team-level events will still be delivered regardless of this setting.

✏️ 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<FormDescription>
Leave this as all domains to receive events from every
domain.
</FormDescription>
<FormDescription>
Leave this as all domains to receive events from every
domain. Domain filters apply only to events that include
a domain.
</FormDescription>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/`(dashboard)/webhooks/webhook-update-dialog.tsx around lines
388 - 391, Update the FormDescription used in the webhook update dialog (inside
the WebhookUpdateDialog component where FormDescription is rendered) to match
the add-webhook clarification: change the text to state that leaving it as "all
domains" will receive events from every domain, but that domain filtering only
applies to events that include a domainId and team-level events will still be
delivered regardless of this setting; replace the existing sentence in the
FormDescription node accordingly to reflect that distinction.

</FormItem>
);
}}
/>
<div className="flex justify-end">
<Button
className="w-[120px]"
Expand Down
Loading