Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@TWilson023
Copy link
Collaborator

@TWilson023 TWilson023 commented Aug 5, 2025

Summary by CodeRabbit

  • New Features

    • Added comprehensive Email Campaigns API with full lifecycle support: create, duplicate, update, delete, count, summary, and event tracking endpoints.
    • New React components for campaign management: editor (with autosave and keyboard shortcuts), controls (publish/pause/resume/delete/duplicate), metrics display, events table and modal with search and pagination, action bar, groups and partners selectors.
    • Campaign trigger configuration UI for transactional campaigns with dynamic input types.
    • Send preview email modal with validation and email input.
    • Campaigns dashboard with sortable, paginated table and Create button.
    • New client-side layouts and hooks supporting Email Campaigns feature flag gating.
    • Sidebar integration showing Email Campaigns menu when feature enabled.
    • Rich text editor enhancements for email content, including image upload and variable mentions.
  • Enhancements

    • Improved email template rendering supporting dynamic variables with sanitized HTML and Markdown outputs.
    • Added campaign-related badges and status indicators with iconography.
    • Campaigns API refined for robustness: validation, permission checks, transactional updates, schedule management.
    • Added event counts and detailed event queries per campaign with access controls.
    • UI enhancements including loading skeletons, modals, filters, and toggle groups for campaign status and type.
    • New utility functions for URL building, form context typing, and trigger condition validation.
    • Enriched campaign metrics with percentage calculations and formatted display.
    • Updated sidebar and navigation to conditionally include Email Campaigns menu based on feature flags.
    • Added new icon components utilized across the UI for consistent visual language.
  • Bug Fixes

    • Adjusted validation and error messaging for campaign workflows and bounty performance attributes.
    • Enhanced workflow execution logic with added filtering and error handling.
  • Chores

    • Added new dependencies for TipTap extensions, sanitize-html, and floating-ui.
    • Schema and Prisma model updates adding new fields, enums, and relationships to support Email Campaigns.
    • Refactored multiple internal API utilities for consistency and improved typing.
    • Extensive TypeScript typing improvements and zod schema enhancements across campaigns and workflows.
  • Style

    • UI polishing with improved transitions, conditional scrollable content areas, and consistent button states across campaign management components.

@vercel
Copy link
Contributor

vercel bot commented Aug 5, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Oct 17, 2025 0:16am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 5, 2025

Walkthrough

Adds gated Email Campaigns: DB/schema and type updates, new campaign APIs and server actions, TipTap-based rich text editor and renderers, many frontend campaign components/hooks/pages/modals, renamed workflow export WORKFLOW_ATTRIBUTE_TRIGGER, and feature-flag wiring for emailCampaigns.

Changes

Cohort / File(s) Summary of changes
Workflows schema & related
apps/web/lib/zod/schemas/workflows.ts, apps/web/lib/api/workflows/utils.ts
Renamed WORKFLOW_ATTRIBUTE_TRIGGER_MAPWORKFLOW_ATTRIBUTE_TRIGGER, added partnerJoined attribute and schedule constants, and added isDaysAttribute / isScheduledWorkflow predicates.
Workflow execution & rendering
apps/web/lib/api/workflows/*execute-*.ts, apps/web/lib/api/workflows/render-campaign-email-*.ts, apps/web/lib/api/workflows/interpolate-email-template.ts
Reworked send-workflow execution and enrollment aggregation, switched to campaign HTML/Markdown render pipeline (sanitize + interpolate), added renderCampaignEmailHTML/Markdown, and renamed renderEmailTemplate→interpolateEmailTemplate.
Campaign API routes & helpers
apps/web/app/(ee)/api/campaigns/**, apps/web/lib/api/campaigns/*, apps/web/lib/api/campaigns/constants.ts, apps/web/lib/api/campaigns/get-campaign-or-throw.ts
Added workspace-gated campaign endpoints (list/count/create/duplicate/events/summary), per-campaign GET/PATCH/DELETE, helpers getCampaigns, getCampaignEvents, getCampaignSummary, getCampaignOrThrow, and DEFAULT_CAMPAIGN_BODY; removed validateCampaign.
Campaign events & summary endpoints
apps/web/app/(ee)/api/campaigns/[campaignId]/events*.ts, .../summary/route.ts
New routes for listing campaign events, event counts, and campaign summary with query schemas, pagination and feature gating.
Frontend campaign app (components & hooks)
apps/web/app/app.dub.co/.../program/campaigns/*
Large set of client components/hooks/pages: CampaignEditor (TipTap), skeleton, CampaignControls/ActionBar, CampaignMetrics/Events/Modal/Columns, GroupsSelector, CampaignsTable, CreateCampaignButton, Delete/Duplicate/Preview modals/hooks, useCampaign, useCampaignsCount, useCampaignsFilters, useCampaignFormContext, layout and page client.
Rich text editor & toolbar (UI pkg)
packages/ui/src/rich-text-area/*, packages/ui/src/index.tsx, packages/ui/package.json
Introduced TipTap-based RichTextArea, toolbar, variable suggestions and image upload; exported rich-text-area; added TipTap and sanitize-html dependencies.
Server actions: preview & image upload
apps/web/lib/actions/campaigns/send-campaign-preview-email.ts, apps/web/lib/actions/partners/upload-email-image.ts
New server actions sendCampaignPreviewEmail and uploadEmailImageAction (signed R2 upload).
Zod schemas, types & feature flag
apps/web/lib/zod/schemas/*.ts, apps/web/lib/types.ts, apps/web/lib/edge-config/get-feature-flags.ts
New/updated schemas & types for campaigns/events/summary, EMAIL_TEMPLATE_VARIABLES, CAMPAIGN_WORKFLOW_ATTRIBUTE_CONFIG, WorkflowAttribute type, and added emailCampaigns feature flag wiring.
Prisma schema & client exports
packages/prisma/schema/*.prisma, packages/prisma/client.ts
Added CampaignStatus.scheduled; made Campaign.body optional and added bodyJson; NotificationEmail↔Partner relation and index; Partner.notificationEmails; re-exported CampaignStatus and CampaignType.
Email package & types
packages/email/src/templates/campaign-email.tsx, packages/email/src/react-email.d.ts, packages/email/tsconfig.json
Added CampaignEmail template, ambient types for @react-email/components, and tsconfig tweaks.
Icons & UI exports
packages/ui/src/icons/nucleo/*, packages/ui/src/icons/nucleo/index.ts
Added multiple new SVG icon components and re-exports.
UI & navigation updates
apps/web/ui/layout/*, apps/web/ui/messages/*, apps/web/ui/partners/*, apps/web/ui/layout/sidebar/app-sidebar-nav.tsx
Sidebar gating for Email Campaigns (flag threaded through NAV_AREAS), PageContentWithSidePanel.individualScrolling prop, MessageMarkdown usage, GroupsMultiSelect.className prop, campaign/partner selectors, and assorted UI adjustments.
Utilities, migrations & deps
packages/utils/src/functions/urls.ts, apps/web/scripts/migrations/backfill-campaign-message-to-markdown.ts, apps/web/package.json, packages/ui/package.json
Added buildUrl util, migration script to backfill messages to Markdown, TipTap/sanitize-html deps added and linkify-react bumped.
Bounties attribute mapping
apps/web/lib/api/bounties/performance-bounty-scope-attributes.ts, various bounty callsites
Introduced PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES and replaced prior WORKFLOW_ATTRIBUTE_LABELS usages with the new mapping.
Misc small changes
apps/web/lib/api/groups/throw-if-invalid-group-ids.ts, apps/web/app/.../utils.ts, apps/web/lib/zod/schemas/messages.ts, apps/web/lib/api/workflows/execute-workflows.ts, apps/web/lib/cron/verify-vercel.ts, etc.
Signature tweaks (allow undefined), added isValidTriggerCondition, relaxed shared messageTextSchema max constraint while keeping max at partner/program endpoints, improved workflow execution filtering/logging, and enabled non-Vercel bypass in a cron check.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant FE as Frontend (React + TipTap)
  participant API as Next API (withWorkspace)
  participant DB as Prisma
  participant R as Renderer (renderCampaignEmail*)
  participant M as Mailer/Storage

  User->>FE: Edit campaign content
  FE->>API: PATCH /api/campaigns/{id}
  API->>DB: getCampaignOrThrow -> update (transaction)
  DB-->>API: updated campaign
  API-->>FE: CampaignSchema response

  User->>FE: Request email preview (addresses)
  FE->>API: action sendCampaignPreviewEmail
  API->>DB: fetch Program & Campaign
  API->>R: renderCampaignEmailMarkdown/HTML + interpolateEmailTemplate
  R-->>API: rendered HTML/text
  API->>M: send CampaignEmail (mailer)
  M-->>API: success
  API-->>FE: success

  User->>FE: Create draft
  FE->>API: POST /api/campaigns
  API->>DB: create campaign (+ optional workflow)
  DB-->>API: new campaign id
  API-->>FE: { id }
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • Bounties updates #2786 — Modifies bounty API routes and submission requirement handling; overlaps with bounty attribute/route edits in this PR.
  • FEAT: Bounties #2736 — Related to workflows schema renames and trigger mappings (WORKFLOW_ATTRIBUTE_TRIGGER) that are used across bounty/workflow code.
  • Enrolled partner page #2821 — Touches the same bounty route file with different feature additions; may conflict or overlap in that file.

Suggested reviewers

  • devkiran

Poem

I twitch my whiskers, code all bright,
TipTap wakes and paints the night.
Drafts get copied, previews wing,
Metrics hum and mailboxes sing.
A rabbit cheers — campaigns take flight! 🐇✉️

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title Check ❓ Inconclusive The title "Email campaigns" is directly related to the primary feature being introduced in this changeset, as the pull request adds extensive new functionality for email campaign management, including API routes, UI components, database schema updates, and related utilities. However, the title is extremely vague and generic—it simply names the feature without conveying any meaningful action or context about what is being implemented (e.g., is it adding, updating, refactoring, or fixing email campaigns?). A teammate scanning the git history would not understand what specifically was done beyond the feature name itself, which violates the guideline that titles should be clear and specific enough to convey the primary change.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ 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 email-campaigns

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.

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/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx (2)

323-323: Remove non-null assertion or add guard.

The non-null assertion workspaceId! is unsafe if workspaceId can be undefined. Consider adding a guard or early return.

Apply this diff to add a defensive check:

                   uploadImage={async (file) => {
                     try {
+                      if (!workspaceId) {
+                        toast.error("Workspace not found");
+                        return null;
+                      }
+
                       const result = await executeImageUpload({
-                        workspaceId: workspaceId!,
+                        workspaceId,
                       });

337-337: Remove forbidden Content-Length header in browser PUT.

Browsers disallow setting Content-Length; it's ignored or can cause issues with some signed URLs. Let the browser set it automatically.

Apply this diff:

                       const uploadResponse = await fetch(signedUrl, {
                         method: "PUT",
                         body: file,
                         headers: {
                           "Content-Type": file.type,
-                          "Content-Length": file.size.toString(),
                         },
                       });
🧹 Nitpick comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx (1)

148-156: Consider removing unused dependencies from the callback.

The saveCampaign dependency array includes isSavingCampaign and watch, but these values are not used within the callback body. Removing them will prevent unnecessary callback recreation.

Apply this diff:

     [
-      isSavingCampaign,
       getValues,
       dirtyFields,
-      watch,
       makeRequest,
       campaign.id,
       reset,
     ],
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2e82d18 and 70fff4e.

📒 Files selected for processing (1)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx (9)
apps/web/lib/types.ts (2)
  • Campaign (661-661)
  • UpdateCampaignFormData (663-663)
apps/web/lib/swr/use-program.ts (1)
  • useProgram (6-40)
apps/web/lib/swr/use-api-mutation.ts (1)
  • useApiMutation (33-108)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/utils.ts (1)
  • isValidTriggerCondition (4-36)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-groups-selector.tsx (1)
  • CampaignGroupsSelector (17-125)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/transactional-campaign-logic.tsx (1)
  • TransactionalCampaignLogic (14-88)
packages/ui/src/rich-text-area/index.tsx (1)
  • RichTextArea (35-198)
apps/web/lib/zod/schemas/campaigns.ts (1)
  • EMAIL_TEMPLATE_VARIABLES (16-19)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-action-bar.tsx (1)
  • CampaignActionBar (15-78)
⏰ 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: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (2)

36-43: Guard bodyJson to never be null before parsing

Avoid 500s when DB bodyJson is null by defaulting it (same in PATCH response).

+import { DEFAULT_CAMPAIGN_BODY } from "@/lib/api/campaigns/constants";
@@
-const parsedCampaign = CampaignSchema.parse({
+const parsedCampaign = CampaignSchema.parse({
   ...campaign,
+  bodyJson: campaign.bodyJson ?? DEFAULT_CAMPAIGN_BODY,
   groups: campaign.groups.map(({ groupId }) => ({ id: groupId })),
   triggerCondition: campaign.workflow?.triggerConditions?.[0],
 });

And later:

-const response = CampaignSchema.parse({
+const response = CampaignSchema.parse({
   ...updatedCampaign,
+  bodyJson: updatedCampaign.bodyJson ?? DEFAULT_CAMPAIGN_BODY,
   groups: updatedCampaign.groups.map(({ groupId }) => ({ id: groupId })),
   triggerCondition: updatedCampaign.workflow?.triggerConditions?.[0],
 });

215-226: Delete route: gate by trigger schedule and handle QStash errors

Skip deletion when trigger has no schedule, not by attribute, and catch errors.

-        const { condition } = parseWorkflowConfig(campaign.workflow);
-
-        if (condition.attribute === "partnerJoined") {
-          return;
-        }
-
-        await qstash.schedules.delete(campaign.workflow.id);
+        const hasSchedule = Boolean(
+          WORKFLOW_SCHEDULES[campaign.workflow.trigger],
+        );
+        if (!hasSchedule) return;
+        try {
+          await qstash.schedules.delete(campaign.workflow.id);
+        } catch (error) {
+          console.warn("Failed to delete schedule:", error);
+        }
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (1)

311-332: Operator from condition is ignored in SQL filtering.

buildEnrollmentWhere hardcodes the gte operator for numeric attributes (lines 319-322), ignoring condition.operator. This means if a workflow condition uses a different operator (e.g., lte, eq, neq), the SQL-level filtering will still use gte, producing incorrect results.

For example, if a condition specifies "totalClicks ≤ 10", the generated query would still use totalClicks >= 10.

Apply this diff to respect the condition operator:

+function getOperatorCondition(operator: string, value: number) {
+  switch (operator) {
+    case 'gte':
+      return { gte: value };
+    case 'gt':
+      return { gt: value };
+    case 'lte':
+      return { lte: value };
+    case 'lt':
+      return { lt: value };
+    case 'eq':
+      return { equals: value };
+    case 'neq':
+      return { not: value };
+    default:
+      throw new Error(`Unsupported operator: ${operator}`);
+  }
+}
+
 function buildEnrollmentWhere(condition: WorkflowCondition) {
   switch (condition.attribute) {
     case "totalClicks":
     case "totalLeads":
     case "totalConversions":
     case "totalSales":
     case "totalSaleAmount":
     case "totalCommissions":
       return {
-        [condition.attribute]: {
-          gte: condition.value,
-        },
+        [condition.attribute]: getOperatorCondition(condition.operator, condition.value),
       };
     case "partnerEnrolledDays":
     case "partnerJoined":
+      // For date-based conditions, we need to handle operators appropriately
+      const targetDate = subDays(new Date(), condition.value);
+      switch (condition.operator) {
+        case 'gte':
+          return { createdAt: { lte: targetDate } }; // More than X days ago
+        case 'lte':
+          return { createdAt: { gte: targetDate } }; // Less than X days ago
+        case 'eq':
+          // For equality, use a date range (same day)
+          const nextDay = new Date(targetDate);
+          nextDay.setDate(nextDay.getDate() + 1);
+          return {
+            createdAt: {
+              gte: targetDate,
+              lt: nextDay,
+            },
+          };
+        default:
+          throw new Error(`Unsupported date operator: ${condition.operator}`);
+      }
-      return {
-        createdAt: {
-          lte: subDays(new Date(), condition.value),
-        },
-      };
   }
 }
♻️ Duplicate comments (5)
apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (2)

86-103: Fix: create workflow on add, delete/detach on clear, and link campaign to new workflow

Currently only updates existing workflows. Adding a trigger to a campaign without a workflow does nothing; clearing a trigger leaves the old workflow firing. Handle both cases and update campaign.workflowId accordingly.

 const updatedCampaign = await prisma.$transaction(async (tx) => {
-  if (campaign.workflowId) {
+  let newWorkflowId: string | null = null;
+
+  if (campaign.workflowId && triggerCondition === null) {
+    await tx.workflow.delete({ where: { id: campaign.workflowId } });
+  } else if (campaign.workflowId) {
     await tx.workflow.update({
       where: {
         id: campaign.workflowId,
       },
       data: {
-        ...(triggerCondition && {
+        ...(triggerCondition && {
           triggerConditions: [triggerCondition],
           trigger: WORKFLOW_ATTRIBUTE_TRIGGER[triggerCondition.attribute],
         }),
         ...(status && {
           disabledAt: status === "paused" ? new Date() : null,
         }),
       },
     });
+  } else if (triggerCondition) {
+    const created = await tx.workflow.create({
+      data: {
+        programId,
+        trigger: WORKFLOW_ATTRIBUTE_TRIGGER[triggerCondition.attribute],
+        triggerConditions: [triggerCondition],
+        actions: [{ type: "sendCampaign", data: { campaignId } }],
+      },
+    });
+    newWorkflowId = created.id;
   }
 
   return await tx.campaign.update({
@@
     data: {
       ...(name && { name }),
       ...(subject && { subject }),
       ...(status && { status }),
       ...(bodyJson && { bodyJson }),
+      ...(triggerCondition === null && campaign.workflowId
+        ? { workflowId: null }
+        : {}),
+      ...(newWorkflowId && { workflowId: newWorkflowId }),
       ...(shouldUpdateGroups && {
         groups: {
           deleteMany: {},
           ...(updatedPartnerGroups &&
             updatedPartnerGroups.length > 0 && {
               create: updatedPartnerGroups.map((group) => ({
                 groupId: group.id,
               })),
             }),
         },
       }),
     },

Also applies to: 104-131


159-167: Add error handling for QStash schedule create/delete

Schedule ops fail when schedule exists/absent; currently unhandled in waitUntil.

-        if (shouldSchedule) {
-          await qstash.schedules.create({
+        if (shouldSchedule) {
+          try {
+            await qstash.schedules.create({
               destination: `${APP_DOMAIN_WITH_NGROK}/api/cron/workflows/${updatedCampaign.workflow.id}`,
               cron: cronSchedule,
               scheduleId: updatedCampaign.workflow.id,
-          });
+            });
+          } catch (error) {
+            console.warn("Failed to create schedule:", error);
+          }
         } else if (shouldDeleteSchedule) {
-          await qstash.schedules.delete(updatedCampaign.workflow.id);
+          try {
+            await qstash.schedules.delete(updatedCampaign.workflow.id);
+          } catch (error) {
+            console.warn("Failed to delete schedule:", error);
+          }
         }
apps/web/lib/zod/schemas/workflows.ts (1)

76-81: Allow null value in workflowConditionSchema

UI assigns null to value; z.number() rejects it, causing runtime parse errors.

 export const workflowConditionSchema = z.object({
   attribute: z.enum(WORKFLOW_ATTRIBUTES),
   operator: z.enum(WORKFLOW_COMPARISON_OPERATORS).default("gte"),
-  value: z.number(),
+  value: z.number().nullable(),
 });
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (2)

139-145: Add error handling for markdown rendering.

Past reviews flagged that renderCampaignEmailMarkdown can throw if campaign.bodyJson is malformed. Without error handling, one invalid campaign body aborts message creation for the entire chunk (up to 100 partners), losing all workflow progress for that batch.

Additionally, the as unknown as TiptapNode cast bypasses type validation, increasing the risk of runtime failures.

Wrap the rendering in error handling to isolate failures to individual partners:

 const messages = await prisma.message.createMany({
-  data: programEnrollmentChunk.map((programEnrollment) => ({
+  data: programEnrollmentChunk
+    .map((programEnrollment) => {
+      try {
+        return {
+          id: createId({ prefix: "msg_" }),
+          programId: programEnrollment.programId,
+          partnerId: programEnrollment.partnerId,
+          senderUserId: campaign.userId,
+          type: "campaign",
+          subject: campaign.subject,
+          text: renderCampaignEmailMarkdown({
+            content: campaign.bodyJson as unknown as TiptapNode,
+            variables: {
+              PartnerName: programEnrollment.partner.name,
+              PartnerEmail: programEnrollment.partner.email,
+            },
+          }),
+        };
+      } catch (error) {
+        console.error(
+          `Failed to render markdown for partner ${programEnrollment.partnerId}:`,
+          error
+        );
+        return null;
+      }
+    })
+    .filter((data): data is NonNullable<typeof data> => data !== null),
-    id: createId({ prefix: "msg_" }),
-    programId: programEnrollment.programId,
-    partnerId: programEnrollment.partnerId,
-    senderUserId: campaign.userId,
-    type: "campaign",
-    subject: campaign.subject,
-    text: renderCampaignEmailMarkdown({
-      content: campaign.bodyJson as unknown as TiptapNode,
-      variables: {
-        PartnerName: programEnrollment.partner.name,
-        PartnerEmail: programEnrollment.partner.email,
-      },
-    }),
-  })),
 });

171-177: Add error handling for HTML generation.

renderCampaignEmailHTML can throw if campaign.bodyJson is malformed. Since messages are already created at line 131, a failure here leaves the system in an inconsistent state: messages exist in the database but emails were never sent to partners.

The as unknown as TiptapNode cast bypasses type safety, increasing the likelihood of runtime errors.

Wrap HTML generation in error handling to prevent inconsistent state:

+function safeRenderCampaignHTML(
+  bodyJson: unknown,
+  variables: { PartnerName: string; PartnerEmail: string }
+): string {
+  try {
+    return renderCampaignEmailHTML({
+      content: bodyJson as unknown as TiptapNode,
+      variables,
+    });
+  } catch (error) {
+    console.error('Failed to render campaign HTML:', error);
+    return '<p>Unable to render campaign content</p>';
+  }
+}
+
 const { data } = await sendBatchEmail(
   partnerUsers.map((partnerUser) => ({
     variant: "notifications",
     to: partnerUser.email!,
     subject: campaign.subject,
     react: CampaignEmail({
       program: {
         name: program.name,
         slug: program.slug,
         logo: program.logo,
         messagingEnabledAt: program.messagingEnabledAt,
       },
       campaign: {
         type: campaign.type,
         subject: campaign.subject,
-        body: renderCampaignEmailHTML({
-          content: campaign.bodyJson as unknown as TiptapNode,
-          variables: {
-            PartnerName: partnerUser.partner.name,
-            PartnerEmail: partnerUser.partner.email,
-          },
-        }),
+        body: safeRenderCampaignHTML(
+          campaign.bodyJson,
+          {
+            PartnerName: partnerUser.partner.name,
+            PartnerEmail: partnerUser.partner.email,
+          }
+        ),
       },
     }),
     tags: [{ name: "type", value: "notification-email" }],
     headers: {
       "Idempotency-Key": `${campaign.id}-${partnerUser.id}`,
     },
   })),
 );
🧹 Nitpick comments (1)
apps/web/lib/zod/schemas/campaigns.ts (1)

21-58: Unify labels with central workflow labels to avoid drift

Labels here are lowercase and can diverge from WORKFLOW_ATTRIBUTE_LABELS (Title Case). Source labels from the single mapping to keep UI consistent.

Apply:

+import { WORKFLOW_ATTRIBUTE_LABELS } from "./workflows";
@@
   totalClicks: {
-    label: "clicks",
+    label: WORKFLOW_ATTRIBUTE_LABELS.totalClicks,
     inputType: "number",
   },
   totalLeads: {
-    label: "leads",
+    label: WORKFLOW_ATTRIBUTE_LABELS.totalLeads,
     inputType: "number",
   },
   totalConversions: {
-    label: "conversions",
+    label: WORKFLOW_ATTRIBUTE_LABELS.totalConversions,
     inputType: "number",
   },
   totalSales: {
-    label: "sales",
+    label: WORKFLOW_ATTRIBUTE_LABELS.totalSales,
     inputType: "number",
   },
   totalSaleAmount: {
-    label: "revenue",
+    label: WORKFLOW_ATTRIBUTE_LABELS.totalSaleAmount,
     inputType: "currency",
   },
   totalCommissions: {
-    label: "commissions",
+    label: WORKFLOW_ATTRIBUTE_LABELS.totalCommissions,
     inputType: "currency",
   },
   partnerEnrolledDays: {
-    label: "enrollment duration",
+    label: WORKFLOW_ATTRIBUTE_LABELS.partnerEnrolledDays,
     inputType: "dropdown",
     dropdownValues: [1, 3, 7, 14, 30],
   },
   partnerJoined: {
-    label: "joins the program",
+    label: WORKFLOW_ATTRIBUTE_LABELS.partnerJoined,
     inputType: "none",
   },
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 70fff4e and b2d195c.

📒 Files selected for processing (7)
  • apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (6 hunks)
  • apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (5 hunks)
  • apps/web/lib/api/workflows/execute-workflows.ts (4 hunks)
  • apps/web/lib/api/workflows/utils.ts (1 hunks)
  • apps/web/lib/zod/schemas/campaigns.ts (2 hunks)
  • apps/web/lib/zod/schemas/workflows.ts (2 hunks)
  • packages/prisma/schema/workflow.prisma (1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-08-25T17:33:45.072Z
Learnt from: devkiran
PR: dubinc/dub#2736
File: apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts:12-12
Timestamp: 2025-08-25T17:33:45.072Z
Learning: The WorkflowTrigger enum in packages/prisma/schema/workflow.prisma contains three values: leadRecorded, saleRecorded, and commissionEarned. All three are properly used throughout the codebase.

Applied to files:

  • packages/prisma/schema/workflow.prisma
🧬 Code graph analysis (6)
apps/web/lib/api/workflows/utils.ts (2)
apps/web/lib/types.ts (1)
  • WorkflowConditionAttribute (625-625)
apps/web/lib/api/workflows/parse-workflow-config.ts (1)
  • parseWorkflowConfig (8-30)
apps/web/lib/zod/schemas/workflows.ts (2)
apps/web/lib/types.ts (1)
  • WorkflowConditionAttribute (625-625)
packages/prisma/client.ts (1)
  • WorkflowTrigger (32-32)
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (6)
apps/web/lib/api/workflows/render-campaign-email-markdown.ts (1)
  • renderCampaignEmailMarkdown (9-55)
apps/web/lib/types.ts (3)
  • TiptapNode (674-680)
  • WorkflowCondition (623-623)
  • WorkflowConditionAttribute (625-625)
packages/email/src/index.ts (1)
  • sendBatchEmail (31-67)
packages/email/src/templates/campaign-email.tsx (1)
  • CampaignEmail (15-111)
apps/web/lib/api/workflows/render-campaign-email-html.ts (1)
  • renderCampaignEmailHTML (10-69)
apps/web/lib/api/workflows/execute-workflows.ts (1)
  • evaluateWorkflowCondition (101-130)
apps/web/lib/api/workflows/execute-workflows.ts (3)
apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts (1)
  • executeCompleteBountyWorkflow (13-234)
apps/web/lib/api/workflows/utils.ts (1)
  • isScheduledWorkflow (11-21)
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (1)
  • executeSendCampaignWorkflow (21-209)
apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (6)
apps/web/lib/api/campaigns/get-campaign-or-throw.ts (1)
  • getCampaignOrThrow (4-40)
apps/web/lib/zod/schemas/campaigns.ts (2)
  • updateCampaignSchema (92-108)
  • CampaignSchema (60-71)
apps/web/lib/api/utils.ts (1)
  • parseRequestBody (9-20)
apps/web/lib/api/groups/throw-if-invalid-group-ids.ts (1)
  • throwIfInvalidGroupIds (5-37)
apps/web/lib/zod/schemas/workflows.ts (2)
  • WORKFLOW_ATTRIBUTE_TRIGGER (34-46)
  • WORKFLOW_SCHEDULES (50-53)
apps/web/lib/api/workflows/utils.ts (1)
  • isScheduledWorkflow (11-21)
apps/web/lib/zod/schemas/campaigns.ts (5)
apps/web/lib/types.ts (2)
  • WorkflowAttribute (688-688)
  • CampaignWorkflowAttributeConfig (682-686)
apps/web/lib/zod/schemas/groups.ts (1)
  • GroupSchema (51-64)
apps/web/lib/zod/schemas/workflows.ts (1)
  • workflowConditionSchema (77-81)
apps/web/lib/zod/schemas/misc.ts (1)
  • getPaginationQuerySchema (32-55)
apps/web/lib/zod/schemas/partners.ts (1)
  • EnrolledPartnerSchema (337-402)
⏰ 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)
packages/prisma/schema/workflow.prisma (1)

2-4: Verified: new enum values are properly integrated throughout the codebase.

The script output confirms that both partnerEnrolled and clickRecorded:

  • Are mapped in WORKFLOW_ATTRIBUTE_TRIGGER (lines 44–45 in workflows.ts)
  • Have schedules defined in WORKFLOW_SCHEDULES (lines 51–52)
  • Are referenced in webhook handlers and workflow execution routes
  • Are fully integrated with no missing mappings or validators

The additions are sound and ready for use.

apps/web/lib/api/workflows/execute-workflows.ts (2)

31-32: Filtering disabled workflows LGTM


87-96: SendCampaign: skip scheduled workflows LGTM

Deferring scheduled workflows via isScheduledWorkflow avoids double execution.

apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (8)

1-19: LGTM! Imports are comprehensive.

All imports are used appropriately throughout the workflow execution logic.


21-27: LGTM! Clean parameter destructuring.

The function signature properly accepts workflow and optional context parameters with appropriate types.


58-61: LGTM! Good safeguard added.

The status check prevents inactive campaigns from being sent, which is an appropriate business rule.


63-73: LGTM! Enrollment fetching refactored.

The extraction of enrollment logic into a helper function improves code organization and reusability.


75-109: LGTM! Robust deduplication logic.

The implementation correctly prevents duplicate campaign emails by querying previously sent emails and filtering them out using a Set for efficient lookups.


111-128: LGTM! Efficient batch processing setup.

The chunking strategy (100 enrollments per chunk) and flattening of partner users with email filtering is implemented correctly.


186-208: LGTM! Notification tracking implemented correctly.

The conditional creation of notification emails only when data exists ensures tracking records are only created for successfully sent emails.


211-309: Clarify the enrollment limit and consider fetching strategy.

The take: 50 limit at line 307 restricts bulk enrollment queries to 50 partners per workflow execution. This may be intentional for rate limiting, but it means larger partner lists require multiple workflow runs.

Additionally, there's an asymmetry between the single-partner and bulk paths:

  • Single partner: Fetches full link stats, evaluates condition client-side with evaluateWorkflowCondition
  • Bulk: Uses SQL-level filtering via buildEnrollmentWhere, no client-side condition evaluation

This could lead to different behavior depending on whether a specific partnerId is provided in the context.

Consider whether the 50-partner limit is sufficient for your use case. If campaigns need to reach more partners, you may need:

  1. Pagination or cursor-based fetching
  2. Multiple workflow executions scheduled over time
  3. A higher limit (with careful consideration of email sending rate limits)

Also verify that buildEnrollmentWhere correctly handles all workflow condition types that evaluateWorkflowCondition supports.

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/zod/schemas/workflows.ts (1)

84-88: Schema still doesn't accept null values for value field.

The value field remains z.number() without .nullable(), but the codebase assigns null as any (referenced in past reviews). This type mismatch bypasses TypeScript's type safety and can cause runtime errors.

Apply this diff to allow null values:

 export const workflowConditionSchema = z.object({
   attribute: z.enum(WORKFLOW_ATTRIBUTES),
   operator: z.enum(WORKFLOW_COMPARISON_OPERATORS).default("gte"),
-  value: z.number(),
+  value: z.number().nullable(),
 });
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (2)

142-148: Critical: Add error handling for markdown rendering failures.

The renderCampaignEmailMarkdown call lacks error handling. If campaign.bodyJson contains invalid structure, rendering will throw, causing message creation to fail for all partners in the chunk (up to 100). Additionally, the as unknown as TiptapNode cast bypasses type validation entirely.

Wrap the rendering in error handling:

+function safeRenderMarkdown(
+  bodyJson: any,
+  variables: { PartnerName: string; PartnerEmail: string }
+): string {
+  try {
+    return renderCampaignEmailMarkdown({
+      content: bodyJson as unknown as TiptapNode,
+      variables,
+    });
+  } catch (error) {
+    console.error('Failed to render campaign markdown:', error);
+    return `Campaign: ${variables.PartnerName}`;
+  }
+}
+
 const messages = await prisma.message.createMany({
   data: programEnrollmentChunk.map((programEnrollment) => ({
     id: createId({ prefix: "msg_" }),
     programId: programEnrollment.programId,
     partnerId: programEnrollment.partnerId,
     senderUserId: campaign.userId,
     type: "campaign",
     subject: campaign.subject,
-    text: renderCampaignEmailMarkdown({
-      content: campaign.bodyJson as unknown as TiptapNode,
-      variables: {
-        PartnerName: programEnrollment.partner.name,
-        PartnerEmail: programEnrollment.partner.email,
-      },
-    }),
+    text: safeRenderMarkdown(campaign.bodyJson, {
+      PartnerName: programEnrollment.partner.name,
+      PartnerEmail: programEnrollment.partner.email,
+    }),
   })),
 });

174-180: Critical: Add error handling for HTML generation failures.

The renderCampaignEmailHTML call lacks error handling. If campaign.bodyJson contains invalid structure, HTML generation will throw, aborting email delivery for all partners in the chunk. Since messages are already created at line 134, this leaves the system in an inconsistent state—messages exist but emails were never sent. The as unknown as TiptapNode cast bypasses type safety.

Wrap the rendering in error handling:

+function safeRenderHTML(
+  bodyJson: any,
+  variables: { PartnerName: string; PartnerEmail: string }
+): string {
+  try {
+    return renderCampaignEmailHTML({
+      content: bodyJson as unknown as TiptapNode,
+      variables,
+    });
+  } catch (error) {
+    console.error('Failed to render campaign HTML:', error);
+    return `<p>Campaign content for ${variables.PartnerName}</p>`;
+  }
+}
+
 const { data } = await sendBatchEmail(
   partnerUsers.map((partnerUser) => ({
     variant: "notifications",
     to: partnerUser.email!,
     subject: campaign.subject,
     react: CampaignEmail({
       program: {
         name: program.name,
         slug: program.slug,
         logo: program.logo,
         messagingEnabledAt: program.messagingEnabledAt,
       },
       campaign: {
         type: campaign.type,
         subject: campaign.subject,
-        body: renderCampaignEmailHTML({
-          content: campaign.bodyJson as unknown as TiptapNode,
-          variables: {
-            PartnerName: partnerUser.partner.name,
-            PartnerEmail: partnerUser.partner.email,
-          },
-        }),
+        body: safeRenderHTML(campaign.bodyJson, {
+          PartnerName: partnerUser.partner.name,
+          PartnerEmail: partnerUser.partner.email,
+        }),
       },
     }),
     tags: [{ name: "type", value: "notification-email" }],
     headers: {
       "Idempotency-Key": `${campaign.id}-${partnerUser.id}`,
     },
   })),
 );
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b2d195c and f7bdda8.

📒 Files selected for processing (3)
  • apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (5 hunks)
  • apps/web/lib/api/workflows/utils.ts (1 hunks)
  • apps/web/lib/zod/schemas/workflows.ts (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
apps/web/lib/api/workflows/utils.ts (3)
apps/web/lib/types.ts (1)
  • WorkflowConditionAttribute (625-625)
apps/web/lib/api/workflows/parse-workflow-config.ts (1)
  • parseWorkflowConfig (8-30)
apps/web/lib/zod/schemas/workflows.ts (1)
  • SCHEDULED_WORKFLOW_TRIGGERS (50-54)
apps/web/lib/zod/schemas/workflows.ts (1)
apps/web/lib/types.ts (1)
  • WorkflowConditionAttribute (625-625)
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (6)
apps/web/lib/api/workflows/render-campaign-email-markdown.ts (1)
  • renderCampaignEmailMarkdown (9-55)
apps/web/lib/types.ts (3)
  • TiptapNode (674-680)
  • WorkflowCondition (623-623)
  • WorkflowConditionAttribute (625-625)
packages/email/src/index.ts (1)
  • sendBatchEmail (31-67)
packages/email/src/templates/campaign-email.tsx (1)
  • CampaignEmail (15-111)
apps/web/lib/api/workflows/render-campaign-email-html.ts (1)
  • renderCampaignEmailHTML (10-69)
apps/web/lib/api/workflows/execute-workflows.ts (1)
  • evaluateWorkflowCondition (101-130)
⏰ 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 (5)
apps/web/lib/api/workflows/utils.ts (1)

12-20: Past critical issue has been resolved.

The implementation now correctly derives scheduled status from SCHEDULED_WORKFLOW_TRIGGERS by checking the workflow's trigger, which aligns with WORKFLOW_SCHEDULES. The special case for partnerJoined (event-based) vs partnerEnrolledDays (scheduled) is appropriate, as both use the partnerEnrolled trigger but have different execution semantics.

apps/web/lib/zod/schemas/workflows.ts (1)

56-60: LGTM! Schedules are now aligned.

The WORKFLOW_SCHEDULES map now includes commissionEarned, resolving the past mismatch with scheduled detection logic in utils.

apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (3)

58-61: Good defensive check for campaign status.

The early return for inactive campaigns prevents unnecessary processing and is a sensible safeguard.


214-313: Well-structured helper function with proper flow control.

The getProgramEnrollments function handles both single-partner and batch scenarios correctly, includes proper evaluation logic, and has appropriate early returns.


315-336: Clean mapping of conditions to database filters.

The buildEnrollmentWhere function correctly maps numeric attributes to gte filters and date-based attributes to createdAt filters using subDays.

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: 2

♻️ Duplicate comments (2)
packages/prisma/schema/workflow.prisma (1)

2-4: Don't reorder existing Postgres enums

Postgres cannot reorder enum labels; Prisma migrations will only append. Moving commissionEarned and inserting new labels at the top will leave the database in the old order, causing drift/noisy diffs. Append new triggers after the existing ones instead of reordering them.

 enum WorkflowTrigger {
-  partnerEnrolled // scheduled
-  clickRecorded // scheduled
-  commissionEarned
-  leadRecorded
-  saleRecorded
+  leadRecorded
+  saleRecorded
+  commissionEarned
+  partnerEnrolled // scheduled
+  clickRecorded // scheduled
 }

Based on learnings.

apps/web/lib/zod/schemas/workflows.ts (1)

67-69: Allow workflow condition values to be nullable

partnerJoined is configured with inputType: "none", so clients won't send a numeric threshold. Keeping value: z.number() forces callers to coerce or cast null, which will either fail validation or rely on as any. Please permit null (and optionally default to it) so non-quantitative triggers can be represented safely.

 export const workflowConditionSchema = z.object({
   attribute: z.enum(WORKFLOW_ATTRIBUTES),
   operator: z.enum(WORKFLOW_COMPARISON_OPERATORS).default("gte"),
-  value: z.number(),
+  value: z.number().nullable(),
 });

If downstream logic assumes numbers, guard against null before comparing.

🧹 Nitpick comments (1)
apps/web/lib/api/bounties/performance-bounty-scope-attributes.ts (1)

1-9: Consider removing redundant as const assertion.

The as const assertion on line 9 is redundant when combined with the explicit Record<..., string> type annotation. The explicit type takes precedence, so the values remain typed as string rather than literal types like "Leads".

If literal types are desired for stricter type safety, consider removing the explicit type annotation:

-export const PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES: Record<
-  "totalLeads" | "totalConversions" | "totalSaleAmount" | "totalCommissions",
-  string
-> = {
+export const PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES = {
   totalLeads: "Leads",
   totalConversions: "Conversions",
   totalSaleAmount: "Revenue",
   totalCommissions: "Commissions",
 } as const;

If the current string typing is intentional, remove the as const to avoid confusion:

 export const PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES: Record<
   "totalLeads" | "totalConversions" | "totalSaleAmount" | "totalCommissions",
   string
 > = {
   totalLeads: "Leads",
   totalConversions: "Conversions",
   totalSaleAmount: "Revenue",
   totalCommissions: "Commissions",
-} as const;
+};
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 55c33ff and 6d15732.

📒 Files selected for processing (10)
  • apps/web/app/(ee)/api/bounties/[bountyId]/route.ts (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submissions-table.tsx (2 hunks)
  • apps/web/lib/api/bounties/generate-performance-bounty-name.ts (2 hunks)
  • apps/web/lib/api/bounties/performance-bounty-scope-attributes.ts (1 hunks)
  • apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (5 hunks)
  • apps/web/lib/zod/schemas/campaigns.ts (2 hunks)
  • apps/web/lib/zod/schemas/workflows.ts (2 hunks)
  • apps/web/ui/partners/bounties/bounty-logic.tsx (2 hunks)
  • apps/web/ui/partners/bounties/bounty-performance.tsx (2 hunks)
  • packages/prisma/schema/workflow.prisma (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/ui/partners/bounties/bounty-logic.tsx
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-08-25T17:33:45.072Z
Learnt from: devkiran
PR: dubinc/dub#2736
File: apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts:12-12
Timestamp: 2025-08-25T17:33:45.072Z
Learning: The WorkflowTrigger enum in packages/prisma/schema/workflow.prisma contains three values: leadRecorded, saleRecorded, and commissionEarned. All three are properly used throughout the codebase.

Applied to files:

  • packages/prisma/schema/workflow.prisma
🧬 Code graph analysis (7)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submissions-table.tsx (1)
apps/web/lib/api/bounties/performance-bounty-scope-attributes.ts (1)
  • PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES (1-9)
apps/web/app/(ee)/api/bounties/[bountyId]/route.ts (1)
apps/web/lib/api/bounties/performance-bounty-scope-attributes.ts (1)
  • PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES (1-9)
apps/web/lib/api/bounties/generate-performance-bounty-name.ts (1)
apps/web/lib/api/bounties/performance-bounty-scope-attributes.ts (1)
  • PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES (1-9)
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (4)
apps/web/lib/api/workflows/render-campaign-email-markdown.ts (1)
  • renderCampaignEmailMarkdown (9-55)
apps/web/lib/types.ts (3)
  • TiptapNode (674-680)
  • WorkflowCondition (623-623)
  • WorkflowConditionAttribute (625-625)
apps/web/lib/api/workflows/render-campaign-email-html.ts (1)
  • renderCampaignEmailHTML (10-69)
apps/web/lib/api/workflows/execute-workflows.ts (1)
  • evaluateWorkflowCondition (101-130)
apps/web/ui/partners/bounties/bounty-performance.tsx (1)
apps/web/lib/api/bounties/performance-bounty-scope-attributes.ts (1)
  • PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES (1-9)
apps/web/lib/zod/schemas/workflows.ts (2)
apps/web/lib/types.ts (1)
  • WorkflowConditionAttribute (625-625)
packages/prisma/client.ts (1)
  • WorkflowTrigger (32-32)
apps/web/lib/zod/schemas/campaigns.ts (5)
apps/web/lib/types.ts (2)
  • WorkflowAttribute (688-688)
  • CampaignWorkflowAttributeConfig (682-686)
apps/web/lib/zod/schemas/groups.ts (1)
  • GroupSchema (51-64)
apps/web/lib/zod/schemas/workflows.ts (1)
  • workflowConditionSchema (66-70)
apps/web/lib/zod/schemas/misc.ts (1)
  • getPaginationQuerySchema (32-55)
apps/web/lib/zod/schemas/partners.ts (1)
  • EnrolledPartnerSchema (337-402)
⏰ 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/ui/partners/bounties/bounty-performance.tsx (1)

1-1: LGTM!

The import and usage of PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES is correct, providing a more specific and type-safe mapping for bounty performance attributes.

Also applies to: 28-31

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submissions-table.tsx (1)

3-3: LGTM!

The replacement of WORKFLOW_ATTRIBUTE_LABELS with PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES ensures consistent, bounty-specific labeling across the UI.

Also applies to: 79-81

apps/web/app/(ee)/api/bounties/[bountyId]/route.ts (1)

4-4: LGTM!

The error message now correctly references the bounty-specific attribute labels, improving clarity for users encountering this validation error.

Also applies to: 127-127

apps/web/lib/api/bounties/generate-performance-bounty-name.ts (1)

4-4: LGTM!

The attribute label lookup is now correctly sourced from the bounty-specific constant, ensuring consistency across the application.

Also applies to: 14-15

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

🧹 Nitpick comments (2)
apps/web/lib/api/partners/bulk-delete-partners.ts (2)

26-26: Fix typo: "finalParterIds" should be "finalPartnerIds".

The variable name contains a typo ("Parter" instead of "Partner"). While this is pre-existing code, fixing it now improves maintainability, especially since the new deletion logic uses this variable.

Apply this diff to fix the typo:

-  const finalParterIds = partners.map((partner) => partner.id);
+  const finalPartnerIds = partners.map((partner) => partner.id);

Then update all references (lines 55, 63, 71, 79):

   await prisma.commission.deleteMany({
     where: {
       partnerId: {
-        in: finalParterIds,
+        in: finalPartnerIds,
       },
     },
   });

   await prisma.payout.deleteMany({
     where: {
       partnerId: {
-        in: finalParterIds,
+        in: finalPartnerIds,
       },
     },
   });

   await prisma.notificationEmail.deleteMany({
     where: {
       partnerId: {
-        in: finalParterIds,
+        in: finalPartnerIds,
       },
     },
   });

   await prisma.partner.deleteMany({
     where: {
       id: {
-        in: finalParterIds,
+        in: finalPartnerIds,
       },
     },
   });

Also applies to: 70-71


4-4: Update comment to include notificationEmail.

The comment lists entities deleted during bulk partner deletion but doesn't mention notificationEmail. Update it to reflect the current implementation.

Apply this diff:

-// bulk delete multiple partners and all associated links, customers, payouts, and commissions
+// bulk delete multiple partners and all associated links, customers, payouts, commissions, and notification emails
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6d15732 and c12b69e.

📒 Files selected for processing (3)
  • apps/web/lib/api/partners/bulk-delete-partners.ts (1 hunks)
  • apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (5 hunks)
  • apps/web/lib/api/workflows/utils.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/lib/api/workflows/utils.ts
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (4)
apps/web/lib/api/workflows/render-campaign-email-markdown.ts (1)
  • renderCampaignEmailMarkdown (9-55)
apps/web/lib/types.ts (3)
  • TiptapNode (674-680)
  • WorkflowCondition (623-623)
  • WorkflowConditionAttribute (625-625)
apps/web/lib/api/workflows/render-campaign-email-html.ts (1)
  • renderCampaignEmailHTML (10-69)
apps/web/lib/api/workflows/execute-workflows.ts (1)
  • evaluateWorkflowCondition (101-130)
⏰ 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/partners/bulk-delete-partners.ts (1)

68-74: Verification passed: notificationEmail schema correctly includes partnerId as a foreign key.

The NotificationEmail model (packages/prisma/schema/notification.prisma, line 15) defines partnerId as a String field with a proper foreign key relation to Partner with cascading delete enabled (line 25). The deletion logic correctly uses this field and maintains referential integrity by deleting related records before the parent.

apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (3)

58-61: LGTM! Good defensive programming.

The campaign status check prevents workflow execution for inactive campaigns, avoiding unnecessary processing and potential confusion.


217-227: LGTM! Clean code organization.

The includePartnerUsers helper improves reusability and keeps the enrollment queries DRY.


229-339: Good refactoring, but past critical issues remain.

The getProgramEnrollments helper is a solid improvement that consolidates enrollment retrieval logic. However, past reviews identified critical issues in this function that must be addressed:

  1. Lines 249-266: The findUnique call with status and groupId filters will throw a Prisma error at runtime. Switch to findFirst as recommended in previous reviews.
  2. Lines 272-301: The partnerEnrolledDays attribute is not populated in the context map, causing condition evaluation to fail for workflows using this attribute. Add it alongside partnerJoined as suggested in previous reviews.

Please address these issues before merging.

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: 2

♻️ Duplicate comments (5)
apps/web/ui/layout/page-content/page-content-with-side-panel.tsx (1)

80-82: Restore visible scrollbars when enabling individual scrolling.

scrollbar-hide strips the visual scrollbar, leaving keyboard/mouse users without any cue that the pane scrolls—exactly the accessibility problem raised in the earlier review. Swap it for a styled scrollbar (or only hide it on coarse pointers) so the scroll affordance remains visible.

-          individualScrolling && "scrollbar-hide min-h-0 overflow-y-auto",
+          individualScrolling &&
+            "scrollbar-thin scrollbar-thumb-neutral-300 scrollbar-track-transparent min-h-0 overflow-y-auto",
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-controls.tsx (2)

139-141: Edge case: Empty groupIds array passes validation.

The current validation groupIds === undefined allows an empty array [] to bypass validation. If sending a campaign to zero groups is invalid, strengthen the check to also reject empty arrays.

Apply this diff:

-    if (groupIds === undefined) {
+    if (!groupIds || groupIds.length === 0) {
       return "Please select the groups you want to send this campaign to.";
     }

271-280: Missing popover close before duplication.

Unlike other menu items (lines 247, 264, 288), the Duplicate action doesn't call setOpenPopover(false) before executing, leaving the popover visually open during navigation to the duplicated campaign.

Apply this diff:

                 <MenuItem
                   as={Command.Item}
                   icon={isDuplicatingCampaign ? LoadingCircle : Duplicate}
                   disabled={isUpdatingCampaign || isDuplicatingCampaign}
                   onSelect={() => {
+                    setOpenPopover(false);
                     handleCampaignDuplication();
                   }}
                 >
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx (2)

323-329: Guard against missing workspaceId before upload

workspaceId! will throw if the hook hasn’t resolved yet (e.g., first render), so image uploads can crash instead of showing a friendly error. Add a defensive check and return early.

                   uploadImage={async (file) => {
                     try {
+                      if (!workspaceId) {
+                        toast.error("Workspace not found");
+                        return null;
+                      }
+
                       const result = await executeImageUpload({
-                        workspaceId: workspaceId!,
+                        workspaceId,
                       });

333-339: Remove forbidden Content-Length header

Browsers ignore or reject manual Content-Length headers, and signed PUT URLs can fail when extra headers are sent. Let the browser compute it.

                       const uploadResponse = await fetch(signedUrl, {
                         method: "PUT",
                         body: file,
                         headers: {
                           "Content-Type": file.type,
-                          "Content-Length": file.size.toString(),
                         },
                       });
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c12b69e and b37c50f.

📒 Files selected for processing (4)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-controls.tsx (1 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor-skeleton.tsx (1 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx (1 hunks)
  • apps/web/ui/layout/page-content/page-content-with-side-panel.tsx (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (4)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-controls.tsx (8)
apps/web/lib/types.ts (2)
  • Campaign (661-661)
  • UpdateCampaignFormData (663-663)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-form-context.tsx (1)
  • useCampaignFormContext (4-5)
apps/web/lib/swr/use-api-mutation.ts (1)
  • useApiMutation (33-108)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/delete-campaign-modal.tsx (1)
  • useDeleteCampaignModal (88-110)
apps/web/ui/modals/confirm-modal.tsx (1)
  • useConfirmModal (107-120)
packages/prisma/client.ts (1)
  • CampaignStatus (8-8)
packages/ui/src/icons/nucleo/media-pause.tsx (1)
  • MediaPause (3-29)
packages/ui/src/icons/nucleo/media-play.tsx (1)
  • MediaPlay (3-22)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor-skeleton.tsx (1)
apps/web/ui/layout/page-content/index.tsx (1)
  • PageContent (10-39)
apps/web/ui/layout/page-content/page-content-with-side-panel.tsx (1)
apps/web/ui/layout/page-content/page-content-header.tsx (1)
  • PageContentHeaderProps (9-15)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx (5)
apps/web/lib/types.ts (2)
  • Campaign (661-661)
  • UpdateCampaignFormData (663-663)
apps/web/lib/swr/use-api-mutation.ts (1)
  • useApiMutation (33-108)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/utils.ts (1)
  • isValidTriggerCondition (4-36)
packages/ui/src/rich-text-area/index.tsx (1)
  • RichTextArea (35-198)
apps/web/lib/zod/schemas/campaigns.ts (1)
  • EMAIL_TEMPLATE_VARIABLES (12-15)
⏰ 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/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-controls.tsx (5)

1-56: LGTM: Component setup is well-structured.

The imports, interface definition, and hook initialization follow React best practices. The use of custom hooks for modals and API mutations keeps the component clean and maintainable.


57-122: LGTM: Confirmation modals correctly capture latest form state.

The modals properly use getValues() within their onConfirm callbacks, ensuring that the latest form data (including bodyJson) is captured at invocation time rather than closing over stale values. This correctly addresses the closure concern from the previous review.


154-171: LGTM: Callback dependencies are correct.

The updateCampaign callback doesn't close over bodyJson directly, and getValues is stable from React Hook Form, so the dependency array [makeRequest, campaign.id] is appropriate. Form values are properly captured via getValues() at the call sites.


173-181: LGTM: Duplication logic is correct.

The duplication handler properly creates a copy via the API, navigates to the new campaign, and invalidates the cache. The implementation is clean and follows expected patterns.


183-215: LGTM: Action button logic handles campaign states correctly.

The status-based button configuration is clean and comprehensive, correctly mapping draft → Publish, active → Pause, and paused → Resume. The default case safely returns null for unexpected states.

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

1-6: LGTM: Clean imports and constant definition.

The imports are appropriate and the labelClassName constant is a good practice for maintaining consistent styling across multiple elements.

@steven-tey steven-tey merged commit dd984c8 into main Oct 17, 2025
9 of 10 checks passed
@steven-tey steven-tey deleted the email-campaigns branch October 17, 2025 01:32
@coderabbitai coderabbitai bot mentioned this pull request Nov 24, 2025
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.

4 participants