-
Notifications
You must be signed in to change notification settings - Fork 2.7k
FEAT: Partner Groups #2735
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
FEAT: Partner Groups #2735
Conversation
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughAdds 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
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 }
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 }
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
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
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 unit tests
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. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this 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 onsearchParamsObj.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 undefinedReturning 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
, beforeconst 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 theRowMenuButton
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 usesuseParams()
while the parent component getsslug
fromuseWorkspace()
. 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 aMenuItem
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.
📒 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 groupIdUsing
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 validFound 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.
There was a problem hiding this 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) lookupsBuild 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 lookupReturning 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 depsThe filters array derives
permalink
fromslug
. Withoutslug
in deps, permalinks can go stale if the workspace slug changes. If you adoptgroupCountMap
, depend on it instead ofgroupCount
.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 consistentMinor 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: preferselect
overinclude
.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.
📒 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 solidCorrect use of guards and defaulting to [] keeps consumers stable. Icon/label getters are consistent.
1-1
: Imports for group filtering look correctThe 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 moveUsing ProgramEnrollmentStatus from @prisma/client improves type-safety and avoids magic strings.
56-56
: Good switch to enum-backed statusSetting 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 schemaProgramEnrollment.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:
- 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 verifiedQuick 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.
There was a problem hiding this 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 importsBoth 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 slugCalling 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 navigatingOn 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.promiseIf 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.
📒 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 SearchBoxPersistedisFiltered 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 deletionHiding Delete group for the default group prevents accidental/invalid deletes. Nice.
const { groups, loading, error } = useGroups<GroupExtendedProps>({ | ||
query: { | ||
includeExpandedFields: true, | ||
}, | ||
}); | ||
|
There was a problem hiding this comment.
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.
const { groups, loading, error } = useGroups<GroupExtendedProps>({ | |
query: { | |
includeExpandedFields: true, | |
}, | |
}); | |
const { groups, loading, error } = useGroups<GroupExtendedProps>({ | |
query: { | |
includeExpandedFields: true, | |
sortBy, | |
sortOrder, | |
}, | |
}); |
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, | ||
}), | ||
}, |
There was a problem hiding this comment.
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.
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.
There was a problem hiding this 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 labelConsider 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 agroupSlug
returned the program with empty rewards. Now, forcingDEFAULT_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 whengroupSlug
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 isDefaultGroupThe 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.
📒 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]
, soparams.groupSlug
won’t be populated by Next.js. This meansgenerateMetadata
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
, butApplyLayout
fetches the unscoped program. If the child routes rely on layout’sprogram
, 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 neededPARTNERS_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 — LGTMImporting the default group constant here keeps fallback handling consistent with the shared groups schema. No issues.
17-20
: Group-scoped fetch wiring looks correctPassing 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 — LGTMProps align with the updated getProgram return shape for group context.
22-23
: 404 on missing group is intentional — default group is enforcedThere'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
Summary by CodeRabbit
New Features
Enhancements
Chores