Skip to content

Conversation

@devkiran
Copy link
Collaborator

@devkiran devkiran commented Oct 16, 2025

Summary by CodeRabbit

  • New Features

    • Email domain management: create, verify, edit, and delete email domains with DNS record tracking and status monitoring
    • Campaign scheduling: schedule campaigns for future delivery with customizable send times
    • Campaign sender selection: assign verified email domains as campaign senders
    • Campaign types: support for marketing and transactional campaign modes with type-specific features
    • Marketing campaign notifications: subscribe to marketing campaign notification preferences
  • Bug Fixes

    • Fixed campaign creation response status code to properly return 201

@vercel
Copy link
Contributor

vercel bot commented Oct 16, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Oct 26, 2025 2:55pm

@socket-security
Copy link

socket-security bot commented Oct 16, 2025

No dependency changes detected. Learn more about Socket for GitHub.

👍 No dependency changes detected in pull request

@devkiran devkiran changed the base branch from main to email-campaigns October 16, 2025 13:11
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 16, 2025

Walkthrough

This PR adds email domain management with API endpoints for CRUD operations, domain verification via scheduled cron job, and UI components for domain administration. It also extends campaign functionality with sender address support, scheduling capabilities for marketing campaigns, and new status states (scheduled, sending, cancelled). Database schema introduces an EmailDomain model with status tracking and updates Campaign to track sender and queue IDs.

Changes

Cohort / File(s) Summary
Email Domain API Routes
apps/web/app/(ee)/api/email-domains/route.ts, apps/web/app/(ee)/api/email-domains/[domain]/route.ts, apps/web/app/(ee)/api/email-domains/[domain]/verify/route.ts
GET/POST endpoints for listing and creating email domains; PATCH/DELETE for updates and deletion; verification endpoint with Resend integration; all routes include workspace authorization and plan/permission gating
Email Domain UI Components
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/email/email-domain-card.tsx, .../email-domain-dns-records.tsx, .../constants.ts, .../page-client.tsx, .../header.tsx
New EmailDomainCard component with status badges and lazy verification; DNS records display with copy actions; status-to-variant mapping constants; page client with plan capability gating and empty states; conditional Email Domains tab in header
Email Domain Modals
apps/web/ui/modals/add-edit-email-domain-modal.tsx, apps/web/ui/modals/delete-email-domain-modal.tsx
Modal components for creating, editing, and deleting email domains with form validation and API integration
Campaign API Routes
apps/web/app/(ee)/api/campaigns/route.ts, apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts, apps/web/app/(ee)/api/campaigns/[campaignId]/duplicate/route.ts
Updated POST response status to 201; PATCH refactored with campaign validation and modular scheduling via new helpers; DELETE updated for campaign cleanup; duplication now copies sender address
Campaign Broadcasting
apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts
New cron endpoint for broadcasting marketing campaigns; handles domain verification, recipient batching, email sending via Resend, and pagination support
Campaign UI Components
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx, .../campaign-controls.tsx, .../send-email-preview-modal.tsx, .../use-campaign-confirmation-modals.tsx
Extended campaign editor with sender address and scheduling fields; refactored controls with modular confirmation modals for publish/schedule/pause/resume/cancel; preview modal now includes sender; new hook for managing campaign state transitions
Campaign UI & Constants
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/campaign-status-badges.tsx, .../campaign-type-badges.tsx, .../campaign-type-icon.tsx, .../campaigns-table.tsx, .../campaign-stats.tsx, .../create-campaign-button.tsx, .../page.tsx, .../use-campaigns-filters.tsx
Added statuses (scheduled, sending, cancelled) with icons; type badges now include value field; new CampaignTypeIcon component; campaigns table and stats refined; create button now uses dropdown for type selection; filters reintroduced Type filter
Campaign Validation & Scheduling
apps/web/lib/api/campaigns/validate-campaign.ts, apps/web/lib/api/campaigns/schedule-campaigns.ts, apps/web/lib/api/campaigns/constants.ts, apps/web/lib/api/campaigns/get-campaigns.ts
New validation logic for status transitions and editability; scheduling helpers for marketing (QStash) and transactional (workflow) campaigns; constants for status transitions and editable/readonly states; added scheduledAt field to query results
Campaign Utilities
apps/web/lib/actions/campaigns/send-campaign-preview-email.ts, apps/web/lib/api/workflows/execute-send-campaign-workflow.ts
Updated preview to include sender field and dynamic email variant; workflow execution now validates from address against verified email domains
Database Schema
packages/prisma/schema/domain.prisma, packages/prisma/schema/campaign.prisma, packages/prisma/schema/program.prisma, packages/prisma/schema/notification.prisma
New EmailDomain model with status enum (pending, verified, failed, temporary_failure, not_started); Campaign model updated with qstashMessageId and from fields; new CampaignStatus values (scheduled, sending, cancelled); Program relation added for emailDomains; PartnerNotificationPreferences adds marketingCampaign field
Email Domain Utilities
apps/web/lib/api/domains/get-email-domain-or-throw.ts, apps/web/lib/zod/schemas/email-domains.ts
New helper for fetching email domains with authorization; Zod schemas for validation (EmailDomainSchema, createEmailDomainBodySchema, updateEmailDomainBodySchema)
Type Definitions
apps/web/lib/types.ts, apps/web/lib/plan-capabilities.ts, apps/web/lib/zod/schemas/campaigns.ts, apps/web/lib/zod/schemas/partner-profile.ts
Added EmailDomainProps type; plan capability canManageEmailDomains; Campaign schema extended with from and scheduledAt; partner notification types includes marketingCampaign
Email Templates & Hooks
packages/email/src/templates/campaign-email.tsx, apps/web/lib/swr/use-email-domains.ts, apps/web/lib/api/programs/get-program-or-throw.ts
Campaign email template adds marketing unsubscribe block; new SWR hook for fetching email domains; program lookup refined to validate workspace association
Cron & External Integration
apps/web/app/(ee)/api/cron/email-domains/verify/route.ts, apps/web/vercel.json, packages/email/src/resend/types.ts, packages/prisma/client.ts
New hourly cron job for verifying email domain status via Resend; Vercel configuration updated with cron schedule; Resend types exported; EmailDomainStatus exported from Prisma client
Resend Webhooks
apps/web/app/api/resend/webhook/email-bounced.ts, apps/web/app/api/resend/webhook/email-delivered.ts
Removed unused subject parameter from webhook handlers
UI Components & Icons
apps/web/ui/modals/add-folder-modal.tsx, apps/web/ui/modals/confirm-modal.tsx, packages/ui/src/icons/nucleo/media-play.tsx, packages/ui/src/icons/nucleo/media-pause.tsx, packages/ui/src/rich-text-area/index.tsx
Minor styling updates; HTML semantic changes (p to div); SVG stroke attributes updated to camelCase; RichTextArea adds editable prop
Campaign Editor Skeleton & Tests
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor-skeleton.tsx, apps/web/tests/campaigns/index.test.ts
Added From and When field skeletons; updated campaign creation test to expect 201 status

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Dashboard as Campaign Dashboard
    participant API as Campaign API
    participant DB as Database
    participant QStash as QStash Queue
    participant Resend as Resend Service

    User->>Dashboard: Create Marketing Campaign
    Dashboard->>API: POST /api/campaigns (type: marketing)
    API->>DB: Save campaign (draft)
    API-->>Dashboard: 201 Created

    User->>Dashboard: Set sender & schedule time
    Dashboard->>API: PATCH /api/campaigns/{id} (from, scheduledAt, status: scheduled)
    API->>API: validateCampaign (check status transition, from address)
    API->>DB: Load verified email domains
    API->>DB: Update campaign (scheduled)
    API->>QStash: Queue scheduled broadcast job
    API->>DB: Save qstashMessageId
    API-->>Dashboard: Updated campaign

    Note over QStash,Resend: At scheduled time...
    QStash->>API: POST /api/cron/campaigns/broadcast
    API->>DB: Load campaign & groups
    API->>DB: Query verified email domains
    API->>Resend: Send batch emails
    Resend-->>API: Email sent
    API->>DB: Create CampaignEmail records
    API->>DB: Update campaign (sent)
