+
Skip to content

Conversation

steven-tey
Copy link
Collaborator

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

Summary by CodeRabbit

  • New Features

    • Full Partner Groups UI — pages, tables, create/edit/delete modals, group-specific rewards & discounts, and group-aware public apply pages.
  • Enhancements

    • Group-scoped workflows: filters, counts, exports, commissions, enrollments/invites, approvals, and cache invalidation now support partner groups.
    • UX: Shift+Click multi-row selection, keyboard shortcut for creating groups, bulk/group change and deletion flows.
  • Chores

    • Billing now tracks workspace groups limit; migration scripts and data backfills added.

Copy link
Contributor

vercel bot commented Aug 9, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Aug 15, 2025 5:08pm

Copy link
Contributor

coderabbitai bot commented Aug 9, 2025

Walkthrough

Adds partner Groups across backend and frontend: new group schemas/types, CRUD and count APIs, group-aware partner enrollment/invite/approve flows, group-scoped rewards/discounts and cache invalidation, dashboard group UIs/hooks, migrations/backfills, removal of legacy reward-centric endpoints/hooks, and UI/UX updates (tables, sidebar, colors).

Changes

Cohort / File(s) Summary
Groups API & helpers
apps/web/app/(ee)/api/groups/route.ts, apps/web/app/(ee)/api/groups/count/route.ts, apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts, apps/web/lib/api/groups/get-group-or-throw.ts, apps/web/lib/api/groups/get-groups.ts
Add Group schemas/types and routes: list, create, get, update, delete, count; getGroupOrThrow resolves by id or slug; getGroups implements SQL-backed listing with optional expanded metrics.
Change partners' group
apps/web/app/(ee)/api/groups/[groupIdOrSlug]/partners/route.ts
New POST route to assign partners to a group: dedupe partnerIds, update enrollments (group + group reward/discount IDs), return count, enqueue cache invalidation job.
Partners list/export & filters
apps/web/app/(ee)/api/partners/route.ts, apps/web/app/(ee)/api/partners/count/route.ts, apps/web/app/(ee)/api/partners/export/route.ts, apps/web/lib/api/partners/get-partners.ts
Remove reward-id filters; add groupId filter and selection; partner rows now include groupId; count/export/list support group filtering and new groupBy option.
Commissions endpoints
apps/web/app/(ee)/api/commissions/route.ts, apps/web/app/(ee)/api/commissions/count/route.ts, apps/web/lib/zod/schemas/commissions.ts
Add optional groupId query param and conditional Prisma where spread to filter by programId+groupId when provided.
Cron: invalidate caches by group
apps/web/app/(ee)/api/cron/links/invalidate-for-discounts/route.ts
Rework cron to accept groupId (and optional partnerIds), aggregate links for the group, chunk and expire via linkCache.expireMany, remove Redis usage, add logging and uniform error handling.
Group-scoped rewards & discounts actions
apps/web/lib/actions/partners/{create-reward,update-reward,delete-reward,create-discount,update-discount,delete-discount}.ts
Convert reward/discount flows to group-centric semantics: use groupId, transactional updates to partnerGroup and enrollments, remove per-partner include/exclude/default semantics, update cache invalidation payloads to groupId.
Enrollment & approval internals
apps/web/lib/partners/approve-partner-enrollment.ts, apps/web/lib/api/partners/create-and-enroll-partner.ts, apps/web/lib/partners/complete-program-applications.ts
Enroll/approve flows resolve group (fallback to program.defaultGroupId), persist groupId and group-derived reward/discount IDs, and use group context for link creation, emails, and webhooks.
Bulk approve / action refactors
apps/web/lib/actions/partners/bulk-approve-partners.ts (new), apps/web/lib/partners/bulk-approve-partners.ts (deleted)
Replace prior bulk-approve helper with an inlined action that handles approvals, batched emails, link creation, webhooks and audit logs using group logic; removed legacy bulk helper file.
Invite / accept flows
apps/web/lib/actions/partners/invite-partner.ts, apps/web/lib/actions/partners/accept-program-invite.ts, apps/web/app/(ee)/partners.dub.co/.../program-sidebar.tsx
Invite now accepts groupId (or uses program.defaultGroupId); accept action input simplified to only programId; UI payloads adjusted accordingly.
Removed reward-centric endpoints/hooks
apps/web/app/(ee)/api/programs/[programId]/{discounts,rewards}/partners/route.ts (deleted), apps/web/lib/partners/get-program-application-rewards.ts (deleted), apps/web/lib/swr/{use-reward-partners,use-discount-partners}.ts (deleted)
Remove legacy reward-partner listing endpoints and SWR hooks in favor of group-scoped flows.
Groups UI (dashboard)
apps/web/app/app.dub.co/(dashboard)/*/program/groups/**
Add group pages/layout/header/tabs, GroupsTable, CreateGroupButton/CreateGroupModal, DeleteGroupModal, GroupSettings, GroupRewards, GroupDiscount, and SWR hooks (useGroups, useGroupsCount, useGroup).
Partners UI updates
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/{partners-table.tsx,invite-partner-sheet.tsx,applications/page-client.tsx}
Add Group column with GroupColorCircle, per-row & bulk ChangeGroup modal, Group selector in invite sheet, and modal-driven bulk-approve flow; selection and bulk actions adapted for groups.
Filters & client hooks
apps/web/app/(ee)/program/{use-partner-filters.tsx,use-commission-filters.tsx}, apps/web/lib/swr/use-api-mutation.ts, apps/web/lib/swr/{use-groups,use-groups-count,use-group}.ts
Replace reward-based filters with Group filter, add useGroups, useGroupsCount, useGroup, and reusable useApiMutation hook.
Schemas, types, RBAC, workspace limits
apps/web/lib/zod/schemas/{groups,partners,discount,rewards,programs,token,workspaces}.ts, apps/web/lib/types.ts, apps/web/lib/api/errors.ts, apps/web/lib/api/rbac/permissions.ts
Introduce group schemas/types, add groupId across schemas, remove default/isDefault and per-partner lists, add WorkspaceSchema.groupsLimit, export GroupProps types, RBAC: groups.read/groups.write, and allow "groups" in exceededLimitError.
getProgram / lander / OG changes
apps/web/lib/fetchers/get-program.ts, apps/web/lib/partners/get-group-rewards-and-discount.ts, apps/web/lib/actions/partners/generate-lander.ts, apps/web/app/api/og/program/route.tsx
getProgram now accepts groupSlug and returns group plus group-scoped rewards/discount; add helper to derive group rewards/discount; switch lander/OG generation to group-scoped data.
Create-id, migrations & scripts
apps/web/lib/api/create-id.ts, apps/web/scripts/migrations/*
Add 'grp_' id prefix and migration scripts to backfill partner groups, application.groupId, and workspace groupsLimit; adjust import paths in several scripts.
Colors / tags / UI color model
apps/web/ui/colors.ts, apps/web/ui/links/{tag-badge,multi-tags-icon}.tsx, apps/web/ui/modals/add-edit-tag-modal.tsx, apps/web/lib/types.ts, apps/web/lib/zod/schemas/tags.ts, apps/web/tests/tags/create-tag-error.test.ts
Introduce RESOURCE_COLORS_DATA/RESOURCE_COLORS and ResourceColorsEnum; switch tag-badge and tag UIs to resource colors; update tests and schemas.
UI layout & sidebar
apps/web/ui/layout/page-content/index.tsx, apps/web/ui/layout/sidebar/*
PageContent accepts headerContent; sidebar adds Groups nav item and an arrow flag for external links; some nav targets updated to group-scoped routes.
Table selection UX
packages/ui/src/table/table.tsx
Add Shift+Click contiguous multi-row selection and update select cell signature to receive table instance.
Misc & small changes
apps/web/app/(ee)/api/programs/[programId]/applications/[applicationId]/route.ts, apps/web/app/api/tags/route.ts, apps/web/app/(ee)/api/tokens/embed/referrals/route.ts, apps/web/lib/api/audit-logs/schemas.ts, tests
Include partnerGroup on application GET, simplify tag color fallback, referrals token accepts groupId, audit logs accept group targets/actions, and minor test/schema adjustments.

Sequence Diagram(s)

sequenceDiagram
  participant UI as Dashboard: Create Group
  participant API as /api/groups
  participant DB as Prisma
  UI->>API: POST /api/groups { name, slug, color }
  API->>DB: prisma.partnerGroup.create({ data: {..., programId, name, slug, color} })
  DB-->>API: created group
  API-->>UI: 201 { group }
Loading
sequenceDiagram
  participant UI as Dashboard: Change Group (bulk)
  participant API as /api/groups/[groupIdOrSlug]/partners
  participant DB as Prisma
  participant Cron as /api/cron/links/invalidate-for-discounts
  UI->>API: POST { partnerIds[] }
  API->>DB: updateMany enrollments (where partnerId in ..., programId) set groupId & reward/discount IDs
  DB-->>API: { count }
  alt count > 0
    API->>Cron: POST { groupId, partnerIds }
    Cron->>DB: fetch enrollments/links for group/partners
    Cron->>Cron: chunk links
    Cron->>linkCache: expireMany(batch)
    Cron-->>API: OK
  end
  API-->>UI: { count }
Loading
sequenceDiagram
  participant Public as Apply Page
  participant Fetch as getProgram({ slug, groupSlug })
  participant DB as Prisma
  Public->>Fetch: getProgram({ slug, groupSlug })
  Fetch->>DB: select program + groups where slug = groupSlug include group rewards/discount
  DB-->>Fetch: program with group and rewards
  Fetch-->>Public: program + group-scoped rewards/discount
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

"I nibble code in moonlit rows,
Groups in colors, tidy rows.
Links expire and rewards find home,
I hop and patch the schema loam.
A carrot cheer — new groups have sprung,
The rabbit hums, the deploy is sung." 🥕

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch partner-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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🔭 Outside diff range comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/use-partner-filters.tsx (1)

144-155: Fix stale searchQuery when only “search” changes

searchQuery depends on searchParamsObj.search but it’s not in the deps array, causing stale URLs when users change only the search term.

-    [activeFilters, workspaceId, extraSearchParams],
+    [activeFilters, workspaceId, searchParamsObj.search, extraSearchParams],
♻️ Duplicate comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/use-partner-filters.tsx (1)

93-111: Bug: Group filter options should default to [] (not null) + avoid O(n*m) lookup; also guard permalink when slug is undefined

Returning null can break consumers expecting an array (other filters return []). Also, the count lookup does a linear search per group. Use a precomputed Map for O(1). Lastly, permalink should be conditional on slug to avoid "/undefined/..." during initial load.

Apply this diff:

       {
         key: "groupId",
         icon: Users6,
         label: "Group",
         options:
-          groups?.map((group) => {
-            const count = groupCount?.find(
-              ({ groupId }) => groupId === group.id,
-            )?._count;
-
-            return {
-              value: group.id,
-              label: group.name,
-              icon: <GroupColorCircle group={group} />,
-              right: nFormatter(count || 0, { full: true }),
-              permalink: `/${slug}/program/groups/${group.slug}/rewards`,
-            };
-          }) ?? null,
+          groups?.map((group) => {
+            const count = groupCountMap.get(group.id) || 0;
+            return {
+              value: group.id,
+              label: group.name,
+              icon: <GroupColorCircle group={group} />,
+              right: nFormatter(count, { full: true }),
+              permalink: slug
+                ? `/${slug}/program/groups/${group.slug}/rewards`
+                : undefined,
+            };
+          }) ?? [],
       },

Place this near other useMemo hooks (after fetching groupCount, before const filters = useMemo(...)):

const groupCountMap = useMemo(
  () =>
    new Map<string, number>(
      (groupCount ?? []).map(({ groupId, _count }) => [groupId, _count]),
    ),
  [groupCount],
);
🧹 Nitpick comments (4)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/use-partner-filters.tsx (1)

113-114: Include slug in filters useMemo deps

slug is used in the permalink; add it to the dependency array to prevent stale permalinks after workspace changes.

-    [countriesCount, statusCount, groupCount, groups],
+    [countriesCount, statusCount, groupCount, groups, slug],
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/groups-table.tsx (3)

30-168: Consider memoizing table data transformation for better performance.

The table data transformation on lines 54-60 includes router prefetching for each group. This operation runs on every render when groups change. Consider memoizing this transformation to avoid unnecessary prefetch calls and improve performance.

+import { useState, useMemo } from "react";

 export function GroupsTable() {
   const router = useRouter();
   const { slug } = useWorkspace();
   const { pagination, setPagination } = usePagination();
   const { queryParams, searchParams } = useRouterStuff();

   const sortBy = searchParams.get("sortBy") || "saleAmount";
   const sortOrder = searchParams.get("sortOrder") === "asc" ? "asc" : "desc";

   const { groups, loading, error } = useGroups<GroupExtendedProps>({
     query: {
       includeExpandedFields: true,
     },
   });

   const {
     groupsCount,
     loading: countLoading,
     error: countError,
   } = useGroupsCount();

   const isFiltered = !!searchParams.get("search");

+  const tableData = useMemo(() => {
+    if (!groups) return [];
+    
+    return groups.map((group) => {
+      // prefetch the group page
+      router.prefetch(`/${slug}/program/groups/${group.slug}/rewards`);
+      return group;
+    });
+  }, [groups, router, slug]);

   const { table, ...tableProps } = useTable({
-    data: groups
-      ? groups.map((group) => {
-          // prefetch the group page
-          router.prefetch(`/${slug}/program/groups/${group.slug}/rewards`);
-          return group;
-        })
-      : [],
+    data: tableData,

200-262: Extract DeleteGroupModal to component root to avoid re-instantiation.

The DeleteGroupModal component is being instantiated inside the RowMenuButton component on every render. This could lead to unnecessary re-renders and potential state loss. Consider lifting it to a parent component or managing it with a single modal instance at the table level.

Additionally, there's an inconsistency in the slug parameter usage - line 202 uses useParams() while the parent component gets slug from useWorkspace(). This could lead to issues if these values differ.

 function RowMenuButton({ row }: { row: Row<GroupExtendedProps> }) {
   const router = useRouter();
-  const { slug } = useParams();
+  const { slug } = useWorkspace();
   const [isOpen, setIsOpen] = useState(false);

   const { DeleteGroupModal, setShowDeleteGroupModal } = useDeleteGroupModal(
     row.original,
   );

264-301: Consider using the existing MenuItem component from @dub/ui.

You're defining a custom MenuItem component here, but based on the relevant code snippets, there's already a MenuItem component available in the @dub/ui package that supports icons and variants. Consider using the existing component to maintain consistency across the codebase.

-function MenuItem({
-  icon: IconComp,
-  label,
-  onSelect,
-  variant = "default",
-}: {
-  icon: Icon;
-  label: string;
-  onSelect: () => void;
-  variant?: "default" | "danger";
-}) {
-  const variantStyles = {
-    default: {
-      text: "text-neutral-600",
-      icon: "text-neutral-500",
-    },
-    danger: {
-      text: "text-red-600",
-      icon: "text-red-600",
-    },
-  };
-
-  const { text, icon } = variantStyles[variant];
-
-  return (
-    <Command.Item
-      className={cn(
-        "flex cursor-pointer select-none items-center gap-2 whitespace-nowrap rounded-md p-2 text-sm",
-        "data-[selected=true]:bg-neutral-100",
-        text,
-      )}
-      onSelect={onSelect}
-    >
-      <IconComp className={cn("size-4 shrink-0", icon)} />
-      {label}
-    </Command.Item>
-  );
-}

Import and use the existing MenuItem from @dub/ui:

 import {
   Button,
   EditColumnsButton,
   Icon,
+  MenuItem,
   Popover,
   StatusBadge,
   Table,
   usePagination,
   useRouterStuff,
   useTable,
 } from "@dub/ui";

Then update the usage in RowMenuButton:

-              <MenuItem
-                icon={PenWriting}
-                label="Edit group"
-                variant="default"
-                onSelect={() =>
+              <Command.Item
+                onSelect={() =>
                   router.push(
                     `/${slug}/program/groups/${row.original.slug}/settings`,
                   )
                 }
-              />
+              >
+                <MenuItem icon={PenWriting} variant="default">
+                  Edit group
+                </MenuItem>
+              </Command.Item>
📜 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 ee48405 and 1f1c45b.

📒 Files selected for processing (4)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/use-commission-filters.tsx (5 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/groups-table.tsx (1 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/use-partner-filters.tsx (4 hunks)
  • apps/web/lib/zod/schemas/groups.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/lib/zod/schemas/groups.ts
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/use-commission-filters.tsx
🧰 Additional context used
🧬 Code Graph Analysis (2)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/use-partner-filters.tsx (3)
apps/web/lib/swr/use-workspace.ts (1)
  • useWorkspace (6-45)
apps/web/lib/swr/use-groups.ts (1)
  • useGroups (11-45)
apps/web/ui/partners/groups/group-color-circle.tsx (1)
  • GroupColorCircle (5-24)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/groups-table.tsx (11)
apps/web/lib/swr/use-workspace.ts (1)
  • useWorkspace (6-45)
apps/web/lib/swr/use-groups.ts (1)
  • useGroups (11-45)
apps/web/lib/types.ts (1)
  • GroupExtendedProps (511-511)
apps/web/lib/swr/use-groups-count.ts (1)
  • useGroupsCount (10-35)
packages/ui/src/table/table.tsx (2)
  • useTable (48-240)
  • Table (325-638)
apps/web/ui/partners/groups/group-color-circle.tsx (1)
  • GroupColorCircle (5-24)
apps/web/lib/zod/schemas/groups.ts (1)
  • DEFAULT_PARTNER_GROUP (9-13)
apps/web/ui/shared/animated-empty-state.tsx (1)
  • AnimatedEmptyState (8-77)
apps/web/ui/modals/delete-group-modal.tsx (1)
  • useDeleteGroupModal (149-173)
packages/ui/src/menu-item.tsx (1)
  • MenuItem (38-70)
packages/ui/src/icons/index.tsx (1)
  • Icon (79-79)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (3)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/use-partner-filters.tsx (2)

36-44: LGTM: partners count grouped by groupId

Using groupBy: "groupId" aligns with the new group-centric model and keeps the API surface consistent with other filters.


108-109: No change needed — the /program/groups/<slug>/rewards permalink is valid

Found multiple references and an implementation confirming the route still exists — leave the permalink as-is.

  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/rewards/group-rewards.tsx — rewards page implementation
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/groups-table.tsx — router.prefetch / router.push to .../rewards
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/group-header.tsx — getHref -> .../rewards
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/use-commission-filters.tsx — permalink uses .../rewards
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/use-partner-filters.tsx — permalink uses .../rewards

Permalink snippet (no change):
permalink: /${slug}/program/groups/${group.slug}/rewards

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/groups-table.tsx (1)

178-195: LGTM! Clean empty state handling.

The conditional rendering for empty state vs table is well-implemented, with appropriate messaging for filtered vs unfiltered scenarios.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

🔭 Outside diff range comments (2)
apps/web/lib/actions/partners/reject-partner.ts (1)

36-53: Prevent TOCTOU race: guard the update with a status predicate.

Between the pending-status check and the update, another process could transition the enrollment (e.g., approve). Updating by id alone could then clear fields for a non-pending enrollment. Guard the update itself with status: pending and handle the 0-count case.

Apply this diff:

-    await prisma.programEnrollment.update({
-      where: {
-        id: programEnrollment.id,
-      },
-      data: {
-        status: ProgramEnrollmentStatus.rejected,
-        groupId: null,
-        clickRewardId: null,
-        leadRewardId: null,
-        saleRewardId: null,
-        discountId: null,
-      },
-    });
+    const { count } = await prisma.programEnrollment.updateMany({
+      where: {
+        id: programEnrollment.id,
+        status: ProgramEnrollmentStatus.pending,
+      },
+      data: {
+        status: ProgramEnrollmentStatus.rejected,
+        groupId: null,
+        clickRewardId: null,
+        leadRewardId: null,
+        saleRewardId: null,
+        discountId: null,
+      },
+    });
+    if (count === 0) {
+      throw new Error("Program enrollment is not pending.");
+    }
apps/web/lib/actions/partners/bulk-reject-partners.ts (1)

38-52: Avoid race by restricting the bulk update to currently-pending rows.

Ids were fetched with status: pending earlier, but statuses can change between the read and write. Add a status predicate to the updateMany to avoid mutating non-pending enrollments.

Apply this diff:

     await prisma.programEnrollment.updateMany({
       where: {
-        id: {
-          in: programEnrollments.map(({ id }) => id),
-        },
+        status: ProgramEnrollmentStatus.pending,
+        id: { in: programEnrollments.map(({ id }) => id) },
       },
       data: {
         status: ProgramEnrollmentStatus.rejected,
         groupId: null,
         clickRewardId: null,
         leadRewardId: null,
         saleRewardId: null,
         discountId: null,
       },
     });
♻️ Duplicate comments (2)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/use-partner-filters.tsx (2)

36-44: Precompute group counts for O(1) lookups

Build a Map once so option rendering is O(n) instead of O(n·m).

Add this immediately after the usePartnersCount for groupId:

   >({
     groupBy: "groupId",
   });
+
+  const groupCountMap = useMemo(
+    () =>
+      new Map(
+        (groupCount ?? []).map(({ groupId, _count }) => [groupId, _count]),
+      ),
+    [groupCount],
+  );

49-66: Fix: options should default to [] (not null) and avoid O(n·m) count lookup

Returning null for options can break consumers that expect an array (other filters return []). Also, the per-option .find(...) is O(n·m). Prefer a precomputed map and return [].

Apply this diff:

-        options:
-          groups?.map((group) => {
-            const count = groupCount?.find(
-              ({ groupId }) => groupId === group.id,
-            )?._count;
-
-            return {
-              value: group.id,
-              label: group.name,
-              icon: <GroupColorCircle group={group} />,
-              right: nFormatter(count || 0, { full: true }),
-              permalink: `/${slug}/program/groups/${group.slug}/rewards`,
-            };
-          }) ?? null,
+        options:
+          groups?.map((group) => {
+            const count = groupCountMap.get(group.id) ?? 0;
+
+            return {
+              value: group.id,
+              label: group.name,
+              icon: <GroupColorCircle group={group} />,
+              right: nFormatter(count, { full: true }),
+              permalink: `/${slug}/program/groups/${group.slug}/rewards`,
+            };
+          }) ?? [],
🧹 Nitpick comments (8)
apps/web/lib/actions/partners/reject-partner.ts (1)

32-33: Prefer enum constant over string literal for pending check.

For consistency with the update (and to reduce typo risk), compare against ProgramEnrollmentStatus.pending.

-    if (programEnrollment.status !== "pending") {
+    if (programEnrollment.status !== ProgramEnrollmentStatus.pending) {
apps/web/lib/actions/partners/bulk-reject-partners.ts (2)

20-27: Use enum for pending filter in the read as well.

Keeps consistency and leverages types.

-        status: "pending",
+        status: ProgramEnrollmentStatus.pending,

54-71: Audit log may over-report if some IDs were not updated due to concurrency.

After adding the status predicate to updateMany, a subset of the pre-fetched enrollments may be updated. Consider re-querying the successfully updated enrollments for logging (or log counts) to ensure logs reflect actual changes.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/use-partner-filters.tsx (1)

113-113: Include slug (and groupCountMap, if used) in filters useMemo deps

The filters array derives permalink from slug. Without slug in deps, permalinks can go stale if the workspace slug changes. If you adopt groupCountMap, depend on it instead of groupCount.

Option A (if you add groupCountMap as suggested):

-    [groupCount, groups, statusCount, countriesCount],
+    [groupCountMap, groups, statusCount, countriesCount, slug],

Option B (minimal change):

-    [groupCount, groups, statusCount, countriesCount],
+    [groupCount, groups, statusCount, countriesCount, slug],
apps/web/lib/actions/partners/ban-partner.ts (1)

34-36: Use the enum for the status check to stay consistent

Minor consistency nit: compare against ProgramEnrollmentStatus.banned instead of the raw string.

-    if (programEnrollment.status === "banned") {
+    if (programEnrollment.status === ProgramEnrollmentStatus.banned) {
apps/web/lib/actions/partners/unban-partner.ts (3)

25-28: Minor clarity: add a quick comment explaining the shared filter.

This where object gets reused across multiple models. A short comment helps future readers avoid misinterpreting it as a model-specific filter.

Apply this diff to add context:

-    const where = {
+    // Shared filter for partner+program across queries
+    const where = {
       programId,
       partnerId,
     };

30-38: Reduce query payload: prefer select over include.

You only use program.defaultGroupId, partner metadata (for logs), and status. Selecting those fields trims the response and avoids loading the entire program record.

Apply this diff:

-    const programEnrollment = await prisma.programEnrollment.findUniqueOrThrow({
-      where: {
-        partnerId_programId: where,
-      },
-      include: {
-        program: true,
-        partner: true,
-      },
-    });
+    const programEnrollment = await prisma.programEnrollment.findUniqueOrThrow({
+      where: {
+        partnerId_programId: where,
+      },
+      select: {
+        status: true,
+        program: { select: { defaultGroupId: true } },
+        partner: true,
+      },
+    });

44-49: Use your standard error type for internal invariants.

Instead of a generic Error, consider throwing your canonical API error (e.g., DubApiError) to keep error handling consistent and typed across the stack.

📜 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 1f1c45b and 7896f93.

📒 Files selected for processing (5)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/use-partner-filters.tsx (4 hunks)
  • apps/web/lib/actions/partners/ban-partner.ts (2 hunks)
  • apps/web/lib/actions/partners/bulk-reject-partners.ts (1 hunks)
  • apps/web/lib/actions/partners/reject-partner.ts (2 hunks)
  • apps/web/lib/actions/partners/unban-partner.ts (3 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 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/lib/actions/partners/bulk-reject-partners.ts
  • apps/web/lib/actions/partners/reject-partner.ts
🧬 Code Graph Analysis (4)
apps/web/lib/actions/partners/reject-partner.ts (1)
packages/prisma/client.ts (1)
  • ProgramEnrollmentStatus (18-18)
apps/web/lib/actions/partners/ban-partner.ts (1)
packages/prisma/client.ts (1)
  • ProgramEnrollmentStatus (18-18)
apps/web/lib/actions/partners/unban-partner.ts (1)
apps/web/lib/api/groups/get-group-or-throw.ts (1)
  • getGroupOrThrow (4-51)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/use-partner-filters.tsx (3)
apps/web/lib/swr/use-workspace.ts (1)
  • useWorkspace (6-45)
apps/web/lib/swr/use-groups.ts (1)
  • useGroups (11-45)
apps/web/ui/partners/groups/group-color-circle.tsx (1)
  • GroupColorCircle (5-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 (10)
apps/web/lib/actions/partners/reject-partner.ts (1)

7-7: Good switch to Prisma enum for status.

Using ProgramEnrollmentStatus avoids string literal typos and keeps the update aligned with the Prisma-generated types.

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

46-50: Clearing group/reward/discount fields on rejection — good parity with single action.

This aligns bulk behavior with the single-reject path and avoids dangling associations.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/use-partner-filters.tsx (2)

91-111: Country filter block looks solid

Correct use of guards and defaulting to [] keeps consumers stable. Icon/label getters are consistent.


1-1: Imports for group filtering look correct

The new imports align with the group-based UX and are used appropriately.

Also applies to: 4-4, 7-7

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

15-15: Importing the Prisma enum is the right move

Using ProgramEnrollmentStatus from @prisma/client improves type-safety and avoids magic strings.


56-56: Good switch to enum-backed status

Setting status via ProgramEnrollmentStatus.banned aligns with Prisma enums and prevents typos.


59-64: OK to clear groupId/discountId on ban — fields are nullable in the schema

ProgramEnrollment.groupId and discountId are declared nullable in the Prisma schema and the relations are optional, so updating them to null should succeed.

  • packages/prisma/schema/program.prisma — ProgramEnrollment:
    • line 83: groupId String?
    • line 88: discountId String?
    • relations: partnerGroup PartnerGroup? @relation(fields: [groupId], references: [id]) and discount Discount? @relation(fields: [discountId], references: [id])
  • apps/web/lib/actions/partners/ban-partner.ts — lines ~59-64: sets groupId and discountId to null (snippet below).

Snippet:

          groupId: null,
          clickRewardId: null,
          leadRewardId: null,
          saleRewardId: null,
          discountId: null,

No NOT NULL or required relation constraints in the Prisma model were found that would block this update.

apps/web/lib/actions/partners/unban-partner.ts (3)

4-4: Good call: resolve default group via API helper.

Using getGroupOrThrow centralizes validation (existence + program ownership) and keeps the unban path aligned with group semantics. LGTM.


51-54: LGTM: default group lookup (IDs only) is sufficient here.

Since you only need groupId and reward/discount IDs, skipping reward/discount includes is appropriate. getGroupOrThrow enforces program ownership, which is key here.


72-77: Unban: group + reward/discount IDs — schema verified

Quick summary: I checked the Prisma schema — the ProgramEnrollment fields used in unban are present and nullable, and Link/Commission/Payout support filtering by programId + partnerId. This update path is safe.

Key evidence:

  • packages/prisma/schema/program.prisma
    • ProgramEnrollment: groupId String?; clickRewardId String?; leadRewardId String?; saleRewardId String?; discountId String?
    • Program: defaultGroupId String?
  • packages/prisma/schema/partner.prisma
    • PartnerGroup: clickRewardId String?; leadRewardId String?; saleRewardId String?; discountId String? (these provide defaultGroup.* used by unban)
  • packages/prisma/schema/link.prisma
    • Link: programId String?; partnerId String?; @@index([programId, partnerId])
  • packages/prisma/schema/commission.prisma
    • Commission: programId String; partnerId String; indexes include programId/partnerId and status (filterable)
  • packages/prisma/schema/payout.prisma
    • Payout: programId String; partnerId String; @@index(programId), @@index(partnerId), @@index(status)

Conclusion: no schema changes required — this looks good to me.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (4)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/groups-table.tsx (4)

12-27: Use type-only imports for Icon and Row to avoid accidental runtime imports

Both Icon and Row are types. Importing them as values risks runtime import errors and unnecessary bundle bloat. Switch to import type.

-import {
-  Button,
-  EditColumnsButton,
-  Icon,
-  Popover,
-  StatusBadge,
-  Table,
-  useCopyToClipboard,
-  usePagination,
-  useRouterStuff,
-  useTable,
-} from "@dub/ui";
+import {
+  Button,
+  EditColumnsButton,
+  Popover,
+  StatusBadge,
+  Table,
+  useCopyToClipboard,
+  usePagination,
+  useRouterStuff,
+  useTable,
+} from "@dub/ui";
+import type { Icon } from "@dub/ui";
@@
-import { Row } from "@tanstack/react-table";
+import type { Row } from "@tanstack/react-table";
@@
-import { useState } from "react";
+import { useEffect, useState } from "react";

Also applies to: 29-29


55-63: Avoid prefetching inside render; move to an effect and guard slug

Calling router.prefetch in the render path runs on every render and can spam the router, especially with many rows. Prefetch once when groups/slug change.

-    data: groups
-      ? groups.map((group) => {
-          // prefetch the group page
-          router.prefetch(`/${slug}/program/groups/${group.slug}/rewards`);
-          return group;
-        })
-      : [],
+    data: groups || [],

Add this effect after the useTable call (or anywhere inside the component):

useEffect(() => {
  if (!slug || !groups?.length) return;
  for (const group of groups) {
    router.prefetch(`/${slug}/program/groups/${group.slug}/rewards`);
  }
}, [router, slug, groups]);

138-141: Guard against undefined slug before navigating

On initial render slug can be undefined. A quick guard avoids pushing a malformed route.

-    onRowClick: (row) => {
-      router.push(`/${slug}/program/groups/${row.original.slug}/rewards`);
-    },
+    onRowClick: (row) => {
+      if (!slug) return;
+      router.push(`/${slug}/program/groups/${row.original.slug}/rewards`);
+    },

244-253: Ensure copyToClipboard returns a Promise when used with toast.promise

If useCopyToClipboard returns a synchronous value/boolean, toast.promise won’t behave as expected. If it doesn’t return a Promise, switch to a simple toast.

If needed:

-  toast.promise(copyToClipboard(row.original.id), {
-    success: "Group ID copied!",
-  });
+  const ok = await copyToClipboard(row.original.id);
+  if (ok) toast.success("Group ID copied!");
📜 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 7896f93 and 248eb41.

📒 Files selected for processing (3)
  • apps/web/app/(ee)/api/groups/[groupIdOrSlug]/partners/route.ts (1 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/group-header.tsx (1 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/groups-table.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/group-header.tsx
  • apps/web/app/(ee)/api/groups/[groupIdOrSlug]/partners/route.ts
🧰 Additional context used
🧬 Code Graph Analysis (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/groups-table.tsx (12)
apps/web/lib/swr/use-workspace.ts (1)
  • useWorkspace (6-45)
apps/web/lib/swr/use-groups.ts (1)
  • useGroups (11-45)
apps/web/lib/types.ts (1)
  • GroupExtendedProps (511-511)
apps/web/lib/swr/use-groups-count.ts (1)
  • useGroupsCount (10-35)
packages/ui/src/table/table.tsx (2)
  • useTable (48-240)
  • Table (325-638)
apps/web/ui/partners/groups/group-color-circle.tsx (1)
  • GroupColorCircle (5-24)
apps/web/lib/zod/schemas/groups.ts (1)
  • DEFAULT_PARTNER_GROUP (9-13)
apps/web/ui/shared/animated-empty-state.tsx (1)
  • AnimatedEmptyState (8-77)
apps/web/ui/modals/delete-group-modal.tsx (1)
  • useDeleteGroupModal (149-173)
packages/ui/src/menu-item.tsx (1)
  • MenuItem (38-70)
packages/ui/src/icons/copy.tsx (1)
  • Copy (1-18)
packages/ui/src/icons/index.tsx (1)
  • Icon (79-79)
⏰ 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/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/groups-table.tsx (2)

53-54: Confirm the search param name matches SearchBoxPersisted

isFiltered checks searchParams.get("search"), and SearchBoxPersisted persists the query internally. Confirm that SearchBoxPersisted uses the "search" key; if it uses a different one (e.g., "q"), the empty-state messaging won’t be correct.

Also applies to: 171-178


255-262: Good guard on default group deletion

Hiding Delete group for the default group prevents accidental/invalid deletes. Nice.

Comment on lines +41 to +46
const { groups, loading, error } = useGroups<GroupExtendedProps>({
query: {
includeExpandedFields: true,
},
});

Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Wire UI sorting to the data fetch (remote sorting)

Current UI updates sort params in the URL, but those aren’t passed to useGroups, so the server won’t re-sort. Pass sortBy and sortOrder through the query to keep data and UI in sync.

-  const { groups, loading, error } = useGroups<GroupExtendedProps>({
-    query: {
-      includeExpandedFields: true,
-    },
-  });
+  const { groups, loading, error } = useGroups<GroupExtendedProps>({
+    query: {
+      includeExpandedFields: true,
+      sortBy,
+      sortOrder,
+    },
+  });
📝 Committable suggestion

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

Suggested change
const { groups, loading, error } = useGroups<GroupExtendedProps>({
query: {
includeExpandedFields: true,
},
});
const { groups, loading, error } = useGroups<GroupExtendedProps>({
query: {
includeExpandedFields: true,
sortBy,
sortOrder,
},
});

Comment on lines +82 to +127
id: "partners",
header: "Partners",
accessorFn: (d) => nFormatter(d.partners),
},
{
id: "clicks",
header: "Clicks",
accessorFn: (d) => nFormatter(d.clicks),
},
{
id: "leads",
header: "Leads",
accessorFn: (d) => nFormatter(d.leads),
},
{
id: "conversions",
header: "Conversions",
accessorFn: (d) => nFormatter(d.conversions),
},
{
id: "saleAmount",
header: "Revenue",
accessorFn: (d) =>
currencyFormatter(d.saleAmount / 100, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
},
{
id: "commissions",
header: "Commissions",
accessorFn: (d) =>
currencyFormatter(d.commissions / 100, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
},
{
id: "netRevenue",
header: "Net Revenue",
accessorFn: (d) =>
currencyFormatter(d.netRevenue / 100, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
},
Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

Preserve numeric sorting: use accessorKey + cell (avoid sorting on formatted strings)

The accessorFn returns formatted strings (nFormatter/currencyFormatter). If the table sorts locally, it will sort lexicographically, not numerically. Using accessorKey retains the raw numeric values for sorting while formatting only in the cell renderer.

       {
         id: "partners",
         header: "Partners",
-        accessorFn: (d) => nFormatter(d.partners),
+        accessorKey: "partners",
+        cell: ({ row }) => nFormatter(row.original.partners),
       },
       {
         id: "clicks",
         header: "Clicks",
-        accessorFn: (d) => nFormatter(d.clicks),
+        accessorKey: "clicks",
+        cell: ({ row }) => nFormatter(row.original.clicks),
       },
       {
         id: "leads",
         header: "Leads",
-        accessorFn: (d) => nFormatter(d.leads),
+        accessorKey: "leads",
+        cell: ({ row }) => nFormatter(row.original.leads),
       },
       {
         id: "conversions",
         header: "Conversions",
-        accessorFn: (d) => nFormatter(d.conversions),
+        accessorKey: "conversions",
+        cell: ({ row }) => nFormatter(row.original.conversions),
       },
       {
         id: "saleAmount",
         header: "Revenue",
-        accessorFn: (d) =>
-          currencyFormatter(d.saleAmount / 100, {
-            minimumFractionDigits: 2,
-            maximumFractionDigits: 2,
-          }),
+        accessorKey: "saleAmount",
+        cell: ({ row }) =>
+          currencyFormatter(row.original.saleAmount / 100, {
+            minimumFractionDigits: 2,
+            maximumFractionDigits: 2,
+          }),
       },
       {
         id: "commissions",
         header: "Commissions",
-        accessorFn: (d) =>
-          currencyFormatter(d.commissions / 100, {
-            minimumFractionDigits: 2,
-            maximumFractionDigits: 2,
-          }),
+        accessorKey: "commissions",
+        cell: ({ row }) =>
+          currencyFormatter(row.original.commissions / 100, {
+            minimumFractionDigits: 2,
+            maximumFractionDigits: 2,
+          }),
       },
       {
         id: "netRevenue",
         header: "Net Revenue",
-        accessorFn: (d) =>
-          currencyFormatter(d.netRevenue / 100, {
-            minimumFractionDigits: 2,
-            maximumFractionDigits: 2,
-          }),
+        accessorKey: "netRevenue",
+        cell: ({ row }) =>
+          currencyFormatter(row.original.netRevenue / 100, {
+            minimumFractionDigits: 2,
+            maximumFractionDigits: 2,
+          }),
       },
📝 Committable suggestion

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

Suggested change
id: "partners",
header: "Partners",
accessorFn: (d) => nFormatter(d.partners),
},
{
id: "clicks",
header: "Clicks",
accessorFn: (d) => nFormatter(d.clicks),
},
{
id: "leads",
header: "Leads",
accessorFn: (d) => nFormatter(d.leads),
},
{
id: "conversions",
header: "Conversions",
accessorFn: (d) => nFormatter(d.conversions),
},
{
id: "saleAmount",
header: "Revenue",
accessorFn: (d) =>
currencyFormatter(d.saleAmount / 100, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
},
{
id: "commissions",
header: "Commissions",
accessorFn: (d) =>
currencyFormatter(d.commissions / 100, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
},
{
id: "netRevenue",
header: "Net Revenue",
accessorFn: (d) =>
currencyFormatter(d.netRevenue / 100, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
},
{
id: "partners",
header: "Partners",
accessorKey: "partners",
cell: ({ row }) => nFormatter(row.original.partners),
},
{
id: "clicks",
header: "Clicks",
accessorKey: "clicks",
cell: ({ row }) => nFormatter(row.original.clicks),
},
{
id: "leads",
header: "Leads",
accessorKey: "leads",
cell: ({ row }) => nFormatter(row.original.leads),
},
{
id: "conversions",
header: "Conversions",
accessorKey: "conversions",
cell: ({ row }) => nFormatter(row.original.conversions),
},
{
id: "saleAmount",
header: "Revenue",
accessorKey: "saleAmount",
cell: ({ row }) =>
currencyFormatter(row.original.saleAmount / 100, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
},
{
id: "commissions",
header: "Commissions",
accessorKey: "commissions",
cell: ({ row }) =>
currencyFormatter(row.original.commissions / 100, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
},
{
id: "netRevenue",
header: "Net Revenue",
accessorKey: "netRevenue",
cell: ({ row }) =>
currencyFormatter(row.original.netRevenue / 100, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
},
🤖 Prompt for AI Agents
In
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/groups-table.tsx
around lines 82 to 127, the current accessorFn returns formatted strings causing
lexicographic sorting; change each column to use accessorKey (e.g., "partners",
"clicks", "leads", "conversions", "saleAmount", "commissions", "netRevenue") so
the table sorts by raw numeric values, and move formatting into a cell renderer
that calls getValue() (or cell.getValue()) and then applies nFormatter or
currencyFormatter (dividing amounts by 100 for currency) with the existing
options so display remains the same but numeric sorting uses raw numbers.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (2)
apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/apply/page.tsx (2)

58-58: Passing optional group to ProgramApplicationForm — LGTM (non-null assertion removal addressed)

Previously flagged non-null assertion is gone; the component accepts an optional group id and handles it.


43-45: Improve heading: include human-readable group label

Consider surfacing the group in the hero to set user expectations, using the group name when available, otherwise title-casing the slug for readability.

Apply this diff:

-          <h1 className="text-4xl font-semibold">Apply to {program.name}</h1>
+          <h1 className="text-4xl font-semibold">
+            Apply to {program.name}{" "}
+            {program.group?.name ??
+              partnerGroupSlug
+                .split("-")
+                .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
+                .join(" ")}
+          </h1>
🧹 Nitpick comments (3)
apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/layout.tsx (2)

17-22: Defaulting to the “default” group changes semantics and can 404 when the group doesn’t exist; add a safe fallback.

Previously, calling getProgram without a groupSlug returned the program with empty rewards. Now, forcing DEFAULT_PARTNER_GROUP.slug will cause a 404 for programs that don’t have a “default” group (e.g., during rollout/backfill gaps). Suggest a graceful fallback to the unscoped program when groupSlug wasn’t explicitly provided.

Apply this diff:

-  const partnerGroupSlug = groupSlug ?? DEFAULT_PARTNER_GROUP.slug;
-
-  const program = await getProgram({
-    slug: programSlug,
-    groupSlug: partnerGroupSlug,
-  });
+  const partnerGroupSlug = groupSlug ?? DEFAULT_PARTNER_GROUP.slug;
+
+  let program = await getProgram({
+    slug: programSlug,
+    groupSlug: partnerGroupSlug,
+  });
+  // If we defaulted to the "default" group and it doesn't exist, fall back to unscoped program.
+  if (!program && !groupSlug) {
+    program = await getProgram({ slug: programSlug });
+  }
 
   if (!program) {
     notFound();
   }

Also applies to: 24-26


31-33: Guard looks good; simplify with optional chaining.

Thanks for addressing the previous null/undefined guard. You can reduce verbosity with optional chaining.

-      program.rewards && program.rewards.length > 0
+      program.rewards?.length
         ? formatRewardDescription({ reward: program.rewards[0] }).toLowerCase()
         : "earn commissions"
apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/apply/page.tsx (1)

10-16: Optional groupSlug plumbed correctly; remove unused isDefaultGroup

The optional param pattern works well for sharing this page between /:programSlug/apply and /:programSlug/:groupSlug/apply. The local isDefaultGroup is defined but unused, though—drop it to avoid lint noise.

Apply this diff:

-  const isDefaultGroup = partnerGroupSlug === DEFAULT_PARTNER_GROUP.slug;
📜 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 248eb41 and b997eb5.

📒 Files selected for processing (3)
  • apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/apply/page.tsx (2 hunks)
  • apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/layout.tsx (1 hunks)
  • apps/web/scripts/migrations/backfill-partner-groups.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/scripts/migrations/backfill-partner-groups.ts
🧰 Additional context used
🧬 Code Graph Analysis (2)
apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/layout.tsx (3)
apps/web/lib/zod/schemas/groups.ts (1)
  • DEFAULT_PARTNER_GROUP (9-13)
apps/web/lib/fetchers/get-program.ts (1)
  • getProgram (10-77)
apps/web/ui/partners/format-reward-description.ts (1)
  • formatRewardDescription (4-39)
apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/apply/page.tsx (3)
apps/web/lib/zod/schemas/groups.ts (1)
  • DEFAULT_PARTNER_GROUP (9-13)
apps/web/lib/fetchers/get-program.ts (1)
  • getProgram (10-77)
apps/web/ui/partners/lander/program-application-form.tsx (1)
  • ProgramApplicationForm (25-245)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (7)
apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/layout.tsx (3)

13-16: groupSlug is not a param of this route; it will be undefined at runtime. Confirm intent.

This layout sits under a single dynamic segment [programSlug], so params.groupSlug won’t be populated by Next.js. This means generateMetadata will always treat the group as the default. If that’s intended, all good; otherwise, consider moving group selection to the URL structure or another source.


52-57: Layout fetch is unscoped while metadata fetch is group-scoped; verify this isn’t causing inconsistencies.

generateMetadata uses a (defaulted) groupSlug, but ApplyLayout fetches the unscoped program. If the child routes rely on layout’s program, content/metadata might diverge. If unintended, align the fetch semantics (e.g., derive the same group slug and apply the same fallback).


36-36: Confirmed — PARTNERS_DOMAIN includes a scheme; no change needed

PARTNERS_DOMAIN is defined in packages/utils/src/constants/main.ts and contains an absolute URL (https:// for production/preview, http:// for local), so ${PARTNERS_DOMAIN}/${program.slug} produces an absolute canonical URL.

  • apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/layout.tsx — canonicalUrl: ${PARTNERS_DOMAIN}/${program.slug}
  • packages/utils/src/constants/main.ts — definition of PARTNERS_DOMAIN (includes scheme for all envs)
apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/apply/page.tsx (4)

2-2: DEFAULT_PARTNER_GROUP import for fallback — LGTM

Importing the default group constant here keeps fallback handling consistent with the shared groups schema. No issues.


17-20: Group-scoped fetch wiring looks correct

Passing the resolved slug into getProgram aligns with the group-aware API. Caching should key correctly per (programSlug, groupSlug).


52-54: LanderRewards now bound to program-scoped rewards/discount — LGTM

Props align with the updated getProgram return shape for group context.


22-23: 404 on missing group is intentional — default group is enforced

There's code that creates a default group for new programs and a backfill migration for existing ones, and the group-aware route re-exports this page, so returning 404 when program.group is missing is expected.

  • apps/web/lib/zod/schemas/groups.ts — defines DEFAULT_PARTNER_GROUP (slug "default")
  • apps/web/lib/actions/partners/create-program.ts — creates a default group when a program is created
  • apps/web/scripts/migrations/backfill-partner-groups.ts — backfills/ensures default groups for existing programs
  • apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/[groupSlug]/apply/page.tsx — re-exports ../../apply/page for param propagation
  • apps/web/app/(ee)/partners.dub.co/(apply)/[programSlug]/apply/page.tsx — current notFound() behavior when program.group is absent

@steven-tey steven-tey merged commit da3c40c into main Aug 15, 2025
8 of 9 checks passed
@steven-tey steven-tey deleted the partner-groups branch August 15, 2025 17:33
This was referenced Aug 25, 2025
This was referenced Oct 14, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

点击 这是indexloc提供的php浏览器服务,不要输入任何密码和下载