Skip to content

Display referral and group moves changelog#3426

Closed
steven-tey wants to merge 161 commits intomainfrom
group-move
Closed

Display referral and group moves changelog#3426
steven-tey wants to merge 161 commits intomainfrom
group-move

Conversation

@steven-tey
Copy link
Collaborator

@steven-tey steven-tey commented Feb 6, 2026

Summary by CodeRabbit

Release Notes

  • New Features

    • Added group move rules to automatically move partners between groups based on performance metrics
    • Added activity logging and audit trail for partner and referral changes
    • Added auto-provisioning of discount codes for partners in groups with this setting enabled
    • Added partner group history tracking with ability to view change details and timeline
    • Added new email notification when partners are moved to different groups
  • Documentation

    • Updated email template support with partner group change notification

… 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.
@vercel
Copy link
Contributor

vercel bot commented Feb 6, 2026

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

Project Deployment Actions Updated (UTC)
dub Ready Ready Preview Feb 6, 2026 2:32am

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 6, 2026

📝 Walkthrough

Walkthrough

This comprehensive PR introduces group move rules, restructures workflow evaluation to support multiple conditions and a new "partnerMetricsUpdated" trigger, refactors discount code deletion, implements activity logging with group change tracking, and adds auto-provisioning of discount codes. Multiple API routes, database schemas, and workflow execution logic are refactored to support these interconnected features.

Changes

Cohort / File(s) Summary
Workflow Evaluation & Execution
apps/web/lib/api/workflows/evaluate-workflow-conditions.ts, apps/web/lib/api/workflows/execute-workflows.ts, apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts, apps/web/lib/api/workflows/execute-send-campaign-workflow.ts, apps/web/lib/api/workflows/parse-workflow-config.ts
Replaced single-condition evaluation with multi-condition evaluator; introduced new ACTION_HANDLERS map; added REASON_TO_ATTRIBUTES filtering; restructured WorkflowContext to accept identity, metrics, and reason fields; updated condition/attribute context extraction and evaluation flow.
Group Move Rules System
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/find-groups-with-matching-rules.ts, apps/web/lib/api/groups/validate-group-move-rules.ts, apps/web/lib/api/groups/get-group-move-rules.ts, apps/web/lib/api/groups/get-group-or-throw.ts, apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts, apps/web/app/(ee)/api/groups/rules/route.ts, packages/prisma/schema/group.prisma
New group move rules engine with overlap detection, conflict validation, and partner movement orchestration; added workflowId relation to PartnerGroup; introduced API route for fetching group rules with plan-based access control.
Discount Code Refactor
apps/web/lib/api/discounts/delete-discount-code.ts, apps/web/lib/api/discounts/queue-discount-code-deletion.ts, apps/web/lib/api/discounts/create-discount-code.ts, apps/web/lib/api/discounts/construct-discount-code.ts, apps/web/app/(ee)/api/cron/discount-codes/[discountCodeId]/delete/route.ts, apps/web/app/(ee)/api/cron/discount-codes/delete/route.ts
Replaced async queueing with direct deletion via deleteDiscountCodes; introduced constructDiscountCode and createDiscountCode utilities; consolidated cron deletion endpoint from per-ID to batch; updated all call sites to pass full discount code objects instead of IDs.
Workflow Trigger Migration
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/partners/create-partner-commission.ts, apps/web/lib/actions/partners/create-manual-commission.ts, apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts, apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts, apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts
Replaced WorkflowTrigger enum usage with "partnerMetricsUpdated" string literal; added reason, identity.workspaceId, and metrics.current fields to all workflow invocations; removed WorkflowTrigger imports across webhook and conversion handlers.
Activity Logging System
apps/web/lib/api/activity-log/track-activity-log.ts, apps/web/lib/api/activity-log/build-change-set.ts, apps/web/app/api/activity-logs/route.ts, apps/web/lib/swr/use-activity-logs.ts, packages/prisma/schema/activity.prisma, apps/web/lib/zod/schemas/activity-log.ts
Introduced comprehensive activity logging with support for batch operations, changeSet tracking, optional userId, and query-based filtering; added Prisma schema fields and Zod schemas for validation.
Activity Log UI Components
apps/web/ui/activity-logs/activity-feed.tsx, apps/web/ui/activity-logs/activity-log-registry.tsx, apps/web/ui/activity-logs/partner-group-changed-renderer.tsx, apps/web/ui/activity-logs/partner-group-activity-item.tsx, apps/web/ui/activity-logs/partner-group-activity-section.tsx, apps/web/ui/activity-logs/partner-group-history-sheet.tsx, apps/web/ui/activity-logs/referral-activity-item.tsx, apps/web/ui/activity-logs/referral-activity-section.tsx, apps/web/ui/activity-logs/referral-*.tsx, apps/web/ui/activity-logs/activity-log-description.tsx, apps/web/ui/activity-logs/activity-entry-chips.tsx
New activity log UI system with specialized renderers for partner group changes and referral status/creation events; reusable chip components (GroupPill, UserChip, SourcePill, ActorChip, ReferralStatusPill); feed and section components with loading states.
Group Settings UI Refactor
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-additional-settings.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-move-rules.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-settings.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/group-link-settings.tsx, apps/web/ui/partners/groups/group-settings-row.tsx
Added comprehensive group move rules UI with complex form state, attribute filtering, range input handling, and conflict detection; refactored group settings to use unified form flow with FormProvider; replaced SettingsRow with GroupSettingsRow; introduced move rules component with upsell gating.
Auto-Provision & Discount Enhancements
apps/web/lib/actions/partners/create-discount.ts, apps/web/lib/actions/partners/update-discount.ts, apps/web/app/(ee)/api/discount-codes/route.ts, apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts, apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts, apps/web/app/(ee)/api/cron/groups/remap-default-links/route.ts, apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx, packages/prisma/schema/discount.prisma
Added autoProvisionEnabledAt field to Discount model and UI toggle; refactored discount creation flow to use new createDiscountCode utility; updated remap endpoints to create codes when auto-provision enabled; added conditional code generation logic in partner-approved workflow.
Partner Group Notification & History
apps/web/lib/api/partners/notify-partner-group-change.ts, apps/web/lib/api/partners/get-partner-users.ts, apps/web/lib/api/partners/get-partner-invite-rewards-and-bounties.ts, packages/email/src/templates/partner-group-changed.tsx, apps/web/ui/partners/partner-info-cards.tsx
Introduced notifyPartnerGroupChange orchestration for batch partner notifications; added PartnerGroupChanged email template; enhanced partner-info UI with group history sheet integration; updated invite-rewards helper to include group data.
Workflow Context & Type System
apps/web/lib/types.ts, 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/discount.ts, apps/web/lib/zod/schemas/groups.ts, packages/prisma/schema/workflow.prisma
Restructured WorkflowContext with new WorkflowIdentity and PartnerMetrics structures; added partnerMetricsUpdated trigger; extended operator functions to support range-based conditions; introduced BountyPerformanceCondition and CampaignTriggerCondition schemas; added partnerGroup relation to Workflow model.
Cron & Orchestration Updates
apps/web/app/(ee)/api/cron/partners/ban/route.ts, apps/web/app/(ee)/api/cron/partners/deactivate/route.ts, apps/web/app/(ee)/api/cron/discount-codes/[discountCodeId]/delete/route.ts, apps/web/app/(ee)/api/groups/[groupIdOrSlug]/partners/route.ts, apps/web/scripts/migrations/migrate-workflow-triggers.ts
Updated discount deletion call sites to pass full objects instead of IDs; removed per-ID cron endpoint; simplified partner move orchestration via movePartnersToGroup; added migration script for trigger enum replacement.
Referral Activity & Status Tracking
apps/web/lib/actions/referrals/update-referral-status.ts, apps/web/lib/referrals/constants.ts, apps/web/ui/referrals/partner-referral-sheet.tsx, apps/web/ui/modals/confirm-referral-status-change-modal.tsx
Introduced REFERRAL_STATUS_TO_ACTIVITY_ACTION mapping; updated status change tracking with changeSet field capturing old/new values; integrated ReferralActivitySection into partner referral view; added activity-logs endpoint to revalidation list.
UI Component Updates & Utilities
apps/web/ui/partners/groups/design/lander/modals/earnings-calculator-block-modal.tsx, apps/web/ui/partners/rewards/rewards-logic.tsx, apps/web/ui/shared/inline-badge-popover.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/use-commission-filters.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty-sheet.tsx, apps/web/ui/partners/partner-profile.tsx
Replaced inline money input with InlineBadgePopoverAmountInput component; updated bounty sheet to use bountyPerformanceConditionSchema; refactored rewards logic component; minor formatting/import adjustments.
Prisma & Icon Additions
packages/prisma/index.ts, packages/ui/src/icons/file-pen.tsx, packages/ui/src/icons/file-send.tsx, packages/ui/src/icons/user-clock.tsx, packages/ui/src/icons/nucleo/user-arrow-right.tsx, packages/ui/src/icons/index.tsx, packages/ui/src/icons/nucleo/index.ts, apps/web/lib/email/email-templates-map.ts
Refactored Prisma singleton with OmittedPrismaClient type alias; added new icon components (FilePen, FileSend, UserClock, UserArrowRight); updated icon indices; added PartnerGroupChanged to email template map.
API Link & Workspace Updates
apps/web/lib/api/links/delete-link.ts, apps/web/lib/api/get-workspace-users.ts, apps/web/lib/partners/approve-partner-enrollment.ts
Updated link deletion to use deleteDiscountCodes with full objects; added user id selection to workspace users query; simplified workspace reference in approve-partner-enrollment.
Tests & Migration
apps/web/tests/partner-groups/index.test.ts, apps/web/tests/utils/resource.ts
Added comprehensive E2E tests for group move rules (PATCH, updates, removals); added autoProvisionEnabledAt field to discount fixture.