Loading
sequenceDiagram
    actor User
    participant Dashboard as Domain Dashboard
    participant API as Email Domain API
    participant Resend as Resend Service
    participant DB as Database
    participant Cron as Hourly Cron

    User->>Dashboard: Add email domain
    Dashboard->>API: POST /api/email-domains (slug)
    API->>Resend: Create resend domain
    Resend-->>API: resendDomainId
    API->>DB: Save EmailDomain (pending)
    API-->>Dashboard: Domain created
    
    Note over Cron,DB: Every hour...
    Cron->>API: GET /api/cron/email-domains/verify
    API->>DB: Find domains with resendDomainId
    API->>Resend: Get domain status
    Resend-->>API: Status (verified/failed/etc)
    API->>DB: Update lastChecked & status
    API-->>Cron: Done

    User->>Dashboard: View domain status
    Dashboard->>API: GET /api/email-domains/{domain}/verify
    API->>Resend: Fetch latest status
    Resend-->>API: Current status
    API->>DB: Update if changed
    API-->>Dashboard: Display status badge
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Areas requiring extra attention:

  • Campaign scheduling logic (apps/web/lib/api/campaigns/schedule-campaigns.ts, apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts) — QStash integration and workflow scheduling with state transitions; verify correctness of status flow and edge cases
  • Email domain verification (apps/web/app/(ee)/api/cron/email-domains/verify/route.ts, apps/web/app/(ee)/api/email-domains/[domain]/verify/route.ts) — Resend API integration, status synchronization logic, and error handling paths
  • Campaign validation (apps/web/lib/api/campaigns/validate-campaign.ts) — Complex status transition rules, editability checks, and domain verification for sender addresses
  • Campaign controls refactor (apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-controls.tsx) — Modal-driven state transitions and form handling; ensure all status paths are covered
  • Database migration implications — New EmailDomain and Campaign schema fields; verify backward compatibility and migration strategy
  • Workspace and plan authorization — Multiple new API routes with workspace/plan gating; audit permission checks across all email domain endpoints

Possibly related PRs

Suggested reviewers

  • steven-tey
  • TWilson023

Poem

🐰 Hop hop, domains verified true,
Campaigns scheduled, and fresh senders too!
Marketing emails with care-ful timing,
Email domains with statuses shining!
The garden grows, where domains and campaigns align,
A feature complete, our system's design! 🌻

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.33% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The pull request title "Email domains & Marketing campaigns" accurately reflects the two primary feature additions implemented across the changeset. The changes establish a comprehensive email domain management system (with API routes, UI components, database models, Resend integration, and verification workflows) and introduce marketing campaign capabilities (with new campaign types, statuses, scheduling logic, and email domain integration). The title is concise, clear, and specific enough that developers reviewing the commit history would immediately understand the scope of these substantial features without being vague or misleading.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch program-email-domains

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Base automatically changed from email-campaigns to main October 17, 2025 01:32
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

♻️ Duplicate comments (8)
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/email/email-domain-dns-records.tsx (1)

174-183: Critical: Fix unreachable loading branch and add error handling (duplicate issue).

This issue was previously flagged but remains unresolved. The loading skeleton is unreachable because records defaults to [] on Line 186, so !records is never true. Additionally, there's no error handling branch.

Apply the diff from the previous review to fix the conditional rendering:

-  const { data, isValidating } = useSWRImmutable<GetDomainResponseSuccess>(
+  const { data, error, isValidating } = useSWRImmutable<GetDomainResponseSuccess>(
     workspaceId &&
       `/api/email-domains/${domain.slug}/verify?workspaceId=${workspaceId}`,
     fetcher,
     {
       onError: (error) => {
         console.error("Failed to fetch email domain verification", error);
       },
     },
   );
 
   const isVerified = data?.status === "verified";
-  const records = data?.records || [];
+  const hasRecords = (data?.records?.length ?? 0) > 0;
+  const records = data?.records ?? [];
 
   return (
     <div className="mt-4 space-y-4">
-      {!records && !isValidating ? (
+      {isValidating && !data ? (
         <div className="h-20 animate-pulse rounded-lg bg-neutral-200" />
       ) : isVerified ? (
         <div className="flex items-center gap-2 text-pretty rounded-lg bg-green-100/80 p-3 text-sm text-green-600">
           <CircleCheck className="h-5 w-5 shrink-0" />
           <div>
             Good news! All the DNS records are verified. You are ready to start
             sending emails with this domain.
           </div>
         </div>
-      ) : records && records.length > 0 ? (
+      ) : hasRecords ? (
         <div className="flex flex-col gap-8">
           ...
         </div>
+      ) : error ? (
+        <div className="rounded-lg bg-red-100/80 p-4 text-center text-sm text-red-700">
+          Failed to load verification records. Please try again.
+        </div>
       ) : (
         <div className="rounded-lg bg-neutral-100/80 p-4 text-center text-sm text-neutral-600">
           Loading verification records...
         </div>
       )}
     </div>
   );

Also applies to: 185-187, 199-210

apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts (2)

116-125: Mark campaign as "sending" only on the first page.

This status update runs unconditionally on every paginated batch. On subsequent pages, the campaign status is already "sending", so the where clause matches zero rows. While harmless, this is an unnecessary database call.

Apply this diff to update the status only on the first page:

-    // Mark the campaign as sending
-    await prisma.campaign.update({
-      where: {
-        id: campaignId,
-        status: "scheduled",
-      },
-      data: {
-        status: "sending",
-      },
-    });
+    // Mark the campaign as sending (first page only)
+    if (!startingAfter) {
+      await prisma.campaign.update({
+        where: {
+          id: campaignId,
+          status: "scheduled",
+        },
+        data: {
+          status: "sending",
+        },
+      });
+    }

192-198: Remove PII from console logs.

Logging partner emails and names violates privacy best practices and may breach compliance requirements (GDPR, CCPA). Console logs can be persisted to external logging systems where PII retention is not controlled.

Apply this diff to log only non-PII metrics:

-    console.table(
-      partnerUsers.map((partnerUser) => ({
-        id: partnerUser.partner.id,
-        name: partnerUser.partner.name,
-        email: partnerUser.email,
-      })),
-    );
+    console.log(
+      `Broadcasting to ${partnerUsers.length} partner users for campaign ${campaignId}`,
+    );
apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (1)

122-127: Inconsistent conditional spread operators prevent field clearing.

Lines 122-126 use truthy checks (name &&, subject &&, from &&, etc.) that prevent clearing fields with empty strings or null. Line 127 correctly uses !== undefined for scheduledAt. This inconsistency can block legitimate updates where users want to clear optional fields like from.

As noted in the previous review, the fix requires:

  1. Update apps/web/lib/zod/schemas/campaigns.ts to add .nullable() to clearable fields
  2. Change conditional checks to use !== undefined for consistency

Refer to the detailed analysis and suggested fix in the existing review comment.

apps/web/app/(ee)/api/email-domains/route.ts (1)

51-63: Non-atomic existence check; rely on DB uniqueness or wrap in a transaction.

Between findFirst and create, a race can pass the check and rely on a later P2002. Either:

  • enforce a unique index at the DB level (preferred), or
  • wrap the check+create in a $transaction (using tx).

The previous review flagged this; validation is now added, but atomicity remains.

Would you confirm a DB-level uniqueness on emailDomain.programId exists? Run:

#!/bin/bash
set -euo pipefail
echo "Searching Prisma schema for EmailDomain uniqueness on programId…"
fd -up --hidden --exclude node_modules schema.prisma | while read -r f; do
  echo "==> $f"
  rg -nP -C3 'model\s+EmailDomain\b(?s).*?\n}' "$f" || true
  rg -nP '@@unique\((?:[^)]*programId[^)]*)\)' "$f" || true
done
apps/web/app/(ee)/api/email-domains/[domain]/route.ts (3)

79-90: PATCH returns stale entity and may write undefined fields.

You update the DB but return the pre-update emailDomain. Also avoid passing possibly-undefined fields to Prisma; include only defined keys.

Apply:

-    try {
-      await prisma.emailDomain.update({
+    try {
+      const updated = await prisma.emailDomain.update({
         where: {
           id: emailDomain.id,
         },
         data: {
-          slug,
-          fromAddress,
+          ...(slug !== undefined ? { slug } : {}),
+          ...(fromAddress !== undefined ? { fromAddress } : {}),
         },
       });
 
-      return NextResponse.json(EmailDomainSchema.parse(emailDomain));
+      return NextResponse.json(EmailDomainSchema.parse(updated));

94-101: Guard P2002 conflict message when slug isn’t provided.

On PATCH, slug can be undefined; user would see “undefined domain…”. Prefer a generic message.

-            message: `This ${slug} domain has been registered already by another program.`,
+            message: "This domain has been registered already by another program.",

149-151: DELETE should not fail after DB delete due to external Resend issues.

After deleting the DB record, awaiting resend.domains.remove can 500 the request even though deletion succeeded. Make it best‑effort and non‑blocking.

-    if (emailDomain.resendDomainId && resend) {
-      await resend.domains.remove(emailDomain.resendDomainId);
-    }
+    if (emailDomain.resendDomainId) {
+      waitUntil(
+        (async () => {
+          try {
+            if (resend) {
+              await resend.domains.remove(emailDomain.resendDomainId!);
+            } else {
+              console.warn("Resend not configured; skipped domain removal");
+            }
+          } catch (e) {
+            console.error("Failed to remove Resend domain", e);
+          }
+        })(),
+      );
+    }
🧹 Nitpick comments (8)
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/email/email-domain-dns-records.tsx (3)

43-145: Consider memoizing the columns array.

The columns array is recreated and mutated on every render. For better performance and React best practices, wrap it in useMemo with [showPriority, showStatus] dependencies.

Example refactor:

const columns = useMemo(() => {
  const baseColumns = [ /* ... */ ];
  if (showPriority) baseColumns.push({ /* ... */ });
  if (showStatus) baseColumns.push({ /* ... */ });
  return baseColumns;
}, [showPriority, showStatus]);

214-214: Remove redundant conditional checks.

  • Line 214: The check records.length > 0 is redundant because the parent condition on Line 210 already ensures this.
  • Line 234: The check dmarcRecords.length > 0 is always true since dmarcRecords is a static array with one element.

Apply this diff:

           <div className="flex flex-col gap-6">
-            {records.length > 0 && (
-              <DnsRecordsTable
+            <DnsRecordsTable
               title="DKIM and SPF (Required)"
               ...
-              />
-            )}
+            />
           </div>
         </div>
 
         <div className="flex flex-col rounded-lg border border-neutral-200 bg-neutral-100 p-4">
-          {dmarcRecords.length > 0 && (
-            <DnsRecordsTable
+          <DnsRecordsTable
             title="DMARC (Recommended)"
             ...
-            />
-          )}
+          />
         </div>

Also applies to: 234-234


188-196: Move static dmarcRecords to module level.

The dmarcRecords array is static and recreated on every render. Define it as a module-level constant outside the component for better performance.

Apply this diff:

+const DMARC_RECORDS = [
+  {
+    record: "dmarc",
+    type: "TXT",
+    name: "_dmarc",
+    value: "v=DMARC1; p=none;",
+    ttl: "Auto",
+  },
+] as const;
+
 export function EmailDomainDnsRecords({ domain }: EmailDomainDnsRecordsProps) {
   const { id: workspaceId } = useWorkspace();
 
   ...
 
-  const dmarcRecords = [
-    {
-      record: "dmarc",
-      type: "TXT",
-      name: "_dmarc",
-      value: "v=DMARC1; p=none;",
-      ttl: "Auto",
-    },
-  ];
-
   return (
     <div className="mt-4 space-y-4">
       ...
-              records={dmarcRecords}
+              records={DMARC_RECORDS}
apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts (2)

74-74: Fix typo in comment.

-    // Idealy this should not happen but just in case
+    // Ideally this should not happen but just in case

228-228: Consider cleaner idempotency key formatting.

The idempotency key uses ${campaign.id}-${startingAfter}, which becomes "campaign-id-undefined" (string literal) on the first page. While functionally correct and unique, a cleaner format would avoid the literal "undefined" string.

Consider one of these alternatives:

           headers: {
-            "Idempotency-Key": `${campaign.id}-${startingAfter}`,
+            "Idempotency-Key": `${campaign.id}-${startingAfter || "0"}`,
           },

Or:

           headers: {
-            "Idempotency-Key": `${campaign.id}-${startingAfter}`,
+            "Idempotency-Key": `${campaign.id}${startingAfter ? `-${startingAfter}` : ""}`,
           },
apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (1)

207-217: Add error handling for QStash cleanup operations.

The QStash cleanup calls (lines 208, 216) lack error handling. While cleanup failures on delete are less critical than on create, they should still be caught to prevent unhandled rejections in waitUntil.

Apply this diff:

   waitUntil(
     (async () => {
       if (campaign.type === "marketing" && campaign.qstashMessageId) {
-        await qstash.messages.delete(campaign.qstashMessageId);
+        try {
+          await qstash.messages.delete(campaign.qstashMessageId);
+        } catch (error) {
+          console.warn(
+            `Failed to delete QStash message ${campaign.qstashMessageId}:`,
+            error,
+          );
+        }
       } else if (campaign.type === "transactional" && campaign.workflow) {
         const { condition } = parseWorkflowConfig(campaign.workflow);
 
         if (condition.attribute === "partnerJoined") {
           return;
         }
 
-        await qstash.schedules.delete(campaign.workflow.id);
+        try {
+          await qstash.schedules.delete(campaign.workflow.id);
+        } catch (error) {
+          console.warn(
+            `Failed to delete QStash schedule for workflow ${campaign.workflow.id}:`,
+            error,
+          );
+        }
       }
     })(),
   );
apps/web/app/(ee)/api/email-domains/[domain]/route.ts (2)

47-49: Make previous Resend-domain removal best‑effort and non‑blocking.

An await here can fail the PATCH due to a transient external error even though the core update could proceed. Defer and swallow errors.

-      if (emailDomain.resendDomainId) {
-        await resend.domains.remove(emailDomain.resendDomainId);
-      }
+      if (emailDomain.resendDomainId) {
+        waitUntil(
+          (async () => {
+            try {
+              await resend.domains.remove(emailDomain.resendDomainId!);
+            } catch (e) {
+              console.error("Failed to remove previous Resend domain", e);
+            }
+          })(),
+        );
+      }

37-38: Type clarity: avoid truthy string in boolean condition.

Make the intent explicit; prevents accidental truthiness bugs.

-    const domainChanged = slug && slug !== emailDomain.slug;
+    const domainChanged = slug !== undefined && slug !== emailDomain.slug;
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f8ac8ed and d318ba7.

📒 Files selected for processing (7)
  • apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (5 hunks)
  • apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts (1 hunks)
  • apps/web/app/(ee)/api/email-domains/[domain]/route.ts (1 hunks)
  • apps/web/app/(ee)/api/email-domains/route.ts (1 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/email/email-domain-dns-records.tsx (1 hunks)
  • apps/web/lib/api/campaigns/schedule-campaigns.ts (1 hunks)
  • packages/prisma/schema/campaign.prisma (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (6)
apps/web/app/(ee)/api/email-domains/route.ts (9)
apps/web/app/(ee)/api/cron/email-domains/verify/route.ts (1)
  • GET (14-59)
apps/web/app/(ee)/api/email-domains/[domain]/verify/route.ts (1)
  • GET (10-67)
apps/web/lib/auth/workspace.ts (1)
  • withWorkspace (42-436)
apps/web/lib/zod/schemas/email-domains.ts (2)
  • EmailDomainSchema (9-18)
  • createEmailDomainBodySchema (20-36)
apps/web/lib/api/utils.ts (1)
  • parseRequestBody (9-20)
apps/web/lib/api/domains/validate-email-domain.ts (1)
  • validateEmailDomain (3-20)
apps/web/lib/api/errors.ts (1)
  • DubApiError (75-92)
packages/email/src/resend/client.ts (1)
  • resend (3-5)
apps/web/lib/api/create-id.ts (1)
  • createId (66-71)
apps/web/lib/api/campaigns/schedule-campaigns.ts (2)
apps/web/lib/api/workflows/utils.ts (1)
  • isScheduledWorkflow (9-22)
apps/web/lib/zod/schemas/workflows.ts (1)
  • WORKFLOW_SCHEDULES (39-42)
apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts (6)
packages/email/src/index.ts (1)
  • sendBatchEmail (31-67)
packages/email/src/templates/campaign-email.tsx (1)
  • CampaignEmail (18-129)
apps/web/lib/api/workflows/render-campaign-email-html.ts (1)
  • renderCampaignEmailHTML (10-69)
apps/web/lib/types.ts (1)
  • TiptapNode (648-654)
apps/web/lib/api/create-id.ts (1)
  • createId (66-71)
apps/web/lib/api/errors.ts (1)
  • handleAndReturnErrorResponse (175-178)
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/email/email-domain-dns-records.tsx (4)
apps/web/lib/types.ts (1)
  • EmailDomainProps (664-664)
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/email/constants.ts (1)
  • EMAIL_DOMAIN_STATUS_TO_VARIANT (3-10)
packages/ui/src/table/table.tsx (2)
  • useTable (49-241)
  • Table (339-662)
packages/email/src/resend/types.ts (1)
  • GetDomainResponseSuccess (12-12)
apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (4)
apps/web/lib/api/campaigns/validate-campaign.ts (1)
  • validateCampaign (15-56)
apps/web/lib/zod/schemas/campaigns.ts (1)
  • updateCampaignSchema (84-104)
apps/web/lib/api/campaigns/schedule-campaigns.ts (2)
  • scheduleMarketingCampaign (9-79)
  • scheduleTransactionalCampaign (82-125)
apps/web/lib/api/workflows/parse-workflow-config.ts (1)
  • parseWorkflowConfig (8-30)
apps/web/app/(ee)/api/email-domains/[domain]/route.ts (7)
apps/web/lib/auth/workspace.ts (1)
  • withWorkspace (42-436)
apps/web/lib/zod/schemas/email-domains.ts (2)
  • updateEmailDomainBodySchema (38-39)
  • EmailDomainSchema (9-18)
apps/web/lib/api/utils.ts (1)
  • parseRequestBody (9-20)
apps/web/lib/api/domains/get-email-domain-or-throw.ts (1)
  • getEmailDomainOrThrow (4-32)
apps/web/lib/api/domains/validate-email-domain.ts (1)
  • validateEmailDomain (3-20)
packages/email/src/resend/client.ts (1)
  • resend (3-5)
apps/web/lib/api/errors.ts (1)
  • DubApiError (75-92)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (6)
apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts (1)

249-276: LGTM: Pagination and status transitions are well-implemented.

The pagination logic correctly handles batching by checking if the full page was returned, and the final status update includes a proper where clause to ensure atomic state transitions from "sending" to "sent".

packages/prisma/schema/campaign.prisma (2)

6-18: LGTM!

The new campaign statuses (sending, sent, cancelled) properly extend the lifecycle for marketing campaigns. The comment annotations clearly distinguish between transactional and marketing campaign states.


30-30: Email validation for from field is already enforced.

The application layer validates the from field as a properly formatted email address using Zod schema validation in apps/web/lib/zod/schemas/campaigns.ts (line 91: z.string().email().trim().toLowerCase()) and apps/web/lib/actions/campaigns/send-campaign-preview-email.ts (line 19: z.string().email().optional()). No action required.

apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (1)

147-161: LGTM!

The scheduling logic correctly routes to the appropriate handler based on campaign type, and waitUntil ensures async operations don't block the response. Clean separation of concerns.

apps/web/app/(ee)/api/email-domains/route.ts (2)

23-30: LGTM: GET handler shape/validation.

Validates with z.array(EmailDomainSchema) and returns JSON; looks good.


46-50: LGTM: Cross-field server-side validation added.

validateEmailDomain enforces fromAddress↔slug consistency; good catch-all.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (2)
apps/web/app/(ee)/api/email-domains/route.ts (2)

65-71: Good: Resend config check occurs before external/DB side effects.

Prevents partial success and orphaned records. This addresses earlier feedback.


51-64: Eliminate TOCTOU on “one domain per program” — rely on DB uniqueness (or wrap in a transaction).

The pre-check (findFirst) followed by create is racy under concurrency and can allow two creates if the schema doesn’t enforce a unique constraint on programId. Prefer:

  • Enforce @@unique([programId]) in Prisma and remove the pre-check, mapping P2002 to a friendly message; or
  • Wrap check+create in an interactive transaction (consider isolation level), noting serverless constraints.

Also, the P2002 handler assumes the conflict is due to slug being registered “by another program,” which can be misleading if the constraint hit is programId. Map by error.meta.target.

Run this to confirm uniqueness constraints in your Prisma schema:

#!/bin/bash
# Locate schema files
fd -t f -E node_modules 'schema.prisma'

# Print EmailDomain model blocks
for f in $(fd -t f -E node_modules 'schema.prisma'); do
  echo "== $f =="
  awk '/model[[:space:]]+EmailDomain\b/{flag=1} flag{print} /\}/{if(flag){exit}}' "$f"
done

# Check for uniqueness on programId and slug
rg -nP '@@unique\(\s*\[(?:[^\]]*programId[^\]]*)\]\)|\bprogramId\s*@unique' -S
rg -nP '@@unique\(\s*\[(?:[^\]]*slug[^\]]*)\]\)|\bslug\s*@unique' -S

If you choose the DB-uniqueness path, apply this focused refactor:

-    const existingEmailDomain = await prisma.emailDomain.findFirst({
-      where: {
-        programId,
-      },
-    });
-
-    if (existingEmailDomain) {
-      throw new DubApiError({
-        code: "bad_request",
-        message:
-          "An email domain has already been configured for this program. Each program can only have one email domain.",
-      });
-    }
@@
-    } catch (error) {
+    } catch (err) {
       // Cleanup to avoid orphaned Resend domains
-      waitUntil(resend.domains.remove(resendDomain.id));
+      waitUntil(
+        (async () => {
+          try {
+            await resend.domains.remove(resendDomain.id);
+          } catch (e) {
+            console.error("Failed to rollback Resend domain after DB error", e);
+          }
+        })(),
+      );
 
-      if (error instanceof Prisma.PrismaClientKnownRequestError) {
-        if (error.code === "P2002") {
-          throw new DubApiError({
-            code: "conflict",
-            message: `This ${slug} domain has been registered already by another program.`,
-          });
-        }
-      }
+      if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
+        const target =
+          Array.isArray((err as any).meta?.target)
+            ? ((err as any).meta!.target as string[]).join(",")
+            : ((err as any).meta?.target as string | undefined);
+        if (target?.includes("programId")) {
+          throw new DubApiError({
+            code: "conflict",
+            message:
+              "An email domain has already been configured for this program. Each program can only have one email domain.",
+          });
+        }
+        if (target?.includes("slug")) {
+          throw new DubApiError({
+            code: "conflict",
+            message: `This ${slug} domain has been registered already by another program.`,
+          });
+        }
+      }
 
-      throw new DubApiError({
-        code: "internal_server_error",
-        message: error.message,
-      });
+      throw new DubApiError({
+        code: "internal_server_error",
+        message: err instanceof Error ? err.message : "Unexpected error",
+      });
     }

Also applies to: 83-94

🧹 Nitpick comments (5)
apps/web/app/(ee)/api/email-domains/route.ts (5)

58-63: Use conflict (409) instead of bad_request for existing program domain.

Semantically this is a 409 conflict.

-      throw new DubApiError({
-        code: "bad_request",
+      throw new DubApiError({
+        code: "conflict",
         message:
           "An email domain has already been configured for this program. Each program can only have one email domain.",
       });

72-82: Avoid variable shadowing; clarify Resend errors.

Rename the destructured error to resendError to avoid shadowing the catch (error) variable and improve readability.

-    const { data: resendDomain, error } = await resend.domains.create({
+    const { data: resendDomain, error: resendError } = await resend.domains.create({
       name: slug,
     });
 
-    if (error) {
+    if (resendError) {
       throw new DubApiError({
         code: "unprocessable_entity",
-        message: error.message,
+        message: resendError.message,
       });
     }

95-96: Harden background verify with guarded waitUntil.

Wrap verify in a try/catch inside waitUntil to avoid unhandled rejections and add a minimal log.

-      waitUntil(resend.domains.verify(resendDomain.id));
+      waitUntil(
+        (async () => {
+          try {
+            await resend.domains.verify(resendDomain.id);
+          } catch (e) {
+            console.error("Resend domain verify failed", e);
+          }
+        })(),
+      );

97-97: Return 201 Created for resource creation.

Aligns status code with creation semantics.

-      return NextResponse.json(EmailDomainSchema.parse(emailDomain));
+      return NextResponse.json(EmailDomainSchema.parse(emailDomain), { status: 201 });

102-109: Address type-safety and improve P2002 error messaging with defensive checks and meta.target inspection.

The review comment identifies two valid improvements:

  1. Type-safety gap (line 115): Accessing error.message without guarding the error type. Since useUnknownInCatchVariables is not enabled in tsconfig, TypeScript defaults to typing catch variables as any, but defensive checks prevent runtime issues if error is not an Error object.

  2. Error messaging (lines 105–107): The P2002 handler uses a generic message instead of checking meta.target (a standard Prisma field that contains conflicting field names as an array). This allows differentiating between programId and slug conflicts to provide context-appropriate messages.

The suggested diff is appropriate: wrapping error.message in a type guard and inspecting meta.target to differentiate error contexts.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d318ba7 and e55cc21.

📒 Files selected for processing (1)
  • apps/web/app/(ee)/api/email-domains/route.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/(ee)/api/email-domains/route.ts (7)
apps/web/lib/auth/workspace.ts (1)
  • withWorkspace (42-436)
apps/web/lib/zod/schemas/email-domains.ts (2)
  • EmailDomainSchema (9-18)
  • createEmailDomainBodySchema (20-36)
apps/web/lib/api/utils.ts (1)
  • parseRequestBody (9-20)
apps/web/lib/api/domains/validate-email-domain.ts (1)
  • validateEmailDomain (3-20)
apps/web/lib/api/errors.ts (1)
  • DubApiError (75-92)
packages/email/src/resend/client.ts (1)
  • resend (3-5)
apps/web/lib/api/create-id.ts (1)
  • createId (66-71)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (3)
apps/web/app/(ee)/api/email-domains/route.ts (3)

23-31: LGTM: Program-scoped fetch with response validation.

Fetching by programId and validating with z.array(EmailDomainSchema) is clean and consistent.


42-50: Good: Normalized input and cross-field validation.

Parsing via zod + validateEmailDomain ensures lowercase normalization and fromAddress↔slug alignment.


99-101: Nice: Best-effort rollback to avoid orphaned Resend domains.

This cleanup closes the external-call gap on DB failures.

@devkiran
Copy link
Collaborator Author

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 26, 2025

✅ Actions performed

Full review triggered.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (3)
apps/web/lib/api/campaigns/validate-campaign.ts (1)

34-49: Fix editability gate: check field presence, not truthiness.

This issue was previously flagged. Falsy updates (e.g., empty string, null) bypass the guard, allowing edits to non-editable campaigns.

Apply:

-  if (
-    input.name ||
-    input.subject ||
-    input.bodyJson ||
-    input.groupIds ||
-    input.triggerCondition ||
-    input.from ||
-    input.scheduledAt
-  ) {
-    if (!CAMPAIGN_EDITABLE_STATUSES.includes(campaign.status)) {
-      throw new DubApiError({
-        code: "bad_request",
-        message: `You can't make changes to a "${campaign.status}" campaign.`,
-      });
-    }
-  }
+  const editableKeys = [
+    "name",
+    "subject",
+    "bodyJson",
+    "groupIds",
+    "triggerCondition",
+    "from",
+    "scheduledAt",
+  ] as const;
+  const isEditing = editableKeys.some((k) =>
+    Object.prototype.hasOwnProperty.call(input, k),
+  );
+  if (isEditing && !CAMPAIGN_EDITABLE_STATUSES.includes(campaign.status)) {
+    throw new DubApiError({
+      code: "bad_request",
+      message: `You can't make changes to a "${campaign.status}" campaign.`,
+    });
+  }
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/email/email-domain-card.tsx (1)

59-64: Auto-expand only once to avoid fighting user intent.

This issue was previously flagged. The effect will re-open the panel after a user collapses it if status remains non-verified.

Apply:

+  const autoOpenedRef = useRef(false);
   // Automatically open DNS records section if status is not verified
   useEffect(() => {
-    if (data?.status && data.status !== "verified") {
+    if (!autoOpenedRef.current && data?.status && data.status !== "verified") {
       setShowDetails(true);
+      autoOpenedRef.current = true;
     }
   }, [data?.status]);
apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (1)

122-127: Align conditional checks: use !== undefined consistently.

This issue was previously flagged. The truthy checks prevent setting falsy values (empty strings or null) which may be valid clears. Line 127 correctly uses !== undefined for scheduledAt.

However, before applying the fix, verify that the schema supports nullable values for these fields. Based on the schema in apps/web/lib/zod/schemas/campaigns.ts (lines 83-103), the fields do not have .nullable(), so they only accept defined non-null values or can be omitted entirely.

Recommended approach:

  1. If clearing fields (setting to null or empty string) is intended behavior, update the schema first to add .nullable() where appropriate
  2. Then align all conditionals to use !== undefined

Apply this pattern after schema verification:

           ...(name !== undefined && { name }),
           ...(subject !== undefined && { subject }),
           ...(from !== undefined && { from }),
           ...(status !== undefined && { status }),
           ...(bodyJson !== undefined && { bodyJson }),
           ...(scheduledAt !== undefined && { scheduledAt }),
🧹 Nitpick comments (1)
apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (1)

205-219: Consider error handling in cleanup logic.

The cleanup logic is well-structured, but failures in waitUntil are silent. Consider adding error logging or monitoring for cleanup failures.

 waitUntil(
   (async () => {
+    try {
       if (campaign.type === "marketing" && campaign.qstashMessageId) {
         await qstash.messages.delete(campaign.qstashMessageId);
       } else if (campaign.type === "transactional" && campaign.workflow) {
         const { condition } = parseWorkflowConfig(campaign.workflow);
 
         if (condition.attribute === "partnerJoined") {
           return;
         }
 
         await qstash.schedules.delete(campaign.workflow.id);
       }
+    } catch (error) {
+      console.error(`Failed to cleanup campaign ${campaignId}:`, error);
+    }
   })(),
 );
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4dd431e and 5a003af.

📒 Files selected for processing (11)
  • apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (5 hunks)
  • apps/web/app/(ee)/api/campaigns/route.ts (1 hunks)
  • apps/web/app/(ee)/api/email-domains/[domain]/route.ts (1 hunks)
  • apps/web/app/(ee)/api/email-domains/route.ts (1 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/email/email-domain-card.tsx (1 hunks)
  • apps/web/lib/api/campaigns/validate-campaign.ts (1 hunks)
  • apps/web/lib/zod/schemas/email-domains.ts (1 hunks)
  • apps/web/tests/campaigns/index.test.ts (1 hunks)
  • apps/web/ui/modals/add-edit-email-domain-modal.tsx (1 hunks)
  • apps/web/ui/modals/delete-email-domain-modal.tsx (1 hunks)
  • packages/prisma/schema/domain.prisma (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (4)
  • apps/web/app/(ee)/api/email-domains/route.ts
  • apps/web/ui/modals/delete-email-domain-modal.tsx
  • apps/web/app/(ee)/api/email-domains/[domain]/route.ts
  • apps/web/ui/modals/add-edit-email-domain-modal.tsx
🧰 Additional context used
🧬 Code graph analysis (4)
apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (5)
apps/web/lib/api/campaigns/validate-campaign.ts (1)
  • validateCampaign (16-85)
apps/web/lib/zod/schemas/campaigns.ts (1)
  • updateCampaignSchema (84-104)
apps/web/lib/api/utils.ts (1)
  • parseRequestBody (9-20)
apps/web/lib/api/campaigns/schedule-campaigns.ts (2)
  • scheduleMarketingCampaign (9-79)
  • scheduleTransactionalCampaign (82-125)
apps/web/lib/api/workflows/parse-workflow-config.ts (1)
  • parseWorkflowConfig (8-30)
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/email/email-domain-card.tsx (7)
apps/web/lib/types.ts (1)
  • EmailDomainProps (664-664)
apps/web/lib/swr/use-workspace.ts (1)
  • useWorkspace (6-46)
packages/ui/src/hooks/use-in-viewport.tsx (1)
  • useInViewport (5-55)
apps/web/ui/modals/add-edit-email-domain-modal.tsx (1)
  • useAddEditEmailDomainModal (171-186)
apps/web/ui/modals/delete-email-domain-modal.tsx (1)
  • useDeleteEmailDomainModal (121-151)
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/email/constants.ts (1)
  • EMAIL_DOMAIN_STATUS_TO_VARIANT (3-10)
apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/email/email-domain-dns-records.tsx (1)
  • EmailDomainDnsRecords (171-255)
apps/web/lib/api/campaigns/validate-campaign.ts (4)
apps/web/lib/zod/schemas/campaigns.ts (1)
  • updateCampaignSchema (84-104)
apps/web/lib/api/campaigns/constants.ts (2)
  • CAMPAIGN_STATUS_TRANSITIONS (8-24)
  • CAMPAIGN_EDITABLE_STATUSES (26-30)
apps/web/lib/api/errors.ts (1)
  • DubApiError (75-92)
packages/prisma/index.ts (1)
  • prisma (3-9)
apps/web/lib/zod/schemas/email-domains.ts (2)
packages/prisma/client.ts (1)
  • EmailDomainStatus (12-12)
apps/web/lib/api/domains/is-valid-domain.ts (2)
  • isValidDomainFormat (11-13)
  • isValidDomain (3-9)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (11)
apps/web/tests/campaigns/index.test.ts (1)

54-54: LGTM: Correct HTTP status for resource creation.

The change to expect 201 (Created) aligns with RESTful semantics for POST operations that create new resources.

apps/web/app/(ee)/api/campaigns/route.ts (1)

94-99: LGTM: Correct HTTP status for POST.

Returning 201 (Created) is the correct HTTP status for successful resource creation.

packages/prisma/schema/domain.prisma (2)

63-69: LGTM: Well-defined status enum.

The status values cover all necessary states for domain verification lifecycle.


71-86: Disregard the index suggestion – workspaceId is not used in database queries.

The review incorrectly assumes workspaceId is used in query filters. However, the actual implementation in apps/web/app/(ee)/api/email-domains/route.ts (line 22-26) filters EmailDomain records only by programId, not workspaceId:

const emailDomains = await prisma.emailDomain.findMany({
  where: {
    programId,
  },
});

Although workspaceId is stored on the EmailDomain model and passed as a query parameter by the frontend, it is never used in WHERE clauses. Indexes only improve performance when used in filtering, sorting, or join conditions—since workspaceId serves neither role here, adding an index would not provide any benefit.

Likely an incorrect or invalid review comment.

apps/web/lib/zod/schemas/email-domains.ts (3)

8-16: LGTM: Well-structured domain schema.

The schema correctly captures all domain properties with appropriate types.


18-28: LGTM: Comprehensive domain validation.

The validation chain properly enforces format, length constraints, and domain restrictions while normalizing to lowercase.


30-31: LGTM: Appropriate use of partial schema.

Using .partial() correctly enables optional field updates.

apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/email/email-domain-card.tsx (2)

37-43: LGTM: Efficient lazy loading pattern.

Using viewport visibility to gate the SWR fetch is a good optimization for performance when many domain cards are rendered.


101-115: LGTM: Well-implemented refresh control.

The refresh button correctly uses the loading state and provides good visual feedback to users.

apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (2)

64-76: LGTM: Proper validation integration.

The destructuring pattern correctly extracts validated fields for downstream use.


147-161: LGTM: Clean type-based scheduling dispatch.

The separation of marketing and transactional campaign scheduling logic is well-structured and appropriately uses waitUntil for non-blocking execution.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
apps/web/lib/api/campaigns/validate-campaign.ts (1)

37-52: Fix editability gate: check field presence, not truthiness.

Falsy updates (e.g., empty string, null) bypass the guard. This issue was previously flagged and remains unresolved. Check for key presence using Object.prototype.hasOwnProperty.call(input, key) or the in operator instead of truthiness.

Apply the previously suggested fix:

-  if (
-    input.name ||
-    input.subject ||
-    input.bodyJson ||
-    input.groupIds ||
-    input.triggerCondition ||
-    input.from ||
-    input.scheduledAt
-  ) {
-    if (!CAMPAIGN_EDITABLE_STATUSES.includes(campaign.status)) {
-      throw new DubApiError({
-        code: "bad_request",
-        message: `You can't make changes to a "${campaign.status}" campaign.`,
-      });
-    }
-  }
+  const editableKeys = [
+    "name",
+    "subject",
+    "bodyJson",
+    "groupIds",
+    "triggerCondition",
+    "from",
+    "scheduledAt",
+  ] as const;
+  const isEditing = editableKeys.some((k) =>
+    Object.prototype.hasOwnProperty.call(input, k),
+  );
+  if (isEditing && !CAMPAIGN_EDITABLE_STATUSES.includes(campaign.status)) {
+    throw new DubApiError({
+      code: "bad_request",
+      message: `You can't make changes to a "${campaign.status}" campaign.`,
+    });
+  }
🧹 Nitpick comments (2)
apps/web/lib/api/campaigns/validate-campaign.ts (2)

16-17: Offer to implement required fields validation.

The TODO highlights an important validation gap: ensuring campaigns have all required fields before transitioning to certain statuses (e.g., from/subject/body before moving to scheduled/active).

Would you like me to generate the implementation for this validation logic, or open an issue to track it?


100-107: Email format already validated by schema.

The split("@") check is redundant since updateCampaignSchema already validates email format with z.string().email(). However, keeping this as a defensive check for domain extraction is acceptable.

If you prefer removing the redundancy, you can simplify to:

-  const parts = campaign.from.split("@");
-
-  if (parts.length !== 2) {
-    throw new DubApiError({
-      code: "bad_request",
-      message: `Campaign (${campaign.id}) has an invalid email address format for 'from' field.`,
-    });
-  }
-
-  const domainPart = parts[1];
+  const domainPart = campaign.from.split("@")[1];
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5a003af and 67f33a3.

📒 Files selected for processing (3)
  • apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts (1 hunks)
  • apps/web/lib/api/campaigns/validate-campaign.ts (1 hunks)
  • apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (4 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts
  • apps/web/lib/api/workflows/execute-send-campaign-workflow.ts
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/api/campaigns/validate-campaign.ts (4)
apps/web/lib/zod/schemas/campaigns.ts (1)
  • updateCampaignSchema (84-104)
apps/web/lib/api/campaigns/constants.ts (2)
  • CAMPAIGN_STATUS_TRANSITIONS (8-24)
  • CAMPAIGN_EDITABLE_STATUSES (26-30)
apps/web/lib/api/errors.ts (1)
  • DubApiError (75-92)
packages/prisma/index.ts (1)
  • prisma (3-9)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (1)
apps/web/lib/api/campaigns/validate-campaign.ts (1)

34-49: Critical: Editability guard still checks truthiness, not key presence.

This issue was flagged in a previous review but remains unaddressed. Falsy updates (empty string, null, 0, false) will bypass the editability check, allowing modifications to non-editable campaigns.

Apply the fix from the previous review:

-  if (
-    input.name ||
-    input.subject ||
-    input.bodyJson ||
-    input.groupIds ||
-    input.triggerCondition ||
-    input.from ||
-    input.scheduledAt
-  ) {
-    if (!CAMPAIGN_EDITABLE_STATUSES.includes(campaign.status)) {
-      throw new DubApiError({
-        code: "bad_request",
-        message: `You can't make changes to a "${campaign.status}" campaign.`,
-      });
-    }
-  }
+  const editableKeys = [
+    "name",
+    "subject",
+    "bodyJson",
+    "groupIds",
+    "triggerCondition",
+    "from",
+    "scheduledAt",
+  ] as const;
+  const isEditing = editableKeys.some((k) =>
+    Object.prototype.hasOwnProperty.call(input, k),
+  );
+  if (isEditing && !CAMPAIGN_EDITABLE_STATUSES.includes(campaign.status)) {
+    throw new DubApiError({
+      code: "bad_request",
+      message: `You can't make changes to a "${campaign.status}" campaign.`,
+    });
+  }
🧹 Nitpick comments (2)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx (2)

82-83: Consider allowing domain selection when multiple domains exist.

The code always uses the first email domain for the From field. If users have multiple verified domains, they cannot choose which one to use. Consider adding a domain selector in the From field UI.


291-341: Provide actionable guidance when no email domains exist.

When !firstEmailDomain, the From field is disabled with the tooltip "No email domain configured," but users have no clear path to add a domain. Consider providing a link to the email domain settings or more actionable guidance.

Example approach:

tooltip={
  isReadOnly
    ? statusMessages[campaign.status]
    : !firstEmailDomain
      ? "No email domain configured. Add one in Settings."
      : ""
}

Or add a helper link in the UI when domains are missing.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 67f33a3 and 05968a6.

📒 Files selected for processing (2)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx (11 hunks)
  • apps/web/lib/api/campaigns/validate-campaign.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx (4)
apps/web/lib/swr/use-program.ts (1)
  • useProgram (6-40)
apps/web/lib/swr/use-workspace.ts (1)
  • useWorkspace (6-46)
apps/web/lib/swr/use-email-domains.ts (1)
  • useEmailDomains (6-25)
apps/web/lib/api/campaigns/constants.ts (1)
  • CAMPAIGN_READONLY_STATUSES (32-36)
apps/web/lib/api/campaigns/validate-campaign.ts (4)
apps/web/lib/zod/schemas/campaigns.ts (1)
  • updateCampaignSchema (84-104)
apps/web/lib/api/campaigns/constants.ts (2)
  • CAMPAIGN_STATUS_TRANSITIONS (8-24)
  • CAMPAIGN_EDITABLE_STATUSES (26-30)
apps/web/lib/api/errors.ts (1)
  • DubApiError (75-92)
packages/prisma/index.ts (1)
  • prisma (3-9)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (4)
apps/web/lib/api/campaigns/validate-campaign.ts (1)

60-74: From address validation correctly updated.

The previous review concern has been properly addressed. The code now validates the new input.from address by spreading the campaign object and overwriting the from field before passing it to validateCampaignFromAddress.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx (3)

73-77: Status messages are clear and well-defined.

The statusMessages object provides helpful tooltip text for each read-only campaign status.


348-362: Recipients field correctly handles both active and read-only states.

The tooltip logic appropriately differentiates between active campaigns (which are paused to edit) and read-only campaigns (which cannot be edited at all).


434-436: Body editability logic correctly handles campaign types.

The asymmetric logic (read-only for marketing, not-active for transactional) aligns with the campaign lifecycle: marketing campaigns become immutable once sending begins, while transactional campaigns can be paused and edited.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants