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

Skip to content

Conversation

@devkiran
Copy link
Collaborator

@devkiran devkiran commented Sep 26, 2025

Summary by CodeRabbit

  • New Features

    • Full campaign management: create, list, view, delete campaigns; marketing/transactional types and scheduled sends; SendCampaign workflow delivers campaign emails.
    • Scheduled endpoint to run cron-triggered workflows.
  • Improvements

    • Messages panel now accepts a program object, shows program avatars, and renders campaign emails with subject and expandable body.
    • Batching, deduplication, templated emails, stronger campaign validation and “been in program” timing trigger.
    • Preserve whitespace in message and email text.
  • Integrations

    • Webhook handlers for email delivered/bounced (and opened) and background job scheduling; advanced/enterprise plan gating.

@vercel
Copy link
Contributor

vercel bot commented Sep 26, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Sep 30, 2025 0:04am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 26, 2025

Walkthrough

Adds 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

Cohort / File(s) Summary
Campaign APIs
apps/web/app/(ee)/api/campaigns/route.ts, apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts
New GET/POST for listing/creating campaigns and GET/DELETE by id; zod validation and plan gating; transactional create/delete with optional workflow creation/deletion; conditional QStash scheduling and cleanup.
Cron workflow runner
apps/web/app/(ee)/api/cron/workflows/[workflowId]/route.ts
New POST (force-dynamic) endpoint that verifies QStash, loads/parses workflow, and dispatches execution (SendCampaign) with standardized error handling/logging.
Workflow core & executors
apps/web/lib/api/workflows/parse-workflow-config.ts, .../execute-workflows.ts, .../execute-send-campaign-workflow.ts, .../execute-award-bounty-workflow.ts
Added parseWorkflowConfig; executeWorkflows dispatches by action type; new executeSendCampaignWorkflow implements batched sends, NotificationEmail creation and template rendering; award-bounty executor renamed/refactored to accept full Workflow.
Campaign validation & ID prefixes
apps/web/lib/api/campaigns/validate-campaign.ts, apps/web/lib/api/create-id.ts
New validateCampaign enforcing trigger/type rules and allowed day values; added cmp_ ID prefix.
Zod schemas
apps/web/lib/zod/schemas/campaigns.ts, .../messages.ts, .../workflows.ts
New campaign schemas and query schema; MessageSchema adds subject and type; workflows schema adds partnerEnrolledDays attribute and SendCampaign action type; enums/labels/trigger map updated.
Prisma schema & client types
packages/prisma/schema/campaign.prisma, .../group.prisma, .../message.prisma, .../notification.prisma, .../program.prisma, .../workflow.prisma, packages/prisma/client.ts
Added Campaign/CampaignGroup models and enums; PartnerGroup relation update; Message.type/subject; NotificationEmail campaign link and timestamps; Program.campaigns; Workflow adds partnerEnrolled trigger and campaign relation; client exports updated (MessageType added, WorkflowTriggerType removed).
Messages UI (program-aware + campaign rendering)
apps/web/ui/messages/messages-panel.tsx, apps/web/app/.../program/messages/[partnerId]/page-client.tsx, apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx
MessagesPanel prop changed to program; new internal components (MessageAvatar/MessageHeader/CampaignMessage); campaign messages expandable; optimistic messages include subject: null and type: "direct".
Resend webhook handlers
apps/web/app/api/resend/webhook/route.ts, .../email-delivered.ts, .../email-bounced.ts, .../email-opened.ts
Replaced local secret with Webhook verifier; added handlers for email.delivered and email.bounced updating NotificationEmail timestamps; removed noisy log in opened handler.
Workflow trigger hookup
apps/web/app/(ee)/api/workflows/partner-approved/route.ts
Calls executeWorkflows for partnerEnrolled trigger after existing steps.
Helpers, misc & templates
apps/web/lib/api/bounties/validate-bounty.ts, apps/web/lib/api/workflows/render-email-template.ts, packages/email/src/templates/*, apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts
Inlined parameter type in validate-bounty; added renderEmailTemplate helper; email templates now preserve whitespace (pre-wrap); notify-partner route filters unread messages by type: "direct".

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
Loading
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
Loading
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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested reviewers

  • TWilson023

Poem

"I hop through branches, code in paw,
Campaigns I plant with a careful claw.
Cron drums, queues hum, emails take flight,
Bounces tracked, partners bright.
A rabbit cheers — deploy tonight!"

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title “Email campaign workflows” directly reflects the primary functionality added by this pull request, which centers on building out the email campaign workflow feature across API routes, schema updates, and background job handlers; it is concise, specific, and omits extraneous details.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch email-campaign-backend

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8008c3e and 24ff293.

📒 Files selected for processing (3)
  • packages/email/src/templates/new-bounty-available.tsx (1 hunks)
  • packages/email/src/templates/new-message-from-partner.tsx (1 hunks)
  • packages/email/src/templates/new-message-from-program.tsx (1 hunks)
⏰ 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)
packages/email/src/templates/new-message-from-program.tsx (1)

125-130: LGTM! White-space preservation improves message formatting.

The inline style correctly preserves line breaks and spacing in message text, ensuring user-formatted content displays as intended in emails.

packages/email/src/templates/new-bounty-available.tsx (1)

80-85: LGTM! Consistent white-space handling for bounty descriptions.

The style attribute correctly preserves formatting in bounty descriptions, matching the approach used in the other email templates.

packages/email/src/templates/new-message-from-partner.tsx (1)

87-92: LGTM! White-space preservation aligned across email templates.

The implementation is consistent with the parallel changes in new-message-from-program.tsx and new-bounty-available.tsx, ensuring uniform formatting behavior across all email templates.


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

❤️ Share
🧪 Early access (Sonnet 4.5): enabled

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

  • Public repositories are always opted into early access features.
  • You can enable or disable early access features from the CodeRabbit UI or by updating the CodeRabbit configuration file.

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

@devkiran devkiran requested a review from TWilson023 September 29, 2025 18:24
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🧹 Nitpick comments (11)
packages/prisma/schema/message.prisma (1)

14-16: Consider indexing the type field if filtering by message type.

The new type and subject fields are well-structured. However, if you plan to query messages filtered by type (e.g., fetching only campaign messages), consider adding an index on the type field 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 unused id field from the function parameter type.

The function signature includes "id" in the Pick<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 using DubApiError for consistency with validation patterns.

The validation errors here throw generic Error instances, while similar validation logic in apps/web/lib/api/campaigns/validate-campaign.ts uses DubApiError with structured error codes. Using DubApiError would 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 (bouncedAt vs deliveredAt) 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 waitUntil block schedules a cron job for workflow execution but doesn't handle potential QStash API failures. While waitUntil ensures 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. While parseWorkflowConfig validates 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 programEnrollment query has no take limit 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 renderEmailTemplate function 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 CSS transition-[max-height,padding] classes. While this works, the dual animation approach may be redundant. Consider relying solely on AnimatedSizeContainer for 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

📥 Commits

Reviewing files that changed from the base of the PR and between 16c0fee and 13db299.

📒 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.ts
  • apps/web/lib/api/workflows/execute-workflows.ts
  • packages/prisma/client.ts
  • packages/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.tsx
  • apps/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 MessageType enum 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 executeWorkflows is 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 to executeWorkflows, which internally fetches the required groupId from 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 program object instead of just programImage aligns with the updated MessagesPanel API and enables richer UI rendering with both logo and name.


172-173: LGTM!

The optimistic message data correctly includes the new subject and type fields, with appropriate values for a direct message (subject: null, type: "direct").

packages/prisma/client.ts (1)

15-15: LGTM! Verify removal of WorkflowTriggerType doesn't break existing code.

The addition of MessageType to the exports is correct and necessary for the new message type enum. However, ensure that the removal of WorkflowTriggerType (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 campaigns field establishes a one-to-many relationship with CampaignGroup, 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 campaigns field establishes a one-to-many relationship with the new Campaign model, 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 webhookSecret as a constant and renaming wh to webhook improves readability.


4-5: LGTM! New email event handlers imported.

The new handlers emailDelivered and emailBounced are imported correctly to support email tracking functionality.


31-36: LGTM! New email event cases added correctly.

The email.delivered and email.bounced cases 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:

  1. webhook.verify() throwing on error is the intended behavior
  2. The Next.js error boundary will handle thrown errors appropriately
  3. The verification failure returns an appropriate HTTP error status

Run the following script to check how svix Webhook.verify() behaves:

apps/web/lib/zod/schemas/messages.ts (3)

5-5: LGTM! MessageType enum imported correctly.

The MessageType enum is properly imported from the Prisma client to support the new type field.


18-19: LGTM! Message schema extended for campaign support.

The subject and type fields correctly extend the MessageSchema to support campaign messages alongside direct messages. Both fields are nullable/typed appropriately:

  • subject is nullable for backward compatibility with direct messages
  • type uses nativeEnum(MessageType) for type-safe Prisma enum integration

58-71: Verify if message creation schemas need subject/type fields.

The messagePartnerSchema (lines 58-71) and messageProgramSchema (lines 92-105) define request payloads for creating messages but don't include the new subject and type fields. Confirm whether:

  1. These schemas only support direct messages (no subject/type needed)
  2. Campaign messages are created through a separate API endpoint
  3. Default values for subject (null) and type (direct) are applied server-side

Run 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 programImage to program correctly provides the full program object to the updated MessagesPanel component. 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: null and type: "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 when type is undefined.

The function accepts Partial<z.infer<typeof createCampaignSchema>>, meaning type can be undefined. When type is 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 that type is provided.

If type should 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 validateCampaign is 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 tags might be undefined.


32-43: LGTM - Proper idempotency check prevents duplicate updates.

The check for existing deliveredAt ensures 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 as email-delivered.ts.

This handler has the same missing error handling around database operations as noted in the email-delivered.ts review. Consider applying similar try-catch blocks for robustness.

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

21-21: LGTM: Campaign relation properly added.

The optional campaign relation allows workflows to be associated with campaigns, enabling automation-type campaigns to trigger workflow execution.


5-5: Verify that the new partnerEnrolled trigger is properly integrated across the codebase.

The addition of partnerEnrolled to the WorkflowTrigger enum introduces a new workflow trigger type. Based on the PR context, this trigger is mapped to the partnerEnrolledDays attribute 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:

  1. The 12-hour frequency is appropriate for all partnerEnrolledDays conditions
  2. More frequent execution (e.g., hourly) might be needed for certain trigger conditions
  3. Different schedules should be used based on the condition.value

You 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, and parseWorkflowConfig properly 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 partnerEnrolledDays attribute 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 SendCampaign to the action types enum
  • Refactor existing action to use enum constant instead of string literal (line 78)
  • Introduce new SendCampaign action variant with proper discriminated union structure
  • Maintain type safety with required campaignId field
apps/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 using parseWorkflowConfig for 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" with WORKFLOW_ACTION_TYPES.AwardBounty improves type safety and maintainability. The parseWorkflowConfig utility handles validation and extraction consistently.

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

41-54: LGTM! Proper fetch before delete.

Using findUniqueOrThrow provides 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 workflowId prevents errors when no workflow is associated.


72-91: LGTM! Clean background cleanup with appropriate guards.

The waitUntil pattern 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 deleted campaignId. 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. The ALLOWED_ATTRIBUTE_VALUES_IN_DAYS constant provides useful validation context for related code.


25-27: LGTM! Correct update schema pattern.

Omitting type makes it immutable after creation, which is the correct pattern. Using partial() allows updating individual fields without requiring all of them.


29-40: LGTM! Response schema with consistent shape.

Using nullable().default(null) for triggerCondition ensures the API response always includes this field (as null or the condition object), which is correct for response consistency. The use of GroupSchema.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_URL ensures 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 the subject field.

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:

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 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 the variables object. Nested property access like variables["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

📥 Commits

Reviewing files that changed from the base of the PR and between 13db299 and a9e4994.

📒 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 to null or undefined.

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].id by 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 incorrect notificationEmail records with mismatched emailId values.

Verify that sendBatchEmail and 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-Key header (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 from WORKFLOW_COMPARISON_OPERATORS are used in workflows, this function returns undefined, 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}`);
   }
 }

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (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

📥 Commits

Reviewing files that changed from the base of the PR and between a9e4994 and cdae831.

📒 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.name instead of partnerUser.partner.name at 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].id by 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 sendBatchEmail guarantees 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 returns undefined for 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`
+      );
   }
 }

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🧹 Nitpick comments (3)
apps/web/lib/api/campaigns/validate-campaign.ts (1)

8-11: Consider requiring type parameter for better type safety.

The function signature accepts Partial<z.infer<typeof createCampaignSchema>>, making both type and triggerCondition optional. While this aligns with the two-stage validation pattern, the function silently does nothing when type is 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 waitUntil block schedules workflows asynchronously, but if qstash.schedules.create fails, 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 CampaignStatus enum 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

📥 Commits

Reviewing files that changed from the base of the PR and between cdae831 and 42b9bd1.

📒 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 triggerCondition and 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 contains operator on lines 40-41 is case-sensitive by default in PostgreSQL. If case-insensitive search is desired, consider using mode: '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: scheduledAt field appears unused.

The scheduledAt field 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 CampaignGroup junction table is well-designed with appropriate cascades, a unique constraint to prevent duplicates, and an index on groupId for 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]

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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

  1. The regex pattern [\w.]+ allows dots in variable names (e.g., {{user.name}}), but EMAIL_TEMPLATE_VARIABLE_KEYS contains only simple identifiers. This may be intentional for future extensibility.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 42b9bd1 and 7f23671.

📒 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 fetchtedCampaign is still misspelled (should be fetchedCampaign) 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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7f23671 and 8008c3e.

📒 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 programImage with a structured program prop (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-expanded to communicate state to screen readers, and mark decorative icons with aria-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}
Copy link
Contributor

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.

@steven-tey steven-tey merged commit 7ee85d2 into main Sep 30, 2025
8 checks passed
@steven-tey steven-tey deleted the email-campaign-backend branch September 30, 2025 00:14
@coderabbitai coderabbitai bot mentioned this pull request Oct 10, 2025
@coderabbitai coderabbitai bot mentioned this pull request Nov 5, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants