-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Email campaigns #2717
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Email campaigns #2717
Conversation
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughAdds 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
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 }
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (3)
🧰 Additional context used🧬 Code graph analysis (3)apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (3)
apps/web/lib/api/campaigns/get-campaign-events.ts (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-events-modal.tsx (3)
⏰ 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)
🔇 Additional comments (1)
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. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
: Sanitizecampaign.body
before injecting it.
campaign.body
is user-controlled HTML inserted straight intostyledHtml
and rendered viadangerouslySetInnerHTML
, 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 buildingstyledHtml
, 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 forworkspaceId
before upload.The non-null assertion
workspaceId!
is unsafe ifworkspaceId
isundefined
. 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
: RemoveContent-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 fromuseCallback
.
isSavingCampaign
andwatch
are not referenced in thesaveCampaign
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
⛔ 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
...eb/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx
Show resolved
Hide resolved
...eb/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/campaign-editor.tsx
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 ifworkspaceId
can beundefined
. 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 usesgetValues("bodyJson")
, which returns the current (possibly edited) form state. To discard changes and revert to the original campaign content, usecampaign.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 acrossmessagePartnerSchema
andmessageProgramSchema
. 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) andwatch
(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 deprecatedtrimRight()
withtrimEnd()
.The
trimRight()
method is deprecated. UsetrimEnd()
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
📒 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)
frommessageTextSchema
meansMessageSchema.text
(line 17) now only enforcesmin(1)
with no upper bound. SinceMessageSchema
is used byPartnerMessagesSchema
andProgramMessagesSchema
(likely for reading/displaying messages), this could allow validation of messages exceeding 2000 characters if they exist in the database.Confirm whether:
- This is intentional to support longer campaign messages or other message types
- All existing messages in the database respect the 2000-character limit
- The creation schemas (
messagePartnerSchema
,messageProgramSchema
) are the only entry points for new messagesIf 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 benull
orundefined
(perEmailTemplateVariables
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 separatedprogram
andcampaign
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 thetext
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 deprecatedtrimRight()
method; prefertrimEnd()
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
📒 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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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, trippingnoSwitchDeclarations
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 aDuplicate 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
: WraptiptapToPlainText
in a fail-safe helper.A malformed
campaign.bodyJson
(e.g., corruptcontent
field) will throw insidetiptapToPlainText
, abortingcreateMany
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
📒 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
@coderabbitai full review |
✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 redundanthard_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
📒 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 theOptions
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 requiresbodyJson
(z.record(...)) and will throw if it’s null. In apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (lines 32–36), defaultcampaign.bodyJson
to an empty object (e.g.,bodyJson: campaign.bodyJson ?? {}
) before callingCampaignSchema.parse
.packages/ui/package.json (1)
106-106
: Addsanitize-html
and@types/sanitize-html
to dependencies
Include"sanitize-html"
underdependencies
and"@types/sanitize-html"
underdevDependencies
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 viewsThe 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 ifparams
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
: Addalt
attribute for accessibility.This issue was flagged in a previous review. The
<img>
element is missing analt
attribute, which is required for screen reader accessibility.
71-73
: Addalt
attribute for accessibility.This issue was flagged in a previous review. The
<img>
element is missing analt
attribute, which is required for screen reader accessibility.
80-105
: Addmultiple={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
: Returnnull
explicitly instead ofundefined
.This issue was flagged in a previous review. While React accepts
undefined
, the convention is to explicitly returnnull
when a component renders nothing.
52-54
: Don't mutateCAMPAIGN_TYPES
during render.This issue was flagged in a previous review.
Array.prototype.sort
mutates the source array. BecauseCAMPAIGN_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
andaria-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 withscrollbar-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 ofcurrentColor
, 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 ofcurrentColor
, 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 accessesparsedInput
— workspace is derived fromctx
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
toGroupColorCircle
, which can beundefined
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 usessearchParamsObj.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 ifworkspaceId
can beundefined
. 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 sendstriggerCondition
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 butcampaign.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 whentriggerCondition
is cleared.When the client sends
triggerCondition: null
(to remove automation), this code doesn't delete the existing workflow or clearworkflowId
. 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
andqstash.schedules.delete
can fail (e.g., schedule already exists or doesn't exist). Without error handling in thewaitUntil
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
: EnsurebodyJson
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
: Replaceany
types with proper TipTap types.The use of
any
forinitialValue
and thesetContent
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
returnsnull
, 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 asynchandleImageUpload
calls don't await the promises, and theisUploading
state doesn't track multiple concurrent uploads. When multiple files are dropped or pasted:
- All uploads start concurrently
- The first upload to complete sets
isUploading
to false- The UI becomes interactive while other uploads are still in progress
- 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. IfuseWorkspace()
returnsundefined
forslug
, 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:
- Hyphenated attributes (
stroke-width
,stroke-linecap
,stroke-linejoin
) instead of camelCase JSX attributes.- Hard-coded stroke color
#737373
instead ofcurrentColor
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 recipientssendBatchEmail 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 errorsAdd 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 typesAvoid 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 lengthrowCount 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 behaviorAdd 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 URLSkip 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-fetchInclude 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 logicPrevent 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 callscommand
withundefined
. 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 useundefined
. 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 BiomeBiome flags /\u0000/ as an unexpected control char. Use a hexadecimal escape.
Based on static analysis hintsfunction 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 invalidbaseUrl
.The
URL
constructor will throw ifbaseUrl
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 fordvh
Precede yourcalc(100dvh-…)
with acalc(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
: UsecurrentColor
instead of hardcoded stroke color.The hardcoded stroke color
"#737373"
prevents theme-based styling. Consider usingcurrentColor
like other nucleo icons (e.g., text-bold.tsx, text-italic.tsx).Apply this diff:
- stroke="#737373" + stroke="currentColor"
23-23
: UsecurrentColor
instead of hardcoded stroke color.The hardcoded stroke color
"#737373"
prevents theme-based styling. Consider usingcurrentColor
like other nucleo icons.Apply this diff:
- stroke="#737373" + stroke="currentColor"
30-30
: UsecurrentColor
instead of hardcoded fill color.The hardcoded fill color
"#737373"
prevents theme-based styling. Consider usingcurrentColor
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 listscountByType
in its dependencies, butcountByType
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 RecordMake 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 shapeapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/campaign-status-badges.tsx (1)
3-34
: Optionally enforce status coverage with satisfies RecordAdd 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 schemaSchema 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 upstreamOptional: 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 timeYou’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 useMemostatus 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 inlinedClasses 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/savingSave 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 triggerHardcoding "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 clarityThe 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 trimRightMinor 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 submittingImprove 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
: Replaceany
type with TipTap'sJSONContent
.The
bodyJson
parameter is typed asany
, which bypasses type safety. TipTap provides theJSONContent
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 stylingbr
for line breaksdiv
for block containerscode
/pre
for code snippetsIf these tags are intentionally excluded for security, this is fine. Otherwise, consider expanding the allowlist.
Summary by CodeRabbit
New Features
Improvements