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

Skip to content

Conversation

@devkiran
Copy link
Collaborator

@devkiran devkiran commented Aug 16, 2025

Summary by CodeRabbit

  • New Features

    • Bulk ban and bulk archive partners with validation, audit logging, notifications, link invalidation, and related cleanup.
    • PartnerStack group import and listing: import groups first, then import partners mapped to those groups.
  • Improvements

    • More reliable country detection during PartnerStack imports.
    • Program creation runs atomically and now requires a default group/folder.
    • Orphan partner groups are removed after imports.

@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 17, 2025 8:02pm

@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 10 minutes and 59 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 d4a26c1 and 32a1e78.

📒 Files selected for processing (4)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/link-settings/partner-links-button.tsx (1 hunks)
  • apps/web/lib/actions/partners/create-program.ts (3 hunks)
  • apps/web/lib/actions/partners/update-program.ts (0 hunks)
  • apps/web/lib/zod/schemas/programs.ts (1 hunks)

Walkthrough

Adds a PartnerStack "import-groups" step and group-aware partner import, implements bulk ban/archive partner actions with new Zod schemas and related side effects (audit, notifications, link/commission updates), makes Program.defaultFolderId/defaultGroupId required in Prisma/Zod, wires the new cron route action, and applies minor partner-action fixes.

Changes

Cohort / File(s) Summary
PartnerStack import flow & API
apps/web/app/(ee)/api/cron/import/partnerstack/route.ts, apps/web/lib/partnerstack/api.ts, apps/web/lib/partnerstack/import-groups.ts, apps/web/lib/partnerstack/import-partners.ts, apps/web/lib/partnerstack/schemas.ts, apps/web/lib/partnerstack/types.ts, apps/web/lib/actions/partners/create-program.ts, apps/web/lib/actions/partners/start-partnerstack-import.ts
Adds import-groups enum step, PartnerStackApi.listGroups(), new import-groups module that upserts non-default groups and queues partner import, makes import-partners group-aware (groupMap, country-code resolution, orphan-group cleanup), and switches initial queue payload/action to import-groups.
Bulk partner actions & validation
apps/web/lib/actions/partners/bulk-archive-partners.ts, apps/web/lib/actions/partners/bulk-ban-partners.ts, apps/web/lib/zod/schemas/partners.ts
Implements bulkArchive and bulkBan actions, introduces bulkArchivePartnersSchema / bulkBanPartnersSchema, performs transactional updates, audit logging, link invalidation, commission/payout updates, and optional partner notifications; exported action schemas updated.
Program schema changes (Prisma & Zod)
packages/prisma/schema/program.prisma, apps/web/lib/zod/schemas/programs.ts
Makes Program.defaultFolderId and Program.defaultGroupId non-nullable in Prisma; makes defaultGroupId required in Zod ProgramSchema.
Minor partner actions adjustments
apps/web/lib/actions/partners/archive-partner.ts, apps/web/lib/partners/approve-partner-enrollment.ts, apps/web/lib/actions/partners/update-program.ts
Reordered composite key fields in a Prisma where clause, removed a non-null assertion on program.defaultGroupId, and stopped persisting defaultFolderId on program update (still validated earlier).

Sequence Diagram(s)

sequenceDiagram
  participant Starter as start-partnerstack-import
  participant Queue as partnerStackImporter.queue
  participant Cron as /api/cron/import/partnerstack
  participant ImportG as import-groups
  participant PS as PartnerStackApi
  participant DB as Prisma
  participant ImportP as import-partners

  Starter->>Queue: enqueue { action: "import-groups", importId, programId, userId }
  Cron->>ImportG: handle payload.action == "import-groups"
  ImportG->>DB: fetch program (workspaceId)
  ImportG->>PS: listGroups()
  PS-->>ImportG: groups[]
  loop for each non-default group
    ImportG->>DB: upsert partnerGroup (programId, slug)
  end
  ImportG->>Queue: enqueue { action: "import-partners", ... }
  Cron->>ImportP: handle payload.action == "import-partners"
  ImportP->>PS: fetch partners (batched)
  ImportP->>DB: upsert partners (use groupMap, resolve countries)
  ImportP->>DB: delete orphan non-default groups when finished
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • devkiran

Poem

I nibble logs and queue each run,
Groups hop in before partners come.
I stitch up slugs and tidy codes,
Stamp bans and archives on the roads.
Carrots, audits, queues — the job is done. 🥕🐇

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

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.

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/lib/actions/partners/create-program.ts (1)

189-194: Ensure import-groups enqueues the “import-partners” step

I confirmed that the cron route in
apps/web/app/(ee)/api/cron/import/partnerstack/route.ts
handles both "import-groups" and "import-partners" actions. However, I didn’t find any partnerStackImporter.queue invocation for "import-partners" in your importer code—specifically in
apps/web/lib/partnerstack/import-groups.ts, which currently only re-queues itself for paging.

Please add (or verify) a queue call for "import-partners" once the final groups batch has been imported. For example, at the end of importGroups:

// apps/web/lib/partnerstack/import-groups.ts

if (!hasMore) {
  // after processing all groups, kick off the partners import
  await partnerStackImporter.queue({
    importId: payload.importId,
    userId: payload.userId,
    programId: payload.programId,
    action: "import-partners",
  });
}

Once that’s in place, the two-step flow (groups → partners) will be fully wired up.

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

59-66: Guard against unknown group slugs to prevent runtime errors

If PartnerStack returns a group slug that isn’t yet present in program.groups (e.g., a new PS group not imported yet), groupMap[slug] is undefined, causing crashes when accessing group.id later. Fall back to the default group when the lookup misses. This aligns with the retrieved learning that a default group always exists.

Apply this diff:

-          group: groupMap[partner.group?.slug ?? DEFAULT_PARTNER_GROUP.slug],
+          group:
+            groupMap[partner.group?.slug ?? DEFAULT_PARTNER_GROUP.slug] ||
+            groupMap[DEFAULT_PARTNER_GROUP.slug],
🧹 Nitpick comments (14)
apps/web/lib/partners/approve-partner-enrollment.ts (1)

55-55: Prefer nullish coalescing over logical OR for group fallback

Use ?? instead of || so an empty string isn't treated as “absent” and silently fall back to the default group.

Apply:

-    groupId: groupId || program.defaultGroupId,
+    groupId: groupId ?? program.defaultGroupId,

Note: If you want to treat empty string as missing, enforce that in the zod schema (e.g., transform "" to undefined) instead of relying on ||.

apps/web/lib/zod/schemas/partners.ts (2)

621-634: Bulk ban schema looks solid; consider DRYing the partnerIds dedupe pattern

The schema reads well: bounded, deduped partner IDs and a reason enum aligned to BAN_PARTNER_REASONS.

Since this dedupe/max/min pattern is repeated across several bulk schemas, consider extracting a reusable helper schema, e.g.:

// near top of file
const dedupedIdArraySchema = z.array(z.string()).max(100).min(1).transform((v) => [...new Set(v)]);

Then use:

partnerIds: dedupedIdArraySchema

641-648: Bulk archive schema is correct; same DRY opportunity for partnerIds

Consistent bounds and deduping.

If you introduce dedupedIdArraySchema as above, you can reuse it here as well to keep schemas consistent and concise.

apps/web/lib/actions/partners/bulk-archive-partners.ts (3)

41-42: Clarify the error message when no eligible partners are found

The message may be misleading if all provided partners are already archived. Make it explicit.

Apply:

-      throw new Error("You must provide at least one valid partner ID.");
+      throw new Error("No eligible partners found to archive for this program.");

45-54: Avoid unnecessary writes: filter updateMany to non-archived enrollments

This prevents no-op updates and reduces write load.

Apply:

   await prisma.programEnrollment.updateMany({
     where: {
       partnerId: {
         in: partnerIds,
       },
       programId,
+      status: {
+        not: "archived",
+      },
     },
     data: {
       status: "archived",
     },
   });

28-37: Remove unused select field

id from programEnrollment isn’t used later; drop it to reduce payload.

Apply:

-      select: {
-        id: true,
-        partner: {
+      select: {
+        partner: {
           select: {
             id: true,
             name: true,
             email: true,
           },
         },
       },
apps/web/lib/actions/partners/bulk-ban-partners.ts (6)

33-36: Prefer enum constant over raw string for status filter

Use ProgramEnrollmentStatus.banned for consistency and type-safety instead of the string literal "banned".

-        status: {
-          not: "banned",
-        },
+        status: {
+          not: ProgramEnrollmentStatus.banned,
+        },

66-81: Avoid resetting bannedAt/bannedReason for already-banned enrollments

The updateMany currently targets all enrollments for the partnerIds, including those already banned. This will overwrite bannedAt and bannedReason on previously banned partners. Align the update scope with the earlier selection (status != banned).

-      prisma.programEnrollment.updateMany({
-        where: {
-          ...commonWhere,
-        },
+      prisma.programEnrollment.updateMany({
+        where: {
+          ...commonWhere,
+          status: { not: ProgramEnrollmentStatus.banned },
+        },
         data: {
           status: ProgramEnrollmentStatus.banned,
           bannedAt: new Date(),
           bannedReason: parsedInput.reason,
           groupId: null,
           clickRewardId: null,
           leadRewardId: null,
           saleRewardId: null,
           discountId: null,
         },
       }),

59-65: Scope updates to the actually eligible partnerIds

Today, commonWhere uses the input partnerIds (which can include non-enrolled or already-banned partners). For clearer intent and fewer no-ops, derive partnerIds from programEnrollments (status != banned) and use that set consistently for updates, cache invalidation, and notifications.

-    const commonWhere = {
-      programId,
-      partnerId: {
-        in: partnerIds,
-      },
-    };
+    const eligiblePartnerIds = programEnrollments.map((pe) => pe.partner.id);
+
+    const commonWhere = {
+      programId,
+      partnerId: {
+        in: eligiblePartnerIds,
+      },
+    };

Note: This aligns DB updates with side-effects already based on programEnrollments.

Also applies to: 27-53


131-146: Include reason in audit logs for better traceability

Adding the banned reason to the audit description improves investigations and support handling.

-            description: `Partner ${partner.id} banned`,
+            description: `Partner ${partner.id} banned (${parsedInput.reason})`,

149-157: Micro: avoid an extra read for Program fields in the background task

You already know programId and workspace context. If you have program details at hand earlier (e.g., from a prior step), pass { name, supportEmail } down to avoid this additional query. Not critical—OK as is.


92-101: Use Prisma enum constants for statuses in bulk-ban-partners.ts

For consistency with ProgramEnrollmentStatus, replace raw string statuses with CommissionStatus and PayoutStatus enum values:

  • File: apps/web/lib/actions/partners/bulk-ban-partners.ts
    Lines: 92–101 and 102–111

Diff:

-import { ProgramEnrollmentStatus } from "@prisma/client";
+import { ProgramEnrollmentStatus, CommissionStatus, PayoutStatus } from "@prisma/client";

       prisma.commission.updateMany({
         where: {
           ...commonWhere,
-          status: "pending",
+          status: CommissionStatus.pending,
         },
         data: {
-          status: "canceled",
+          status: CommissionStatus.canceled,
         },
       }),

       prisma.payout.updateMany({
         where: {
           ...commonWhere,
-          status: "pending",
+          status: PayoutStatus.pending,
         },
         data: {
-          status: "canceled",
+          status: PayoutStatus.canceled,
         },
       }),

[optional_refactors_recommended]

apps/web/lib/partnerstack/import-groups.ts (1)

45-51: Upsert should update the group name to avoid stale metadata

Currently update: {} means a renamed PartnerStack group won’t reflect in our DB. Updating name on conflict keeps data consistent while slug remains the stable key.

Apply this diff:

       await prisma.partnerGroup.upsert({
         where: {
           programId_slug: {
             programId,
             slug: group.slug,
           },
         },
         create: {
           id: createId({ prefix: "grp_" }),
           programId,
           name: group.name,
           slug: group.slug,
         },
-        update: {},
+        update: {
+          name: group.name,
+        },
       });
apps/web/lib/partnerstack/import-partners.ts (1)

112-123: Make country resolution case-insensitive and normalize codes

PartnerStack may send country names/codes with varying casing. Normalizing improves match rate and reduces noisy logs.

Apply this diff:

-  // Resolve partner's country: check if it's a valid code first,
-  // otherwise fall back to lookup by country name because PS returns the name in some cases
+  // Resolve partner's country: prefer ISO code if present (case-insensitive),
+  // otherwise fall back to lookup by country name (case-insensitive)
   const country = partner.address?.country;
-  const countryCode = country
-    ? COUNTRY_CODES.includes(country)
-      ? country
-      : COUNTRY_NAME_TO_CODE.get(country) ?? null
+  const countryCode = country
+    ? (() => {
+        const raw = country.trim();
+        const asCode = raw.toUpperCase();
+        if (COUNTRY_CODES.includes(asCode)) {
+          return asCode;
+        }
+        // name lookup is case-insensitive
+        return COUNTRY_NAME_TO_CODE.get(raw.toLowerCase()) ?? null;
+      })()
     : null;
 
-  if (country && !countryCode) {
-    console.log(`Country code not found for country ${country}`);
-  }
+  if (country && !countryCode) {
+    console.warn(`Country code not found for country "${country}"`);
+  }

And make the name-to-code map case-insensitive at definition:

-const COUNTRY_NAME_TO_CODE = new Map(
-  Object.entries(COUNTRIES).map(([code, name]) => [name, code]),
-);
+const COUNTRY_NAME_TO_CODE = new Map(
+  Object.entries(COUNTRIES).map(([code, name]) => [name.toLowerCase(), code]),
+);
📜 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 f4b9778 and 10898ee.

📒 Files selected for processing (15)
  • apps/web/app/(ee)/api/cron/import/partnerstack/route.ts (2 hunks)
  • apps/web/lib/actions/partners/archive-partner.ts (1 hunks)
  • apps/web/lib/actions/partners/bulk-archive-partners.ts (1 hunks)
  • apps/web/lib/actions/partners/bulk-ban-partners.ts (1 hunks)
  • apps/web/lib/actions/partners/create-program.ts (1 hunks)
  • apps/web/lib/actions/partners/start-partnerstack-import.ts (1 hunks)
  • apps/web/lib/partners/approve-partner-enrollment.ts (1 hunks)
  • apps/web/lib/partnerstack/api.ts (2 hunks)
  • apps/web/lib/partnerstack/import-groups.ts (1 hunks)
  • apps/web/lib/partnerstack/import-partners.ts (7 hunks)
  • apps/web/lib/partnerstack/schemas.ts (3 hunks)
  • apps/web/lib/partnerstack/types.ts (2 hunks)
  • apps/web/lib/zod/schemas/partners.ts (1 hunks)
  • apps/web/lib/zod/schemas/programs.ts (1 hunks)
  • packages/prisma/schema/program.prisma (1 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 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
📚 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/lib/partnerstack/import-partners.ts
🧬 Code Graph Analysis (8)
apps/web/lib/partnerstack/types.ts (1)
apps/web/lib/partnerstack/schemas.ts (1)
  • partnerStackGroup (25-30)
apps/web/lib/zod/schemas/partners.ts (1)
packages/prisma/client.ts (1)
  • PartnerBannedReason (10-10)
apps/web/lib/partnerstack/import-groups.ts (4)
apps/web/lib/partnerstack/types.ts (1)
  • PartnerStackImportPayload (18-20)
apps/web/lib/partnerstack/importer.ts (1)
  • partnerStackImporter (47-47)
apps/web/lib/partnerstack/api.ts (1)
  • PartnerStackApi (19-138)
apps/web/lib/api/create-id.ts (1)
  • createId (57-66)
apps/web/app/(ee)/api/cron/import/partnerstack/route.ts (1)
apps/web/lib/partnerstack/import-groups.ts (1)
  • importGroups (7-59)
apps/web/lib/actions/partners/bulk-archive-partners.ts (3)
apps/web/lib/actions/safe-action.ts (1)
  • authActionClient (33-82)
apps/web/lib/zod/schemas/partners.ts (1)
  • bulkArchivePartnersSchema (641-648)
apps/web/lib/api/audit-logs/record-audit-log.ts (1)
  • recordAuditLog (43-63)
apps/web/lib/actions/partners/bulk-ban-partners.ts (4)
apps/web/lib/actions/safe-action.ts (1)
  • authActionClient (33-82)
apps/web/lib/zod/schemas/partners.ts (2)
  • bulkBanPartnersSchema (621-634)
  • BAN_PARTNER_REASONS (51-58)
packages/prisma/client.ts (1)
  • ProgramEnrollmentStatus (18-18)
apps/web/lib/api/audit-logs/record-audit-log.ts (1)
  • recordAuditLog (43-63)
apps/web/lib/partnerstack/api.ts (2)
apps/web/lib/partnerstack/types.ts (2)
  • PartnerStackListResponse (12-16)
  • PartnerStackGroup (22-22)
apps/web/lib/partnerstack/schemas.ts (1)
  • partnerStackGroup (25-30)
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 (24-24)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (7)
apps/web/lib/zod/schemas/programs.ts (1)

41-41: defaultGroupId now required — verify migration & callsites

  • Confirm defaultGroupId is non-nullable in packages/prisma/schema/program.prisma (String, no ?).
  • Ensure there’s a corresponding Prisma migration (e.g. in packages/prisma/migrations) that:
    • Alters defaultGroupId to NOT NULL
    • Backfills existing rows with a valid group ID
  • Verify that migration has been applied in your development and production databases.
  • Search for any remaining nullable checks or non-null assertions on defaultGroupId:
    • Found in
    apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/invite-partner-sheet.tsx
    defaultValues: { groupId: program?.defaultGroupId || "" }
    – if program is always defined, you can simplify to program.defaultGroupId.
  • Remove or refactor any stale ? chains or ! assertions around defaultGroupId where no longer needed.
apps/web/lib/actions/partners/archive-partner.ts (1)

27-30: LGTM: composite key field order is irrelevant

Reordering the fields within the Prisma composite key object is a no-op. Safe change.

apps/web/lib/actions/partners/start-partnerstack-import.ts (1)

49-54: Kickoff step switched to "import-groups" — aligns with new workflow

Good handoff after credentials validation. This should chain into the partners import next.

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

6-7: Importing partnerStackGroup schema — OK


22-23: New PartnerStackGroup type export — OK

Matches the schema and supports API listGroups().

apps/web/app/(ee)/api/cron/import/partnerstack/route.ts (1)

5-5: LGTM: New "import-groups" action is correctly wired into the cron route

  • The import added and the switch-case branch are correct and align with the updated import flow (groups → partners).

Also applies to: 26-28

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

4-4: Enum addition for "import-groups" fits the new flow

The new import step integrates well with the multi-step importer and keeps validation centralized.

@devkiran devkiran changed the title Group updates Follow-up improvements to partner groups and imports Aug 16, 2025
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 (1)
apps/web/lib/partnerstack/import-partners.ts (1)

189-192: Do not override enrollment status to "approved" on update

This will unintentionally unban or unarchive partners that were bulk-banned/archived elsewhere in this PR. Only set status on create; preserve it on update.

Minimal change:

-    update: {
-      status: "approved",
-    },
+    update: {
+      // Preserve existing status; do not override to "approved"
+    },

If your Prisma version disallows empty updates, consider updating only the group/reward fields (if you want to keep them in sync) while omitting the status field. Alternatively, conditionally update based on current status (e.g., skip updates when status in ["banned", "archived"]).

🧹 Nitpick comments (3)
apps/web/lib/partnerstack/import-partners.ts (3)

16-19: Normalize COUNTRY_NAME_TO_CODE to be case-insensitive

PartnerStack may return country names with varying casing/whitespace. Pre-normalizing the map keys avoids misses.

-const COUNTRY_NAME_TO_CODE = new Map(
-  Object.entries(COUNTRIES).map(([code, name]) => [name, code]),
-);
+const COUNTRY_NAME_TO_CODE = new Map(
+  Object.entries(COUNTRIES).map(([code, name]) => [name.toLowerCase(), code]),
+);

32-34: Type groupMap for safety and better inference

Explicit typing helps TS catch undefined access later and keeps createPartner’s group: PartnerGroup contract honest.

-const groupMap = Object.fromEntries(
-  program.groups.map((group) => [group.slug, group]),
-);
+const groupMap: Record<string, PartnerGroup> = Object.fromEntries(
+  program.groups.map((group) => [group.slug, group] as const),
+);

144-151: Normalize/trim country input and prefer structured warning

Handle case/whitespace for both codes and names to improve resolution. Also prefer warn-level logging (or structured logging if available).

-  const country = partner.address?.country;
-  const countryCode = country
-    ? COUNTRY_CODES.includes(country)
-      ? country
-      : COUNTRY_NAME_TO_CODE.get(country) ?? null
-    : null;
+  const rawCountry = partner.address?.country?.trim();
+  const countryCode = rawCountry
+    ? COUNTRY_CODES.includes(rawCountry.toUpperCase())
+      ? rawCountry.toUpperCase()
+      : COUNTRY_NAME_TO_CODE.get(rawCountry.toLowerCase()) ?? null
+    : null;
 
-  if (country && !countryCode) {
-    console.log(`Country code not found for country ${country}`);
+  if (rawCountry && !countryCode) {
+    console.warn(`Country code not found for country "${rawCountry}"`);
   }

Optionally, replace console.warn with logImportError to keep a consistent error stream:

await logImportError({
  ...commonImportLogInputs,
  code: "INVALID_COUNTRY",
  message: `Unable to resolve country "${rawCountry}"`,
});

Also applies to: 153-155

📜 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 10898ee and 745128b.

📒 Files selected for processing (2)
  • apps/web/lib/partnerstack/import-groups.ts (1 hunks)
  • apps/web/lib/partnerstack/import-partners.ts (8 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/lib/partnerstack/import-groups.ts
🧰 Additional context used
🧠 Learnings (2)
📚 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
📚 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/lib/partnerstack/import-partners.ts
⏰ 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 (5)
apps/web/lib/partnerstack/import-partners.ts (5)

2-3: Typed Prisma/util imports look good

Using PartnerGroup/Program and COUNTRY_CODES is appropriate for the new logic.


118-124: createPartner signature updated to require a group: OK

Requiring PartnerGroup here is fine given the earlier fallback. Ensure the call-site fix in Line 64 lands to avoid undefined.


157-170: Prisma upsert with empty update: please verify; consider a minimal, safe update

Using update: { /* do nothing */ } may be rejected by Prisma (depending on version/generator) because update data can’t be empty. If that’s the case, make the update a no-op that doesn’t overwrite user-managed fields.

Proposed minimal update that’s effectively non-destructive:

-    update: {
-      // do nothing
-    },
+    update: {
+      // Keep existing values; only set country if resolvable
+      country: countryCode ?? undefined,
+    },

If Prisma accepts an empty update in your setup, feel free to keep it empty and add a comment that it’s intentional.


183-188: Propagating group-linked reward/discount IDs on create looks correct

This aligns enrollment with the assigned group at the time of creation.


76-106: Confirm deletion scope for empty partner groups
The cleanup logic in apps/web/lib/partnerstack/import-partners.ts (lines 76–106) deletes every non-default group with no partners. Because the PartnerGroup model has no source/provider or externalId flag, it will also remove any user-created or future-planned empty groups.

Please confirm intended behavior:

  • If you only want to delete groups created by this import, you’ll need to extend the schema (e.g., add a source or externalId field) and populate it during import, then filter on that field here.
  • Otherwise, document that all empty groups (except the default) will be purged and ensure this aligns with product requirements.

@devkiran devkiran requested a review from steven-tey August 17, 2025 11:42
@steven-tey
Copy link
Collaborator

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 17, 2025

✅ Actions performed

Full review triggered.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (3)
apps/web/lib/actions/partners/create-program.ts (3)

60-124: Avoid external I/O (logo upload) inside the DB transaction

storage.upload(...) runs inside the transaction. If the transaction rolls back after the upload, you’ll leak an orphaned file in R2. Keep DB transactions short and deterministic; do I/O outside and compensate on failure.

Here’s a minimal restructuring pattern:

// Before starting the transaction:
const programId = createId({ prefix: "prog_" });
const logoUrl = uploadedLogo
  ? (await storage.upload(`programs/${programId}/logo_${nanoid(7)}`, uploadedLogo)).url
  : null;

try {
  const program = await prisma.$transaction(async (tx) => {
    // ... use programId and logoUrl here ...
    // tx.folder.upsert(...)
    // tx.program.create({ data: { id: programId, ...(logoUrl && { logo: logoUrl }), ... } })
    // tx.partnerGroup.upsert(...)
    // tx.project.update(...)
    return programData;
  });

  // continue with queueing import, waitUntil, redirect, etc.
} catch (e) {
  // Best-effort cleanup if transaction failed after upload
  if (logoUrl) {
    await storage.delete(logoUrl.replace(`${R2_URL}/`, ""));
  }
  throw e;
}

This preserves the deterministic transaction boundary and prevents storage leaks on rollback.


63-83: Consider ensuring creator access on an existing 'Partner Links' folder

tx.folder.upsert(..., update: {}) won’t adjust permissions if the folder already exists. If a prior run (or another actor) created the folder without this user, the creator might lack access to the default folder referenced by the new program.

Options:

  • In the update branch, try to connect (or connectOrCreate if applicable) the current user with an appropriate role.
  • Alternatively, check membership and add it in a follow-up write within the transaction if missing.

If the product guarantees a single program creation path per workspace so the create branch always runs, this can be ignored.


148-166: Unconditional foldersUsage increment may drift if folder already existed

foldersUsage: { increment: 1 } runs regardless of whether the upsert actually created the folder. If the folder previously existed, the usage metric could overcount.

If usage is meant to reflect actual folder creations, only increment when the upsert creates a new record. Since Prisma’s upsert doesn’t return a created/updated flag, a common pattern is:

  • findUnique to detect existence,
  • create if missing (and increment usage),
  • otherwise reuse the existing folder without incrementing.

If your domain guarantees the folder doesn’t pre-exist at program creation time, feel free to skip this.

📜 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 745128b and 609390c.

📒 Files selected for processing (3)
  • apps/web/lib/actions/partners/create-program.ts (2 hunks)
  • apps/web/lib/zod/schemas/programs.ts (1 hunks)
  • packages/prisma/schema/program.prisma (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/prisma/schema/program.prisma
🧰 Additional context used
🧬 Code Graph Analysis (1)
apps/web/lib/actions/partners/create-program.ts (1)
apps/web/lib/api/create-id.ts (1)
  • createId (57-66)
⏰ 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 (2)
apps/web/lib/actions/partners/create-program.ts (2)

60-83: Good call consolidating creation into a single transaction

Wrapping folder upsert + program creation + related setup in one transaction is a solid improvement for consistency and failure-atomicity.


193-194: Integration of “import-groups” action verified

I’ve confirmed that the new action string is fully wired up:

  • partnerStackImportSteps schema includes "import-groups"
  • Both start-partnerstack-import and create-program actions use action: "import-groups"
  • apps/web/lib/partnerstack/import-groups.ts implements importGroups(...) and enqueues the next step (import-partners)
  • apps/web/app/(ee)/api/cron/import/partnerstack/route.ts handles case "import-groups": await importGroups(payload);

No further changes needed.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🔭 Outside diff range comments (1)
apps/web/lib/partnerstack/import-partners.ts (1)

189-191: Keep enrollments in sync with PartnerStack group mappings on updates

Upsert’s update branch only flips status to "approved". If a returning partner’s group changed in PartnerStack, we won’t realign groupId and reward/discount fields, causing inconsistent incentives. Mirror the create branch fields in update.

   await prisma.programEnrollment.upsert({
     where: {
       partnerId_programId: {
         partnerId,
         programId: program.id,
       },
     },
     create: {
       programId: program.id,
       partnerId,
       status: "approved",
       groupId: group.id,
       clickRewardId: group.clickRewardId,
       leadRewardId: group.leadRewardId,
       saleRewardId: group.saleRewardId,
       discountId: group.discountId,
     },
     update: {
-      status: "approved",
+      status: "approved",
+      groupId: group.id,
+      clickRewardId: group.clickRewardId,
+      leadRewardId: group.leadRewardId,
+      saleRewardId: group.saleRewardId,
+      discountId: group.discountId,
     },
   });

Also applies to: 180-188

♻️ Duplicate comments (3)
apps/web/lib/zod/schemas/programs.ts (1)

40-41: Align Zod nullability with Prisma for defaultFolderId (create + update schemas)

Prisma now requires Program.defaultFolderId to be non-null. Keeping Zod as nullable risks admitting invalid payloads that fail at DB time. Tighten both create and update schemas.

Apply this diff:

-  defaultFolderId: z.string().nullable(),
+  defaultFolderId: z.string(),
@@
-  defaultFolderId: z.string().nullable(),
+  defaultFolderId: z.string(),

If you intentionally accept null at the API edge, ensure server-side normalization strips it before persistence to avoid constraint violations.

Also applies to: 61-61

apps/web/lib/partnerstack/api.ts (1)

66-75: Use PAGE_LIMIT + URLSearchParams; add explicit return type; support cursor param for consistency

Current implementation hardcodes limit=100, omits an explicit return type, and doesn’t allow passing a starting_after cursor like other list methods. For consistency and to enable pagination by callers, align this with listPartners/listCustomers/listCommissions.

Apply this diff:

-  async listGroups() {
-    const {
-      data: { items },
-    } =
-      await this.fetch<PartnerStackListResponse<PartnerStackGroup>>(
-        `/groups?limit=100`,
-      );
-
-    return partnerStackGroup.array().parse(items);
-  }
+  async listGroups({ startingAfter }: { startingAfter?: string }): Promise<PartnerStackGroup[]> {
+    const searchParams = new URLSearchParams();
+    searchParams.append("limit", PAGE_LIMIT.toString());
+    if (startingAfter) {
+      searchParams.append("starting_after", startingAfter);
+    }
+    const {
+      data: { items },
+    } = await this.fetch<PartnerStackListResponse<PartnerStackGroup>>(
+      `/groups?${searchParams.toString()}`,
+    );
+    return partnerStackGroup.array().parse(items);
+  }
apps/web/lib/partnerstack/import-partners.ts (1)

64-65: Fix: Fallback when group slug is unknown (prevents runtime crash)

If PartnerStack returns a slug not present in groupMap, the current lookup yields undefined and createPartner will crash when accessing group.id. Fallback to the default group when the slug isn’t found.

-          group: groupMap[partner.group?.slug ?? DEFAULT_PARTNER_GROUP.slug],
+          // Fallback to default group if slug is unknown or missing
+          group:
+            groupMap[partner.group?.slug ?? ""] ??
+            groupMap[DEFAULT_PARTNER_GROUP.slug],
🧹 Nitpick comments (10)
apps/web/lib/partners/approve-partner-enrollment.ts (2)

49-56: Prefer nullish coalescing for group resolution; keep types narrowed

Using || treats empty strings as falsy. Given the guard above, use ?? to only fall back when groupId is nullish. If TS complains about possible undefined, precompute a narrowed variable.

Apply this diff:

-    groupId: groupId || program.defaultGroupId,
+    groupId: groupId ?? program.defaultGroupId,

Optionally, narrow once and reuse (outside the selected lines):

// Align the guard to nullish checks if you adopt ?? (optional):
if (groupId == null && program.defaultGroupId == null) {
  throw new Error("No group ID provided and no default group ID found.");
}
const resolvedGroupId = (groupId ?? program.defaultGroupId) as string;

const group = await getGroupOrThrow({
  programId,
  groupId: resolvedGroupId,
  includeRewardsAndDiscount: true,
});

69-124: Make enrollment + link update atomic with a Prisma transaction

The enrollment update and link update run independently. If one succeeds and the other fails, you can end up in a partially-approved state. Prefer a single transaction to guarantee atomicity.

Example refactor (simplified):

const { programEnrollment, updatedLink } = await prisma.$transaction(async (tx) => {
  const programEnrollment = await tx.programEnrollment.update({
    // ...same where/include
    data: {
      // ...same data
    },
    include: {
      partner: {
        include: {
          users: {
            where: { notificationPreferences: { applicationApproved: true } },
            include: { user: true },
          },
        },
      },
    },
  });

  const updatedLink = link
    ? await tx.link.update({
        where: { id: link.id },
        data: {
          programId,
          partnerId,
          folderId: program.defaultFolderId,
        },
        include: { tags: { select: { tag: true } } },
      })
    : null;

  return { programEnrollment, updatedLink };
});
packages/prisma/schema/program.prisma (1)

34-35: Consider adding explicit relations + onDelete guardrails for defaults

To prevent dangling IDs and accidental deletes, model these as relations with explicit onDelete behavior. Optional, but improves integrity and DX.

Example schema sketch (adjust names/behaviors to your models):

model Program {
  // ...
  defaultFolderId String
  defaultFolder   Folder       @relation(fields: [defaultFolderId], references: [id], onUpdate: Cascade, onDelete: Restrict)
  defaultGroupId  String
  defaultGroup    PartnerGroup @relation(fields: [defaultGroupId], references: [id], onUpdate: Cascade, onDelete: Restrict)

  @@index([defaultFolderId])
  @@index([defaultGroupId])
}

Note: This aligns with the learning that a default partner group always exists in Dub, so making defaultGroupId required is consistent with your domain model.

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

62-80: Pre-existing folder may lack membership for the creating user

You add the user as owner only on the create path. If "Partner Links" already exists, the user won’t be attached via users.create. If that’s a requirement, consider a post-upsert connectOrCreate to ensure access for the current user.

apps/web/lib/partnerstack/import-groups.ts (1)

28-55: Handle pagination and keep group names in sync on upsert

  • If a workspace has > PAGE_LIMIT groups, the current single call will miss additional pages.
  • Upsert update: {} won’t refresh a group’s name if it changes in PartnerStack; recommend syncing the name.

Suggested refactor: iterate through pages using starting_after (using the last item’s key) and update the name on conflict.

-  const groups = await partnerStackApi.listGroups();
-
-  if (groups.length > 0) {
-    for (const group of groups) {
-      // Default group is already created in the program creation
-      if (group.default) {
-        continue;
-      }
-
-      await prisma.partnerGroup.upsert({
-        where: {
-          programId_slug: {
-            programId,
-            slug: group.slug,
-          },
-        },
-        create: {
-          id: createId({ prefix: "grp_" }),
-          programId,
-          name: group.name,
-          slug: group.slug,
-        },
-        update: {},
-      });
-
-      console.log(`Imported group ${group.name}.`);
-    }
-  }
+  let startingAfter: string | undefined = undefined;
+  while (true) {
+    const groups = await partnerStackApi.listGroups({ startingAfter });
+    if (groups.length === 0) {
+      break;
+    }
+    for (const group of groups) {
+      // Default group is already created in the program creation
+      if (group.default) {
+        continue;
+      }
+      await prisma.partnerGroup.upsert({
+        where: {
+          programId_slug: {
+            programId,
+            slug: group.slug,
+          },
+        },
+        create: {
+          id: createId({ prefix: "grp_" }),
+          programId,
+          name: group.name,
+          slug: group.slug,
+        },
+        update: {
+          // Keep name in sync if it changed in PartnerStack
+          name: group.name,
+        },
+      });
+      console.log(`Imported group ${group.name}.`);
+    }
+    startingAfter = groups[groups.length - 1]?.key;
+  }
apps/web/lib/actions/partners/bulk-archive-partners.ts (1)

18-38: Use typed enum for status and avoid redundant writes

  • Replace string literals "archived" with ProgramEnrollmentStatus.archived.
  • Also filter updateMany to skip already-archived rows to reduce no-op writes.
 import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log";
 import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
 import { bulkArchivePartnersSchema } from "@/lib/zod/schemas/partners";
 import { prisma } from "@dub/prisma";
+import { ProgramEnrollmentStatus } from "@dub/prisma/client";
 import { waitUntil } from "@vercel/functions";
   const programEnrollments = await prisma.programEnrollment.findMany({
     where: {
       partnerId: {
         in: partnerIds,
       },
       programId,
       status: {
-        not: "archived",
+        not: ProgramEnrollmentStatus.archived,
       },
     },
   await prisma.programEnrollment.updateMany({
     where: {
       partnerId: {
         in: partnerIds,
       },
       programId,
+      status: {
+        not: ProgramEnrollmentStatus.archived,
+      },
     },
     data: {
-      status: "archived",
+      status: ProgramEnrollmentStatus.archived,
     },
   });

Also applies to: 44-53, 3-7

apps/web/lib/actions/partners/bulk-ban-partners.ts (3)

66-81: Constrain updateMany to skip already-banned rows

Adding a status filter avoids redundant writes and reduces row-level churn.

   await prisma.$transaction([
     prisma.programEnrollment.updateMany({
       where: {
         ...commonWhere,
+        status: {
+          not: ProgramEnrollmentStatus.banned,
+        },
       },
       data: {
         status: ProgramEnrollmentStatus.banned,
         bannedAt: new Date(),
         bannedReason: parsedInput.reason,
         groupId: null,

14-16: Unify enum import source for consistency

Elsewhere in the codebase enums/types come from @dub/prisma/client. Prefer importing ProgramEnrollmentStatus from the same package.

-import { ProgramEnrollmentStatus } from "@prisma/client";
+import { ProgramEnrollmentStatus } from "@dub/prisma/client";

164-185: Guard email-sending with try/catch to isolate failures

Non-critical email failures shouldn’t jeopardize the rest of the background tasks. recordAuditLog already handles its own errors; mirror that for resend.

-        await resend.batch.send(
-          programEnrollments
-            .filter(({ partner }) => partner.email)
-            .map(({ partner }) => ({
-              from: VARIANT_TO_FROM_MAP.notifications,
-              to: partner.email!,
-              subject: `You've been banned from the ${program.name} Partner Program`,
-              variant: "notifications",
-              react: PartnerBanned({
-                partner: {
-                  name: partner.name,
-                  email: partner.email!,
-                },
-                program: {
-                  name: program.name,
-                  supportEmail: program.supportEmail || "[email protected]",
-                },
-                bannedReason: BAN_PARTNER_REASONS[parsedInput.reason],
-              }),
-            })),
-        );
+        try {
+          await resend.batch.send(
+            programEnrollments
+              .filter(({ partner }) => partner.email)
+              .map(({ partner }) => ({
+                from: VARIANT_TO_FROM_MAP.notifications,
+                to: partner.email!,
+                subject: `You've been banned from the ${program.name} Partner Program`,
+                variant: "notifications",
+                react: PartnerBanned({
+                  partner: {
+                    name: partner.name,
+                    email: partner.email!,
+                  },
+                  program: {
+                    name: program.name,
+                    supportEmail: program.supportEmail || "[email protected]",
+                  },
+                  bannedReason: BAN_PARTNER_REASONS[parsedInput.reason],
+                }),
+              })),
+          );
+        } catch (err) {
+          console.error("Failed to send ban emails", err);
+        }
apps/web/lib/partnerstack/import-partners.ts (1)

32-34: Type groupMap to catch missing slugs at compile time

Typing groupMap surfaces unknown slug lookups during development and reduces runtime surprises.

-  const groupMap = Object.fromEntries(
-    program.groups.map((group) => [group.slug, group]),
-  );
+  const groupMap = program.groups.reduce<Record<string, PartnerGroup>>(
+    (acc, group) => {
+      acc[group.slug] = group;
+      return acc;
+    },
+    {},
+  );
📜 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 26ea0e8 and d4a26c1.

📒 Files selected for processing (16)
  • apps/web/app/(ee)/api/cron/import/partnerstack/route.ts (2 hunks)
  • apps/web/lib/actions/partners/archive-partner.ts (1 hunks)
  • apps/web/lib/actions/partners/bulk-archive-partners.ts (1 hunks)
  • apps/web/lib/actions/partners/bulk-ban-partners.ts (1 hunks)
  • apps/web/lib/actions/partners/create-program.ts (2 hunks)
  • apps/web/lib/actions/partners/start-partnerstack-import.ts (1 hunks)
  • apps/web/lib/actions/partners/update-program.ts (0 hunks)
  • apps/web/lib/partners/approve-partner-enrollment.ts (1 hunks)
  • apps/web/lib/partnerstack/api.ts (2 hunks)
  • apps/web/lib/partnerstack/import-groups.ts (1 hunks)
  • apps/web/lib/partnerstack/import-partners.ts (8 hunks)
  • apps/web/lib/partnerstack/schemas.ts (3 hunks)
  • apps/web/lib/partnerstack/types.ts (2 hunks)
  • apps/web/lib/zod/schemas/partners.ts (1 hunks)
  • apps/web/lib/zod/schemas/programs.ts (1 hunks)
  • packages/prisma/schema/program.prisma (1 hunks)
💤 Files with no reviewable changes (1)
  • apps/web/lib/actions/partners/update-program.ts
🧰 Additional context used
🧠 Learnings (3)
📚 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:

  • packages/prisma/schema/program.prisma
  • apps/web/lib/partnerstack/import-partners.ts
📚 Learning: 2025-08-16T11:14:00.629Z
Learnt from: devkiran
PR: dubinc/dub#2754
File: apps/web/lib/partnerstack/schemas.ts:47-52
Timestamp: 2025-08-16T11:14:00.629Z
Learning: The PartnerStack API always includes the `group` field in partner responses, so the schema should use `.nullable()` rather than `.nullish()` since the field is never omitted/undefined.

Applied to files:

  • apps/web/lib/partnerstack/schemas.ts
  • apps/web/lib/partnerstack/import-partners.ts
📚 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/lib/partnerstack/import-partners.ts
🧬 Code Graph Analysis (9)
apps/web/lib/actions/partners/bulk-archive-partners.ts (3)
apps/web/lib/actions/safe-action.ts (1)
  • authActionClient (33-82)
apps/web/lib/zod/schemas/partners.ts (1)
  • bulkArchivePartnersSchema (641-648)
apps/web/lib/api/audit-logs/record-audit-log.ts (1)
  • recordAuditLog (43-63)
apps/web/lib/partnerstack/types.ts (1)
apps/web/lib/partnerstack/schemas.ts (1)
  • partnerStackGroup (25-30)
apps/web/lib/partnerstack/import-groups.ts (4)
apps/web/lib/partnerstack/types.ts (1)
  • PartnerStackImportPayload (18-20)
apps/web/lib/partnerstack/importer.ts (1)
  • partnerStackImporter (47-47)
apps/web/lib/partnerstack/api.ts (1)
  • PartnerStackApi (19-138)
apps/web/lib/api/create-id.ts (1)
  • createId (57-66)
apps/web/app/(ee)/api/cron/import/partnerstack/route.ts (1)
apps/web/lib/partnerstack/import-groups.ts (1)
  • importGroups (7-61)
apps/web/lib/actions/partners/bulk-ban-partners.ts (4)
apps/web/lib/actions/safe-action.ts (1)
  • authActionClient (33-82)
apps/web/lib/zod/schemas/partners.ts (2)
  • bulkBanPartnersSchema (621-634)
  • BAN_PARTNER_REASONS (51-58)
packages/prisma/client.ts (1)
  • ProgramEnrollmentStatus (18-18)
apps/web/lib/api/audit-logs/record-audit-log.ts (1)
  • recordAuditLog (43-63)
apps/web/lib/zod/schemas/partners.ts (1)
packages/prisma/client.ts (1)
  • PartnerBannedReason (10-10)
apps/web/lib/partnerstack/api.ts (2)
apps/web/lib/partnerstack/types.ts (2)
  • PartnerStackListResponse (12-16)
  • PartnerStackGroup (22-22)
apps/web/lib/partnerstack/schemas.ts (1)
  • partnerStackGroup (25-30)
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 (24-24)
apps/web/lib/actions/partners/create-program.ts (1)
apps/web/lib/api/create-id.ts (1)
  • createId (57-66)
🔇 Additional comments (15)
apps/web/lib/partners/approve-partner-enrollment.ts (2)

77-85: Double-check updating createdAt on approval

Setting createdAt during an update usually overwrites the original creation timestamp. If this is intended to track approval time, consider a dedicated approvedAt field or rely on updatedAt.

Potential fix if unintended:

-        createdAt: new Date(),

111-114: Confirm defaultFolderId is guaranteed non-null

folderId is set to program.defaultFolderId. Given recent schema changes, this should be non-null at runtime, but ensure the Prisma type reflects that to avoid TS drift and accidental null writes.

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

189-194: Switch to PartnerStack "import-groups" step looks correct

This aligns with the new two-phase import flow (groups → partners) and matches the updated schemas and cron route.

apps/web/lib/partnerstack/schemas.ts (3)

3-10: New import step "import-groups" is consistent with the staged import pipeline

Enum addition is straightforward and unblocks the group-first import.


25-30: partnerStackGroup schema LGTM

Fields and types match expected API shape for groups.


47-51: group field on partner schema uses .nullable() — matches PartnerStack contract

This aligns with the stated API behavior (group present but possibly null). Optional chaining downstream remains fine.

apps/web/lib/actions/partners/start-partnerstack-import.ts (1)

49-54: Queuing "import-groups" to kick off the PartnerStack flow is correct

Credentials are set first, connection is tested, and the new staged action is queued. Looks good.

apps/web/lib/actions/partners/archive-partner.ts (1)

27-30: No-op composite key field order — safe change

Swapping the field order inside the composite unique key selector is a no-op in Prisma; keys are matched by name, not order. The update will behave identically. LGTM.

apps/web/lib/partnerstack/types.ts (1)

22-23: Good addition: exported PartnerStackGroup type

Exporting the PartnerStackGroup type derived from the Zod schema keeps types cohesive with validation. This aligns with existing patterns in this file.

apps/web/app/(ee)/api/cron/import/partnerstack/route.ts (1)

26-28: New "import-groups" action wiring looks correct

  • Import added and switch case correctly invokes importGroups(payload) before other steps.
  • Schema-validated payload used consistently.

No issues spotted here.

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

56-76: Background audit logging via waitUntil looks solid

Good use of waitUntil to decouple side effects from the request lifecycle; batching audit log writes per partner is appropriate here.

apps/web/lib/zod/schemas/partners.ts (2)

621-634: Bulk ban schema looks correct and robust

  • Deduplication + bounds on partnerIds.
  • reason constrained to BAN_PARTNER_REASONS keys with correct typing against PartnerBannedReason.

No changes requested.


641-648: Bulk archive schema LGTM

  • Matches the ban schema’s partnerIds constraints and deduplication.
  • Aligns with action input.
apps/web/lib/partnerstack/import-partners.ts (2)

144-155: Country code resolution is thoughtful and resilient

  • Code-vs-name resolution with COUNTRY_CODES and COUNTRY_NAME_TO_CODE handles PartnerStack variability well.
  • Logging on unresolved countries is helpful; consider using your centralized logger if available.

Also applies to: 16-18


76-106: Confirm Prisma relation and refine orphan-group deletion

  • Double-check that the partners field in the where: { partners: { none: {} } } filter matches the actual relation name on the PartnerGroup model in your Prisma schema. If the relation is named differently (for example, programEnrollments or memberships), update the clause accordingly:

     // before
           slug: { not: DEFAULT_PARTNER_GROUP.slug },
  •    partners: { none: {} },
    
  •    programEnrollments: { none: {} }, // ← use the actual relation field
    
    
    
  • To avoid removing intentionally-empty groups, narrow the deletion scope. For instance:

    • Only delete groups tagged or flagged as “import-generated” (e.g., add a source = "PartnerStack" field during import).
    • Or record created group IDs in this run and delete only those with no partners.
    • Example pattern:
    // Tag groups on creation
    await prisma.partnerGroup.create({ data: {, source: "PartnerStack" } });
    
    // Later cleanup
    await prisma.partnerGroup.deleteMany({
      where: {
        source: "PartnerStack",
        partners: { none: {} },
        slug: { not: DEFAULT_PARTNER_GROUP.slug },
      },
    });

This will ensure you only remove true “orphan” groups from the current import without affecting legitimate empty buckets.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants