Skip to content

Conversation

@devkiran
Copy link
Collaborator

@devkiran devkiran commented Dec 16, 2025

Summary by CodeRabbit

  • New Features

    • UI to create/manage Group Move rules; auto-provision discount codes; new partner "group changed" email template.
  • Improvements

    • Workflows consolidated under a single partner-metrics trigger with richer identity & metrics and multi-condition evaluation.
    • New server-side "move group" workflow action that reassigns partners and remaps discount codes/links.
    • Plan capability flag to enable group-move rules.
  • Bug Fixes

    • Simplified scheduled job and discount-code remap/delete flows.
  • Tests

    • Added tests for group move rules.

@vercel
Copy link
Contributor

vercel bot commented Dec 16, 2025

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

Project Deployment Actions Updated (UTC)
dub Error Error Feb 2, 2026 11:38am

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 16, 2025

📝 Walkthrough

Walkthrough

Adds multi-condition workflow evaluation, a MoveGroup workflow and server-side partner-move operation, group move UI/API/validation, enriched partnerMetricsUpdated workflow context, centralized ACTION_HANDLERS dispatch, discount auto-provisioning (DB + Stripe + cron), and widespread call-site adaptations.

Changes

Cohort / File(s) Summary
Workflow core & types
apps/web/lib/api/workflows/execute-workflows.ts, apps/web/lib/api/workflows/evaluate-workflow-conditions.ts, apps/web/lib/api/workflows/parse-workflow-config.ts, apps/web/lib/types.ts, apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts, apps/web/lib/api/workflows/execute-send-campaign-workflow.ts
Introduce ACTION_HANDLERS-driven executeWorkflows, add array-based evaluateWorkflowConditions, expose parsed conditions array, update WorkflowContext/types (reason, identity, metrics), and replace single-condition checks with multi-condition evaluation.
Move-group workflow & server ops
apps/web/lib/api/workflows/execute-move-group-workflow.ts, apps/web/lib/api/groups/move-partners-to-group.ts, apps/web/lib/api/groups/upsert-group-move-rules.ts, apps/web/lib/api/groups/get-group-move-rules.ts, apps/web/lib/api/groups/find-groups-with-matching-rules.ts, apps/web/lib/api/groups/validate-group-move-rules.ts, apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts, apps/web/app/(ee)/api/groups/rules/route.ts
Add MoveGroup executor, movePartnersToGroup helper, APIs/hooks to get/upsert/validate/find group move rules, persist workflowId on PartnerGroup, and tie workflow lifecycle to group updates/deletes.
Call-site trigger changes
apps/web/lib/api/conversions/track-lead.ts, apps/web/lib/api/conversions/track-sale.ts, apps/web/lib/integrations/shopify/create-sale.ts, apps/web/lib/actions/partners/*.ts, apps/web/app/(ee)/api/stripe/integration/webhook/*, apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts, apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts
Replace WorkflowTrigger enum uses with string "partnerMetricsUpdated", and enrich executeWorkflows calls with reason, identity, and metrics; adapt downstream consumers to new context and evaluateWorkflowConditions.
Discount code lifecycle & Stripe
apps/web/lib/api/discounts/create-discount-code.ts, apps/web/lib/api/discounts/delete-discount-code.ts, apps/web/lib/api/discounts/queue-discount-code-deletion.ts (removed), apps/web/lib/stripe/create-stripe-discount-code.ts, apps/web/lib/actions/partners/*, apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts, apps/web/app/(ee)/api/cron/discount-codes/delete/route.ts, apps/web/app/(ee)/api/discount-codes/*, packages/prisma/schema/discount.prisma, apps/web/lib/zod/schemas/discount.ts
Centralize createDiscountCode, add deleteDiscountCodes (accepts DiscountCode objects), remove queue-based deletion helper, add discount autoProvision fields, adapt cron routes to withCron, and make Stripe code creation partner-aware.
Group UI, form & tests
apps/web/app/.../group-move-rules.tsx, apps/web/app/.../group-additional-settings.tsx, apps/web/app/.../group-settings.tsx, apps/web/tests/partner-groups/index.test.ts, apps/web/lib/swr/use-groups-rules.ts
Add GroupMoveRules UI + form flow, client-side validation against other groups, SWR hook, tests for moveRules persistence, and skeleton/placeholder updates.
Emails & partner notifications
apps/web/lib/api/partners/get-partner-users.ts, apps/web/lib/api/partners/notify-partner-group-change.ts, apps/web/lib/api/partners/get-partner-invite-rewards-and-bounties.ts, packages/email/src/templates/partner-group-changed.tsx, apps/web/lib/email/email-templates-map.ts
Add partner user retrieval and notifyPartnerGroupChange flow; add and register PartnerGroupChanged email template used when partners are moved.
Schemas, migrations & Prisma
apps/web/lib/zod/schemas/workflows.ts, apps/web/lib/zod/schemas/bounties.ts, apps/web/lib/zod/schemas/campaigns.ts, apps/web/lib/zod/schemas/groups.ts, apps/web/scripts/migrations/migrate-workflow-triggers.ts, packages/prisma/schema/*.prisma, packages/prisma/index.ts
Add "between" operator, MoveGroup action, bounty/campaign-specific condition schemas, PartnerGroup.workflowId, WorkflowTrigger.partnerMetricsUpdated, migration script to normalize triggers, discount.autoProvisionEnabledAt, and update Prisma client singleton typing.
UI components & small refactors
apps/web/ui/shared/inline-badge-popover.tsx, apps/web/ui/partners/rewards/rewards-logic.tsx, apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx, packages/ui/src/icons/nucleo/*
Add InlineBadgePopoverAmountInput and refactor amount input usage; add auto-provision toggle in discount sheet; add new icon component/export; minor skeleton/UI adjustments.
Cron & misc call-site swaps
various apps/web/* files
Migrate cron routes to withCron, swap queue→delete-discount-code flows across codebase, update many call sites to use new workflow context and evaluateWorkflowConditions, and add supporting helpers (getGroupMoveRules, upsertGroupMoveRules, etc.).

Sequence Diagram(s)

sequenceDiagram
    participant Source as Event
    participant Dispatcher as executeWorkflows
    participant DB as Prisma
    participant Evaluator as evaluateWorkflowConditions
    participant Executor as executeMoveGroupWorkflow
    participant Worker as movePartnersToGroup

    Source->>Dispatcher: executeWorkflows(trigger:"partnerMetricsUpdated", reason, identity, metrics)
    Dispatcher->>DB: fetch workflows for program & reason
    Dispatcher->>Dispatcher: enrich context (identity.groupId, aggregated metrics)
    Dispatcher->>Executor: ACTION_HANDLERS.moveGroup.execute(workflow, context)
    Executor->>Evaluator: evaluateWorkflowConditions({conditions, attributes})
    Evaluator-->>Executor: boolean result
    alt conditions satisfied
      Executor->>DB: fetch target group & owners
      Executor->>Worker: movePartnersToGroup(programId, partnerIds, userId, targetGroup)
      Worker->>DB: update programEnrollments (groupId, rewards, discount)
      Worker->>Worker: schedule background jobs (remap links, create/delete discount codes, notify partners)
      Worker-->>Executor: done
    else not satisfied
      Executor-->>Dispatcher: no-op
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • TWilson023
  • steven-tey

Poem

🐇 I hopped through rules and metrics bright,
I nudged workflows left and right,
Codes sprout when partners pass the test,
Groups find homes and inboxes rest,
A joyful thump — the changes light.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ 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 'Group move' accurately captures the primary feature introduced in this PR—group move rules and workflows for partners. It is concise and directly reflects the main change.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch group-move

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

❤️ Share

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

… update related schemas. This change includes renaming and restructuring of parameters in the workflow and route files to streamline partner group management.
- Introduced `GroupMoveRule` component to manage rules for moving partners between groups based on specific criteria.
- Updated `GroupAdditionalSettings` to include the new `GroupMoveRule` component.
- Enhanced `GroupSettingsFormSkeleton` with additional settings rows for group ID and slug.
- Refactored `AmountInput` to utilize `InlineBadgePopoverAmountInput` for better input handling.
- Added `UserArrowRight` icon for improved UI representation.
- Introduced `upsertGroupMoveWorkflow` to manage group move rules and workflows.
- Updated `getGroupOrThrow` to include move rules in the group data.
- Enhanced the PATCH route to handle move rules and integrate with the new workflow.
- Refactored `GroupMoveRule` component to utilize react-hook-form for better form management.
- Updated Zod schemas to include move rules in group definitions and updates.
- Improved UI for group additional settings to accommodate new move rule functionality.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

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

⚠️ Outside diff range comments (1)
apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts (1)

132-153: ⚠️ Potential issue | 🟡 Minor

Wrap workflow and group operations in a transaction for atomicity.

The upsertGroupMoveRules operation and subsequent partnerGroup.update at lines 132–153 are not transactional. If a workflow is created but the group update fails, an orphaned workflow record remains in the database and becomes inaccessible due to the @unique constraint on workflowId. Wrap both operations in prisma.$transaction() to ensure they either both succeed or both fail.

🤖 Fix all issues with AI agents
In `@apps/web/app/`(ee)/api/cron/groups/remap-discount-codes/route.ts:
- Line 7: Replace the non-standard zod import "import * as z from 'zod/v4'" with
the canonical import from the zod package; update the import to use the named
export (z) so code using z.object, z.string, etc., continues to work and follows
project guidelines (search for the import line that currently reads import * as
z from "zod/v4" and change it to import { z } from "zod").

In `@apps/web/lib/api/discounts/delete-discount-code.ts`:
- Around line 40-55: The current loop uses Promise.allSettled over chunkOfCodes
and ignores results, which can silently drop failures from queue.enqueueJSON and
create a DB/queue consistency gap; update the logic in delete-discount-code.ts
to first enqueue each chunk via queue.enqueueJSON (referencing queue.enqueueJSON
and APP_DOMAIN_WITH_NGROK) and inspect the Promise.allSettled results, logging
or rethrowing any rejected entries (or failing the operation) so failed enqueues
are visible and retryable, or move the DB delete to occur only after successful
enqueues for each discount code.

In `@apps/web/lib/api/groups/upsert-group-move-rules.ts`:
- Around line 31-48: The current conditional only deletes an existing workflow
when moveRules?.length === 0 and group.workflowId exists, but if moveRules is an
empty array and there is no existing workflow the code falls through and creates
a new empty workflow; update the early-return logic in the upsert flow (the
block using moveRules?.length === 0 and group.workflowId) to treat any empty
array as a "remove rules" signal: when moveRules?.length === 0 return {
workflowId: null } (and delete existing workflow if group.workflowId is present)
so you never create a new workflow with empty triggerConditions; adjust the
branches that follow the empty-array check (the subsequent if (!moveRules)
branch) accordingly so undefined remains used only for the absent/null moveRules
case.
🧹 Nitpick comments (6)
apps/web/lib/api/groups/move-partners-to-group.ts (2)

53-53: Consider using a distinct variable name for filtered partner IDs.

Reassigning the partnerIds parameter shadows the original input and can reduce traceability during debugging. A descriptive name like enrolledPartnerIds would clarify that this list represents validated enrollments.

♻️ Suggested refactor
-  partnerIds = programEnrollments.map(({ partnerId }) => partnerId);
+  const enrolledPartnerIds = programEnrollments.map(({ partnerId }) => partnerId);

Then use enrolledPartnerIds in subsequent code (lines 58, 81, 96, 103, 111).


90-113: Consider logging failures from background tasks.

Promise.allSettled captures rejections but discards them silently. Failed notifications or link recordings could go unnoticed, making debugging harder.

♻️ Suggested improvement
-      await Promise.allSettled([
+      const results = await Promise.allSettled([
         qstash.publishJSON({
           url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/remap-default-links`,
           body: {
             programId,
             groupId: group.id,
             partnerIds,
             userId,
           },
         }),

         triggerDraftBountySubmissionCreation({
           programId,
           partnerIds,
         }),

         recordLink(partnerLinks),

         notifyPartnerGroupChange({
           programId,
           groupId: group.id,
           partnerIds,
         }),
       ]);
+
+      results.forEach((result, index) => {
+        if (result.status === "rejected") {
+          console.error(
+            `Background task ${index} failed in movePartnersToGroup:`,
+            result.reason,
+          );
+        }
+      });
apps/web/app/(ee)/api/workflows/partner-approved/route.ts (1)

160-223: Add structured workflow logging for auto‑provisioning outcomes.
This step returns silently for several skip cases and uses console.error, which makes troubleshooting harder than the other steps. Consider logging start/skip reasons and using the workflow logger for failures.

♻️ Suggested logging improvements
 await context.run("create-discount-codes", async () => {
+  logger.info({
+    message: "Started executing workflow step 'create-discount-codes'.",
+    data: input,
+  });
   if (!groupId) {
+    logger.warn({
+      message: `The partner ${partnerId} is not associated with any group.`,
+    });
     return;
   }

   const group = await prisma.partnerGroup.findUnique({
     where: { id: groupId },
     include: { discount: true },
   });

   if (!group?.discount?.autoProvisionEnabledAt) {
+    logger.info({
+      message: `Discount auto-provisioning not enabled for group ${groupId}.`,
+    });
     return;
   }

   const workspace = await prisma.project.findUniqueOrThrow({
     where: { id: program.workspaceId },
     select: { stripeConnectId: true },
   });

   if (!workspace.stripeConnectId) {
+    logger.warn({
+      message: `Workspace ${program.workspaceId} is not connected to Stripe.`,
+    });
     return;
   }

   const partnerLinks = await prisma.link.findMany({
     where: {
       programId,
       partnerId,
       partnerGroupDefaultLinkId: { not: null },
       discountCode: { is: null },
     },
     select: { id: true },
   });

   if (partnerLinks.length === 0) {
+    logger.info({
+      message: `No eligible links found for partner ${partnerId}.`,
+    });
     return;
   }

   for (const link of partnerLinks) {
     try {
       await createDiscountCode({
         stripeConnectId: workspace.stripeConnectId,
         partner,
         link,
         discount: group.discount,
       });
     } catch (error) {
-      console.error(
-        `Failed to create discount code for link ${link.id}:`,
-        error,
-      );
+      logger.error({
+        message: `Failed to create discount code for link ${link.id}.`,
+        error,
+      });
     }
   }
 });
apps/web/lib/api/groups/upsert-group-move-rules.ts (1)

9-10: Remove .ts extensions from import paths.

TypeScript module resolution typically doesn't require explicit .ts extensions in import paths. Including them can cause issues with some bundlers and is inconsistent with common TypeScript conventions.

♻️ Proposed fix
-import { findGroupsWithMatchingRules } from "./find-groups-with-matching-rules.ts";
-import { getGroupMoveRules } from "./get-group-move-rules.ts";
+import { findGroupsWithMatchingRules } from "./find-groups-with-matching-rules";
+import { getGroupMoveRules } from "./get-group-move-rules";
apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts (1)

438-440: Inefficient: calling deleteDiscountCodes separately for each discount code.

deleteDiscountCodes accepts an array and batches the database delete and Stripe queue operations. Calling it once per discount code creates unnecessary overhead.

♻️ Proposed fix
-            ...discountCodesToDelete.map((discountCode) =>
-              deleteDiscountCodes(discountCode),
-            ),
+            discountCodesToDelete.length > 0 &&
+              deleteDiscountCodes(discountCodesToDelete),
apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts (1)

139-148: Sequential discount code creation may be slow for large partner sets.

Creating discount codes one at a time in a for-loop can be slow and increases the cron job duration. Consider using Promise.all or batched parallel execution (e.g., with p-limit or chunking) to improve throughput.

♻️ Proposed improvement using Promise.all
       if (workspace.stripeConnectId) {
-        for (const { link, partner } of partners) {
-          await createDiscountCode({
+        await Promise.all(
+          partners.map(({ link, partner }) =>
+            createDiscountCode({
               stripeConnectId: workspace.stripeConnectId,
               partner,
               link,
               discount: group.discount,
-          });
-        }
+            }),
+          ),
+        );
       }

Comment on lines +31 to +48
if (moveRules?.length === 0 && group.workflowId) {
await prisma.workflow.delete({
where: {
id: group.workflowId,
},
});

return {
workflowId: null,
};
}

// Do nothing if no move rule is provided
if (!moveRules) {
return {
workflowId: undefined,
};
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Potential edge case: empty moveRules array without existing workflow.

If moveRules is an empty array ([]) and the group has no existing workflowId, the function will proceed to create a new workflow with empty triggerConditions. This may not be the intended behavior—typically an empty array signals "remove rules" rather than "create workflow with no rules."

Consider returning early for empty arrays regardless of existing workflow state:

🛡️ Proposed fix
   if (moveRules?.length === 0 && group.workflowId) {
     await prisma.workflow.delete({
       where: {
         id: group.workflowId,
       },
     });

     return {
       workflowId: null,
     };
   }

+  // Return early if moveRules is an empty array (no workflow to create)
+  if (moveRules?.length === 0) {
+    return {
+      workflowId: undefined,
+    };
+  }
+
   // Do nothing if no move rule is provided
   if (!moveRules) {
     return {
       workflowId: undefined,
     };
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (moveRules?.length === 0 && group.workflowId) {
await prisma.workflow.delete({
where: {
id: group.workflowId,
},
});
return {
workflowId: null,
};
}
// Do nothing if no move rule is provided
if (!moveRules) {
return {
workflowId: undefined,
};
}
if (moveRules?.length === 0 && group.workflowId) {
await prisma.workflow.delete({
where: {
id: group.workflowId,
},
});
return {
workflowId: null,
};
}
// Return early if moveRules is an empty array (no workflow to create)
if (moveRules?.length === 0) {
return {
workflowId: undefined,
};
}
// Do nothing if no move rule is provided
if (!moveRules) {
return {
workflowId: undefined,
};
}
🤖 Prompt for AI Agents
In `@apps/web/lib/api/groups/upsert-group-move-rules.ts` around lines 31 - 48, The
current conditional only deletes an existing workflow when moveRules?.length ===
0 and group.workflowId exists, but if moveRules is an empty array and there is
no existing workflow the code falls through and creates a new empty workflow;
update the early-return logic in the upsert flow (the block using
moveRules?.length === 0 and group.workflowId) to treat any empty array as a
"remove rules" signal: when moveRules?.length === 0 return { workflowId: null }
(and delete existing workflow if group.workflowId is present) so you never
create a new workflow with empty triggerConditions; adjust the branches that
follow the empty-array check (the subsequent if (!moveRules) branch) accordingly
so undefined remains used only for the absent/null moveRules case.

@devkiran
Copy link
Collaborator Author

devkiran commented Feb 2, 2026

@coderabbitai, full review please. Are there any breaking changes?

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 2, 2026

@devkiran, I'll trigger a full review. Based on the changes in this PR, I can identify several breaking changes:

Breaking Changes

1. executeWorkflows Function Signature

File: apps/web/lib/api/workflows/execute-workflows.ts

Old signature:

executeWorkflows({ trigger, context }: { trigger: WorkflowTrigger; context: WorkflowContext })

New signature:

executeWorkflows({ trigger, reason, identity, metrics }: WorkflowContext)

The function now expects destructured fields from WorkflowContext instead of receiving trigger and context separately.

2. WorkflowContext Interface Restructure

File: apps/web/lib/types.ts

Old structure:

{
  programId, partnerId, groupId,
  current/historical metrics
}

New structure:

{
  trigger: WorkflowTrigger;
  reason?: "lead" | "sale" | "commission";
  identity: { programId, partnerId, groupId? };
  metrics?: { current?, aggregated? };
}

3. Workflow Condition Evaluation

  • evaluateWorkflowCondition removed
  • Replaced with evaluateWorkflowConditions accepting { conditions: WorkflowCondition[], attributes: ... }

4. Discount Code Deletion API

Old: queueDiscountCodeDeletion(ids: string[])
New: deleteDiscountCodes(codes: DiscountCode[])

The function now requires full DiscountCode objects instead of just IDs.

5. Schema Changes

  • workflowConditionSchema split into domain-specific schemas:
    • bountyPerformanceConditionSchema
    • campaignTriggerConditionSchema
    • General workflowConditionSchema (with updated shape)

6. Workflow Trigger Migration

  • Enum values (WorkflowTrigger.leadRecorded, saleRecorded, commissionEarned) replaced with string literal "partnerMetricsUpdated"
  • Migration script added: apps/web/scripts/migrations/migrate-workflow-triggers.ts

7. Database Schema Changes

  • PartnerGroup: Added workflowId field (optional, unique)
  • Discount: Added autoProvisionEnabledAt field
  • WorkflowTrigger: Added partnerMetricsUpdated enum member

New Features

  • Group move rules and workflows
  • Auto-provisioning of discount codes when partners join groups
  • Partner notification system for group changes
  • Enhanced workflow action handler system
✅ Actions performed

Full review triggered.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
apps/web/lib/api/workflows/execute-workflows.ts (1)

130-142: ⚠️ Potential issue | 🟠 Major

Gate the groupId check only for workflows that require it.

Lines 137-142 block all workflows when groupId is missing. However, executeSendCampaignWorkflow doesn't require the partner to have a groupId in context—it uses campaign group associations instead. Only AwardBounty and MoveGroup actually check and use groupId from the context, so this is a functional regression that prevents valid campaigns from executing.

Consider gating the check to only AwardBounty and MoveGroup workflows:

🔧 Suggested adjustment
-  if (!programEnrollment.groupId) {
+  const requiresGroupId = workflows.some((workflow) => {
+    const parsed = parseWorkflowConfig(workflow);
+    return [
+      WORKFLOW_ACTION_TYPES.AwardBounty,
+      WORKFLOW_ACTION_TYPES.MoveGroup,
+    ].includes(parsed.action.type);
+  });
+
+  if (requiresGroupId && !programEnrollment.groupId) {
     console.error(
       `Partner ${partnerId} is not enrolled in a group in program ${programId}.`,
     );
     return;
   }
🤖 Fix all issues with AI agents
In `@apps/web/lib/api/workflows/execute-workflows.ts`:
- Around line 66-88: The pre-filtering and commission-check currently call
parseWorkflowConfig directly which can throw and abort processing; wrap calls to
parseWorkflowConfig inside try/catch blocks (for the workflows.filter pre-filter
and the workflows.some commission check) and if parse fails, log the error and
skip that workflow (i.e., treat it as not matching and not requiring
commissions) so a single bad config doesn't stop execution; reference
parseWorkflowConfig, workflows, REASON_TO_ATTRIBUTES, shouldFetchCommissions,
trigger and reason when locating the spots to guard and add the logging/skip
behavior.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

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

⚠️ Outside diff range comments (3)
packages/prisma/schema/workflow.prisma (1)

1-10: ⚠️ Potential issue | 🟡 Minor

Migration script incomplete — clickRecorded not handled.

The migration script at apps/web/scripts/migrations/migrate-workflow-triggers.ts migrates leadRecorded, saleRecorded, and commissionEarned to partnerMetricsUpdated, but does not handle clickRecorded. While clickRecorded appears unused in the codebase, the TODO comment indicates it should be removed after migrations. Add migration logic for clickRecorded before removing it from the enum to prevent schema inconsistencies.

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

115-122: ⚠️ Potential issue | 🟡 Minor

Inconsistent schema usage for triggerCondition in query vs update.

getCampaignsQuerySchema uses workflowConditionSchema (line 120) for parsing triggerCondition, while CampaignSchema and updateCampaignSchema use the new campaignTriggerConditionSchema. This could cause issues if a query includes operators or value types that the stricter campaign schema doesn't support.

Consider using campaignTriggerConditionSchema here for consistency:

Suggested fix
     triggerCondition: z
       .string()
       .pipe(
         z.preprocess(
           (input: string) => JSON.parse(input),
-          workflowConditionSchema,
+          campaignTriggerConditionSchema,
         ),
       )
       .optional(),
apps/web/lib/api/workflows/execute-workflows.ts (1)

137-142: ⚠️ Potential issue | 🟠 Major

Only require groupId for MoveGroup workflows; other workflows should not be blocked by a missing group assignment.

The early return at lines 137-142 prevents all workflows from executing if a partner lacks a group assignment. However, groupId is nullable in the ProgramEnrollment model and is only required for MoveGroup workflows. AwardBounty workflows should execute regardless of group assignment. Either gate the check to only MoveGroup or allow workflows to handle missing groupId individually (similar to how execute-move-group-workflow already does on line 29).

🤖 Fix all issues with AI agents
In `@apps/web/app/`(ee)/api/cron/discount-codes/delete/route.ts:
- Around line 27-34: The success message always reports the discount was
disabled even though disableStripeDiscountCode(code, stripeConnectId) can return
undefined; update the route to branch on the function's return value (the result
of disableStripeDiscountCode) and only call logAndRespond with the success
message when a non-undefined result is returned, otherwise either throw an error
or call logAndRespond with a distinct "not found" or failure message that
includes code and workspace.stripeConnectId so logs reflect the real Stripe
update outcome.

In `@apps/web/lib/api/groups/find-groups-with-matching-rules.ts`:
- Around line 50-59: The current early return in
find-groups-with-matching-rules.ts assumes that if sharedAttributes.length === 0
the rule sets cannot conflict, but disjoint attributes can still be
simultaneously satisfied (e.g., leads ≥ X and conversions ≥ Y); update the logic
where rules1ByAttribute and rules2ByAttribute are compared so you do not
automatically return false for sharedAttributes.length === 0 — either treat
disjoint attribute sets as overlapping by returning true in that branch, or
implement a satisfiability check that combines independent attribute constraints
from rulesForAttribute / rules1ByAttribute and rules2ByAttribute to determine if
there exists an assignment satisfying both rule sets, and replace the return
false with the chosen behavior.

In `@apps/web/lib/api/groups/upsert-group-move-rules.ts`:
- Around line 9-10: Update the two local imports so they match project
convention by removing the ".ts" extension: change the import paths that
reference findGroupsWithMatchingRules and getGroupMoveRules to import from
"./find-groups-with-matching-rules" and "./get-group-move-rules" respectively;
ensure only the extension is removed and identifiers
(findGroupsWithMatchingRules, getGroupMoveRules) remain unchanged.
🧹 Nitpick comments (12)
apps/web/ui/shared/inline-badge-popover.tsx (2)

339-350: Money input handlers applied unconditionally for all input types.

The handleMoneyKeyDown and handleMoneyInputChange handlers are invoked regardless of the type prop. For type="number" inputs that aren't currency/percentage values, these handlers may impose unwanted formatting constraints (e.g., restricting decimal places or allowed characters).

Consider conditionally applying these handlers only for currency/percentage types:

♻️ Proposed fix
         onKeyDown={(e) => {
           if (e.key === "Enter") {
             e.preventDefault();
             setIsOpen(false);
           }
-          handleMoneyKeyDown(e);
+          if (type !== "number") {
+            handleMoneyKeyDown(e);
+          }
           onKeyDown?.(e);
         }}
         onChange={(e) => {
-          handleMoneyInputChange(e);
+          if (type !== "number") {
+            handleMoneyInputChange(e);
+          }
           onChange?.(e);
         }}

317-360: Consider adding displayName for better debugging.

Components created with forwardRef benefit from an explicit displayName for React DevTools and error messages. This is consistent with InlineBadgePopoverInput which also lacks one.

✨ Suggested improvement
 });
+
+InlineBadgePopoverAmountInput.displayName = "InlineBadgePopoverAmountInput";
apps/web/lib/integrations/shopify/create-sale.ts (1)

164-166: Prefer enum usage for workflow triggers (if still exported).

Using the enum keeps compile-time safety and avoids typos during future refactors.

♻️ Suggested change
-import { prisma } from "@dub/prisma";
+import { WorkflowTrigger, prisma } from "@dub/prisma";
...
-          trigger: "partnerMetricsUpdated",
+          trigger: WorkflowTrigger.partnerMetricsUpdated,
apps/web/scripts/migrations/migrate-workflow-triggers.ts (1)

4-19: Ensure the migration exits cleanly by disconnecting Prisma and surfacing failures.

Without a catch/finally, failures can be swallowed and Prisma connections may keep the process alive.

🧹 Suggested cleanup pattern
 async function main() {
   const workflows = await prisma.workflow.updateMany({
     where: {
       trigger: {
         in: ["leadRecorded", "saleRecorded", "commissionEarned"],
       },
     },
     data: {
       trigger: "partnerMetricsUpdated",
     },
   });

   console.log(`Updated ${workflows.count} workflows.`);
 }

-main();
+main()
+  .catch((error) => {
+    console.error("Migration failed:", error);
+    process.exitCode = 1;
+  })
+  .finally(async () => {
+    await prisma.$disconnect();
+  });
apps/web/lib/api/groups/get-group-or-throw.ts (1)

26-35: Optimize the workflow fetch to only include triggerConditions and remove the full object from the response.

The function currently fetches the entire workflow object but only uses triggerConditions for moveRules. Remove workflow from the return shape to keep the payload minimal.

🔧 Suggested optimization
   const group = await prisma.partnerGroup.findUnique({
     // ...
     include: {
       clickReward: includeExpandedFields,
       leadReward: includeExpandedFields,
       saleReward: includeExpandedFields,
       discount: includeExpandedFields,
       utmTemplate: includeExpandedFields,
       partnerGroupDefaultLinks: includeExpandedFields,
       program: includeExpandedFields,
-      workflow: includeExpandedFields,
+      workflow: includeExpandedFields
+        ? { select: { triggerConditions: true } }
+        : false,
     },
   });
   
-  return {
-    ...group,
-    moveRules: group.workflow?.triggerConditions,
-  };
+  const { workflow, ...rest } = group;
+  return {
+    ...rest,
+    moveRules: workflow?.triggerConditions,
+  };
packages/email/src/templates/partner-group-changed.tsx (1)

97-108: Consider using a more stable key for list items.

Using label as a React key could cause issues if two rewards happen to have the same label text. Consider using the index or combining index with label for uniqueness.

♻️ Suggested change
-                    {rewards.map((reward) => (
-                      <Row key={reward.label} className="mb-0 mt-2">
+                    {rewards.map((reward, index) => (
+                      <Row key={`reward-${index}`} className="mb-0 mt-2">

Apply the same pattern for the bounties list at lines 119-130.

apps/web/lib/stripe/create-stripe-discount-code.ts (1)

66-80: The final throw statement is unreachable but serves as a defensive safeguard.

The while loop will always either return successfully or throw within the catch block when attempt >= MAX_ATTEMPTS. The throw at line 80 can never execute. While this is dead code, it acts as a safety net if the loop logic is ever modified.

♻️ Optional: Remove unreachable code
      currentCode = newCode;
    }
  }
-
-  throw new Error("Failed to create Stripe discount code.");
 }

Alternatively, keep it as a defensive assertion with // Should never reach here comment.

apps/web/lib/actions/partners/bulk-deactivate-partners.ts (1)

94-96: Consider simplifying the redundant identity mapping.

The .map((dc) => dc) is an identity function and can be removed for cleaner code:

♻️ Suggested simplification
-        const allDiscountCodes = programEnrollments.flatMap((pe) =>
-          pe.discountCodes.map((dc) => dc),
-        );
+        const allDiscountCodes = programEnrollments.flatMap(
+          (pe) => pe.discountCodes,
+        );
apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts (1)

140-147: Sequential discount code creation may be slow for large batches.

The loop creates discount codes one at a time. For partners with many links, this could be slow. Consider parallelizing with Promise.all or Promise.allSettled.

♻️ Suggested refactor
-      if (workspace.stripeConnectId) {
-        for (const { link, partner } of partners) {
-          await createDiscountCode({
-            stripeConnectId: workspace.stripeConnectId,
-            partner,
-            link,
-            discount: group.discount,
-          });
-        }
-      }
+      if (workspace.stripeConnectId) {
+        await Promise.allSettled(
+          partners.map(({ link, partner }) =>
+            createDiscountCode({
+              stripeConnectId: workspace.stripeConnectId,
+              partner,
+              link,
+              discount: group.discount,
+            }),
+          ),
+        );
+      }
apps/web/app/(ee)/api/cron/groups/remap-default-links/route.ts (1)

15-15: Consider using standard zod import.

Similar to other files in this PR, this uses import * as z from "zod/v4" instead of the standard import { z } from "zod". Per project guidelines, standard schemas should import from the zod package directly.

apps/web/lib/api/groups/validate-group-move-rules.ts (2)

17-19: Minor: Redundant null check.

rule.value == null (loose equality) already covers both null and undefined cases, making || rule.value === undefined redundant.

Suggested simplification
     // Check if value is set
-    if (rule.value == null || rule.value === undefined) {
+    if (rule.value == null) {
       throw new Error(`Rule ${i + 1}: Please enter a value.`);
     }

41-41: Minor: Same redundant pattern for min/max checks.

Similar to the earlier check, min == null and max == null already cover undefined.

Suggested simplification
-      if (min == null || min === undefined || isNaN(min) || min <= 0) {
+      if (min == null || isNaN(min) || min <= 0) {
         throw new Error(
           `Rule ${i + 1}: Please enter a minimum value greater than 0.`,
         );
       }

-      if (max == null || max === undefined || isNaN(max) || max <= 0) {
+      if (max == null || isNaN(max) || max <= 0) {
         throw new Error(
           `Rule ${i + 1}: Please enter a maximum value (limit) greater than 0.`,
         );
       }

Also applies to: 47-47

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@apps/web/lib/api/workflows/execute-move-group-workflow.ts`:
- Around line 56-62: The attributes object currently defaults missing metrics to
0 which can falsely satisfy conditions; update the assignments in
execute-move-group-workflow.ts so each field uses null when the metric is
missing instead of 0 (e.g., replace metrics?.aggregated?.leads ?? 0 with
metrics?.aggregated?.leads ?? null for totalLeads, same for totalConversions,
totalSaleAmount and totalCommissions), ensuring the
Partial<Record<WorkflowConditionAttribute, number | null>> type remains correct
and downstream evaluators treat null as "missing" rather than a numeric zero.
- Around line 36-47: Replace the prisma.programEnrollment.findUniqueOrThrow call
with prisma.programEnrollment.findUnique and add an explicit null check for the
returned programEnrollment; if it is null, gracefully skip/return from
executeMoveGroupWorkflow (matching patterns used in
execute-send-campaign-workflow.ts and execute-workflows.ts) so the flow
continues without throwing. Specifically, update the fetch that assigns
programEnrollment to use prisma.programEnrollment.findUnique({...}) and then: if
(!programEnrollment) return (or otherwise skip processing), ensuring subsequent
code that references programEnrollment.groupId is guarded by this check.
- Around line 80-92: The fetch for the target group uses
prisma.partnerGroup.findUnique({ where: { id: newGroupId } }) without scoping to
the program, which can allow moving a partner into a group from another program;
update the query that retrieves newGroup (the prisma.partnerGroup call using
newGroupId) to include the program constraint (e.g., where: { id: newGroupId,
programId: <programId variable> } or switch to findFirst with both id and
programId) so the lookup enforces the workflow's program context before
proceeding.

…e discount code creation, enhance logging messages, and remove unnecessary partner and link selections.
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

🤖 Fix all issues with AI agents
In `@apps/web/app/`(ee)/api/cron/groups/remap-discount-codes/route.ts:
- Around line 82-98: The update currently uses group.discount?.id which can
become undefined; decide whether you want to clear the discount or skip updating
when the group has no discount and implement the corresponding guard: either
wrap the prisma.discountCode.updateMany call (the block that uses
discountCodesToUpdate and prisma.discountCode.updateMany) in an if
(group.discount) check to skip updates when group.discount is absent, or
explicitly set data.discountId to null when clearing is intended (e.g., set
discountId to group.discount ? group.discount.id : null) so Prisma receives null
rather than undefined.
- Around line 151-158: The loop blindly dereferences link.programEnrollment with
a non-null assertion in the remap-discount-codes handler; update the loop that
calls createDiscountCode so it either filters out links missing
programEnrollment before iterating or performs a null-check inside the loop and
skips (or logs) those entries, ensuring you pass a valid partner to
createDiscountCode; reference the link.programEnrollment property and the
createDiscountCode call in your change and preserve workspace.stripeConnectId,
link, and group.discount when invoking createDiscountCode.
🧹 Nitpick comments (2)
apps/web/app/(ee)/api/workflows/partner-approved/route.ts (2)

160-227: Missing workflow logger usage in new step.

Other steps in this workflow consistently use logger.info and logger.error for observability, but this new step only uses console.error on failure. Consider adding logger calls for consistency and better debugging:

     // Step 2: Auto-provision discount code if enabled
     await context.run("create-discount-codes", async () => {
+      logger.info({
+        message: "Started executing workflow step 'create-discount-codes'.",
+        data: input,
+      });
+
       if (!groupId) {
         return;
       }

Also, the silent error handling (lines 220-225) means discount code creation failures won't surface in workflow logs. If this is intentional for resilience, consider at minimum logging these failures via the workflow logger so they're discoverable.


179-186: Consider consolidating workspace queries.

The workspace is queried three times in this workflow with different field selections (Step 1: id, plan; Step 2: stripeConnectId; Step 4: id, webhookEnabled).

For a minor optimization, you could fetch all required workspace fields once before the steps begin:

const workspace = await prisma.project.findUniqueOrThrow({
  where: { id: program.workspaceId },
  select: {
    id: true,
    plan: true,
    stripeConnectId: true,
    webhookEnabled: true,
  },
});

Then pass the workspace object to each step. This reduces database round-trips, though the current approach is still correct.

Comment on lines +82 to +98
// Update the discount codes to use the new discount if they are equivalent
if (discountCodesToUpdate.length > 0) {
console.log(
`Found ${discountCodesToUpdate.length} discount codes equivalent to the new group's discount. Updating them.`,
);

const programEnrollments = await prisma.programEnrollment.findMany({
await prisma.discountCode.updateMany({
where: {
partnerId: {
in: partnerIds,
id: {
in: discountCodesToUpdate.map(({ id }) => id),
},
programId,
},
include: {
discountCodes: {
include: {
discount: true,
},
},
data: {
discountId: group.discount?.id,
},
});
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Guard against null group.discount before updating.

If group.discount is null, the discountId will be set to undefined, which Prisma may ignore in updateMany (leaving the old value). If the intent is to clear the discount association when the group has no discount, use null explicitly. Otherwise, skip the update when group.discount is absent.

🛡️ Proposed guard
   // Update the discount codes to use the new discount if they are equivalent
-  if (discountCodesToUpdate.length > 0) {
+  if (discountCodesToUpdate.length > 0 && group.discount) {
     console.log(
       `Found ${discountCodesToUpdate.length} discount codes equivalent to the new group's discount. Updating them.`,
     );

     await prisma.discountCode.updateMany({
       where: {
         id: {
           in: discountCodesToUpdate.map(({ id }) => id),
         },
       },
       data: {
-        discountId: group.discount?.id,
+        discountId: group.discount.id,
       },
     });
   }
🤖 Prompt for AI Agents
In `@apps/web/app/`(ee)/api/cron/groups/remap-discount-codes/route.ts around lines
82 - 98, The update currently uses group.discount?.id which can become
undefined; decide whether you want to clear the discount or skip updating when
the group has no discount and implement the corresponding guard: either wrap the
prisma.discountCode.updateMany call (the block that uses discountCodesToUpdate
and prisma.discountCode.updateMany) in an if (group.discount) check to skip
updates when group.discount is absent, or explicitly set data.discountId to null
when clearing is intended (e.g., set discountId to group.discount ?
group.discount.id : null) so Prisma receives null rather than undefined.

Comment on lines +151 to +158
for (const link of links) {
await createDiscountCode({
stripeConnectId: workspace.stripeConnectId,
partner: link.programEnrollment!.partner,
link,
discount: group.discount,
});
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Non-null assertion on programEnrollment could fail.

Line 154 uses link.programEnrollment!.partner, but the type system doesn't guarantee programEnrollment is non-null. If data inconsistency exists (a link without an enrollment), this will throw at runtime. Consider filtering out links without enrollments or adding a null check.

🛡️ Proposed safeguard
       for (const link of links) {
+        if (!link.programEnrollment) {
+          console.warn(`Link ${link.id} has no programEnrollment, skipping.`);
+          continue;
+        }
+
         await createDiscountCode({
           stripeConnectId: workspace.stripeConnectId,
-          partner: link.programEnrollment!.partner,
+          partner: link.programEnrollment.partner,
           link,
           discount: group.discount,
         });
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (const link of links) {
await createDiscountCode({
stripeConnectId: workspace.stripeConnectId,
partner: link.programEnrollment!.partner,
link,
discount: group.discount,
});
}
for (const link of links) {
if (!link.programEnrollment) {
console.warn(`Link ${link.id} has no programEnrollment, skipping.`);
continue;
}
await createDiscountCode({
stripeConnectId: workspace.stripeConnectId,
partner: link.programEnrollment.partner,
link,
discount: group.discount,
});
}
🤖 Prompt for AI Agents
In `@apps/web/app/`(ee)/api/cron/groups/remap-discount-codes/route.ts around lines
151 - 158, The loop blindly dereferences link.programEnrollment with a non-null
assertion in the remap-discount-codes handler; update the loop that calls
createDiscountCode so it either filters out links missing programEnrollment
before iterating or performs a null-check inside the loop and skips (or logs)
those entries, ensuring you pass a valid partner to createDiscountCode;
reference the link.programEnrollment property and the createDiscountCode call in
your change and preserve workspace.stripeConnectId, link, and group.discount
when invoking createDiscountCode.

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