-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Email campaign workflows #2891
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
Email campaign workflows #2891
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughAdds campaign models and schemas, new campaign CRUD APIs, workflow parsing and executors (SendCampaign + refactor of AwardBounty), cron endpoint for scheduled workflows (QStash-signed), Resend webhook handlers, messaging UI updates for campaign messages, Prisma schema changes, email template rendering, and related helpers and validations. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
participant Client
participant API as /api/campaigns
participant DB as Prisma
participant Q as QStash
rect #F7FAFF
Client->>API: POST /api/campaigns (payload)
API->>DB: Tx: create Campaign + CampaignGroups
API->>DB: Optional: create Workflow (automation)
alt automation + scheduled trigger
API->>Q: schedule cron job for workflow
end
API-->>Client: 201 Campaign (CampaignSchema)
end
sequenceDiagram
autonumber
participant Q as QStash
participant Cron as /api/cron/workflows/{id}
participant DB as Prisma
participant Dispatcher as executeWorkflows
participant Send as executeSendCampaignWorkflow
participant Mail as EmailService
participant Notif as NotificationEmail
Q->>Cron: POST (signed)
Cron->>Cron: verify signature
Cron->>DB: load workflow
alt not found or disabled
Cron-->>Q: 200 skip
else
Cron->>Dispatcher: dispatch(workflow)
Dispatcher->>Send: executeSendCampaignWorkflow(workflow, context)
Send->>DB: load campaign + groups
Send->>DB: find enrollments by condition & groups
Send->>DB: filter already-sent
alt recipients exist
Send->>Mail: sendBatchEmail (personalized)
Send->>Notif: create NotificationEmail records
end
Cron-->>Q: 200 ok
end
sequenceDiagram
autonumber
participant Resend
participant Hook as /api/resend/webhook
participant Handlers as delivered/bounced/opened
participant DB as Prisma
Resend->>Hook: POST event
Hook->>Hook: verify webhook
Hook->>Handlers: route by event.type
Handlers->>DB: find NotificationEmail by emailId
Handlers->>DB: set deliveredAt/openedAt/bouncedAt if unset
Hook-->>Resend: 200
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 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
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (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 (3)
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. 🧪 Early access (Sonnet 4.5): enabledWe are currently testing the Sonnet 4.5 model, which is expected to improve code review quality. However, this model may lead to increased noise levels in the review comments. Please disable the early access features if the noise level causes any inconvenience. Note:
Comment |
…ations and improve clarity.
…d enhance campaign workflow logic
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: 7
🧹 Nitpick comments (11)
packages/prisma/schema/message.prisma (1)
14-16: Consider indexing thetypefield if filtering by message type.The new
typeandsubjectfields are well-structured. However, if you plan to query messages filtered bytype(e.g., fetching only campaign messages), consider adding an index on thetypefield to optimize query performance.Apply this diff to add an index:
@@index([programId, partnerId]) @@index(partnerId) @@index(senderPartnerId) @@index(senderUserId) + @@index(type) }apps/web/lib/api/workflows/parse-workflow-config.ts (2)
8-10: Consider removing unusedidfield from the function parameter type.The function signature includes
"id"in thePick<Workflow, ...>type, but this field is never referenced in the function body. If it's not needed for error context or logging, consider removing it for clarity.Apply this diff if the field is not needed:
export function parseWorkflowConfig( - workflow: Pick<Workflow, "id" | "triggerConditions" | "actions">, + workflow: Pick<Workflow, "triggerConditions" | "actions">, ) {
17-23: Consider usingDubApiErrorfor consistency with validation patterns.The validation errors here throw generic
Errorinstances, while similar validation logic inapps/web/lib/api/campaigns/validate-campaign.tsusesDubApiErrorwith structured error codes. UsingDubApiErrorwould provide better error handling and consistency across the codebase.Apply this diff to align with the established pattern:
+import { DubApiError } from "../errors"; + export function parseWorkflowConfig( workflow: Pick<Workflow, "id" | "triggerConditions" | "actions">, ) { const conditions = z .array(workflowConditionSchema) .parse(workflow.triggerConditions); const actions = z.array(workflowActionSchema).parse(workflow.actions); if (conditions.length === 0) { - throw new Error("No conditions found in workflow."); + throw new DubApiError({ + message: "No conditions found in workflow.", + code: "bad_request", + }); } if (actions.length === 0) { - throw new Error("No actions found in workflow."); + throw new DubApiError({ + message: "No actions found in workflow.", + code: "bad_request", + }); }apps/web/app/api/resend/webhook/email-delivered.ts (1)
19-30: Consider adding error handling for database operations.The Prisma query can throw on database connection errors or other issues. While the webhook handler may have error handling at the route level, consider wrapping database operations in try-catch for robustness and better error logging.
+ try { const notificationEmail = await prisma.notificationEmail.findUnique({ where: { emailId, }, }); if (!notificationEmail) { console.log( `notificationEmail ${emailId} not found for email.delivered webhook.`, ); return; } + } catch (error) { + console.error( + `Error fetching notificationEmail ${emailId}:`, + error + ); + throw error; + }apps/web/app/api/resend/webhook/email-bounced.ts (1)
3-44: Consider extracting shared webhook handler logic.This handler is nearly identical to
email-delivered.ts, differing only in the timestamp field name (bouncedAtvsdeliveredAt) and log messages. Consider extracting a shared utility function to reduce duplication and improve maintainability.Example shared utility:
// webhook-handler-utils.ts async function updateNotificationEmailTimestamp( emailId: string, tags: Record<string, string> | undefined, timestampField: 'deliveredAt' | 'bouncedAt' | 'openedAt', eventName: string ) { if (tags?.type !== "notification-email") { console.log( `Ignoring email.${eventName} webhook for email ${emailId} because it's not a notification email...` ); return; } const notificationEmail = await prisma.notificationEmail.findUnique({ where: { emailId }, }); if (!notificationEmail) { console.log( `notificationEmail ${emailId} not found for email.${eventName} webhook.` ); return; } if (notificationEmail[timestampField]) { return; } await prisma.notificationEmail.update({ where: { emailId }, data: { [timestampField]: new Date() }, }); }Then each handler becomes:
export async function emailBounced({ email_id: emailId, subject, tags }) { await updateNotificationEmailTimestamp(emailId, tags, 'bouncedAt', 'bounced'); }apps/web/app/(ee)/api/campaigns/route.ts (1)
157-180: Consider adding error handling for QStash schedule creation failures.The
waitUntilblock schedules a cron job for workflow execution but doesn't handle potential QStash API failures. WhilewaitUntilensures the response isn't blocked, a schedule creation failure would be silent and prevent the workflow from executing.Consider adding error logging for schedule creation:
waitUntil( (async () => { if (!campaign.workflow) { return; } const { condition } = parseWorkflowConfig(campaign.workflow); // Skip scheduling if the condition is not based on partnerEnrolledDays, // or if the required enrolled days is 0 (immediate execution case) if ( condition.attribute !== "partnerEnrolledDays" || condition.value === 0 ) { return; } - await qstash.schedules.create({ - destination: `${APP_DOMAIN_WITH_NGROK}/api/cron/workflows/${campaign.workflow.id}`, - cron: "0 */12 * * *", // Every 12 hours - scheduleId: campaign.workflow.id, - }); + try { + await qstash.schedules.create({ + destination: `${APP_DOMAIN_WITH_NGROK}/api/cron/workflows/${campaign.workflow.id}`, + cron: "0 */12 * * *", // Every 12 hours + scheduleId: campaign.workflow.id, + }); + } catch (error) { + console.error(`Failed to schedule workflow ${campaign.workflow.id}:`, error); + // Optionally: Send alert or log to monitoring system + } })(), );apps/web/lib/api/workflows/execute-workflows.ts (1)
75-89: Consider adding a default case for unknown action types.The switch statement handles known action types (
AwardBounty,SendCampaign) but doesn't have a default case. WhileparseWorkflowConfigvalidates actions, adding a default case would make the code more defensive and easier to debug if new action types are added but not handled here.switch (action.type) { case WORKFLOW_ACTION_TYPES.AwardBounty: await executeAwardBountyWorkflow({ workflow, context: workflowContext, }); break; case WORKFLOW_ACTION_TYPES.SendCampaign: await executeSendCampaignWorkflow({ workflow, context: workflowContext, }); break; + + default: + console.error( + `Unknown workflow action type: ${action.type} for workflow ${workflow.id}`, + ); + break; }apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (3)
43-66: Consider adding a query limit for large partner sets.The
programEnrollmentquery has notakelimit and deeply includes partner users. For programs with thousands of enrolled partners, this could cause memory issues or timeouts. Consider paginating the query or adding a reasonable upper bound.For example:
let programEnrollments = await prisma.programEnrollment.findMany({ where: { programId, partnerId, status: "approved", ...(campaign.groups.length > 0 && { groupId: { in: campaign.groups.map(({ groupId }) => groupId), }, }), ...buildEnrollmentWhere(condition), }, + take: 10000, // reasonable upper bound include: {
118-166: Consider wrapping message creation and email sending in a transaction.Messages are created (lines 118-133) before emails are sent (lines 136-152). If email sending fails, orphaned message records will exist without corresponding sent emails. Consider using a Prisma transaction or creating messages only after successful email delivery.
183-197: Consider HTML-escaping template variables to prevent XSS.The
renderEmailTemplatefunction performs simple string substitution without HTML escaping. If variables (e.g.,partner.name) contain HTML/script tags, they'll be injected into the email body. While campaign templates are created by program owners (not external users), partner names could theoretically contain malicious content.Consider escaping variables:
+import { escapeHtml } from "@dub/utils"; // or similar utility + function renderEmailTemplate({ template, variables, }: { template: string; variables: Record<string, string | number | null | undefined>; }): string { return template.replace( /{{\s*([\w.]+)(?:\|([^}]+))?\s*}}/g, (_, key, fallback) => { const value = variables[key]; - return value != null ? String(value) : fallback ?? ""; + const result = value != null ? String(value) : fallback ?? ""; + return escapeHtml(result); }, ); }apps/web/ui/messages/messages-panel.tsx (1)
434-461: Consider simplifying animation approach.The email body uses both
AnimatedSizeContainer(with spring physics) and CSStransition-[max-height,padding]classes. While this works, the dual animation approach may be redundant. Consider relying solely onAnimatedSizeContainerfor height animation and removing the CSS transition classes for cleaner, more predictable animation behavior.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (28)
apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts(1 hunks)apps/web/app/(ee)/api/campaigns/route.ts(1 hunks)apps/web/app/(ee)/api/cron/workflows/[workflowId]/route.ts(1 hunks)apps/web/app/(ee)/api/workflows/partner-approved/route.ts(2 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx(2 hunks)apps/web/app/api/resend/webhook/email-bounced.ts(1 hunks)apps/web/app/api/resend/webhook/email-delivered.ts(1 hunks)apps/web/app/api/resend/webhook/email-opened.ts(0 hunks)apps/web/app/api/resend/webhook/route.ts(2 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx(2 hunks)apps/web/lib/api/bounties/validate-bounty.ts(1 hunks)apps/web/lib/api/campaigns/validate-campaign.ts(1 hunks)apps/web/lib/api/create-id.ts(1 hunks)apps/web/lib/api/workflows/execute-award-bounty-workflow.ts(1 hunks)apps/web/lib/api/workflows/execute-send-campaign-workflow.ts(1 hunks)apps/web/lib/api/workflows/execute-workflows.ts(2 hunks)apps/web/lib/api/workflows/parse-workflow-config.ts(1 hunks)apps/web/lib/zod/schemas/campaigns.ts(1 hunks)apps/web/lib/zod/schemas/messages.ts(2 hunks)apps/web/lib/zod/schemas/workflows.ts(5 hunks)apps/web/ui/messages/messages-panel.tsx(3 hunks)packages/prisma/client.ts(1 hunks)packages/prisma/schema/campaign.prisma(1 hunks)packages/prisma/schema/group.prisma(2 hunks)packages/prisma/schema/message.prisma(2 hunks)packages/prisma/schema/notification.prisma(2 hunks)packages/prisma/schema/program.prisma(3 hunks)packages/prisma/schema/workflow.prisma(1 hunks)
💤 Files with no reviewable changes (1)
- apps/web/app/api/resend/webhook/email-opened.ts
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2025-05-29T04:45:18.504Z
Learnt from: devkiran
PR: dubinc/dub#2448
File: packages/email/src/templates/partner-program-summary.tsx:0-0
Timestamp: 2025-05-29T04:45:18.504Z
Learning: In the PartnerProgramSummary email template (packages/email/src/templates/partner-program-summary.tsx), the stat titles are hardcoded constants ("Clicks", "Leads", "Sales", "Earnings") that will always match the ICONS object keys after toLowerCase() conversion, so icon lookup failures are not possible.
Applied to files:
apps/web/lib/zod/schemas/workflows.ts
📚 Learning: 2025-08-25T17:33:45.072Z
Learnt from: devkiran
PR: dubinc/dub#2736
File: apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts:12-12
Timestamp: 2025-08-25T17:33:45.072Z
Learning: The WorkflowTrigger enum in packages/prisma/schema/workflow.prisma contains three values: leadRecorded, saleRecorded, and commissionEarned. All three are properly used throughout the codebase.
Applied to files:
apps/web/lib/zod/schemas/workflows.tsapps/web/lib/api/workflows/execute-workflows.tspackages/prisma/client.tspackages/prisma/schema/workflow.prisma
📚 Learning: 2025-09-17T17:44:03.965Z
Learnt from: TWilson023
PR: dubinc/dub#2857
File: apps/web/lib/actions/partners/update-program.ts:96-0
Timestamp: 2025-09-17T17:44:03.965Z
Learning: In apps/web/lib/actions/partners/update-program.ts, the team prefers to keep the messagingEnabledAt update logic simple by allowing client-provided timestamps rather than implementing server-controlled timestamp logic to avoid added complexity.
Applied to files:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsxapps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx
📚 Learning: 2025-06-19T01:46:45.723Z
Learnt from: steven-tey
PR: dubinc/dub#0
File: :0-0
Timestamp: 2025-06-19T01:46:45.723Z
Learning: PayPal webhook verification in the Dub codebase is handled at the route level in `apps/web/app/(ee)/api/paypal/webhook/route.ts` using the `verifySignature` function. Individual webhook handlers like `payoutsItemFailed` don't need to re-verify signatures since they're only called after successful verification.
Applied to files:
apps/web/app/api/resend/webhook/route.ts
📚 Learning: 2025-08-26T14:20:23.943Z
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/app/api/workspaces/[idOrSlug]/notification-preferences/route.ts:13-14
Timestamp: 2025-08-26T14:20:23.943Z
Learning: The updateNotificationPreference action in apps/web/lib/actions/update-notification-preference.ts already handles all notification preference types dynamically, including newBountySubmitted, through its schema validation using the notificationTypes enum and Prisma's dynamic field update pattern.
Applied to files:
packages/prisma/schema/notification.prisma
🧬 Code graph analysis (15)
apps/web/lib/api/workflows/parse-workflow-config.ts (1)
apps/web/lib/zod/schemas/workflows.ts (2)
workflowConditionSchema(63-67)workflowActionSchema(76-90)
apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (4)
apps/web/app/(ee)/api/campaigns/route.ts (1)
GET(27-76)apps/web/lib/auth/workspace.ts (1)
withWorkspace(42-436)apps/web/lib/zod/schemas/campaigns.ts (1)
CampaignSchema(29-40)apps/web/lib/api/workflows/parse-workflow-config.ts (1)
parseWorkflowConfig(8-30)
apps/web/lib/api/campaigns/validate-campaign.ts (2)
apps/web/lib/zod/schemas/campaigns.ts (2)
createCampaignSchema(8-23)ALLOWED_ATTRIBUTE_VALUES_IN_DAYS(6-6)apps/web/lib/api/errors.ts (1)
DubApiError(75-92)
apps/web/ui/messages/messages-panel.tsx (2)
apps/web/lib/types.ts (2)
Message(613-613)ProgramProps(447-447)packages/utils/src/constants/misc.ts (1)
OG_AVATAR_URL(29-29)
apps/web/lib/zod/schemas/campaigns.ts (2)
apps/web/lib/zod/schemas/workflows.ts (1)
workflowConditionSchema(63-67)apps/web/lib/zod/schemas/groups.ts (1)
GroupSchema(38-51)
apps/web/lib/zod/schemas/workflows.ts (1)
packages/prisma/client.ts (1)
WorkflowTrigger(31-31)
apps/web/lib/api/workflows/execute-workflows.ts (3)
apps/web/lib/api/workflows/parse-workflow-config.ts (1)
parseWorkflowConfig(8-30)apps/web/lib/api/workflows/execute-award-bounty-workflow.ts (1)
executeAwardBountyWorkflow(12-210)apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (1)
executeSendCampaignWorkflow(11-168)
apps/web/lib/api/workflows/execute-award-bounty-workflow.ts (2)
apps/web/lib/types.ts (1)
WorkflowContext(590-607)apps/web/lib/api/workflows/parse-workflow-config.ts (1)
parseWorkflowConfig(8-30)
apps/web/app/(ee)/api/workflows/partner-approved/route.ts (1)
apps/web/lib/api/workflows/execute-workflows.ts (1)
executeWorkflows(16-91)
apps/web/app/(ee)/api/cron/workflows/[workflowId]/route.ts (4)
apps/web/app/(ee)/api/cron/utils.ts (1)
logAndRespond(1-13)apps/web/lib/api/workflows/parse-workflow-config.ts (1)
parseWorkflowConfig(8-30)apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (1)
executeSendCampaignWorkflow(11-168)apps/web/lib/api/errors.ts (1)
handleAndReturnErrorResponse(175-178)
apps/web/lib/zod/schemas/messages.ts (1)
packages/prisma/client.ts (1)
MessageType(15-15)
apps/web/app/api/resend/webhook/route.ts (2)
apps/web/app/api/resend/webhook/email-delivered.ts (1)
emailDelivered(3-44)apps/web/app/api/resend/webhook/email-bounced.ts (1)
emailBounced(3-44)
apps/web/lib/api/bounties/validate-bounty.ts (1)
apps/web/lib/zod/schemas/bounties.ts (1)
createBountySchema(43-72)
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (4)
apps/web/lib/types.ts (2)
WorkflowContext(590-607)WorkflowCondition(579-579)apps/web/lib/api/workflows/parse-workflow-config.ts (1)
parseWorkflowConfig(8-30)apps/web/lib/api/create-id.ts (1)
createId(64-69)packages/email/src/index.ts (1)
sendBatchEmail(32-70)
apps/web/app/(ee)/api/campaigns/route.ts (11)
apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (1)
GET(11-38)apps/web/lib/auth/workspace.ts (1)
withWorkspace(42-436)apps/web/lib/zod/schemas/campaigns.ts (3)
getCampaignsQuerySchema(42-47)CampaignSchema(29-40)createCampaignSchema(8-23)apps/web/app/(ee)/api/cron/workflows/[workflowId]/route.ts (1)
POST(13-58)apps/web/lib/api/utils.ts (1)
parseRequestBody(9-20)apps/web/lib/api/groups/throw-if-invalid-group-ids.ts (1)
throwIfInvalidGroupIds(5-37)apps/web/lib/api/campaigns/validate-campaign.ts (1)
validateCampaign(8-34)apps/web/lib/api/create-id.ts (1)
createId(64-69)apps/web/lib/zod/schemas/workflows.ts (1)
WORKFLOW_ATTRIBUTE_TRIGGER_MAP(28-37)apps/web/lib/api/workflows/parse-workflow-config.ts (1)
parseWorkflowConfig(8-30)packages/utils/src/constants/main.ts (1)
APP_DOMAIN_WITH_NGROK(20-25)
🔇 Additional comments (57)
apps/web/lib/api/bounties/validate-bounty.ts (1)
13-13: LGTM! Inline type simplifies the code.Inlining
Partial<z.infer<typeof createBountySchema>>directly removes an unnecessary type alias layer without affecting type safety or runtime behavior.apps/web/lib/api/create-id.ts (1)
37-38: LGTM!The addition of the
"cmp_"prefix for campaign IDs follows the existing pattern and enables campaign ID generation throughout the application.packages/prisma/schema/message.prisma (1)
1-4: LGTM!The
MessageTypeenum is well-defined with clear values to distinguish between direct messages and campaign messages.apps/web/app/(ee)/api/workflows/partner-approved/route.ts (2)
5-5: LGTM!The import for
executeWorkflowsis correctly added to support the new workflow execution step.
310-326: LGTM!Step 5 correctly integrates workflow execution using the
"partnerEnrolled"trigger. The implementation follows the established pattern of previous steps and properly delegates toexecuteWorkflows, which internally fetches the requiredgroupIdfrom the program enrollment.apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx (2)
127-127: LGTM!Passing the full
programobject instead of justprogramImagealigns with the updatedMessagesPanelAPI and enables richer UI rendering with both logo and name.
172-173: LGTM!The optimistic message data correctly includes the new
subjectandtypefields, with appropriate values for a direct message (subject: null,type: "direct").packages/prisma/client.ts (1)
15-15: LGTM! Verify removal ofWorkflowTriggerTypedoesn't break existing code.The addition of
MessageTypeto the exports is correct and necessary for the new message type enum. However, ensure that the removal ofWorkflowTriggerType(mentioned in the AI summary) doesn't break any existing code that may reference it.Run the following script to verify no remaining usages of
WorkflowTriggerType:packages/prisma/schema/group.prisma (2)
32-32: LGTM! Campaign relation added correctly.The
campaignsfield establishes a one-to-many relationship withCampaignGroup, aligning with the new campaign functionality introduced in this PR.
53-53: Index reordering is cosmetic.Moving
@@index(programId)after@@unique([groupId, url])has no functional impact—Prisma directive order doesn't affect behavior. This is purely a formatting change.packages/prisma/schema/program.prisma (2)
75-75: LGTM! Campaign relation added correctly.The
campaignsfield establishes a one-to-many relationship with the newCampaignmodel, enabling programs to have multiple campaigns.
58-74: Formatting changes are cosmetic.The whitespace alignment adjustments have no semantic impact on the schema.
apps/web/app/api/resend/webhook/route.ts (4)
8-13: LGTM! Webhook setup improved.Extracting
webhookSecretas a constant and renamingwhtowebhookimproves readability.
4-5: LGTM! New email event handlers imported.The new handlers
emailDeliveredandemailBouncedare imported correctly to support email tracking functionality.
31-36: LGTM! New email event cases added correctly.The
email.deliveredandemail.bouncedcases are handled consistently with existing event handlers. The handlers are called after successful webhook verification, following the correct pattern.Based on learnings: This follows the same pattern as PayPal webhook verification, where verification happens at the route level and individual handlers are called only after successful verification.
16-20: Verify error handling for webhook verification.The comment on line 15 states "Throws on error, returns the verified content on success," but the return value from
webhook.verify()is not captured or used. Ensure that:
webhook.verify()throwing on error is the intended behavior- The Next.js error boundary will handle thrown errors appropriately
- The verification failure returns an appropriate HTTP error status
Run the following script to check how
svixWebhook.verify() behaves:apps/web/lib/zod/schemas/messages.ts (3)
5-5: LGTM! MessageType enum imported correctly.The
MessageTypeenum is properly imported from the Prisma client to support the newtypefield.
18-19: LGTM! Message schema extended for campaign support.The
subjectandtypefields correctly extend theMessageSchemato support campaign messages alongside direct messages. Both fields are nullable/typed appropriately:
subjectis nullable for backward compatibility with direct messagestypeusesnativeEnum(MessageType)for type-safe Prisma enum integration
58-71: Verify if message creation schemas need subject/type fields.The
messagePartnerSchema(lines 58-71) andmessageProgramSchema(lines 92-105) define request payloads for creating messages but don't include the newsubjectandtypefields. Confirm whether:
- These schemas only support direct messages (no subject/type needed)
- Campaign messages are created through a separate API endpoint
- Default values for
subject(null) andtype(direct) are applied server-sideRun the following script to check how these schemas are used and if campaign message creation needs these fields:
apps/web/lib/api/workflows/parse-workflow-config.ts (1)
25-29: LGTM - Clear handling of single condition/action limitation.The comment clearly documents the current constraint of supporting only one trigger and one action. The implementation correctly extracts and returns the first elements.
apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx (2)
170-170: LGTM - Aligns with MessagesPanel API update.The prop change from
programImagetoprogramcorrectly provides the full program object to the updatedMessagesPanelcomponent. The conditional rendering guards above ensure the panel is only rendered when messaging is enabled.
214-215: LGTM - Correct optimistic message fields for direct messages.The additions of
subject: nullandtype: "direct"align with the extended Message schema and correctly distinguish direct messages from campaign messages.apps/web/lib/api/campaigns/validate-campaign.ts (3)
12-26: LGTM - Automation campaign validation is correct.The validation logic properly enforces that automation campaigns must have a trigger condition with an allowed value. Error messages are clear and descriptive.
28-33: LGTM - Marketing campaign validation is correct.The validation properly prevents trigger conditions on marketing campaigns, maintaining a clear separation between campaign types.
8-11: Clarify behavior whentypeis undefined.The function accepts
Partial<z.infer<typeof createCampaignSchema>>, meaningtypecan beundefined. Whentypeis undefined, neither validation block executes and the function returns silently. If this is intentional (e.g., for partial updates), consider adding a comment. Otherwise, consider validating thattypeis provided.If
typeshould always be present during validation, apply this diff:export const validateCampaign = ({ type, triggerCondition, }: Partial<z.infer<typeof createCampaignSchema>>) => { + if (!type) { + throw new DubApiError({ + message: "Campaign type is required.", + code: "bad_request", + }); + } + if (type === "automation") {Run this script to check how
validateCampaignis called:apps/web/app/api/resend/webhook/email-delivered.ts (3)
3-11: LGTM - Function signature correctly matches webhook payload.The parameter destructuring appropriately converts snake_case to camelCase, and types align with the Resend webhook payload structure.
12-17: LGTM - Appropriate filtering for notification emails.The early return correctly filters to only process notification emails. The optional chaining safely handles cases where
tagsmight be undefined.
32-43: LGTM - Proper idempotency check prevents duplicate updates.The check for existing
deliveredAtensures the webhook is idempotent and won't overwrite timestamps on duplicate webhook deliveries. The same error handling consideration from the previous comment applies to the update operation.apps/web/app/api/resend/webhook/email-bounced.ts (1)
19-43: Same error handling consideration asemail-delivered.ts.This handler has the same missing error handling around database operations as noted in the
email-delivered.tsreview. Consider applying similar try-catch blocks for robustness.packages/prisma/schema/workflow.prisma (2)
21-21: LGTM: Campaign relation properly added.The optional
campaignrelation allows workflows to be associated with campaigns, enabling automation-type campaigns to trigger workflow execution.
5-5: Verify that the newpartnerEnrolledtrigger is properly integrated across the codebase.The addition of
partnerEnrolledto theWorkflowTriggerenum introduces a new workflow trigger type. Based on the PR context, this trigger is mapped to thepartnerEnrolledDaysattribute in workflow conditions.Run the following script to verify the integration:
apps/web/app/(ee)/api/campaigns/route.ts (5)
1-25: LGTM: Imports are well-organized.All necessary utilities, schemas, and types are properly imported for campaign management functionality.
27-76: LGTM: GET handler correctly implements campaign listing with proper filtering.The handler properly:
- Enforces plan access (advanced/enterprise)
- Implements search and status filtering
- Includes related workflow and group data
- Transforms and validates output via
CampaignSchema
83-94: LGTM: Request validation and campaign validation are properly enforced.The code correctly:
- Parses and validates request body via
createCampaignSchema- Validates group IDs belong to the program
- Validates campaign constraints (trigger conditions for automation type)
96-149: LGTM: Transaction logic properly handles workflow and campaign creation.The transaction correctly:
- Generates prefixed IDs for campaign and workflow
- Creates workflow only for automation-type campaigns with trigger conditions
- Associates workflow with campaign via
workflowId- Creates campaign-group associations atomically
174-178: Verify the cron schedule frequency aligns with business requirements.The workflow is scheduled to run every 12 hours (
0 */12 * * *). For time-sensitive campaigns (e.g., welcome emails after enrollment), a 12-hour delay might not provide the best user experience.Consider whether:
- The 12-hour frequency is appropriate for all
partnerEnrolledDaysconditions- More frequent execution (e.g., hourly) might be needed for certain trigger conditions
- Different schedules should be used based on the
condition.valueYou may want to verify this with product requirements or add a comment explaining the rationale for the 12-hour interval.
apps/web/lib/api/workflows/execute-workflows.ts (2)
1-14: LGTM: Refactored imports align with the new workflow execution architecture.The addition of
WORKFLOW_ACTION_TYPES, executor functions, andparseWorkflowConfigproperly supports the new action-based dispatch pattern.
73-89: LGTM: Cleaner workflow dispatch with centralized config parsing.The refactored approach:
- Centralizes workflow validation via
parseWorkflowConfig- Uses a switch statement for type-safe action dispatch
- Delegates to specialized executor functions
- Improves maintainability and extensibility for future action types
This is a solid improvement over inline condition/action extraction.
packages/prisma/schema/notification.prisma (1)
1-32: LGTM! Schema changes are well-structured.The additions support campaign-related notifications with proper:
- Enum extension for Campaign type
- Nullable campaignId field and relation with cascade delete
- Email lifecycle tracking fields (deliveredAt, openedAt, bouncedAt)
- Appropriate indexing for query performance
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (1)
72-100: LGTM! Deduplication and batching logic is sound.The code correctly:
- Fetches already-sent campaign emails to prevent duplicates
- Uses a Set for efficient O(1) partner ID lookups
- Chunks enrollments into batches of 100 for reasonable API payload sizes
- Returns early if no new recipients remain
packages/prisma/schema/campaign.prisma (1)
1-45: LGTM! Campaign schema is well-designed.The schema correctly models:
- Campaign lifecycle with appropriate status enum values
- One-to-one workflow relationship with cascade delete
- Many-to-many campaign-group relationship via junction table
- Proper indexing and unique constraints
- Cascade deletes to maintain referential integrity
apps/web/lib/zod/schemas/workflows.ts (2)
14-14: LGTM! New workflow attribute follows existing patterns.The
partnerEnrolledDaysattribute is properly integrated:
- Added to WORKFLOW_ATTRIBUTES constant
- Labeled appropriately in WORKFLOW_ATTRIBUTE_LABELS
- Mapped to correct trigger in WORKFLOW_ATTRIBUTE_TRIGGER_MAP
55-90: LGTM! Action schema extensions are well-structured.The changes correctly:
- Add
SendCampaignto the action types enum- Refactor existing action to use enum constant instead of string literal (line 78)
- Introduce new
SendCampaignaction variant with proper discriminated union structure- Maintain type safety with required
campaignIdfieldapps/web/lib/api/workflows/execute-award-bounty-workflow.ts (2)
2-3: LGTM! Clean refactor to centralized workflow parsing.The signature change from
{ condition, action, context }to{ workflow, context }aligns with the broader PR pattern of usingparseWorkflowConfigfor validation and extraction. This improves consistency across workflow executors and centralizes the parsing logic.Also applies to: 7-7, 10-10, 12-18
19-23: Good use of constants for type checking.Replacing the string literal
"awardBounty"withWORKFLOW_ACTION_TYPES.AwardBountyimproves type safety and maintainability. TheparseWorkflowConfigutility handles validation and extraction consistently.apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (4)
41-54: LGTM! Proper fetch before delete.Using
findUniqueOrThrowprovides clean 404 handling and ensures the campaign exists before attempting deletion.
56-70: LGTM! Proper transactional deletion.The transaction ensures atomic deletion of both the campaign and its associated workflow. The conditional check for
workflowIdprevents errors when no workflow is associated.
72-91: LGTM! Clean background cleanup with appropriate guards.The
waitUntilpattern allows the response to return immediately while cleanup happens asynchronously. The conditional logic correctly skips schedule deletion for non-time-based triggers and immediate execution cases (value === 0).
93-93: Verify the return value - should this be campaignId?The DELETE endpoint returns
{ id: programId }instead of the deletedcampaignId. This is unusual for a DELETE operation, where typically the deleted resource ID or a success message is returned. Verify if this is intentional or if it should return the deleted campaign ID.If this should return the campaign ID, apply this diff:
- return NextResponse.json({ id: programId }); + return NextResponse.json({ id: campaignId });apps/web/lib/zod/schemas/campaigns.ts (4)
6-23: LGTM! Well-structured creation schema with proper validation.The schema includes appropriate string validation (trimming, length constraints) and uses
nullish()correctly for optional fields. TheALLOWED_ATTRIBUTE_VALUES_IN_DAYSconstant provides useful validation context for related code.
25-27: LGTM! Correct update schema pattern.Omitting
typemakes it immutable after creation, which is the correct pattern. Usingpartial()allows updating individual fields without requiring all of them.
29-40: LGTM! Response schema with consistent shape.Using
nullable().default(null)fortriggerConditionensures the API response always includes this field (asnullor the condition object), which is correct for response consistency. The use ofGroupSchema.pick({ id: true })appropriately restricts the exposed group fields.
42-47: LGTM! Query schema with sensible defaults.The schema provides sensible defaults (sort by creation date descending) and forward-compatible structure (sortBy enum ready for additional fields). Optional filters for status and search are appropriate.
apps/web/ui/messages/messages-panel.tsx (4)
117-183: LGTM! Campaign message handling is well-structured.The conditional branching between campaign and regular messages is clear, and the refactoring to use modular components (MessageAvatar, MessageHeader, CampaignMessage) improves maintainability and readability.
251-284: LGTM! Avatar logic is well-implemented.The conditional avatar selection based on message type (campaign vs. regular) and the program logo badge for user messages provides a clear visual distinction. The fallback to
OG_AVATAR_URLensures avatars always render.
286-340: LGTM! Header rendering logic is appropriate.The conditional header visibility logic correctly minimizes redundancy for same-side messages while ensuring important metadata (timestamps, status indicators, campaign labels) is displayed when relevant.
420-420: Verify that the Message schema includes thesubjectfield.The code references
message.subject, which should exist in the Message schema for campaign-type messages. Please ensure the MessageSchema definition includes this field to avoid runtime errors.Run the following script to verify the Message schema definition:
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
🧹 Nitpick comments (3)
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (3)
30-41: Consider logging when campaign is not found.The silent return on missing campaigns may make debugging difficult. Consider adding a log statement to track when workflows reference non-existent campaigns.
const campaign = await prisma.campaign.findUnique({ where: { id: campaignId, }, include: { groups: true, }, }); if (!campaign) { + console.warn(`Campaign ${campaignId} not found for workflow ${workflow.id}`); return; }
101-167: Consider wrapping batch operations in a transaction for atomicity.The batch processing creates messages (line 118), sends emails (line 136), and records notification emails (line 155) as separate operations. If email sending fails or the process crashes between steps, the database state will be inconsistent (messages created but emails not recorded, or vice versa).
Consider using Prisma's interactive transactions to ensure atomicity:
for (const programEnrollmentChunk of programEnrollmentsChunks) { await prisma.$transaction(async (tx) => { // Get partner users... const partnerUsers = programEnrollmentChunk.flatMap(...); // Create messages await tx.message.createMany({...}); // Send emails const { data } = await sendBatchEmail(...); // Record notification emails if (data) { await tx.notificationEmail.createMany({...}); } else { throw new Error('Email sending failed, rolling back messages'); } }); }Note: This assumes email sending should be treated as part of the atomic operation. If emails can be sent independently of database state, the current approach may be acceptable.
183-197: Regex allows dots in variable names, but nested access is not implemented.The regex pattern
[\w.]+matches variable names with dots (e.g.,{{ partner.name }}), but line 193 only accesses top-level keys from thevariablesobject. Nested property access likevariables["partner.name"]will fail.If nested property access is not intended, simplify the regex:
return template.replace( - /{{\s*([\w.]+)(?:\|([^}]+))?\s*}}/g, + /{{\s*(\w+)(?:\|([^}]+))?\s*}}/g, (_, key, fallback) => { const value = variables[key]; return value != null ? String(value) : fallback ?? ""; }, );If nested access is intended, implement property path resolution:
const value = key.split('.').reduce((obj, k) => obj?.[k], variables);
📜 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(1 hunks)packages/prisma/schema/program.prisma(2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/prisma/schema/program.prisma
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (4)
apps/web/lib/types.ts (2)
WorkflowContext(590-607)WorkflowCondition(579-579)apps/web/lib/api/workflows/parse-workflow-config.ts (1)
parseWorkflowConfig(8-30)apps/web/lib/api/create-id.ts (1)
createId(64-69)packages/email/src/index.ts (1)
sendBatchEmail(32-70)
⏰ 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 (9)
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (9)
1-17: LGTM!The imports are well-organized and the function signature appropriately supports both cron-triggered (no context) and event-triggered (with context) execution patterns.
18-28: LGTM!The early return for non-matching action types is efficient, and the context fallback logic properly handles both targeted (with partnerId) and broadcast (without partnerId) campaign scenarios.
43-70: LGTM!The enrollment query correctly filters by status, program, partner, group membership, and time-based conditions. The nested includes are necessary for downstream processing, and the early return avoids unnecessary work.
72-97: LGTM!The duplicate detection logic is well-implemented. Using a Set for O(1) lookups and filtering by campaignId, type, and partnerId prevents re-sending campaigns to partners who have already received them.
99-100: LGTM!Chunking enrollments into batches of 100 is a sensible approach to manage rate limits and transaction sizes.
103-115: LGTM!The partner user extraction correctly flattens the enrollment structure while preserving necessary context for both message creation and email sending.
136-152: Verify email is non-null before sending.Line 139 uses a non-null assertion (
partnerUser.email!), but user records may not always have email addresses. This could cause runtime failures when Resend attempts to send tonullorundefined.Filter out users without email addresses before sending:
// Send emails +const partnerUsersWithEmail = partnerUsers.filter((u) => u.email); + +if (partnerUsersWithEmail.length === 0) { + continue; +} + const { data } = await sendBatchEmail( - partnerUsers.map((partnerUser) => ({ + partnerUsersWithEmail.map((partnerUser) => ({ variant: "notifications", - to: partnerUser.email!, + to: partnerUser.email, subject: campaign.subject,
154-166: Array index mapping assumes batch response order matches request order.Line 159 maps
data.data[idx].idby array index, assuming the Resend batch response order matches the request order. If the email provider returns results out of order, fails silently for some emails, or includes partial results, this will create incorrectnotificationEmailrecords with mismatchedemailIdvalues.Verify that
sendBatchEmailand the underlying Resend API guarantee response ordering:#!/bin/bash # Description: Check sendBatchEmail implementation and Resend batch response behavior echo "=== Checking sendBatchEmail implementation ===" rg -n -A 10 "export const sendBatchEmail" packages/email/src/ echo "" echo "=== Searching for Resend batch response handling ===" rg -n -A 10 "sendBatchEmailViaResend" packages/email/src/ echo "" echo "=== Looking for batch response data structure ===" rg -n -C 5 "CreateBatchResponse" packages/email/src/If ordering is not guaranteed, use the
Idempotency-Keyheader (already set at line 149) to match responses to requests, or add an identifier field to track correspondence.
170-181: Handle all comparison operators or throw an error for unsupported ones.The function only handles
"gte"operator. If other operators fromWORKFLOW_COMPARISON_OPERATORSare used in workflows, this function returnsundefined, causing the spread operation at line 53 to silently fail and potentially include unintended enrollments.Add a default case to fail fast:
function buildEnrollmentWhere(condition: WorkflowCondition) { const thresholdDate = subDays(new Date(), condition.value); switch (condition.operator) { case "gte": return { createdAt: { lte: thresholdDate, }, }; + default: + throw new Error(`Unsupported operator: ${condition.operator}`); } }
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
🧹 Nitpick comments (1)
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (1)
30-41: Consider logging when campaign is not found.The silent return on line 40 may hide configuration errors in scheduled workflows. Adding a log statement would improve observability for debugging missing campaigns.
const campaign = await prisma.campaign.findUnique({ where: { id: campaignId, }, include: { groups: true, }, }); if (!campaign) { + console.warn(`Campaign ${campaignId} not found for workflow ${workflow.id}`); return; }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (4)
apps/web/lib/types.ts (2)
WorkflowContext(590-607)WorkflowCondition(579-579)apps/web/lib/api/workflows/parse-workflow-config.ts (1)
parseWorkflowConfig(8-30)apps/web/lib/api/create-id.ts (1)
createId(64-69)packages/email/src/index.ts (1)
sendBatchEmail(32-70)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (6)
apps/web/lib/api/workflows/execute-send-campaign-workflow.ts (6)
72-97: LGTM! Efficient deduplication logic.The use of a Set for O(1) lookup to prevent duplicate campaign sends is efficient and correct.
118-133: Personalization uses partner organization name, not individual user name.Both the message (line 129) and email (line 144) use
partner.name(the organization name), not the individual user's name. If you want personalized greetings for each user, consider using the user's name instead.To personalize to individual users:
await prisma.message.createMany({ data: programEnrollmentChunk.map((programEnrollment) => ({ id: createId({ prefix: "msg_" }), programId: programEnrollment.programId, partnerId: programEnrollment.partnerId, senderUserId: campaign.userId, type: "campaign", subject: campaign.subject, text: renderEmailTemplate({ template: campaign.body, variables: { - name: programEnrollment.partner.name, + name: programEnrollment.partner.users[0]?.user.name ?? programEnrollment.partner.name, }, }), })), });And update the email sending to use
partnerUser.nameinstead ofpartnerUser.partner.nameat line 144.
183-197: LGTM! Simple and effective template rendering.The regex-based template substitution correctly handles variable replacement with optional fallback values. The implementation is safe and appropriate for the use case.
103-115: Verify partner users have email addresses before processing.Line 139 uses
partnerUser.email!, but the user data structure doesn't filter out users without email addresses. This could cause runtime failures when sending emails.Add validation to filter users with email addresses:
// Get partner users to notify -const partnerUsers = programEnrollmentChunk.flatMap((enrollment) => +const partnerUsers = programEnrollmentChunk.flatMap((enrollment) => enrollment.partner.users.map(({ user }) => ({ ...user, partner: { ...enrollment.partner, users: undefined, }, enrollment: { ...enrollment, partner: undefined, }, })), -); +).filter((user) => user.email); + +if (partnerUsers.length === 0) { + continue; +}
154-166: Array index mapping may be unreliable if sendBatchEmail returns out of order.Line 159 maps
data.data[idx].idby array index, assuming the batch email response order matches the request order. If the email service returns results out of order or skips failed sends, this will create incorrect NotificationEmail records.Verify that
sendBatchEmailguarantees response order matches request order by checking the Resend API documentation:Does the Resend batch email API guarantee that the response order matches the request order?
170-181: Add default case to handle unsupported operators.The function only handles
"gte"and returnsundefinedfor other operators. When undefined is spread in line 53, the query silently omits the date filter, potentially sending campaigns to the wrong partners.Add a default case:
function buildEnrollmentWhere(condition: WorkflowCondition) { const thresholdDate = subDays(new Date(), condition.value); switch (condition.operator) { case "gte": return { createdAt: { lte: thresholdDate, }, }; + default: + throw new Error( + `Unsupported operator: ${condition.operator} in workflow condition` + ); } }
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
🧹 Nitpick comments (3)
apps/web/lib/api/campaigns/validate-campaign.ts (1)
8-11: Consider requiringtypeparameter for better type safety.The function signature accepts
Partial<z.infer<typeof createCampaignSchema>>, making bothtypeandtriggerConditionoptional. While this aligns with the two-stage validation pattern, the function silently does nothing whentypeis undefined, which could hide bugs if callers forget to pass it.Consider this more explicit signature:
export const validateCampaign = ({ type, triggerCondition, -}: Partial<z.infer<typeof createCampaignSchema>>) => { +}: Pick<z.infer<typeof createCampaignSchema>, 'type' | 'triggerCondition'>) => {Based on learnings.
apps/web/app/(ee)/api/campaigns/route.ts (1)
157-180: Consider error handling for QStash schedule creation.The
waitUntilblock schedules workflows asynchronously, but ifqstash.schedules.createfails, the error won't be reported to the client. This could result in campaigns being created without their scheduled execution working.Consider adding error logging:
waitUntil( (async () => { + try { if (!campaign.workflow) { return; } const { condition } = parseWorkflowConfig(campaign.workflow); // Skip scheduling if the condition is not based on partnerEnrolledDays, // or if the required enrolled days is 0 (immediate execution case) if ( condition.attribute !== "partnerEnrolledDays" || condition.value === 0 ) { return; } await qstash.schedules.create({ destination: `${APP_DOMAIN_WITH_NGROK}/api/cron/workflows/${campaign.workflow.id}`, cron: "0 */12 * * *", // Every 12 hours scheduleId: campaign.workflow.id, }); + } catch (error) { + await log({ + message: `Failed to schedule workflow ${campaign.workflow?.id}: ${error.message}`, + type: "errors", + }); + } })(), );packages/prisma/schema/campaign.prisma (1)
1-11: LGTM! Consider documenting campaign status lifecycle.The enum definitions are clear and appropriate. The
CampaignStatusenum includes states for both one-time (marketing) and recurring (transactional/automation) campaigns.Consider adding comments to clarify the status lifecycle:
enum CampaignStatus { - draft - active - paused - sent + draft // Initial state for all campaigns + active // Campaign is enabled and sending + paused // Temporarily disabled + sent // Completed (one-time marketing campaigns) }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
apps/web/app/(ee)/api/campaigns/route.ts(1 hunks)apps/web/lib/api/campaigns/validate-campaign.ts(1 hunks)packages/prisma/schema/campaign.prisma(1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-07-30T15:25:13.936Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
Applied to files:
apps/web/lib/api/campaigns/validate-campaign.ts
🧬 Code graph analysis (2)
apps/web/lib/api/campaigns/validate-campaign.ts (2)
apps/web/lib/zod/schemas/campaigns.ts (2)
createCampaignSchema(8-23)ALLOWED_ATTRIBUTE_VALUES_IN_DAYS(6-6)apps/web/lib/api/errors.ts (1)
DubApiError(75-92)
apps/web/app/(ee)/api/campaigns/route.ts (12)
apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (1)
GET(11-38)apps/web/lib/auth/workspace.ts (1)
withWorkspace(42-436)apps/web/lib/zod/schemas/campaigns.ts (3)
getCampaignsQuerySchema(42-47)CampaignSchema(29-40)createCampaignSchema(8-23)apps/web/app/(ee)/api/cron/workflows/[workflowId]/route.ts (1)
POST(13-58)apps/web/lib/api/utils.ts (1)
parseRequestBody(9-20)apps/web/lib/api/groups/throw-if-invalid-group-ids.ts (1)
throwIfInvalidGroupIds(5-37)apps/web/lib/api/campaigns/validate-campaign.ts (1)
validateCampaign(8-34)apps/web/lib/api/create-id.ts (1)
createId(64-69)apps/web/lib/types.ts (1)
WorkflowAction(586-586)apps/web/lib/zod/schemas/workflows.ts (1)
WORKFLOW_ATTRIBUTE_TRIGGER_MAP(28-37)apps/web/lib/api/workflows/parse-workflow-config.ts (1)
parseWorkflowConfig(8-30)packages/utils/src/constants/main.ts (1)
APP_DOMAIN_WITH_NGROK(20-25)
⏰ 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 (10)
apps/web/lib/api/campaigns/validate-campaign.ts (2)
12-26: Validation logic is correct and well-structured.The transactional campaign validation properly checks for the presence of
triggerConditionand validates its value against the allowed list. Error messages are clear and actionable.
28-34: LGTM!The marketing campaign validation correctly ensures that trigger conditions are not allowed for marketing campaigns, with a clear error message.
apps/web/app/(ee)/api/campaigns/route.ts (5)
1-24: LGTM!Imports are well-organized and all appear necessary for the route implementation.
26-76: GET handler implementation is solid.The query logic correctly filters by status and search terms, includes related workflow and groups data, and transforms the results to match the expected schema format.
Note: The
containsoperator on lines 40-41 is case-sensitive by default in PostgreSQL. If case-insensitive search is desired, consider usingmode: 'insensitive':...(search && { OR: [ - { name: { contains: search } }, - { subject: { contains: search } }, + { name: { contains: search, mode: 'insensitive' } }, + { subject: { contains: search, mode: 'insensitive' } }, ], }),
78-94: LGTM!Request parsing and validation flow is well-structured with proper separation between schema validation and business logic validation.
96-149: Transaction logic is correct and well-structured.The transaction properly handles the conditional workflow creation for transactional campaigns and ensures atomicity between campaign, workflow, and group creation. The pre-generated campaign ID allows the workflow action to reference it correctly.
174-178: QStash schedule deletion is already handled in the campaign delete handler; no further action required.packages/prisma/schema/campaign.prisma (3)
23-23: Note:scheduledAtfield appears unused.The
scheduledAtfield is defined but not set in the campaign creation endpoint (apps/web/app/(ee)/api/campaigns/route.ts). This might be intended for future functionality to schedule one-time marketing campaigns.If this field is not yet implemented, consider adding a TODO comment in the schema or removing it until needed.
35-45: LGTM!The
CampaignGroupjunction table is well-designed with appropriate cascades, a unique constraint to prevent duplicates, and an index ongroupIdfor efficient lookups.
28-28: <!-- [scratchpad]
[tasks review_cascade_direction_2/2]
After running the script, inspect if deletion logic exists in route.ts or WorkflowService.
[end]
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
🧹 Nitpick comments (1)
apps/web/lib/api/workflows/render-email-template.ts (1)
35-41: Implementation is correct with minor observations.The regex and replacer logic properly handles:
- Whitespace around variable names
- Optional fallback values
- Null/undefined checks (using
!= null)- Type coercion for numbers
Two observations:
The regex pattern
[\w.]+allows dots in variable names (e.g.,{{user.name}}), butEMAIL_TEMPLATE_VARIABLE_KEYScontains only simple identifiers. This may be intentional for future extensibility.When both the variable value and fallback are missing/null, the function defaults to an empty string. This behavior is reasonable but not explicitly documented in the JSDoc.
If dots in variable names aren't needed, consider simplifying the regex:
- return template.replace( - /{{\s*([\w.]+)(?:\|([^}]+))?\s*}}/g, + return template.replace( + /{{\s*(\w+)(?:\|([^}]+))?\s*}}/g, (_, key, fallback) => {Or document the empty string default in the JSDoc:
* - `{{variableName}}` - replaced with the variable value - * - `{{variableName|fallback}}` - replaced with the variable value, or fallback if variable is null/undefined + * - `{{variableName|fallback}}` - replaced with the variable value, or fallback if variable is null/undefined, or empty string if both are missing
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts(1 hunks)apps/web/lib/api/workflows/execute-send-campaign-workflow.ts(1 hunks)apps/web/lib/api/workflows/render-email-template.ts(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/web/lib/api/workflows/execute-send-campaign-workflow.ts
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (4)
apps/web/app/(ee)/api/campaigns/route.ts (1)
GET(27-76)apps/web/lib/auth/workspace.ts (1)
withWorkspace(42-436)apps/web/lib/zod/schemas/campaigns.ts (1)
CampaignSchema(29-40)apps/web/lib/api/workflows/parse-workflow-config.ts (1)
parseWorkflowConfig(8-30)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (3)
apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts (1)
27-33: The typo in variable name persists.The variable
fetchtedCampaignis still misspelled (should befetchedCampaign) as previously flagged.Apply this diff:
- const fetchtedCampaign = CampaignSchema.parse({ + const fetchedCampaign = CampaignSchema.parse({ ...campaign, groups: campaign.groups.map(({ groupId }) => ({ id: groupId })), triggerCondition: campaign.workflow?.triggerConditions?.[0], }); - return NextResponse.json(fetchtedCampaign); + return NextResponse.json(fetchedCampaign);apps/web/lib/api/workflows/render-email-template.ts (2)
1-19: Excellent documentation!The JSDoc is comprehensive, clearly explains the Handlebars-style syntax with fallback support, and provides a helpful example.
23-34: LGTM on type signature design.The function signature properly uses indexed access types and Partial<Record<...>> to allow optional variables with flexible value types.
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
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts(1 hunks)apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts(1 hunks)apps/web/lib/api/workflows/execute-send-campaign-workflow.ts(1 hunks)apps/web/ui/messages/messages-panel.tsx(3 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
- apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts
- apps/web/lib/api/workflows/execute-send-campaign-workflow.ts
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/ui/messages/messages-panel.tsx (3)
apps/web/lib/types.ts (2)
Message(613-613)ProgramProps(447-447)packages/utils/src/constants/misc.ts (1)
OG_AVATAR_URL(29-29)packages/ui/src/animated-size-container.tsx (1)
AnimatedSizeContainer(67-67)
⏰ 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 (7)
apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts (1)
52-57: LGTM! Type filter correctly restricts notifications to direct messages.The addition of
type: "direct"appropriately prevents campaign messages from triggering partner notifications through this endpoint. The clarifying comments and logical field ordering improve readability.apps/web/ui/messages/messages-panel.tsx (6)
1-21: LGTM! Clean imports and well-typed Sender interface.The new imports support the campaign message UI (AnimatedSizeContainer, Envelope, ChevronRight), and the Sender interface provides a clear, reusable type for avatar and header logic.
27-35: LGTM! Program prop provides necessary context for campaign messages.Replacing
programImagewith a structuredprogramprop (logo + name) appropriately supports the new campaign message rendering and program-based avatars while maintaining type safety.
117-183: LGTM! Clear separation of campaign and direct message rendering.The type-based branching and modular components (MessageAvatar, MessageHeader) improve maintainability and reduce duplication while clearly distinguishing campaign emails from direct messages.
251-284: LGTM! MessageAvatar correctly handles campaign and direct message contexts.The component appropriately switches between program and sender avatars based on message type, and the program badge logic correctly appears only on direct messages from program users.
286-340: LGTM! MessageHeader provides appropriate context for both message types.The component correctly displays sender/program names, timestamps, and status indicators with proper conditional logic, and the "Email sent" label clearly distinguishes campaign messages.
399-433: Add accessibility attributes to the expansion button.The toggle button is still missing accessibility attributes that were flagged in a previous review. Add
aria-expandedto communicate state to screen readers, and mark decorative icons witharia-hidden="true".Apply this diff to improve accessibility:
<button onClick={() => setIsExpanded(!isExpanded)} + aria-expanded={isExpanded} className={cn( "flex w-full items-center justify-between gap-2 rounded-t-xl px-4 py-2.5 pb-2", !isExpanded && "rounded-b-xl", isExpanded && "border-b border-neutral-200", )} > <div className="flex min-w-0 items-center gap-2"> <Envelope className={cn( "text-content-default size-4 shrink-0", isMySide && "text-content-inverted", )} + aria-hidden="true" /> <span className={cn( "text-content-default truncate text-sm font-medium", isMySide && "text-content-inverted", )} > {message.subject} </span> </div> <div className="flex shrink-0 items-center gap-1 text-xs font-semibold"> <p>{isExpanded ? "Hide" : "Show"} email</p> <ChevronRight className={cn( "size-3.5 transition-transform duration-200", isExpanded && "rotate-90", )} + aria-hidden="true" /> </div> </button>
| isMySide && "text-content-inverted", | ||
| )} | ||
| > | ||
| {message.subject} |
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.
🛠️ Refactor suggestion | 🟠 Major
🧩 Analysis chain
Verify that campaign messages always include a subject.
The code assumes message.subject is always present for campaign-type messages. Ensure the validation layer enforces a required subject for campaign messages to prevent rendering issues.
Run the following script to check the Message schema and campaign-related validation:
🏁 Script executed:
#!/bin/bash
# Description: Verify Message schema includes required subject for campaign messages
# Check Message schema definition
echo "=== Message Schema ==="
ast-grep --pattern 'const MessageSchema = z.object({
$$$
})'
# Check for campaign message validation
echo -e "\n=== Campaign Message Validation ==="
rg -nP --type=ts 'type.*["\']campaign["\']' -A 5 -B 5
# Check Prisma schema for Message model
echo -e "\n=== Prisma Message Model ==="
fd -e prisma -x cat {} \; | rg -A 20 'model Message'Length of output: 1681
Require non-null subject or add UI fallback
The Zod MessageSchema marks subject as z.string().nullable(), so message.subject may be null and render as “null”. Either make subject non-nullable for campaign messages in the schema (e.g. via a discriminated union) or guard the JSX: {message.subject ?? ''}.
🤖 Prompt for AI Agents
In apps/web/ui/messages/messages-panel.tsx around line 420, message.subject is
nullable per the Zod MessageSchema so rendering could show "null"; update the
JSX to guard against null by rendering a fallback (e.g., use the nullish
coalescing operator to render an empty string or a suitable placeholder: replace
direct {message.subject} with {message.subject ?? ''} or another UX-friendly
fallback), or alternatively adjust the MessageSchema to make subject
non-nullable for campaign messages using a discriminated union and ensure all
call sites reflect the schema change.
Summary by CodeRabbit
New Features
Improvements
Integrations