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

Skip to content

Conversation

@steven-tey
Copy link
Collaborator

@steven-tey steven-tey commented Aug 16, 2025

Summary by CodeRabbit

  • New Features

    • Group-based defaults for partner enrollment and imports, ensuring consistent reward/discount assignment.
    • Program logos are applied at creation and shown in welcome/invite emails immediately.
  • Refactor

    • Removed endpoints that listed program rewards and discounts.
    • Program fetch no longer auto-includes default rewards/discounts.
    • Enrollment and invite flows now drive assignment via groupId rather than per-event reward parameters.
    • Importers and integrations switched to group-based attributes.
  • Chores

    • Removed deprecated default flags from Reward and Discount schemas.
    • Minor migration and type-check adjustments.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 16, 2025

Warning

Rate limit exceeded

@steven-tey has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait 9 minutes and 24 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between 4b3f2c3 and f875691.

📒 Files selected for processing (5)
  • apps/web/lib/actions/partners/update-program.ts (0 hunks)
  • apps/web/lib/api/partners/create-and-enroll-partner.ts (4 hunks)
  • apps/web/lib/middleware/utils/app-redirect.ts (1 hunks)
  • apps/web/lib/rewardful/import-campaign.ts (3 hunks)
  • apps/web/ui/partners/partner-details-sheet.tsx (1 hunks)

Walkthrough

Removed program-level GET endpoints for rewards/discounts, eliminated Reward/Discount default fields, and migrated partner enrollment/import flows from reward-centric defaults to group-centric defaults; program logo is now set during creation transaction.

Changes

Cohort / File(s) Summary
API routes cleanup
apps/web/app/(ee)/api/programs/[programId]/discounts/route.ts, apps/web/app/(ee)/api/programs/[programId]/rewards/route.ts, apps/web/app/(ee)/api/programs/[programId]/route.ts
Deleted GET routes for program discounts and rewards; program GET no longer requests includeDefaultDiscount/includeDefaultRewards.
Program fetch refactor
apps/web/lib/api/programs/get-program-or-throw.ts
Removed includeDefaultRewards/includeDefaultDiscount options and associated eager-loading/augmentation; returns program (optionally with lander data) without injecting rewards/discounts.
Partner creation & invite
apps/web/lib/actions/partners/create-program.ts, apps/web/lib/actions/partners/invite-partner.ts
Set program.logo during creation transaction; removed post-creation logo update; removed reward param from invitePartner; mapped first-created reward into partner-group column at creation time; invitePartner no longer defaults groupId when passing to createAndEnrollPartner.
Create & enroll partner API
apps/web/lib/api/partners/create-and-enroll-partner.ts
Switched from reward/discount-driven enrollment to group-driven enrollment: removed reward/discountId params, added groupId param, fetches group via getGroupOrThrow(includeRewardsAndDiscount) and upserts enrollment using group-derived reward/discount IDs.
Importers → group-centric
apps/web/lib/partnerstack/import-partners.ts, apps/web/lib/rewardful/import-campaign.ts, apps/web/lib/rewardful/import-partners.ts, apps/web/lib/tolt/import-partners.ts, apps/web/lib/rewardful/schemas.ts
Import flows now resolve a default partner group (DEFAULT_PARTNER_GROUP.slug) and pass group-derived attributes (groupId, sale/lead/clickRewardId, discountId) instead of per-event reward objects; payload schema renamed rewardId → groupId.
Schema & Prisma changes
packages/prisma/schema/reward.prisma, packages/prisma/schema/discount.prisma
Removed default Boolean field from Reward and Discount models.
Scripts / migrations
apps/web/scripts/migrations/backfill-partner-groups.ts, apps/web/scripts/migrations/migrate-program-invites.ts, apps/web/scripts/migrations/migrate-rewards-remainder.ts
Minor adjustments: added ts directives/ignore and import path fixes; runtime logic unchanged.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Server
  participant Groups as Partner Groups
  participant DB as Prisma

  Client->>Server: createAndEnrollPartner({ programId, groupId? })
  Server->>Groups: getGroupOrThrow(groupId || program.defaultGroupId) (include rewards/discount)
  Groups-->>Server: { id, saleRewardId, leadRewardId, clickRewardId, discountId }
  Server->>DB: upsert programEnrollment with group-derived IDs (groupId, sale/lead/clickRewardId, discountId)
  DB-->>Server: Enrollment result
  Server-->>Client: Enrollment response
Loading
sequenceDiagram
  participant Client
  participant Server
  participant Storage as File Storage
  participant DB as Prisma

  Client->>Server: createProgram({ logo?, rewards })
  Server->>Storage: uploadLogo(logo)
  Storage-->>Server: logoUrl
  Server->>DB: Transaction: create program { logo: logoUrl }, create rewards, upsert partner group mapping (map first reward to group column)
  DB-->>Server: Program + Reward + Group
  Server-->>Client: Program created
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • devkiran

Poem

I nudge the groups, not single rewards, in rows so neat,
I hop and tuck the logo safe where DB and storage meet.
Routes close gently, defaults change their tune,
Enrollments march behind the group — beneath the moon.
Thump-thump, a rabbit cheers the tidy code. 🐇✨

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch program-creation-groups

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
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@vercel
Copy link
Contributor

vercel bot commented Aug 16, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Aug 16, 2025 1:32am

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

🔭 Outside diff range comments (2)
apps/web/scripts/migrations/backfill-partner-groups.ts (2)

151-158: Default group selection can silently fail depending on iteration order.

isDefaultGroup relies on programIdCounts[group.programId] === 1 (i.e., “the first group we encounter for a program”). Since groups are ordered by _count.programId: "desc" (size of each group) across all programs, the first group for a program may not be the one containing the default reward. If a non-default-reward group is processed first, the actual default group for that program will never be marked as default.

Pick the default group per program based on the presence of the default reward, independent of iteration order. A minimal change:

  • Track whether a program has already been assigned a default group.
  • Set isDefaultGroup to true when encountering the first group for that program that contains a default reward (not the first group overall).

Apply within this block:

-      if (programIdCounts[group.programId] === undefined) {
-        programIdCounts[group.programId] = 0;
-      }
-      programIdCounts[group.programId]++;
-
-      const isDefaultGroup =
-        hasDefaultReward && programIdCounts[group.programId] === 1;
+      if (programIdCounts[group.programId] === undefined) {
+        programIdCounts[group.programId] = 0;
+      }
+      programIdCounts[group.programId]++;
+
+      // Track assignment separately from count-based ordering
+      if (typeof defaultGroupAssigned === "undefined") {
+        // add near other counters; see snippet below
+      }
+      const isDefaultGroup = hasDefaultReward && !defaultGroupAssigned[group.programId];
+      if (isDefaultGroup) {
+        defaultGroupAssigned[group.programId] = true;
+      }

Add this near the other counters (around Line 77–80):

// Tracks if we've already assigned a default group for a program
const defaultGroupAssigned: Record<string, boolean> = {};

This ensures the first group containing a default reward becomes the program’s default, regardless of iteration order.

I can push a follow-up patch with the full change if useful.


188-196: Defer Program.defaultGroupId updates until after creating PartnerGroup records

The migration currently updates each Program’s defaultGroupId inside the loop (around lines 188–196) before any PartnerGroup rows are actually inserted (via prisma.partnerGroup.createMany at lines 250–254). If Program.defaultGroupId has a FK → PartnerGroup.id, those updates will fail.

Please refactor as follows:

• In apps/web/scripts/migrations/backfill-partner-groups.ts, replace the in‐loop update with a collector:

-      if (isDefaultGroup) {
-        await prisma.program.update({
-          where: { id: group.programId },
-          data: { defaultGroupId: finalGroupId },
-        });
-        console.log(
-          `Updated program ${group.programId} with default group ${finalGroupId}`,
-        );
-      }
+      if (isDefaultGroup) {
+        defaultGroupUpdates.push({
+          programId: group.programId,
+          groupId: finalGroupId,
+        });
+      }

• Near the top of the file, declare the collector:

const defaultGroupUpdates: Array<{ programId: string; groupId: string }> = [];

• After the prisma.partnerGroup.createMany(…) call (lines 250–254), add:

for (const { programId, groupId } of defaultGroupUpdates) {
  await prisma.program.update({
    where: { id: programId },
    data: { defaultGroupId: groupId },
  });
  console.log(`Updated program ${programId} with default group ${groupId}`);
}

This ensures all PartnerGroups exist before setting any FK, avoiding constraint errors.

🧹 Nitpick comments (20)
apps/web/scripts/migrations/migrate-program-invites.ts (2)

61-71: Unused variable from link update.

linkRes is never used after the update/record. Consider dropping the binding or prefixing with _ to avoid lints and signal intent.

Apply:

-      const linkRes = await prisma.link
+      await prisma.link
         .update({
           where: {
             id: link.id,
           },
           data: {
             programId: programInvite.programId,
             partnerId: partner.id,
           },
         })
         .then((link) => recordLink(link));

10-11: Unify import paths in migrate-program-invites.ts

Both getEvents and recordLink exports were found at:

  • apps/web/lib/analytics/get-events.ts
  • apps/web/lib/tinybird/record-link.ts

Your current imports

import { getEvents }   from "../../lib/analytics/get-events";
import { recordLink }  from "../../lib/tinybird";

correctly resolve those modules, but the file still mixes @/lib/... aliases and ../../lib/... relative imports. If you run this script via ts-node/tsx without enabling TS path-mapping, any remaining @/lib/... imports will fail at runtime.

• Option A (recommended): convert all remaining @/lib/... imports in this script to relative paths for consistency.
• Option B: ensure your script runner is configured to honor tsconfig.json’s paths so aliases resolve correctly.

[optional_refactors_recommended]

apps/web/scripts/migrations/backfill-partner-groups.ts (1)

142-149: Default-group detection is OK, but depends on legacy field; keep the ignore minimal.

The ts-ignore on reward.default is reasonable for a one-off migration tied to a legacy schema. However, make sure this script is executed on a client built from the schema that still contains Reward.default, or add a guard similar to the one suggested in migrate-rewards-remainder.ts to fail fast with a helpful message.

Would you like me to add the same guard pattern here?

apps/web/lib/rewardful/schemas.ts (1)

14-18: Rename to groupId looks right; consider back-compat for in-flight jobs

If any queued/import payloads still send rewardId, this will 400 without a migration step. You can accept both and map rewardId -> groupId transparently, then remove rewardId later.

Apply this diff to keep the new contract while providing a safe transition:

 export const rewardfulImportPayloadSchema = z.object({
   importId: z.string(),
   userId: z.string(),
   programId: z.string(),
-  groupId: z.string().optional(),
+  // New
+  groupId: z.string().optional(),
+  // Back-compat (deprecated)
+  rewardId: z.string().optional(),
   campaignId: z.string(),
   action: rewardfulImportSteps,
   page: z.number().optional(),
-});
+}).transform((data) => ({
+  ...data,
+  // Prefer explicit groupId; fall back to deprecated rewardId if present
+  groupId: data.groupId ?? data.rewardId,
+}));
apps/web/lib/api/partners/create-and-enroll-partner.ts (3)

85-89: Guard against missing defaultGroupId; avoid non-null assertion and drop unnecessary include