Sequence Diagram(s)

sequenceDiagram
    actor Admin
    participant API as API Route
    participant Service as movePartnersToGroup
    participant DB as Database
    participant Queue as Qstash Queue
    participant Email as Email Service

    Admin->>API: PATCH /api/groups/[id]/partners (partnerIds)
    API->>Service: movePartnersToGroup(workspaceId, programId, partnerIds, userId, group)
    Service->>DB: Fetch program enrollments
    Service->>DB: Update enrollments with new group + rewards
    Service->>DB: Fetch updated enrollments with links
    Service->>DB: Build activity log changeSet (old vs new group)
    Service->>Queue: Trigger remap-discount-codes cron
    Service->>Queue: Trigger draft bounty submission creation
    Service->>Email: Queue batch partner notification emails
    Service->>DB: Record activity logs
    Service-->>API: Return updated count
    API-->>Admin: 200 OK { count }
Loading
sequenceDiagram
    participant Webhook as Stripe Webhook
    participant Handler as Webhook Handler
    participant WF as executeWorkflows
    participant DB as Database
    participant Workflow as Workflow Engine

    Webhook->>Handler: POST /stripe/integration/webhook (sale event)
    Handler->>Handler: Parse sale amount & conversions
    Handler->>WF: executeWorkflows({ trigger: "partnerMetricsUpdated", reason: "sale", identity: {workspaceId, programId, partnerId}, metrics: {current: {saleAmount, conversions}} })
    WF->>DB: Fetch workflows matching trigger & reason
    WF->>DB: Fetch partner enrollment with metrics context
    WF->>Workflow: For each workflow: evaluateWorkflowConditions({conditions, attributes})
    Workflow-->>WF: boolean (conditions pass?)
    alt Conditions Pass
        WF->>Workflow: Execute matched actions (e.g., MoveGroup, SendCampaign)
        Workflow->>DB: Perform action-specific updates
    end
    WF-->>Handler: Execution complete
    Handler-->>Webhook: 200 OK
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐰 Group moves and workflows, now unified and bright,
Conditions evaluated in batches, what a delight!
Discount codes auto-provision with care,
Activity logs trace each change through the air,
A hop toward smarter partner automation there! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 19.35% 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 PR title 'Display referral and group moves changelog' accurately reflects the main focus of changes, which include adding activity log tracking for referral status changes and group movements, new UI components for displaying these activities, and related infrastructure updates.

✏️ 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.

@steven-tey steven-tey changed the title Remove clickRecorded trigger Display referral and group moves changelog Feb 6, 2026
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

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

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

⚠️ Outside diff range comments (2)
apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts (1)

134-160: ⚠️ Potential issue | 🟡 Minor

Pre-existing risk: condition may be undefined if triggerConditions is an empty array.

Line 137 accesses [0] on the parsed array without a guard. If triggerConditions is [], condition is undefined and line 151 (partner[condition.attribute]) throws at runtime.

The migration to evaluateWorkflowConditions at lines 155-160 is itself correct — wrapping the single condition in an array matches the new API.

Proposed defensive guard
     const condition = z
       .array(workflowConditionSchema)
       .parse(bounty.workflow.triggerConditions)[0];
 
+    if (!condition) {
+      return logAndRespond(
+        `Bounty ${bountyId} workflow has no trigger conditions.`,
+      );
+    }
+
     // Partners with their link metrics
apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts (1)

132-153: ⚠️ Potential issue | 🟡 Minor

Potential orphaned workflow if the group update fails.

upsertGroupMoveRules creates/updates a workflow in the DB (lines 132-136), then the resulting workflowId is persisted on the group at line 153. If the prisma.partnerGroup.update at line 139 throws, the workflow record will exist but won't be referenced by any group, leaving an orphan.

Consider wrapping both operations in a transaction, or performing the upsert inside the Promise.all block, or adding compensating cleanup on failure.

🤖 Fix all issues with AI agents
In `@apps/web/app/`(ee)/api/cron/groups/remap-discount-codes/route.ts:
- Around line 149-159: The loop that calls createDiscountCode for each link
should not allow one failure to abort the rest; wrap each call to
createDiscountCode (inside the workspace.stripeConnectId conditional iterating
over links) in a per-link try/catch, catching and logging the error (include
link id and partner context) and continue to the next link so subsequent
discount codes still get created; ensure the catch does not rethrow so withCron
does not fail for a single link.

In
`@apps/web/app/app.dub.co/`(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-move-rules.tsx:
- Around line 463-491: handleUpdateMaxValue currently issues two separate
updates (returns a value for field.onChange and calls onUpdate({ operator:
"between" })), which can cause the operator and value to desync; change it to
perform an atomic update by not calling onUpdate separately and instead return
undefined when you need to update operator + value together and call onUpdate
once with both properties (e.g., onUpdate({ operator: "between", value: newRange
})) so the caller can skip field.onChange when the function returns undefined;
specifically modify handleUpdateMaxValue (use ruleOperator and convertedValue to
decide when to bundle operator:"between" with the new value) and adjust the
caller (handleChange / field.onChange) to only call field.onChange when
handleUpdateMaxValue returns a non-undefined value.

In `@apps/web/lib/api/activity-log/track-activity-log.ts`:
- Line 44: Replace the insecure console.log call that prints prettyPrint(inputs)
in track-activity-log.ts with a redacted, structured log using the existing
logger: do not serialize full inputs (which include userId, resourceId,
description); instead log only non-sensitive metadata such as inputs.length and
the set of action types (derive from inputs[].action) and, if needed, a hashed
or redacted user identifier. Use the same logger used in the error path (replace
console.log with logger.info or logger.debug) and remove or prevent printing of
prettyPrint(inputs) to stdout.

In `@apps/web/lib/api/groups/find-groups-with-matching-rules.ts`:
- Around line 55-59: The logic currently treats rule sets with no
sharedAttributes as non-conflicting by returning false; change the behavior so
that when sharedAttributes.length === 0 the function treats the rule sets as
overlapping and returns true instead. Locate the block that checks
sharedAttributes.length (the early-return branch in the conflict-detection
function) and replace the return false with return true (or remove the
early-return and let the general overlap logic handle disjoint attributes),
ensuring the function now treats independent constraints on different attributes
as potentially satisfiable simultaneously.

In `@apps/web/lib/api/partners/get-partner-users.ts`:
- Around line 27-31: The Prisma relation filter on notificationPreferences in
get-partner-users.ts (and similarly in workflows/partner-approved/route.ts)
currently excludes PartnerUser rows that have no PartnerNotificationPreferences
record; update the query using the notificationPreference variable to add an OR
branch that keeps users with notificationPreferences null (i.e., OR: [{
notificationPreferences: { [notificationPreference]: true } }, {
notificationPreferences: null }]) so users without a prefs record are not
filtered out, or alternatively ensure PartnerNotificationPreferences is always
created when creating a PartnerUser (adjust create logic used by
invites/accept/route.ts and seed paths) so that notificationPreferences is never
null; target the notificationPreferences relation filter and PartnerUser
creation code paths when applying the fix.

In `@apps/web/lib/api/workflows/execute-send-campaign-workflow.ts`:
- Line 75: The code is unsafely casting `condition as CampaignTriggerCondition`
before passing it to `getProgramEnrollments`; instead, validate and narrow the
runtime shape first: check that `condition.operator === "gte"` and `typeof
condition.value === "number"` (or use `campaignTriggerConditionSchema` to
validate) and only then cast/forward it, otherwise handle the unsupported
operators (e.g., throw a clear error or convert/skip); update the logic in
execute-send-campaign-workflow.ts around the `condition` variable so
`getProgramEnrollments` always receives a true numeric
`CampaignTriggerCondition`.

In `@apps/web/lib/zod/schemas/bounties.ts`:
- Around line 20-24: The bountyPerformanceConditionSchema currently uses
z.enum(WORKFLOW_ATTRIBUTES) which permits non-stats attributes; create a new
constant (e.g., BOUNTY_PERFORMANCE_ATTRIBUTES) listing only the supported stats:
"totalLeads", "totalConversions", "totalSaleAmount", and "totalCommissions",
then update bountyPerformanceConditionSchema to use
z.enum(BOUNTY_PERFORMANCE_ATTRIBUTES) instead of WORKFLOW_ATTRIBUTES so
schema-level validation matches PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES and prevents
unsupported attributes like "partnerEnrolledDays" or "partnerJoined".
🟡 Minor comments (14)
apps/web/ui/shared/inline-badge-popover.tsx-339-350 (1)

339-350: ⚠️ Potential issue | 🟡 Minor

Money-specific handlers are applied unconditionally to the "number" type.

handleMoneyKeyDown and handleMoneyInputChange are invoked for all three types, including "number". If those handlers restrict input (e.g., limiting decimal places or blocking non-currency characters), they could produce unexpected behavior when the component is used for plain numeric condition values (e.g., attributeType === "number" in rewards-logic.tsx Line 554).

Consider guarding the calls:

Suggested 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);
         }}
#!/bin/bash
# Check what handleMoneyKeyDown and handleMoneyInputChange do to understand impact on "number" type
fd "form-utils" --type f --exec cat {}
apps/web/lib/api/groups/find-groups-with-matching-rules.ts-40-48 (1)

40-48: ⚠️ Potential issue | 🟡 Minor

Silent overwriting of duplicate rule attributes will cause incorrect conflict detection.

There is no schema-level or function-level validation preventing duplicate attributes within a rule set. When two conditions have the same attribute (e.g., two totalLeads rules), Map.set() silently overwrites the first condition. This discards data and causes doRuleSetsOverlap() to produce incorrect results when checking for conflicts between rule sets.

Add validation to validateGroupMoveRules() to detect and reject duplicate attributes, or ensure the Zod schema enforces uniqueness per rule set.

apps/web/lib/zod/schemas/workflows.ts-86-92 (1)

86-92: ⚠️ Potential issue | 🟡 Minor

Align optional max in type and schema.

OperatorFn in types.ts (line 680) defines cV as number | { min: number; max?: number } with optional max, but the workflow condition schema (lines 87-92) requires max as non-optional. The between function's guard at line 58 (if (min == null || max == null)) handles this safely at runtime, but the type/schema mismatch creates inconsistency. Make max required in OperatorFn or optional in the schema to align them.

apps/web/lib/api/groups/get-group-or-throw.ts-52-55 (1)

52-55: ⚠️ Potential issue | 🟡 Minor

Normalize moveRules to null instead of undefined when no workflow exists.

group.workflow?.triggerConditions evaluates to undefined in JavaScript when the workflow is null or doesn't exist. While API routes that parse the result through GroupSchema may coerce undefined to null, direct callers (e.g., action functions) receive the raw undefined value. The tests consistently expect moveRules: null, and this inconsistency could cause silent failures in code checking moveRules === null.

Use the ?? null operator to explicitly normalize:

Suggested fix
   return {
     ...group,
-    moveRules: group.workflow?.triggerConditions,
+    moveRules: group.workflow?.triggerConditions ?? null,
   };
apps/web/lib/api/partners/notify-partner-group-change.ts-22-38 (1)

22-38: ⚠️ Potential issue | 🟡 Minor

Add replyTo field to match the pattern used in other partner notification emails.

The code is missing a replyTo field in the email configuration. Other partner notification emails in the codebase consistently use replyTo: program.supportEmail || "noreply" (see partner-approved route, invite-partner action, and notify-partner-commission). Add this field to line 44 for consistency:

replyTo: program.supportEmail || "noreply",

The program.supportEmail is available since program is fully included when getPartnerInviteRewardsAndBounties calls getGroupOrThrow({ includeExpandedFields: true }).

apps/web/app/(ee)/api/cron/discount-codes/delete/route.ts-27-30 (1)

27-30: ⚠️ Potential issue | 🟡 Minor

workspace.stripeConnectId may be null.

stripeConnectId is a nullable field in the workspace schema. The disableStripeDiscountCode function accepts string | null but throws an error if the value is falsy. While withCron will catch this error, similar code in the codebase explicitly guards against null values (e.g., in apps/web/app/(ee)/api/discount-codes/route.ts). Add a null check before calling the Stripe utility to match the established pattern.

🛡️ Suggested guard
+  if (!workspace.stripeConnectId) {
+    return logAndRespond(
+      `Workspace for program ${programId} has no Stripe Connect ID.`,
+    );
+  }
+
   const disabledDiscountCode = await disableStripeDiscountCode({
     code,
     stripeConnectId: workspace.stripeConnectId,
   });
apps/web/ui/activity-logs/referral-activity-item.tsx-46-53 (1)

46-53: ⚠️ Potential issue | 🟡 Minor

Missing day in timestamp format — dates will render without the day of the month.

The formatDate call includes month, year, hour, and minute but omits day. This will produce timestamps like "Feb 2026, 2:30 PM" rather than "Feb 6, 2026, 2:30 PM".

Proposed fix
             {formatDate(log.createdAt, {
               month: "short",
+              day: "numeric",
               year: "numeric",
               hour: "numeric",
               minute: "2-digit",
             })}
apps/web/ui/activity-logs/action-renderers/partner-group-changed-renderer.tsx-7-10 (1)

7-10: ⚠️ Potential issue | 🟡 Minor

GroupChangeSet.old and .new should be nullable to match the data producer.

In build-change-set.ts, both old and new can be null (when the respective enrollment has no partnerGroup). The as GroupChangeSet cast on line 21 hides this mismatch. While the runtime check on line 23 guards against new being null, the old field is never guarded — accessing groupChange.old.name elsewhere would NPE.

🔧 Proposed fix
 interface GroupChangeSet {
-  old: Pick<GroupProps, "id" | "name">;
-  new: Pick<GroupProps, "id" | "name">;
+  old: Pick<GroupProps, "id" | "name"> | null;
+  new: Pick<GroupProps, "id" | "name"> | null;
 }
apps/web/lib/api/workflows/execute-move-group-workflow.ts-37-47 (1)

37-47: ⚠️ Potential issue | 🟡 Minor

findUniqueOrThrow will surface as an unhandled Prisma exception in a background workflow.

All other guard checks in this function use console.error / console.log + return to skip gracefully. If the enrollment doesn't exist (e.g., partner was unenrolled between trigger and execution), this will throw a PrismaClientKnownRequestError instead of logging and skipping, which may produce noisy error reports or crash the workflow runner depending on how upstream handles it.

Consider using findUnique + a null check for consistency with the rest of this function's error-handling style.

Proposed fix
-  const programEnrollment = await prisma.programEnrollment.findUniqueOrThrow({
+  const programEnrollment = await prisma.programEnrollment.findUnique({
     where: {
       partnerId_programId: {
         partnerId,
         programId,
       },
     },
     select: {
       groupId: true,
     },
   });
 
+  if (!programEnrollment) {
+    console.error(
+      `Program enrollment not found for partner ${partnerId} in program ${programId}. Skipping..`,
+    );
+    return;
+  }
+
apps/web/lib/api/groups/upsert-group-move-rules.ts-50-63 (1)

50-63: ⚠️ Potential issue | 🟡 Minor

Add server-side validation of moveRules or document the assumption that input is pre-validated.

Validation occurs only at the client level in group-additional-settings.tsx via validateGroupMoveRules before the API call. The server-side upsertGroupMoveRules function receives moveRules but does not validate their structure, attributes, operators, or values. If invalid rules somehow bypass the client validation (e.g., direct API call), they will reach the persistence layer unchecked. Consider either calling validateGroupMoveRules within upsertGroupMoveRules for defense-in-depth, or add a comment documenting that the function assumes pre-validated input from the API route.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-move-rules.tsx-529-535 (1)

529-535: ⚠️ Potential issue | 🟡 Minor

Floating-point rounding risk in currency conversion.

Number("19.99") * 100 yields 1998.9999… rather than 1999. Since these are stored as integers (cents), consider using Math.round:

-  return isCurrency ? numValue * 100 : numValue;
+  return isCurrency ? Math.round(numValue * 100) : numValue;
apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts-151-154 (1)

151-154: ⚠️ Potential issue | 🟡 Minor

Non-null assertion on link.programEnrollment! can throw at runtime.

programEnrollment is selected as a nested relation (line 126–135) but is optional on the Link model. If a link somehow lacks a program enrollment, this will throw a TypeError. Consider guarding or filtering.

Suggested guard
         for (const link of links) {
+          if (!link.programEnrollment?.partner) {
+            console.error(`Link ${link.id} has no program enrollment or partner.`);
+            continue;
+          }
           await createDiscountCode({
             stripeConnectId: workspace.stripeConnectId,
-            partner: link.programEnrollment!.partner,
+            partner: link.programEnrollment.partner,
             link,
             discount: group.discount,
           });
apps/web/lib/api/groups/move-partners-to-group.ts-90-189 (1)

90-189: ⚠️ Potential issue | 🟡 Minor

Unhandled failure inside waitUntil silently drops all side-effects.

If either prisma.link.findMany (line 92) or the second prisma.programEnrollment.findMany (line 105) throws, the entire waitUntil callback rejects and none of the downstream tasks (remap, bounty creation, recordLink, notifications, activity log) execute. Because waitUntil swallows rejections, this would be invisible.

Consider wrapping the body in a try/catch with error logging, or at least separating the independent fetches so a failure in one doesn't block the others.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-additional-settings.tsx-118-126 (1)

118-126: ⚠️ Potential issue | 🟡 Minor

error is unknown in the catch block — accessing .message may fail type checking.

TypeScript catch blocks type error as unknown. Direct access to error.message at Line 124 will cause a compile error with strict settings.

Proposed fix
       try {
         validateGroupMoveRules(data.moveRules);
       } catch (error) {
-        toast.error(error.message);
+        toast.error(
+          error instanceof Error ? error.message : "Invalid move rules",
+        );
         return;
       }
🧹 Nitpick comments (27)
apps/web/scripts/migrations/migrate-workflow-triggers.ts (1)

19-19: Add error handling for clearer failure diagnostics.

If the migration fails (e.g., invalid enum value, DB connection issue), the unhandled rejection may produce a confusing stack trace. A simple .catch() makes failures explicit.

Proposed fix
-main();
+main().catch((e) => {
+  console.error("Migration failed:", e);
+  process.exit(1);
+});
packages/ui/src/icons/user-clock.tsx (1)

13-30: Hardcoded #525252 color prevents theming via CSS color.

Unlike FileSend (which uses stroke="currentColor"), this icon has hardcoded stroke="#525252" and fill="#525252". This means the icon won't respond to parent color changes, creating an inconsistency across the new icon set.

If the design intentionally fixes the color, this is fine. Otherwise, consider using currentColor for both stroke and fill to allow theming.

♻️ Suggested change for consistency
       <path
         d="M9 7.25C10.519 7.25 11.75 6.019 11.75 4.5C11.75 2.981 10.519 1.75 9 1.75C7.481 1.75 6.25 2.981 6.25 4.5C6.25 6.019 7.481 7.25 9 7.25Z"
-        stroke="#525252"
+        stroke="currentColor"
         strokeWidth={1.5}
         strokeLinecap="round"
         strokeLinejoin="round"
       />
       <path
         d="M9.39805 9.7856C9.26475 9.7758 9.13625 9.75 9.00035 9.75C6.44935 9.75 4.26135 11.28 3.29135 13.47C2.92635 14.295 3.37835 15.2439 4.23835 15.5149C5.27485 15.8423 6.61564 16.1218 8.15934 16.2041"
-        stroke="#525252"
+        stroke="currentColor"
         strokeWidth={1.5}
         strokeLinecap="round"
         strokeLinejoin="round"
       />
       <path
         d="M14 10C11.7939 10 10 11.7944 10 14C10 16.2056 11.7939 18 14 18C16.2061 18 18 16.2056 18 14C18 11.7944 16.2061 10 14 10ZM16.3125 14.9502C16.1934 15.2398 15.9141 15.415 15.6191 15.415C15.5234 15.415 15.4277 15.3969 15.3339 15.3588L13.7148 14.6938C13.4336 14.5781 13.25 14.3042 13.25 14V12.25C13.25 11.8359 13.5859 11.5 14 11.5C14.4141 11.5 14.75 11.8359 14.75 12.25V13.4971L15.9043 13.9712C16.2871 14.1284 16.4707 14.5669 16.3125 14.9502Z"
-        fill="#525252"
+        fill="currentColor"
       />
apps/web/lib/stripe/create-stripe-discount-code.ts (1)

61-74: Code grows by 2 characters per retry — acceptable but worth noting.

Each retry appends nanoid(2) to the already-extended currentCode, so after 2 retries the code is 4 characters longer than the original. With MAX_ATTEMPTS = 3 this is bounded and fine, but if MAX_ATTEMPTS ever increases, the code could exceed Stripe's promotion code length limit.

💡 Alternative: always append to the original code to keep length predictable
+  const originalCode = code;
   let attempt = 0;
   let currentCode = code;

   while (attempt < MAX_ATTEMPTS) {
     try {
       ...
     } catch (error: any) {
       ...
       attempt++;

       if (attempt >= MAX_ATTEMPTS) {
         throw error;
       }

-      const newCode = `${currentCode}${nanoid(2)}`;
+      const newCode = `${originalCode}${nanoid(2 * attempt)}`;
apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx (1)

471-490: Auto-provision toggle doesn't mark the form as dirty.

Other setValue calls in this file (e.g., lines 397–399, 441–443) pass { shouldDirty: true }. The auto-provision toggle on line 481 omits this option, which means toggling it alone won't enable a dirty-form guard if one is added later, and formState.isDirty won't reflect the change.

Proposed fix
-              fn={() => setValue("autoProvision", !autoProvision)}
+              fn={() => setValue("autoProvision", !autoProvision, { shouldDirty: true })}
apps/web/lib/api/discounts/delete-discount-code.ts (1)

43-56: Promise.allSettled silently swallows enqueue failures — consider logging settled rejections.

If individual queue.enqueueJSON calls reject, Promise.allSettled captures them but nothing logs the failures. This could make debugging silent Stripe cleanup failures difficult.

Proposed improvement
   for (const chunkOfCodes of chunks) {
-    await Promise.allSettled(
+    const results = await Promise.allSettled(
       chunkOfCodes.map((discountCode) =>
         queue.enqueueJSON({
           url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discount-codes/delete`,
           method: "POST",
           body: {
             code: discountCode.code,
             programId: discountCode.programId,
           },
         }),
       ),
     );
+
+    const failures = results.filter((r) => r.status === "rejected");
+    if (failures.length > 0) {
+      console.error(
+        `Failed to enqueue ${failures.length} discount code deletion(s).`,
+        failures.map((f) => (f as PromiseRejectedResult).reason),
+      );
+    }
   }
apps/web/lib/zod/schemas/workflows.ts (1)

44-63: Runtime type guards are sound but hide schema-level mismatches.

The gte and between operator functions gracefully return false when the value type doesn't match expectations. However, the workflowConditionSchema (lines 83-93) allows any operator/value combination to pass validation — e.g., { operator: "between", value: 5 } or { operator: "gte", value: { min: 1, max: 10 } } would parse successfully but silently evaluate to false at runtime.

Consider using a discriminated union on operator so invalid pairings are rejected at parse time rather than silently failing evaluation.

💡 Suggested schema structure
-export const workflowConditionSchema = z.object({
-  attribute: z.enum(WORKFLOW_ATTRIBUTES),
-  operator: z.enum(WORKFLOW_COMPARISON_OPERATORS).default("gte"),
-  value: z.union([
-    z.number(),
-    z.object({
-      min: z.number(),
-      max: z.number(),
-    }),
-  ]),
-});
+export const workflowConditionSchema = z.discriminatedUnion("operator", [
+  z.object({
+    attribute: z.enum(WORKFLOW_ATTRIBUTES),
+    operator: z.literal("gte"),
+    value: z.number(),
+  }),
+  z.object({
+    attribute: z.enum(WORKFLOW_ATTRIBUTES),
+    operator: z.literal("between"),
+    value: z.object({
+      min: z.number(),
+      max: z.number(),
+    }),
+  }),
+]);
apps/web/app/(ee)/api/cron/groups/remap-default-links/route.ts (1)

225-248: Consider parallelizing the two independent QStash publish calls.

The sync-utm and remap-discount-codes jobs are independent of each other and are currently awaited sequentially. Parallelizing would reduce the overall latency of this cron handler.

♻️ Proposed parallel scheduling
-    const syncUtmJob = await qstash.publishJSON({
+    const [syncUtmJob, remapDiscountCodesJob] = await Promise.all([
+      qstash.publishJSON({
       url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/sync-utm`,
       body: {
         groupId,
         partnerIds,
       },
-    });
-
-    console.log(
-      `Scheduled sync-utm job for group ${groupId}: ${prettyPrint(syncUtmJob)}`,
-    );
-
-    const remapDiscountCodesJob = await qstash.publishJSON({
-      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/remap-discount-codes`,
-      body: {
-        programId,
-        partnerIds,
-        groupId,
-      },
-    });
+      }),
+      qstash.publishJSON({
+        url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/remap-discount-codes`,
+        body: {
+          programId,
+          partnerIds,
+          groupId,
+        },
+      }),
+    ]);
 
     console.log(
-      `Scheduled remap-discount-codes job for group ${groupId}: ${prettyPrint(remapDiscountCodesJob)}`,
+      `Scheduled sync-utm job for group ${groupId}: ${prettyPrint(syncUtmJob)}`,
+    );
+    console.log(
+      `Scheduled remap-discount-codes job for group ${groupId}: ${prettyPrint(remapDiscountCodesJob)}`,
     );
apps/web/tests/partner-groups/index.test.ts (2)

142-257: Good test coverage for the move rules lifecycle.

The create → augment → remove flow with GET verification after each mutation is solid. One minor observation:

The destructuring block to strip applicationFormData, applicationFormPublishedAt, landerData, landerPublishedAt, program from fetchedGroup is repeated three times (lines 165–172, 210–217, 242–249). Consider extracting a small helper to reduce duplication:

const stripExpandedFields = ({ applicationFormData, applicationFormPublishedAt, landerData, landerPublishedAt, program, ...rest }: GroupWithProgramProps) => rest;

151-158: Consider asserting the PATCH response body includes moveRules.

The tests verify persistence via a follow-up GET, which is good for end-to-end confidence. However, asserting the PATCH response body itself would catch serialization issues earlier and ensure the API contract is correct on writes too. If the PATCH response intentionally omits moveRules, this is fine as-is.

apps/web/ui/partners/partner-info-cards.tsx (1)

306-322: Nit: partnerGroupHistorySheet renders outside the isEnrolled guard.

The "View history" button is gated by isEnrolled, but the sheet node on line 322 is rendered unconditionally within this section. It's functionally harmless (sheet defaults to closed, and there's no trigger for non-enrolled partners), but moving it inside the isEnrolled block would make the intent clearer.

Suggested placement
           {isEnrolled && (
             <div className="flex min-h-7 items-center justify-between">
               <h3 className="text-content-emphasis text-sm font-semibold">
                 Group
               </h3>

               {hasActivityLogs && (
                 <Button
                   variant="outline"
                   text="View history"
                   className="h-7 w-fit rounded-lg px-1.5 text-xs font-medium text-neutral-400"
                   onClick={() => setGroupHistoryOpen(true)}
                 />
               )}
             </div>
           )}

-          {partnerGroupHistorySheet}
+          {isEnrolled && partnerGroupHistorySheet}
           {partner ? (
apps/web/app/(ee)/api/cron/partners/deactivate/route.ts (1)

50-52: Redundant identity map.

discountCodes.map((dc) => dc) is a no-op. Simplify:

Suggested fix
-  const discountCodes = programEnrollments.flatMap(({ discountCodes }) =>
-    discountCodes.map((dc) => dc),
-  );
+  const discountCodes = programEnrollments.flatMap(
+    ({ discountCodes }) => discountCodes,
+  );
apps/web/lib/api/groups/validate-group-move-rules.ts (2)

17-17: Nit: redundant undefined check.

== null already matches both null and undefined via loose equality, so the explicit === undefined is redundant. Same pattern repeats on lines 41 and 47.

Suggested simplification
-    if (rule.value == null || rule.value === undefined) {
+    if (rule.value == null) {

And similarly for lines 41 and 47:

-      if (min == null || min === undefined || isNaN(min) || min <= 0) {
+      if (min == null || isNaN(min) || min <= 0) {
-      if (max == null || max === undefined || isNaN(max) || max <= 0) {
+      if (max == null || isNaN(max) || max <= 0) {

8-60: Consider handling unknown operators.

Rules with operators other than "gte" or "between" pass validation with only the attribute/value presence checks. If other operators are supported (or could be introduced), their value constraints won't be enforced.

If the set of supported operators is fixed, a catch-all else throwing on unrecognized operators would make this future-proof.

apps/web/lib/api/partners/notify-partner-group-change.ts (1)

40-60: Missing replyTo on queued emails.

Other email-sending flows in this codebase (e.g., the deactivate cron at route.ts line 79) set replyTo: program.supportEmail || "noreply". This notification doesn't include a replyTo, so replies from partners will go to the default sender. If this is intentional, ignore — otherwise, consider threading program.supportEmail through.

apps/web/app/api/activity-logs/route.ts (1)

29-31: Consider using select instead of include for the user relation.

include: { user: true } fetches all user columns from the database. While the activityLogSchema.parse() on line 34 strips extra fields from the response, using select on the user relation would avoid pulling unnecessary data (potentially sensitive fields) from the DB in the first place.

♻️ Suggested change
     include: {
-      user: true,
+      user: {
+        select: {
+          id: true,
+          name: true,
+          email: true,
+          image: true,
+        },
+      },
     },
packages/prisma/schema/workflow.prisma (1)

5-6: Confirm clickRecorded removal is tracked.

The TODO is noted, but ensure there's an issue or follow-up task to actually run the migration and remove this enum value. Stale enum values in Prisma schemas tend to linger if not explicitly tracked.

apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts (1)

438-440: Batch the deleteDiscountCodes call instead of mapping individual items.

deleteDiscountCodes already accepts an array, so calling it once with the full list avoids N separate DB deleteMany calls and N separate queue-enqueue rounds.

♻️ Proposed fix
-            ...discountCodesToDelete.map((discountCode) =>
-              deleteDiscountCodes(discountCode),
-            ),
+            discountCodesToDelete.length > 0 &&
+              deleteDiscountCodes(discountCodesToDelete),
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-settings.tsx (2)

64-72: SWR revalidation may reset the form and discard unsaved edits.

When SWR revalidates (e.g., on window focus), group gets a new object reference, triggering reset() even if the data hasn't changed. This would silently clear any in-progress edits. Consider comparing against previous values before resetting, or disabling revalidation options like revalidateOnFocus for this hook.

♻️ Example: guard reset with a shallow check
  useEffect(() => {
    if (group) {
-     reset({
-       name: group.name,
-       slug: group.slug,
-       color: group.color,
-     });
+     reset(
+       {
+         name: group.name,
+         slug: group.slug,
+         color: group.color,
+       },
+       { keepDirtyValues: true },
+     );
    }
  }, [group, reset]);

106-144: Non-null assertion on group! is safe but implicit.

On Line 125, group!.slug relies on the isLoading guard at Line 106. The assertion is technically safe here since isLoading === false implies group !== null, but it's an implicit contract.

apps/web/lib/swr/use-activity-logs.ts (1)

15-22: workspaceId! produces "undefined" string when workspace hasn't loaded.

searchParams is built eagerly before the SWR gate checks workspaceId. While SWR won't fetch with the malformed URL, the non-null assertion on Line 17 silently coerces undefined to the string "undefined" in the query params.

Consider deferring searchParams construction or guarding on workspaceId:

♻️ Suggested fix
- const searchParams = query
-   ? new URLSearchParams({
-       workspaceId: workspaceId!,
-       resourceType: query.resourceType,
-       resourceId: query.resourceId,
-       ...(query.action && { action: query.action }),
-     }).toString()
-   : "";
+ const searchParams =
+   query && workspaceId
+     ? new URLSearchParams({
+         workspaceId,
+         resourceType: query.resourceType,
+         resourceId: query.resourceId,
+         ...(query.action && { action: query.action }),
+       }).toString()
+     : "";
apps/web/lib/api/groups/upsert-group-move-rules.ts (1)

72-76: Consider using WorkflowTrigger.partnerMetricsUpdated directly instead of a type assertion.

Line 73 uses "partnerMetricsUpdated" as WorkflowTrigger. Since this trigger value exists as an enum member, referencing the enum directly would be more type-safe and catch renames at compile time.

Suggested change
-    trigger: "partnerMetricsUpdated" as WorkflowTrigger,
+    trigger: WorkflowTrigger.partnerMetricsUpdated,
apps/web/app/(ee)/api/workflows/partner-approved/route.ts (1)

160-227: Step 2 lacks workflow logger usage, unlike all other steps.

Every other step in this workflow uses logger.info / logger.error for observability (e.g., lines 76–78, 231–233, 323–325). Step 2 only uses console.error on line 221, making it harder to trace in the workflow logging infrastructure.

Consider adding logger.info at entry/exit and replacing console.error with logger.error for consistency.

Suggested improvement
     // 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;
       }
         } 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}.`,
+            data: error,
+          });
         }
apps/web/lib/api/groups/move-partners-to-group.ts (1)

64-68: Parameter reassignment narrows partnerIds silently.

Line 68 reassigns partnerIds to only the partners that actually have enrollments. This is correct behavior but can be surprising to readers since partnerIds in the rest of the function no longer matches the caller's input. A distinct variable name would make the intent clearer.

Suggestion
-  partnerIds = programEnrollments.map(({ partnerId }) => partnerId);
+  const enrolledPartnerIds = programEnrollments.map(({ partnerId }) => partnerId);

Then use enrolledPartnerIds in updateMany, link queries, and the Promise.allSettled block.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-additional-settings.tsx (1)

61-65: Missing form in useEffect dependency array.

React's exhaustive-deps lint rule would flag this. While form from useForm is stable across renders, explicitly including it silences the warning and is more correct.

Proposed fix
   useEffect(() => {
     if (group) {
       form.reset({ moveRules: group.moveRules ?? [] });
     }
-  }, [group]);
+  }, [group, form]);
apps/web/lib/api/activity-log/track-activity-log.ts (1)

26-30: Hardcoded "referral.created" action string — consider using the typed constant.

This magic string creates a coupling that could drift from the schema. If ActivityLogAction is an inferred union type, consider extracting a constant or referencing the schema to keep things in sync.

apps/web/lib/types.ts (2)

777-799: WorkflowIdentity and PartnerMetrics are not exported — intentional?

These interfaces are used as property types of the exported WorkflowContext. Callers constructing WorkflowContext objects (e.g., track-lead.ts, track-sale.ts) rely on structural typing to satisfy the shape, which works. However, if any caller needs to independently type a variable as WorkflowIdentity or PartnerMetrics, they won't be able to import these types.

This is fine with structural typing, but exporting them would improve developer ergonomics for consumers.


678-681: OperatorFn signature updated to support range-based comparison.

The cV parameter now accepts number | { min: number; max?: number }, enabling "between" operator support. The parameter names aV and cV are terse — consider actualValue and conditionValue for clarity.

Comment on lines +149 to +159
// Create discount code for the partner default links
if (workspace.stripeConnectId) {
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 | 🟠 Major

Missing per-link error handling — one failure aborts remaining discount code creation.

In the partner-approved workflow (route.ts lines 212–226), each createDiscountCode call is wrapped in try/catch so a single failure doesn't block others. Here, if createDiscountCode throws for one link, the entire cron handler fails (propagated to withCron) and no subsequent links get codes.

Suggested fix: add per-link try/catch
       if (workspace.stripeConnectId) {
         for (const link of links) {
-          await createDiscountCode({
-            stripeConnectId: workspace.stripeConnectId,
-            partner: link.programEnrollment!.partner,
-            link,
-            discount: group.discount,
-          });
+          try {
+            await createDiscountCode({
+              stripeConnectId: workspace.stripeConnectId,
+              partner: link.programEnrollment!.partner,
+              link,
+              discount: group.discount,
+            });
+          } catch (error) {
+            console.error(
+              `Failed to create discount code for link ${link.id}:`,
+              error,
+            );
+          }
         }
       }
🤖 Prompt for AI Agents
In `@apps/web/app/`(ee)/api/cron/groups/remap-discount-codes/route.ts around lines
149 - 159, The loop that calls createDiscountCode for each link should not allow
one failure to abort the rest; wrap each call to createDiscountCode (inside the
workspace.stripeConnectId conditional iterating over links) in a per-link
try/catch, catching and logging the error (include link id and partner context)
and continue to the next link so subsequent discount codes still get created;
ensure the catch does not rethrow so withCron does not fail for a single link.

Comment on lines +463 to +491
const handleUpdateMaxValue = (
currentFieldValue: ValueType,
convertedValue: number,
onUpdate: (updates: Partial<WorkflowCondition>) => void,
ruleOperator: WorkflowCondition["operator"],
): ValueType => {
if (isRangeValue(currentFieldValue)) {
const rangeValue = currentFieldValue as RangeValue;
const min =
rangeValue.min != null && rangeValue.min > 0 ? rangeValue.min : undefined;
return (
min != null ? { min, max: convertedValue } : { max: convertedValue }
) as any;
}

// Create range from current value
const currentMin = getMinValue(currentFieldValue);
const newRange =
currentMin != null && currentMin > 0
? { min: currentMin, max: convertedValue }
: { max: convertedValue };

// Update operator if needed
if (ruleOperator === "gte") {
onUpdate({ operator: "between" });
}

return newRange as any;
};
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 | 🟠 Major

Dual state update may cause operator/value desync.

handleUpdateMaxValue both calls onUpdate({ operator: "between" }) (line 487) and returns a new value that the caller passes to field.onChange (line 412). These two updates target overlapping form state (moveRules.${index}):

  • onUpdate triggers useFieldArray.update(index, { ...rule, operator: "between" }), which replaces the entire rule (including the old value).
  • field.onChange(newValue) sets only moveRules.${index}.value.

If updateRule fires after field.onChange (or vice versa due to React batching), the new value could be overwritten by the stale rule snapshot in { ...rule, ...updatedRule }.

Consider updating both operator and value atomically in a single onUpdate call:

Suggested fix
 const handleUpdateMaxValue = (
   currentFieldValue: ValueType,
   convertedValue: number,
   onUpdate: (updates: Partial<WorkflowCondition>) => void,
   ruleOperator: WorkflowCondition["operator"],
 ): ValueType => {
   if (isRangeValue(currentFieldValue)) {
     const rangeValue = currentFieldValue as RangeValue;
     const min =
       rangeValue.min != null && rangeValue.min > 0 ? rangeValue.min : undefined;
     return (
       min != null ? { min, max: convertedValue } : { max: convertedValue }
     ) as any;
   }

   // Create range from current value
   const currentMin = getMinValue(currentFieldValue);
   const newRange =
     currentMin != null && currentMin > 0
       ? { min: currentMin, max: convertedValue }
       : { max: convertedValue };

   // Update operator if needed
   if (ruleOperator === "gte") {
-    onUpdate({ operator: "between" });
+    onUpdate({ operator: "between", value: newRange as any });
   }

-  return newRange as any;
+  return ruleOperator === "gte" ? undefined : (newRange as any);
 };

Note: The caller (handleChange at line 412) would need to skip field.onChange when the return is undefined (i.e., the update was handled atomically). This may need further adjustment to fit cleanly.

🤖 Prompt for AI Agents
In
`@apps/web/app/app.dub.co/`(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-move-rules.tsx
around lines 463 - 491, handleUpdateMaxValue currently issues two separate
updates (returns a value for field.onChange and calls onUpdate({ operator:
"between" })), which can cause the operator and value to desync; change it to
perform an atomic update by not calling onUpdate separately and instead return
undefined when you need to update operator + value together and call onUpdate
once with both properties (e.g., onUpdate({ operator: "between", value: newRange
})) so the caller can skip field.onChange when the function returns undefined;
specifically modify handleUpdateMaxValue (use ruleOperator and convertedValue to
decide when to bundle operator:"between" with the new value) and adjust the
caller (handleChange / field.onChange) to only call field.onChange when
handleUpdateMaxValue returns a non-undefined value.

})),
});

console.log("[trackActivityLog] Activity log created", prettyPrint(inputs));
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 | 🟠 Major

Logging full activity log inputs to stdout may expose user identifiers.

prettyPrint(inputs) serializes the entire input array including userId, resourceId, and description to console.log. In production, this data flows to stdout and may be captured by log aggregators without redaction. Consider logging only the count and action types, or routing this through the structured logger used in the error path.

Proposed fix
-    console.log("[trackActivityLog] Activity log created", prettyPrint(inputs));
+    console.log(
+      `[trackActivityLog] Created ${inputs.length} activity log(s): ${inputs.map((i) => i.action).join(", ")}`,
+    );
📝 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
console.log("[trackActivityLog] Activity log created", prettyPrint(inputs));
console.log(
`[trackActivityLog] Created ${inputs.length} activity log(s): ${inputs.map((i) => i.action).join(", ")}`,
);
🤖 Prompt for AI Agents
In `@apps/web/lib/api/activity-log/track-activity-log.ts` at line 44, Replace the
insecure console.log call that prints prettyPrint(inputs) in
track-activity-log.ts with a redacted, structured log using the existing logger:
do not serialize full inputs (which include userId, resourceId, description);
instead log only non-sensitive metadata such as inputs.length and the set of
action types (derive from inputs[].action) and, if needed, a hashed or redacted
user identifier. Use the same logger used in the error path (replace console.log
with logger.info or logger.debug) and remove or prevent printing of
prettyPrint(inputs) to stdout.

Comment on lines +55 to +59
// If there are no shared attributes, the rule sets cannot conflict
// (e.g., one checks conversions, the other checks leads - they're independent)
if (sharedAttributes.length === 0) {
return false;
}
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 | 🔴 Critical

Logic bug: disjoint attributes should be treated as overlapping, not non-conflicting.

The comment on line 34 states the intent: "Two rule sets conflict if there exists ANY set of attribute values that would satisfy both simultaneously." However, when rule sets have no shared attributes, the function returns false (no conflict), which is incorrect.

Consider:

  • Rule set 1: {totalLeads >= 10} — satisfied when leads ≥ 10 (conversions unconstrained)
  • Rule set 2: {totalConversions >= 5} — satisfied when conversions ≥ 5 (leads unconstrained)

A partner with 10 leads and 5 conversions satisfies both simultaneously, so they do conflict. The current code would return false, allowing both groups to have these rules and causing ambiguity during partner moves.

The fix: when there are no shared attributes, rule sets always overlap (independent constraints on different dimensions never exclude each other).

Proposed fix
   // If there are no shared attributes, the rule sets cannot conflict
   // (e.g., one checks conversions, the other checks leads - they're independent)
   if (sharedAttributes.length === 0) {
-    return false;
+    return true;
   }
📝 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 there are no shared attributes, the rule sets cannot conflict
// (e.g., one checks conversions, the other checks leads - they're independent)
if (sharedAttributes.length === 0) {
return false;
}
// If there are no shared attributes, the rule sets cannot conflict
// (e.g., one checks conversions, the other checks leads - they're independent)
if (sharedAttributes.length === 0) {
return true;
}
🤖 Prompt for AI Agents
In `@apps/web/lib/api/groups/find-groups-with-matching-rules.ts` around lines 55 -
59, The logic currently treats rule sets with no sharedAttributes as
non-conflicting by returning false; change the behavior so that when
sharedAttributes.length === 0 the function treats the rule sets as overlapping
and returns true instead. Locate the block that checks sharedAttributes.length
(the early-return branch in the conflict-detection function) and replace the
return false with return true (or remove the early-return and let the general
overlap logic handle disjoint attributes), ensuring the function now treats
independent constraints on different attributes as potentially satisfiable
simultaneously.

Comment on lines +27 to +31
...(notificationPreference && {
notificationPreferences: {
[notificationPreference]: true,
},
}),
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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -name "schema.prisma" -type f | head -5

Repository: dubinc/dub

Length of output: 92


🏁 Script executed:

rg "PartnerNotificationPreferences" --type=ts -A 5 -B 5 | head -200

Repository: dubinc/dub

Length of output: 801


🏁 Script executed:

cat -n apps/web/lib/api/partners/get-partner-users.ts | head -50

Repository: dubinc/dub

Length of output: 1461


🏁 Script executed:

cat ./packages/prisma/schema/schema.prisma | grep -A 20 "model PartnerUser"

Repository: dubinc/dub

Length of output: 36


🏁 Script executed:

cat ./packages/prisma/schema/schema.prisma | grep -A 15 "model PartnerNotificationPreferences"

Repository: dubinc/dub

Length of output: 36


🏁 Script executed:

rg "PartnerNotificationPreferences.*create" --type=ts -B 3 -A 3 | head -100

Repository: dubinc/dub

Length of output: 36


🏁 Script executed:

wc -l ./packages/prisma/schema/schema.prisma

Repository: dubinc/dub

Length of output: 95


🏁 Script executed:

rg "model PartnerUser" ./packages/prisma/schema/schema.prisma -A 20

Repository: dubinc/dub

Length of output: 36


🏁 Script executed:

rg "model PartnerNotificationPreferences" ./packages/prisma/schema/schema.prisma -A 15

Repository: dubinc/dub

Length of output: 36


🏁 Script executed:

rg "PartnerNotificationPreferences" --type=ts --type=js | grep -E "(create|new)" -i | head -50

Repository: dubinc/dub

Length of output: 36


🏁 Script executed:

cat ./packages/prisma/schema/schema.prisma

Repository: dubinc/dub

Length of output: 2810


🏁 Script executed:

rg "PartnerNotificationPreferences" --type=ts --type=js -l | head -20

Repository: dubinc/dub

Length of output: 100


🏁 Script executed:

rg "model PartnerUser|model PartnerNotificationPreferences" ./packages/prisma/schema/ -A 20

Repository: dubinc/dub

Length of output: 2395


🏁 Script executed:

sed -n '/model Partner/,/^model /p' ./packages/prisma/schema/schema.prisma | head -80

Repository: dubinc/dub

Length of output: 36


🏁 Script executed:

rg "PartnerNotificationPreferences" --type=ts --type=js -B 5 -A 5 | grep -E "(create|upsert|connect)" -i -B 3 -A 3 | head -100

Repository: dubinc/dub

Length of output: 36


🏁 Script executed:

rg "partnerUser.*create|PartnerUser.*create" --type=ts --type=js -A 10 | head -80

Repository: dubinc/dub

Length of output: 1556


🏁 Script executed:

rg "partnerUser.create" --type=ts --type=js -B 3 -A 10 | head -150

Repository: dubinc/dub

Length of output: 1879


🏁 Script executed:

rg "notificationPreferences.*create|notificationPreferences.*upsert" --type=ts --type=js -B 5 -A 5

Repository: dubinc/dub

Length of output: 36


🏁 Script executed:

rg "getPartnerUsers" --type=ts --type=js -B 3 -A 3 | head -100

Repository: dubinc/dub

Length of output: 4844


🏁 Script executed:

rg "partnerUser.create|partnerUser.createMany" --type=ts --type=js -A 15 | grep -E "notificationPreferences|create|data" | head -80

Repository: dubinc/dub

Length of output: 865


🏁 Script executed:

cat -n apps/web/app/\(ee\)/api/partner-profile/invites/accept/route.ts | grep -A 20 "partnerUser.create"

Repository: dubinc/dub

Length of output: 655


🏁 Script executed:

rg "partnerUser\.create\(|partnerUser\.createMany\(" --type=ts --type=js -B 2 -A 15 | grep -v node_modules

Repository: dubinc/dub

Length of output: 2596


🏁 Script executed:

find . -type f -name "*.ts" -o -name "*.js" | xargs grep -l "partnerUser" | xargs grep -l "create" | grep -v node_modules | head -20

Repository: dubinc/dub

Length of output: 1003


🏁 Script executed:

rg "partnerUser\.findMany|partnerUser\.findFirst" --type=ts -A 20 | grep -E "where|notificationPreference" -B 5 -A 5 | head -80

Repository: dubinc/dub

Length of output: 5100


🏁 Script executed:

cat -n apps/web/app/\(ee\)/api/workflows/partner-approved/route.ts | grep -B 5 -A 15 "notificationPreferences"

Repository: dubinc/dub

Length of output: 690


🏁 Script executed:

rg "invite-partner-user" --type=ts -A 30 | grep -E "create|notification"

Repository: dubinc/dub

Length of output: 36


🏁 Script executed:

cat -n apps/web/lib/api/partners/invite-partner-user.ts | head -100

Repository: dubinc/dub

Length of output: 2343


🏁 Script executed:

rg "fix-broken-partner-users" --type=ts -A 50 | head -100

Repository: dubinc/dub

Length of output: 36


🏁 Script executed:

cat -n apps/web/scripts/fix-broken-partner-users.ts

Repository: dubinc/dub

Length of output: 1345


🏁 Script executed:

rg "partnerNotificationPreferences" ./packages/prisma/schema/notification.prisma -B 2 -A 10

Repository: dubinc/dub

Length of output: 36


🏁 Script executed:

cat ./packages/prisma/schema/notification.prisma | grep -A 15 "model PartnerNotificationPreferences"

Repository: dubinc/dub

Length of output: 457


Users without notificationPreferences records will be silently excluded from results.

The Prisma relation filter notificationPreferences: { [pref]: true } excludes any partnerUser row without an associated PartnerNotificationPreferences record entirely—not just those where the flag is false. Since the schema defines the relation as optional (notificationPreferences PartnerNotificationPreferences?) and invites/accept/route.ts is the only code path that creates preference records (while seed.ts creates users without them), users without preferences will be filtered out silently.

This affects both get-partner-users.ts (lines 27-31) and workflows/partner-approved/route.ts (line 247-248).

Ensure PartnerNotificationPreferences records are always created when a PartnerUser is created, or update the filter to include users without a preferences record:

OR: [
  { notificationPreferences: { [notificationPreference]: true } },
  { notificationPreferences: null },
]
🤖 Prompt for AI Agents
In `@apps/web/lib/api/partners/get-partner-users.ts` around lines 27 - 31, The
Prisma relation filter on notificationPreferences in get-partner-users.ts (and
similarly in workflows/partner-approved/route.ts) currently excludes PartnerUser
rows that have no PartnerNotificationPreferences record; update the query using
the notificationPreference variable to add an OR branch that keeps users with
notificationPreferences null (i.e., OR: [{ notificationPreferences: {
[notificationPreference]: true } }, { notificationPreferences: null }]) so users
without a prefs record are not filtered out, or alternatively ensure
PartnerNotificationPreferences is always created when creating a PartnerUser
(adjust create logic used by invites/accept/route.ts and seed paths) so that
notificationPreferences is never null; target the notificationPreferences
relation filter and PartnerUser creation code paths when applying the fix.

partnerId,
groupIds: campaign.groups.map(({ groupId }) => groupId),
condition,
condition: condition as CampaignTriggerCondition,
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Check the return type of parseWorkflowConfig and the definition of CampaignTriggerCondition
echo "=== parseWorkflowConfig return type ==="
ast-grep --pattern 'function parseWorkflowConfig($_) {
  $$$
}'

echo -e "\n=== CampaignTriggerCondition schema ==="
rg -n "campaignTriggerConditionSchema" --type=ts -C5

Repository: dubinc/dub

Length of output: 5518


🏁 Script executed:

# Find workflowConditionSchema definition
echo "=== workflowConditionSchema definition ==="
rg -n "workflowConditionSchema" --type=ts -A10 | head -50

echo -e "\n=== Context around line 75 in execute-send-campaign-workflow.ts ==="
sed -n '60,85p' apps/web/lib/api/workflows/execute-send-campaign-workflow.ts

Repository: dubinc/dub

Length of output: 3918


🏁 Script executed:

# Check what WORKFLOW_COMPARISON_OPERATORS contains
echo "=== WORKFLOW_COMPARISON_OPERATORS definition ==="
rg -n "WORKFLOW_COMPARISON_OPERATORS" --type=ts -B2 -A5 | head -30

# Also check WORKFLOW_ATTRIBUTES to be thorough
echo -e "\n=== WORKFLOW_ATTRIBUTES definition ==="
rg -n "WORKFLOW_ATTRIBUTES" --type=ts -B2 -A5 | head -30

Repository: dubinc/dub

Length of output: 5148


Remove unsafe type assertion or add type validation before casting.

The cast condition as CampaignTriggerCondition is unsafe. workflowConditionSchema allows operator: "between" and value: { min, max }, but campaignTriggerConditionSchema only accepts operator: "gte" with value: number. A workflow condition with operator "between" would pass the assertion at compile time but fail at runtime in getProgramEnrollments, which expects a numeric value.

Add runtime validation (e.g., check condition.operator === "gte" and typeof condition.value === "number") before the cast, or refactor the type to properly narrow the condition before passing it to getProgramEnrollments.

🤖 Prompt for AI Agents
In `@apps/web/lib/api/workflows/execute-send-campaign-workflow.ts` at line 75, The
code is unsafely casting `condition as CampaignTriggerCondition` before passing
it to `getProgramEnrollments`; instead, validate and narrow the runtime shape
first: check that `condition.operator === "gte"` and `typeof condition.value ===
"number"` (or use `campaignTriggerConditionSchema` to validate) and only then
cast/forward it, otherwise handle the unsupported operators (e.g., throw a clear
error or convert/skip); update the logic in execute-send-campaign-workflow.ts
around the `condition` variable so `getProgramEnrollments` always receives a
true numeric `CampaignTriggerCondition`.

Comment on lines +20 to +24
export const bountyPerformanceConditionSchema = z.object({
attribute: z.enum(WORKFLOW_ATTRIBUTES),
operator: z.literal("gte").default("gte"),
value: z.number(),
});
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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find the bounties.ts file and workflows.ts file
find . -type f -name "bounties.ts" -o -name "workflows.ts" | grep -E "(zod|schemas)" | head -20

Repository: dubinc/dub

Length of output: 132


🏁 Script executed:

# Check the WORKFLOW_ATTRIBUTES definition in workflows.ts
rg -n "WORKFLOW_ATTRIBUTES" --type=ts -A 15 | head -80

Repository: dubinc/dub

Length of output: 8427


🏁 Script executed:

# Get the full bounties.ts schema file to see the current state
cat -n apps/web/lib/zod/schemas/bounties.ts | head -150

Repository: dubinc/dub

Length of output: 5812


🏁 Script executed:

# Find PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES definition
rg -n "PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES" --type=ts -B 2 -A 20 | head -100

Repository: dubinc/dub

Length of output: 8793


🏁 Script executed:

# Check WORKFLOW_ATTRIBUTES definition
rg -n "export const WORKFLOW_ATTRIBUTES" --type=ts -A 30

Repository: dubinc/dub

Length of output: 2248


🏁 Script executed:

# Check bounty creation endpoints for validation
rg -n "bountyPerformanceConditionSchema" --type=ts

Repository: dubinc/dub

Length of output: 965


🏁 Script executed:

# Find and display PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES
find . -name "performance-bounty-scope-attributes.ts" -o -name "*performance*bounty*" | head -20

Repository: dubinc/dub

Length of output: 257


🏁 Script executed:

# Get the full content of performance-bounty-scope-attributes file
cat -n apps/web/lib/api/bounties/performance-bounty-scope-attributes.ts 2>/dev/null || rg -n "PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES" --type=ts -B 2 -A 20 | grep -v "bounty-logic\|bounty-performance" | head -50

Repository: dubinc/dub

Length of output: 402


🏁 Script executed:

# Check bounty creation endpoint to see if there's server-side validation
fd -e ts "bounties.*route\|create.*bounty" | head -10

Repository: dubinc/dub

Length of output: 36


🏁 Script executed:

# Search for bounty creation/update routes
find . -path "*/bounties/*route.ts" | head -20

Repository: dubinc/dub

Length of output: 662


🏁 Script executed:

# Check if there's server-side validation that restricts bounty performance condition attributes
rg -n "performanceCondition" --type=ts -B 3 -A 3 | grep -E "route\.ts|bounty.*create|bounty.*update" | head -30

Repository: dubinc/dub

Length of output: 2731


🏁 Script executed:

# Look for bounty creation routes specifically
fd -e ts "\[bountyId\]|bounties" | xargs rg -l "createBountySchema\|bountyPerformanceConditionSchema" | head -10

Repository: dubinc/dub

Length of output: 36


🏁 Script executed:

# Check the full bounties creation route
cat -n apps/web/app/(ee)/api/bounties/route.ts | head -250

Repository: dubinc/dub

Length of output: 199


🏁 Script executed:

# Check if there's any validation of performanceCondition attributes at creation time
rg -n "performanceCondition" apps/web/app/\(ee\)/api/bounties/route.ts -B 5 -A 5

Repository: dubinc/dub

Length of output: 2104


🏁 Script executed:

# Check the generatePerformanceBountyName function to see what attributes it handles
cat -n apps/web/lib/api/bounties/generate-performance-bounty-name.ts

Repository: dubinc/dub

Length of output: 1125


🏁 Script executed:

# Check for any validation of bounty performance condition attributes on the server
rg -n "PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES\[" --type=ts -B 2 -A 2

Repository: dubinc/dub

Length of output: 3535


🏁 Script executed:

# Check if WORKFLOW_ATTRIBUTE_TRIGGER includes partnerEnrolledDays/partnerJoined
cat -n apps/web/lib/zod/schemas/workflows.ts | grep -A 10 "WORKFLOW_ATTRIBUTE_TRIGGER"

Repository: dubinc/dub

Length of output: 580


🏁 Script executed:

# Check WORKFLOW_ATTRIBUTE_TRIGGER access at line 227 - does it have safety checks?
sed -n '215,235p' apps/web/app/\(ee\)/api/bounties/route.ts

Repository: dubinc/dub

Length of output: 666


🏁 Script executed:

# Verify the type signature of BountyPerformanceCondition
rg -n "type BountyPerformanceCondition\|interface BountyPerformanceCondition" --type=ts -A 5

Repository: dubinc/dub

Length of output: 36


🏁 Script executed:

# Check if there's any validation that would catch invalid attributes in the workflow creation
rg -n "WORKFLOW_ATTRIBUTE_TRIGGER\[" --type=ts -B 3 -A 3

Repository: dubinc/dub

Length of output: 1860


Restrict bounty performance condition attributes to valid types.

z.enum(WORKFLOW_ATTRIBUTES) allows "partnerEnrolledDays" and "partnerJoined", which are not supported for bounty performance conditions. Bounties only support stats-based attributes: totalLeads, totalConversions, totalSaleAmount, and totalCommissions. Without schema-level restriction, invalid attributes could bypass the UI and cause runtime errors when accessing PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES.

Define a dedicated constant for bounty performance attributes:

Suggested fix
+const BOUNTY_PERFORMANCE_ATTRIBUTES = [
+  "totalLeads",
+  "totalConversions",
+  "totalSaleAmount",
+  "totalCommissions",
+] as const;
+
 export const bountyPerformanceConditionSchema = z.object({
-  attribute: z.enum(WORKFLOW_ATTRIBUTES),
+  attribute: z.enum(BOUNTY_PERFORMANCE_ATTRIBUTES),
   operator: z.literal("gte").default("gte"),
   value: z.number(),
 });
📝 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
export const bountyPerformanceConditionSchema = z.object({
attribute: z.enum(WORKFLOW_ATTRIBUTES),
operator: z.literal("gte").default("gte"),
value: z.number(),
});
const BOUNTY_PERFORMANCE_ATTRIBUTES = [
"totalLeads",
"totalConversions",
"totalSaleAmount",
"totalCommissions",
] as const;
export const bountyPerformanceConditionSchema = z.object({
attribute: z.enum(BOUNTY_PERFORMANCE_ATTRIBUTES),
operator: z.literal("gte").default("gte"),
value: z.number(),
});
🤖 Prompt for AI Agents
In `@apps/web/lib/zod/schemas/bounties.ts` around lines 20 - 24, The
bountyPerformanceConditionSchema currently uses z.enum(WORKFLOW_ATTRIBUTES)
which permits non-stats attributes; create a new constant (e.g.,
BOUNTY_PERFORMANCE_ATTRIBUTES) listing only the supported stats: "totalLeads",
"totalConversions", "totalSaleAmount", and "totalCommissions", then update
bountyPerformanceConditionSchema to use z.enum(BOUNTY_PERFORMANCE_ATTRIBUTES)
instead of WORKFLOW_ATTRIBUTES so schema-level validation matches
PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES and prevents unsupported attributes like
"partnerEnrolledDays" or "partnerJoined".

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.

2 participants