+
Skip to content

Conversation

TWilson023
Copy link
Collaborator

@TWilson023 TWilson023 commented Aug 5, 2025

Summary by CodeRabbit

  • New Features

    • Email Campaigns (feature-gated): campaign list, create draft transactional campaign, duplicate, publish/pause/resume, delete.
    • Campaign Editor: rich-text editor with variables, image upload, autosave/manual save, CMD/CTRL+S shortcut, transactional trigger configuration.
    • Metrics & Events: delivery/open/bounce stats, counts, filters, search, pagination, “View all” modal, campaign summary.
    • Send preview emails to test recipients.
  • Improvements

    • Sticky action bar, editor skeletons, sidebar shows Email Campaigns when enabled, improved badges, selectors, and layout scrolling option.

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 11, 2025 2:00pm

Copy link
Contributor

coderabbitai bot commented Aug 5, 2025

Walkthrough

Adds an "Email campaigns" feature: new Prisma fields/relations, Zod schemas/types, workspace-gated API routes and helpers, TipTap-based rich-text editor and HTML generator, many UI pages/components/hooks/modals, feature-flag plumbing, and new icons/utilities.

Changes

Cohort / File(s) Summary
Campaign API routes
apps/web/app/(ee)/api/campaigns/*
Added/updated handlers for list/POST, single campaign GET/PATCH/DELETE, duplicate, events, events count, summary, and count; all workspace-scoped and plan/feature-gated, using Prisma and new helpers.
Campaign API helpers & actions
apps/web/lib/api/campaigns/*, apps/web/lib/actions/*, apps/web/lib/api/workflows/*
New helpers: getCampaigns, getCampaignSummary, getCampaignEvents, getCampaignOrThrow, DEFAULT_CAMPAIGN_BODY, generateCampaignEmailHTML, tiptapToPlainText. New actions: sendCampaignPreviewEmail, uploadEmailImageAction. Removed validateCampaign. Workflow execution updated to use generated HTML/CampaignEmail.
Zod schemas & types
apps/web/lib/zod/schemas/*, apps/web/lib/types.ts
New/changed schemas: campaign schemas (Campaign, CampaignList, events/summary/count), EMAIL_TEMPLATE_VARIABLES, CAMPAIGN_WORKFLOW_ATTRIBUTES/labels; renamed WORKFLOW_ATTRIBUTE_TRIGGER_MAPWORKFLOW_ATTRIBUTE_TRIGGER; workflowCondition operator defaulted to "gte"; exported TS types and BetaFeatures extended with emailCampaigns.
Prisma schema & client
packages/prisma/schema/*.prisma, packages/prisma/client.ts
Added bodyJson to Campaign, made body optional, added scheduled status; NotificationEmail gains partner relation + index; Partner gains notificationEmails; re-exported CampaignStatus and CampaignType.
Campaign UI (dashboard)
apps/web/app/app.dub.co/.../program/campaigns/*
New pages/components/hooks: campaigns list page/layout gating, CampaignsTable, CreateCampaignButton, ProgramCampaignPageClient/page, CampaignEditor + skeleton, CampaignControls, CampaignActionBar, CampaignMetrics, CampaignEvents (+modal/columns), CampaignGroupsSelector, TransactionalCampaignLogic, send preview & delete modals/hooks, useCampaign, useCampaignsCount, useCampaignsFilters, campaign form context.
Rich-text editor & UI package
packages/ui/src/rich-text-area/*, packages/ui/src/index.tsx, apps/web/package.json, packages/ui/package.json
Added TipTap-based RichTextArea, toolbar, suggestions/mentions, image upload; exported via UI index; added TipTap and sanitize-html deps.
UI layout, navigation & feature flags
apps/web/ui/layout/*, apps/web/lib/edge-config/get-feature-flags.ts, apps/web/app/.../layout.tsx
PageContentWithSidePanel supports individualScrolling; sidebar/nav threaded with emailCampaignsEnabled; edge-config now tracks emailCampaigns; page layout gates on flag.
UI utilities & smaller components
apps/web/ui/partners/*, apps/web/ui/partners/groups/*, packages/ui/src/toggle-group.tsx, packages/utils/src/functions/urls.ts
Partner/group selectors, GroupsMultiSelect className prop, ToggleGroup layout?: boolean, buildUrl utility, and related UI tweaks.
Icons
packages/ui/src/icons/nucleo/*, packages/ui/src/icons/nucleo/index.ts
Added multiple SVG icon components and re-exported them from icons index.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant U as User (Dashboard)
  participant FE as Next.js API
  participant WS as withWorkspace
  participant DB as Prisma
  participant WF as Workflow utils

  U->>FE: POST /api/campaigns { type: "transactional" }
  FE->>WS: authorize + resolve programId
  WS-->>FE: context
  FE->>DB: tx: create campaign (draft), optional workflow row
  FE-->>U: { id }
Loading
sequenceDiagram
  autonumber
  participant U as User (Editor)
  participant AC as sendCampaignPreviewEmail action
  participant Auth as authActionClient
  participant DB as Prisma
  participant Gen as generateCampaignEmailHTML
  participant Mail as Email service

  U->>AC: send preview (campaignId, emails, subject, bodyJson)
  AC->>Auth: auth + program resolution
  par parallel
    AC->>DB: fetch Program
    AC->>DB: fetch Campaign
  end
  AC->>Gen: generateCampaignEmailHTML(bodyJson, variables)
  Gen-->>AC: sanitized HTML
  AC->>Mail: send batch email with CampaignEmail payload
  Mail-->>AC: OK
  AC-->>U: success
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • steven-tey

Poem

I nibble keys and hop with cheer,
TipTap fields and templates near,
Campaigns copy, send, and play,
Paper planes leap into the day.
Hooray — new flags and emails here! 🐇✉️

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
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.
✅ 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 campaigns” directly names the principal feature introduced throughout the changeset, covering new API routes, UI components, schemas, and database updates. It is concise, focused, and clearly signals to reviewers that the PR implements email campaign functionality.
✨ 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

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4b5d3f7 and b6cb118.

📒 Files selected for processing (3)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-events-modal.tsx (1 hunks)
  • apps/web/lib/api/campaigns/get-campaign-events.ts (1 hunks)
  • apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (3)
apps/web/lib/api/workflows/tiptap-to-text.ts (2)
  • tiptapToPlainText (110-328)
  • TiptapNode (3-9)
packages/email/src/templates/campaign-email.tsx (1)
  • CampaignEmail (15-111)
apps/web/lib/api/workflows/generate-campaign-email-html.ts (1)
  • generateCampaignEmailHTML (12-57)
apps/web/lib/api/campaigns/get-campaign-events.ts (1)
apps/web/lib/zod/schemas/campaigns.ts (2)
  • getCampaignsEventsQuerySchema (106-111)
  • campaignEventSchema (126-142)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-events-modal.tsx (3)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-events.tsx (2)
  • EventStatus (14-14)
  • CampaignEvent (16-16)
packages/utils/src/functions/urls.ts (1)
  • buildUrl (182-200)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-events-columns.tsx (1)
  • campaignEventsColumns (6-60)
⏰ 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 (1)
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (1)

160-161: LGTM!

Extracting the program reference improves code clarity and avoids repeated property access.


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

♻️ Duplicate comments (4)
apps/web/package.json (1)

44-47: Still need the TipTap React runtime.

The editor components import from @tiptap/react; without the runtime dependency the Next build will still crash (“Cannot find module '@tiptap/react'”). Please add it alongside the other TipTap packages.

     "@tiptap/extension-mention": "^3.0.9",
     "@tiptap/html": "^3.6.5",
     "@tiptap/starter-kit": "^3.0.9",
+    "@tiptap/react": "^3.0.9",
packages/email/src/templates/campaign-email.tsx (1)

40-52: Sanitize campaign.body before injecting it.

campaign.body is user-controlled HTML inserted straight into styledHtml and rendered via dangerouslySetInnerHTML, so any <script> or event handler payload survives. This reintroduces an XSS vector in outbound emails. Sanitize the body (server-safe, e.g. isomorphic-dompurify) before building styledHtml, then interpolate the sanitized string instead of the raw body.

-import {
+import DOMPurify from "isomorphic-dompurify";
+import {
@@
-  const styledHtml = `
+  const sanitizedBody = DOMPurify.sanitize(campaign.body, {
+    ALLOWED_TAGS: [
+      "a",
+      "br",
+      "em",
+      "img",
+      "li",
+      "ol",
+      "p",
+      "span",
+      "strong",
+      "ul",
+      "h1",
+      "h2",
+      "h3",
+    ],
+    ALLOWED_ATTR: ["href", "rel", "target", "class", "src", "alt", "data-type", "data-id"],
+  });
+
+  const styledHtml = `
     <div style="max-width: 100%; overflow: hidden;">
       <style>
@@
-      ${campaign.body}
+      ${sanitizedBody}
     </div>
   `;
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx (2)

267-270: Add guard for workspaceId before upload.

The non-null assertion workspaceId! is unsafe if workspaceId is undefined. This issue was previously flagged.

Apply this diff:

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

279-286: Remove Content-Length header from browser fetch.

Browsers prohibit manually setting Content-Length; it's set automatically. This issue was previously flagged.

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)

105-113: Remove unused dependencies from useCallback.

isSavingCampaign and watch are not referenced in the saveCampaign function body, causing 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 258961c and b758ccb.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (8)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx (1 hunks)
  • apps/web/lib/actions/campaigns/send-campaign-preview-email.ts (1 hunks)
  • apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (3 hunks)
  • apps/web/lib/api/workflows/generate-campaign-email-html.ts (1 hunks)
  • apps/web/package.json (3 hunks)
  • packages/email/src/react-email.d.ts (1 hunks)
  • packages/email/src/templates/campaign-email.tsx (1 hunks)
  • packages/email/tsconfig.json (2 hunks)
✅ Files skipped from review due to trivial changes (1)
  • packages/email/tsconfig.json
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/web/lib/api/workflows/execute-send-campaign-workflow.ts
  • apps/web/lib/actions/campaigns/send-campaign-preview-email.ts
  • apps/web/lib/api/workflows/generate-campaign-email-html.ts
🧰 Additional context used
🧬 Code graph analysis (2)
packages/email/src/templates/campaign-email.tsx (1)
packages/email/src/react-email.d.ts (10)
  • Html (4-4)
  • Head (5-5)
  • Preview (17-17)
  • Tailwind (18-18)
  • Body (6-6)
  • Container (7-7)
  • Section (8-8)
  • Img (13-13)
  • Heading (16-16)
  • Link (14-14)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx (6)
apps/web/lib/types.ts (2)
  • Campaign (666-666)
  • UpdateCampaignFormData (668-668)
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/campaign-status-badges.tsx (1)
  • CAMPAIGN_STATUS_BADGES (3-34)
packages/ui/src/rich-text-area/index.tsx (1)
  • RichTextArea (35-192)
apps/web/lib/zod/schemas/campaigns.ts (1)
  • EMAIL_TEMPLATE_VARIABLES (11-14)
🪛 ast-grep (0.39.6)
packages/email/src/templates/campaign-email.tsx

[warning] 90-90: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html

(react-unsafe-html-injection)

🪛 Biome (2.1.2)
packages/email/src/templates/campaign-email.tsx

[error] 91-91: Avoid passing content using the dangerouslySetInnerHTML prop.

Setting content using code can expose users to cross-site scripting (XSS) attacks

(lint/security/noDangerouslySetInnerHtml)

⏰ 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: 4

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

268-270: Add guard for undefined workspaceId.

The non-null assertion workspaceId! on line 269 is unsafe if workspaceId can be undefined. This could cause a runtime error.

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,
                     });

278-285: Remove forbidden Content-Length header.

Browsers disallow setting the Content-Length header in fetch requests; it's automatically set by the browser. Including it (line 283) can cause the request to fail or be ignored.

Apply this diff to remove the forbidden header:

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

331-333: Fix reset handler to restore original campaign content.

The onReset callback on line 332 uses getValues("bodyJson"), which returns the current (possibly edited) form state. To discard changes and revert to the original campaign content, use campaign.bodyJson instead.

Apply this diff to restore the original content:

         onReset={() => {
-          editorRef.current?.setContent(getValues("bodyJson"));
+          form.reset();
+          editorRef.current?.setContent(campaign.bodyJson);
         }}

Or, if only the editor should reset without resetting other form fields:

         onReset={() => {
-          editorRef.current?.setContent(getValues("bodyJson"));
+          editorRef.current?.setContent(campaign.bodyJson);
         }}
🧹 Nitpick comments (3)
apps/web/lib/zod/schemas/messages.ts (1)

60-60: Consider extracting a validated message text schema to reduce duplication.

The pattern .max(MAX_MESSAGE_LENGTH) is duplicated across messagePartnerSchema and messageProgramSchema. If additional message creation schemas are added (e.g., for campaigns), this duplication will increase.

Extract a validated schema:

const messageTextSchema = z.string().min(1);
const validatedMessageTextSchema = messageTextSchema.max(MAX_MESSAGE_LENGTH);

export const messagePartnerSchema = z.object({
  partnerId: z.string(),
  text: validatedMessageTextSchema,
  // ...
});

export const messageProgramSchema = z.object({
  programSlug: z.string(),
  text: validatedMessageTextSchema,
  // ...
});

Also applies to: 94-94

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

104-112: Remove unused dependencies from useCallback.

The dependency array includes isSavingCampaign (line 105) and watch (line 108), but neither is referenced in the callback body. Including them causes unnecessary re-creation of the callback.

Apply this diff to remove the unused dependencies:

   },
   [
-    isSavingCampaign,
     getValues,
     dirtyFields,
-    watch,
     makeRequest,
     campaign.id,
     reset,
   ],
 );
apps/web/lib/api/campaigns/tiptap-to-text.ts (1)

47-47: Replace deprecated trimRight() with trimEnd().

The trimRight() method is deprecated. Use trimEnd() instead, which is the standardized name with identical behavior.

Apply this diff:

-      lines.push(cur.trimRight());
+      lines.push(cur.trimEnd());
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b758ccb and 6883d00.

📒 Files selected for processing (5)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx (1 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/campaigns-table.tsx (1 hunks)
  • apps/web/lib/api/campaigns/tiptap-to-text.ts (1 hunks)
  • apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (3 hunks)
  • apps/web/lib/zod/schemas/messages.ts (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/campaigns-table.tsx
🧰 Additional context used
🧬 Code graph analysis (3)
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (3)
apps/web/lib/api/campaigns/tiptap-to-text.ts (2)
  • tiptapToPlainText (76-276)
  • TiptapNode (12-18)
packages/email/src/templates/campaign-email.tsx (1)
  • CampaignEmail (15-111)
apps/web/lib/api/workflows/generate-campaign-email-html.ts (1)
  • generateCampaignEmailHTML (12-57)
apps/web/lib/api/campaigns/tiptap-to-text.ts (1)
apps/web/lib/types.ts (1)
  • EmailTemplateVariables (677-680)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx (7)
apps/web/lib/types.ts (2)
  • Campaign (666-666)
  • UpdateCampaignFormData (668-668)
apps/web/lib/swr/use-api-mutation.ts (1)
  • useApiMutation (33-108)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/campaign-status-badges.tsx (1)
  • CAMPAIGN_STATUS_BADGES (3-34)
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 (13-83)
packages/ui/src/rich-text-area/index.tsx (1)
  • RichTextArea (35-192)
apps/web/lib/zod/schemas/campaigns.ts (1)
  • EMAIL_TEMPLATE_VARIABLES (11-14)
🪛 Biome (2.1.2)
apps/web/lib/api/campaigns/tiptap-to-text.ts

[error] 67-67: Unexpected control character in a regular expression.

Control characters are unusual and potentially incorrect inputs, so they are disallowed.

(lint/suspicious/noControlCharactersInRegex)


[error] 99-101: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

The declaration is defined in this switch clause:

Safe fix: Wrap the declaration in a block.

(lint/correctness/noSwitchDeclarations)


[error] 224-225: Duplicate case label.

The first similar label is here:

(lint/suspicious/noDuplicateCase)

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

9-9: Verify that MessageSchema.text should lack a max length constraint.

Removing .max(MAX_MESSAGE_LENGTH) from messageTextSchema means MessageSchema.text (line 17) now only enforces min(1) with no upper bound. Since MessageSchema is used by PartnerMessagesSchema and ProgramMessagesSchema (likely for reading/displaying messages), this could allow validation of messages exceeding 2000 characters if they exist in the database.

Confirm whether:

  1. This is intentional to support longer campaign messages or other message types
  2. All existing messages in the database respect the 2000-character limit
  3. The creation schemas (messagePartnerSchema, messageProgramSchema) are the only entry points for new messages

If messages should always be capped at 2000 characters, consider restoring the max constraint to the base schema:

-const messageTextSchema = z.string().min(1);
+const messageTextSchema = z.string().min(1).max(MAX_MESSAGE_LENGTH);

And remove the redundant .max() calls from lines 60 and 94.

Also applies to: 17-17

apps/web/lib/api/campaigns/tiptap-to-text.ts (1)

230-240: Guard against null/undefined variable values.

The conf.variables[id] can be null or undefined (per EmailTemplateVariables type), but is returned directly. This breaks the function's string return type contract.

Apply this diff to ensure a string is always returned:

       case "mention": {
         // Mention node: show label if present, else @id
         const id = node.attrs?.id;
         const label = node.attrs?.label;
 
         if (conf.variables && id && conf.variables[id]) {
-          return conf.variables[id];
+          return conf.variables[id] ?? "";
         }
 
         return label || (id ? `@${id}` : "@mention");
       }

Likely an incorrect or invalid review comment.

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

168-186: Email structure looks good, pending error handling fixes.

The restructuring to use the CampaignEmail react component with separated program and campaign data improves code organization and aligns with email template patterns. The props match the template's expected structure.

However, ensure the error handling issues flagged in the previous comments are addressed before merging, as they could cause workflow failures.

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 (2)
apps/web/lib/api/workflows/tiptap-to-text.ts (2)

98-117: Wrap case block to properly scope the text variable.

The text variable should be wrapped in braces to restrict its scope and prevent potential access from other case clauses.


224-225: Remove duplicate "paragraph" case.

The "paragraph" case appears twice (lines 95 and 224). The second occurrence is unreachable and should be removed.

🧹 Nitpick comments (1)
apps/web/lib/api/workflows/tiptap-to-text.ts (1)

38-55: Remove unused variable and replace deprecated method.

Line 46 declares tokenLen but never uses it. Lines 47 and 53 use the deprecated trimRight() method; prefer trimEnd() instead.

Apply this diff:

   for (const token of words) {
-    const tokenLen = token.length;
     if ((cur + token).length > width && cur.length > 0) {
-      lines.push(cur.trimRight());
+      lines.push(cur.trimEnd());
       cur = token;
     } else {
       cur += token;
     }
   }
-  if (cur.length) lines.push(cur.trimRight());
+  if (cur.length) lines.push(cur.trimEnd());
   return lines;
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6883d00 and cdee6e5.

📒 Files selected for processing (1)
  • apps/web/lib/api/workflows/tiptap-to-text.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/api/workflows/tiptap-to-text.ts (1)
apps/web/lib/types.ts (1)
  • EmailTemplateVariables (677-680)
🪛 Biome (2.1.2)
apps/web/lib/api/workflows/tiptap-to-text.ts

[error] 67-67: Unexpected control character in a regular expression.

Control characters are unusual and potentially incorrect inputs, so they are disallowed.

(lint/suspicious/noControlCharactersInRegex)


[error] 99-101: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

The declaration is defined in this switch clause:

Safe fix: Wrap the declaration in a block.

(lint/correctness/noSwitchDeclarations)


[error] 224-225: Duplicate case label.

The first similar label is here:

(lint/suspicious/noDuplicateCase)

⏰ 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: 1

♻️ Duplicate comments (5)
apps/web/lib/api/workflows/tiptap-to-text.ts (3)

98-125: Block‑scope the "text" case to satisfy the linter.

The let text declaration leaks across switch clauses, tripping noSwitchDeclarations and risking redeclaration conflicts. Wrap the case body in braces.

-      case "text":
-        let text = renderMarks(escapeText(node.text || ""), node.marks);
+      case "text": {
+        let text = renderMarks(escapeText(node.text || ""), node.marks);
         // Replace template variables if any
         if (conf.variables) {
           text = text.replace(/{{\s*([\w\d_]+)\s*}}/g, (_, key) => {
             return conf.variables?.[key] ?? `{{${key}}}`;
           });
         }
@@
-        }
-
-        return text;
+        }
+
+        return text;
+      }

235-237: Remove the duplicate "paragraph" case label.

This second "paragraph" branch duplicates the earlier one (Line 95), producing a Duplicate case label compile error. Delete the redundant clause.

-      case "paragraph":
-        return (node.content || []).map((n) => walk(n, listState)).join("");
-

246-248: Handle empty-string mention variables correctly.

conf.variables[id] treats "" as missing, so mentions fall back to @id instead of rendering the supplied empty value. Check for key presence and return the stored value (even if empty).

-        if (conf.variables && id && conf.variables[id]) {
-          return conf.variables[id];
+        if (conf.variables && id && id in conf.variables) {
+          return conf.variables[id] ?? "";
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (2)

147-153: Wrap tiptapToPlainText in a fail-safe helper.

A malformed campaign.bodyJson (e.g., corrupt content field) will throw inside tiptapToPlainText, aborting createMany and leaving the workflow partially processed. Catch and log the failure, falling back to a safe string so the chunk proceeds.

+    const renderPlainText = (
+      bodyJson: unknown,
+      variables: { PartnerName: string; PartnerEmail: string },
+    ) => {
+      try {
+        return tiptapToPlainText(bodyJson as TiptapNode, { variables });
+      } catch (error) {
+        console.error(
+          "Failed to render campaign plain text",
+          campaign.id,
+          variables,
+          error,
+        );
+        return `Campaign content for ${variables.PartnerName}`;
+      }
+    };
+
     const messages = await prisma.message.createMany({
       data: programEnrollmentChunk.map((programEnrollment) => ({
@@
-        text: tiptapToPlainText(campaign.bodyJson as TiptapNode, {
-          variables: {
-            PartnerName: programEnrollment.partner.name,
-            PartnerEmail: programEnrollment.partner.email,
-          },
-        }),
+        text: renderPlainText(campaign.bodyJson, {
+          PartnerName: programEnrollment.partner.name,
+          PartnerEmail: programEnrollment.partner.email,
+        }),

168-186: Add defensive HTML rendering to keep the workflow from crashing.

generateCampaignEmailHTML can throw on invalid TipTap JSON; when that happens, sendBatchEmail never runs and you’ve already created messages, leaving the workflow inconsistent. Catch the error, log it with context, and fall back to a minimal HTML body.

+    const renderCampaignHtml = (
+      bodyJson: unknown,
+      variables: { PartnerName: string; PartnerEmail: string },
+    ) => {
+      try {
+        return generateCampaignEmailHTML({ bodyJson, variables });
+      } catch (error) {
+        console.error(
+          "Failed to render campaign HTML",
+          campaign.id,
+          variables,
+          error,
+        );
+        return `<p>Campaign content for ${variables.PartnerName}</p>`;
+      }
+    };
+
@@
-        react: CampaignEmail({
+        react: CampaignEmail({
           program: {
             name: program.name,
             slug: program.slug,
             logo: program.logo,
             messagingEnabledAt: program.messagingEnabledAt,
           },
           campaign: {
             type: campaign.type,
             subject: campaign.subject,
-            body: generateCampaignEmailHTML({
-              bodyJson: campaign.bodyJson as any,
-              variables: {
-                PartnerName: partnerUser.partner.name,
-                PartnerEmail: partnerUser.partner.email,
-              },
-            }),
+            body: renderCampaignHtml(campaign.bodyJson, {
+              PartnerName: partnerUser.partner.name,
+              PartnerEmail: partnerUser.partner.email,
+            }),
           },
         }),
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cdee6e5 and 7eec88b.

📒 Files selected for processing (2)
  • apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (3 hunks)
  • apps/web/lib/api/workflows/tiptap-to-text.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (3)
apps/web/lib/api/workflows/tiptap-to-text.ts (2)
  • tiptapToPlainText (76-299)
  • TiptapNode (12-18)
packages/email/src/templates/campaign-email.tsx (1)
  • CampaignEmail (15-111)
apps/web/lib/api/workflows/generate-campaign-email-html.ts (1)
  • generateCampaignEmailHTML (12-57)
apps/web/lib/api/workflows/tiptap-to-text.ts (1)
apps/web/lib/types.ts (1)
  • EmailTemplateVariables (677-680)
🪛 Biome (2.1.2)
apps/web/lib/api/workflows/tiptap-to-text.ts

[error] 67-67: Unexpected control character in a regular expression.

Control characters are unusual and potentially incorrect inputs, so they are disallowed.

(lint/suspicious/noControlCharactersInRegex)


[error] 99-101: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

The declaration is defined in this switch clause:

Safe fix: Wrap the declaration in a block.

(lint/correctness/noSwitchDeclarations)


[error] 235-236: Duplicate case label.

The first similar label is here:

(lint/suspicious/noDuplicateCase)

⏰ 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

@devkiran
Copy link
Collaborator

@coderabbitai full review

Copy link
Contributor

coderabbitai bot commented Oct 11, 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: 0

♻️ Duplicate comments (1)
apps/web/lib/api/workflows/tiptap-to-text.ts (1)

62-65: Fix the control character in regex.

The regex uses \u0000 which embeds a literal control character, triggering a linter error. Use hexadecimal escape \x00 instead.

Apply this diff:

 function escapeText(t: string): string {
   // This is plain-text -> no HTML; just normalize control chars
-  return t.replace(/\u0000/g, "");
+  return t.replace(/\x00/g, "");
 }
🧹 Nitpick comments (1)
apps/web/lib/api/workflows/tiptap-to-text.ts (1)

249-251: Remove redundant hard_break case
Tiptap node names use "hardBreak" and there are no "hard_break" occurrences outside this converter. Remove lines 274–275 unless you explicitly need to support snake_case payloads.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7eec88b and 4b5d3f7.

📒 Files selected for processing (2)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx (1 hunks)
  • apps/web/lib/api/workflows/tiptap-to-text.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/api/workflows/tiptap-to-text.ts (1)
apps/web/lib/types.ts (1)
  • EmailTemplateVariables (677-680)
🪛 Biome (2.1.2)
apps/web/lib/api/workflows/tiptap-to-text.ts

[error] 64-64: Unexpected control character in a regular expression.

Control characters are unusual and potentially incorrect inputs, so they are disallowed.

(lint/suspicious/noControlCharactersInRegex)

⏰ 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 (2)
apps/web/lib/api/workflows/tiptap-to-text.ts (2)

1-24: LGTM!

The type definitions are clean and well-documented. The TiptapNode interface properly captures the recursive structure, and the Options interface provides clear JSDoc comments for each configuration option.


110-328: Well-structured implementation with good error handling.

The converter properly handles:

  • Null and malformed node checks (lines 126-134)
  • Recursive traversal with state management for nested lists
  • Safe text processing without HTML execution
  • Template variable substitution with correct nullish handling
  • Various Tiptap node types including lists, quotes, code blocks

The previous review issues (mention variable handling, text variable scoping) have been correctly addressed.

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

Caution

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

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

208-227: Add error handling for QStash schedule deletion in DELETE.

Similar to the PATCH endpoint, the QStash schedule deletion in the DELETE handler (line 225) lacks error handling. If the schedule doesn't exist or the delete fails, the error is silently lost in the waitUntil background task.

Apply this diff:

-    await qstash.schedules.delete(campaign.workflow.id);
+    try {
+      await qstash.schedules.delete(campaign.workflow.id);
+    } catch (error) {
+      console.warn("Failed to delete QStash schedule:", error);
+    }

32-36: Provide fallback for bodyJson in GET endpoint
CampaignSchema.parse requires bodyJson (z.record(...)) and will throw if it’s null. In apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (lines 32–36), default campaign.bodyJson to an empty object (e.g., bodyJson: campaign.bodyJson ?? {}) before calling CampaignSchema.parse.

packages/ui/package.json (1)

106-106: Add sanitize-html and @types/sanitize-html to dependencies
Include "sanitize-html" under dependencies and "@types/sanitize-html" under devDependencies in packages/ui/package.json to ensure HTML sanitization for email rendering.

apps/web/ui/layout/sidebar/app-sidebar-nav.tsx (1)

489-501: Fix route detection for campaigns/messages fullscreen views

The checks use “includes('/program/campaigns/')” and miss the root paths without trailing slash (e.g., /program/campaigns). Tighten detection.

-        : pathname.includes("/program/campaigns/") ||
-            pathname.includes("/program/messages/") ||
+        : ["/program/campaigns", "/program/messages"].some(
+            (base) =>
+              pathname === `/${slug}${base}` ||
+              pathname.startsWith(`/${slug}${base}/`),
+          ) ||
             pathname.endsWith("/program/payouts/success")
           ? null
♻️ Duplicate comments (51)
packages/utils/src/functions/urls.ts (1)

194-194: Previously flagged: Use .set() instead of .append() to prevent duplicate parameters.

This concern was already raised in a previous review. Using .append() allows multiple values for the same key, which can cause unexpected behavior if params contains duplicate keys or the function is called multiple times with overlapping parameters.

apps/web/ui/partners/campaigns/campaign-partners-selector.tsx (4)

1-3: Remove @ts-nocheck before merging.

This issue was flagged in a previous review. TypeScript checking is completely disabled for this file, which defeats the purpose of using TypeScript and can hide type errors that may cause runtime issues.


50-53: Add alt attribute for accessibility.

This issue was flagged in a previous review. The <img> element is missing an alt attribute, which is required for screen reader accessibility.


71-73: Add alt attribute for accessibility.

This issue was flagged in a previous review. The <img> element is missing an alt attribute, which is required for screen reader accessibility.


80-105: Add multiple={true} prop to Combobox.

This issue was flagged in a previous review. The Combobox component needs to know this is a multi-select control to render the appropriate UI (checkboxes, aria labels, keyboard navigation). Without the multiple prop, the Combobox will default to single-select behavior for its internal UI rendering.

apps/web/ui/partners/campaigns/campaign-type-selector.tsx (3)

37-39: Return null explicitly instead of undefined.

This issue was flagged in a previous review. While React accepts undefined, the convention is to explicitly return null when a component renders nothing.


52-54: Don't mutate CAMPAIGN_TYPES during render.

This issue was flagged in a previous review. Array.prototype.sort mutates the source array. Because CAMPAIGN_TYPES is exported and reused elsewhere, every render here permanently reorders that shared constant. Copy the array before sorting.


83-100: Add accessibility attributes to the popover trigger button.

This issue was flagged in a previous review. The button lacks aria-expanded and aria-haspopup attributes, preventing screen reader users from knowing whether the dropdown is open and that it triggers a menu.

apps/web/ui/layout/page-content/page-content-with-side-panel.tsx (1)

77-78: Duplicate: Accessibility concern with scrollbar-hide utility.

This is the same issue flagged in the previous review. The scrollbar-hide utility removes scrollbar visibility, which impacts accessibility for keyboard users, screen reader users, and users with motor disabilities who rely on visual scrollbar cues.

Refer to the previous review comment for suggested alternatives (styled scrollbars, visual scroll indicators, or media query-based conditional hiding).

apps/web/package.json (1)

44-47: Add the missing TipTap runtime: @tiptap/react.

Without it, editor components will fail to resolve at build/runtime.

Apply this diff in dependencies:

     "@tiptap/extension-image": "^3.0.9",
     "@tiptap/extension-mention": "^3.0.9",
     "@tiptap/html": "^3.6.5",
     "@tiptap/starter-kit": "^3.0.9",
+    "@tiptap/react": "^3.6.5",
packages/ui/src/icons/nucleo/media-play.tsx (2)

6-8: Align dimensions with other nucleo icons.

The icon uses 17×16 dimensions, but other nucleo icons use 18×18. This inconsistency may cause alignment issues in the UI.


14-19: Fix SVG attribute naming - React requires camelCase.

The SVG attributes use kebab-case (stroke-width, stroke-linecap, stroke-linejoin), which is invalid in React JSX and will cause runtime warnings or rendering issues.

packages/ui/src/icons/nucleo/media-pause.tsx (3)

6-7: Align dimensions with other nucleo icons.

The icon uses 17×16 dimensions while other nucleo icons use 18×18. This inconsistency may cause alignment issues in the UI.


14-18: Use React conventions for SVG attributes and currentColor for theming.

The SVG paths use kebab-case attributes instead of camelCase and have a hardcoded stroke color "#171717" instead of currentColor, preventing theme-based styling.


22-25: Use React conventions for SVG attributes and currentColor for theming.

The SVG paths use kebab-case attributes instead of camelCase and have a hardcoded stroke color "#171717" instead of currentColor, preventing theme-based styling.

packages/ui/src/icons/nucleo/envelope-check.tsx (1)

13-27: Fix JSX SVG attribute casing so the component compiles.

The SVG attributes use kebab-case (clip-path, stroke-width, stroke-linecap, stroke-linejoin), which is invalid in React JSX and will cause build errors.

apps/web/lib/actions/partners/upload-email-image.ts (2)

9-11: Remove unused schema parameter.

The schema requires workspaceId but the action never accesses parsedInput — workspace is derived from ctx instead. This makes the schema parameter unnecessary and potentially confusing.


29-31: Preserve error context for debugging.

The catch block discards the original error and throws a generic message, making it difficult to diagnose failures. Consider logging the error server-side or including it in the thrown error message.

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

27-31: Guard against undefined group when rendering GroupColorCircle.

Line 28 passes row.original.group to GroupColorCircle, which can be undefined based on the event data structure. Line 30 correctly uses optional chaining (group?.name), but line 28 doesn't guard the component render.

Apply this diff to add a guard:

           <div className="flex items-center gap-1">
-            <GroupColorCircle group={row.original.group} />
+            {row.original.group && <GroupColorCircle group={row.original.group} />}
             <span className="text-content-subtle truncate text-xs font-medium">
               {row.original.group?.name}
             </span>
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/use-campaigns-filters.tsx (1)

114-124: Add missing dependency to searchQuery useMemo.

The searchQuery useMemo uses searchParamsObj.search in line 120 but doesn't list it in the dependency array (line 123). This can cause the memoized value to become stale when the search parameter changes.

Apply this diff to fix the dependency array:

   const searchQuery = useMemo(
     () =>
       new URLSearchParams({
         ...Object.fromEntries(
           activeFilters.map(({ key, value }) => [key, value]),
         ),
         ...(searchParamsObj.search && { search: searchParamsObj.search }),
         workspaceId: workspaceId || "",
       }).toString(),
-    [activeFilters, workspaceId],
+    [activeFilters, workspaceId, searchParamsObj.search],
   );
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx (3)

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

The non-null assertion workspaceId! at line 269 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,
                       });

278-285: Remove forbidden Content-Length header in browser PUT.

Browsers disallow setting Content-Length (line 283); 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(),
                         },
                       });

331-333: Incomplete reset handler - form state not reset.

The onReset callback at lines 331-333 only resets the editor content but doesn't reset the form state. This means other fields (name, subject, groupIds, triggerCondition) remain in their edited state after clicking reset.

Apply this diff to reset both the form and editor:

           onReset={() => {
+            form.reset();
             editorRef.current?.setContent(campaign.bodyJson);
           }}
apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (4)

83-98: Critical: Cannot create workflow when adding trigger to campaign without one.

This code only updates an existing workflow (when campaign.workflowId is present). If a campaign has no workflow and the client sends triggerCondition to add automation, this branch is skipped entirely—no workflow is created, and the campaign remains without automation.

Add logic before this block to create a workflow when triggerCondition is provided but campaign.workflowId is null:

+    let createdWorkflow = null;
+
+    if (triggerCondition && !campaign.workflowId) {
+      createdWorkflow = await tx.workflow.create({
+        data: {
+          programId,
+          trigger: WORKFLOW_ATTRIBUTE_TRIGGER[triggerCondition.attribute],
+          triggerConditions: [triggerCondition],
+          actions: [{ type: "send_email", data: { campaignId } }],
+        },
+      });
+    }
+
     if (campaign.workflowId) {
       await tx.workflow.update({

Then include the new workflow ID in the campaign update (around line 100-126):

       data: {
         ...(name && { name }),
         ...(subject && { subject }),
         ...(status && { status }),
         ...(bodyJson && { bodyJson }),
+        ...(createdWorkflow && { workflowId: createdWorkflow.id }),

83-98: Handle workflow teardown when triggerCondition is cleared.

When the client sends triggerCondition: null (to remove automation), this code doesn't delete the existing workflow or clear workflowId. The old workflow continues to fire even though the UI shows no automation.

Apply this diff to handle workflow deletion:

+    const nextStatus = status ?? campaign.status;
+
+    if (triggerCondition === null && campaign.workflowId) {
+      await tx.workflow.delete({
+        where: { id: campaign.workflowId },
+      });
+    }
+
-    if (campaign.workflowId) {
+    if (campaign.workflowId && triggerCondition !== null) {
       await tx.workflow.update({

Then update the campaign data block to null out the workflowId:

       data: {
         ...(name && { name }),
         ...(subject && { subject }),
         ...(status && { status }),
         ...(bodyJson && { bodyJson }),
+        ...(triggerCondition === null && campaign.workflowId
+          ? { workflowId: null }
+          : {}),

155-162: QStash schedule operations lack error handling.

Both qstash.schedules.create and qstash.schedules.delete can fail (e.g., schedule already exists or doesn't exist). Without error handling in the waitUntil background task, these failures occur silently, leaving scheduling state inconsistent with campaign state.

Wrap QStash operations in try-catch:

     if (shouldSchedule) {
-      await qstash.schedules.create({
-        destination: `${APP_DOMAIN_WITH_NGROK}/api/cron/workflows/${updatedCampaign.workflow.id}`,
-        cron: "0 */12 * * *",
-        scheduleId: updatedCampaign.workflow.id,
-      });
+      try {
+        await qstash.schedules.create({
+          destination: `${APP_DOMAIN_WITH_NGROK}/api/cron/workflows/${updatedCampaign.workflow.id}`,
+          cron: "0 */12 * * *",
+          scheduleId: updatedCampaign.workflow.id,
+        });
+      } catch (error) {
+        console.warn("Failed to create QStash 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 QStash schedule:", error);
+      }
     }

166-170: Ensure bodyJson in the response is never null.

If updatedCampaign.bodyJson is null, CampaignSchema.parse will throw, returning a 500 after successful updates. Default it like the GET handler does.

 const response = CampaignSchema.parse({
   ...updatedCampaign,
+  bodyJson: updatedCampaign.bodyJson ?? { subject: "", html: "", text: "" },
   groups: updatedCampaign.groups.map(({ groupId }) => ({ id: groupId })),
   triggerCondition: updatedCampaign.workflow?.triggerConditions?.[0],
 });
packages/ui/src/rich-text-area/index.tsx (3)

21-33: Replace any types with proper TipTap types.

The use of any for initialValue and the setContent parameter bypasses TypeScript's type checking, which can lead to runtime errors if invalid content structures are passed.

TipTap provides proper types. Apply this diff:

+import type { Content } from "@tiptap/react";
+
 interface RichTextAreaProps {
-  initialValue?: any;
+  initialValue?: Content;
   onChange?: (editor: Editor) => void;
   placeholder?: string;
   className?: string;
   editorClassName?: string;
   uploadImage?: (file: File) => Promise<string | null>;
   variables?: string[];
 }
 
 export interface RichTextAreaRef {
-  setContent: (content: any) => void;
+  setContent: (content: Content) => void;
 }

50-75: Add user feedback for failed image uploads.

When uploadImage returns null, the loading state clears but the user receives no notification that the upload failed. This creates confusion—users don't know why their image didn't appear.

Add a toast notification or error message when src is falsy:

     const src = await uploadImage?.(file);
     if (!src) {
       setIsUploading(false);
+      // Add user feedback - adjust to your toast library
+      console.error("Image upload failed");
+      // Example: toast.error("Failed to upload image. Please try again.");
       return;
     }

97-111: Race condition with concurrent image uploads.

The forEach loops with async handleImageUpload calls don't await the promises, and the isUploading state doesn't track multiple concurrent uploads. When multiple files are dropped or pasted:

  1. All uploads start concurrently
  2. The first upload to complete sets isUploading to false
  3. The UI becomes interactive while other uploads are still in progress
  4. Errors from slower uploads may be lost

Apply this diff to handle multiple uploads correctly:

             onDrop: (currentEditor, files, pos) => {
-              files.forEach((file) =>
-                handleImageUpload(file, currentEditor, pos),
-              );
+              Promise.all(
+                files.map((file) =>
+                  handleImageUpload(file, currentEditor, pos)
+                )
+              );
             },
             onPaste: (currentEditor, files, htmlContent) => {
               if (htmlContent) return false;
-              files.forEach((file) =>
-                handleImageUpload(
-                  file,
-                  currentEditor,
-                  currentEditor.state.selection.anchor,
-                ),
-              );
+              Promise.all(
+                files.map((file) =>
+                  handleImageUpload(
+                    file,
+                    currentEditor,
+                    currentEditor.state.selection.anchor,
+                  )
+                )
+              );
             },
packages/ui/package.json (1)

75-81: Add the mandatory @tiptap/extension-suggestion dependency.

@tiptap/extension-mention (line 77) imports @tiptap/extension-suggestion directly, but that package isn't listed in dependencies. This will cause "Cannot find module '@tiptap/extension-suggestion'" errors at runtime.

Add the missing dependency:

     "@tiptap/extension-file-handler": "^3.0.9",
     "@tiptap/extension-image": "^3.0.9",
     "@tiptap/extension-mention": "^3.0.9",
+    "@tiptap/extension-suggestion": "^3.0.9",
     "@tiptap/extensions": "^3.0.9",
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/layout.tsx (1)

13-13: Guard against undefined slug in redirect.

The redirect at Line 13 uses slug directly in a template literal without null/undefined checking. If useWorkspace() returns undefined for slug, this will construct an invalid path /undefined/program.

Add a null check before the redirect:

  if (loading) return <LayoutLoader />;

- if (!flags?.emailCampaigns) redirect(`/${slug}/program`);
+ if (!flags?.emailCampaigns) {
+   if (!slug) return notFound();
+   redirect(`/${slug}/program`);
+ }

Or handle the undefined slug case with a more robust pattern.

packages/ui/src/icons/nucleo/envelope-open.tsx (1)

13-26: Address the JSX attribute issues flagged in previous review.

The JSX attribute naming and hard-coded color issues identified in the previous review comment have not been resolved. The paths still use:

  1. Hyphenated attributes (stroke-width, stroke-linecap, stroke-linejoin) instead of camelCase JSX attributes.
  2. Hard-coded stroke color #737373 instead of currentColor for theming.
packages/ui/src/icons/nucleo/envelope-ban.tsx (1)

13-41: Fix SVG attribute naming for React (camelCase required)

Use clipPath, strokeWidth, strokeLinecap, strokeLinejoin to avoid React warnings.

-      <g clip-path="url(#clip0_31261_84159)">
+      <g clipPath="url(#clip0_31261_84159)">
         <path
           d="M1.36133 4.47217L6.62455 7.37561C6.85866 7.50472 7.14177 7.50472 7.37588 7.37561L12.6391 4.47217"
           stroke="#737373"
-          stroke-width="1.5"
-          stroke-linecap="round"
-          stroke-linejoin="round"
+          strokeWidth="1.5"
+          strokeLinecap="round"
+          strokeLinejoin="round"
         />
         <path
           d="M12.6391 6.35442V4.08339C12.6391 3.22394 11.943 2.52783 11.0836 2.52783H2.91688C2.05744 2.52783 1.36133 3.22394 1.36133 4.08339V9.91672C1.36133 10.7762 2.05744 11.4723 2.91688 11.4723H6.0637"
           stroke="#737373"
-          stroke-width="1.5"
-          stroke-linecap="round"
-          stroke-linejoin="round"
+          strokeWidth="1.5"
+          strokeLinecap="round"
+          strokeLinejoin="round"
         />
         <path
           d="M10.8891 13.4166C12.2852 13.4166 13.4169 12.285 13.4169 10.8889C13.4169 9.49275 12.2852 8.36108 10.8891 8.36108C9.49299 8.36108 8.36133 9.49275 8.36133 10.8889C8.36133 12.285 9.49299 13.4166 10.8891 13.4166Z"
           stroke="#737373"
-          stroke-width="1.5"
-          stroke-linecap="round"
-          stroke-linejoin="round"
+          strokeWidth="1.5"
+          strokeLinecap="round"
+          strokeLinejoin="round"
         />
         <path
           d="M9.10156 12.6762L12.6716 9.1062"
           stroke="#737373"
-          stroke-width="1.5"
-          stroke-linecap="round"
-          stroke-linejoin="round"
+          strokeWidth="1.5"
+          strokeLinecap="round"
+          strokeLinejoin="round"
         />
       </g>
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/campaign-stats.tsx (1)

30-36: Avoid “0” flash during loading and remove duplicate hook calls

  • Don’t default counts to 0 before data; render skeleton instead.
  • Call useRouterStuff once to avoid redundant work.
-  const { queryParams } = useRouterStuff();
-  const { searchParamsObj } = useRouterStuff();
+  const { queryParams, searchParamsObj } = useRouterStuff();

-  const { totalCount, marketingCount, transactionalCount } = useMemo(() => {
-    const marketingCampaign = campaignsCount?.find(
-      (c) => c.type === "marketing",
-    ) ?? {
-      _count: 0,
-    };
-
-    const transactionalCampaign = campaignsCount?.find(
-      (c) => c.type === "transactional",
-    ) ?? {
-      _count: 0,
-    };
-
-    return {
-      totalCount: marketingCampaign._count + transactionalCampaign._count,
-      marketingCount: marketingCampaign._count,
-      transactionalCount: transactionalCampaign._count,
-    };
-  }, [campaignsCount]);
+  const { totalCount, marketingCount, transactionalCount } = useMemo(() => {
+    if (!campaignsCount) {
+      return {
+        totalCount: undefined,
+        marketingCount: undefined,
+        transactionalCount: undefined,
+      };
+    }
+
+    const marketingCampaign = campaignsCount.find((c) => c.type === "marketing");
+    const transactionalCampaign = campaignsCount.find(
+      (c) => c.type === "transactional",
+    );
+
+    return {
+      totalCount:
+        (marketingCampaign?._count ?? 0) + (transactionalCampaign?._count ?? 0),
+      marketingCount: marketingCampaign?._count,
+      transactionalCount: transactionalCampaign?._count,
+    };
+  }, [campaignsCount]);

Also applies to: 38-56

apps/web/lib/actions/campaigns/send-campaign-preview-email.ts (1)

49-74: Check sendBatchEmail result and de‑duplicate recipients

sendBatchEmail returns { data, error }; failures won’t throw, so the action currently reports success on error. Also consider deduping addresses to avoid double sends.

-    await sendBatchEmail(
-      emailAddresses.map((email) => ({
+    const uniqueEmails = Array.from(new Set(emailAddresses));
+    const response = await sendBatchEmail(
+      uniqueEmails.map((email) => ({
         variant: "notifications",
         to: email,
         subject: `[TEST] ${subject}`,
         react: CampaignEmail({
           program: {
             name: program.name,
             slug: program.slug,
             logo: program.logo,
             messagingEnabledAt: program.messagingEnabledAt,
           },
           campaign: {
             type: campaign.type,
             subject,
             body: generateCampaignEmailHTML({
               bodyJson,
               variables: {
                 PartnerName: "Partner",
                 PartnerEmail: "partner@acme.com",
               },
             }),
           },
         }),
       })),
     );
+    if (response?.error) {
+      const msg =
+        typeof response.error === "string"
+          ? response.error
+          : response.error?.message ?? "Failed to send preview email.";
+      throw new Error(msg);
+    }
packages/ui/src/rich-text-area/rich-text-toolbar.tsx (2)

82-93: Restrict file types and handle upload errors

Add accept="image/*", validate type/size, and wrap onImageUpload in try/catch to surface failures.

   <input
     ref={inputImageRef}
     type="file"
+    accept="image/*"
     className="hidden"
     onChange={(e) => {
       const file = e.target.files?.[0];
       if (!file) return;
-
-      onImageUpload(file);
+      // Basic guards
+      if (!file.type.startsWith("image/")) {
+        alert("Please select a valid image file.");
+        e.target.value = "";
+        return;
+      }
+      // Optional size check (e.g., 5MB)
+      const MAX_BYTES = 5 * 1024 * 1024;
+      if (file.size > MAX_BYTES) {
+        alert("Image is too large (max 5MB).");
+        e.target.value = "";
+        return;
+      }
+      try {
+        onImageUpload?.(file);
+      } catch (err) {
+        alert("Image upload failed. Please try again.");
+      }
       e.target.value = "";
     }}
   />

116-139: Replace window.prompt and validate URLs (prevent javascript: etc.)

Blocking prompt is poor UX and current check allows dangerous protocols. Validate http(s) and sanitize input; preferably use a modal/popover.

-        const url = window.prompt("Link URL", previousUrl);
-
-        if (!url?.trim()) {
+        const url = window.prompt("Link URL", previousUrl);
+        const trimmedUrl = url?.trim();
+        if (!trimmedUrl) {
           editor.chain().focus().extendMarkRange("link").unsetLink().run();
           return;
         }
+        // Only allow http/https URLs
+        if (!/^https?:\/\/.+/i.test(trimmedUrl)) {
+          alert("Please enter a valid URL starting with http:// or https://");
+          return;
+        }
 
         editor
           .chain()
           .focus()
           .extendMarkRange("link")
-          .setLink({ href: url })
+          .setLink({ href: trimmedUrl })
           .run();
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/campaigns-table.tsx (2)

99-111: Guard against unknown campaign types

Avoid destructuring from possibly undefined entry to prevent runtime errors.

-          const { icon: Icon, iconClassName } =
-            CAMPAIGN_TYPE_BADGES[row.original.type];
+          const typeBadge = CAMPAIGN_TYPE_BADGES[row.original.type];
+          const Icon = typeBadge?.icon ?? Mail;
+          const iconClassName =
+            typeBadge?.iconClassName ?? "text-neutral-600 bg-neutral-100";

79-88: Use total row count for pagination, not current page length

rowCount should be total campaigns across all pages. Fetch count and pass it to Table.

   const {
     data: campaigns,
     isLoading: campaignsLoading,
     error,
   } = useSWR<CampaignList[]>(
     workspaceId &&
       `/api/campaigns${getQueryString({
         workspaceId: workspaceId,
       }).toString()}`,
     fetcher,
     {
       keepPreviousData: true,
     },
   );
+
+  const { data: total } = useSWR<number>(
+    workspaceId &&
+      `/api/campaigns/count${getQueryString({
+        workspaceId: workspaceId,
+      }).toString()}`,
+    fetcher,
+  );
@@
     resourceName: (p) => `campaign${p ? "s" : ""}`,
-    rowCount: campaigns?.length || 0,
+    rowCount: total ?? campaigns?.length ?? 0,
     loading: campaignsLoading,
     error: error ? "Failed to load campaigns" : undefined,

Also applies to: 248-251

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

27-32: Avoid showing a “default” label while marking the field invalid.

Showing partnerEnrolledDays as text with invalid={!field.value} is inconsistent. Use a neutral placeholder or gate invalid on touched.

-                text={
-                  field.value
-                    ? CAMPAIGN_WORKFLOW_ATTRIBUTE_LABELS[field.value]
-                    : CAMPAIGN_WORKFLOW_ATTRIBUTE_LABELS.partnerEnrolledDays
-                }
-                invalid={!field.value}
+                text={
+                  field.value
+                    ? CAMPAIGN_WORKFLOW_ATTRIBUTE_LABELS[field.value]
+                    : "Select attribute"
+                }
+                // Optionally: pass invalid only after user interaction
+                invalid={!field.value /* and touched state if available */}
apps/web/lib/api/campaigns/get-campaigns.ts (1)

88-88: Keep list/search in sync with count: include subject in search.

Counts already match by name OR subject; list only matches name. Align both.

-      ${search ? Prisma.sql`AND LOWER(c.name) LIKE LOWER(${`%${search}%`})` : Prisma.sql``}
+      ${
+        search
+          ? Prisma.sql`AND (
+              LOWER(c.name) LIKE LOWER(${`%${search}%`})
+              OR LOWER(c.subject) LIKE LOWER(${`%${search}%`})
+            )`
+          : Prisma.sql``
+      }
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (2)

147-153: Wrap plain-text generation with a safe helper to avoid chunk aborts.

A malformed bodyJson will throw and fail createMany for the whole chunk.

// Place near imports
function safeGenerateCampaignText(
  bodyJson: any,
  variables: { PartnerName: string; PartnerEmail: string },
) {
  try {
    return tiptapToPlainText(bodyJson as TiptapNode, { variables });
  } catch (err) {
    console.error("Failed to generate campaign plain text", { err });
    return `Campaign update for ${variables.PartnerName}`;
  }
}
-        text: tiptapToPlainText(campaign.bodyJson as TiptapNode, {
-          variables: {
-            PartnerName: programEnrollment.partner.name,
-            PartnerEmail: programEnrollment.partner.email,
-          },
-        }),
+        text: safeGenerateCampaignText(campaign.bodyJson, {
+          PartnerName: programEnrollment.partner.name,
+          PartnerEmail: programEnrollment.partner.email,
+        }),

176-185: Add defensive HTML generation to prevent aborting email sends.

Errors from generateCampaignEmailHTML will skip all recipients in the chunk.

// Place near imports
function safeGenerateCampaignHTML(
  bodyJson: any,
  variables: { PartnerName: string; PartnerEmail: string },
) {
  try {
    return generateCampaignEmailHTML({ bodyJson, variables });
  } catch (err) {
    console.error("Failed to generate campaign HTML", { err });
    return `<p>Hi ${variables.PartnerName},</p><p></p>`;
  }
}
-            body: generateCampaignEmailHTML({
-              bodyJson: campaign.bodyJson as any,
-              variables: {
-                PartnerName: partnerUser.partner.name,
-                PartnerEmail: partnerUser.partner.email,
-              },
-            }),
+            body: safeGenerateCampaignHTML(campaign.bodyJson, {
+              PartnerName: partnerUser.partner.name,
+              PartnerEmail: partnerUser.partner.email,
+            }),
apps/web/app/(ee)/api/campaigns/count/route.ts (1)

20-22: Make search case-insensitive to match list behavior

Add mode: "insensitive" for both fields so count and list queries are consistent across DBs (esp. Postgres).

Apply:

-      ...(search && {
-        OR: [{ name: { contains: search } }, { subject: { contains: search } }],
-      }),
+      ...(search && {
+        OR: [
+          { name: { contains: search, mode: "insensitive" } },
+          { subject: { contains: search, mode: "insensitive" } },
+        ],
+      }),
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/use-campaigns-count.tsx (1)

20-36: Gate SWR on both workspaceId and defaultProgramId; avoid workspaceId=undefined in URL

Skip fetch until both IDs are set, and only build query when workspaceId exists.

Apply:

-  const queryString = getQueryString(
-    {
-      ...params,
-      workspaceId,
-    },
-    {
-      exclude: exclude || [],
-    },
-  );
+  const queryString =
+    workspaceId
+      ? getQueryString(
+          { ...params, workspaceId },
+          { exclude: exclude || [] },
+        )
+      : "";
 
-  const { data: campaignsCount, error } = useSWR(
-    defaultProgramId ? `/api/campaigns/count${queryString}` : null,
+  const { data: campaignsCount, error } = useSWR(
+    defaultProgramId && workspaceId
+      ? `/api/campaigns/count${queryString}`
+      : null,
     fetcher,
     {
       keepPreviousData: true,
     },
   );

Based on learnings

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

46-76: Wire pagination into SWR keys so page changes re-fetch

Include pagination.pageIndex/pageSize in both events and count keys to trigger revalidation when paging.

   } = useSWR<CampaignEvent[]>(
     showModal && campaignId && workspaceId
       ? buildUrl(`/api/campaigns/${campaignId}/events`, {
           workspaceId,
           status,
+          page: pagination.pageIndex,
+          pageSize: pagination.pageSize,
           ...(debouncedSearch && { search: debouncedSearch }),
         })
       : null,
     fetcher,
@@
   } = useSWR<number>(
     showModal && campaignId && workspaceId
       ? buildUrl(`/api/campaigns/${campaignId}/events/count`, {
           workspaceId,
           status,
+          page: pagination.pageIndex,
+          pageSize: pagination.pageSize,
           ...(debouncedSearch && { search: debouncedSearch }),
         })
       : null,
     fetcher,
packages/prisma/schema/campaign.prisma (1)

6-16: Enforce type-specific status constraints in app logic

Prevent invalid status–type combos (e.g., marketing active/paused, transactional sent/scheduled) in schemas and routes.

  • Restrict allowed statuses by CampaignType in create/update Zod schemas.
  • Validate transitions in PATCH handler to block illegal changes.
packages/ui/src/rich-text-area/variables.tsx (1)

101-112: Guard keyboard handlers when list is empty (modulo-by-zero/undefined bug)

Arrow/Enter with empty items computes % 0 and calls command with undefined. Add guards.

-    const upHandler = () => {
-      setSelectedIndex((selectedIndex + items.length - 1) % items.length);
-    };
-
-    const downHandler = () => {
-      setSelectedIndex((selectedIndex + 1) % items.length);
-    };
-
-    const enterHandler = () => {
-      command({ id: items[selectedIndex] });
-    };
+    const moveSelection = (delta: number) => {
+      if (!items.length) return false;
+      setSelectedIndex((prev) => (prev + delta + items.length) % items.length);
+      return true;
+    };
+
+    const upHandler = () => moveSelection(-1);
+    const downHandler = () => moveSelection(1);
+
+    const enterHandler = () => {
+      if (!items.length) return false;
+      command({ id: items[selectedIndex] });
+      return true;
+    };
@@
-        if (event.key === "ArrowUp") {
-          upHandler();
-          return true;
-        }
+        if (event.key === "ArrowUp") return upHandler();
@@
-        if (event.key === "ArrowDown") {
-          downHandler();
-          return true;
-        }
+        if (event.key === "ArrowDown") return downHandler();
@@
-        if (event.key === "Enter") {
-          enterHandler();
-          return true;
-        }
+        if (event.key === "Enter") return enterHandler();

Also applies to: 115-134

apps/web/ui/layout/sidebar/app-sidebar-nav.tsx (1)

319-322: Use undefined for optional badge fallback (avoid empty badge render)

Bounties falls back to ""; other badges use undefined. Align to avoid empty badges.

-              : "",
+              : undefined,
apps/web/lib/api/workflows/tiptap-to-text.ts (1)

62-65: Replace control-character regex with printable escape to satisfy Biome

Biome flags /\u0000/ as an unexpected control char. Use a hexadecimal escape.
Based on static analysis hints

 function escapeText(t: string): string {
   // This is plain-text -> no HTML; just normalize control chars
-  return t.replace(/\u0000/g, "");
+  return t.replace(/\x00/g, "");
 }
🧹 Nitpick comments (28)
packages/utils/src/functions/urls.ts (1)

186-189: Consider adding error handling for invalid baseUrl.

The URL constructor will throw if baseUrl is malformed. While some utility functions in this file (e.g., isValidUrl, getParamsFromURL) use try-catch blocks, this function doesn't handle URL construction errors.

If error handling is desired for consistency with other functions in this file, apply this diff:

 export function buildUrl(
   baseUrl: string,
   params?: Record<string, string | number | boolean | null | undefined>,
 ) {
+  try {
     const url = new URL(
       baseUrl,
       typeof window !== "undefined" ? window.location.origin : "http://localhost",
     );
 
     if (params) {
       Object.entries(params).forEach(([key, value]) => {
         if (value !== null && value !== undefined && value !== "") {
           url.searchParams.append(key, String(value));
         }
       });
     }
 
     return url.toString();
+  } catch (e) {
+    return baseUrl;
+  }
 }

Alternatively, document that the function expects valid URLs and will throw on invalid input, making the caller responsible for validation.

apps/web/ui/layout/page-content/page-content-with-side-panel.tsx (1)

55-56: Add a fallback for dvh
Precede your calc(100dvh-…) with a calc(100vh-…) fallback or wrap the dynamic unit in an @supports (height:100dvh) block (or use the JS-driven --vh pattern) to cover browsers below Chrome 108, Firefox 101 and Safari 15.4.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx (1)

70-78: Consider adding email format validation for better UX.

The current implementation validates that email addresses are non-empty after trimming, which is functional. However, adding a basic email format check (e.g., regex pattern) would catch typos earlier and provide immediate feedback before submission.

Example implementation:

 const emails = emailAddresses
   .split(",")
   .map((email) => email.trim())
   .filter((email) => email.length > 0);

+const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+const invalidEmails = emails.filter(email => !emailRegex.test(email));
+
+if (invalidEmails.length > 0) {
+  toast.error(`Invalid email format: ${invalidEmails.join(", ")}`);
+  return;
+}
+
 if (emails.length === 0) {
   toast.error("Please enter valid email addresses.");
   return;
 }
apps/web/package.json (1)

143-143: Align @types/sanitize-html with sanitize-html@^2.17.0
Bump the devDependency to match your sanitize-html version:

-  "@types/sanitize-html": "^2.16.0",
+  "@types/sanitize-html": "^2.17.0",
packages/ui/src/icons/nucleo/envelope-check.tsx (3)

16-16: Use currentColor instead of hardcoded stroke color.

The hardcoded stroke color "#737373" prevents theme-based styling. Consider using currentColor like other nucleo icons (e.g., text-bold.tsx, text-italic.tsx).

Apply this diff:

-          stroke="#737373"
+          stroke="currentColor"

23-23: Use currentColor instead of hardcoded stroke color.

The hardcoded stroke color "#737373" prevents theme-based styling. Consider using currentColor like other nucleo icons.

Apply this diff:

-          stroke="#737373"
+          stroke="currentColor"

30-30: Use currentColor instead of hardcoded fill color.

The hardcoded fill color "#737373" prevents theme-based styling. Consider using currentColor like other nucleo icons.

Apply this diff:

-          fill="#737373"
+          fill="currentColor"
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-events-columns.tsx (1)

62-76: Simplify timestamp selection with nullish coalescing.

The getTimestamp helper can be refactored to use nullish coalescing for a more concise implementation.

Apply this diff:

 const getTimestamp = (event: CampaignEvent) => {
-  if (event.deliveredAt) {
-    return event.deliveredAt;
-  }
-
-  if (event.openedAt) {
-    return event.openedAt;
-  }
-
-  if (event.bouncedAt) {
-    return event.bouncedAt;
-  }
-
-  return null;
+  return event.deliveredAt ?? event.openedAt ?? event.bouncedAt ?? null;
 };
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/use-campaigns-filters.tsx (1)

84-84: Fix filters useMemo dependencies.

The filters useMemo at line 84 lists countByType in its dependencies, but countByType is not actually used in the computation (the type filter block is commented out at lines 61-82). This creates an unnecessary re-render trigger.

Apply this diff:

     ],
-    [countByType, countByStatus],
+    [countByStatus],
   );

If you plan to uncomment the type filter block later, you can restore countByType to the dependency array at that time.

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

15-26: Await mutatePrefix to ensure cache consistency.

Line 23 calls mutatePrefix without awaiting it. While this might be intentional for fire-and-forget behavior, awaiting ensures the cache is updated before the function completes, which can prevent race conditions if other code depends on the updated cache state.

Apply this diff:

       onSuccess: (campaign) => {
         router.push(`/${slug}/program/campaigns/${campaign.id}`);
-        mutatePrefix("/api/campaigns");
+        await mutatePrefix("/api/campaigns");
       },

However, if onSuccess is not expected to be async, you may need to handle this differently.

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

3-14: Add compile-time exhaustiveness with satisfies Record

Make keys and value shape type-safe and future-proof. Optional, but prevents missing entries when adding new types.

-import { Megaphone, Workflow } from "@dub/ui";
+import { Megaphone, Workflow } from "@dub/ui";
+// Optional: tighten types without runtime impact
+// import type { CampaignType } from "@prisma/client";

-export const CAMPAIGN_TYPE_BADGES = {
+export const CAMPAIGN_TYPE_BADGES = {
   marketing: {
     label: "Marketing",
     icon: Megaphone,
     iconClassName: "text-green-600 bg-green-100",
   },
   transactional: {
     label: "Transactional",
     icon: Workflow,
     iconClassName: "text-blue-600 bg-blue-100",
   },
-} as const;
+} as const
+// satisfies Record<CampaignType, { label: string; icon: React.ComponentType<{ className?: string }>; iconClassName: string }>
+; // using `satisfies` keeps literal types while enforcing shape
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/campaign-status-badges.tsx (1)

3-34: Optionally enforce status coverage with satisfies Record

Add a type-only import and satisfies to ensure all statuses are covered and values conform.

-import { CircleHalfDottedClock, CirclePlay, PaperPlane, Pen2 } from "@dub/ui";
+import { CircleHalfDottedClock, CirclePlay, PaperPlane, Pen2 } from "@dub/ui";
+// Optional: keep runtime same, improve compile-time guarantees
+// import type { CampaignStatus } from "@/lib/types"; // or from @prisma/client if that's the source

-export const CAMPAIGN_STATUS_BADGES = {
+export const CAMPAIGN_STATUS_BADGES = {
   draft: {
     label: "Draft",
     variant: "neutral",
     icon: Pen2,
     iconClassName: "text-neutral-600",
   },
   active: {
     label: "Active",
     variant: "success",
     icon: CirclePlay,
     iconClassName: "text-green-600",
   },
   paused: {
     label: "Paused",
     variant: "warning",
     icon: CircleHalfDottedClock,
     iconClassName: "text-yellow-600",
   },
   sent: {
     label: "Sent",
     variant: "neutral",
     icon: PaperPlane,
     iconClassName: "text-neutral-600",
   },
   scheduled: {
     label: "Scheduled",
     variant: "neutral",
     icon: CircleHalfDottedClock,
     iconClassName: "text-neutral-600",
   },
-} as const;
+} as const
+// satisfies Record<CampaignStatus, { label: string; variant: "neutral" | "success" | "warning"; icon: React.ComponentType<{ className?: string }>; iconClassName: string }>
+;
apps/web/lib/actions/campaigns/send-campaign-preview-email.ts (1)

15-17: Avoid unused/unguarded workspaceId in schema

Schema requires workspaceId but the action ignores it (uses ctx.workspace). Either remove it from the schema or assert it matches ctx.workspace.id to prevent spoofed inputs.

   .action(async ({ parsedInput, ctx }) => {
     const { workspace } = ctx;
-    const { campaignId, subject, bodyJson, emailAddresses } = parsedInput;
+    const { campaignId, subject, bodyJson, emailAddresses, workspaceId } = parsedInput;
+    if (workspaceId && workspaceId !== workspace.id) {
+      throw new Error("Workspace mismatch.");
+    }
apps/web/lib/api/workflows/render-email-template.ts (1)

28-36: Narrow key type; confirm HTML-escaping is handled upstream

Optional: cast key to keyof EmailTemplateVariables to keep types tight.
Also, if the rendered string is inserted as HTML, ensure values are HTML-escaped elsewhere to avoid markup injection.

-    (_, key, fallback) => {
-      const value = variables[key];
+    (_, key, fallback) => {
+      const value = variables[key as keyof EmailTemplateVariables];
       return value != null ? String(value) : fallback ?? "";
     },
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-metrics.tsx (1)

21-26: Validate API response with Zod at fetch time

You’re typing with campaignSummarySchema but not parsing. Parse with Zod to catch shape drift.

-  } = useSWR<z.infer<typeof campaignSummarySchema>>(
+  } = useSWR<z.infer<typeof campaignSummarySchema>>(
     campaignId && workspaceId
       ? `/api/campaigns/${campaignId}/summary?workspaceId=${workspaceId}`
       : null,
-    fetcher,
+    (url) => fetcher(url).then((data) => campaignSummarySchema.parse(data)),
   );
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/campaigns-table.tsx (1)

72-74: Align default sort with columns

"updatedAt" isn’t a declared sortable column here. Use "createdAt" or add an Updated column.

-  const sortBy = searchParams.get("sortBy") || "updatedAt";
+  const sortBy = searchParams.get("sortBy") || "createdAt";
apps/web/lib/api/campaigns/get-campaigns.ts (1)

49-60: Consider filtering metrics by email type to avoid inflated counts.

If NotificationEmail stores multiple types with a campaignId, aggregate only Campaign emails.

     FROM NotificationEmail
     WHERE 
       programId = ${programId}
       AND campaignId IS NOT NULL
+      AND type = 'Campaign'
     GROUP BY campaignId
  • Verify the exact enum/string for type in @prisma/client before applying.
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (1)

138-159: Reduce inconsistent states and duplicates on retry.

Messages are created before email send; failures leave messages without NotificationEmail rows, so retries will create duplicate messages.

  • Make message creation idempotent (unique key on [campaignId, partnerId]) and use upsert.
  • Add a unique constraint for NotificationEmail (campaignId, partnerId, recipientUserId); use createMany with skipDuplicates: true.
  • Consider an outbox pattern: create “pending email” rows, send, then mark sent within a resilient worker. This avoids partial success when external calls fail.

Also applies to: 162-214

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

34-41: Reset dependent fields when attribute changes.

When the attribute changes, clear value to avoid invalid combos.

-                  onSelect={field.onChange}
+                  onSelect={(val) => {
+                    field.onChange(val);
+                    // Reset dependent fields
+                    // If useCampaignFormContext exposes setValue:
+                    // setValue("triggerCondition.value", undefined, { shouldValidate: true });
+                  }}

Based on learnings

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

70-86: Trim unnecessary dependencies in validation useMemo

status and triggerCondition aren’t referenced; drop them from deps to avoid redundant recomputes.

-  }, [name, subject, groupIds, bodyJson, status, triggerCondition]);
+  }, [name, subject, groupIds, bodyJson]);
packages/email/src/templates/campaign-email.tsx (1)

25-25: Injected Tailwind classes won’t be inlined

Classes inside campaign.body (e.g., px-1, text-blue-700) won’t be processed by React Email’s Tailwind inliner. Either:

  • Inline styles during HTML generation, or
  • Map content to React Email components and style there, or
  • Strip class attributes in the sanitizer if not relied upon.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-action-bar.tsx (1)

65-73: Prevent double-submits: disable Save while submitting/saving

Save is only disabled when not dirty. Users can click again while submitting unless the Button auto-disables on loading. Safer to disable during submitting/saving too.

           <Button
             type="button"
             text="Save changes"
             variant="primary"
             className="h-7 px-2.5 text-xs"
             loading={isSubmitting || isSaving}
             onClick={onSave}
-            disabled={!isDirty}
+            disabled={!isDirty || isSubmitting || isSaving}
           />
apps/web/app/(ee)/api/campaigns/route.ts (1)

66-71: Avoid string drift: derive attribute from the same constant as trigger

Hardcoding "partnerEnrolledDays" risks drift from WORKFLOW_ATTRIBUTE_TRIGGER. Recommend deriving and reusing the same attribute key.

-      const trigger = WORKFLOW_ATTRIBUTE_TRIGGER["partnerEnrolledDays"];
-
-      const triggerCondition: WorkflowCondition = {
-        attribute: "partnerEnrolledDays",
+      const attribute: keyof typeof WORKFLOW_ATTRIBUTE_TRIGGER = "partnerEnrolledDays";
+      const trigger = WORKFLOW_ATTRIBUTE_TRIGGER[attribute];
+      const triggerCondition: WorkflowCondition = {
+        attribute,
         operator: "gte",
         value: 1,
       };
apps/web/app/(ee)/api/campaigns/[campaignId]/duplicate/route.ts (1)

26-27: Avoid shadowing params.campaignId for clarity

The local campaignId inside the transaction shadows the route param. Rename to newCampaignId to reduce confusion and improve readability.

-      const campaignId = createId({ prefix: "cmp_" });
+      const newCampaignId = createId({ prefix: "cmp_" });
@@
-                  campaignId,
+                  campaignId: newCampaignId,
@@
-          id: campaignId,
+          id: newCampaignId,

Ensure all references within the transaction use newCampaignId (including action.data.campaignId and the campaign create data).

Also applies to: 43-44, 56-56

apps/web/lib/api/workflows/tiptap-to-text.ts (1)

44-51: Use trimEnd instead of deprecated trimRight

Minor lint/compat improvement.

-      lines.push(cur.trimRight());
+      lines.push(cur.trimEnd());
@@
-  if (cur.length) lines.push(cur.trimRight());
+  if (cur.length) lines.push(cur.trimEnd());
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/delete-campaign-modal.tsx (1)

27-37: Add onError toast and disable Delete while submitting

Improve UX and prevent double-submits.

   const handleCampaignDeletion = async () => {
     await deleteCampaign(`/api/campaigns/${campaign.id}`, {
       method: "DELETE",
       onSuccess: async () => {
         setShowModal(false);
         await mutatePrefix("/api/campaigns");
         toast.success("Campaign deleted successfully!");
         router.push(`/${slug}/program/campaigns`);
       },
+      onError: (err) => {
+        const message =
+          (err && (err.message || err.error || err.toString())) ||
+          "Failed to delete campaign.";
+        toast.error(message);
+      },
     });
   };
@@
           <Button
             variant="danger"
             className="h-9 w-fit px-3"
             text="Delete"
             loading={isSubmitting}
             onClick={handleCampaignDeletion}
+            disabled={isSubmitting}
           />

Also applies to: 75-81

apps/web/lib/api/workflows/generate-campaign-email-html.ts (2)

12-18: Replace any type with TipTap's JSONContent.

The bodyJson parameter is typed as any, which bypasses type safety. TipTap provides the JSONContent type from @tiptap/core for this purpose.

Apply this diff:

+import type { JSONContent } from "@tiptap/core";
 import { EmailTemplateVariables } from "@/lib/types";
 import { EMAIL_TEMPLATE_VARIABLES } from "@/lib/zod/schemas/campaigns";
 import Image from "@tiptap/extension-image";
 export function generateCampaignEmailHTML({
   bodyJson,
   variables,
 }: {
-  bodyJson: any;
+  bodyJson: JSONContent;
   variables: Partial<EmailTemplateVariables>;
 }): string {

59-69: Consider adding common email formatting tags.

The allowlist omits some commonly used email tags that might be useful:

  • span for inline styling
  • br for line breaks
  • div for block containers
  • code/pre for code snippets

If these tags are intentionally excluded for security, this is fine. Otherwise, consider expanding the allowlist.

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.

3 participants

点击 这是indexloc提供的php浏览器服务,不要输入任何密码和下载