The non-null assertion on program.defaultGroupId can throw at runtime if a caller bypasses the earlier check. Also, you’re not using the included relations from getGroupOrThrow here.

Apply this diff to resolve the group ID explicitly and skip relation includes:

-  const group = await getGroupOrThrow({
-    programId: program.id,
-    groupId: groupId || program.defaultGroupId!,
-    includeRewardsAndDiscount: true,
-  });
+  const resolvedGroupId = groupId ?? program.defaultGroupId;
+  if (!resolvedGroupId) {
+    throw new DubApiError({
+      code: "bad_request",
+      message:
+        "Group ID is required but neither an explicit groupId nor program.defaultGroupId was provided.",
+    });
+  }
+  const group = await getGroupOrThrow({
+    programId: program.id,
+    groupId: resolvedGroupId,
+  });

48-64: Duplicate enrollment pre-checks can race; rely on unique constraints for true guarantees

The pre-checks for existing enrollments by email can race under concurrent requests. Consider enforcing uniqueness at the DB layer (unique composite constraint) and catching conflicts, using the pre-checks only for UX.

I can propose a pattern that wraps the upsert/create in a try/catch for Prisma’s P2002 conflict if you want.


160-176: Avoid mixing booleans with Promises in Promise.allSettled

The link.saleAmount > 0 && backfillLinkCommissions(...) expression adds a boolean when the condition is false. While harmless, it’s noisy and can confuse readers.

Apply this diff for clarity:

-          Promise.allSettled([
+          Promise.allSettled([
             linkCache.delete({
               domain: link.domain,
               key: link.key,
             }),
 
             recordLink(link),
 
-            link.saleAmount > 0 &&
-              backfillLinkCommissions({
-                id: link.id,
-                partnerId: upsertedPartner.id,
-                programId: program.id,
-              }),
+            ...(link.saleAmount > 0
+              ? [
+                  backfillLinkCommissions({
+                    id: link.id,
+                    partnerId: upsertedPartner.id,
+                    programId: program.id,
+                  }),
+                ]
+              : []),
           ]),
apps/web/lib/actions/partners/invite-partner.ts (2)

80-82: Make the error more actionable

Including the programId helps debugging when multiple programs exist.

Apply this diff:

-    if (!groupId && !program.defaultGroupId) {
-      throw new Error("No group ID provided and no default group ID found.");
-    }
+    if (!groupId && !program.defaultGroupId) {
+      throw new Error(
+        `No group ID provided and no default group ID found for program ${program.id}.`,
+      );
+    }

84-95: Pass an explicit fallback groupId to the enroll call

This keeps createAndEnrollPartner free of non-null assertions and makes the intent here explicit.

Apply this diff:

     const enrolledPartner = await createAndEnrollPartner({
       program,
       link,
       workspace,
       partner: {
         name,
         email,
       },
       skipEnrollmentCheck: true,
       status: "invited",
-      groupId,
+      groupId: groupId ?? program.defaultGroupId!,
     });
apps/web/lib/api/programs/get-program-or-throw.ts (1)

23-28: Use a truly unique selector in findUnique (or switch to findFirst for scoped lookups)

Passing both id and workspaceId to findUnique is atypical unless you have a composite unique on (id, workspaceId). Since id is globally unique, simplify the selector.

Apply this diff:

-  const program = (await prisma.program.findUnique({
-    where: {
-      id: programId,
-      workspaceId,
-    },
-  })) as ProgramProps | null;
+  const program = (await prisma.program.findUnique({
+    where: { id: programId },
+  })) as ProgramProps | null;

If you do need workspace scoping, prefer:

  • findFirst({ where: { id: programId, workspaceId } })
  • or a composite unique input (e.g., where: { id_workspaceId: { id: programId, workspaceId } }) if declared in Prisma.
apps/web/lib/actions/partners/create-program.ts (3)

87-92: Avoid external I/O inside the Prisma transaction

Uploading to R2 while holding a DB transaction increases lock time and risks orphaned files on rollback. Upload first, then start the transaction.

Apply this refactor:

  1. Move logo upload before the transaction:
// Before prisma.$transaction
const programId = createId({ prefix: "prog_" });
const defaultGroupId = createId({ prefix: "grp_" });

const logoUrl =
  uploadedLogo
    ? await storage
        .upload(`programs/${programId}/logo_${nanoid(7)}`, uploadedLogo)
        .then(({ url }) => url)
    : null;
  1. Inside the transaction, remove the upload block and use logoUrl:
-    const programId = createId({ prefix: "prog_" });
-    const defaultGroupId = createId({ prefix: "grp_" });
-
-    const logoUrl = uploadedLogo
-      ? await storage
-          .upload(`programs/${programId}/logo_${nanoid(7)}`, uploadedLogo)
-          .then(({ url }) => url)
-      : null;
+    // programId, defaultGroupId, and logoUrl are precomputed outside the transaction

108-119: Be explicit about allowed zero values when gating reward creation

Using truthiness will skip creation when amount is 0; if 0 is invalid, keep as-is. If you want to gate on presence, check for nullish instead.

Apply this diff:

-        ...(type &&
-          amount && {
+        ...(type != null &&
+          amount != null && {
             rewards: {
               create: {
                 id: createId({ prefix: "rw_" }),
                 type,
                 amount,
                 maxDuration,
                 event: defaultRewardType,
               },
             },
           }),

271-281: Pass the default group explicitly when inviting partners

This avoids relying on downstream fallbacks and makes the group assignment intention clear.

Apply this diff:

   await createAndEnrollPartner({
     program,
     link: partnerLink,
     workspace,
     partner: {
       name: partner.email.split("@")[0],
       email: partner.email,
     },
     skipEnrollmentCheck: true,
     status: "invited",
+    groupId: program.defaultGroupId!,
   });
apps/web/lib/rewardful/import-campaign.ts (3)

56-61: Redundant event in the where clause

event is already part of rewardProps; specifying it again is unnecessary.

Apply:

-  const existingReward = await prisma.reward.findFirst({
-    where: { ...rewardProps, event: EventType.sale },
+  const existingReward = await prisma.reward.findFirst({
+    where: { ...rewardProps },

63-97: Make reward + group creation atomic to avoid race conditions

If multiple imports run concurrently, you can end up with duplicate rewards or group updates failing mid-way. Wrap the reward creation and group create/update in a single transaction and capture the final groupId deterministically.

Apply:

-  if (!existingReward) {
-    // if no existing reward, create a new one + group
-    const createdReward = await prisma.reward.create({
-      data: {
-        ...rewardProps,
-        id: createId({ prefix: "rw_" }),
-      },
-    });
-
-    // if the default group has an associated sale reward already, we need to create a new group
-    if (groups[0].saleRewardId) {
-      const createdGroup = await prisma.partnerGroup.create({
-        data: {
-          id: createId({ prefix: "grp_" }),
-          programId,
-          name: `(Rewardful) ${campaign.name}`,
-          slug: `rewardful-${campaignId}`,
-          color: randomValue(RESOURCE_COLORS),
-          saleRewardId: createdReward.id,
-        },
-      });
-      groupId = createdGroup.id;
-      // else we just update the existing group with the newly created sale reward
-    } else {
-      const updatedGroup = await prisma.partnerGroup.update({
-        where: {
-          id: groups[0].id,
-        },
-        data: {
-          saleRewardId: createdReward.id,
-        },
-      });
-      groupId = updatedGroup.id;
-    }
-  } else {
+  if (!existingReward) {
+    // Create reward and link/create group atomically
+    const [, finalGroup] = await prisma.$transaction(async (tx) => {
+      const createdReward = await tx.reward.create({
+        data: {
+          ...rewardProps,
+          id: createId({ prefix: "rw_" }),
+        },
+      });
+
+      if (groups[0].saleRewardId) {
+        const createdGroup = await tx.partnerGroup.create({
+          data: {
+            id: createId({ prefix: "grp_" }),
+            programId,
+            name: `(Rewardful) ${campaign.name}`,
+            slug: `rewardful-${campaignId}`,
+            color: randomValue(RESOURCE_COLORS),
+            saleRewardId: createdReward.id,
+          },
+        });
+        return [createdReward, createdGroup] as const;
+      } else {
+        const updatedGroup = await tx.partnerGroup.update({
+          where: { id: groups[0].id },
+          data: { saleRewardId: createdReward.id },
+        });
+        return [createdReward, updatedGroup] as const;
+      }
+    });
+    groupId = finalGroup.id;
+  } else {

99-100: Avoid non-null assertion; add a safe fallback for groupId

existingReward may not be linked to a group. Fall back to the default group's id when present.

Apply:

-    groupId = existingReward.salePartnerGroup?.id!;
+    groupId = existingReward.salePartnerGroup?.id ?? groups[0]?.id ?? null;
apps/web/lib/partnerstack/import-partners.ts (1)

156-165: Consider syncing group attributes on update as well

Upsert.update only sets status. If an enrollment exists with stale or missing group/reward IDs, it won’t be corrected. If desired, also set defaultGroupAttributes in update (idempotent if unchanged).

Apply:

   update: {
-    status: "approved",
+    status: "approved",
+    ...defaultGroupAttributes,
   },

If you intentionally don’t want to mutate existing enrollments’ group assignment, ignore this.

apps/web/lib/rewardful/import-partners.ts (2)

75-92: Make partner creation resilient to individual failures

Using Promise.all will reject the entire batch if one createPartnerAndLinks call fails. Consider Promise.allSettled (as used in Tolt importer) to continue importing others and log failures.

Apply:

-      await Promise.all(
-        activeAffiliates.map((affiliate) =>
-          createPartnerAndLinks({
-            program,
-            affiliate,
-            userId,
-            defaultGroupAttributes: {
-              groupId: defaultGroup.id,
-              saleRewardId: defaultGroup.saleRewardId,
-              leadRewardId: defaultGroup.leadRewardId,
-              clickRewardId: defaultGroup.clickRewardId,
-              discountId: defaultGroup.discountId,
-            },
-          }),
-        ),
-      );
+      const results = await Promise.allSettled(
+        activeAffiliates.map((affiliate) =>
+          createPartnerAndLinks({
+            program,
+            affiliate,
+            userId,
+            defaultGroupAttributes: {
+              groupId: defaultGroup.id,
+              saleRewardId: defaultGroup.saleRewardId,
+              leadRewardId: defaultGroup.leadRewardId,
+              clickRewardId: defaultGroup.clickRewardId,
+              discountId: defaultGroup.discountId,
+            },
+          }),
+        ),
+      );
+      // Optional: log failures for observability
+      results.forEach((r, i) => {
+        if (r.status === "rejected") {
+          console.error("Rewardful partner import failed", {
+            email: activeAffiliates[i]?.email,
+            reason: r.reason,
+          });
+        }
+      });

149-165: Consider updating enrollment group attributes on upsert.update

Same as PartnerStack flow: update currently only flips status. If you want existing enrollments to reflect the selected group, include defaultGroupAttributes on update as well.

Apply:

   update: {
-    status: "approved",
+    status: "approved",
+    ...defaultGroupAttributes,
   },

If changing an existing partner’s group is risky for your product semantics, keep the current behavior.

apps/web/lib/tolt/import-partners.ts (1)

151-167: Optionally sync group attributes on enrollment update

As with the other importers, consider including defaultGroupAttributes in the update path if you want existing enrollments to align with the default group.

Apply:

   update: {
-    status: "approved",
+    status: "approved",
+    ...defaultGroupAttributes,
   },
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 1ad8500 and 5687a81.

📒 Files selected for processing (17)
  • apps/web/app/(ee)/api/programs/[programId]/discounts/route.ts (0 hunks)
  • apps/web/app/(ee)/api/programs/[programId]/rewards/route.ts (0 hunks)
  • apps/web/app/(ee)/api/programs/[programId]/route.ts (0 hunks)
  • apps/web/lib/actions/partners/create-program.ts (5 hunks)
  • apps/web/lib/actions/partners/invite-partner.ts (1 hunks)
  • apps/web/lib/api/partners/create-and-enroll-partner.ts (4 hunks)
  • apps/web/lib/api/programs/get-program-or-throw.ts (1 hunks)
  • apps/web/lib/partnerstack/import-partners.ts (5 hunks)
  • apps/web/lib/rewardful/import-campaign.ts (3 hunks)
  • apps/web/lib/rewardful/import-partners.ts (6 hunks)
  • apps/web/lib/rewardful/schemas.ts (1 hunks)
  • apps/web/lib/tolt/import-partners.ts (5 hunks)
  • apps/web/scripts/migrations/backfill-partner-groups.ts (1 hunks)
  • apps/web/scripts/migrations/migrate-program-invites.ts (1 hunks)
  • apps/web/scripts/migrations/migrate-rewards-remainder.ts (1 hunks)
  • packages/prisma/schema/discount.prisma (0 hunks)
  • packages/prisma/schema/reward.prisma (0 hunks)
💤 Files with no reviewable changes (5)
  • packages/prisma/schema/discount.prisma
  • packages/prisma/schema/reward.prisma
  • apps/web/app/(ee)/api/programs/[programId]/route.ts
  • apps/web/app/(ee)/api/programs/[programId]/rewards/route.ts
  • apps/web/app/(ee)/api/programs/[programId]/discounts/route.ts
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-08-14T05:17:51.787Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/lib/actions/partners/delete-reward.ts:33-41
Timestamp: 2025-08-14T05:17:51.787Z
Learning: In the partner groups system, a rewardId can only belong to one group, establishing a one-to-one relationship between rewards and groups. This means using Prisma's `update` method (rather than `updateMany`) is appropriate when updating groups by rewardId.

Applied to files:

  • apps/web/scripts/migrations/backfill-partner-groups.ts
  • apps/web/lib/rewardful/import-campaign.ts
  • apps/web/lib/rewardful/import-partners.ts
  • apps/web/lib/api/partners/create-and-enroll-partner.ts
  • apps/web/lib/actions/partners/create-program.ts
  • apps/web/lib/partnerstack/import-partners.ts
  • apps/web/lib/tolt/import-partners.ts
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.

Applied to files:

  • apps/web/scripts/migrations/backfill-partner-groups.ts
📚 Learning: 2025-08-14T05:00:23.180Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/app/api/og/program/route.tsx:63-64
Timestamp: 2025-08-14T05:00:23.180Z
Learning: In Dub's partner program system, the default partner group will always exist. When programs are created, a default partner group is automatically upserted using DEFAULT_PARTNER_GROUP constant, so accessing program.groups[0] in contexts where the default group is queried is safe.

Applied to files:

  • apps/web/lib/partnerstack/import-partners.ts
  • apps/web/lib/tolt/import-partners.ts
🧬 Code Graph Analysis (6)
apps/web/lib/rewardful/import-campaign.ts (5)
apps/web/lib/rewardful/types.ts (1)
  • RewardfulImportPayload (102-104)
apps/web/lib/zod/schemas/groups.ts (1)
  • DEFAULT_PARTNER_GROUP (9-13)
apps/web/lib/api/create-id.ts (1)
  • createId (57-66)
apps/web/ui/colors.ts (1)
  • RESOURCE_COLORS (36-38)
apps/web/lib/rewardful/importer.ts (1)
  • rewardfulImporter (45-45)
apps/web/lib/rewardful/import-partners.ts (1)
apps/web/lib/zod/schemas/groups.ts (1)
  • DEFAULT_PARTNER_GROUP (9-13)
apps/web/lib/api/partners/create-and-enroll-partner.ts (1)
apps/web/lib/api/groups/get-group-or-throw.ts (1)
  • getGroupOrThrow (4-51)
apps/web/lib/actions/partners/create-program.ts (1)
apps/web/lib/zod/schemas/rewards.ts (1)
  • REWARD_EVENT_COLUMN_MAPPING (118-122)
apps/web/lib/partnerstack/import-partners.ts (2)
apps/web/lib/zod/schemas/groups.ts (1)
  • DEFAULT_PARTNER_GROUP (9-13)
apps/web/lib/partnerstack/types.ts (1)
  • PartnerStackPartner (21-21)
apps/web/lib/tolt/import-partners.ts (2)
apps/web/lib/zod/schemas/groups.ts (1)
  • DEFAULT_PARTNER_GROUP (9-13)
apps/web/lib/tolt/types.ts (1)
  • ToltAffiliate (25-25)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (11)
apps/web/scripts/migrations/backfill-partner-groups.ts (1)

81-116: Duplication strategy for reward/discount IDs looks solid.

The counting-and-duplication logic to enforce the one-to-one relationship between rewards/discounts and groups matches the partner-groups constraint we’ve seen in prior work. Deep-cloning modifiers avoids JSON reference pitfalls.

apps/web/lib/actions/partners/create-program.ts (1)

141-144: Dynamic reward-to-group mapping is spot-on

Using REWARD_EVENT_COLUMN_MAPPING to set the correct reward column keeps group defaults consistent with the created reward.

apps/web/lib/rewardful/import-campaign.ts (3)

14-25: Default group lookup is correct and safely scoped

Filtering groups by DEFAULT_PARTNER_GROUP.slug and relying on groups[0] aligns with the invariant that a default group always exists.


42-52: Looking at the Reward model:

  • maxDuration Int? // in months (0 -> not recurring, null -> infinite)
  • amount Int with no unit comment, but downstream code treats it as:
    • cents when type=flat
    • percent (0–100) when type=percentage

In import-campaign.ts, you map:

maxDuration: max_commission_period_months,
amount:
  reward_type === "amount"
    ? commission_amount_cents
    : commission_percent,
  • max_commission_period_months is already in months → correct for maxDuration.
  • commission_amount_cents is cents → correct for flat amount.
  • commission_percent is a whole‐number percent → correct for percentage amount.

No unit mismatch detected. You can safely remove this check.


102-110: Review importCampaign’s program update guard

Unconditionally setting minPayoutAmount and holdingPeriodDays here will overwrite any custom program settings on every import. These fields propagate into email templates, UI badges, payout-processing cron jobs, API routes, schemas, and more—any unintended override could break payout thresholds or holding-period logic across the app.

Please confirm intended behavior:

  • Should importCampaign always overwrite existing values, or only initialize them when they’re still at their default (0)?
  • If initializing only once is desired, wrap the update in a conditional (e.g., check current program values before setting).

Location to adjust:

  • apps/web/lib/rewardful/import-campaign.ts, lines 104–110 (prisma.program.update call)
apps/web/lib/partnerstack/import-partners.ts (2)

24-33: Default group selection pattern looks good

Filtering by DEFAULT_PARTNER_GROUP.slug and using program.groups[0] is consistent with the invariant that the default group is always present.


62-69: Good move to centralize on group-derived enrollment attributes

Passing defaultGroupAttributes keeps enrollment consistent with group ownership. This removes event-specific branching and simplifies the flow.

apps/web/lib/rewardful/import-partners.ts (2)

36-37: Ensure defaultGroup is present

With the OR fallback above, defaultGroup will always be defined. Otherwise, this line can throw at runtime.

If you keep the current where, add an explicit check to throw a helpful error when no group is found.


111-116: Conditional propagation of groupId to next page is correct

Only forwarding groupId during partner pagination is consistent with the group-based enrollment flow.

apps/web/lib/tolt/import-partners.ts (2)

18-27: Default group selection aligns with new group-centric model

Querying by DEFAULT_PARTNER_GROUP.slug and using program.groups[0] keeps the importer consistent with group ownership.


63-79: Nice use of Promise.allSettled with result extraction

Collecting only fulfilled partners ensures downstream addPartners only processes valid ids.

@steven-tey
Copy link
Collaborator Author

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 16, 2025

✅ Actions performed

Full review triggered.

@steven-tey
Copy link
Collaborator Author

@coderabbitai full review please

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 16, 2025

@steven-tey I'll perform a full review of all the changes in this PR.

✅ 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: 3

🔭 Outside diff range comments (2)
apps/web/scripts/migrations/backfill-partner-groups.ts (1)

144-149: Harden default‐reward detection after schema change

The Reward model in packages/prisma/schema/reward.prisma no longer includes a default field, so:

  • The // @ts-ignore hides a real type error.
  • At runtime reward.default is always undefined
    hasDefaultReward is never true ⇒
    no default partner group ever gets set.

Please update the migration to:

  • Remove the // @ts-ignore and use a safe check:
    Boolean((reward as any)?.default)
  • Detect whether any reward has the flag, and if not, fall back to marking the first group per program as default.
  • Ensure you still call prisma.program.update({ data: { defaultGroupId: … } }) when isDefaultGroup is true.

Suggested diff around lines 144–149 of apps/web/scripts/migrations/backfill-partner-groups.ts:

-      const hasDefaultReward = rewards.some(
-        (reward) =>
-          // @ts-ignore (old reward schema)
-          reward.default &&
-          (reward.id === group.saleRewardId ||
-            reward.id === group.leadRewardId ||
-            reward.id === group.clickRewardId),
-      );
+      // Safely detect a default flag (if it still exists)
+      const hasDefaultReward = rewards.some((reward) =>
+        Boolean((reward as any)?.default) &&
+        (reward.id === group.saleRewardId ||
+          reward.id === group.leadRewardId ||
+          reward.id === group.clickRewardId),
+      );
+
+      // If no reward has a default flag, fall back to the first group per program
+      const defaultFlagExists = rewards.some((r) => Boolean((r as any)?.default));
+      const isDefaultGroup =
+        programIdCounts[group.programId] === 1 &&
+        (defaultFlagExists ? hasDefaultReward : true);

This ensures a default partner group is always assigned, even after dropping the Reward.default column.

apps/web/lib/actions/partners/create-program.ts (1)

211-215: Finalize logo after commit: upload to program path, update DB, then delete temp

Perform the upload and DB update after the transaction succeeds, and only then delete the temporary upload. This makes the workflow idempotent and removes the rollback/leak risk.

-      // delete the temporary uploaded logo
-      uploadedLogo &&
-        isStored(uploadedLogo) &&
-        storage.delete(uploadedLogo.replace(`${R2_URL}/`, "")),
+      // finalize the logo post-commit: upload to program path, set DB field, then delete temp
+      uploadedLogo &&
+        isStored(uploadedLogo) &&
+        (async () => {
+          const { url } = await storage.upload(
+            `programs/${program.id}/logo_${nanoid(7)}`,
+            uploadedLogo,
+          );
+          await prisma.program.update({
+            where: { id: program.id },
+            data: { logo: url },
+          });
+          await storage.delete(uploadedLogo.replace(`${R2_URL}/`, ""));
+        })(),
♻️ Duplicate comments (4)
apps/web/scripts/migrations/migrate-rewards-remainder.ts (1)

1-2: ts-nocheck hides Prisma client mismatch; add runtime guard or document ordering

This file still queries Reward.default (Line 37 via where: { default: true }). With // @ts-nocheck, TypeScript won’t warn, but Prisma will throw at runtime once the field is removed.

  • Either document that this script must run before removing Reward.default and with a client generated from the legacy schema, or
  • Add a small runtime guard that catches “Unknown arg default” and exits with a clear message.

Apply this minimal diff to avoid silencing the compiler entirely:

-// @ts-nocheck
+

Then wrap the default-reward lookup:

let programDefaultRewards;
try {
  programDefaultRewards = await prisma.reward.findMany({
    where: {
      programId: {
        in: programEnrollmentsWithNoReward.map((e) => e.programId),
      },
      // @ts-expect-error: legacy schema
      default: true,
    },
  });
} catch (err: any) {
  if (`${err?.message || err}`.includes("Unknown arg `default`")) {
    console.error(
      "This script must run before removing Reward.default and with a Prisma client generated from the legacy schema.",
    );
    process.exit(1);
  }
  throw err;
}
apps/web/lib/rewardful/import-campaign.ts (1)

72-85: Make PartnerGroup creation idempotent (slug is unique per program)

Creating with slug: rewardful-${campaignId} will throw on repeat imports due to the composite unique constraint on [programId, slug]. Upsert to avoid collisions.

-      const createdGroup = await prisma.partnerGroup.create({
-        data: {
-          id: createId({ prefix: "grp_" }),
-          programId,
-          name: `(Rewardful) ${campaign.name}`,
-          slug: `rewardful-${campaignId}`,
-          color: randomValue(RESOURCE_COLORS),
-          saleRewardId: createdReward.id,
-        },
-      });
-      groupId = createdGroup.id;
+      const slug = `rewardful-${campaignId}`;
+      const group = await prisma.partnerGroup.upsert({
+        where: { programId_slug: { programId, slug } },
+        create: {
+          id: createId({ prefix: "grp_" }),
+          programId,
+          name: `(Rewardful) ${campaign.name}`,
+          slug,
+          color: randomValue(RESOURCE_COLORS),
+          saleRewardId: createdReward.id,
+        },
+        update: {
+          saleRewardId: createdReward.id,
+        },
+      });
+      groupId = group.id;
apps/web/lib/api/programs/get-program-or-throw.ts (1)

23-29: Include landerData when requested; current query shape can’t satisfy ProgramWithLanderDataSchema

When includeLanderData is true, the findUnique call doesn’t include the related landerData, so parsing with ProgramWithLanderDataSchema will likely fail or drop fields. Add a conditional include.

Apply:

-  const program = (await prisma.program.findUnique({
-    where: {
-      id: programId,
-      workspaceId,
-    },
-  })) as ProgramProps | null;
+  const program = await prisma.program.findUnique({
+    where: {
+      id: programId,
+      workspaceId,
+    },
+    include: includeLanderData
+      ? {
+          // pull only what ProgramWithLanderDataSchema actually expects
+          // refine these relations if the schema requires nested fields
+          landerData: true,
+        }
+      : undefined,
+  });

Optional type-safety improvement: instead of casting to ProgramProps, use Prisma’s inferred payload types (e.g., Prisma.ProgramGetPayload) so the compiler helps keep the parse schema and query in sync.

Run this to confirm exactly which relations ProgramWithLanderDataSchema requires:

#!/bin/bash
# Inspect ProgramWithLanderDataSchema and lander schema usage
rg -n -C4 'export\s+const\s+ProgramWithLanderDataSchema' apps/web/lib/zod/schemas
rg -n -C4 'programLanderSchema|landerData' apps/web/lib/zod/schemas
apps/web/lib/rewardful/import-partners.ts (1)

26-33: Bug: invalid groupId yields empty groups[] and runtime crash; add OR fallback to default group

As written, an invalid groupId filters out all groups, making program.groups[0] undefined and crashing when accessed later. Prefer an OR filter that falls back to the default group when the provided id is invalid.

Apply:

-    include: {
-      groups: {
-        // if groupId is provided, use it, otherwise use the default group
-        where: {
-          ...(groupId ? { id: groupId } : { slug: DEFAULT_PARTNER_GROUP.slug }),
-        },
-      },
-    },
+    include: {
+      groups: {
+        // prefer provided groupId; gracefully fallback to the default group
+        where: {
+          OR: groupId
+            ? [{ id: groupId }, { slug: DEFAULT_PARTNER_GROUP.slug }]
+            : [{ slug: DEFAULT_PARTNER_GROUP.slug }],
+        },
+      },
+    },
🧹 Nitpick comments (13)
apps/web/scripts/migrations/migrate-program-invites.ts (1)

10-11: Verify mixed alias vs relative imports for script runtime

You’re mixing "@/lib/…" path aliases with "../../lib/…" relative imports in the same script. Depending on how this script is executed (ts-node/tsx without tsconfig-paths), one of these resolution strategies might fail.

  • If scripts are run with tsconfig paths enabled, prefer consistent "@/lib/…" aliases everywhere.
  • If scripts are executed without path alias support, prefer consistently relative paths.

Please confirm the intended runtime and standardize accordingly to avoid brittle module resolution.

apps/web/lib/rewardful/import-campaign.ts (2)

56-61: Minor: redundant event filter

event: EventType.sale is already in rewardProps, so adding it again in where is redundant. Safe to drop for clarity.

-  const existingReward = await prisma.reward.findFirst({
-    where: { ...rewardProps, event: EventType.sale },
+  const existingReward = await prisma.reward.findFirst({
+    where: { ...rewardProps },

99-100: Avoid non-null assertion on possibly missing relation

existingReward.salePartnerGroup?.id! asserts presence at compile time but can still be undefined at runtime. Either ensure the relation exists or handle the absence by falling back to the default group/new group creation.

-    groupId = existingReward.salePartnerGroup?.id!;
+    groupId = existingReward.salePartnerGroup?.id ?? null;
+    // Optionally: if null, attach this reward to the default group or create a group.
apps/web/lib/api/programs/get-program-or-throw.ts (1)

37-40: Parsing toggle is fine; ensure the fetched shape matches the chosen schema

The conditional parse based on includeLanderData is clean. It will be correct once the query includes the necessary relations when includeLanderData is true.

If you adopt the Prisma payload typing suggested above, you can drop the explicit ProgramProps cast and let the schema guard the runtime shape.

apps/web/lib/api/partners/create-and-enroll-partner.ts (1)

85-89: Add an explicit guard when neither groupId nor program.defaultGroupId is present

getGroupOrThrow currently relies on groupId || program.defaultGroupId!, which can be undefined at runtime in edge cases (legacy/migrated programs). Throw a clearer error before calling getGroupOrThrow, and avoid the non-null assertion.

Apply:

-  const group = await getGroupOrThrow({
-    programId: program.id,
-    groupId: groupId || program.defaultGroupId!,
-    includeRewardsAndDiscount: true,
-  });
+  if (!groupId && !program.defaultGroupId) {
+    throw new DubApiError({
+      code: "bad_request",
+      message:
+        "No groupId provided and the program has no defaultGroupId configured.",
+    });
+  }
+  const resolvedGroupId = (groupId ?? program.defaultGroupId)!;
+  const group = await getGroupOrThrow({
+    programId: program.id,
+    groupId: resolvedGroupId,
+    includeRewardsAndDiscount: true,
+  });

If all programs are guaranteed to have a default group after migrations, confirm that the invariant holds for all tenants and environments (incl. imports). Otherwise, this guard will save an opaque “not found” error.

apps/web/lib/rewardful/import-partners.ts (1)

36-37: Defensive check: ensure a group was returned before accessing its fields

Even with the OR fallback, add a small guard to clarify errors if no default group exists (e.g., misconfigured program).

-  const defaultGroup = program.groups[0];
+  const defaultGroup = program.groups[0];
+  if (!defaultGroup) {
+    throw new Error(
+      "Default partner group not found. Ensure the default group exists or pass a valid groupId.",
+    );
+  }
apps/web/lib/partnerstack/import-partners.ts (1)

32-33: Optional: guard against missing default group

If a program somehow lacks the default group (migration gap), this will be undefined and fail later. A small guard improves debuggability.

-  const defaultGroup = program.groups[0];
+  const defaultGroup = program.groups[0];
+  if (!defaultGroup) {
+    throw new Error(
+      `Default partner group not found for program ${program.id}.`,
+    );
+  }
apps/web/lib/actions/partners/create-program.ts (3)

84-86: Generate IDs outside the transaction for retry idempotency

If Prisma retries the interactive transaction, generating new IDs on each attempt can lead to unexpected conflicts or duplicated side effects. Prefer generating programId/defaultGroupId before entering $transaction and reusing them inside.

-    const programId = createId({ prefix: "prog_" });
-    const defaultGroupId = createId({ prefix: "grp_" });
+    // programId and defaultGroupId are generated outside $transaction for idempotency

Outside the transaction (just before calling prisma.$transaction), add:

const programId = createId({ prefix: "prog_" });
const defaultGroupId = createId({ prefix: "grp_" });

141-144: Guard the computed reward column to avoid sending an “undefined” field to Prisma

If the mapping is ever extended or event is unexpected, [REWARD_EVENT_COLUMN_MAPPING[createdReward.event]] could evaluate to undefined, yielding an object with an “undefined” key that Prisma will reject. Add a second guard for the mapping’s existence.

-        ...(createdReward && {
-          [REWARD_EVENT_COLUMN_MAPPING[createdReward.event]]: createdReward.id,
-        }),
+        ...(createdReward &&
+          REWARD_EVENT_COLUMN_MAPPING[createdReward.event] && {
+            [REWARD_EVENT_COLUMN_MAPPING[createdReward.event]]: createdReward.id,
+          }),

217-224: Verify ProgramWelcome template behavior when logo is set post-commit

With the logo now finalized after commit, program.logo may be null at email-send time. Confirm ProgramWelcome tolerates a missing logo or use a fallback for this email render.

Optionally pass a fallback just for the email:

react: ProgramWelcome({
  email: user.email!,
  workspace,
  program: {
    ...program,
    // Prefer the stored URL, but fall back to the temporary uploadedLogo if present
    logo: program.logo ?? (isStored(uploadedLogo) ? uploadedLogo : null),
  },
}),
apps/web/lib/tolt/import-partners.ts (3)

18-27: Add a defensive check when resolving the default group

You filter by slug and then read program.groups[0]. Based on past learnings, the default group should always exist, but adding a small guard prevents a hard crash if assumptions change or data is corrupted.

-  const defaultGroup = program.groups[0];
+  const defaultGroup = program.groups[0];
+  if (!defaultGroup) {
+    throw new Error(`Default partner group not found for program ${program.id}`);
+  }

80-85: Naming nit: clarify settled results vs. promise

partnersPromise holds settled results, not a promise. Renaming improves readability.

-      const partnersPromise = await Promise.allSettled(
+      const partnerResults = await Promise.allSettled(
         activeAffiliates.map((affiliate) =>
           createPartner({
             program,
             affiliate,
             defaultGroupAttributes: {
               groupId: defaultGroup.id,
               saleRewardId: defaultGroup.saleRewardId,
               leadRewardId: defaultGroup.leadRewardId,
               clickRewardId: defaultGroup.clickRewardId,
               discountId: defaultGroup.discountId,
             },
           }),
         ),
       );
 
-      const partners = partnersPromise
+      const partners = partnerResults
         .filter(
           (p): p is PromiseFulfilledResult<Partner> => p.status === "fulfilled",
         )
         .map((p) => p.value);

151-167: Consider updating group attributes on existing enrollments

On upsert, you only update status. If the default group’s reward IDs changed since a prior import, existing enrollments won’t reflect the new defaults. If that’s acceptable, ignore; otherwise, spread ...defaultGroupAttributes into the update payload.

     update: {
       status: "approved",
+      ...defaultGroupAttributes,
     },
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between bc3ac82 and 4b3f2c3.

📒 Files selected for processing (17)
  • apps/web/app/(ee)/api/programs/[programId]/discounts/route.ts (0 hunks)
  • apps/web/app/(ee)/api/programs/[programId]/rewards/route.ts (0 hunks)
  • apps/web/app/(ee)/api/programs/[programId]/route.ts (0 hunks)
  • apps/web/lib/actions/partners/create-program.ts (5 hunks)
  • apps/web/lib/actions/partners/invite-partner.ts (1 hunks)
  • apps/web/lib/api/partners/create-and-enroll-partner.ts (4 hunks)
  • apps/web/lib/api/programs/get-program-or-throw.ts (1 hunks)
  • apps/web/lib/partnerstack/import-partners.ts (5 hunks)
  • apps/web/lib/rewardful/import-campaign.ts (3 hunks)
  • apps/web/lib/rewardful/import-partners.ts (6 hunks)
  • apps/web/lib/rewardful/schemas.ts (1 hunks)
  • apps/web/lib/tolt/import-partners.ts (5 hunks)
  • apps/web/scripts/migrations/backfill-partner-groups.ts (1 hunks)
  • apps/web/scripts/migrations/migrate-program-invites.ts (1 hunks)
  • apps/web/scripts/migrations/migrate-rewards-remainder.ts (1 hunks)
  • packages/prisma/schema/discount.prisma (0 hunks)
  • packages/prisma/schema/reward.prisma (0 hunks)
💤 Files with no reviewable changes (5)
  • packages/prisma/schema/discount.prisma
  • apps/web/app/(ee)/api/programs/[programId]/discounts/route.ts
  • apps/web/app/(ee)/api/programs/[programId]/route.ts
  • apps/web/app/(ee)/api/programs/[programId]/rewards/route.ts
  • packages/prisma/schema/reward.prisma
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/lib/actions/partners/delete-reward.ts:33-41
Timestamp: 2025-08-14T05:17:51.787Z
Learning: In the partner groups system, a rewardId can only belong to one group, establishing a one-to-one relationship between rewards and groups. This means using Prisma's `update` method (rather than `updateMany`) is appropriate when updating groups by rewardId.
📚 Learning: 2025-08-14T05:17:51.787Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/lib/actions/partners/delete-reward.ts:33-41
Timestamp: 2025-08-14T05:17:51.787Z
Learning: In the partner groups system, a rewardId can only belong to one group, establishing a one-to-one relationship between rewards and groups. This means using Prisma's `update` method (rather than `updateMany`) is appropriate when updating groups by rewardId.

Applied to files:

  • apps/web/scripts/migrations/backfill-partner-groups.ts
  • apps/web/lib/rewardful/import-partners.ts
  • apps/web/lib/rewardful/import-campaign.ts
  • apps/web/lib/api/partners/create-and-enroll-partner.ts
  • apps/web/lib/partnerstack/import-partners.ts
  • apps/web/lib/tolt/import-partners.ts
  • apps/web/lib/actions/partners/create-program.ts
📚 Learning: 2025-08-14T05:00:23.180Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/app/api/og/program/route.tsx:63-64
Timestamp: 2025-08-14T05:00:23.180Z
Learning: In Dub's partner program system, the default partner group will always exist. When programs are created, a default partner group is automatically upserted using DEFAULT_PARTNER_GROUP constant, so accessing program.groups[0] in contexts where the default group is queried is safe.

Applied to files:

  • apps/web/lib/partnerstack/import-partners.ts
  • apps/web/lib/tolt/import-partners.ts
🧬 Code Graph Analysis (6)
apps/web/lib/rewardful/import-partners.ts (2)
apps/web/lib/zod/schemas/groups.ts (1)
  • DEFAULT_PARTNER_GROUP (9-13)
apps/web/lib/rewardful/types.ts (1)
  • RewardfulAffiliate (42-55)
apps/web/lib/rewardful/import-campaign.ts (5)
apps/web/lib/rewardful/types.ts (1)
  • RewardfulImportPayload (102-104)
apps/web/lib/zod/schemas/groups.ts (1)
  • DEFAULT_PARTNER_GROUP (9-13)
apps/web/lib/api/create-id.ts (1)
  • createId (57-66)
apps/web/ui/colors.ts (1)
  • RESOURCE_COLORS (36-38)
apps/web/lib/rewardful/importer.ts (1)
  • rewardfulImporter (45-45)
apps/web/lib/api/partners/create-and-enroll-partner.ts (1)
apps/web/lib/api/groups/get-group-or-throw.ts (1)
  • getGroupOrThrow (4-51)
apps/web/lib/partnerstack/import-partners.ts (2)
apps/web/lib/zod/schemas/groups.ts (1)
  • DEFAULT_PARTNER_GROUP (9-13)
apps/web/lib/partnerstack/types.ts (1)
  • PartnerStackPartner (21-21)
apps/web/lib/tolt/import-partners.ts (1)
apps/web/lib/zod/schemas/groups.ts (1)
  • DEFAULT_PARTNER_GROUP (9-13)
apps/web/lib/actions/partners/create-program.ts (1)
apps/web/lib/zod/schemas/rewards.ts (1)
  • REWARD_EVENT_COLUMN_MAPPING (118-122)
🔇 Additional comments (8)
apps/web/lib/rewardful/schemas.ts (1)

14-14: Ignore the suggestion—rewardfulImportPayloadSchema already uses groupId and no longer references rewardId.
All rewardId hits from your search belong to the Reward entity (UI, migrations, Zod schemas, API actions) and are unrelated to the Rewardful import payload. The Rewardful importer’s schema, types, route, and queue calls consistently use groupId (and never rewardId), so no backward-compatible alias is required.

Likely an incorrect or invalid review comment.

apps/web/lib/rewardful/import-campaign.ts (2)

102-111: LGTM: program payout and holding period synced from campaign

Updating minPayoutAmount and holdingPeriodDays unconditionally here keeps program-level settings aligned with Rewardful. No issues spotted.


14-25: Guard against missing default group (groups[0] may be undefined)

You dereference groups[0] later; if a program somehow lacks a default group, this will throw. Add a guard and, optionally, create the default group on-the-fly.

You can also narrow the included fields and fetch just one group:

-  const { workspaceId, groups } = await prisma.program.findUniqueOrThrow({
+  const { workspaceId, groups } = await prisma.program.findUniqueOrThrow({
     where: {
       id: programId,
     },
     include: {
       groups: {
         where: {
           slug: DEFAULT_PARTNER_GROUP.slug,
         },
+        select: { id: true, saleRewardId: true },
+        take: 1,
       },
     },
   });

Follow-up (outside this hunk):

const defaultGroup = groups[0];
if (!defaultGroup) {
  // Either throw with a clear message or create the default group here.
  throw new Error(`Default partner group missing for program ${programId}`);
}
⛔ Skipped due to learnings
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/app/api/og/program/route.tsx:63-64
Timestamp: 2025-08-14T05:00:23.180Z
Learning: In Dub's partner program system, the default partner group will always exist. When programs are created, a default partner group is automatically upserted using DEFAULT_PARTNER_GROUP constant, so accessing program.groups[0] in contexts where the default group is queried is safe.
apps/web/lib/actions/partners/invite-partner.ts (1)

94-95: Pass-through groupId is good; fallback now centralized downstream

Forwarding groupId as-provided (possibly undefined) keeps this action thin and delegates fallback to createAndEnrollPartner. You already guard earlier (Lines 80–82) to ensure at least one of groupId or program.defaultGroupId exists.

apps/web/lib/api/partners/create-and-enroll-partner.ts (1)

102-107: Group-centric enrollment payload looks correct

Using groupId plus reward/discount IDs sourced from the group aligns with the new model and eliminates the old per-event mapping complexity.

apps/web/lib/rewardful/import-partners.ts (1)

82-89: Propagation of group-derived attributes is aligned with the new model

Passing groupId and reward/discount IDs via defaultGroupAttributes keeps enrollment consistent and simplifies downstream logic.

apps/web/lib/partnerstack/import-partners.ts (2)

24-29: Good: default group filtered at the query

Fetching only the default group and deriving attributes from it matches the new group-centric approach.


62-69: Propagation of group-derived attributes is consistent

defaultGroupAttributes structure and its downstream use in createPartner are consistent with the group-based enrollment flow.

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

🔭 Outside diff range comments (6)
apps/web/scripts/migrations/migrate-rewards-remainder.ts (1)

30-39: Prisma query filters on removed Reward.default will throw; add a runtime guard and narrow the TS ignore

Without a guard, Prisma will error with “Unknown arg default in where.default” when the generated client no longer has that field. Fail fast with a clear message and scope the TS ignore narrowly:

-  const programDefaultRewards = await prisma.reward.findMany({
-    where: {
-      programId: {
-        in: programEnrollmentsWithNoReward.map(
-          (enrollment) => enrollment.programId,
-        ),
-      },
-      default: true,
-    },
-  });
+  let programDefaultRewards;
+  try {
+    programDefaultRewards = await prisma.reward.findMany({
+      where: {
+        programId: {
+          in: programEnrollmentsWithNoReward.map(
+            (enrollment) => enrollment.programId,
+          ),
+        },
+        // @ts-expect-error - legacy schema only: Reward.default was dropped in newer versions
+        default: true,
+      },
+    });
+  } catch (err) {
+    const msg = String((err && (err as any).message) || err);
+    if (msg.includes("Unknown arg `default`") || msg.includes("Unknown argument `default`")) {
+      console.error(
+        "migrate-rewards-remainder.ts: Prisma client does not support Reward.default.\n" +
+          "Run this script BEFORE removing Reward.default and with a client generated from the legacy schema.",
+      );
+      process.exit(1);
+    }
+    throw err;
+  }
apps/web/scripts/migrations/backfill-partner-groups.ts (5)

28-33: Default group detection depends on iteration order; many programs may miss defaultGroupId

isDefaultGroup is gated by programIdCounts[programId] === 1. Because groupBy(...).orderBy({ _count: { programId: "desc" } }) doesn’t guarantee the default group appears first within each program, a program may never have its default assigned even if a default reward exists.

  • Track whether a default was assigned per program (map), not position.
  • Set default when encountering the first group that contains a legacy-default reward, regardless of iteration order.

See diffs on Lines 70-76 (add map), 156-158 (change the condition), and 188-196 (set the flag when scheduling the update).

Also applies to: 151-158


188-196: Possible FK violation: updating Program.defaultGroupId before PartnerGroup rows exist

prisma.program.update({ defaultGroupId: finalGroupId }) runs before partnerGroup.createMany. If there’s a foreign key from Program.defaultGroupId to PartnerGroup.id, this will fail. Also, doing program updates inside the Promise.all loop adds unnecessary DB churn.

Defer these updates until after groups are created. Collect them during the map, then apply in a batch:

-      if (isDefaultGroup) {
-        await prisma.program.update({
-          where: { id: group.programId },
-          data: { defaultGroupId: finalGroupId },
-        });
-        console.log(
-          `Updated program ${group.programId} with default group ${finalGroupId}`,
-        );
-      }
+      if (isDefaultGroup) {
+        programDefaultGroupUpdates.push({
+          programId: group.programId,
+          defaultGroupId: finalGroupId,
+        });
+        programDefaultAssigned[group.programId] = true;
+      }

And right after creating partner groups, apply the program updates:

   console.log(`Created ${groupsRes.count} partner groups`);
 
+  // Apply default group updates after groups are created to avoid FK violations
+  if (programDefaultGroupUpdates.length) {
+    await prisma.$transaction(
+      programDefaultGroupUpdates.map(({ programId, defaultGroupId }) =>
+        prisma.program.update({
+          where: { id: programId },
+          data: { defaultGroupId },
+        }),
+      ),
+    );
+    console.log(
+      `Updated default group for ${programDefaultGroupUpdates.length} programs`,
+    );
+  }
+
   for (const pe of programEnrollmentsToUpdate) {

Also applies to: 250-256


70-76: Track default assignment per program and defer updates for batch execution

Introduce a per-program flag to avoid order-dependence and an array to batch program updates after group creation.

   const programEnrollmentsToUpdate: {
     ids: string[];
     data: Prisma.ProgramEnrollmentUpdateManyArgs["data"];
   }[] = [];
 
+  // Track default-group assignment and defer program updates until groups are created
+  const programDefaultGroupUpdates: { programId: string; defaultGroupId: string }[] = [];
+  const programDefaultAssigned: Record<string, boolean> = {};

156-158: Make default-group assignment independent of iteration order

Replace the “first group per program” gate with a per-program flag.

-      const isDefaultGroup =
-        hasDefaultReward && programIdCounts[group.programId] === 1;
+      const isDefaultGroup =
+        hasDefaultReward && !programDefaultAssigned[group.programId];

238-241: Verify unique constraints on createMany operations

Our schema inspection shows:

  • Reward model (packages/prisma/schema/reward.prisma)
    • Only @id on id and two @@index definitions—no non-ID unique constraints.
    reward.createMany({ skipDuplicates: true }) will only skip on id conflicts (which you’re generating anew), so it’s safe.

  • Discount model (packages/prisma/schema/discount.prisma)
    • Only @id on id and one @@index on programId—no non-ID unique constraints.
    discount.createMany({ skipDuplicates: true }) is safe.

  • PartnerGroup model (packages/prisma/schema/partner.prisma)
    • Non-ID unique fields within the model block:
    partnerUserId String @unique
    clickRewardId String? @unique
    leadRewardId String? @unique
    saleRewardId String? @unique
    discountId String? @unique
    @@unique([programId, slug])
    → Any duplicate slug or reward/disc-ID fields will cause the insert to be skipped. Subsequent FK-based updates targeting those “new” IDs would then fail.

Action items:

  • For PartnerGroup backfill (lines 244-248, 250-256 in apps/web/scripts/migrations/backfill-partner-groups.ts):
    • Ensure generated slug (and any reward/disc-ID fields) are unique—e.g., suffix or normalize—before createMany({ skipDuplicates: true }).
    • Or switch to per-row upserts/updates for PartnerGroup to guarantee each record exists (avoiding silent skips).
♻️ Duplicate comments (7)
apps/web/scripts/migrations/migrate-rewards-remainder.ts (1)

1-2: File-wide ts-nocheck hides a breaking Prisma schema mismatch; scope the ignore and add a runtime guard or clearly document ordering

Given this PR removes Reward.default across the schema, the query below using where: { default: true } will throw at runtime even though // @ts-nocheck silences TS. Either:

  • Keep this as a legacy-only script and document the ordering at the top, and add a runtime guard, or
  • Remove the file-wide no-check, add a targeted // @ts-expect-error on the default filter, and add the same runtime guard.

Minimal tweak to make the intent explicit without changing behavior:

-// @ts-nocheck
-
+// @ts-nocheck
+// NOTE: This script expects a Prisma client generated from the legacy schema where Reward.default exists.
+// It will fail fast with a clear message if run against the new schema (see guard around the `default` filter).

To verify what Prisma schema is present in this branch and whether Reward.default still exists, run:

#!/bin/bash
set -euo pipefail

echo "==> Reward model in Prisma schema (context):"
rg -nP -C2 'model\s+Reward\b' packages/prisma || true

echo
echo "==> Occurrences of 'default' in Prisma schema directory (sanity check):"
rg -nP -C2 '\bdefault\b' packages/prisma || true
apps/web/lib/rewardful/import-campaign.ts (2)

72-97: Guard against empty groups result and use defaultGroupId fallback

Dereferencing groups[0] without a presence check can throw when there’s no “default” slug match. Use a fallback to the defaultGroupId and handle both cases.

-    // if the default group has an associated sale reward already, we need to create a new group
-    if (groups[0].saleRewardId) {
+    // Resolve the default group via slug match first, then fall back to defaultGroupId
+    const defaultGroup =
+      groups[0] ??
+      (defaultGroupId
+        ? await prisma.partnerGroup.findUnique({ where: { id: defaultGroupId } })
+        : null);
+
+    if (!defaultGroup) {
+      throw new Error("No default partner group found for this program.");
+    }
+
+    // if the default group has an associated sale reward already, we need to create a new group
+    if (defaultGroup.saleRewardId) {
       const createdGroup = await prisma.partnerGroup.create({
         data: {
           id: createId({ prefix: "grp_" }),
           programId,
           name: `(Rewardful) ${campaign.name}`,
           slug: `rewardful-${campaignId}`,
           color: randomValue(RESOURCE_COLORS),
           saleRewardId: createdReward.id,
         },
       });
       groupId = createdGroup.id;
 
       // else we just update the existing group with the newly created sale reward
     } else {
       const updatedGroup = await prisma.partnerGroup.update({
         where: {
-          id: groups[0].id,
+          id: defaultGroup.id,
         },
         data: {
           saleRewardId: createdReward.id,
         },
       });
       groupId = updatedGroup.id;
     }

74-84: Make group creation idempotent with upsert to avoid slug collisions

Creating a group with slug rewardful-${campaignId} can fail on re-runs due to the unique [programId, slug] constraint. Use upsert.

-      const createdGroup = await prisma.partnerGroup.create({
-        data: {
-          id: createId({ prefix: "grp_" }),
-          programId,
-          name: `(Rewardful) ${campaign.name}`,
-          slug: `rewardful-${campaignId}`,
-          color: randomValue(RESOURCE_COLORS),
-          saleRewardId: createdReward.id,
-        },
-      });
-      groupId = createdGroup.id;
+      const slug = `rewardful-${campaignId}`;
+      const group = await prisma.partnerGroup.upsert({
+        where: { programId_slug: { programId, slug } },
+        create: {
+          id: createId({ prefix: "grp_" }),
+          programId,
+          name: `(Rewardful) ${campaign.name}`,
+          slug,
+          color: randomValue(RESOURCE_COLORS),
+          saleRewardId: createdReward.id,
+        },
+        update: {
+          saleRewardId: createdReward.id,
+        },
+      });
+      groupId = group.id;
apps/web/lib/api/programs/get-program-or-throw.ts (1)

23-40: Include landerData when includeLanderData is true to satisfy schema parsing

Currently, you always fetch only base program fields. If ProgramWithLanderDataSchema expects related payloads, parse() may fail. Conditionally include landerData (and nested relations as required by the schema).

   const program = (await prisma.program.findUnique({
     where: {
       id: programId,
       workspaceId,
     },
-  })) as ProgramProps | null;
+    include: includeLanderData
+      ? {
+          landerData: {
+            include: {
+              // Adjust these to match what ProgramWithLanderDataSchema parses
+              blocks: true,
+              rewards: true,
+              // add more nested relations if needed by your schema
+            },
+          },
+        }
+      : undefined,
+  })) as ProgramProps | null;

If the schema expects a different shape, align the include accordingly.

apps/web/lib/rewardful/import-partners.ts (1)

26-33: Guard against invalid groupId: add OR fallback to default group and assert existence

If an invalid/nonexistent groupId is provided, the current where returns zero groups, making program.groups[0] undefined and causing downstream crashes when accessing defaultGroup.*.

  • Use an OR fallback to DEFAULT_PARTNER_GROUP.slug so a valid group is always returned.
  • Add a defensive check after selection.

Apply:

-    include: {
-      groups: {
-        // if groupId is provided, use it, otherwise use the default group
-        where: {
-          ...(groupId ? { id: groupId } : { slug: DEFAULT_PARTNER_GROUP.slug }),
-        },
-      },
-    },
+    include: {
+      groups: {
+        // prefer provided groupId; gracefully fallback to the default group if missing/invalid
+        where: {
+          OR: groupId
+            ? [{ id: groupId }, { slug: DEFAULT_PARTNER_GROUP.slug }]
+            : [{ slug: DEFAULT_PARTNER_GROUP.slug }],
+        },
+      },
+    },

And just after selecting the group:

-  const defaultGroup = program.groups[0];
+  const defaultGroup = program.groups[0];
+  if (!defaultGroup) {
+    throw new Error(`Default partner group not found for program ${program.id}`);
+  }

Also applies to: 36-37

apps/web/lib/actions/partners/create-program.ts (2)

87-92: Move storage.upload out of the Prisma $transaction

Uploading inside an interactive transaction risks orphaned blobs on rollback and duplicate uploads on retry. Perform the upload after the transaction commits, then update program.logo.

Apply in-transaction removal:

-    const logoUrl = uploadedLogo
-      ? await storage
-          .upload(`programs/${programId}/logo_${nanoid(7)}`, uploadedLogo)
-          .then(({ url }) => url)
-      : null;

Then, outside the transaction (example):

// after the transaction returns `program`
if (uploadedLogo) {
  const { url } = await storage.upload(
    `programs/${program.id}/logo_${nanoid(7)}`,
    uploadedLogo,
  );
  const updated = await prisma.program.update({
    where: { id: program.id },
    data: { logo: url },
  });

  // ensure subsequent emails/audit logs use the updated program with logo
  program = updated;
}

99-100: Don’t set logo in program.create; assign post-commit

Once the upload is moved out of the transaction, drop this inline assignment to avoid referencing a non-existent logoUrl variable and to keep DB writes transactional.

-        ...(logoUrl && { logo: logoUrl }),
+        // logo is set post-transaction to avoid orphaned uploads on rollback
🧹 Nitpick comments (22)
apps/web/scripts/migrations/migrate-rewards-remainder.ts (2)

76-87: Avoid unbounded Promise.all on large datasets; consider batching or a concurrency limiter

If updatedEnrollments is large, this will fan out an unbounded number of updates and can overload the DB. Two safer options:

  • Sequential (simple and safe):
-  await Promise.all(
-    updatedEnrollments.map((enrollment) =>
-      prisma.programEnrollment.update({
-        where: { id: enrollment.id },
-        data: {
-          clickRewardId: enrollment.clickRewardId,
-          leadRewardId: enrollment.leadRewardId,
-          saleRewardId: enrollment.saleRewardId,
-        },
-      }),
-    ),
-  );
+  for (const enrollment of updatedEnrollments) {
+    await prisma.programEnrollment.update({
+      where: { id: enrollment.id },
+      data: {
+        clickRewardId: enrollment.clickRewardId,
+        leadRewardId: enrollment.leadRewardId,
+        saleRewardId: enrollment.saleRewardId,
+      },
+    });
+  }
  • Or keep concurrency but cap it using p-limit (optional). I can provide a ready-to-drop snippet if you prefer this route.

90-91: Propagate failures and exit non‑zero on error

Wrap main() to ensure the script exits with code 1 on failure and logs the error:

-main();
+main().catch((err) => {
+  console.error(err);
+  process.exit(1);
+});
apps/web/lib/rewardful/import-campaign.ts (2)

56-61: Redundant event filter in findFirst

event is already included in rewardProps as EventType.sale. Minor nit: drop the duplicate condition.

-  const existingReward = await prisma.reward.findFirst({
-    where: { ...rewardProps, event: EventType.sale },
+  const existingReward = await prisma.reward.findFirst({
+    where: { ...rewardProps },
     include: {
       salePartnerGroup: true, // rewardful only supports sale rewards
     },
   });

63-85: Consider wrapping reward+group mutations in a transaction

Creating the reward, then updating/creating a group, and then updating the program are logically coupled. A failure mid-way could leave the reward orphaned. Optional, but a transaction would keep state consistent.

I can provide a transaction-wrapped version if you want to adopt it.

apps/web/lib/rewardful/import-partners.ts (2)

75-92: Don’t let a single partner failure abort the batch; use Promise.allSettled

Using Promise.all will reject on the first failure and stop remaining imports/logging. allSettled keeps the batch resilient and consistent with PartnerStack/Tolt importers.

-      await Promise.all(
+      await Promise.allSettled(
         activeAffiliates.map((affiliate) =>
           createPartnerAndLinks({
             program,
             affiliate,
             userId,
             defaultGroupAttributes: {
               groupId: defaultGroup.id,
               saleRewardId: defaultGroup.saleRewardId,
               leadRewardId: defaultGroup.leadRewardId,
               clickRewardId: defaultGroup.clickRewardId,
               discountId: defaultGroup.discountId,
             },
           }),
         ),
       );

149-168: Confirm intended behavior on upsert update: group attributes aren’t updated

On update, only status is changed. If an enrollment already exists with different group/reward/discount IDs, this code will not move the partner into the selected group nor refresh reward IDs.

  • If the goal is to avoid reassigning existing partners, this is fine.
  • If you intend to align existing enrollments to the chosen default group, you’ll need an update path (or a separate migration/repair job).

Would you like me to draft a safe update strategy that updates only when groupId is null to avoid clobbering manual assignments?

apps/web/lib/partnerstack/import-partners.ts (2)

112-118: Nit: message says “leads” but condition checks customers

The condition checks partner.stats.CUSTOMER_COUNT, but the message says “No leads found…”. Align the message to avoid confusion.

-      message: `No leads found for partner ${partner.email}`,
+      message: `No customers found for partner ${partner.email}`,

149-165: Confirm upsert semantics: update path does not set group/reward fields

Same consideration as other importers: update only changes status. If that’s intentional (don’t reassign existing enrollments), good; if not, consider updating missing group/reward fields.

I can propose a guarded update that sets groupId only when null, if desired.

apps/web/lib/tolt/import-partners.ts (1)

151-167: Verify intended behavior: enrollment update path only sets status

As with Rewardful/PartnerStack, the update path doesn’t propagate defaultGroupAttributes. If that’s by design to preserve existing group assignments, no action needed; otherwise, consider an update strategy.

Happy to propose a guarded update to fill missing group assignments without overwriting explicit ones.

apps/web/lib/actions/partners/create-program.ts (2)

126-144: LGTM: dynamic reward→group mapping on default group creation

Using REWARD_EVENT_COLUMN_MAPPING with a computed property to seed the default group with the first created reward is correct. Consider mirroring this in the upsert update branch for completeness if the row exists (edge case).


217-224: If logo is set post-commit, ensure emails use the updated program

ProgramWelcome currently uses the program object captured before post-commit updates. If you move the upload out of the transaction, pass the updated program (with logo) to ensure the email renders the final logo.

Do you want me to include a minimal refactor that switches program to a mutable let and updates it before waitUntil?

apps/web/scripts/migrations/backfill-partner-groups.ts (6)

142-149: Replace @ts-ignore with a typed, precomputed legacy check and remove the O(n·m) scan

Accessing reward.default via @ts-ignore is brittle. Prefer computing a Set of legacy default reward IDs once and use it here. This removes the type hole and cuts per-group scans.

Apply this diff to rewrite the check:

-  const hasDefaultReward = rewards.some(
-    (reward) =>
-      // @ts-ignore (old reward schema)
-      reward.default &&
-      (reward.id === group.saleRewardId ||
-        reward.id === group.leadRewardId ||
-        reward.id === group.clickRewardId),
-  );
+  const hasDefaultReward =
+    (group.saleRewardId && defaultRewardIds.has(group.saleRewardId)) ||
+    (group.leadRewardId && defaultRewardIds.has(group.leadRewardId)) ||
+    (group.clickRewardId && defaultRewardIds.has(group.clickRewardId));

Add this helper right after fetching rewards (see suggested diff on Lines 38-45):

type LegacyReward = (typeof rewards)[number] & { default?: boolean };
const defaultRewardIds = new Set(
  (rewards as LegacyReward[]).filter((r) => r.default).map((r) => r.id),
);

38-45: Precompute legacy default reward IDs to support old schema without ts-ignore

Build a Set once to detect legacy default rewards. This avoids type suppression and improves performance.

   const rewards = await prisma.reward.findMany({
     where: {
       programId: {
         in: groups.map((group) => group.programId),
       },
     },
   });
+  // Build a Set of legacy default reward IDs (supports old reward schema safely)
+  type LegacyReward = (typeof rewards)[number] & { default?: boolean };
+  const defaultRewardIds = new Set(
+    (rewards as LegacyReward[]).filter((r) => r.default).map((r) => r.id),
+  );

77-80: Add types to id counters for clarity and safety

Explicit types help avoid accidental string/number coercion and improve readability.

-  const rewardIdCounts = {};
-  const discountIdCounts = {};
-  const programIdCounts = {};
+  const rewardIdCounts: Record<string, number> = {};
+  const discountIdCounts: Record<string, number> = {};
+  const programIdCounts: Record<string, number> = {};

103-105: Avoid mutating Prisma result objects with ad-hoc keys

Writing group["updated_*"] onto a typed Prisma group object is unsafe and obscures data flow.

Prefer local state:

// at the top of the map callback
const updatedRewardIds: Partial<Record<"clickRewardId" | "leadRewardId" | "saleRewardId", string>> = {};

// when duplicating
updatedRewardIds[rewardType] = newRewardId;

// later
const finalClickRewardId = updatedRewardIds.clickRewardId ?? group.clickRewardId;
const finalLeadRewardId = updatedRewardIds.leadRewardId ?? group.leadRewardId;
const finalSaleRewardId = updatedRewardIds.saleRewardId ?? group.saleRewardId;

let finalDiscountId = group.discountId;
if (newDiscountIdWasCreated) finalDiscountId = newDiscountId; // track similarly instead of mutating `group`

Also applies to: 131-133


135-138: Deep-copy potential JSON columns on Discount as well

You deep-copy Reward.modifiers but not Discount JSON fields. If Discount has any JSON/JSONB columns, consider deep-copying to avoid accidental shared references.

Would you like me to scan the Prisma schema for JSON fields on Discount and patch the duplication accordingly?


168-186: Optional: pre-index enrollments to avoid repeated O(n) filtering per group

Filtering programEnrollments for every group leads to O(G·E). For large datasets, build a Map keyed by a composite of the five IDs once, then lookups are O(1).

Example outside this hunk:

// Before the groups.map:
const enrollmentsByKey = new Map<string, string[]>();
for (const e of programEnrollments) {
  const key = `${e.programId}|${e.clickRewardId ?? ""}|${e.leadRewardId ?? ""}|${e.saleRewardId ?? ""}|${e.discountId ?? ""}`;
  const arr = enrollmentsByKey.get(key);
  if (arr) arr.push(e.id);
  else enrollmentsByKey.set(key, [e.id]);
}

// Inside the groups.map:
const key = `${group.programId}|${group.clickRewardId ?? ""}|${group.leadRewardId ?? ""}|${group.saleRewardId ?? ""}|${group.discountId ?? ""}`;
const ids = enrollmentsByKey.get(key) ?? [];
apps/web/scripts/migrations/migrate-program-invites.ts (5)

61-74: Avoid unused variable and promise chaining; use await and avoid shadowing link.

linkRes is unused, and the .then((link) => ...) shadows the outer link. Using await improves readability and error handling. Also logs should reference the updated link.

Apply:

-      const linkRes = await prisma.link
-        .update({
-          where: {
-            id: link.id,
-          },
-          data: {
-            programId: programInvite.programId,
-            partnerId: partner.id,
-          },
-        })
-        .then((link) => recordLink(link));
-
-      console.log(`Updated link ${link.id}`);
+      const updatedLink = await prisma.link.update({
+        where: { id: link.id },
+        data: {
+          programId: programInvite.programId,
+          partnerId: partner.id,
+        },
+      });
+      try {
+        await recordLink(updatedLink);
+      } catch (e) {
+        console.warn(`recordLink failed for link ${updatedLink.id}:`, e);
+      }
+
+      console.log(`Updated link ${updatedLink.id}`);

139-148: Backfill may miss >5k sales; consider paginating until exhaustion.

Limit 5000 risks truncation for active links. A simple loop to fetch pages until fewer than limit are returned would make this idempotent and complete.

Example helper (place near top or alongside this function):

async function getAllEventsPaginated(params: Omit<Parameters<typeof getEvents>[0], "page"> & { limit?: number }) {
  const limit = params.limit ?? 5000;
  let page = 1;
  const all: SaleEvent[] = [];
  // Guard against runaway loops
  const MAX_PAGES = 200;

  while (page <= MAX_PAGES) {
    const chunk = await getEvents({ ...params, page, limit });
    all.push(...chunk);
    if (chunk.length < limit) break;
    page += 1;
  }
  return all;
}

Then replace the call:

const saleEvents = await getAllEventsPaginated({
  workspaceId: workspace.id,
  linkId: link.id,
  event: "sales",
  interval: "all",
  limit: 5000,
  sortOrder: "desc",
  sortBy: "timestamp",
});

150-171: Avoid hardcoding currency to USD; prefer event-provided currency with USD fallback.

If SaleEvent carries currency, propagate it; otherwise default to "usd". This improves reporting accuracy.

Apply:

-    currency: "usd",
+    currency: (e.sale?.currency?.toLowerCase?.() ?? "usd") as "usd",

If SaleEvent doesn’t include currency, ignore this change.


18-19: take: 10 likely limits the migration; confirm intent.

If this is for initial dry runs, consider batching until no records remain (or remove take) before running in production.


10-11: Use path aliases in migration script for consistency

The script already uses @/lib/... imports (see lines 3–6), and both getEvents and recordLink are correctly exported under apps/web/lib. Switch these two imports to aliases:

--- apps/web/scripts/migrations/migrate-program-invites.ts
@@ -10,7 +10,7 @@
-import { getEvents } from "../../lib/analytics/get-events";
-import { recordLink } from "../../lib/tinybird";
+import { getEvents } from "@/lib/analytics/get-events";
+import { recordLink } from "@/lib/tinybird";

[optional_refactors_recommended]

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between bc3ac82 and 4b3f2c3.

📒 Files selected for processing (17)
  • apps/web/app/(ee)/api/programs/[programId]/discounts/route.ts (0 hunks)
  • apps/web/app/(ee)/api/programs/[programId]/rewards/route.ts (0 hunks)
  • apps/web/app/(ee)/api/programs/[programId]/route.ts (0 hunks)
  • apps/web/lib/actions/partners/create-program.ts (5 hunks)
  • apps/web/lib/actions/partners/invite-partner.ts (1 hunks)
  • apps/web/lib/api/partners/create-and-enroll-partner.ts (4 hunks)
  • apps/web/lib/api/programs/get-program-or-throw.ts (1 hunks)
  • apps/web/lib/partnerstack/import-partners.ts (5 hunks)
  • apps/web/lib/rewardful/import-campaign.ts (3 hunks)
  • apps/web/lib/rewardful/import-partners.ts (6 hunks)
  • apps/web/lib/rewardful/schemas.ts (1 hunks)
  • apps/web/lib/tolt/import-partners.ts (5 hunks)
  • apps/web/scripts/migrations/backfill-partner-groups.ts (1 hunks)
  • apps/web/scripts/migrations/migrate-program-invites.ts (1 hunks)
  • apps/web/scripts/migrations/migrate-rewards-remainder.ts (1 hunks)
  • packages/prisma/schema/discount.prisma (0 hunks)
  • packages/prisma/schema/reward.prisma (0 hunks)
💤 Files with no reviewable changes (5)
  • packages/prisma/schema/reward.prisma
  • packages/prisma/schema/discount.prisma
  • apps/web/app/(ee)/api/programs/[programId]/route.ts
  • apps/web/app/(ee)/api/programs/[programId]/rewards/route.ts
  • apps/web/app/(ee)/api/programs/[programId]/discounts/route.ts
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-08-14T05:17:51.787Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/lib/actions/partners/delete-reward.ts:33-41
Timestamp: 2025-08-14T05:17:51.787Z
Learning: In the partner groups system, a rewardId can only belong to one group, establishing a one-to-one relationship between rewards and groups. This means using Prisma's `update` method (rather than `updateMany`) is appropriate when updating groups by rewardId.

Applied to files:

  • apps/web/scripts/migrations/backfill-partner-groups.ts
  • apps/web/lib/rewardful/import-partners.ts
  • apps/web/lib/api/partners/create-and-enroll-partner.ts
  • apps/web/lib/rewardful/import-campaign.ts
  • apps/web/lib/tolt/import-partners.ts
  • apps/web/lib/partnerstack/import-partners.ts
  • apps/web/lib/actions/partners/create-program.ts
📚 Learning: 2025-08-14T05:00:23.180Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/app/api/og/program/route.tsx:63-64
Timestamp: 2025-08-14T05:00:23.180Z
Learning: In Dub's partner program system, the default partner group will always exist. When programs are created, a default partner group is automatically upserted using DEFAULT_PARTNER_GROUP constant, so accessing program.groups[0] in contexts where the default group is queried is safe.

Applied to files:

  • apps/web/lib/tolt/import-partners.ts
  • apps/web/lib/partnerstack/import-partners.ts
🧬 Code Graph Analysis (5)
apps/web/lib/rewardful/import-partners.ts (2)
apps/web/lib/zod/schemas/groups.ts (1)
  • DEFAULT_PARTNER_GROUP (9-13)
apps/web/lib/rewardful/types.ts (1)
  • RewardfulAffiliate (42-55)
apps/web/lib/api/partners/create-and-enroll-partner.ts (1)
apps/web/lib/api/groups/get-group-or-throw.ts (1)
  • getGroupOrThrow (4-51)
apps/web/lib/rewardful/import-campaign.ts (5)
apps/web/lib/rewardful/types.ts (1)
  • RewardfulImportPayload (102-104)
apps/web/lib/zod/schemas/groups.ts (1)
  • DEFAULT_PARTNER_GROUP (9-13)
apps/web/lib/api/create-id.ts (1)
  • createId (57-66)
apps/web/ui/colors.ts (1)
  • RESOURCE_COLORS (36-38)
apps/web/lib/rewardful/importer.ts (1)
  • rewardfulImporter (45-45)
apps/web/lib/tolt/import-partners.ts (2)
apps/web/lib/zod/schemas/groups.ts (1)
  • DEFAULT_PARTNER_GROUP (9-13)
apps/web/lib/tolt/types.ts (1)
  • ToltAffiliate (25-25)
apps/web/lib/partnerstack/import-partners.ts (2)
apps/web/lib/zod/schemas/groups.ts (1)
  • DEFAULT_PARTNER_GROUP (9-13)
apps/web/lib/partnerstack/types.ts (1)
  • PartnerStackPartner (21-21)
🔇 Additional comments (14)
apps/web/scripts/migrations/migrate-rewards-remainder.ts (1)

12-19: Confirm selection criteria: only enrollments with all three reward IDs null

The filter currently requires all three fields to be null. If the intent was to also backfill enrollments that are missing any single event reward, adjust the predicate accordingly (e.g., OR across the three fields).

apps/web/lib/rewardful/schemas.ts (2)

14-18: Schema rename to groupId looks good

Switching the payload from rewardId to groupId aligns with the PR’s group-centric flow. No structural issues in the schema.


10-18: No more rewardId references in Rewardful import code
I searched the entire repo and found zero occurrences of rewardId under apps/web/lib/rewardful. All remaining rewardId hits are in migration scripts or in partner-reward UI/API code, not in the Rewardful import paths. You can consider this change complete—there are no in-repo producers or consumers still using rewardId for import payloads.

apps/web/lib/rewardful/import-campaign.ts (2)

98-100: Avoid non-null assertion on optional relation; handle absence explicitly

salePartnerGroup may be null if the reward isn’t assigned to any group. Avoid “!” to prevent masking undefined at runtime.

-    groupId = existingReward.salePartnerGroup?.id!;
+    groupId = existingReward.salePartnerGroup?.id ?? null;

If this case should never happen, add an explicit check and create or map a group instead of silently dropping groupId.


14-25: Don’t rely solely on slug “default”; include a fallback via defaultGroupId

Querying the default group by slug can fail if a program is missing or has renamed the default group. Prefer using program.defaultGroupId as a primary key fallback.

Apply:

-  const { workspaceId, groups } = await prisma.program.findUniqueOrThrow({
+  const { workspaceId, defaultGroupId, groups } = await prisma.program.findUniqueOrThrow({
     where: {
       id: programId,
     },
     include: {
       groups: {
         where: {
           slug: DEFAULT_PARTNER_GROUP.slug,
         },
       },
     },
   });

You can then safely fall back to defaultGroupId later if groups is empty (see suggested changes around Lines 72–97).

⛔ Skipped due to learnings
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/app/api/og/program/route.tsx:63-64
Timestamp: 2025-08-14T05:00:23.180Z
Learning: In Dub's partner program system, the default partner group will always exist. When programs are created, a default partner group is automatically upserted using DEFAULT_PARTNER_GROUP constant, so accessing program.groups[0] in contexts where the default group is queried is safe.
apps/web/lib/actions/partners/invite-partner.ts (2)

80-82: Pre-check for group context is good

Guarding against missing groupId and defaultGroupId avoids runtime issues downstream. This complements the deeper fallback logic introduced in create-and-enroll-partner.


84-95: Passing groupId through (without defaulting) aligns with new group-driven flow

Delegating default resolution to createAndEnrollPartner is consistent with the refactor. Ensure createAndEnrollPartner hardens its own guard so outside callers can’t pass neither groupId nor defaultGroupId.

I’ve proposed an internal guard in create-and-enroll-partner.ts to cover this.

apps/web/lib/api/partners/create-and-enroll-partner.ts (1)

91-112: Correct usage: group-derived IDs on enrollment payload

Using group.id and the group’s reward/discount IDs directly is the right move for the new group-centric model.

apps/web/lib/rewardful/import-partners.ts (1)

111-116: LGTM: queue propagation of groupId only when continuing partner import

Forwarding groupId only when action === "import-partners" is correct and avoids leaking it into subsequent “customers” import steps.

apps/web/lib/partnerstack/import-partners.ts (2)

24-33: LGTM: default group resolution and usage

Selecting the default group via slug and deriving defaultGroupAttributes from program.groups[0] is consistent and aligns with the groups-first model.

Also applies to: 32-33


62-69: LGTM: correct propagation of group-derived reward/discount IDs

Building and passing defaultGroupAttributes ensures enrollments are populated from the program’s default group.

apps/web/lib/tolt/import-partners.ts (2)

18-27: LGTM: default group selection is consistent with the new model

Filtering by DEFAULT_PARTNER_GROUP.slug and deriving defaultGroup from program.groups[0] is correct.

Also applies to: 26-27


63-79: Good use of Promise.allSettled to isolate per-partner failures

This maintains batch progress even if some partner creations fail.

apps/web/scripts/migrations/migrate-program-invites.ts (1)

139-148: No changes needed: “sales” is the correct event value

The getEvents call uses the event parameter validated by eventsQuerySchema, which is built on
EVENT_TYPES = ["clicks", "leads", "sales"] (apps/web/lib/analytics/constants.ts:213).
Therefore passing "sales" (plural) is expected and no update is required.

@steven-tey steven-tey merged commit a861c6a into main Aug 16, 2025
8 checks passed
@steven-tey steven-tey deleted the program-creation-groups branch August 16, 2025 01:40
@coderabbitai coderabbitai bot mentioned this pull request Sep 30, 2025
@coderabbitai coderabbitai bot mentioned this pull request Oct 15, 2025
@coderabbitai coderabbitai bot mentioned this pull request Nov 21, 2025
@coderabbitai coderabbitai bot mentioned this pull request Dec 9, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants