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

Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion apps/web/app/(ee)/api/groups/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,30 @@ export const GET = withWorkspace(
programId,
});

return NextResponse.json(z.array(GroupSchemaExtended).parse(groups));
// Fetch rewards and discount data for each group
const groupsWithRewards = await Promise.all(
groups.map(async (group) => {
const groupWithRewards = await prisma.partnerGroup.findUnique({
where: { id: group.id },
include: {
clickReward: true,
leadReward: true,
saleReward: true,
discount: true,
},
});

return {
...group,
clickReward: groupWithRewards?.clickReward,
leadReward: groupWithRewards?.leadReward,
saleReward: groupWithRewards?.saleReward,
discount: groupWithRewards?.discount,
};
})
);
Comment on lines +31 to +51
Copy link
Contributor

Choose a reason for hiding this comment

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

The API route introduces a severe N+1 query performance issue by making individual database queries for each group to fetch reward&2f;discount data that may already be available from the original query.

View Details
📝 Patch Details
diff --git a/apps/web/app/(ee)/api/groups/route.ts b/apps/web/app/(ee)/api/groups/route.ts
index 8c40358f0..a61bc59c7 100644
--- a/apps/web/app/(ee)/api/groups/route.ts
+++ b/apps/web/app/(ee)/api/groups/route.ts
@@ -27,30 +27,8 @@ export const GET = withWorkspace(
       programId,
     });
 
-    // Fetch rewards and discount data for each group
-    const groupsWithRewards = await Promise.all(
-      groups.map(async (group) => {
-        const groupWithRewards = await prisma.partnerGroup.findUnique({
-          where: { id: group.id },
-          include: {
-            clickReward: true,
-            leadReward: true,
-            saleReward: true,
-            discount: true,
-          },
-        });
-
-        return {
-          ...group,
-          clickReward: groupWithRewards?.clickReward,
-          leadReward: groupWithRewards?.leadReward,
-          saleReward: groupWithRewards?.saleReward,
-          discount: groupWithRewards?.discount,
-        };
-      })
-    );
-
-    return NextResponse.json(z.array(GroupSchemaExtended).parse(groupsWithRewards));
+    // Groups now include reward and discount data from the optimized query
+    return NextResponse.json(z.array(GroupSchemaExtended).parse(groups));
   },
   {
     requiredPermissions: ["groups.read"],
diff --git a/apps/web/lib/api/groups/get-groups.ts b/apps/web/lib/api/groups/get-groups.ts
index 3a36b744f..8dc003227 100644
--- a/apps/web/lib/api/groups/get-groups.ts
+++ b/apps/web/lib/api/groups/get-groups.ts
@@ -40,6 +40,36 @@ export async function getGroups(filters: GroupFilters) {
       pg.name,
       pg.slug,
       pg.color,
+      -- Reward data
+      cr.id as clickRewardId,
+      cr.description as clickRewardDescription,
+      cr.event as clickRewardEvent,
+      cr.type as clickRewardType,
+      cr.amount as clickRewardAmount,
+      cr.maxDuration as clickRewardMaxDuration,
+      cr.modifiers as clickRewardModifiers,
+      lr.id as leadRewardId,
+      lr.description as leadRewardDescription,
+      lr.event as leadRewardEvent,
+      lr.type as leadRewardType,
+      lr.amount as leadRewardAmount,
+      lr.maxDuration as leadRewardMaxDuration,
+      lr.modifiers as leadRewardModifiers,
+      sr.id as saleRewardId,
+      sr.description as saleRewardDescription,
+      sr.event as saleRewardEvent,
+      sr.type as saleRewardType,
+      sr.amount as saleRewardAmount,
+      sr.maxDuration as saleRewardMaxDuration,
+      sr.modifiers as saleRewardModifiers,
+      -- Discount data
+      d.id as discountId,
+      d.amount as discountAmount,
+      d.type as discountType,
+      d.maxDuration as discountMaxDuration,
+      d.description as discountDescription,
+      d.couponId as discountCouponId,
+      d.couponTestId as discountCouponTestId,
       ${
         includeExpandedFields
           ? Prisma.sql`
@@ -64,6 +94,10 @@ export async function getGroups(filters: GroupFilters) {
         `
       }
     FROM PartnerGroup pg
+    LEFT JOIN Reward cr ON cr.id = pg.clickRewardId
+    LEFT JOIN Reward lr ON lr.id = pg.leadRewardId
+    LEFT JOIN Reward sr ON sr.id = pg.saleRewardId
+    LEFT JOIN Discount d ON d.id = pg.discountId
     LEFT JOIN ProgramEnrollment pe ON pe.groupId = pg.id AND pe.status IN ('approved', 'invited')
     ${
       includeExpandedFields
@@ -105,5 +139,43 @@ export async function getGroups(filters: GroupFilters) {
     conversions: Number(group.totalConversions),
     commissions: Number(group.totalCommissions),
     netRevenue: Number(group.netRevenue),
+    // Transform reward data
+    clickReward: group.clickRewardId ? {
+      id: group.clickRewardId,
+      description: group.clickRewardDescription,
+      event: group.clickRewardEvent,
+      type: group.clickRewardType,
+      amount: Number(group.clickRewardAmount),
+      maxDuration: group.clickRewardMaxDuration,
+      modifiers: group.clickRewardModifiers,
+    } : null,
+    leadReward: group.leadRewardId ? {
+      id: group.leadRewardId,
+      description: group.leadRewardDescription,
+      event: group.leadRewardEvent,
+      type: group.leadRewardType,
+      amount: Number(group.leadRewardAmount),
+      maxDuration: group.leadRewardMaxDuration,
+      modifiers: group.leadRewardModifiers,
+    } : null,
+    saleReward: group.saleRewardId ? {
+      id: group.saleRewardId,
+      description: group.saleRewardDescription,
+      event: group.saleRewardEvent,
+      type: group.saleRewardType,
+      amount: Number(group.saleRewardAmount),
+      maxDuration: group.saleRewardMaxDuration,
+      modifiers: group.saleRewardModifiers,
+    } : null,
+    // Transform discount data
+    discount: group.discountId ? {
+      id: group.discountId,
+      amount: Number(group.discountAmount),
+      type: group.discountType,
+      maxDuration: group.discountMaxDuration,
+      description: group.discountDescription,
+      couponId: group.discountCouponId,
+      couponTestId: group.discountCouponTestId,
+    } : null,
   }));
 }

Analysis

N+1 query performance issue in GET &2f;api&2f;groups route

What fails&3a; The getGroups() API route executes N+1 database queries when fetching reward and discount data for groups, causing significant performance degradation as the number of groups scales.

How to reproduce&3a;

# Make API request to groups endpoint
curl -H "Authorization: Bearer $TOKEN" https://app.dub.co/api/groups

# With 100 groups, this executes 101 database queries:
# 1 query from getGroups() + 100 individual prisma.partnerGroup.findUnique() calls

Result&3a; Each group triggers a separate prisma.partnerGroup.findUnique() query in Promise.all() loop &28;lines 31-51 in route.ts&29;. For N groups, this creates N+1 total queries instead of a single optimized query.

Expected&3a; Single query with JOINs to fetch all group data including rewards and discounts in one database round-trip, eliminating the N+1 query anti-pattern.


return NextResponse.json(z.array(GroupSchemaExtended).parse(groupsWithRewards));
},
{
requiredPermissions: ["groups.read"],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AnalyticsResponse } from "@/lib/analytics/types";
import { editQueryString } from "@/lib/analytics/utils";
import useGroups from "@/lib/swr/use-groups";
Copy link
Contributor

Choose a reason for hiding this comment

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

Unused import&3a; useGroups is imported but never used in the analytics partners table component.

View Details
📝 Patch Details
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/analytics-partners-table.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/analytics-partners-table.tsx
index c7ee1f128..47b6d3f11 100644
--- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/analytics-partners-table.tsx
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/analytics-partners-table.tsx
@@ -1,6 +1,5 @@
 import { AnalyticsResponse } from "@/lib/analytics/types";
 import { editQueryString } from "@/lib/analytics/utils";
-import useGroups from "@/lib/swr/use-groups";
 import { AnalyticsContext } from "@/ui/analytics/analytics-provider";
 import { PartnerRowItem } from "@/ui/partners/partner-row-item";
 import { FilterButtonTableRow } from "@/ui/shared/filter-button-table-row";
@@ -18,7 +17,6 @@ import useSWR from "swr";
 
 export function AnalyticsPartnersTable() {
   const { selectedTab, queryString } = useContext(AnalyticsContext);
-  const { groups } = useGroups();
 
   const { pagination, setPagination } = usePagination(10);
 

Analysis

Unused import&3a; useGroups in analytics partners table component

What fails&3a; TypeScript compiler reports error TS6133: 'groups' is declared but its value is never read in apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/analytics-partners-table.tsx:21

How to reproduce&3a;

cd apps/web && pnpm exec tsc --noEmit --noUnusedLocals --noUnusedParameters "app/app.dub.co/(dashboard)/[slug]/(ee)/program/analytics/analytics-partners-table.tsx"

Result&3a; TypeScript error about unused groups variable from useGroups() hook

Expected&3a; No unused import warnings - the component explicitly passes group={null} to all PartnerRowItem components and includes a comment that &22;groupId not available in analytics data, so no group rewards will be shown&22;

import { AnalyticsContext } from "@/ui/analytics/analytics-provider";
import { PartnerRowItem } from "@/ui/partners/partner-row-item";
import { FilterButtonTableRow } from "@/ui/shared/filter-button-table-row";
Expand All @@ -17,6 +18,7 @@ import useSWR from "swr";

export function AnalyticsPartnersTable() {
const { selectedTab, queryString } = useContext(AnalyticsContext);
const { groups } = useGroups();

const { pagination, setPagination } = usePagination(10);

Expand Down Expand Up @@ -52,14 +54,15 @@ export function AnalyticsPartnersTable() {
minSize: 250,
cell: ({ row }) => {
const p = row.original.partner;
// Note: groupId not available in analytics data, so no group rewards will be shown
return (
<PartnerRowItem
partner={{
...p,
payoutsEnabledAt: p.payoutsEnabledAt
? new Date(p.payoutsEnabledAt)
: null,
id: p.id,
name: p.name,
image: p.image,
}}
group={null}
/>
);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,17 @@ export function PartnersTable() {
enableHiding: false,
minSize: 250,
cell: ({ row }) => {
const group = groups?.find((g) => g.id === row.original.groupId);
return (
<PartnerRowItem partner={row.original} showPermalink={false} />
<PartnerRowItem
partner={{
id: row.original.id,
name: row.original.name,
image: row.original.image,
}}
group={group}
showPermalink={false}
/>
);
},
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import useGroups from "@/lib/swr/use-groups";
import usePayoutsCount from "@/lib/swr/use-payouts-count";
import useProgram from "@/lib/swr/use-program";
import useWorkspace from "@/lib/swr/use-workspace";
Expand Down Expand Up @@ -52,6 +53,7 @@ const PayoutTableInner = memo(
setSelectedFilter,
}: ReturnType<typeof usePayoutFilters>) => {
const { id: workspaceId, defaultProgramId } = useWorkspace();
const { groups } = useGroups();
const { queryParams, searchParams, getQueryString } = useRouterStuff();

const sortBy = searchParams.get("sortBy") || "periodEnd";
Expand Down Expand Up @@ -110,7 +112,17 @@ const PayoutTableInner = memo(
{
header: "Partner",
cell: ({ row }) => {
return <PartnerRowItem partner={row.original.partner} />;
// Note: groupId not available in payout data, so no group rewards will be shown
return (
<PartnerRowItem
partner={{
id: row.original.partner.id,
name: row.original.partner.name,
image: row.original.partner.image,
}}
group={null}
/>
);
},
},
{
Expand Down
128 changes: 91 additions & 37 deletions apps/web/ui/partners/partner-row-item.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,116 @@
import { DynamicTooltipWrapper, GreekTemple } from "@dub/ui";
import { DynamicTooltipWrapper } from "@dub/ui";
import { CursorRays, InvoiceDollar, UserPlus, Gift } from "@dub/ui/icons";
import { cn } from "@dub/utils";
import { OG_AVATAR_URL } from "@dub/utils/src/constants";
import { CircleMinus } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { getGroupRewardsAndDiscount } from "@/lib/partners/get-group-rewards-and-discount";
import { GroupProps, RewardProps, DiscountProps } from "@/lib/types";

export function PartnerRowItem({
partner,
group,
showPermalink = true,
}: {
partner: {
id: string;
name: string;
image?: string | null;
payoutsEnabledAt?: Date | null;
};
group?: GroupProps | null;
showPermalink?: boolean;
}) {
const { slug } = useParams();
const As = showPermalink ? Link : "div";

const showPayoutsEnabled = "payoutsEnabledAt" in partner;
// Helper function to format reward amount
const formatRewardAmount = (reward: RewardProps) => {
if (reward.type === "percentage") {
return `${reward.amount}%`;
}
return `$${(reward.amount / 100).toFixed(2)}`;
};

// Helper function to get duration text
const getDurationText = (maxDuration?: number | null) => {
if (maxDuration === 0) return "one-time";
if (maxDuration == null) return "for the customer's lifetime";
return `for ${maxDuration} month${maxDuration > 1 ? 's' : ''}`;
};

// Helper function to get reward description
const getRewardDescription = (reward: RewardProps) => {
const amount = formatRewardAmount(reward);
const duration = getDurationText(reward.maxDuration);

switch (reward.event) {
case "sale":
return `Up to ${amount} per sale ${duration}`;
case "lead":
return `${amount} per lead`;
case "click":
return `${amount} per click`;
default:
return `${amount} per ${reward.event}`;
}
};

// Get group rewards and discount
const { rewards, discount } = group ? getGroupRewardsAndDiscount(group) : { rewards: [], discount: null };

// Sort rewards in the specified order: sale, lead, click
const sortedRewards = rewards.sort((a, b) => {
const order = { sale: 0, lead: 1, click: 2 } as const;
return (order[a.event as keyof typeof order] ?? 999) - (order[b.event as keyof typeof order] ?? 999);
});

// Icon mapping for rewards
const getRewardIcon = (event: string) => {
switch (event) {
case "sale":
return InvoiceDollar;
case "lead":
return UserPlus;
case "click":
return CursorRays;
default:
return CursorRays;
}
};

return (
<div className="flex items-center gap-2">
<DynamicTooltipWrapper
tooltipProps={
showPayoutsEnabled
group && (rewards.length > 0 || discount)
? {
delayDuration: 300,
content: (
<div className="grid max-w-xs gap-2 p-4">
<div className="flex items-center gap-2 text-sm font-medium">
Payouts{" "}
{partner.payoutsEnabledAt ? "enabled" : "disabled"}
<div
className={cn(
"flex size-5 items-center justify-center rounded-md border border-green-300 bg-green-200 text-green-800",
!partner.payoutsEnabledAt &&
"border-red-300 bg-red-200 text-red-800",
)}
>
{partner.payoutsEnabledAt ? (
<GreekTemple className="size-3" />
) : (
<CircleMinus className="size-3" />
)}
<div className="grid max-w-xs gap-1 p-2">
{sortedRewards.map((reward) => {
const Icon = getRewardIcon(reward.event);

return (
<div key={reward.id} className="flex items-start gap-2">
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-neutral-100 text-neutral-600">
<Icon className="size-4" />
</div>
<span className="text-xs font-medium text-neutral-700 mt-1">
{getRewardDescription(reward)}
</span>
</div>
);
})}
{discount && (
<div className="flex items-start gap-2">
<div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-neutral-100 text-neutral-600">
<Gift className="size-4" />
</div>
<span className="text-xs font-medium text-neutral-700 mt-1">
New users get {discount.type === "percentage" ? `${discount.amount}%` : `$${(discount.amount / 100).toFixed(0)}`} off {getDurationText(discount.maxDuration)}
</span>
</div>
</div>
<div className="text-pretty text-sm text-neutral-500">
{partner.payoutsEnabledAt
? "This partner has payouts enabled, which means they will be able to receive payouts from this program"
: "This partner does not have payouts enabled, which means they will not be able to receive any payouts from this program"}
</div>
)}
</div>
),
}
Expand All @@ -64,19 +123,14 @@ export function PartnerRowItem({
alt={partner.name}
className="size-5 shrink-0 rounded-full"
/>
{showPayoutsEnabled && (
<div
className={cn(
"absolute -bottom-0.5 -right-0.5 size-2 rounded-full bg-green-500",
!partner.payoutsEnabledAt && "bg-red-500",
)}
/>
)}
</div>
</DynamicTooltipWrapper>
<As
href={`/${slug}/program/partners?partnerId=${partner.id}`}
{...(showPermalink && { target: "_blank" })}
{...(showPermalink && {
href: `/${slug}/program/partners?partnerId=${partner.id}`,
target: "_blank",
rel: "noopener noreferrer",
})}
className={cn(
"min-w-0 truncate",
showPermalink && "cursor-alias decoration-dotted hover:underline",
Expand Down