From e1a1f4942980a43e94fc33f5d17226989d9228a0 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 1 Apr 2026 17:50:51 +0530 Subject: [PATCH 01/86] Partner viewer role and program/link scoping for partner profile --- .../partner-profile/programs/count/route.ts | 28 ++-- .../api/partner-profile/programs/route.ts | 135 +++++++++--------- .../profile/members/page-client.tsx | 13 +- .../lib/api/partners/get-scoped-link-ids.ts | 73 ++++++++++ .../partner-users/partner-user-permissions.ts | 1 + apps/web/lib/auth/partner.ts | 26 +++- .../ui/modals/invite-partner-user-modal.tsx | 3 +- packages/prisma/schema/link.prisma | 1 + packages/prisma/schema/partner.prisma | 32 +++++ packages/prisma/schema/program.prisma | 40 +++--- 10 files changed, 253 insertions(+), 99 deletions(-) create mode 100644 apps/web/lib/api/partners/get-scoped-link-ids.ts diff --git a/apps/web/app/(ee)/api/partner-profile/programs/count/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/count/route.ts index 7a1183b490e..8586f4df7b2 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/count/route.ts @@ -4,15 +4,23 @@ import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/count - count program enrollments for a given partnerId -export const GET = withPartnerProfile(async ({ partner, searchParams }) => { - const { status } = partnerProfileProgramsCountQuerySchema.parse(searchParams); +export const GET = withPartnerProfile( + async ({ partner, searchParams, assignedProgramIds }) => { + const { status } = + partnerProfileProgramsCountQuerySchema.parse(searchParams); - const count = await prisma.programEnrollment.count({ - where: { - partnerId: partner.id, - ...(status && { status }), - }, - }); + const count = await prisma.programEnrollment.count({ + where: { + partnerId: partner.id, + ...(status && { status }), + ...(assignedProgramIds.length > 0 && { + programId: { + in: assignedProgramIds, + }, + }), + }, + }); - return NextResponse.json(count); -}); + return NextResponse.json(count); + }, +); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/route.ts index e2625420ac5..3b6750e9ef3 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/route.ts @@ -7,77 +7,84 @@ import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/partner-profile/programs - get all program enrollments for a given partnerId -export const GET = withPartnerProfile(async ({ partner, searchParams }) => { - const { includeRewardsDiscounts, status } = - partnerProfileProgramsQuerySchema.parse(searchParams); +export const GET = withPartnerProfile( + async ({ partner, searchParams, assignedProgramIds }) => { + const { includeRewardsDiscounts, status } = + partnerProfileProgramsQuerySchema.parse(searchParams); - const programEnrollments = await prisma.programEnrollment.findMany({ - where: { - partnerId: partner.id, - ...(status && { status }), - program: { - deactivatedAt: null, - }, - }, - include: { - links: { - take: 1, - orderBy: { - createdAt: "asc", + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + partnerId: partner.id, + ...(status && { status }), + program: { + deactivatedAt: null, }, + ...(assignedProgramIds.length > 0 && { + programId: { + in: assignedProgramIds, + }, + }), }, - program: { - include: { - workspace: { - select: { - plan: true, + include: { + links: { + take: 1, + orderBy: { + createdAt: "asc", + }, + }, + program: { + include: { + workspace: { + select: { + plan: true, + }, }, }, }, - }, - application: { - select: { - rejectionReason: true, - rejectionNote: true, - reviewedAt: true, + application: { + select: { + rejectionReason: true, + rejectionNote: true, + reviewedAt: true, + }, }, + ...(includeRewardsDiscounts && { + clickReward: true, + leadReward: true, + saleReward: true, + discount: true, + }), }, - ...(includeRewardsDiscounts && { - clickReward: true, - leadReward: true, - saleReward: true, - discount: true, - }), - }, - orderBy: [ - { - totalCommissions: "desc", - }, - { - createdAt: "asc", - }, - ], - }); + orderBy: [ + { + totalCommissions: "desc", + }, + { + createdAt: "asc", + }, + ], + }); - const response = programEnrollments.map((enrollment) => { - return { - ...enrollment, - rewards: includeRewardsDiscounts - ? [ - enrollment.clickReward, - enrollment.leadReward, - enrollment.saleReward, - ].filter((r): r is Reward => r !== null) - : [], - application: enrollment.application - ? { - rejectionReason: enrollment.application.rejectionReason, - rejectionNote: enrollment.application.rejectionNote, - reviewedAt: enrollment.application.reviewedAt, - } - : null, - }; - }); + const response = programEnrollments.map((enrollment) => { + return { + ...enrollment, + rewards: includeRewardsDiscounts + ? [ + enrollment.clickReward, + enrollment.leadReward, + enrollment.saleReward, + ].filter((r): r is Reward => r !== null) + : [], + application: enrollment.application + ? { + rejectionReason: enrollment.application.rejectionReason, + rejectionNote: enrollment.application.rejectionNote, + reviewedAt: enrollment.application.reviewedAt, + } + : null, + }; + }); - return NextResponse.json(z.array(ProgramEnrollmentSchema).parse(response)); -}); + return NextResponse.json(z.array(ProgramEnrollmentSchema).parse(response)); + }, +); diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx index 0e5b411bebd..4911b7d4b42 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx @@ -29,10 +29,10 @@ import { User, UserCrown, } from "@dub/ui/icons"; -import { cn, fetcher, timeAgo } from "@dub/utils"; +import { capitalize, cn, fetcher, timeAgo } from "@dub/utils"; import { ColumnDef, Row } from "@tanstack/react-table"; import { Command } from "cmdk"; -import { UserMinus, UserPlus } from "lucide-react"; +import { EyeClosed, UserMinus, UserPlus } from "lucide-react"; import { useSession } from "next-auth/react"; import { useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; @@ -89,6 +89,7 @@ export function ProfileMembersPageClient() { options: [ { value: "owner", label: "Owner", icon: UserCrown }, { value: "member", label: "Member", icon: User }, + { value: "viewer", label: "Viewer", icon: EyeClosed }, ], }, { @@ -342,8 +343,11 @@ function RoleCell({ : undefined } > - - + {Object.values(PartnerRole).map((role) => ( + + ))} ); @@ -378,6 +382,7 @@ function RowMenuButton({ return ( <> + { + // Check if user has any program-level scoping at all + const programAssignmentCount = await prisma.partnerUserProgram.count({ + where: { + partnerUserId, + }, + }); + + // No program assignments → unrestricted access + if (programAssignmentCount === 0) { + return undefined; + } + + // User is scoped — check if they have access to this specific program + const hasProgramAccess = await prisma.partnerUserProgram.findUnique({ + where: { + partnerUserId_programId: { + partnerUserId, + programId, + }, + }, + select: { + id: true, + }, + }); + + if (!hasProgramAccess) { + // Scoped but doesn't have this program → no access + return []; + } + + // Has program access — check for link-level scoping + const linkAssignments = await prisma.partnerUserLink.findMany({ + where: { + partnerUserId, + programId, + }, + select: { + linkId: true, + }, + }); + + // No link assignments → see all links in this program + if (linkAssignments.length === 0) { + return undefined; + } + + // Has link assignments → restricted to those links + return linkAssignments.map((a) => a.linkId); +} diff --git a/apps/web/lib/auth/partner-users/partner-user-permissions.ts b/apps/web/lib/auth/partner-users/partner-user-permissions.ts index e271af335ea..df2f1f153d1 100644 --- a/apps/web/lib/auth/partner-users/partner-user-permissions.ts +++ b/apps/web/lib/auth/partner-users/partner-user-permissions.ts @@ -27,6 +27,7 @@ const ROLE_PERMISSIONS: Record = { "postbacks.write", ], member: [], + viewer: [], } as const; export function hasPermission(role: PartnerRole, permission: Permission) { diff --git a/apps/web/lib/auth/partner.ts b/apps/web/lib/auth/partner.ts index e80ae2f2f73..aeefd70e45c 100644 --- a/apps/web/lib/auth/partner.ts +++ b/apps/web/lib/auth/partner.ts @@ -25,6 +25,8 @@ interface WithPartnerProfileHandler { session, partner, partnerUser, + assignedProgramIds, + assignedLinkIds, }: { req: Request; params: Record; @@ -32,7 +34,9 @@ interface WithPartnerProfileHandler { headers?: Headers; session: Session; partner: Omit; - partnerUser: Pick; + partnerUser: Pick; + assignedProgramIds: string[]; + assignedLinkIds: string[]; }): Promise; } @@ -224,6 +228,16 @@ export const withPartnerProfile = ( platforms: true, }, }, + assignedPrograms: { + select: { + programId: true, + }, + }, + assignedLinks: { + select: { + linkId: true, + }, + }, }, }); @@ -254,6 +268,13 @@ export const withPartnerProfile = ( } } + const assignedProgramIds = partnerUser.assignedPrograms.map( + ({ programId }) => programId, + ); + const assignedLinkIds = partnerUser.assignedLinks.map( + ({ linkId }) => linkId, + ); + const { industryInterests, preferredEarningStructures, @@ -281,9 +302,12 @@ export const withPartnerProfile = ( platforms: partnerPlatformSchema.array().parse(platforms), } as Omit, partnerUser: { + id: partnerUser.id, userId: partnerUser.userId, role: partnerUser.role, }, + assignedProgramIds, + assignedLinkIds, headers: responseHeaders, }); } catch (error) { diff --git a/apps/web/ui/modals/invite-partner-user-modal.tsx b/apps/web/ui/modals/invite-partner-user-modal.tsx index f810b4cf76e..a73a950087c 100644 --- a/apps/web/ui/modals/invite-partner-user-modal.tsx +++ b/apps/web/ui/modals/invite-partner-user-modal.tsx @@ -2,6 +2,7 @@ import { MAX_INVITES_PER_REQUEST } from "@/lib/constants/partner-profile"; import { mutatePrefix } from "@/lib/swr/mutate"; import usePartnerProfile from "@/lib/swr/use-partner-profile"; import { invitePartnerUserSchema } from "@/lib/zod/schemas/partner-profile"; +import { PartnerRole } from "@dub/prisma/client"; import { Button, Modal, useMediaQuery, useRouterStuff } from "@dub/ui"; import { Trash } from "@dub/ui/icons"; import { capitalize, pluralize } from "@dub/utils"; @@ -126,7 +127,7 @@ function InvitePartnerUserModal({ defaultValue="member" className="rounded-r-md border border-l-0 border-neutral-300 bg-white pl-4 pr-8 text-neutral-600 focus:border-neutral-300 focus:outline-none focus:ring-0 sm:text-sm" > - {["owner", "member"].map((role) => ( + {Object.values(PartnerRole).map((role) => ( diff --git a/packages/prisma/schema/link.prisma b/packages/prisma/schema/link.prisma index cd5c7d5a919..49798c9ba2d 100644 --- a/packages/prisma/schema/link.prisma +++ b/packages/prisma/schema/link.prisma @@ -91,6 +91,7 @@ model Link { customers Customer[] commissions Commission[] fraudEvents FraudEvent[] + partnerUserLinks PartnerUserLink[] @@unique([domain, key]) // for getting a link by domain and key @@unique([projectId, externalId]) // for getting a link by externalId diff --git a/packages/prisma/schema/partner.prisma b/packages/prisma/schema/partner.prisma index 2649a524e2f..6a30636181d 100644 --- a/packages/prisma/schema/partner.prisma +++ b/packages/prisma/schema/partner.prisma @@ -1,6 +1,7 @@ enum PartnerRole { owner member + viewer } enum PartnerProfileType { @@ -110,6 +111,8 @@ model PartnerUser { partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade) notificationPreferences PartnerNotificationPreferences? + assignedPrograms PartnerUserProgram[] + assignedLinks PartnerUserLink[] @@unique([userId, partnerId]) @@index(partnerId) @@ -159,3 +162,32 @@ model PartnerSalesChannel { @@unique([partnerId, salesChannel]) } + +model PartnerUserProgram { + id String @id @default(cuid()) + partnerUserId String + programId String + createdAt DateTime @default(now()) + + partnerUser PartnerUser @relation(fields: [partnerUserId], references: [id], onDelete: Cascade) + program Program @relation(fields: [programId], references: [id], onDelete: Cascade) + + @@unique([partnerUserId, programId]) + @@index(programId) +} + +model PartnerUserLink { + id String @id @default(cuid()) + partnerUserId String + linkId String + programId String + createdAt DateTime @default(now()) + + partnerUser PartnerUser @relation(fields: [partnerUserId], references: [id], onDelete: Cascade) + link Link @relation(fields: [linkId], references: [id], onDelete: Cascade) + program Program @relation(fields: [programId], references: [id], onDelete: Cascade) + + @@unique([partnerUserId, linkId]) + @@index(linkId) + @@index(programId) +} diff --git a/packages/prisma/schema/program.prisma b/packages/prisma/schema/program.prisma index 9d517c5f9d1..f341ca86e22 100644 --- a/packages/prisma/schema/program.prisma +++ b/packages/prisma/schema/program.prisma @@ -39,14 +39,14 @@ model Program { minPayoutAmount Int @default(0) // Default minimum payout amount of $0 payoutMode ProgramPayoutMode @default(internal) - inviteEmailData Json? @db.Json - embedData Json? @db.Json - resources Json? @db.Json - referralFormData Json? @db.Json - applicationRequirements Json? @db.Json - termsUrl String? @db.Text - helpUrl String? @db.Text - supportEmail String? + inviteEmailData Json? @db.Json + embedData Json? @db.Json + resources Json? @db.Json + referralFormData Json? @db.Json + applicationRequirements Json? @db.Json + termsUrl String? @db.Text + helpUrl String? @db.Text + supportEmail String? messagingEnabledAt DateTime? partnerNetworkEnabledAt DateTime? @@ -89,6 +89,8 @@ model Program { fraudEventGroups FraudEventGroup[] sourceFraudEvents FraudEvent[] @relation("SourceFraudEvents") referrals PartnerReferral[] + partnerUserPrograms PartnerUserProgram[] + partnerUserLinks PartnerUserLink[] @@index(workspaceId) @@index(addedToMarketplaceAt) @@ -185,25 +187,25 @@ enum ProgramApplicationRejectionReason { } model ProgramApplication { - id String @id @default(cuid()) + id String @id @default(cuid()) programId String groupId String? name String email String country String? - website String? @db.Text - youtube String? @db.Text - twitter String? @db.Text - linkedin String? @db.Text - instagram String? @db.Text - tiktok String? @db.Text - formData Json? @db.Json + website String? @db.Text + youtube String? @db.Text + twitter String? @db.Text + linkedin String? @db.Text + instagram String? @db.Text + tiktok String? @db.Text + formData Json? @db.Json userId String? // user who reviewed the application rejectionReason ProgramApplicationRejectionReason? - rejectionNote String? @db.Text + rejectionNote String? @db.Text reviewedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt program Program @relation(fields: [programId], references: [id]) partnerGroup PartnerGroup? @relation(fields: [groupId], references: [id]) From 7fbf6a2a2c6c7f52c6404163704f5cbe075a5c33 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 1 Apr 2026 17:52:01 +0530 Subject: [PATCH 02/86] Delete get-scoped-link-ids.ts --- .../lib/api/partners/get-scoped-link-ids.ts | 73 ------------------- 1 file changed, 73 deletions(-) delete mode 100644 apps/web/lib/api/partners/get-scoped-link-ids.ts diff --git a/apps/web/lib/api/partners/get-scoped-link-ids.ts b/apps/web/lib/api/partners/get-scoped-link-ids.ts deleted file mode 100644 index 5f29374c081..00000000000 --- a/apps/web/lib/api/partners/get-scoped-link-ids.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { prisma } from "@dub/prisma"; - -/** - * For non-owner users on [programId] routes, determine which links they can access. - * - * Returns: - * - `undefined` if the user has no program/link assignments (unrestricted access) - * - `string[]` of allowed link IDs if the user is scoped - * - * The logic: - * 1. Check if the user has ANY PartnerUserProgram records (i.e. is scoped at all) - * 2. If not scoped → return undefined (see everything) - * 3. If scoped but doesn't have this program assigned → return [] (see nothing) - * 4. If scoped and has this program → check PartnerUserLink records: - * - If link records exist → return those link IDs - * - If no link records → return undefined (see all links in this program) - */ -export async function getScopedLinkIds({ - partnerUserId, - programId, -}: { - partnerUserId: string; - programId: string; -}): Promise { - // Check if user has any program-level scoping at all - const programAssignmentCount = await prisma.partnerUserProgram.count({ - where: { - partnerUserId, - }, - }); - - // No program assignments → unrestricted access - if (programAssignmentCount === 0) { - return undefined; - } - - // User is scoped — check if they have access to this specific program - const hasProgramAccess = await prisma.partnerUserProgram.findUnique({ - where: { - partnerUserId_programId: { - partnerUserId, - programId, - }, - }, - select: { - id: true, - }, - }); - - if (!hasProgramAccess) { - // Scoped but doesn't have this program → no access - return []; - } - - // Has program access — check for link-level scoping - const linkAssignments = await prisma.partnerUserLink.findMany({ - where: { - partnerUserId, - programId, - }, - select: { - linkId: true, - }, - }); - - // No link assignments → see all links in this program - if (linkAssignments.length === 0) { - return undefined; - } - - // Has link assignments → restricted to those links - return linkAssignments.map((a) => a.linkId); -} From a9a1fef246ce47bda4f6d086d62003de38d91f65 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 1 Apr 2026 18:20:30 +0530 Subject: [PATCH 03/86] Updated routes to include assigned program IDs in message retrieval, count, and payout queries, ensuring access control based on program assignments. --- .../partner-profile/messages/count/route.ts | 47 ++-- .../api/partner-profile/messages/route.ts | 205 +++++++++--------- .../partner-profile/payouts/count/route.ts | 95 ++++---- .../(ee)/api/partner-profile/payouts/route.ts | 89 ++++---- apps/web/lib/auth/partner.ts | 13 ++ 5 files changed, 245 insertions(+), 204 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts b/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts index c1d422742bf..955f683ecec 100644 --- a/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts @@ -4,25 +4,32 @@ import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/partner-profile/messages/count - count messages for a partner -export const GET = withPartnerProfile(async ({ partner, searchParams }) => { - const { unread } = countMessagesQuerySchema.parse(searchParams); +export const GET = withPartnerProfile( + async ({ partner, searchParams, assignedProgramIds }) => { + const { unread } = countMessagesQuerySchema.parse(searchParams); - const count = await prisma.message.count({ - where: { - partnerId: partner.id, - ...(unread !== undefined && { - // Only count messages from the program - senderPartnerId: null, - readInApp: unread - ? // Only count unread messages - null - : { - // Only count read messages - not: null, - }, - }), - }, - }); + const count = await prisma.message.count({ + where: { + partnerId: partner.id, + ...(assignedProgramIds.length > 0 && { + programId: { + in: assignedProgramIds, + }, + }), + ...(unread !== undefined && { + // Only count messages from the program + senderPartnerId: null, + readInApp: unread + ? // Only count unread messages + null + : { + // Only count read messages + not: null, + }, + }), + }, + }); - return NextResponse.json(count); -}); + return NextResponse.json(count); + }, +); diff --git a/apps/web/app/(ee)/api/partner-profile/messages/route.ts b/apps/web/app/(ee)/api/partner-profile/messages/route.ts index 25fb19a3662..0c7da0c8b80 100644 --- a/apps/web/app/(ee)/api/partner-profile/messages/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/messages/route.ts @@ -7,120 +7,127 @@ import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/partner-profile/messages - get messages grouped by program -export const GET = withPartnerProfile(async ({ partner, searchParams }) => { - const { - programSlug, - sortBy, - sortOrder, - messagesLimit: messagesLimitArg, - } = getProgramMessagesQuerySchema.parse(searchParams); +export const GET = withPartnerProfile( + async ({ partner, searchParams, assignedProgramIds }) => { + const { + programSlug, + sortBy, + sortOrder, + messagesLimit: messagesLimitArg, + } = getProgramMessagesQuerySchema.parse(searchParams); - const messagesLimit = messagesLimitArg ?? (programSlug ? undefined : 10); + const messagesLimit = messagesLimitArg ?? (programSlug ? undefined : 10); - const programs = await prisma.program.findMany({ - where: { - // Partner is not banned from the program - partners: { - none: { - partnerId: partner.id, - status: "banned", + const programs = await prisma.program.findMany({ + where: { + // Partner is not banned from the program + partners: { + none: { + partnerId: partner.id, + status: "banned", + }, }, - }, + ...(assignedProgramIds.length > 0 && { + id: { + in: assignedProgramIds, + }, + }), - ...(programSlug - ? { - slug: programSlug, - OR: [ - // Partner is enrolled in the program - // in this case, return messages regardless of messaging enabled status which is passed to the UI - { - partners: { - some: { - partnerId: partner.id, + ...(programSlug + ? { + slug: programSlug, + OR: [ + // Partner is enrolled in the program + // in this case, return messages regardless of messaging enabled status which is passed to the UI + { + partners: { + some: { + partnerId: partner.id, + }, }, }, - }, - { - // Partner has received a direct message from the program - messages: { - some: { - partnerId: partner.id, - senderPartnerId: null, // Sent by the program + { + // Partner has received a direct message from the program + messages: { + some: { + partnerId: partner.id, + senderPartnerId: null, // Sent by the program + }, }, }, - }, - ], - } - : { - OR: [ - // Program has messaging enabled and partner has 1+ messages with the program - { - messagingEnabledAt: { - not: null, - }, - messages: { - some: { - partnerId: partner.id, + ], + } + : { + OR: [ + // Program has messaging enabled and partner has 1+ messages with the program + { + messagingEnabledAt: { + not: null, + }, + messages: { + some: { + partnerId: partner.id, + }, }, }, - }, - // Partner has received a direct message from the program - { - messages: { - some: { - partnerId: partner.id, - senderPartnerId: null, // Sent by the program + // Partner has received a direct message from the program + { + messages: { + some: { + partnerId: partner.id, + senderPartnerId: null, // Sent by the program + }, }, }, - }, - ], - }), - }, - include: { - messages: { - where: { - partnerId: partner.id, - }, - include: { - senderPartner: true, - senderUser: true, - }, - orderBy: { - [sortBy]: sortOrder, + ], + }), + }, + include: { + messages: { + where: { + partnerId: partner.id, + }, + include: { + senderPartner: true, + senderUser: true, + }, + orderBy: { + [sortBy]: sortOrder, + }, + take: messagesLimit, }, - take: messagesLimit, }, - }, - }); + }); - return NextResponse.json( - ProgramMessagesSchema.parse( - programs - // Sort by unread first, then by most recent message - .sort((a, b) => { - const aUnread = a.messages.some( - (m) => !m.senderPartnerId && !m.readInApp, - ); - const bUnread = b.messages.some( - (m) => !m.senderPartnerId && !m.readInApp, - ); + return NextResponse.json( + ProgramMessagesSchema.parse( + programs + // Sort by unread first, then by most recent message + .sort((a, b) => { + const aUnread = a.messages.some( + (m) => !m.senderPartnerId && !m.readInApp, + ); + const bUnread = b.messages.some( + (m) => !m.senderPartnerId && !m.readInApp, + ); - if (aUnread !== bUnread) { - return aUnread ? -1 : 1; - } + if (aUnread !== bUnread) { + return aUnread ? -1 : 1; + } - return sortOrder === "desc" - ? (b.messages?.[0]?.[sortBy]?.getTime() ?? 0) - - (a.messages?.[0]?.[sortBy]?.getTime() ?? 0) - : (a.messages?.[0]?.[sortBy]?.getTime() ?? 0) - - (b.messages?.[0]?.[sortBy]?.getTime() ?? 0); - }) - // Map to {program, messages} - .map(({ messages, ...program }) => ({ - program, - messages, - })), - ), - ); -}); + return sortOrder === "desc" + ? (b.messages?.[0]?.[sortBy]?.getTime() ?? 0) - + (a.messages?.[0]?.[sortBy]?.getTime() ?? 0) + : (a.messages?.[0]?.[sortBy]?.getTime() ?? 0) - + (b.messages?.[0]?.[sortBy]?.getTime() ?? 0); + }) + // Map to {program, messages} + .map(({ messages, ...program }) => ({ + program, + messages, + })), + ), + ); + }, +); diff --git a/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts b/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts index e4090cf0a18..067aed58870 100644 --- a/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts @@ -5,50 +5,57 @@ import { PayoutStatus, Prisma } from "@dub/prisma/client"; import { NextResponse } from "next/server"; // GET /api/partner-profile/payouts/count – get payouts count for a partner -export const GET = withPartnerProfile(async ({ partner, searchParams }) => { - const { programId, groupBy, status } = - payoutsCountQuerySchema.parse(searchParams); - - const where: Prisma.PayoutWhereInput = { - partnerId: partner.id, - ...(programId && { programId }), - }; - - if (groupBy === "status") { - const payouts = await prisma.payout.groupBy({ - by: ["status"], - where: where, - _count: true, - _sum: { - amount: true, +export const GET = withPartnerProfile( + async ({ partner, searchParams, assignedProgramIds }) => { + const { programId, groupBy, status } = + payoutsCountQuerySchema.parse(searchParams); + + const where: Prisma.PayoutWhereInput = { + partnerId: partner.id, + ...(programId && { programId }), + ...(assignedProgramIds.length > 0 && { + programId: { + in: assignedProgramIds, + }, + }), + }; + + if (groupBy === "status") { + const payouts = await prisma.payout.groupBy({ + by: ["status"], + where: where, + _count: true, + _sum: { + amount: true, + }, + }); + + const counts = payouts.map((p) => ({ + status: p.status, + count: p._count, + amount: p._sum.amount, + })); + + Object.values(PayoutStatus).forEach((status) => { + if (!counts.find((p) => p.status === status)) { + counts.push({ + status, + count: 0, + amount: 0, + }); + } + }); + + return NextResponse.json(counts); + } + + const count = await prisma.payout.count({ + where: { + ...where, + status, }, }); - const counts = payouts.map((p) => ({ - status: p.status, - count: p._count, - amount: p._sum.amount, - })); - - Object.values(PayoutStatus).forEach((status) => { - if (!counts.find((p) => p.status === status)) { - counts.push({ - status, - count: 0, - amount: 0, - }); - } - }); - - return NextResponse.json(counts); - } - - const count = await prisma.payout.count({ - where: { - ...where, - status, - }, - }); - - return NextResponse.json(count); -}); + return NextResponse.json(count); + }, +); diff --git a/apps/web/app/(ee)/api/partner-profile/payouts/route.ts b/apps/web/app/(ee)/api/partner-profile/payouts/route.ts index f952ef92887..1727e87d58e 100644 --- a/apps/web/app/(ee)/api/partner-profile/payouts/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/payouts/route.ts @@ -7,48 +7,55 @@ import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/partner-profile/payouts - get all payouts for a partner -export const GET = withPartnerProfile(async ({ partner, searchParams }) => { - const { - programId, - status, - sortBy, - sortOrder, - page = 1, - pageSize, - } = partnerProfilePayoutsQuerySchema.parse(searchParams); +export const GET = withPartnerProfile( + async ({ partner, searchParams, assignedProgramIds }) => { + const { + programId, + status, + sortBy, + sortOrder, + page = 1, + pageSize, + } = partnerProfilePayoutsQuerySchema.parse(searchParams); - const payouts = await prisma.payout.findMany({ - where: { - partnerId: partner.id, - ...(programId && { programId }), - ...(status && { status }), - }, - include: { - program: true, - }, - skip: (page - 1) * pageSize, - take: pageSize, - orderBy: { - [sortBy]: sortOrder, - }, - }); + const payouts = await prisma.payout.findMany({ + where: { + partnerId: partner.id, + ...(programId && { programId }), + ...(status && { status }), + ...(assignedProgramIds.length > 0 && { + programId: { + in: assignedProgramIds, + }, + }), + }, + include: { + program: true, + }, + skip: (page - 1) * pageSize, + take: pageSize, + orderBy: { + [sortBy]: sortOrder, + }, + }); - const transformedPayouts = payouts.map((payout) => { - const mode = - payout.mode ?? - getEffectivePayoutMode({ - payoutMode: payout.program.payoutMode, - payoutsEnabledAt: partner.payoutsEnabledAt, - }); + const transformedPayouts = payouts.map((payout) => { + const mode = + payout.mode ?? + getEffectivePayoutMode({ + payoutMode: payout.program.payoutMode, + payoutsEnabledAt: partner.payoutsEnabledAt, + }); - return { - ...payout, - mode, - traceId: payout.stripePayoutTraceId, - }; - }); + return { + ...payout, + mode, + traceId: payout.stripePayoutTraceId, + }; + }); - return NextResponse.json( - z.array(PartnerPayoutResponseSchema).parse(transformedPayouts), - ); -}); + return NextResponse.json( + z.array(PartnerPayoutResponseSchema).parse(transformedPayouts), + ); + }, +); diff --git a/apps/web/lib/auth/partner.ts b/apps/web/lib/auth/partner.ts index aeefd70e45c..18bbecf49c9 100644 --- a/apps/web/lib/auth/partner.ts +++ b/apps/web/lib/auth/partner.ts @@ -275,6 +275,19 @@ export const withPartnerProfile = ( ({ linkId }) => linkId, ); + // If the user is scoped to specific programs and the route has a programId param, + // verify they have access to this program + if ( + params.programId && + assignedProgramIds.length > 0 && + !assignedProgramIds.includes(params.programId) + ) { + throw new DubApiError({ + code: "not_found", + message: "Program not found.", + }); + } + const { industryInterests, preferredEarningStructures, From 2f06b4522715daa2de18485506de8a68be5c9982 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 1 Apr 2026 21:51:08 +0530 Subject: [PATCH 04/86] Refactor partner profile API routes to destructure assigned program IDs and link IDs from partnerUser, enhancing code clarity and consistency across message and payout queries. --- .../api/partner-profile/messages/count/route.ts | 2 +- .../app/(ee)/api/partner-profile/messages/route.ts | 2 +- .../(ee)/api/partner-profile/payouts/count/route.ts | 2 +- .../app/(ee)/api/partner-profile/payouts/route.ts | 2 +- .../api/partner-profile/programs/count/route.ts | 2 +- .../app/(ee)/api/partner-profile/programs/route.ts | 2 +- apps/web/lib/auth/partner.ts | 13 ++++++------- 7 files changed, 12 insertions(+), 13 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts b/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts index 955f683ecec..98a866a2b7a 100644 --- a/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts @@ -5,7 +5,7 @@ import { NextResponse } from "next/server"; // GET /api/partner-profile/messages/count - count messages for a partner export const GET = withPartnerProfile( - async ({ partner, searchParams, assignedProgramIds }) => { + async ({ partner, searchParams, partnerUser: { assignedProgramIds } }) => { const { unread } = countMessagesQuerySchema.parse(searchParams); const count = await prisma.message.count({ diff --git a/apps/web/app/(ee)/api/partner-profile/messages/route.ts b/apps/web/app/(ee)/api/partner-profile/messages/route.ts index 0c7da0c8b80..f8a86a1240a 100644 --- a/apps/web/app/(ee)/api/partner-profile/messages/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/messages/route.ts @@ -8,7 +8,7 @@ import { NextResponse } from "next/server"; // GET /api/partner-profile/messages - get messages grouped by program export const GET = withPartnerProfile( - async ({ partner, searchParams, assignedProgramIds }) => { + async ({ partner, searchParams, partnerUser: { assignedProgramIds } }) => { const { programSlug, sortBy, diff --git a/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts b/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts index 067aed58870..364975e94d5 100644 --- a/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts @@ -6,7 +6,7 @@ import { NextResponse } from "next/server"; // GET /api/partner-profile/payouts/count – get payouts count for a partner export const GET = withPartnerProfile( - async ({ partner, searchParams, assignedProgramIds }) => { + async ({ partner, searchParams, partnerUser: { assignedProgramIds } }) => { const { programId, groupBy, status } = payoutsCountQuerySchema.parse(searchParams); diff --git a/apps/web/app/(ee)/api/partner-profile/payouts/route.ts b/apps/web/app/(ee)/api/partner-profile/payouts/route.ts index 1727e87d58e..7e153ac4796 100644 --- a/apps/web/app/(ee)/api/partner-profile/payouts/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/payouts/route.ts @@ -8,7 +8,7 @@ import * as z from "zod/v4"; // GET /api/partner-profile/payouts - get all payouts for a partner export const GET = withPartnerProfile( - async ({ partner, searchParams, assignedProgramIds }) => { + async ({ partner, searchParams, partnerUser: { assignedProgramIds } }) => { const { programId, status, diff --git a/apps/web/app/(ee)/api/partner-profile/programs/count/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/count/route.ts index 8586f4df7b2..65389fcbbeb 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/count/route.ts @@ -5,7 +5,7 @@ import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/count - count program enrollments for a given partnerId export const GET = withPartnerProfile( - async ({ partner, searchParams, assignedProgramIds }) => { + async ({ partner, searchParams, partnerUser: { assignedProgramIds } }) => { const { status } = partnerProfileProgramsCountQuerySchema.parse(searchParams); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/route.ts index 3b6750e9ef3..8e73a6748e0 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/route.ts @@ -8,7 +8,7 @@ import * as z from "zod/v4"; // GET /api/partner-profile/programs - get all program enrollments for a given partnerId export const GET = withPartnerProfile( - async ({ partner, searchParams, assignedProgramIds }) => { + async ({ partner, searchParams, partnerUser: { assignedProgramIds } }) => { const { includeRewardsDiscounts, status } = partnerProfileProgramsQuerySchema.parse(searchParams); diff --git a/apps/web/lib/auth/partner.ts b/apps/web/lib/auth/partner.ts index 18bbecf49c9..c99d6a7529a 100644 --- a/apps/web/lib/auth/partner.ts +++ b/apps/web/lib/auth/partner.ts @@ -25,8 +25,6 @@ interface WithPartnerProfileHandler { session, partner, partnerUser, - assignedProgramIds, - assignedLinkIds, }: { req: Request; params: Record; @@ -34,9 +32,10 @@ interface WithPartnerProfileHandler { headers?: Headers; session: Session; partner: Omit; - partnerUser: Pick; - assignedProgramIds: string[]; - assignedLinkIds: string[]; + partnerUser: Pick & { + assignedProgramIds: string[]; + assignedLinkIds: string[]; + }; }): Promise; } @@ -318,9 +317,9 @@ export const withPartnerProfile = ( id: partnerUser.id, userId: partnerUser.userId, role: partnerUser.role, + assignedProgramIds, + assignedLinkIds, }, - assignedProgramIds, - assignedLinkIds, headers: responseHeaders, }); } catch (error) { From 4966fbc5d248ce7e296150f9d71056ace161bb0e Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 1 Apr 2026 22:29:05 +0530 Subject: [PATCH 05/86] Enforce partner role permissions on partner server actions --- .../actions/partners/accept-program-invite.ts | 8 ++++- .../partners/create-bounty-submission.ts | 8 ++++- .../partners/mark-program-messages-read.ts | 8 ++++- .../partners/merge-partner-accounts.ts | 8 ++++- .../lib/actions/partners/message-program.ts | 8 ++++- .../start-partner-platform-verification.ts | 8 ++++- .../partners/update-partner-platforms.ts | 8 ++++- .../partners/verify-partner-website.ts | 8 ++++- .../partners/verify-social-account-by-code.ts | 8 ++++- .../partners/withdraw-partner-application.ts | 22 ++++++------- .../partner-users/partner-user-permissions.ts | 31 +++++++++---------- 11 files changed, 87 insertions(+), 38 deletions(-) diff --git a/apps/web/lib/actions/partners/accept-program-invite.ts b/apps/web/lib/actions/partners/accept-program-invite.ts index 86de8597d34..8eea3175715 100644 --- a/apps/web/lib/actions/partners/accept-program-invite.ts +++ b/apps/web/lib/actions/partners/accept-program-invite.ts @@ -1,5 +1,6 @@ "use server"; +import { throwIfNoPermission } from "@/lib/auth/partner-users/throw-if-no-permission"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { EnrolledPartnerSchema } from "@/lib/zod/schemas/partners"; import { prisma } from "@dub/prisma"; @@ -14,9 +15,14 @@ const acceptProgramInviteSchema = z.object({ export const acceptProgramInviteAction = authPartnerActionClient .inputSchema(acceptProgramInviteSchema) .action(async ({ parsedInput, ctx }) => { - const { partner } = ctx; + const { partner, partnerUser } = ctx; const { programId } = parsedInput; + throwIfNoPermission({ + role: partnerUser.role, + permission: "program_invites.accept", + }); + const enrollment = await prisma.programEnrollment.update({ where: { partnerId_programId: { diff --git a/apps/web/lib/actions/partners/create-bounty-submission.ts b/apps/web/lib/actions/partners/create-bounty-submission.ts index 5e5d91da678..366a4021097 100644 --- a/apps/web/lib/actions/partners/create-bounty-submission.ts +++ b/apps/web/lib/actions/partners/create-bounty-submission.ts @@ -1,5 +1,6 @@ "use server"; +import { throwIfNoPermission } from "@/lib/auth/partner-users/throw-if-no-permission"; import { BountySubmissionHandler } from "@/lib/bounty/api/create-bounty-submission"; import { createBountySubmissionInputSchema } from "@/lib/zod/schemas/bounties"; import { authPartnerActionClient } from "../safe-action"; @@ -7,7 +8,12 @@ import { authPartnerActionClient } from "../safe-action"; export const createBountySubmissionAction = authPartnerActionClient .inputSchema(createBountySubmissionInputSchema) .action(async ({ ctx, parsedInput }) => { - const { partner } = ctx; + const { partner, partnerUser } = ctx; + + throwIfNoPermission({ + role: partnerUser.role, + permission: "bounties.submit", + }); const submissionHandler = new BountySubmissionHandler({ ...parsedInput, diff --git a/apps/web/lib/actions/partners/mark-program-messages-read.ts b/apps/web/lib/actions/partners/mark-program-messages-read.ts index 7403c6bc2c0..db374862f1b 100644 --- a/apps/web/lib/actions/partners/mark-program-messages-read.ts +++ b/apps/web/lib/actions/partners/mark-program-messages-read.ts @@ -1,5 +1,6 @@ "use server"; +import { throwIfNoPermission } from "@/lib/auth/partner-users/throw-if-no-permission"; import { prisma } from "@dub/prisma"; import * as z from "zod/v4"; import { authPartnerActionClient } from "../safe-action"; @@ -12,9 +13,14 @@ const schema = z.object({ export const markProgramMessagesReadAction = authPartnerActionClient .inputSchema(schema) .action(async ({ parsedInput, ctx }) => { - const { partner } = ctx; + const { partner, partnerUser } = ctx; const { programSlug } = parsedInput; + throwIfNoPermission({ + role: partnerUser.role, + permission: "messages.mark_as_read", + }); + const program = await prisma.program.findFirstOrThrow({ select: { id: true, diff --git a/apps/web/lib/actions/partners/merge-partner-accounts.ts b/apps/web/lib/actions/partners/merge-partner-accounts.ts index 5217f0e6189..c536dcc6f55 100644 --- a/apps/web/lib/actions/partners/merge-partner-accounts.ts +++ b/apps/web/lib/actions/partners/merge-partner-accounts.ts @@ -1,5 +1,6 @@ "use server"; +import { throwIfNoPermission } from "@/lib/auth/partner-users/throw-if-no-permission"; import { generateOTP } from "@/lib/auth/utils"; import { qstash } from "@/lib/cron"; import { ratelimit, redis } from "@/lib/upstash"; @@ -39,9 +40,14 @@ const schema = z.discriminatedUnion("step", [ export const mergePartnerAccountsAction = authPartnerActionClient .inputSchema(schema) .action(async ({ parsedInput, ctx }) => { - const { user } = ctx; + const { user, partnerUser } = ctx; const { step } = parsedInput; + throwIfNoPermission({ + role: partnerUser.role, + permission: "partner_profile.update", + }); + switch (step) { case "send-tokens": return await sendTokens({ diff --git a/apps/web/lib/actions/partners/message-program.ts b/apps/web/lib/actions/partners/message-program.ts index 05fbd08b4e7..4f93c3a7f08 100644 --- a/apps/web/lib/actions/partners/message-program.ts +++ b/apps/web/lib/actions/partners/message-program.ts @@ -1,6 +1,7 @@ "use server"; import { createId } from "@/lib/api/create-id"; +import { throwIfNoPermission } from "@/lib/auth/partner-users/throw-if-no-permission"; import { qstash } from "@/lib/cron"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; @@ -15,9 +16,14 @@ import { authPartnerActionClient } from "../safe-action"; export const messageProgramAction = authPartnerActionClient .inputSchema(messageProgramSchema) .action(async ({ parsedInput, ctx }) => { - const { partner, user } = ctx; + const { partner, user, partnerUser } = ctx; const { programSlug, text } = parsedInput; + throwIfNoPermission({ + role: partnerUser.role, + permission: "messages.send", + }); + const program = await prisma.program.findFirstOrThrow({ select: { id: true, diff --git a/apps/web/lib/actions/partners/start-partner-platform-verification.ts b/apps/web/lib/actions/partners/start-partner-platform-verification.ts index 6c8c177c3f3..b8074358972 100644 --- a/apps/web/lib/actions/partners/start-partner-platform-verification.ts +++ b/apps/web/lib/actions/partners/start-partner-platform-verification.ts @@ -6,6 +6,7 @@ import { } from "@/lib/api/oauth/utils"; import { PARTNER_PLATFORMS_PROVIDERS } from "@/lib/api/partner-profile/partner-platforms-providers"; import { upsertPartnerPlatform } from "@/lib/api/partner-profile/upsert-partner-platform"; +import { throwIfNoPermission } from "@/lib/auth/partner-users/throw-if-no-permission"; import { generateOTP } from "@/lib/auth/utils"; import { extractEmailDomain } from "@/lib/email/extract-email-domain"; import { isGenericEmail } from "@/lib/is-generic-email"; @@ -56,9 +57,14 @@ type VerificationParams = { export const startPartnerPlatformVerificationAction = authPartnerActionClient .inputSchema(startPartnerPlatformVerificationSchema) .action(async ({ ctx, parsedInput }) => { - const { partner } = ctx; + const { partner, partnerUser } = ctx; const { platform, handle, source } = parsedInput; + throwIfNoPermission({ + role: partnerUser.role, + permission: "partner_profile.update", + }); + // Rate limit check const { success } = await ratelimit(5, "1 h").limit( `social-verification:${partner.id}:${platform}`, diff --git a/apps/web/lib/actions/partners/update-partner-platforms.ts b/apps/web/lib/actions/partners/update-partner-platforms.ts index 718a7e2c2c5..d1cd49d3cb9 100644 --- a/apps/web/lib/actions/partners/update-partner-platforms.ts +++ b/apps/web/lib/actions/partners/update-partner-platforms.ts @@ -1,6 +1,7 @@ "use server"; import { upsertPartnerPlatform } from "@/lib/api/partner-profile/upsert-partner-platform"; +import { throwIfNoPermission } from "@/lib/auth/partner-users/throw-if-no-permission"; import { sanitizeSocialHandle, sanitizeWebsite } from "@/lib/social-utils"; import { parseUrlSchemaAllowEmpty } from "@/lib/zod/schemas/utils"; import { prisma } from "@dub/prisma"; @@ -59,7 +60,12 @@ const updatePartnerPlatformsSchema = z.object({ export const updatePartnerPlatformsAction = authPartnerActionClient .inputSchema(updatePartnerPlatformsSchema) .action(async ({ ctx, parsedInput }) => { - const { partner } = ctx; + const { partner, partnerUser } = ctx; + + throwIfNoPermission({ + role: partnerUser.role, + permission: "partner_profile.update", + }); const partnerPlatform = await prisma.partnerPlatform.findMany({ where: { diff --git a/apps/web/lib/actions/partners/verify-partner-website.ts b/apps/web/lib/actions/partners/verify-partner-website.ts index 6284ceee133..01487ed87bb 100644 --- a/apps/web/lib/actions/partners/verify-partner-website.ts +++ b/apps/web/lib/actions/partners/verify-partner-website.ts @@ -1,5 +1,6 @@ "use server"; +import { throwIfNoPermission } from "@/lib/auth/partner-users/throw-if-no-permission"; import { prisma } from "@dub/prisma"; import { getDomainWithoutWWW } from "@dub/utils"; import dns from "dns"; @@ -7,7 +8,12 @@ import { authPartnerActionClient } from "../safe-action"; export const verifyPartnerWebsiteAction = authPartnerActionClient.action( async ({ ctx }) => { - const { partner } = ctx; + const { partner, partnerUser } = ctx; + + throwIfNoPermission({ + role: partnerUser.role, + permission: "partner_profile.update", + }); const partnerPlatform = await prisma.partnerPlatform.findUnique({ where: { diff --git a/apps/web/lib/actions/partners/verify-social-account-by-code.ts b/apps/web/lib/actions/partners/verify-social-account-by-code.ts index 389039df451..a5d9d4df663 100644 --- a/apps/web/lib/actions/partners/verify-social-account-by-code.ts +++ b/apps/web/lib/actions/partners/verify-social-account-by-code.ts @@ -1,5 +1,6 @@ "use server"; +import { throwIfNoPermission } from "@/lib/auth/partner-users/throw-if-no-permission"; import { getLinkedInPost } from "@/lib/api/scrape-creators/get-linkedin-post"; import { getSocialProfile } from "@/lib/api/scrape-creators/get-social-profile"; import { ratelimit } from "@/lib/upstash"; @@ -19,9 +20,14 @@ const schema = z.object({ export const verifySocialAccountByCodeAction = authPartnerActionClient .inputSchema(schema) .action(async ({ ctx, parsedInput }) => { - const { partner } = ctx; + const { partner, partnerUser } = ctx; const { platform, handle, postUrl } = parsedInput; + throwIfNoPermission({ + role: partnerUser.role, + permission: "partner_profile.update", + }); + if (!["youtube", "instagram", "linkedin"].includes(platform)) { throw new Error("Only YouTube, Instagram, and LinkedIn are supported."); } diff --git a/apps/web/lib/actions/partners/withdraw-partner-application.ts b/apps/web/lib/actions/partners/withdraw-partner-application.ts index c2c559b9b30..4c3f1059581 100644 --- a/apps/web/lib/actions/partners/withdraw-partner-application.ts +++ b/apps/web/lib/actions/partners/withdraw-partner-application.ts @@ -1,26 +1,24 @@ "use server"; +import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { prisma } from "@dub/prisma"; import * as z from "zod/v4"; import { authPartnerActionClient } from "../safe-action"; +const inputSchema = z.object({ + programId: z.string(), +}); + export const withdrawPartnerApplicationAction = authPartnerActionClient - .inputSchema( - z.object({ - programId: z.string(), - }), - ) + .inputSchema(inputSchema) .action(async ({ ctx, parsedInput }) => { const { programId } = parsedInput; const { partner } = ctx; - const programEnrollment = await prisma.programEnrollment.findUniqueOrThrow({ - where: { - partnerId_programId: { - partnerId: partner.id, - programId, - }, - }, + const programEnrollment = await getProgramEnrollmentOrThrow({ + partnerId: partner.id, + programId, + include: {}, }); if (programEnrollment.status !== "pending") { diff --git a/apps/web/lib/auth/partner-users/partner-user-permissions.ts b/apps/web/lib/auth/partner-users/partner-user-permissions.ts index df2f1f153d1..5428b28c677 100644 --- a/apps/web/lib/auth/partner-users/partner-user-permissions.ts +++ b/apps/web/lib/auth/partner-users/partner-user-permissions.ts @@ -12,26 +12,23 @@ const PERMISSIONS = [ "payout_settings.update", "postbacks.read", "postbacks.write", + "messages.send", + "messages.mark_as_read", + "program_invites.accept", + "bounties.submit", ] as const; -const ROLE_PERMISSIONS: Record = { - owner: [ - "users.update", - "users.delete", - "user_invites.create", - "user_invites.delete", - "user_invites.update", - "partner_profile.update", - "payout_settings.update", - "postbacks.read", - "postbacks.write", - ], - member: [], - viewer: [], +const ROLE_PERMISSIONS: Record> = { + owner: new Set(PERMISSIONS), + member: new Set([ + "messages.send", + "messages.mark_as_read", + "program_invites.accept", + "bounties.submit", + ]), + viewer: new Set([]), } as const; export function hasPermission(role: PartnerRole, permission: Permission) { - const allowed = ROLE_PERMISSIONS[role] ?? []; - - return allowed.includes(permission); + return ROLE_PERMISSIONS[role]?.has(permission) ?? false; } From 98ef49836e23f5372e15de8f47b412c259949453 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 2 Apr 2026 21:26:57 +0530 Subject: [PATCH 06/86] Add 'payouts.read' permission to partner profile payout routes --- apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts | 3 +++ apps/web/app/(ee)/api/partner-profile/payouts/route.ts | 3 +++ apps/web/lib/auth/partner-users/partner-user-permissions.ts | 2 ++ 3 files changed, 8 insertions(+) diff --git a/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts b/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts index 364975e94d5..9cd6024310b 100644 --- a/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts @@ -58,4 +58,7 @@ export const GET = withPartnerProfile( return NextResponse.json(count); }, + { + requiredPermission: "payouts.read", + }, ); diff --git a/apps/web/app/(ee)/api/partner-profile/payouts/route.ts b/apps/web/app/(ee)/api/partner-profile/payouts/route.ts index 7e153ac4796..bc7a3ce7881 100644 --- a/apps/web/app/(ee)/api/partner-profile/payouts/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/payouts/route.ts @@ -58,4 +58,7 @@ export const GET = withPartnerProfile( z.array(PartnerPayoutResponseSchema).parse(transformedPayouts), ); }, + { + requiredPermission: "payouts.read", + }, ); diff --git a/apps/web/lib/auth/partner-users/partner-user-permissions.ts b/apps/web/lib/auth/partner-users/partner-user-permissions.ts index 5428b28c677..fd071c94a30 100644 --- a/apps/web/lib/auth/partner-users/partner-user-permissions.ts +++ b/apps/web/lib/auth/partner-users/partner-user-permissions.ts @@ -16,6 +16,7 @@ const PERMISSIONS = [ "messages.mark_as_read", "program_invites.accept", "bounties.submit", + "payouts.read", ] as const; const ROLE_PERMISSIONS: Record> = { @@ -25,6 +26,7 @@ const ROLE_PERMISSIONS: Record> = { "messages.mark_as_read", "program_invites.accept", "bounties.submit", + "payouts.read", ]), viewer: new Set([]), } as const; From 4441542a8ac7af43d373cfc09dfa7d130738f217 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 2 Apr 2026 21:30:15 +0530 Subject: [PATCH 07/86] Add missing partner permission checks for links, bounties, and referrals Add links.write and referrals.submit permissions, enforce them on partner link CRUD routes, bounty file upload, and referral submission to prevent viewer/member role bypass. --- .../[programId]/links/[linkId]/route.ts | 98 +++++++++++-------- .../programs/[programId]/links/route.ts | 3 + .../partners/upload-bounty-submission-file.ts | 8 +- .../lib/actions/referrals/submit-referral.ts | 8 +- .../partner-users/partner-user-permissions.ts | 4 + 5 files changed, 76 insertions(+), 45 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts index 3eebfcc2586..da52f3e9b21 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts @@ -153,55 +153,67 @@ export const PATCH = withPartnerProfile( return NextResponse.json(PartnerProfileLinkSchema.parse(partnerLink)); }, + { + requiredPermission: "links.write", + }, ); // DELETE /api/partner-profile/[programId]/links/[linkId] - delete a link for a partner -export const DELETE = withPartnerProfile(async ({ partner, params }) => { - const { programId, linkId } = params; - - const { links, status } = await getProgramEnrollmentOrThrow({ - partnerId: partner.id, - programId, - include: { - links: true, - }, - }); - - if (["banned", "deactivated"].includes(status)) { - throw new DubApiError({ - code: "forbidden", - message: "You are banned from this program.", +export const DELETE = withPartnerProfile( + async ({ partner, params }) => { + const { programId, linkId } = params; + + const { links, status } = await getProgramEnrollmentOrThrow({ + partnerId: partner.id, + programId, + include: { + links: true, + }, }); - } - const link = links.find((link) => link.id === linkId); + if (["banned", "deactivated"].includes(status)) { + throw new DubApiError({ + code: "forbidden", + message: "You are banned from this program.", + }); + } - if (!link) { - throw new DubApiError({ - code: "not_found", - message: "Link not found.", - }); - } + const link = links.find((link) => link.id === linkId); - // Check if this is a default link - if (link.partnerGroupDefaultLinkId) { - throw new DubApiError({ - code: "forbidden", - message: "You cannot delete your default link.", - }); - } - - // Check if link has any clicks, leads, or sales - if (link.clicks > 0 || link.leads > 0 || toCentsNumber(link.saleAmount) > 0) { - throw new DubApiError({ - code: "bad_request", - message: - "You can only delete links with 0 clicks, 0 leads, and $0 in sales.", - }); - } + if (!link) { + throw new DubApiError({ + code: "not_found", + message: "Link not found.", + }); + } - // Delete the link - await deleteLink(link.id); + // Check if this is a default link + if (link.partnerGroupDefaultLinkId) { + throw new DubApiError({ + code: "forbidden", + message: "You cannot delete your default link.", + }); + } - return NextResponse.json({ id: link.id }); -}); + // Check if link has any clicks, leads, or sales + if ( + link.clicks > 0 || + link.leads > 0 || + toCentsNumber(link.saleAmount) > 0 + ) { + throw new DubApiError({ + code: "bad_request", + message: + "You can only delete links with 0 clicks, 0 leads, and $0 in sales.", + }); + } + + // Delete the link + await deleteLink(link.id); + + return NextResponse.json({ id: link.id }); + }, + { + requiredPermission: "links.write", + }, +); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts index cfcab2c5da2..e8448195d18 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts @@ -151,4 +151,7 @@ export const POST = withPartnerProfile( status: 201, }); }, + { + requiredPermission: "links.write", + }, ); diff --git a/apps/web/lib/actions/partners/upload-bounty-submission-file.ts b/apps/web/lib/actions/partners/upload-bounty-submission-file.ts index 05b1b83621b..440fea1d789 100644 --- a/apps/web/lib/actions/partners/upload-bounty-submission-file.ts +++ b/apps/web/lib/actions/partners/upload-bounty-submission-file.ts @@ -1,6 +1,7 @@ "use server"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; +import { throwIfNoPermission } from "@/lib/auth/partner-users/throw-if-no-permission"; import { getBountySubmissionUploadUrl } from "@/lib/bounty/api/get-bounty-submission-upload-url"; import * as z from "zod/v4"; import { authPartnerActionClient } from "../safe-action"; @@ -16,7 +17,12 @@ const schema = z.object({ export const uploadBountySubmissionFileAction = authPartnerActionClient .inputSchema(schema) .action(async ({ ctx, parsedInput }) => { - const { partner } = ctx; + const { partner, partnerUser } = ctx; + + throwIfNoPermission({ + role: partnerUser.role, + permission: "bounties.submit", + }); const { programId, bountyId, fileName, contentType, contentLength } = parsedInput; diff --git a/apps/web/lib/actions/referrals/submit-referral.ts b/apps/web/lib/actions/referrals/submit-referral.ts index 1a63e15c7af..8a5028b9d18 100644 --- a/apps/web/lib/actions/referrals/submit-referral.ts +++ b/apps/web/lib/actions/referrals/submit-referral.ts @@ -5,6 +5,7 @@ import { createId } from "@/lib/api/create-id"; import { DubApiError } from "@/lib/api/errors"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { notifyPartnerReferralSubmitted } from "@/lib/api/referrals/notify-partner-referral-submitted"; +import { throwIfNoPermission } from "@/lib/auth/partner-users/throw-if-no-permission"; import { REFERRAL_FORM_REQUIRED_FIELD_KEYS } from "@/lib/referrals/constants"; import { ReferralFormDataField } from "@/lib/types"; import { @@ -61,9 +62,14 @@ function convertFieldValue( export const submitReferralAction = authPartnerActionClient .inputSchema(createPartnerReferralSchema) .action(async ({ parsedInput, ctx }) => { - const { partner, user } = ctx; + const { partner, user, partnerUser } = ctx; const { programId, formData: rawFormData } = parsedInput; + throwIfNoPermission({ + role: partnerUser.role, + permission: "referrals.submit", + }); + const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId, diff --git a/apps/web/lib/auth/partner-users/partner-user-permissions.ts b/apps/web/lib/auth/partner-users/partner-user-permissions.ts index fd071c94a30..4ec98a8dcb4 100644 --- a/apps/web/lib/auth/partner-users/partner-user-permissions.ts +++ b/apps/web/lib/auth/partner-users/partner-user-permissions.ts @@ -16,6 +16,8 @@ const PERMISSIONS = [ "messages.mark_as_read", "program_invites.accept", "bounties.submit", + "links.write", + "referrals.submit", "payouts.read", ] as const; @@ -26,6 +28,8 @@ const ROLE_PERMISSIONS: Record> = { "messages.mark_as_read", "program_invites.accept", "bounties.submit", + "links.write", + "referrals.submit", "payouts.read", ]), viewer: new Set([]), From 04e7bcbf41f7a15b5357b9e7204c6e2f28dd26eb Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 2 Apr 2026 21:56:19 +0530 Subject: [PATCH 08/86] Add payout_settings.read permission and scope earnings/customers by assigned links Add payout_settings.read permission to gate payout settings endpoint. Scope customers and earnings routes by assignedLinkIds for restricted partner users. --- .../api/partner-profile/payouts/settings/route.ts | 13 +++++++++---- .../programs/[programId]/customers/count/route.ts | 8 +++++++- .../programs/[programId]/customers/route.ts | 8 +++++++- .../programs/[programId]/earnings/count/route.ts | 8 +++++++- .../programs/[programId]/earnings/route.ts | 8 +++++++- .../[programId]/earnings/timeseries/route.ts | 8 +++++++- .../partner-profile/get-earnings-for-partner.ts | 3 +++ .../get-partner-earnings-timeseries.ts | 15 ++++++++++----- .../partner-users/partner-user-permissions.ts | 2 ++ 9 files changed, 59 insertions(+), 14 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/payouts/settings/route.ts b/apps/web/app/(ee)/api/partner-profile/payouts/settings/route.ts index 4f381434338..160be79217c 100644 --- a/apps/web/app/(ee)/api/partner-profile/payouts/settings/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/payouts/settings/route.ts @@ -3,8 +3,13 @@ import { getPartnerPayoutMethods } from "@/lib/payouts/get-partner-payout-method import { NextResponse } from "next/server"; // GET /api/partner-profile/payouts/settings -export const GET = withPartnerProfile(async ({ partner }) => { - const payoutMethods = await getPartnerPayoutMethods(partner); +export const GET = withPartnerProfile( + async ({ partner }) => { + const payoutMethods = await getPartnerPayoutMethods(partner); - return NextResponse.json(payoutMethods); -}); + return NextResponse.json(payoutMethods); + }, + { + requiredPermission: "payout_settings.read", + }, +); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/count/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/count/route.ts index 7a9160e81b9..d0b62576a59 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/count/route.ts @@ -13,7 +13,12 @@ import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/:programId/customers/count – Get customer counts grouped by a field export const GET = withPartnerProfile( - async ({ partner, params, searchParams }) => { + async ({ + partner, + params, + searchParams, + partnerUser: { assignedLinkIds }, + }) => { const { programId } = params; const { search, country, linkId, groupBy } = getPartnerCustomersCountQuerySchema.parse(searchParams); @@ -61,6 +66,7 @@ export const GET = withPartnerProfile( name: { search: sanitizeFullTextSearch(search) }, } : {}), + ...(assignedLinkIds ? { linkId: { in: assignedLinkIds } } : {}), }; // Get customer count by country diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/route.ts index 972dc2be0dc..8472e88ad0d 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/route.ts @@ -20,7 +20,12 @@ import * as z from "zod/v4"; // GET /api/partner-profile/programs/:programId/customers – Get all customers for a partner program export const GET = withPartnerProfile( - async ({ partner, params, searchParams }) => { + async ({ + partner, + params, + searchParams, + partnerUser: { assignedLinkIds }, + }) => { const { programId } = params; const { search, @@ -60,6 +65,7 @@ export const GET = withPartnerProfile( projectId: program.workspaceId, ...(country && { country }), ...(linkId && { linkId }), + ...(assignedLinkIds ? { linkId: { in: assignedLinkIds } } : {}), // Only allow search if customer data sharing is enabled ...(search && customerDataSharingEnabledAt ? search.includes("@") diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts index 05b0a467ce3..4225e30516c 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts @@ -10,7 +10,12 @@ import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/[programId]/earnings/count – get earnings count for a partner in a program enrollment export const GET = withPartnerProfile( - async ({ partner, params, searchParams }) => { + async ({ + partner, + params, + searchParams, + partnerUser: { assignedLinkIds }, + }) => { const { program, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, @@ -50,6 +55,7 @@ export const GET = withPartnerProfile( gte: startDate, lte: endDate, }, + ...(assignedLinkIds ? { linkId: { in: assignedLinkIds } } : {}), }; if (groupBy) { diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts index ab10116d8ad..ec417371883 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts @@ -6,7 +6,12 @@ import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/[programId]/earnings – get earnings for a partner in a program enrollment export const GET = withPartnerProfile( - async ({ partner, params, searchParams }) => { + async ({ + partner, + params, + searchParams, + partnerUser: { assignedLinkIds }, + }) => { const { programId, partnerId, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, @@ -21,6 +26,7 @@ export const GET = withPartnerProfile( programId, partnerId, customerDataSharingEnabledAt, + linkIds: assignedLinkIds, }); return NextResponse.json(earnings); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts index d1243b97c95..be249fb8557 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts @@ -5,13 +5,19 @@ import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/[programId]/earnings/timeseries - get timeseries chart for a partner's earnings export const GET = withPartnerProfile( - async ({ partner, params, searchParams }) => { + async ({ + partner, + params, + searchParams, + partnerUser: { assignedLinkIds }, + }) => { const filters = getPartnerEarningsTimeseriesSchema.parse(searchParams); const timeseries = await getPartnerEarningsTimeseries({ partnerId: partner.id, programId: params.programId, filters, + linkIds: assignedLinkIds, }); return NextResponse.json(timeseries); diff --git a/apps/web/lib/api/partner-profile/get-earnings-for-partner.ts b/apps/web/lib/api/partner-profile/get-earnings-for-partner.ts index 88b4b3c8acb..97eefa6787f 100644 --- a/apps/web/lib/api/partner-profile/get-earnings-for-partner.ts +++ b/apps/web/lib/api/partner-profile/get-earnings-for-partner.ts @@ -13,6 +13,7 @@ interface GetEarningsForPartnerParams programId: string; partnerId: string; customerDataSharingEnabledAt: Date | null; + linkIds?: string[]; } export async function getEarningsForPartner( @@ -35,6 +36,7 @@ export async function getEarningsForPartner( programId, partnerId, customerDataSharingEnabledAt, + linkIds, } = params; const { startDate, endDate } = getStartEndDates({ @@ -54,6 +56,7 @@ export async function getEarningsForPartner( status, type, linkId, + ...(linkIds ? { linkId: { in: linkIds } } : {}), customerId, payoutId, createdAt: { diff --git a/apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts b/apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts index c3d91522660..560103ed979 100644 --- a/apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts +++ b/apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts @@ -7,15 +7,19 @@ import { Prisma } from "@dub/prisma/client"; import { format } from "date-fns"; import * as z from "zod/v4"; +interface GetPartnerEarningsTimeseriesParams { + partnerId: string; + programId: string; + filters: z.infer; + linkIds?: string[]; +} + export async function getPartnerEarningsTimeseries({ partnerId, programId, filters, -}: { - partnerId: string; - programId: string; - filters: z.infer; -}) { + linkIds, +}: GetPartnerEarningsTimeseriesParams) { const { groupBy, type, @@ -64,6 +68,7 @@ export async function getPartnerEarningsTimeseries({ ${type ? Prisma.sql`AND type = ${type}` : Prisma.sql``} ${payoutId ? Prisma.sql`AND payoutId = ${payoutId}` : Prisma.sql``} ${linkId ? Prisma.sql`AND linkId = ${linkId}` : Prisma.sql``} + ${linkIds ? Prisma.sql`AND linkId IN (${Prisma.join(linkIds)})` : Prisma.sql``} ${customerId ? Prisma.sql`AND customerId = ${customerId}` : Prisma.sql``} ${status ? Prisma.sql`AND status = ${status}` : Prisma.sql``} GROUP BY start${groupBy ? (groupBy === "type" ? Prisma.sql`, type` : Prisma.sql`, linkId`) : Prisma.sql``} diff --git a/apps/web/lib/auth/partner-users/partner-user-permissions.ts b/apps/web/lib/auth/partner-users/partner-user-permissions.ts index 4ec98a8dcb4..d133aab2cb8 100644 --- a/apps/web/lib/auth/partner-users/partner-user-permissions.ts +++ b/apps/web/lib/auth/partner-users/partner-user-permissions.ts @@ -10,6 +10,7 @@ const PERMISSIONS = [ "user_invites.update", "partner_profile.update", "payout_settings.update", + "payout_settings.read", "postbacks.read", "postbacks.write", "messages.send", @@ -24,6 +25,7 @@ const PERMISSIONS = [ const ROLE_PERMISSIONS: Record> = { owner: new Set(PERMISSIONS), member: new Set([ + "payout_settings.read", "messages.send", "messages.mark_as_read", "program_invites.accept", From c9817d068c497b9b1f6a1d521c3ad2e762098fd1 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 2 Apr 2026 22:06:21 +0530 Subject: [PATCH 09/86] Hide Payouts, Messages, Postbacks tabs from partner viewers Gate sidebar nav items by permissions: Payouts requires payouts.read, Messages requires messages.send, Postbacks requires postbacks.read. Also hides PayoutStats widget from unauthorized roles. --- .../layout/sidebar/partners-sidebar-nav.tsx | 76 ++++++++++++------- 1 file changed, 50 insertions(+), 26 deletions(-) diff --git a/apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx b/apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx index c696705c84b..87c7df8a81d 100644 --- a/apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx +++ b/apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx @@ -1,11 +1,13 @@ "use client"; +import { hasPermission } from "@/lib/auth/partner-users/partner-user-permissions"; import usePartnerProfile from "@/lib/swr/use-partner-profile"; import { usePartnerProgramBounties } from "@/lib/swr/use-partner-program-bounties"; import useProgramEnrollment from "@/lib/swr/use-program-enrollment"; import useProgramEnrollmentsCount from "@/lib/swr/use-program-enrollments-count"; import { useProgramMessagesCount } from "@/lib/swr/use-program-messages-count"; import { ProgramsPromoCard } from "@/ui/partners/program-marketplace/programs-promo-card"; +import type { PartnerRole } from "@dub/prisma/client"; import { useRouterStuff } from "@dub/ui"; import { Bell, @@ -45,11 +47,13 @@ type SidebarNavData = { programBountiesCount?: number; showDetailedAnalytics?: boolean; postbacksEnabled?: boolean; + partnerRole?: PartnerRole; }; const NAV_GROUPS: SidebarNavGroups = ({ pathname, unreadMessagesCount, + partnerRole, }) => [ { name: "Programs", @@ -59,14 +63,18 @@ const NAV_GROUPS: SidebarNavGroups = ({ href: "/programs", active: pathname.startsWith("/programs"), }, - { - name: "Payouts", - description: - "View all your upcoming and previous payouts for all your programs.", - icon: MoneyBills2, - href: "/payouts", - active: pathname.startsWith("/payouts"), - }, + ...(partnerRole && hasPermission(partnerRole, "payouts.read") + ? [ + { + name: "Payouts", + description: + "View all your upcoming and previous payouts for all your programs.", + icon: MoneyBills2, + href: "/payouts" as `/${string}`, + active: pathname.startsWith("/payouts"), + }, + ] + : []), { name: "Partner profile", description: @@ -75,14 +83,20 @@ const NAV_GROUPS: SidebarNavGroups = ({ href: "/profile", active: pathname.startsWith("/profile"), }, - { - name: "Messages", - description: "Chat with programs you're enrolled in", - icon: Msgs, - href: "/messages", - active: pathname.startsWith("/messages"), - badge: unreadMessagesCount ? Math.min(9, unreadMessagesCount) : undefined, - }, + ...(partnerRole && hasPermission(partnerRole, "messages.send") + ? [ + { + name: "Messages", + description: "Chat with programs you're enrolled in", + icon: Msgs, + href: "/messages" as `/${string}`, + active: pathname.startsWith("/messages"), + badge: unreadMessagesCount + ? Math.min(9, unreadMessagesCount) + : undefined, + }, + ] + : []), ]; const NAV_AREAS: SidebarNavAreas = { @@ -125,7 +139,7 @@ const NAV_AREAS: SidebarNavAreas = { ], }), - profile: ({ postbacksEnabled }) => ({ + profile: ({ postbacksEnabled, partnerRole }) => ({ title: "Partner profile", direction: "left", content: [ @@ -144,7 +158,9 @@ const NAV_AREAS: SidebarNavAreas = { }, ], }, - ...(postbacksEnabled + ...(postbacksEnabled && + partnerRole && + hasPermission(partnerRole, "postbacks.read") ? [ { name: "Developer", @@ -177,6 +193,7 @@ const NAV_AREAS: SidebarNavAreas = { queryString, programBountiesCount, showDetailedAnalytics, + partnerRole, }) => ({ title: (
@@ -198,13 +215,17 @@ const NAV_AREAS: SidebarNavAreas = { href: `/programs/${programSlug}/links`, locked: isUnapproved, }, - { - name: "Messages", - icon: Msgs, - href: `/messages/${programSlug}` as `/${string}`, - locked: isUnapproved, - arrow: true, - }, + ...(partnerRole && hasPermission(partnerRole, "messages.send") + ? [ + { + name: "Messages", + icon: Msgs, + href: `/messages/${programSlug}` as `/${string}`, + locked: isUnapproved, + arrow: true, + }, + ] + : []), ], }, { @@ -366,6 +387,7 @@ export function PartnersSidebarNav({ programBountiesCount: bountiesCount.active, showDetailedAnalytics, postbacksEnabled: partner?.featureFlags?.postbacks, + partnerRole: partner?.role, }} toolContent={toolContent} newsContent={newsContent} @@ -375,7 +397,9 @@ export function PartnersSidebarNav({ ) : ( <> - + {partner?.role && hasPermission(partner.role, "payouts.read") && ( + + )} ) } From 12ea11edac46a17f2bb1e54fb7046dc124f281c2 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 3 Apr 2026 12:13:55 +0530 Subject: [PATCH 10/86] Update footer --- .../ui/modals/invite-partner-user-modal.tsx | 142 ++++++++++-------- 1 file changed, 82 insertions(+), 60 deletions(-) diff --git a/apps/web/ui/modals/invite-partner-user-modal.tsx b/apps/web/ui/modals/invite-partner-user-modal.tsx index a73a950087c..66a15f7db1c 100644 --- a/apps/web/ui/modals/invite-partner-user-modal.tsx +++ b/apps/web/ui/modals/invite-partner-user-modal.tsx @@ -86,7 +86,6 @@ function InvitePartnerUserModal({

Invite Teammates

@@ -99,70 +98,93 @@ function InvitePartnerUserModal({
-
- - {pluralize("Email", fields.length)} - - - {fields.map((field, index) => ( -
-
-
- - +
+
+ + {pluralize("Email", fields.length)} + + + {fields.map((field, index) => ( +
+
+
+ + +
+ {index > 0 && ( +
- {index > 0 && ( -
- ))} - -
-
+
); From c2d6ca8b4ecb00efcaaa494b7e16b223bcb199fe Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 3 Apr 2026 13:17:48 +0530 Subject: [PATCH 11/86] Partner profile members: programs column, schema, and per-user assignment APIs --- .../(ee)/api/partner-profile/users/route.ts | 17 +++- .../profile/members/page-client.tsx | 32 ++++--- .../members/partner-member-programs-cell.tsx | 90 +++++++++++++++++++ apps/web/lib/zod/schemas/partner-profile.ts | 8 ++ 4 files changed, 136 insertions(+), 11 deletions(-) create mode 100644 apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx diff --git a/apps/web/app/(ee)/api/partner-profile/users/route.ts b/apps/web/app/(ee)/api/partner-profile/users/route.ts index e993df4a993..94489a7565e 100644 --- a/apps/web/app/(ee)/api/partner-profile/users/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/users/route.ts @@ -40,14 +40,29 @@ export const GET = withPartnerProfile(async ({ partner, searchParams }) => { }, include: { user: true, + assignedPrograms: { + orderBy: { + createdAt: "asc", + }, + include: { + program: { + select: { + id: true, + name: true, + logo: true, + }, + }, + }, + }, }, }); - const parsedUsers = users.map(({ user, ...rest }) => + const parsedUsers = users.map(({ user, assignedPrograms, ...rest }) => partnerUserSchema.parse({ ...rest, ...user, createdAt: rest.createdAt, // preserve the createdAt field from PartnerUser + programs: rest.role === "owner" ? [] : assignedPrograms, }), ); diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx index 4911b7d4b42..f5f69cffbbc 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx @@ -37,6 +37,7 @@ import { useSession } from "next-auth/react"; import { useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import useSWR from "swr"; +import { PartnerMemberProgramsCell } from "./partner-member-programs-cell"; export function ProfileMembersPageClient() { const { partner } = usePartnerProfile(); @@ -137,20 +138,20 @@ export function ProfileMembersPageClient() { id: "name", header: "Name", accessorFn: (row) => row.name || row.email, - minSize: 360, - size: 870, - maxSize: 900, + minSize: 250, cell: ({ row }) => { const user = row.original; + const isCurrentUser = session?.user?.email === user.email; return ( -
+
-
-

+
+

{user.name || user.email} + {isCurrentUser ? (You) : null}

-

+

{status === "invited" ? `Invited ${timeAgo(user.createdAt)}` : user.email} @@ -160,13 +161,24 @@ export function ProfileMembersPageClient() { ); }, }, + + { + id: "programs", + header: "Programs", + minSize: 80, + maxSize: 80, + meta: { disableTruncate: true }, + cell: ({ row }) => ( + + ), + }, { id: "role", header: "Role", accessorFn: (row) => row.role, - minSize: 120, - size: 150, - maxSize: 200, + minSize: 50, + maxSize: 50, + meta: { disableTruncate: true }, cell: ({ row }) => ( + {children} +

+ ); +} + +export function PartnerMemberProgramsCell({ + programs, +}: { + programs: PartnerUserProps["programs"]; +}) { + const { programEnrollments, isLoading: enrollmentsLoading } = + useProgramEnrollments(); + + const fromEnrollments = (programEnrollments ?? []).map((e) => ({ + id: e.programId, + name: e.program.name, + logo: e.program.logo, + })); + + const displayPrograms = programs.length > 0 ? programs : fromEnrollments; + + if (enrollmentsLoading && programs.length === 0) { + return ( +
+
+
+ ); + } + + if (displayPrograms.length === 0) { + return ( + +
+ +
+
+ ); + } + + const visible = displayPrograms.slice(0, MAX_VISIBLE_LOGOS); + const extra = + displayPrograms.length > MAX_VISIBLE_LOGOS + ? Math.min(displayPrograms.length - MAX_VISIBLE_LOGOS, MAX_OVERFLOW_LABEL) + : 0; + + return ( + +
+ {visible.map((p, index) => ( + 0 && "-ml-1.5", + )} + /> + ))} + {extra > 0 ? ( +
+ +{extra} +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/lib/zod/schemas/partner-profile.ts b/apps/web/lib/zod/schemas/partner-profile.ts index 484811cf87d..408457052de 100644 --- a/apps/web/lib/zod/schemas/partner-profile.ts +++ b/apps/web/lib/zod/schemas/partner-profile.ts @@ -28,6 +28,7 @@ import { CustomerEnrichedSchema } from "./customers"; import { LinkSchema } from "./links"; import { getPaginationQuerySchema } from "./misc"; import { payoutsQuerySchema } from "./payouts"; +import { ProgramSchema } from "./programs"; import { referralFormDataSchema } from "./referral-form"; import { centsSchema } from "./utils"; @@ -194,6 +195,13 @@ export const partnerUserSchema = z.object({ role: z.enum(PartnerRole), image: z.string().nullish(), createdAt: z.date(), + programs: z.array( + ProgramSchema.pick({ + id: true, + name: true, + logo: true, + }), + ), }); export const partnerProfileChangeHistoryLogSchema = z.array( From 43df4169fe9efe2b3ca6577a685bb4308a78e151 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 3 Apr 2026 13:17:52 +0530 Subject: [PATCH 12/86] Update route.ts --- apps/web/app/(ee)/api/partner-profile/invites/route.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/app/(ee)/api/partner-profile/invites/route.ts b/apps/web/app/(ee)/api/partner-profile/invites/route.ts index 7487a46fdd8..cf12b25091a 100644 --- a/apps/web/app/(ee)/api/partner-profile/invites/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/invites/route.ts @@ -36,6 +36,7 @@ export const GET = withPartnerProfile(async ({ partner, searchParams }) => { ...invite, id: null, name: invite.email, + programs: [], }), ); From 14add7c5db314631ee6a1a23819a867b299c9501 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 3 Apr 2026 15:01:37 +0530 Subject: [PATCH 13/86] assign and unassign the program --- .../users/[userId]/programs/route.ts | 148 +++++++++++ .../(ee)/api/partner-profile/users/route.ts | 1 + .../profile/members/page-client.tsx | 21 +- .../members/partner-member-programs-cell.tsx | 19 +- .../members/partner-member-programs-sheet.tsx | 251 ++++++++++++++++++ apps/web/lib/api/partner-profile/client.ts | 13 + apps/web/lib/zod/schemas/partner-profile.ts | 15 ++ 7 files changed, 463 insertions(+), 5 deletions(-) create mode 100644 apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts create mode 100644 apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx diff --git a/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts b/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts new file mode 100644 index 00000000000..1d2dc79b950 --- /dev/null +++ b/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts @@ -0,0 +1,148 @@ +import { DubApiError } from "@/lib/api/errors"; +import { parseRequestBody } from "@/lib/api/utils"; +import { withPartnerProfile } from "@/lib/auth/partner"; +import { + assignProgramInputSchema, + assignedProgramOutputSchema, +} from "@/lib/zod/schemas/partner-profile"; +import { prisma } from "@dub/prisma"; +import { NextResponse } from "next/server"; + +// PUT /api/partner-profile/users/[userId]/programs - set assigned programs for a user +export const PUT = withPartnerProfile( + async ({ partner, params, req }) => { + const { userId } = params; + const { programIds } = assignProgramInputSchema.parse( + await parseRequestBody(req), + ); + + const targetUser = await prisma.partnerUser.findUnique({ + where: { + userId_partnerId: { + userId, + partnerId: partner.id, + }, + }, + }); + + if (!targetUser) { + throw new DubApiError({ + code: "not_found", + message: "User not found.", + }); + } + + if (targetUser.role === "owner") { + throw new DubApiError({ + code: "bad_request", + message: "Cannot scope an owner to specific programs or links.", + }); + } + + // Validate all programIds are programs the partner is enrolled in + if (programIds.length > 0) { + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + partnerId: partner.id, + programId: { + in: programIds, + }, + }, + select: { + programId: true, + }, + }); + + const enrolledProgramIds = new Set( + programEnrollments.map((e) => e.programId), + ); + + const invalidIds = programIds.filter((id) => !enrolledProgramIds.has(id)); + + if (invalidIds.length > 0) { + throw new DubApiError({ + code: "bad_request", + message: `Invalid program IDs: ${invalidIds.join(", ")}`, + }); + } + } + + // Replace all program assignments in a transaction + // Also remove link assignments for programs that are being removed + const result = await prisma.$transaction(async (tx) => { + // Get current program assignments to find removed programs + const currentAssignments = await tx.partnerUserProgram.findMany({ + where: { + partnerUserId: targetUser.id, + }, + select: { + programId: true, + }, + }); + + const newProgramIdSet = new Set(programIds); + const removedProgramIds = currentAssignments + .map((a) => a.programId) + .filter((id) => !newProgramIdSet.has(id)); + + // Delete all current program assignments + await tx.partnerUserProgram.deleteMany({ + where: { + partnerUserId: targetUser.id, + }, + }); + + // Remove link assignments for removed programs + if (removedProgramIds.length > 0) { + await tx.partnerUserLink.deleteMany({ + where: { + partnerUserId: targetUser.id, + programId: { + in: removedProgramIds, + }, + }, + }); + } + + // Create new program assignments + if (programIds.length > 0) { + await tx.partnerUserProgram.createMany({ + data: programIds.map((programId) => ({ + partnerUserId: targetUser.id, + programId, + })), + skipDuplicates: true, + }); + } + + // Return the updated assignments + return tx.partnerUserProgram.findMany({ + where: { + partnerUserId: targetUser.id, + }, + include: { + program: { + select: { + id: true, + name: true, + slug: true, + logo: true, + }, + }, + }, + }); + }); + + return NextResponse.json( + assignedProgramOutputSchema.parse( + result.map((ap) => ({ + program: ap.program, + createdAt: ap.createdAt, + })), + ), + ); + }, + { + requiredPermission: "users.update", + }, +); diff --git a/apps/web/app/(ee)/api/partner-profile/users/route.ts b/apps/web/app/(ee)/api/partner-profile/users/route.ts index 94489a7565e..8f8ffc3b728 100644 --- a/apps/web/app/(ee)/api/partner-profile/users/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/users/route.ts @@ -49,6 +49,7 @@ export const GET = withPartnerProfile(async ({ partner, searchParams }) => { select: { id: true, name: true, + slug: true, logo: true, }, }, diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx index f5f69cffbbc..21b3b55e0fc 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx @@ -38,6 +38,7 @@ import { useSearchParams } from "next/navigation"; import { useEffect, useMemo, useState } from "react"; import useSWR from "swr"; import { PartnerMemberProgramsCell } from "./partner-member-programs-cell"; +import { PartnerMemberProgramsSheet } from "./partner-member-programs-sheet"; export function ProfileMembersPageClient() { const { partner } = usePartnerProfile(); @@ -80,6 +81,10 @@ export function ProfileMembersPageClient() { const { InvitePartnerUserModal, setShowInvitePartnerUserModal } = useInvitePartnerUserModal(); + const [selectedUserForPrograms, setSelectedUserForPrograms] = + useState(null); + const [showProgramsSheet, setShowProgramsSheet] = useState(false); + // Combined filter configuration const filters = useMemo( () => [ @@ -169,7 +174,13 @@ export function ProfileMembersPageClient() { maxSize: 80, meta: { disableTruncate: true }, cell: ({ row }) => ( - + { + setSelectedUserForPrograms(row.original); + setShowProgramsSheet(true); + }} + /> ), }, { @@ -237,6 +248,14 @@ export function ProfileMembersPageClient() { return ( <> + {selectedUserForPrograms && ( + + )} void; +}) { return ( -
+
{children}
); @@ -20,8 +29,10 @@ function ProgramsHover({ children }: { children: ReactNode }) { export function PartnerMemberProgramsCell({ programs, + onClick, }: { programs: PartnerUserProps["programs"]; + onClick?: () => void; }) { const { programEnrollments, isLoading: enrollmentsLoading } = useProgramEnrollments(); @@ -47,7 +58,7 @@ export function PartnerMemberProgramsCell({ if (displayPrograms.length === 0) { return ( - +
@@ -62,7 +73,7 @@ export function PartnerMemberProgramsCell({ : 0; return ( - +
{visible.map((p, index) => ( void; +} + +function PartnerMemberProgramsSheetContent({ + user, + isCurrentUserOwner, + setShowSheet, +}: PartnerMemberProgramsSheetProps) { + const [isSaving, setIsSaving] = useState(false); + const { programEnrollments, isLoading } = useProgramEnrollments(); + + const isTargetOwner = user.role === "owner"; + const canEdit = isCurrentUserOwner && !isTargetOwner; + + const assignedProgramIds = new Set(user.programs.map((p) => p.id)); + + // Empty assignedPrograms means "access to all" (no restrictions) + const hasAllAccess = assignedProgramIds.size === 0; + + const [accessState, setAccessState] = useState>({}); + + // Initialize access state from user's assigned programs when enrollments load + useEffect(() => { + if (!programEnrollments) return; + + const initial: Record = {}; + for (const enrollment of programEnrollments) { + if (isTargetOwner || hasAllAccess) { + initial[enrollment.programId] = true; + } else { + initial[enrollment.programId] = assignedProgramIds.has( + enrollment.programId, + ); + } + } + setAccessState(initial); + }, [programEnrollments, assignedProgramIds, isTargetOwner, hasAllAccess]); + + const hasChanges = programEnrollments + ? programEnrollments.some((enrollment) => { + const current = accessState[enrollment.programId] ?? false; + const original = + hasAllAccess || assignedProgramIds.has(enrollment.programId); + return current !== original; + }) + : false; + + const handleSave = async () => { + if (!user.id) return; + + const programIds = Object.entries(accessState) + .filter(([, hasAccess]) => hasAccess) + .map(([id]) => id); + + setIsSaving(true); + + await partnerProfileFetch( + "@put/api/partner-profile/users/:userId/programs", + { + params: { userId: user.id }, + body: { programIds }, + onSuccess: async () => { + toast.success("Program assignments updated!"); + await mutate( + (key) => + typeof key === "string" && + key.startsWith("/api/partner-profile/users"), + ); + setShowSheet(false); + }, + onError: (ctx) => { + toast.error( + ctx.error.message ?? "Failed to update program assignments.", + ); + }, + }, + ); + + setIsSaving(false); + }; + + return ( + <> +
+
+ Programs + +
+
+ +
+
+ {isLoading ? ( + [...Array(3)].map((_, i) => ) + ) : !programEnrollments || programEnrollments.length === 0 ? ( +
+ No programs available. +
+ ) : ( + programEnrollments.map((enrollment) => ( + + setAccessState((prev) => ({ + ...prev, + [enrollment.programId]: hasAccess, + })) + } + /> + )) + )} +
+
+ + {canEdit && ( +
+ +
+ )} + + ); +} + +function ProgramRow({ + program, + hasAccess, + canEdit, + onChange, +}: { + program: Pick; + hasAccess: boolean; + canEdit: boolean; + onChange: (hasAccess: boolean) => void; +}) { + return ( +
+
+ + + {program.name} + +
+ + {canEdit ? ( + + ) : ( + +
+ ); +} + +function ProgramRowPlaceholder() { + return ( +
+
+
+
+
+
+
+ ); +} + +export function PartnerMemberProgramsSheet( + props: PartnerMemberProgramsSheetProps, +) { + return ( + + + + ); +} + +export function usePartnerMemberProgramsSheet({ + user, + isCurrentUserOwner, +}: { + user: PartnerUserProps | null; + isCurrentUserOwner: boolean; +}) { + const [showSheet, setShowSheet] = useState(false); + + return { + setShowSheet, + PartnerMemberProgramsSheet: user ? ( + + ) : null, + }; +} diff --git a/apps/web/lib/api/partner-profile/client.ts b/apps/web/lib/api/partner-profile/client.ts index 1e13f6fa4fa..35122722a93 100644 --- a/apps/web/lib/api/partner-profile/client.ts +++ b/apps/web/lib/api/partner-profile/client.ts @@ -5,6 +5,10 @@ import { sendTestPostbackInputSchema, updatePostbackInputSchema, } from "@/lib/postback/schemas"; +import { + assignProgramInputSchema, + assignedProgramOutputSchema, +} from "@/lib/zod/schemas/partner-profile"; import { createFetch, createSchema } from "@better-fetch/fetch"; import * as z from "zod/v4"; @@ -65,6 +69,15 @@ export const partnerProfileFetch = createFetch({ body: sendTestPostbackInputSchema, output: z.object({}), }, + + // Set assigned programs for a user + "@put/api/partner-profile/users/:userId/programs": { + params: z.object({ + userId: z.string(), + }), + body: assignProgramInputSchema, + output: z.array(assignedProgramOutputSchema), + }, }, { strict: true, diff --git a/apps/web/lib/zod/schemas/partner-profile.ts b/apps/web/lib/zod/schemas/partner-profile.ts index 408457052de..121bcb5f26b 100644 --- a/apps/web/lib/zod/schemas/partner-profile.ts +++ b/apps/web/lib/zod/schemas/partner-profile.ts @@ -188,6 +188,20 @@ export const getPartnerUsersQuerySchema = z.object({ role: z.enum(PartnerRole).optional(), }); +export const assignProgramInputSchema = z.object({ + programIds: z.array(z.string()), +}); + +export const assignedProgramOutputSchema = z.object({ + program: ProgramSchema.pick({ + id: true, + name: true, + slug: true, + logo: true, + }), + createdAt: z.coerce.date(), +}); + export const partnerUserSchema = z.object({ id: z.string().nullable(), name: z.string().nullable(), @@ -199,6 +213,7 @@ export const partnerUserSchema = z.object({ ProgramSchema.pick({ id: true, name: true, + slug: true, logo: true, }), ), From 7281874cbeba53425d418f84180be04b94d50168 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 3 Apr 2026 15:12:35 +0530 Subject: [PATCH 14/86] Update partner-member-programs-sheet.tsx --- .../members/partner-member-programs-sheet.tsx | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx index 1f2b821b69b..348d940f832 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx @@ -2,7 +2,7 @@ import { partnerProfileFetch } from "@/lib/api/partner-profile/client"; import useProgramEnrollments from "@/lib/swr/use-program-enrollments"; -import { PartnerUserProps } from "@/lib/types"; +import { PartnerUserProps, ProgramProps } from "@/lib/types"; import { BlurImage, Button, Sheet } from "@dub/ui"; import { cn, OG_AVATAR_URL } from "@dub/utils"; import { X } from "lucide-react"; @@ -29,29 +29,25 @@ function PartnerMemberProgramsSheetContent({ const isTargetOwner = user.role === "owner"; const canEdit = isCurrentUserOwner && !isTargetOwner; - const assignedProgramIds = new Set(user.programs.map((p) => p.id)); - - // Empty assignedPrograms means "access to all" (no restrictions) - const hasAllAccess = assignedProgramIds.size === 0; - const [accessState, setAccessState] = useState>({}); // Initialize access state from user's assigned programs when enrollments load useEffect(() => { if (!programEnrollments) return; + const ids = new Set(user.programs.map((p) => p.id)); + const allAccess = isTargetOwner || ids.size === 0; + const initial: Record = {}; for (const enrollment of programEnrollments) { - if (isTargetOwner || hasAllAccess) { - initial[enrollment.programId] = true; - } else { - initial[enrollment.programId] = assignedProgramIds.has( - enrollment.programId, - ); - } + initial[enrollment.programId] = + allAccess || ids.has(enrollment.programId); } setAccessState(initial); - }, [programEnrollments, assignedProgramIds, isTargetOwner, hasAllAccess]); + }, [programEnrollments, user.programs, isTargetOwner]); + + const assignedProgramIds = new Set(user.programs.map((p) => p.id)); + const hasAllAccess = assignedProgramIds.size === 0; const hasChanges = programEnrollments ? programEnrollments.some((enrollment) => { @@ -112,7 +108,7 @@ function PartnerMemberProgramsSheetContent({
-
+
{isLoading ? ( [...Array(3)].map((_, i) => ) ) : !programEnrollments || programEnrollments.length === 0 ? ( @@ -162,13 +158,13 @@ function ProgramRow({ canEdit, onChange, }: { - program: Pick; + program: Pick; hasAccess: boolean; canEdit: boolean; onChange: (hasAccess: boolean) => void; }) { return ( -
+
Date: Fri, 3 Apr 2026 15:24:18 +0530 Subject: [PATCH 15/86] Update route.ts --- apps/web/app/(ee)/api/partner-profile/users/route.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(ee)/api/partner-profile/users/route.ts b/apps/web/app/(ee)/api/partner-profile/users/route.ts index 8f8ffc3b728..090afc4e5fc 100644 --- a/apps/web/app/(ee)/api/partner-profile/users/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/users/route.ts @@ -63,7 +63,8 @@ export const GET = withPartnerProfile(async ({ partner, searchParams }) => { ...rest, ...user, createdAt: rest.createdAt, // preserve the createdAt field from PartnerUser - programs: rest.role === "owner" ? [] : assignedPrograms, + programs: + rest.role === "owner" ? [] : assignedPrograms.map((ap) => ap.program), }), ); From 200cc884041b68dfdedf4f9dab9a40835fa77c5f Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 3 Apr 2026 15:24:21 +0530 Subject: [PATCH 16/86] Update route.ts --- .../(ee)/api/partner-profile/users/[userId]/programs/route.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts b/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts index 1d2dc79b950..db7cb0419a4 100644 --- a/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts @@ -7,6 +7,7 @@ import { } from "@/lib/zod/schemas/partner-profile"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; +import * as z from "zod/v4"; // PUT /api/partner-profile/users/[userId]/programs - set assigned programs for a user export const PUT = withPartnerProfile( @@ -134,7 +135,7 @@ export const PUT = withPartnerProfile( }); return NextResponse.json( - assignedProgramOutputSchema.parse( + z.array(assignedProgramOutputSchema).parse( result.map((ap) => ({ program: ap.program, createdAt: ap.createdAt, From 39fd5d66b84065e0478242f554660f487ec1fcde Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 3 Apr 2026 21:39:15 +0530 Subject: [PATCH 17/86] Add link assignment route for partner user programs - Add PUT /api/partner-profile/users/[userId]/programs/[programId]/links to assign specific links within a program to a partner user - Auto-create PartnerUserProgram if not exists when assigning links - Normalize program assignments to empty when all enrolled programs are selected (= all access) --- .../programs/[programId]/links/route.ts | 153 ++++++++++++++++++ .../users/[userId]/programs/route.ts | 18 ++- apps/web/lib/zod/schemas/partner-profile.ts | 14 ++ 3 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/[programId]/links/route.ts diff --git a/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/[programId]/links/route.ts b/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/[programId]/links/route.ts new file mode 100644 index 00000000000..8aade104b6b --- /dev/null +++ b/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/[programId]/links/route.ts @@ -0,0 +1,153 @@ +import { DubApiError } from "@/lib/api/errors"; +import { parseRequestBody } from "@/lib/api/utils"; +import { withPartnerProfile } from "@/lib/auth/partner"; +import { + assignLinkInputSchema, + assignedLinkOutputSchema, +} from "@/lib/zod/schemas/partner-profile"; +import { prisma } from "@dub/prisma"; +import { NextResponse } from "next/server"; +import * as z from "zod/v4"; + +// PUT /api/partner-profile/users/[userId]/programs/[programId]/links - set assigned links for a user in a program +export const PUT = withPartnerProfile( + async ({ partner, params, req }) => { + const { userId, programId } = params; + const { linkIds } = assignLinkInputSchema.parse( + await parseRequestBody(req), + ); + + const targetUser = await prisma.partnerUser.findUnique({ + where: { + userId_partnerId: { + userId, + partnerId: partner.id, + }, + }, + }); + + if (!targetUser) { + throw new DubApiError({ + code: "not_found", + message: "User not found.", + }); + } + + if (targetUser.role === "owner") { + throw new DubApiError({ + code: "bad_request", + message: "Cannot scope an owner to specific programs or links.", + }); + } + + // Validate the program is one the partner is enrolled in + const programEnrollment = await prisma.programEnrollment.findUnique({ + where: { + partnerId_programId: { + partnerId: partner.id, + programId, + }, + }, + }); + + if (!programEnrollment) { + throw new DubApiError({ + code: "bad_request", + message: "Partner is not enrolled in this program.", + }); + } + + // Validate all linkIds belong to this partner+program + if (linkIds.length > 0) { + const links = await prisma.link.findMany({ + where: { + id: { + in: linkIds, + }, + programId, + partnerId: partner.id, + }, + select: { + id: true, + }, + }); + + const validLinkIds = new Set(links.map((l) => l.id)); + const invalidIds = linkIds.filter((id) => !validLinkIds.has(id)); + + if (invalidIds.length > 0) { + throw new DubApiError({ + code: "bad_request", + message: `Invalid link IDs: ${invalidIds.join(", ")}`, + }); + } + } + + const result = await prisma.$transaction(async (tx) => { + // Ensure PartnerUserProgram exists (assigning links implies program access) + await tx.partnerUserProgram.upsert({ + where: { + partnerUserId_programId: { + partnerUserId: targetUser.id, + programId, + }, + }, + create: { + partnerUserId: targetUser.id, + programId, + }, + update: {}, + }); + + // Delete all current link assignments for this user+program + await tx.partnerUserLink.deleteMany({ + where: { + partnerUserId: targetUser.id, + programId, + }, + }); + + // Create new link assignments + if (linkIds.length > 0) { + await tx.partnerUserLink.createMany({ + data: linkIds.map((linkId) => ({ + partnerUserId: targetUser.id, + linkId, + programId, + })), + skipDuplicates: true, + }); + } + + // Return the updated assignments + return tx.partnerUserLink.findMany({ + where: { + partnerUserId: targetUser.id, + programId, + }, + include: { + link: { + select: { + id: true, + domain: true, + key: true, + shortLink: true, + }, + }, + }, + }); + }); + + return NextResponse.json( + z.array(assignedLinkOutputSchema).parse( + result.map((al) => ({ + link: al.link, + createdAt: al.createdAt, + })), + ), + ); + }, + { + requiredPermission: "users.update", + }, +); diff --git a/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts b/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts index db7cb0419a4..34a85e2e80c 100644 --- a/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts @@ -68,6 +68,18 @@ export const PUT = withPartnerProfile( } } + // If all enrolled programs are selected, normalize to empty (= "all access") + const totalEnrollments = await prisma.programEnrollment.count({ + where: { + partnerId: partner.id, + }, + }); + + const effectiveProgramIds = + programIds.length > 0 && programIds.length >= totalEnrollments + ? [] + : programIds; + // Replace all program assignments in a transaction // Also remove link assignments for programs that are being removed const result = await prisma.$transaction(async (tx) => { @@ -81,7 +93,7 @@ export const PUT = withPartnerProfile( }, }); - const newProgramIdSet = new Set(programIds); + const newProgramIdSet = new Set(effectiveProgramIds); const removedProgramIds = currentAssignments .map((a) => a.programId) .filter((id) => !newProgramIdSet.has(id)); @@ -106,9 +118,9 @@ export const PUT = withPartnerProfile( } // Create new program assignments - if (programIds.length > 0) { + if (effectiveProgramIds.length > 0) { await tx.partnerUserProgram.createMany({ - data: programIds.map((programId) => ({ + data: effectiveProgramIds.map((programId) => ({ partnerUserId: targetUser.id, programId, })), diff --git a/apps/web/lib/zod/schemas/partner-profile.ts b/apps/web/lib/zod/schemas/partner-profile.ts index 121bcb5f26b..87b98f05834 100644 --- a/apps/web/lib/zod/schemas/partner-profile.ts +++ b/apps/web/lib/zod/schemas/partner-profile.ts @@ -202,6 +202,20 @@ export const assignedProgramOutputSchema = z.object({ createdAt: z.coerce.date(), }); +export const assignLinkInputSchema = z.object({ + linkIds: z.array(z.string()), +}); + +export const assignedLinkOutputSchema = z.object({ + link: LinkSchema.pick({ + id: true, + domain: true, + key: true, + shortLink: true, + }), + createdAt: z.coerce.date(), +}); + export const partnerUserSchema = z.object({ id: z.string().nullable(), name: z.string().nullable(), From 9a172c4425a6924091de096e72a47e037a0b3997 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 6 Apr 2026 12:55:25 +0530 Subject: [PATCH 18/86] Add programAccess field to PartnerUser with ProgramAccessScope enum Replace implicit "no rows = all access" convention with an explicit programAccess field (all | restricted) on PartnerUser. Extract programScopeFilter helper to replace repeated filter patterns across API routes. Update UI to show access mode selector in programs sheet. --- .../partner-profile/messages/count/route.ts | 10 ++- .../api/partner-profile/messages/route.ts | 11 ++-- .../partner-profile/payouts/count/route.ts | 7 +- .../(ee)/api/partner-profile/payouts/route.ts | 7 +- .../partner-profile/programs/count/route.ts | 7 +- .../api/partner-profile/programs/route.ts | 7 +- .../users/[userId]/programs/route.ts | 30 ++++----- .../(ee)/api/partner-profile/users/route.ts | 3 +- .../profile/members/page-client.tsx | 1 + .../members/partner-member-programs-cell.tsx | 24 ++++--- .../members/partner-member-programs-sheet.tsx | 66 ++++++++++++++----- .../lib/actions/partners/message-program.ts | 2 +- .../partner-users/partner-user-permissions.ts | 6 +- .../partner-users/program-scope-filter.ts | 13 ++++ apps/web/lib/auth/partner.ts | 17 +++-- apps/web/lib/zod/schemas/partner-profile.ts | 3 + .../layout/sidebar/partners-sidebar-nav.tsx | 4 +- packages/prisma/schema/partner.prisma | 18 +++-- 18 files changed, 142 insertions(+), 94 deletions(-) create mode 100644 apps/web/lib/auth/partner-users/program-scope-filter.ts diff --git a/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts b/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts index 98a866a2b7a..76e3dcfbf95 100644 --- a/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts @@ -5,17 +5,12 @@ import { NextResponse } from "next/server"; // GET /api/partner-profile/messages/count - count messages for a partner export const GET = withPartnerProfile( - async ({ partner, searchParams, partnerUser: { assignedProgramIds } }) => { + async ({ partner, searchParams }) => { const { unread } = countMessagesQuerySchema.parse(searchParams); const count = await prisma.message.count({ where: { partnerId: partner.id, - ...(assignedProgramIds.length > 0 && { - programId: { - in: assignedProgramIds, - }, - }), ...(unread !== undefined && { // Only count messages from the program senderPartnerId: null, @@ -32,4 +27,7 @@ export const GET = withPartnerProfile( return NextResponse.json(count); }, + { + requiredPermission: "messages.read", + }, ); diff --git a/apps/web/app/(ee)/api/partner-profile/messages/route.ts b/apps/web/app/(ee)/api/partner-profile/messages/route.ts index f8a86a1240a..23a1013b88e 100644 --- a/apps/web/app/(ee)/api/partner-profile/messages/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/messages/route.ts @@ -8,7 +8,7 @@ import { NextResponse } from "next/server"; // GET /api/partner-profile/messages - get messages grouped by program export const GET = withPartnerProfile( - async ({ partner, searchParams, partnerUser: { assignedProgramIds } }) => { + async ({ partner, searchParams }) => { const { programSlug, sortBy, @@ -27,12 +27,6 @@ export const GET = withPartnerProfile( status: "banned", }, }, - ...(assignedProgramIds.length > 0 && { - id: { - in: assignedProgramIds, - }, - }), - ...(programSlug ? { slug: programSlug, @@ -130,4 +124,7 @@ export const GET = withPartnerProfile( ), ); }, + { + requiredPermission: "messages.read", + }, ); diff --git a/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts b/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts index 9cd6024310b..8bd250584d9 100644 --- a/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts @@ -1,4 +1,5 @@ import { withPartnerProfile } from "@/lib/auth/partner"; +import { programScopeFilter } from "@/lib/auth/partner-users/program-scope-filter"; import { payoutsCountQuerySchema } from "@/lib/zod/schemas/payouts"; import { prisma } from "@dub/prisma"; import { PayoutStatus, Prisma } from "@dub/prisma/client"; @@ -13,11 +14,7 @@ export const GET = withPartnerProfile( const where: Prisma.PayoutWhereInput = { partnerId: partner.id, ...(programId && { programId }), - ...(assignedProgramIds.length > 0 && { - programId: { - in: assignedProgramIds, - }, - }), + ...programScopeFilter(assignedProgramIds), }; if (groupBy === "status") { diff --git a/apps/web/app/(ee)/api/partner-profile/payouts/route.ts b/apps/web/app/(ee)/api/partner-profile/payouts/route.ts index bc7a3ce7881..01414cbdcc8 100644 --- a/apps/web/app/(ee)/api/partner-profile/payouts/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/payouts/route.ts @@ -8,7 +8,7 @@ import * as z from "zod/v4"; // GET /api/partner-profile/payouts - get all payouts for a partner export const GET = withPartnerProfile( - async ({ partner, searchParams, partnerUser: { assignedProgramIds } }) => { + async ({ partner, searchParams }) => { const { programId, status, @@ -23,11 +23,6 @@ export const GET = withPartnerProfile( partnerId: partner.id, ...(programId && { programId }), ...(status && { status }), - ...(assignedProgramIds.length > 0 && { - programId: { - in: assignedProgramIds, - }, - }), }, include: { program: true, diff --git a/apps/web/app/(ee)/api/partner-profile/programs/count/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/count/route.ts index 65389fcbbeb..f8b73c90879 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/count/route.ts @@ -1,4 +1,5 @@ import { withPartnerProfile } from "@/lib/auth/partner"; +import { programScopeFilter } from "@/lib/auth/partner-users/program-scope-filter"; import { partnerProfileProgramsCountQuerySchema } from "@/lib/zod/schemas/partner-profile"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; @@ -13,11 +14,7 @@ export const GET = withPartnerProfile( where: { partnerId: partner.id, ...(status && { status }), - ...(assignedProgramIds.length > 0 && { - programId: { - in: assignedProgramIds, - }, - }), + ...programScopeFilter(assignedProgramIds), }, }); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/route.ts index 8e73a6748e0..13ecdb17ca5 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/route.ts @@ -1,4 +1,5 @@ import { withPartnerProfile } from "@/lib/auth/partner"; +import { programScopeFilter } from "@/lib/auth/partner-users/program-scope-filter"; import { partnerProfileProgramsQuerySchema } from "@/lib/zod/schemas/partner-profile"; import { ProgramEnrollmentSchema } from "@/lib/zod/schemas/programs"; import { prisma } from "@dub/prisma"; @@ -19,11 +20,7 @@ export const GET = withPartnerProfile( program: { deactivatedAt: null, }, - ...(assignedProgramIds.length > 0 && { - programId: { - in: assignedProgramIds, - }, - }), + ...programScopeFilter(assignedProgramIds), }, include: { links: { diff --git a/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts b/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts index 34a85e2e80c..dd562db8d9d 100644 --- a/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts @@ -13,7 +13,7 @@ import * as z from "zod/v4"; export const PUT = withPartnerProfile( async ({ partner, params, req }) => { const { userId } = params; - const { programIds } = assignProgramInputSchema.parse( + const { programAccess, programIds } = assignProgramInputSchema.parse( await parseRequestBody(req), ); @@ -41,7 +41,7 @@ export const PUT = withPartnerProfile( } // Validate all programIds are programs the partner is enrolled in - if (programIds.length > 0) { + if (programAccess === "restricted" && programIds.length > 0) { const programEnrollments = await prisma.programEnrollment.findMany({ where: { partnerId: partner.id, @@ -68,18 +68,6 @@ export const PUT = withPartnerProfile( } } - // If all enrolled programs are selected, normalize to empty (= "all access") - const totalEnrollments = await prisma.programEnrollment.count({ - where: { - partnerId: partner.id, - }, - }); - - const effectiveProgramIds = - programIds.length > 0 && programIds.length >= totalEnrollments - ? [] - : programIds; - // Replace all program assignments in a transaction // Also remove link assignments for programs that are being removed const result = await prisma.$transaction(async (tx) => { @@ -93,11 +81,23 @@ export const PUT = withPartnerProfile( }, }); + const effectiveProgramIds = programAccess === "all" ? [] : programIds; + const newProgramIdSet = new Set(effectiveProgramIds); const removedProgramIds = currentAssignments .map((a) => a.programId) .filter((id) => !newProgramIdSet.has(id)); + // Update programAccess on the PartnerUser + await tx.partnerUser.update({ + where: { + id: targetUser.id, + }, + data: { + programAccess, + }, + }); + // Delete all current program assignments await tx.partnerUserProgram.deleteMany({ where: { @@ -117,7 +117,7 @@ export const PUT = withPartnerProfile( }); } - // Create new program assignments + // Create new program assignments (only for restricted access) if (effectiveProgramIds.length > 0) { await tx.partnerUserProgram.createMany({ data: effectiveProgramIds.map((programId) => ({ diff --git a/apps/web/app/(ee)/api/partner-profile/users/route.ts b/apps/web/app/(ee)/api/partner-profile/users/route.ts index 090afc4e5fc..cfe94609c83 100644 --- a/apps/web/app/(ee)/api/partner-profile/users/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/users/route.ts @@ -63,8 +63,7 @@ export const GET = withPartnerProfile(async ({ partner, searchParams }) => { ...rest, ...user, createdAt: rest.createdAt, // preserve the createdAt field from PartnerUser - programs: - rest.role === "owner" ? [] : assignedPrograms.map((ap) => ap.program), + programs: assignedPrograms.map(({ program }) => program), }), ); diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx index 21b3b55e0fc..14f38741425 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx @@ -176,6 +176,7 @@ export function ProfileMembersPageClient() { cell: ({ row }) => ( { setSelectedUserForPrograms(row.original); setShowProgramsSheet(true); diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx index 5c97fddcbbf..084dcfebccd 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx @@ -29,9 +29,11 @@ function ProgramsHover({ export function PartnerMemberProgramsCell({ programs, + programAccess, onClick, }: { programs: PartnerUserProps["programs"]; + programAccess: PartnerUserProps["programAccess"]; onClick?: () => void; }) { const { programEnrollments, isLoading: enrollmentsLoading } = @@ -43,9 +45,9 @@ export function PartnerMemberProgramsCell({ logo: e.program.logo, })); - const displayPrograms = programs.length > 0 ? programs : fromEnrollments; + const displayPrograms = programAccess === "all" ? fromEnrollments : programs; - if (enrollmentsLoading && programs.length === 0) { + if (enrollmentsLoading && programAccess === "all") { return (
MAX_VISIBLE_LOGOS - ? Math.min(displayPrograms.length - MAX_VISIBLE_LOGOS, MAX_OVERFLOW_LABEL) - : 0; + const hiddenCount = Math.max(0, displayPrograms.length - MAX_VISIBLE_LOGOS); + const extra = hiddenCount > 0 ? Math.min(hiddenCount, MAX_OVERFLOW_LABEL) : 0; + + const overflowLabel = + programAccess === "all" && hiddenCount > 0 + ? hiddenCount > MAX_OVERFLOW_LABEL + ? `All ${MAX_OVERFLOW_LABEL}+` + : `All ${hiddenCount}` + : null; return ( @@ -89,10 +96,11 @@ export function PartnerMemberProgramsCell({ {extra > 0 ? (
- +{extra} + {overflowLabel ?? `+${extra}`}
) : null}
diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx index 348d940f832..11ab52bcf01 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx @@ -29,6 +29,9 @@ function PartnerMemberProgramsSheetContent({ const isTargetOwner = user.role === "owner"; const canEdit = isCurrentUserOwner && !isTargetOwner; + const [scopeMode, setScopeMode] = useState<"all" | "restricted">( + user.programAccess, + ); const [accessState, setAccessState] = useState>({}); // Initialize access state from user's assigned programs when enrollments load @@ -36,7 +39,7 @@ function PartnerMemberProgramsSheetContent({ if (!programEnrollments) return; const ids = new Set(user.programs.map((p) => p.id)); - const allAccess = isTargetOwner || ids.size === 0; + const allAccess = isTargetOwner || user.programAccess === "all"; const initial: Record = {}; for (const enrollment of programEnrollments) { @@ -44,26 +47,32 @@ function PartnerMemberProgramsSheetContent({ allAccess || ids.has(enrollment.programId); } setAccessState(initial); - }, [programEnrollments, user.programs, isTargetOwner]); + setScopeMode(isTargetOwner ? "all" : user.programAccess); + }, [programEnrollments, user.programs, user.programAccess, isTargetOwner]); - const assignedProgramIds = new Set(user.programs.map((p) => p.id)); - const hasAllAccess = assignedProgramIds.size === 0; + const hasChanges = (() => { + if (scopeMode !== user.programAccess) return true; + if (scopeMode === "all") return false; - const hasChanges = programEnrollments - ? programEnrollments.some((enrollment) => { - const current = accessState[enrollment.programId] ?? false; - const original = - hasAllAccess || assignedProgramIds.has(enrollment.programId); - return current !== original; - }) - : false; + // Check if individual program selections changed + if (!programEnrollments) return false; + const assignedProgramIds = new Set(user.programs.map((p) => p.id)); + return programEnrollments.some((enrollment) => { + const current = accessState[enrollment.programId] ?? false; + const original = assignedProgramIds.has(enrollment.programId); + return current !== original; + }); + })(); const handleSave = async () => { if (!user.id) return; - const programIds = Object.entries(accessState) - .filter(([, hasAccess]) => hasAccess) - .map(([id]) => id); + const programIds = + scopeMode === "all" + ? [] + : Object.entries(accessState) + .filter(([, hasAccess]) => hasAccess) + .map(([id]) => id); setIsSaving(true); @@ -71,7 +80,7 @@ function PartnerMemberProgramsSheetContent({ "@put/api/partner-profile/users/:userId/programs", { params: { userId: user.id }, - body: { programIds }, + body: { programAccess: scopeMode, programIds }, onSuccess: async () => { toast.success("Program assignments updated!"); await mutate( @@ -108,6 +117,24 @@ function PartnerMemberProgramsSheetContent({
+ {canEdit && ( +
+ + +
+ )} +
{isLoading ? ( [...Array(3)].map((_, i) => ) @@ -120,8 +147,11 @@ function PartnerMemberProgramsSheetContent({ setAccessState((prev) => ({ ...prev, diff --git a/apps/web/lib/actions/partners/message-program.ts b/apps/web/lib/actions/partners/message-program.ts index 4f93c3a7f08..5d6f513c50e 100644 --- a/apps/web/lib/actions/partners/message-program.ts +++ b/apps/web/lib/actions/partners/message-program.ts @@ -21,7 +21,7 @@ export const messageProgramAction = authPartnerActionClient throwIfNoPermission({ role: partnerUser.role, - permission: "messages.send", + permission: "messages.write", }); const program = await prisma.program.findFirstOrThrow({ diff --git a/apps/web/lib/auth/partner-users/partner-user-permissions.ts b/apps/web/lib/auth/partner-users/partner-user-permissions.ts index d133aab2cb8..b8cb093aad5 100644 --- a/apps/web/lib/auth/partner-users/partner-user-permissions.ts +++ b/apps/web/lib/auth/partner-users/partner-user-permissions.ts @@ -13,7 +13,8 @@ const PERMISSIONS = [ "payout_settings.read", "postbacks.read", "postbacks.write", - "messages.send", + "messages.read", + "messages.write", "messages.mark_as_read", "program_invites.accept", "bounties.submit", @@ -26,7 +27,8 @@ const ROLE_PERMISSIONS: Record> = { owner: new Set(PERMISSIONS), member: new Set([ "payout_settings.read", - "messages.send", + "messages.read", + "messages.write", "messages.mark_as_read", "program_invites.accept", "bounties.submit", diff --git a/apps/web/lib/auth/partner-users/program-scope-filter.ts b/apps/web/lib/auth/partner-users/program-scope-filter.ts new file mode 100644 index 00000000000..0f4a298b193 --- /dev/null +++ b/apps/web/lib/auth/partner-users/program-scope-filter.ts @@ -0,0 +1,13 @@ +export function programScopeFilter(assignedProgramIds: string[] | undefined): { + programId?: { in: string[] }; +} { + if (assignedProgramIds === undefined) { + return {}; + } + + return { + programId: { + in: assignedProgramIds, + }, + }; +} diff --git a/apps/web/lib/auth/partner.ts b/apps/web/lib/auth/partner.ts index c99d6a7529a..47f4b20f8ff 100644 --- a/apps/web/lib/auth/partner.ts +++ b/apps/web/lib/auth/partner.ts @@ -32,8 +32,11 @@ interface WithPartnerProfileHandler { headers?: Headers; session: Session; partner: Omit; - partnerUser: Pick & { - assignedProgramIds: string[]; + partnerUser: Pick< + PartnerUser, + "id" | "userId" | "role" | "programAccess" + > & { + assignedProgramIds: string[] | undefined; assignedLinkIds: string[]; }; }): Promise; @@ -267,9 +270,10 @@ export const withPartnerProfile = ( } } - const assignedProgramIds = partnerUser.assignedPrograms.map( - ({ programId }) => programId, - ); + const assignedProgramIds = + partnerUser.programAccess === "all" + ? undefined + : partnerUser.assignedPrograms.map(({ programId }) => programId); const assignedLinkIds = partnerUser.assignedLinks.map( ({ linkId }) => linkId, ); @@ -278,7 +282,7 @@ export const withPartnerProfile = ( // verify they have access to this program if ( params.programId && - assignedProgramIds.length > 0 && + assignedProgramIds !== undefined && !assignedProgramIds.includes(params.programId) ) { throw new DubApiError({ @@ -317,6 +321,7 @@ export const withPartnerProfile = ( id: partnerUser.id, userId: partnerUser.userId, role: partnerUser.role, + programAccess: partnerUser.programAccess, assignedProgramIds, assignedLinkIds, }, diff --git a/apps/web/lib/zod/schemas/partner-profile.ts b/apps/web/lib/zod/schemas/partner-profile.ts index 87b98f05834..0631c324e26 100644 --- a/apps/web/lib/zod/schemas/partner-profile.ts +++ b/apps/web/lib/zod/schemas/partner-profile.ts @@ -8,6 +8,7 @@ import { PartnerPayoutMethod, PartnerProfileType, PartnerRole, + ProgramAccessScope, ProgramEnrollmentStatus, ReferralStatus, } from "@dub/prisma/client"; @@ -189,6 +190,7 @@ export const getPartnerUsersQuerySchema = z.object({ }); export const assignProgramInputSchema = z.object({ + programAccess: z.enum(ProgramAccessScope), programIds: z.array(z.string()), }); @@ -221,6 +223,7 @@ export const partnerUserSchema = z.object({ name: z.string().nullable(), email: z.string(), role: z.enum(PartnerRole), + programAccess: z.enum(ProgramAccessScope), image: z.string().nullish(), createdAt: z.date(), programs: z.array( diff --git a/apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx b/apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx index 87c7df8a81d..0f2b74866ad 100644 --- a/apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx +++ b/apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx @@ -83,7 +83,7 @@ const NAV_GROUPS: SidebarNavGroups = ({ href: "/profile", active: pathname.startsWith("/profile"), }, - ...(partnerRole && hasPermission(partnerRole, "messages.send") + ...(partnerRole && hasPermission(partnerRole, "messages.read") ? [ { name: "Messages", @@ -215,7 +215,7 @@ const NAV_AREAS: SidebarNavAreas = { href: `/programs/${programSlug}/links`, locked: isUnapproved, }, - ...(partnerRole && hasPermission(partnerRole, "messages.send") + ...(partnerRole && hasPermission(partnerRole, "messages.read") ? [ { name: "Messages", diff --git a/packages/prisma/schema/partner.prisma b/packages/prisma/schema/partner.prisma index 6a30636181d..10966894b58 100644 --- a/packages/prisma/schema/partner.prisma +++ b/packages/prisma/schema/partner.prisma @@ -4,6 +4,11 @@ enum PartnerRole { viewer } +enum ProgramAccessScope { + all + restricted +} + enum PartnerProfileType { individual company @@ -100,12 +105,13 @@ model PartnerInvite { } model PartnerUser { - id String @id @default(cuid()) - role PartnerRole @default(member) - userId String - partnerId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + role PartnerRole @default(member) + programAccess ProgramAccessScope @default(all) + userId String + partnerId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) partner Partner @relation(fields: [partnerId], references: [id], onDelete: Cascade) From ad162e000a9ff70c0fe12509db333f7cf49e8884 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 6 Apr 2026 15:12:35 +0530 Subject: [PATCH 19/86] Add per-program link restrictions for partner users with restricted access - Extend assignProgramInputSchema with linkIds record and partnerUserSchema programs with links - Include assigned links per program in users list API response - Handle link assignments in programs PUT endpoint with batch validation - Simplify programs route: move validation before transaction, eliminate N+1 queries - Add PartnerLinksSelector component with "All links" option using partner-scoped link fetching - Add link picker UI per accessible program in the programs sheet --- .../users/[userId]/programs/route.ts | 118 +++++---- .../(ee)/api/partner-profile/users/route.ts | 40 ++- .../members/partner-links-selector.tsx | 128 +++++++++ .../members/partner-member-programs-sheet.tsx | 243 ++++++++++++------ apps/web/lib/zod/schemas/partner-profile.ts | 10 + 5 files changed, 413 insertions(+), 126 deletions(-) create mode 100644 apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-links-selector.tsx diff --git a/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts b/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts index dd562db8d9d..9391512a4b6 100644 --- a/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts @@ -13,9 +13,8 @@ import * as z from "zod/v4"; export const PUT = withPartnerProfile( async ({ partner, params, req }) => { const { userId } = params; - const { programAccess, programIds } = assignProgramInputSchema.parse( - await parseRequestBody(req), - ); + const { programAccess, programIds, linkIds } = + assignProgramInputSchema.parse(await parseRequestBody(req)); const targetUser = await prisma.partnerUser.findUnique({ where: { @@ -40,13 +39,15 @@ export const PUT = withPartnerProfile( }); } + const effectiveProgramIds = programAccess === "all" ? [] : programIds; + // Validate all programIds are programs the partner is enrolled in - if (programAccess === "restricted" && programIds.length > 0) { + if (effectiveProgramIds.length > 0) { const programEnrollments = await prisma.programEnrollment.findMany({ where: { partnerId: partner.id, programId: { - in: programIds, + in: effectiveProgramIds, }, }, select: { @@ -55,10 +56,12 @@ export const PUT = withPartnerProfile( }); const enrolledProgramIds = new Set( - programEnrollments.map((e) => e.programId), + programEnrollments.map(({ programId }) => programId), ); - const invalidIds = programIds.filter((id) => !enrolledProgramIds.has(id)); + const invalidIds = effectiveProgramIds.filter( + (id) => !enrolledProgramIds.has(id), + ); if (invalidIds.length > 0) { throw new DubApiError({ @@ -68,27 +71,52 @@ export const PUT = withPartnerProfile( } } - // Replace all program assignments in a transaction - // Also remove link assignments for programs that are being removed - const result = await prisma.$transaction(async (tx) => { - // Get current program assignments to find removed programs - const currentAssignments = await tx.partnerUserProgram.findMany({ + // Batch-validate all link IDs — each must exist and belong to one of the assigned programs + const allRequestedLinkIds = Object.entries(linkIds).flatMap( + ([, ids]) => ids ?? [], + ); + + if (allRequestedLinkIds.length > 0) { + const validLinks = await prisma.link.findMany({ where: { - partnerUserId: targetUser.id, + id: { + in: allRequestedLinkIds, + }, + programId: { + in: effectiveProgramIds, + }, }, select: { - programId: true, + id: true, }, }); - const effectiveProgramIds = programAccess === "all" ? [] : programIds; + const validLinkIdSet = new Set(validLinks.map((l) => l.id)); + const invalidIds = allRequestedLinkIds.filter( + (id) => !validLinkIdSet.has(id), + ); + + if (invalidIds.length > 0) { + throw new DubApiError({ + code: "bad_request", + message: `Invalid link IDs: ${invalidIds.join(", ")}`, + }); + } + } - const newProgramIdSet = new Set(effectiveProgramIds); - const removedProgramIds = currentAssignments - .map((a) => a.programId) - .filter((id) => !newProgramIdSet.has(id)); + // Pre-compute link assignment rows (undefined = all links = no restriction rows) + const newLinkAssignments = effectiveProgramIds.flatMap((programId) => { + const programLinkIds = linkIds[programId]; + if (programLinkIds === undefined) return []; + return programLinkIds.map((linkId) => ({ + partnerUserId: targetUser.id, + linkId, + programId, + })); + }); - // Update programAccess on the PartnerUser + // Transaction: delete old, create new + await prisma.$transaction(async (tx) => { await tx.partnerUser.update({ where: { id: targetUser.id, @@ -98,52 +126,50 @@ export const PUT = withPartnerProfile( }, }); - // Delete all current program assignments + // Replace program assignments await tx.partnerUserProgram.deleteMany({ where: { partnerUserId: targetUser.id, }, }); - // Remove link assignments for removed programs - if (removedProgramIds.length > 0) { - await tx.partnerUserLink.deleteMany({ - where: { - partnerUserId: targetUser.id, - programId: { - in: removedProgramIds, - }, - }, - }); - } - - // Create new program assignments (only for restricted access) if (effectiveProgramIds.length > 0) { await tx.partnerUserProgram.createMany({ data: effectiveProgramIds.map((programId) => ({ partnerUserId: targetUser.id, programId, })), - skipDuplicates: true, }); } - // Return the updated assignments - return tx.partnerUserProgram.findMany({ + // Replace link assignments + await tx.partnerUserLink.deleteMany({ where: { partnerUserId: targetUser.id, }, - include: { - program: { - select: { - id: true, - name: true, - slug: true, - logo: true, - }, + }); + + if (newLinkAssignments.length > 0) { + await tx.partnerUserLink.createMany({ + data: newLinkAssignments, + }); + } + }); + + const result = await prisma.partnerUserProgram.findMany({ + where: { + partnerUserId: targetUser.id, + }, + include: { + program: { + select: { + id: true, + name: true, + slug: true, + logo: true, }, }, - }); + }, }); return NextResponse.json( diff --git a/apps/web/app/(ee)/api/partner-profile/users/route.ts b/apps/web/app/(ee)/api/partner-profile/users/route.ts index cfe94609c83..85ba622ddd6 100644 --- a/apps/web/app/(ee)/api/partner-profile/users/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/users/route.ts @@ -8,6 +8,7 @@ import { } from "@/lib/zod/schemas/partner-profile"; import { prisma } from "@dub/prisma"; import { PartnerRole } from "@dub/prisma/client"; +import { groupBy } from "@dub/utils"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; @@ -55,16 +56,41 @@ export const GET = withPartnerProfile(async ({ partner, searchParams }) => { }, }, }, + assignedLinks: { + include: { + link: { + select: { + id: true, + domain: true, + key: true, + shortLink: true, + }, + }, + }, + }, }, }); - const parsedUsers = users.map(({ user, assignedPrograms, ...rest }) => - partnerUserSchema.parse({ - ...rest, - ...user, - createdAt: rest.createdAt, // preserve the createdAt field from PartnerUser - programs: assignedPrograms.map(({ program }) => program), - }), + const parsedUsers = users.map( + ({ user, assignedPrograms, assignedLinks, ...rest }) => { + const groupedByProgramId = groupBy(assignedLinks, (al) => al.programId); + const linksByProgramId = Object.fromEntries( + Object.entries(groupedByProgramId).map(([programId, items]) => [ + programId, + items.map((al) => al.link), + ]), + ); + + return partnerUserSchema.parse({ + ...rest, + ...user, + createdAt: rest.createdAt, + programs: assignedPrograms.map(({ program }) => ({ + ...program, + links: linksByProgramId[program.id] ?? [], + })), + }); + }, ); return NextResponse.json(parsedUsers); diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-links-selector.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-links-selector.tsx new file mode 100644 index 00000000000..17c95cd3c57 --- /dev/null +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-links-selector.tsx @@ -0,0 +1,128 @@ +"use client"; + +import usePartnerLinks from "@/lib/swr/use-partner-links"; +import { Combobox, LinkLogo } from "@dub/ui"; +import { getApexDomain, linkConstructor, truncate } from "@dub/utils"; + +const ALL_LINKS_VALUE = "__all__"; +const MAX_DISPLAYED_LINKS = 5; + +export function PartnerLinksSelector({ + programId, + selectedLinkIds, + setSelectedLinkIds, +}: { + programId: string; + selectedLinkIds: string[] | undefined; + setSelectedLinkIds: (ids: string[] | undefined) => void; +}) { + const { links, loading } = usePartnerLinks({ programId }); + + const isAllLinks = selectedLinkIds === undefined; + + const linkOptions = (links ?? []).map((link) => ({ + value: link.id, + label: linkConstructor({ + domain: link.domain, + key: link.key, + pretty: true, + }), + icon: ( + + ), + meta: { url: link.url }, + })); + + const options = [ + { + value: ALL_LINKS_VALUE, + label: "All links", + }, + ...linkOptions, + ]; + + const selected = isAllLinks + ? [{ value: ALL_LINKS_VALUE, label: "All links" }] + : linkOptions.filter((opt) => selectedLinkIds.includes(opt.value)); + + const handleSelect = ({ value }: { value: string }) => { + if (value === ALL_LINKS_VALUE) { + setSelectedLinkIds(undefined); + return; + } + + if (isAllLinks) { + // Switching from "All links" to a specific link + setSelectedLinkIds([value]); + return; + } + + if (selectedLinkIds.includes(value)) { + const remaining = selectedLinkIds.filter((id) => id !== value); + // If nothing is selected, revert to all links + if (remaining.length === 0) { + setSelectedLinkIds(undefined); + } else { + setSelectedLinkIds(remaining); + } + } else { + setSelectedLinkIds([...selectedLinkIds, value]); + } + }; + + const displayedSelected = selected.slice(0, MAX_DISPLAYED_LINKS); + const plusCount = Math.max(0, selected.length - MAX_DISPLAYED_LINKS); + + return ( + + {loading ? ( +
+ ) : isAllLinks ? ( +
All links
+ ) : selected.length === 0 ? ( +
Select links...
+ ) : ( +
+ {displayedSelected.map((option) => ( + + {"meta" in option && option.meta ? ( + + ) : null} + + {truncate(option.label, 32)} + + + ))} + {plusCount > 0 && ( + + + {plusCount} more + + )} +
+ )} + + ); +} diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx index 11ab52bcf01..be6adefc5d2 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx @@ -3,6 +3,7 @@ import { partnerProfileFetch } from "@/lib/api/partner-profile/client"; import useProgramEnrollments from "@/lib/swr/use-program-enrollments"; import { PartnerUserProps, ProgramProps } from "@/lib/types"; +import { ProgramAccessScope } from "@dub/prisma/client"; import { BlurImage, Button, Sheet } from "@dub/ui"; import { cn, OG_AVATAR_URL } from "@dub/utils"; import { X } from "lucide-react"; @@ -10,6 +11,7 @@ import Link from "next/link"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { mutate } from "swr"; +import { PartnerLinksSelector } from "./partner-links-selector"; interface PartnerMemberProgramsSheetProps { user: PartnerUserProps; @@ -25,42 +27,86 @@ function PartnerMemberProgramsSheetContent({ }: PartnerMemberProgramsSheetProps) { const [isSaving, setIsSaving] = useState(false); const { programEnrollments, isLoading } = useProgramEnrollments(); + const [accessState, setAccessState] = useState>({}); - const isTargetOwner = user.role === "owner"; - const canEdit = isCurrentUserOwner && !isTargetOwner; - - const [scopeMode, setScopeMode] = useState<"all" | "restricted">( + const [programAccess, setProgramAccess] = useState( user.programAccess, ); - const [accessState, setAccessState] = useState>({}); - // Initialize access state from user's assigned programs when enrollments load + const [linkState, setLinkState] = useState< + Record + >({}); + + const isTargetOwner = user.role === "owner"; + const canEdit = isCurrentUserOwner && !isTargetOwner; + useEffect(() => { if (!programEnrollments) return; - const ids = new Set(user.programs.map((p) => p.id)); + const programMap = new Map(user.programs.map((p) => [p.id, p])); const allAccess = isTargetOwner || user.programAccess === "all"; - const initial: Record = {}; + const initialAccess: Record = {}; + const initialLinks: Record = {}; + for (const enrollment of programEnrollments) { - initial[enrollment.programId] = - allAccess || ids.has(enrollment.programId); + const program = programMap.get(enrollment.programId); + initialAccess[enrollment.programId] = + allAccess || programMap.has(enrollment.programId); + + // If program has explicit link assignments, use them; otherwise undefined = all links + if (program && program.links.length > 0) { + initialLinks[enrollment.programId] = program.links.map((l) => l.id); + } else { + initialLinks[enrollment.programId] = undefined; + } } - setAccessState(initial); - setScopeMode(isTargetOwner ? "all" : user.programAccess); + + setAccessState(initialAccess); + setLinkState(initialLinks); + setProgramAccess(isTargetOwner ? "all" : user.programAccess); }, [programEnrollments, user.programs, user.programAccess, isTargetOwner]); const hasChanges = (() => { - if (scopeMode !== user.programAccess) return true; - if (scopeMode === "all") return false; - - // Check if individual program selections changed + if (programAccess !== user.programAccess) return true; + if (programAccess === "all") return false; if (!programEnrollments) return false; - const assignedProgramIds = new Set(user.programs.map((p) => p.id)); + + const programMap = new Map(user.programs.map((p) => [p.id, p])); + return programEnrollments.some((enrollment) => { const current = accessState[enrollment.programId] ?? false; - const original = assignedProgramIds.has(enrollment.programId); - return current !== original; + const original = programMap.has(enrollment.programId); + if (current !== original) return true; + + // Check link changes for programs that have access + if (current) { + const program = programMap.get(enrollment.programId); + const originalLinkIds = program?.links.map((l) => l.id) ?? []; + const currentLinkIds = linkState[enrollment.programId]; + + // Both undefined = no change (all links) + if (currentLinkIds === undefined && originalLinkIds.length === 0) { + return false; + } + + // One undefined, other not + if (currentLinkIds === undefined || originalLinkIds.length === 0) { + return currentLinkIds !== undefined || originalLinkIds.length !== 0; + } + + // Compare arrays + const sortedOriginal = [...originalLinkIds].sort(); + const sortedCurrent = [...currentLinkIds].sort(); + + if (sortedOriginal.length !== sortedCurrent.length) { + return true; + } + + return sortedOriginal.some((id, i) => id !== sortedCurrent[i]); + } + + return false; }); })(); @@ -68,19 +114,33 @@ function PartnerMemberProgramsSheetContent({ if (!user.id) return; const programIds = - scopeMode === "all" + programAccess === "all" ? [] : Object.entries(accessState) .filter(([, hasAccess]) => hasAccess) .map(([id]) => id); + // Build linkIds map for accessible programs + const linkIds: Record = {}; + if (programAccess === "restricted") { + for (const programId of programIds) { + linkIds[programId] = linkState[programId]; + } + } + setIsSaving(true); await partnerProfileFetch( "@put/api/partner-profile/users/:userId/programs", { - params: { userId: user.id }, - body: { programAccess: scopeMode, programIds }, + params: { + userId: user.id, + }, + body: { + programAccess, + programIds, + linkIds, + }, onSuccess: async () => { toast.success("Program assignments updated!"); await mutate( @@ -124,9 +184,9 @@ function PartnerMemberProgramsSheetContent({ onAccessChange(e.target.value === "access")} + > + + + + ) : ( + +
- {canEdit ? ( - - ) : ( - -
)}
); diff --git a/apps/web/lib/zod/schemas/partner-profile.ts b/apps/web/lib/zod/schemas/partner-profile.ts index 0631c324e26..f827afa5a5d 100644 --- a/apps/web/lib/zod/schemas/partner-profile.ts +++ b/apps/web/lib/zod/schemas/partner-profile.ts @@ -192,6 +192,7 @@ export const getPartnerUsersQuerySchema = z.object({ export const assignProgramInputSchema = z.object({ programAccess: z.enum(ProgramAccessScope), programIds: z.array(z.string()), + linkIds: z.record(z.string(), z.array(z.string()).optional()), }); export const assignedProgramOutputSchema = z.object({ @@ -232,6 +233,15 @@ export const partnerUserSchema = z.object({ name: true, slug: true, logo: true, + }).extend({ + links: z.array( + LinkSchema.pick({ + id: true, + domain: true, + key: true, + shortLink: true, + }), + ), }), ), }); From 2c156298cf4473f73586dcc8902af71a99cb8cc4 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 6 Apr 2026 15:44:17 +0530 Subject: [PATCH 20/86] Update upload-bounty-submission-file.ts --- apps/web/lib/actions/partners/upload-bounty-submission-file.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/lib/actions/partners/upload-bounty-submission-file.ts b/apps/web/lib/actions/partners/upload-bounty-submission-file.ts index 440fea1d789..f9270ab74ef 100644 --- a/apps/web/lib/actions/partners/upload-bounty-submission-file.ts +++ b/apps/web/lib/actions/partners/upload-bounty-submission-file.ts @@ -23,6 +23,7 @@ export const uploadBountySubmissionFileAction = authPartnerActionClient role: partnerUser.role, permission: "bounties.submit", }); + const { programId, bountyId, fileName, contentType, contentLength } = parsedInput; From f798984c378b3f9c13b7a2d4e7a0e0e729c3612a Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 6 Apr 2026 15:44:19 +0530 Subject: [PATCH 21/86] Update withdraw-partner-application.ts --- .../lib/actions/partners/withdraw-partner-application.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/lib/actions/partners/withdraw-partner-application.ts b/apps/web/lib/actions/partners/withdraw-partner-application.ts index 4c3f1059581..d2e42829d59 100644 --- a/apps/web/lib/actions/partners/withdraw-partner-application.ts +++ b/apps/web/lib/actions/partners/withdraw-partner-application.ts @@ -1,6 +1,7 @@ "use server"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; +import { throwIfNoPermission } from "@/lib/auth/partner-users/throw-if-no-permission"; import { prisma } from "@dub/prisma"; import * as z from "zod/v4"; import { authPartnerActionClient } from "../safe-action"; @@ -13,7 +14,12 @@ export const withdrawPartnerApplicationAction = authPartnerActionClient .inputSchema(inputSchema) .action(async ({ ctx, parsedInput }) => { const { programId } = parsedInput; - const { partner } = ctx; + const { partner, partnerUser } = ctx; + + throwIfNoPermission({ + role: partnerUser.role, + permission: "program_enrollments.withdraw", + }); const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId: partner.id, From e0d27285f775975056ecc5383ec5a77e3d2afbc7 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 6 Apr 2026 15:44:21 +0530 Subject: [PATCH 22/86] Update partner-user-permissions.ts --- apps/web/lib/auth/partner-users/partner-user-permissions.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/lib/auth/partner-users/partner-user-permissions.ts b/apps/web/lib/auth/partner-users/partner-user-permissions.ts index b8cb093aad5..730501cc369 100644 --- a/apps/web/lib/auth/partner-users/partner-user-permissions.ts +++ b/apps/web/lib/auth/partner-users/partner-user-permissions.ts @@ -17,6 +17,7 @@ const PERMISSIONS = [ "messages.write", "messages.mark_as_read", "program_invites.accept", + "program_enrollments.withdraw", "bounties.submit", "links.write", "referrals.submit", @@ -31,6 +32,7 @@ const ROLE_PERMISSIONS: Record> = { "messages.write", "messages.mark_as_read", "program_invites.accept", + "program_enrollments.withdraw", "bounties.submit", "links.write", "referrals.submit", From 9d4fabeb646e3712878c5eadae204860ffaf969b Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 6 Apr 2026 15:47:29 +0530 Subject: [PATCH 23/86] Update partner.ts --- apps/web/lib/auth/partner.ts | 39 +++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/apps/web/lib/auth/partner.ts b/apps/web/lib/auth/partner.ts index 47f4b20f8ff..396e30cf13d 100644 --- a/apps/web/lib/auth/partner.ts +++ b/apps/web/lib/auth/partner.ts @@ -232,7 +232,12 @@ export const withPartnerProfile = ( }, assignedPrograms: { select: { - programId: true, + program: { + select: { + id: true, + slug: true, + }, + }, }, }, assignedLinks: { @@ -273,22 +278,32 @@ export const withPartnerProfile = ( const assignedProgramIds = partnerUser.programAccess === "all" ? undefined - : partnerUser.assignedPrograms.map(({ programId }) => programId); + : partnerUser.assignedPrograms.map(({ program }) => program.id); + const assignedProgramSlugs = + partnerUser.programAccess === "all" + ? [] + : partnerUser.assignedPrograms.map(({ program }) => program.slug); const assignedLinkIds = partnerUser.assignedLinks.map( ({ linkId }) => linkId, ); // If the user is scoped to specific programs and the route has a programId param, - // verify they have access to this program - if ( - params.programId && - assignedProgramIds !== undefined && - !assignedProgramIds.includes(params.programId) - ) { - throw new DubApiError({ - code: "not_found", - message: "Program not found.", - }); + // verify they have access to this program (param may be program id or slug) + if (params.programId && assignedProgramIds !== undefined) { + let hasAccess = false; + + if (params.programId.startsWith("prog_")) { + hasAccess = assignedProgramIds.includes(params.programId); + } else { + hasAccess = assignedProgramSlugs.includes(params.programId); + } + + if (!hasAccess) { + throw new DubApiError({ + code: "not_found", + message: "Program not found.", + }); + } } const { From e673234db21f94eed7859ae50b8ebc38b8362cf6 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 6 Apr 2026 16:57:09 +0530 Subject: [PATCH 24/86] Add program access handling and link restrictions for partner users - Introduce programAccess field with default value "all" in invite and accept routes - Update program enrollment retrieval to include assigned link filtering - Enhance link routes to enforce access restrictions based on assigned links - Modify earnings timeseries query to handle empty linkIds gracefully - Refactor partner profile handling to accommodate new access logic --- .../partner-profile/invites/accept/route.ts | 1 + .../(ee)/api/partner-profile/invites/route.ts | 1 + .../[programId]/links/[linkId]/route.ts | 24 ++++- .../programs/[programId]/links/route.ts | 62 +++++++------ .../programs/[programId]/route.ts | 90 ++++++++++--------- .../get-partner-earnings-timeseries.ts | 2 +- .../get-program-enrollment-or-throw.ts | 1 + apps/web/lib/auth/partner.ts | 5 +- .../web/lib/swr/use-program-messages-count.ts | 9 +- apps/web/lib/swr/use-program-messages.ts | 9 +- 10 files changed, 131 insertions(+), 73 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/invites/accept/route.ts b/apps/web/app/(ee)/api/partner-profile/invites/accept/route.ts index 21b148c810f..ddf97b125f4 100644 --- a/apps/web/app/(ee)/api/partner-profile/invites/accept/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/invites/accept/route.ts @@ -50,6 +50,7 @@ export const POST = withSession(async ({ session }) => { userId: session.user.id, role: invite.role, partnerId: partner.id, + programAccess: "all", notificationPreferences: { create: {}, }, diff --git a/apps/web/app/(ee)/api/partner-profile/invites/route.ts b/apps/web/app/(ee)/api/partner-profile/invites/route.ts index cf12b25091a..27b8ea51264 100644 --- a/apps/web/app/(ee)/api/partner-profile/invites/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/invites/route.ts @@ -36,6 +36,7 @@ export const GET = withPartnerProfile(async ({ partner, searchParams }) => { ...invite, id: null, name: invite.email, + programAccess: "all", programs: [], }), ); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts index da52f3e9b21..a98fc7d9dc4 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts @@ -14,13 +14,26 @@ import { NextResponse } from "next/server"; // PATCH /api/partner-profile/[programId]/links/[linkId] - update a link for a partner export const PATCH = withPartnerProfile( - async ({ partner, params, req, session }) => { + async ({ + partner, + params, + req, + session, + partnerUser: { assignedLinkIds }, + }) => { const { url, key, comments } = createPartnerLinkSchema .pick({ url: true, key: true, comments: true }) .parse(await parseRequestBody(req)); const { programId, linkId } = params; + if (assignedLinkIds && !assignedLinkIds.includes(linkId)) { + throw new DubApiError({ + code: "forbidden", + message: "You are not authorized to access this link.", + }); + } + const { program, links, @@ -160,9 +173,16 @@ export const PATCH = withPartnerProfile( // DELETE /api/partner-profile/[programId]/links/[linkId] - delete a link for a partner export const DELETE = withPartnerProfile( - async ({ partner, params }) => { + async ({ partner, params, partnerUser: { assignedLinkIds } }) => { const { programId, linkId } = params; + if (assignedLinkIds && !assignedLinkIds.includes(linkId)) { + throw new DubApiError({ + code: "forbidden", + message: "You are not authorized to access this link.", + }); + } + const { links, status } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId, diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts index e8448195d18..a85ed6f70c6 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts @@ -16,32 +16,42 @@ import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/partner-profile/programs/[programId]/links - get a partner's links in a program -export const GET = withPartnerProfile(async ({ partner, params }) => { - const { links, discountCodes } = await getProgramEnrollmentOrThrow({ - partnerId: partner.id, - programId: params.programId, - include: { - links: true, - discountCodes: true, - }, - }); - - // Add discount code to the links - const linksByDiscountCode = new Map( - discountCodes?.map((discountCode) => [discountCode.linkId, discountCode]), - ); - - const result = links.map((link) => { - const discountCode = linksByDiscountCode.get(link.id); - - return { - ...link, - discountCode: discountCode?.code, - }; - }); - - return NextResponse.json(z.array(PartnerProfileLinkSchema).parse(result)); -}); +export const GET = withPartnerProfile( + async ({ partner, params, partnerUser: { assignedLinkIds } }) => { + const { links, discountCodes } = await getProgramEnrollmentOrThrow({ + partnerId: partner.id, + programId: params.programId, + include: { + links: assignedLinkIds + ? { + where: { + id: { + in: assignedLinkIds, + }, + }, + } + : true, + discountCodes: true, + }, + }); + + // Add discount code to the links + const linksByDiscountCode = new Map( + discountCodes?.map((discountCode) => [discountCode.linkId, discountCode]), + ); + + const result = links.map((link) => { + const discountCode = linksByDiscountCode.get(link.id); + + return { + ...link, + discountCode: discountCode?.code, + }; + }); + + return NextResponse.json(z.array(PartnerProfileLinkSchema).parse(result)); + }, +); // POST /api/partner-profile/[programId]/links - create a link for a partner export const POST = withPartnerProfile( diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/route.ts index c3e1d9c4798..3c87e73487e 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/route.ts @@ -5,47 +5,57 @@ import { Reward } from "@dub/prisma/client"; import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/[programId] – get a partner's enrollment in a program -export const GET = withPartnerProfile(async ({ partner, params }) => { - const programEnrollment = await getProgramEnrollmentOrThrow({ - partnerId: partner.id, - programId: params.programId, - include: { - program: true, - partner: true, - links: true, - clickReward: true, - leadReward: true, - saleReward: true, - discount: true, - partnerGroup: true, - application: { - select: { - rejectionReason: true, - rejectionNote: true, - reviewedAt: true, +export const GET = withPartnerProfile( + async ({ partner, params, partnerUser: { assignedLinkIds } }) => { + const programEnrollment = await getProgramEnrollmentOrThrow({ + partnerId: partner.id, + programId: params.programId, + include: { + program: true, + partner: true, + links: assignedLinkIds + ? { + where: { + id: { + in: assignedLinkIds, + }, + }, + } + : true, + clickReward: true, + leadReward: true, + saleReward: true, + discount: true, + partnerGroup: true, + application: { + select: { + rejectionReason: true, + rejectionNote: true, + reviewedAt: true, + }, }, }, - }, - }); + }); - const rewards = [ - programEnrollment.clickReward, - programEnrollment.leadReward, - programEnrollment.saleReward, - ].filter((r): r is Reward => r !== null); + const rewards = [ + programEnrollment.clickReward, + programEnrollment.leadReward, + programEnrollment.saleReward, + ].filter((r): r is Reward => r !== null); - return NextResponse.json( - ProgramEnrollmentSchema.parse({ - ...programEnrollment, - rewards, - group: programEnrollment.partnerGroup, - application: programEnrollment.application - ? { - rejectionReason: programEnrollment.application.rejectionReason, - rejectionNote: programEnrollment.application.rejectionNote, - reviewedAt: programEnrollment.application.reviewedAt, - } - : null, - }), - ); -}); + return NextResponse.json( + ProgramEnrollmentSchema.parse({ + ...programEnrollment, + rewards, + group: programEnrollment.partnerGroup, + application: programEnrollment.application + ? { + rejectionReason: programEnrollment.application.rejectionReason, + rejectionNote: programEnrollment.application.rejectionNote, + reviewedAt: programEnrollment.application.reviewedAt, + } + : null, + }), + ); + }, +); diff --git a/apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts b/apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts index 560103ed979..09893413e67 100644 --- a/apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts +++ b/apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts @@ -68,7 +68,7 @@ export async function getPartnerEarningsTimeseries({ ${type ? Prisma.sql`AND type = ${type}` : Prisma.sql``} ${payoutId ? Prisma.sql`AND payoutId = ${payoutId}` : Prisma.sql``} ${linkId ? Prisma.sql`AND linkId = ${linkId}` : Prisma.sql``} - ${linkIds ? Prisma.sql`AND linkId IN (${Prisma.join(linkIds)})` : Prisma.sql``} + ${linkIds && linkIds.length > 0 ? Prisma.sql`AND linkId IN (${Prisma.join(linkIds)})` : Prisma.sql``} ${customerId ? Prisma.sql`AND customerId = ${customerId}` : Prisma.sql``} ${status ? Prisma.sql`AND status = ${status}` : Prisma.sql``} GROUP BY start${groupBy ? (groupBy === "type" ? Prisma.sql`, type` : Prisma.sql`, linkId`) : Prisma.sql``} diff --git a/apps/web/lib/api/programs/get-program-enrollment-or-throw.ts b/apps/web/lib/api/programs/get-program-enrollment-or-throw.ts index 815a0aa58d1..5ae06465ea4 100644 --- a/apps/web/lib/api/programs/get-program-enrollment-or-throw.ts +++ b/apps/web/lib/api/programs/get-program-enrollment-or-throw.ts @@ -18,6 +18,7 @@ export async function getProgramEnrollmentOrThrow< ...include, links: include.links ? { + ...(typeof include.links === "object" ? include.links : {}), orderBy: { createdAt: "asc", }, diff --git a/apps/web/lib/auth/partner.ts b/apps/web/lib/auth/partner.ts index 396e30cf13d..46d1709754c 100644 --- a/apps/web/lib/auth/partner.ts +++ b/apps/web/lib/auth/partner.ts @@ -37,7 +37,7 @@ interface WithPartnerProfileHandler { "id" | "userId" | "role" | "programAccess" > & { assignedProgramIds: string[] | undefined; - assignedLinkIds: string[]; + assignedLinkIds: string[] | undefined; }; }): Promise; } @@ -338,7 +338,8 @@ export const withPartnerProfile = ( role: partnerUser.role, programAccess: partnerUser.programAccess, assignedProgramIds, - assignedLinkIds, + assignedLinkIds: + assignedLinkIds.length > 0 ? assignedLinkIds : undefined, }, headers: responseHeaders, }); diff --git a/apps/web/lib/swr/use-program-messages-count.ts b/apps/web/lib/swr/use-program-messages-count.ts index def8157a2cf..898ac7bf857 100644 --- a/apps/web/lib/swr/use-program-messages-count.ts +++ b/apps/web/lib/swr/use-program-messages-count.ts @@ -1,7 +1,9 @@ import { fetcher } from "@dub/utils"; import useSWR, { SWRConfiguration } from "swr"; import * as z from "zod/v4"; +import { hasPermission } from "../auth/partner-users/partner-user-permissions"; import { countMessagesQuerySchema } from "../zod/schemas/messages"; +import usePartnerProfile from "./use-partner-profile"; const partialQuerySchema = countMessagesQuerySchema.partial(); @@ -14,8 +16,13 @@ export function useProgramMessagesCount({ enabled?: boolean; swrOpts?: SWRConfiguration; } = {}) { + const { partner } = usePartnerProfile(); + + const isEnabled = + enabled && partner?.role && hasPermission(partner.role, "messages.read"); + const { data, isLoading, error, mutate } = useSWR( - enabled + isEnabled ? `/api/partner-profile/messages/count?${new URLSearchParams({ ...(query as Record), }).toString()}` diff --git a/apps/web/lib/swr/use-program-messages.ts b/apps/web/lib/swr/use-program-messages.ts index c7702dbfb88..fcf344e9e36 100644 --- a/apps/web/lib/swr/use-program-messages.ts +++ b/apps/web/lib/swr/use-program-messages.ts @@ -1,10 +1,12 @@ import { fetcher } from "@dub/utils"; import useSWR, { SWRConfiguration } from "swr"; import * as z from "zod/v4"; +import { hasPermission } from "../auth/partner-users/partner-user-permissions"; import { ProgramMessagesSchema, getProgramMessagesQuerySchema, } from "../zod/schemas/messages"; +import usePartnerProfile from "./use-partner-profile"; const partialQuerySchema = getProgramMessagesQuerySchema.partial(); @@ -17,10 +19,15 @@ export function useProgramMessages({ enabled?: boolean; swrOpts?: SWRConfiguration; } = {}) { + const { partner } = usePartnerProfile(); + + const isEnabled = + enabled && partner?.role && hasPermission(partner.role, "messages.read"); + const { data, isLoading, error, mutate } = useSWR< z.infer & { delivered?: false } >( - enabled + isEnabled ? `/api/partner-profile/messages?${new URLSearchParams({ ...(query as Record), }).toString()}` From 1ce6fdebaef411745e6983f85c82bbdeb3d62881 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 6 Apr 2026 17:10:27 +0530 Subject: [PATCH 25/86] Enhance partner profile API to include assigned link filtering - Update various routes to utilize assignedLinkIds for filtering links in program enrollment retrieval - Modify customer retrieval logic to enforce access restrictions based on assigned links - Ensure proper handling of link data in export routes for partner profile analytics and events - Refactor code for clarity and maintainability --- .../api/cron/export/events/partner/route.ts | 20 ++- .../[programId]/analytics/export/route.ts | 17 +- .../programs/[programId]/analytics/route.ts | 17 +- .../customers/[customerId]/route.ts | 168 ++++++++++-------- .../[programId]/events/export/route.ts | 18 +- packages/prisma/schema/schema.prisma | 2 +- 6 files changed, 157 insertions(+), 85 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/export/events/partner/route.ts b/apps/web/app/(ee)/api/cron/export/events/partner/route.ts index c59cfd93720..d2128502757 100644 --- a/apps/web/app/(ee)/api/cron/export/events/partner/route.ts +++ b/apps/web/app/(ee)/api/cron/export/events/partner/route.ts @@ -54,6 +54,11 @@ export async function POST(req: Request) { }, select: { email: true, + partners: { + select: { + assignedLinks: true, + }, + }, }, }); @@ -65,13 +70,26 @@ export async function POST(req: Request) { return logAndRespond(`User ${userId} has no email. Skipping the export.`); } + const assignedLinkIds = user.partners.flatMap((partner) => + partner.assignedLinks.map((link) => link.linkId), + ); + const { program, links, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId, programId, include: { program: true, - links: true, + links: + assignedLinkIds.length > 0 + ? { + where: { + id: { + in: assignedLinkIds, + }, + }, + } + : true, }, }); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts index fdab58d685f..5fa2c623b58 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts @@ -16,14 +16,27 @@ import JSZip from "jszip"; // GET /api/partner-profile/programs/[programId]/analytics/export – get export data for partner profile analytics export const GET = withPartnerProfile( - async ({ partner, params, searchParams }) => { + async ({ + partner, + params, + searchParams, + partnerUser: { assignedLinkIds }, + }) => { const { program, links, totalCommissions } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, - links: true, + links: assignedLinkIds + ? { + where: { + id: { + in: assignedLinkIds, + }, + }, + } + : true, }, }); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts index 442fe0c2baa..7cd65c0d417 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts @@ -14,14 +14,27 @@ import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/[programId]/analytics – get analytics for a program enrollment link export const GET = withPartnerProfile( - async ({ partner, params, searchParams }) => { + async ({ + partner, + params, + searchParams, + partnerUser: { assignedLinkIds }, + }) => { const { program, links, totalCommissions } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, - links: true, + links: assignedLinkIds + ? { + where: { + id: { + in: assignedLinkIds, + }, + }, + } + : true, }, }); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts index fa95f850c0c..224c19ec772 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts @@ -17,92 +17,106 @@ import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/partner-profile/programs/:programId/customers/:customerId – Get a customer by ID -export const GET = withPartnerProfile(async ({ partner, params }) => { - const { customerId, programId } = params; +export const GET = withPartnerProfile( + async ({ partner, params, partnerUser: { assignedLinkIds } }) => { + const { customerId, programId } = params; - const { program, links, totalCommissions, customerDataSharingEnabledAt } = - await getProgramEnrollmentOrThrow({ - partnerId: partner.id, - programId: programId, - include: { - program: true, - links: true, - }, - }); + const { program, links, totalCommissions, customerDataSharingEnabledAt } = + await getProgramEnrollmentOrThrow({ + partnerId: partner.id, + programId: programId, + include: { + program: true, + links: true, + }, + }); - if ( - LARGE_PROGRAM_IDS.includes(program.id) && - toCentsNumber(totalCommissions) < LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS - ) { - throw new DubApiError({ - code: "forbidden", - message: "This feature is not available for your program.", - }); - } + if ( + LARGE_PROGRAM_IDS.includes(program.id) && + toCentsNumber(totalCommissions) < + LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS + ) { + throw new DubApiError({ + code: "forbidden", + message: "This feature is not available for your program.", + }); + } - const customer = await prisma.customer.findUnique({ - where: { - id: customerId, - }, - include: { - // find the first sale commission for this customer and partner - commissions: { - where: { - partnerId: partner.id, - type: CommissionType.sale, - }, - take: 1, - orderBy: { - createdAt: "asc", + const customer = await prisma.customer.findUnique({ + where: { + id: customerId, + }, + include: { + // find the first sale commission for this customer and partner + commissions: { + where: { + partnerId: partner.id, + type: CommissionType.sale, + }, + take: 1, + orderBy: { + createdAt: "asc", + }, }, }, - }, - }); - - if (!customer || customer?.projectId !== program.workspaceId) { - throw new DubApiError({ - code: "not_found", - message: "Customer is not part of this program.", }); - } - const events = await getCustomerEvents({ - customerId: customer.id, - linkIds: links.map((link) => link.id), - includeMetadata: false, - }); + if (!customer || customer?.projectId !== program.workspaceId) { + throw new DubApiError({ + code: "not_found", + message: "Customer is not part of this program.", + }); + } + + if ( + assignedLinkIds && + customer.linkId && + !assignedLinkIds.includes(customer.linkId) + ) { + throw new DubApiError({ + code: "forbidden", + message: "You are not authorized to access this customer.", + }); + } - if (events.length === 0) { - throw new DubApiError({ - code: "not_found", - message: "Customer is not attributed to any links by this partner.", + const events = await getCustomerEvents({ + customerId: customer.id, + linkIds: links.map((link) => link.id), + includeMetadata: false, }); - } - // get the first partner link that this customer interacted with - const firstLinkId = events[events.length - 1].link_id; - const link = links.find((link) => link.id === firstLinkId); - const firstSaleAt = - customer.commissions[0]?.createdAt ?? customer.firstSaleAt; + if (events.length === 0) { + throw new DubApiError({ + code: "not_found", + message: "Customer is not attributed to any links by this partner.", + }); + } - return NextResponse.json( - PartnerProfileCustomerSchema.extend({ - ...(customerDataSharingEnabledAt && { name: z.string().nullish() }), - }).parse({ - ...transformCustomer({ - ...customer, - firstSaleAt, - email: customer.email - ? customerDataSharingEnabledAt - ? customer.email - : obfuscateCustomerEmail(customer.email) - : customer.name || generateRandomName(), + // get the first partner link that this customer interacted with + const firstLinkId = events[events.length - 1].link_id; + const link = links.find((link) => link.id === firstLinkId); + const firstSaleAt = + customer.commissions[0]?.createdAt ?? customer.firstSaleAt; + + return NextResponse.json( + PartnerProfileCustomerSchema.extend({ + ...(customerDataSharingEnabledAt && { name: z.string().nullish() }), + }).parse({ + ...transformCustomer({ + ...customer, + firstSaleAt, + email: customer.email + ? customerDataSharingEnabledAt + ? customer.email + : obfuscateCustomerEmail(customer.email) + : customer.name || generateRandomName(), + }), + activity: { + ...customer, + events, + link, + }, }), - activity: { - ...customer, - events, - link, - }, - }), - ); -}); + ); + }, +); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts index c004073f058..4d3bcc1fe72 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts @@ -34,14 +34,28 @@ const MAX_EVENTS_TO_EXPORT = 1000; // GET /api/partner-profile/programs/[programId]/events/export – get export data for partner profile events export const GET = withPartnerProfile( - async ({ partner, params, searchParams, session }) => { + async ({ + partner, + params, + searchParams, + session, + partnerUser: { assignedLinkIds }, + }) => { const { program, links, totalCommissions, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, - links: true, + links: assignedLinkIds + ? { + where: { + id: { + in: assignedLinkIds, + }, + }, + } + : true, }, }); diff --git a/packages/prisma/schema/schema.prisma b/packages/prisma/schema/schema.prisma index 5ecabe0d31f..d90e263032a 100644 --- a/packages/prisma/schema/schema.prisma +++ b/packages/prisma/schema/schema.prisma @@ -53,7 +53,7 @@ model User { partnerComments PartnerComment[] fraudEventGroups FraudEventGroup[] activityLogs ActivityLog[] - createdCommissions Commission[] + createdCommissions Commission[] @@index(sentMail) @@index(source) From 6b4698b188494438ce6bdb39fc26e418fb6a79da Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 6 Apr 2026 17:20:42 +0530 Subject: [PATCH 26/86] Extract linkScopeFilter and linkIncludeFilter helpers for assignedLinkIds Replace repeated inline assignedLinkIds ternary patterns across 9 route files with two reusable helpers, mirroring the existing programScopeFilter pattern. --- .../[programId]/analytics/export/route.ts | 11 +----- .../programs/[programId]/analytics/route.ts | 11 +----- .../[programId]/customers/count/route.ts | 7 +++- .../programs/[programId]/customers/route.ts | 3 +- .../[programId]/earnings/count/route.ts | 3 +- .../[programId]/events/export/route.ts | 11 +----- .../programs/[programId]/events/route.ts | 10 ++++- .../programs/[programId]/links/route.ts | 11 +----- .../programs/[programId]/route.ts | 11 +----- .../auth/partner-users/link-scope-filter.ts | 37 +++++++++++++++++++ 10 files changed, 65 insertions(+), 50 deletions(-) create mode 100644 apps/web/lib/auth/partner-users/link-scope-filter.ts diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts index 5fa2c623b58..ed1e8a8b786 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts @@ -5,6 +5,7 @@ import { convertToCSV } from "@/lib/analytics/utils"; import { DubApiError } from "@/lib/api/errors"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { linkIncludeFilter } from "@/lib/auth/partner-users/link-scope-filter"; import { LARGE_PROGRAM_IDS, LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS, @@ -28,15 +29,7 @@ export const GET = withPartnerProfile( programId: params.programId, include: { program: true, - links: assignedLinkIds - ? { - where: { - id: { - in: assignedLinkIds, - }, - }, - } - : true, + links: linkIncludeFilter(assignedLinkIds), }, }); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts index 7cd65c0d417..7b7a55aa5b4 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts @@ -3,6 +3,7 @@ import { getAnalytics } from "@/lib/analytics/get-analytics"; import { DubApiError } from "@/lib/api/errors"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { linkIncludeFilter } from "@/lib/auth/partner-users/link-scope-filter"; import { LARGE_PROGRAM_IDS, LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS, @@ -26,15 +27,7 @@ export const GET = withPartnerProfile( programId: params.programId, include: { program: true, - links: assignedLinkIds - ? { - where: { - id: { - in: assignedLinkIds, - }, - }, - } - : true, + links: linkIncludeFilter(assignedLinkIds), }, }); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/count/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/count/route.ts index d0b62576a59..428711c9094 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/count/route.ts @@ -1,6 +1,10 @@ import { DubApiError } from "@/lib/api/errors"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { + linkIncludeFilter, + linkScopeFilter, +} from "@/lib/auth/partner-users/link-scope-filter"; import { LARGE_PROGRAM_IDS, LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS, @@ -29,6 +33,7 @@ export const GET = withPartnerProfile( programId: programId, include: { program: true, + links: linkIncludeFilter(assignedLinkIds), }, }); @@ -66,7 +71,7 @@ export const GET = withPartnerProfile( name: { search: sanitizeFullTextSearch(search) }, } : {}), - ...(assignedLinkIds ? { linkId: { in: assignedLinkIds } } : {}), + ...linkScopeFilter(assignedLinkIds), }; // Get customer count by country diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/route.ts index 8472e88ad0d..f4240a3f9cb 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/route.ts @@ -3,6 +3,7 @@ import { DubApiError } from "@/lib/api/errors"; import { obfuscateCustomerEmail } from "@/lib/api/partner-profile/obfuscate-customer-email"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { linkScopeFilter } from "@/lib/auth/partner-users/link-scope-filter"; import { LARGE_PROGRAM_IDS, LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS, @@ -65,7 +66,7 @@ export const GET = withPartnerProfile( projectId: program.workspaceId, ...(country && { country }), ...(linkId && { linkId }), - ...(assignedLinkIds ? { linkId: { in: assignedLinkIds } } : {}), + ...linkScopeFilter(assignedLinkIds), // Only allow search if customer data sharing is enabled ...(search && customerDataSharingEnabledAt ? search.includes("@") diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts index 4225e30516c..d98c769762c 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts @@ -2,6 +2,7 @@ import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates"; import { obfuscateCustomerEmail } from "@/lib/api/partner-profile/obfuscate-customer-email"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { linkScopeFilter } from "@/lib/auth/partner-users/link-scope-filter"; import { generateRandomName } from "@/lib/names"; import { getPartnerEarningsCountQuerySchema } from "@/lib/zod/schemas/partner-profile"; import { prisma } from "@dub/prisma"; @@ -55,7 +56,7 @@ export const GET = withPartnerProfile( gte: startDate, lte: endDate, }, - ...(assignedLinkIds ? { linkId: { in: assignedLinkIds } } : {}), + ...linkScopeFilter(assignedLinkIds), }; if (groupBy) { diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts index 4d3bcc1fe72..ec7015e22db 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts @@ -10,6 +10,7 @@ import { DubApiError } from "@/lib/api/errors"; import { obfuscateCustomerEmail } from "@/lib/api/partner-profile/obfuscate-customer-email"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { linkIncludeFilter } from "@/lib/auth/partner-users/link-scope-filter"; import { LARGE_PROGRAM_IDS, LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS, @@ -47,15 +48,7 @@ export const GET = withPartnerProfile( programId: params.programId, include: { program: true, - links: assignedLinkIds - ? { - where: { - id: { - in: assignedLinkIds, - }, - }, - } - : true, + links: linkIncludeFilter(assignedLinkIds), }, }); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/route.ts index af6281286f9..746818fa18d 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/route.ts @@ -4,6 +4,7 @@ import { DubApiError } from "@/lib/api/errors"; import { obfuscateCustomerEmail } from "@/lib/api/partner-profile/obfuscate-customer-email"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { linkIncludeFilter } from "@/lib/auth/partner-users/link-scope-filter"; import { LARGE_PROGRAM_IDS, LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS, @@ -20,14 +21,19 @@ import * as z from "zod/v4"; // GET /api/partner-profile/programs/[programId]/events – get events for a program enrollment link export const GET = withPartnerProfile( - async ({ partner, params, searchParams }) => { + async ({ + partner, + params, + searchParams, + partnerUser: { assignedLinkIds }, + }) => { const { program, links, totalCommissions, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, - links: true, + links: linkIncludeFilter(assignedLinkIds), }, }); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts index a85ed6f70c6..59d84668b4d 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts @@ -5,6 +5,7 @@ import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enro import { parseRequestBody } from "@/lib/api/utils"; import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { linkIncludeFilter } from "@/lib/auth/partner-users/link-scope-filter"; import { PartnerProfileLinkSchema } from "@/lib/zod/schemas/partner-profile"; import { createPartnerLinkSchema, @@ -22,15 +23,7 @@ export const GET = withPartnerProfile( partnerId: partner.id, programId: params.programId, include: { - links: assignedLinkIds - ? { - where: { - id: { - in: assignedLinkIds, - }, - }, - } - : true, + links: linkIncludeFilter(assignedLinkIds), discountCodes: true, }, }); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/route.ts index 3c87e73487e..c74b383e3c4 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/route.ts @@ -1,5 +1,6 @@ import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { linkIncludeFilter } from "@/lib/auth/partner-users/link-scope-filter"; import { ProgramEnrollmentSchema } from "@/lib/zod/schemas/programs"; import { Reward } from "@dub/prisma/client"; import { NextResponse } from "next/server"; @@ -13,15 +14,7 @@ export const GET = withPartnerProfile( include: { program: true, partner: true, - links: assignedLinkIds - ? { - where: { - id: { - in: assignedLinkIds, - }, - }, - } - : true, + links: linkIncludeFilter(assignedLinkIds), clickReward: true, leadReward: true, saleReward: true, diff --git a/apps/web/lib/auth/partner-users/link-scope-filter.ts b/apps/web/lib/auth/partner-users/link-scope-filter.ts new file mode 100644 index 00000000000..c4d5ec61071 --- /dev/null +++ b/apps/web/lib/auth/partner-users/link-scope-filter.ts @@ -0,0 +1,37 @@ +/** + * Prisma `where` fragment for filtering by linkId. + * Use in direct queries: where: { ...linkScopeFilter(assignedLinkIds) } + */ +export function linkScopeFilter(assignedLinkIds: string[] | undefined): { + linkId?: { in: string[] }; +} { + if (assignedLinkIds === undefined) { + return {}; + } + + return { + linkId: { + in: assignedLinkIds, + }, + }; +} + +/** + * Prisma `include.links` value for getProgramEnrollmentOrThrow calls. + * Returns `true` (all links) or `{ where: { id: { in: ... } } }` (scoped). + */ +export function linkIncludeFilter( + assignedLinkIds: string[] | undefined, +): true | { where: { id: { in: string[] } } } { + if (assignedLinkIds === undefined) { + return true; + } + + return { + where: { + id: { + in: assignedLinkIds, + }, + }, + }; +} From fe46afac5385e5b7f8ee0640b7c2f892117f010e Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 6 Apr 2026 21:44:20 +0530 Subject: [PATCH 27/86] Update route.ts --- apps/web/app/(ee)/api/partner-profile/payouts/route.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(ee)/api/partner-profile/payouts/route.ts b/apps/web/app/(ee)/api/partner-profile/payouts/route.ts index 01414cbdcc8..4f48ed817f4 100644 --- a/apps/web/app/(ee)/api/partner-profile/payouts/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/payouts/route.ts @@ -1,5 +1,6 @@ import { getEffectivePayoutMode } from "@/lib/api/payouts/get-effective-payout-mode"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { programScopeFilter } from "@/lib/auth/partner-users/program-scope-filter"; import { partnerProfilePayoutsQuerySchema } from "@/lib/zod/schemas/partner-profile"; import { PartnerPayoutResponseSchema } from "@/lib/zod/schemas/payouts"; import { prisma } from "@dub/prisma"; @@ -8,7 +9,7 @@ import * as z from "zod/v4"; // GET /api/partner-profile/payouts - get all payouts for a partner export const GET = withPartnerProfile( - async ({ partner, searchParams }) => { + async ({ partner, searchParams, partnerUser: { assignedProgramIds } }) => { const { programId, status, @@ -22,6 +23,7 @@ export const GET = withPartnerProfile( where: { partnerId: partner.id, ...(programId && { programId }), + ...programScopeFilter(assignedProgramIds), ...(status && { status }), }, include: { From 8a28d6c36fd9b74a36c9419d9351bf30252657e1 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 6 Apr 2026 21:51:58 +0530 Subject: [PATCH 28/86] missed a few spot --- .../app/(ee)/api/cron/export/events/partner/route.ts | 12 ++---------- .../(ee)/api/partner-profile/invites/accept/route.ts | 1 - .../(ee)/api/partner-profile/messages/count/route.ts | 4 +++- .../app/(ee)/api/partner-profile/messages/route.ts | 3 +++ 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/export/events/partner/route.ts b/apps/web/app/(ee)/api/cron/export/events/partner/route.ts index d2128502757..888f3f9cdec 100644 --- a/apps/web/app/(ee)/api/cron/export/events/partner/route.ts +++ b/apps/web/app/(ee)/api/cron/export/events/partner/route.ts @@ -10,6 +10,7 @@ import { obfuscateCustomerEmail } from "@/lib/api/partner-profile/obfuscate-cust import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { generateExportFilename } from "@/lib/api/utils/generate-export-filename"; import { generateRandomString } from "@/lib/api/utils/generate-random-string"; +import { linkIncludeFilter } from "@/lib/auth/partner-users/link-scope-filter"; import { MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING } from "@/lib/constants/partner-profile"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { generateRandomName } from "@/lib/names"; @@ -80,16 +81,7 @@ export async function POST(req: Request) { programId, include: { program: true, - links: - assignedLinkIds.length > 0 - ? { - where: { - id: { - in: assignedLinkIds, - }, - }, - } - : true, + links: linkIncludeFilter(assignedLinkIds), }, }); diff --git a/apps/web/app/(ee)/api/partner-profile/invites/accept/route.ts b/apps/web/app/(ee)/api/partner-profile/invites/accept/route.ts index ddf97b125f4..21b148c810f 100644 --- a/apps/web/app/(ee)/api/partner-profile/invites/accept/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/invites/accept/route.ts @@ -50,7 +50,6 @@ export const POST = withSession(async ({ session }) => { userId: session.user.id, role: invite.role, partnerId: partner.id, - programAccess: "all", notificationPreferences: { create: {}, }, diff --git a/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts b/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts index 76e3dcfbf95..ddb243c3752 100644 --- a/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts @@ -1,11 +1,12 @@ import { withPartnerProfile } from "@/lib/auth/partner"; +import { programScopeFilter } from "@/lib/auth/partner-users/program-scope-filter"; import { countMessagesQuerySchema } from "@/lib/zod/schemas/messages"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/partner-profile/messages/count - count messages for a partner export const GET = withPartnerProfile( - async ({ partner, searchParams }) => { + async ({ partner, searchParams, partnerUser: { assignedProgramIds } }) => { const { unread } = countMessagesQuerySchema.parse(searchParams); const count = await prisma.message.count({ @@ -21,6 +22,7 @@ export const GET = withPartnerProfile( // Only count read messages not: null, }, + ...programScopeFilter(assignedProgramIds), }), }, }); diff --git a/apps/web/app/(ee)/api/partner-profile/messages/route.ts b/apps/web/app/(ee)/api/partner-profile/messages/route.ts index 23a1013b88e..e9bb017c46a 100644 --- a/apps/web/app/(ee)/api/partner-profile/messages/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/messages/route.ts @@ -6,6 +6,9 @@ import { import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; +// TODO: +// Add program scope filter + // GET /api/partner-profile/messages - get messages grouped by program export const GET = withPartnerProfile( async ({ partner, searchParams }) => { From af779b3ae251992fb2cb1610051216297128003b Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 6 Apr 2026 22:37:28 +0530 Subject: [PATCH 29/86] Update route.ts --- .../api/partner-profile/messages/route.ts | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/messages/route.ts b/apps/web/app/(ee)/api/partner-profile/messages/route.ts index e9bb017c46a..cbdc4bbf3ff 100644 --- a/apps/web/app/(ee)/api/partner-profile/messages/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/messages/route.ts @@ -1,4 +1,6 @@ +import { DubApiError } from "@/lib/api/errors"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { programScopeFilter } from "@/lib/auth/partner-users/program-scope-filter"; import { ProgramMessagesSchema, getProgramMessagesQuerySchema, @@ -6,12 +8,13 @@ import { import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; -// TODO: -// Add program scope filter - // GET /api/partner-profile/messages - get messages grouped by program export const GET = withPartnerProfile( - async ({ partner, searchParams }) => { + async ({ + partner, + searchParams, + partnerUser: { assignedProgramSlugs, assignedProgramIds }, + }) => { const { programSlug, sortBy, @@ -21,6 +24,17 @@ export const GET = withPartnerProfile( const messagesLimit = messagesLimitArg ?? (programSlug ? undefined : 10); + if ( + programSlug && + assignedProgramSlugs && + !assignedProgramSlugs.includes(programSlug) + ) { + throw new DubApiError({ + code: "not_found", + message: `Program ${programSlug} not found.`, + }); + } + const programs = await prisma.program.findMany({ where: { // Partner is not banned from the program @@ -79,6 +93,7 @@ export const GET = withPartnerProfile( }, ], }), + ...programScopeFilter(assignedProgramIds), }, include: { messages: { From a898a60d1bacd8da3d3c304cfd2ffad3ccf90737 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 6 Apr 2026 22:37:30 +0530 Subject: [PATCH 30/86] Update partner.ts --- apps/web/lib/auth/partner.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/lib/auth/partner.ts b/apps/web/lib/auth/partner.ts index 46d1709754c..891b9f7f5c4 100644 --- a/apps/web/lib/auth/partner.ts +++ b/apps/web/lib/auth/partner.ts @@ -37,6 +37,7 @@ interface WithPartnerProfileHandler { "id" | "userId" | "role" | "programAccess" > & { assignedProgramIds: string[] | undefined; + assignedProgramSlugs: string[] | undefined; assignedLinkIds: string[] | undefined; }; }): Promise; @@ -338,6 +339,7 @@ export const withPartnerProfile = ( role: partnerUser.role, programAccess: partnerUser.programAccess, assignedProgramIds, + assignedProgramSlugs, assignedLinkIds: assignedLinkIds.length > 0 ? assignedLinkIds : undefined, }, From d43d077063d03102ffeec15e93dfd2bff269231f Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 6 Apr 2026 22:47:44 +0530 Subject: [PATCH 31/86] Format --- .../webhook/checkout-session-completed.ts | 2 +- .../partners/verify-social-account-by-code.ts | 2 +- apps/web/lib/integrations/appsflyer/schema.ts | 18 ++++++++---------- .../web/tests/commissions/bulk-updates.test.ts | 12 +++++++++--- .../reject-partner-application-modal.tsx | 3 ++- .../partners/partner-application-details.tsx | 2 +- 6 files changed, 22 insertions(+), 17 deletions(-) diff --git a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts index 228d5e024f2..e5d23718d99 100644 --- a/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts +++ b/apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts @@ -27,9 +27,9 @@ import { Customer, Project } from "@dub/prisma/client"; import { COUNTRIES_TO_CONTINENTS, nanoid, pick } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import type Stripe from "stripe"; +import { getCheckoutSessionProductId } from "./utils/get-checkout-session-product-id"; import { getConnectedCustomer } from "./utils/get-connected-customer"; import { getPromotionCode } from "./utils/get-promotion-code"; -import { getCheckoutSessionProductId } from "./utils/get-checkout-session-product-id"; import { updateCustomerWithStripeCustomerId } from "./utils/update-customer-with-stripe-customer-id"; // Handle event "checkout.session.completed" diff --git a/apps/web/lib/actions/partners/verify-social-account-by-code.ts b/apps/web/lib/actions/partners/verify-social-account-by-code.ts index a5d9d4df663..c0d671ebed5 100644 --- a/apps/web/lib/actions/partners/verify-social-account-by-code.ts +++ b/apps/web/lib/actions/partners/verify-social-account-by-code.ts @@ -1,8 +1,8 @@ "use server"; -import { throwIfNoPermission } from "@/lib/auth/partner-users/throw-if-no-permission"; import { getLinkedInPost } from "@/lib/api/scrape-creators/get-linkedin-post"; import { getSocialProfile } from "@/lib/api/scrape-creators/get-social-profile"; +import { throwIfNoPermission } from "@/lib/auth/partner-users/throw-if-no-permission"; import { ratelimit } from "@/lib/upstash"; import { redis } from "@/lib/upstash/redis"; import { prisma } from "@dub/prisma"; diff --git a/apps/web/lib/integrations/appsflyer/schema.ts b/apps/web/lib/integrations/appsflyer/schema.ts index 011dba0e322..ef9a41e017e 100644 --- a/apps/web/lib/integrations/appsflyer/schema.ts +++ b/apps/web/lib/integrations/appsflyer/schema.ts @@ -2,20 +2,18 @@ import * as z from "zod/v4"; import { APPSFLYER_MACRO_VALUES } from "./constants"; import { isValidAppsFlyerMacroTemplate } from "./macro-template"; -export const appsFlyerMacroExactValueSchema = z.string().refine( - (v) => APPSFLYER_MACRO_VALUES.includes(v), - { +export const appsFlyerMacroExactValueSchema = z + .string() + .refine((v) => APPSFLYER_MACRO_VALUES.includes(v), { message: `Value must be one of: ${APPSFLYER_MACRO_VALUES.join(", ")}`, - }, -); + }); /** Free-form value; every `{{...}}` token must be a known macro. */ -export const appsFlyerMacroTemplateValueSchema = z.string().refine( - (v) => isValidAppsFlyerMacroTemplate(v), - { +export const appsFlyerMacroTemplateValueSchema = z + .string() + .refine((v) => isValidAppsFlyerMacroTemplate(v), { message: `Invalid macro in value. Use only: ${APPSFLYER_MACRO_VALUES.join(", ")}`, - }, -); + }); export const appsFlyerRequiredParameterSchema = z.object({ key: z.string().min(1), diff --git a/apps/web/tests/commissions/bulk-updates.test.ts b/apps/web/tests/commissions/bulk-updates.test.ts index 521ec8c316a..a029e6a0bdb 100644 --- a/apps/web/tests/commissions/bulk-updates.test.ts +++ b/apps/web/tests/commissions/bulk-updates.test.ts @@ -7,7 +7,9 @@ describe.sequential("/commissions/bulk - bulk updates", async () => { const { http } = await h.init(); const getCommissionsByStatus = async (status: string) => { - const { status: responseStatus, data } = await http.get({ + const { status: responseStatus, data } = await http.get< + CommissionResponse[] + >({ path: "/commissions", query: { status, @@ -49,7 +51,9 @@ describe.sequential("/commissions/bulk - bulk updates", async () => { }); expect(status).toEqual(404); - expect(data.error.message).toContain("One or more commissions were not found"); + expect(data.error.message).toContain( + "One or more commissions were not found", + ); }); test("PATCH /commissions/bulk - returns bad_request for paid commissions", async () => { @@ -90,7 +94,9 @@ describe.sequential("/commissions/bulk - bulk updates", async () => { const commissionIds = pendingCommissions.slice(0, 2).map((c) => c.id); - const { status, data } = await http.patch>({ + const { status, data } = await http.patch< + Array<{ id: string; status: string }> + >({ path: "/commissions/bulk", body: { commissionIds, diff --git a/apps/web/ui/modals/reject-partner-application-modal.tsx b/apps/web/ui/modals/reject-partner-application-modal.tsx index ba67ac154f3..92e4e5abc20 100644 --- a/apps/web/ui/modals/reject-partner-application-modal.tsx +++ b/apps/web/ui/modals/reject-partner-application-modal.tsx @@ -184,7 +184,8 @@ export function RejectPartnerApplicationModal({ Additional notes (optional) - {rejectionNote.length}/{PROGRAM_APPLICATION_REJECTION_NOTE_MAX_LENGTH} + {rejectionNote.length}/ + {PROGRAM_APPLICATION_REJECTION_NOTE_MAX_LENGTH}
diff --git a/apps/web/ui/partners/partner-application-details.tsx b/apps/web/ui/partners/partner-application-details.tsx index 40107d9e15d..a0254ad3c9c 100644 --- a/apps/web/ui/partners/partner-application-details.tsx +++ b/apps/web/ui/partners/partner-application-details.tsx @@ -338,7 +338,7 @@ function PartnerApplicationDetailsSkeleton() { {[...Array(3)].map((_, idx) => (
From 3d554c7c674657d9b27f75199ad80d7a3c2233c1 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 6 Apr 2026 22:58:01 +0530 Subject: [PATCH 32/86] Update partner.ts --- apps/web/lib/auth/partner.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/lib/auth/partner.ts b/apps/web/lib/auth/partner.ts index 891b9f7f5c4..a96caf84447 100644 --- a/apps/web/lib/auth/partner.ts +++ b/apps/web/lib/auth/partner.ts @@ -282,7 +282,7 @@ export const withPartnerProfile = ( : partnerUser.assignedPrograms.map(({ program }) => program.id); const assignedProgramSlugs = partnerUser.programAccess === "all" - ? [] + ? undefined : partnerUser.assignedPrograms.map(({ program }) => program.slug); const assignedLinkIds = partnerUser.assignedLinks.map( ({ linkId }) => linkId, @@ -290,13 +290,17 @@ export const withPartnerProfile = ( // If the user is scoped to specific programs and the route has a programId param, // verify they have access to this program (param may be program id or slug) - if (params.programId && assignedProgramIds !== undefined) { + if ( + params.programId && + assignedProgramIds !== undefined && + assignedProgramSlugs !== undefined + ) { let hasAccess = false; if (params.programId.startsWith("prog_")) { hasAccess = assignedProgramIds.includes(params.programId); } else { - hasAccess = assignedProgramSlugs.includes(params.programId); + hasAccess = assignedProgramSlugs?.includes(params.programId); } if (!hasAccess) { From 8b4be02d03029efe0dc2f6176d19ba14eb5106a9 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 7 Apr 2026 10:49:10 +0530 Subject: [PATCH 33/86] Refactor partner profile API routes to include additional conditions for linkId filtering and remove unused links route --- .../api/cron/export/events/partner/route.ts | 3 +- .../[programId]/analytics/export/route.ts | 3 +- .../programs/[programId]/analytics/route.ts | 3 +- .../[programId]/events/export/route.ts | 6 +- .../programs/[programId]/events/route.ts | 3 +- .../programs/[programId]/links/route.ts | 153 ------------------ .../users/[userId]/programs/route.ts | 2 +- .../members/partner-member-programs-cell.tsx | 53 ++++-- 8 files changed, 52 insertions(+), 174 deletions(-) delete mode 100644 apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/[programId]/links/route.ts diff --git a/apps/web/app/(ee)/api/cron/export/events/partner/route.ts b/apps/web/app/(ee)/api/cron/export/events/partner/route.ts index 888f3f9cdec..5aab1c5430c 100644 --- a/apps/web/app/(ee)/api/cron/export/events/partner/route.ts +++ b/apps/web/app/(ee)/api/cron/export/events/partner/route.ts @@ -126,7 +126,8 @@ export async function POST(req: Request) { includeMetadata: false, ...(parsedParams.linkId ? { linkId: parsedParams.linkId } - : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING + : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING && + assignedLinkIds.length === 0 ? { partnerId } : { linkId: parseFilterValue(links.map((link) => link.id)) }), dataAvailableFrom: program.startedAt ?? program.createdAt, diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts index ed1e8a8b786..5e007c5aaf9 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts @@ -121,7 +121,8 @@ export const GET = withPartnerProfile( workspaceId: program.workspaceId, ...(parsedParams.linkId ? { linkId: parsedParams.linkId } - : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING + : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING && + assignedLinkIds === undefined ? { partnerId: partner.id } : { linkId: parseFilterValue(links.map((link) => link.id)) }), dataAvailableFrom: program.startedAt ?? program.createdAt, diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts index 7b7a55aa5b4..67e0e23de84 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts @@ -96,7 +96,8 @@ export const GET = withPartnerProfile( workspaceId: program.workspaceId, ...(parsedParams.linkId ? { linkId: parsedParams.linkId } - : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING + : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING && + assignedLinkIds === undefined ? { partnerId: partner.id } : { linkId: parseFilterValue(links.map((link) => link.id)) }), dataAvailableFrom: program.startedAt ?? program.createdAt, diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts index ec7015e22db..98d020c975a 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts @@ -162,7 +162,8 @@ export const GET = withPartnerProfile( workspaceId: program.workspaceId, ...(parsedParams.linkId ? { linkId: parsedParams.linkId } - : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING + : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING && + assignedLinkIds === undefined ? { partnerId: partner.id } : { linkId: parseFilterValue(links.map((link) => link.id)) }), dataAvailableFrom: program.startedAt ?? program.createdAt, @@ -202,7 +203,8 @@ export const GET = withPartnerProfile( includeMetadata: false, ...(parsedParams.linkId ? { linkId: parsedParams.linkId } - : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING + : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING && + assignedLinkIds === undefined ? { partnerId: partner.id } : { linkId: parseFilterValue(links.map((link) => link.id)) }), limit: MAX_EVENTS_TO_EXPORT, diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/route.ts index 746818fa18d..2412be191c8 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/route.ts @@ -109,7 +109,8 @@ export const GET = withPartnerProfile( includeMetadata: false, ...(parsedParams.linkId ? { linkId: parsedParams.linkId } - : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING + : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING && + assignedLinkIds === undefined ? { partnerId: partner.id } : { linkId: parseFilterValue(links.map((link) => link.id)) }), dataAvailableFrom: program.startedAt ?? program.createdAt, diff --git a/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/[programId]/links/route.ts b/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/[programId]/links/route.ts deleted file mode 100644 index 8aade104b6b..00000000000 --- a/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/[programId]/links/route.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { DubApiError } from "@/lib/api/errors"; -import { parseRequestBody } from "@/lib/api/utils"; -import { withPartnerProfile } from "@/lib/auth/partner"; -import { - assignLinkInputSchema, - assignedLinkOutputSchema, -} from "@/lib/zod/schemas/partner-profile"; -import { prisma } from "@dub/prisma"; -import { NextResponse } from "next/server"; -import * as z from "zod/v4"; - -// PUT /api/partner-profile/users/[userId]/programs/[programId]/links - set assigned links for a user in a program -export const PUT = withPartnerProfile( - async ({ partner, params, req }) => { - const { userId, programId } = params; - const { linkIds } = assignLinkInputSchema.parse( - await parseRequestBody(req), - ); - - const targetUser = await prisma.partnerUser.findUnique({ - where: { - userId_partnerId: { - userId, - partnerId: partner.id, - }, - }, - }); - - if (!targetUser) { - throw new DubApiError({ - code: "not_found", - message: "User not found.", - }); - } - - if (targetUser.role === "owner") { - throw new DubApiError({ - code: "bad_request", - message: "Cannot scope an owner to specific programs or links.", - }); - } - - // Validate the program is one the partner is enrolled in - const programEnrollment = await prisma.programEnrollment.findUnique({ - where: { - partnerId_programId: { - partnerId: partner.id, - programId, - }, - }, - }); - - if (!programEnrollment) { - throw new DubApiError({ - code: "bad_request", - message: "Partner is not enrolled in this program.", - }); - } - - // Validate all linkIds belong to this partner+program - if (linkIds.length > 0) { - const links = await prisma.link.findMany({ - where: { - id: { - in: linkIds, - }, - programId, - partnerId: partner.id, - }, - select: { - id: true, - }, - }); - - const validLinkIds = new Set(links.map((l) => l.id)); - const invalidIds = linkIds.filter((id) => !validLinkIds.has(id)); - - if (invalidIds.length > 0) { - throw new DubApiError({ - code: "bad_request", - message: `Invalid link IDs: ${invalidIds.join(", ")}`, - }); - } - } - - const result = await prisma.$transaction(async (tx) => { - // Ensure PartnerUserProgram exists (assigning links implies program access) - await tx.partnerUserProgram.upsert({ - where: { - partnerUserId_programId: { - partnerUserId: targetUser.id, - programId, - }, - }, - create: { - partnerUserId: targetUser.id, - programId, - }, - update: {}, - }); - - // Delete all current link assignments for this user+program - await tx.partnerUserLink.deleteMany({ - where: { - partnerUserId: targetUser.id, - programId, - }, - }); - - // Create new link assignments - if (linkIds.length > 0) { - await tx.partnerUserLink.createMany({ - data: linkIds.map((linkId) => ({ - partnerUserId: targetUser.id, - linkId, - programId, - })), - skipDuplicates: true, - }); - } - - // Return the updated assignments - return tx.partnerUserLink.findMany({ - where: { - partnerUserId: targetUser.id, - programId, - }, - include: { - link: { - select: { - id: true, - domain: true, - key: true, - shortLink: true, - }, - }, - }, - }); - }); - - return NextResponse.json( - z.array(assignedLinkOutputSchema).parse( - result.map((al) => ({ - link: al.link, - createdAt: al.createdAt, - })), - ), - ); - }, - { - requiredPermission: "users.update", - }, -); diff --git a/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts b/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts index 9391512a4b6..24bd4e6f762 100644 --- a/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts @@ -9,7 +9,7 @@ import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; -// PUT /api/partner-profile/users/[userId]/programs - set assigned programs for a user +// PUT /api/partner-profile/users/[userId]/programs export const PUT = withPartnerProfile( async ({ partner, params, req }) => { const { userId } = params; diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx index 084dcfebccd..26bd382bfe7 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx @@ -58,6 +58,43 @@ export function PartnerMemberProgramsCell({ ); } + if (programAccess === "all") { + const totalCount = displayPrograms.length; + + if (totalCount <= 1) { + const program = displayPrograms[0]; + + return ( + + {program ? ( + + ) : ( +
+ +
+ )} +
+ ); + } + + const allLabel = + totalCount > MAX_OVERFLOW_LABEL + ? `All ${MAX_OVERFLOW_LABEL}+` + : `All ${totalCount}`; + + return ( + +
+ {allLabel} +
+
+ ); + } + if (displayPrograms.length === 0) { return ( @@ -72,13 +109,6 @@ export function PartnerMemberProgramsCell({ const hiddenCount = Math.max(0, displayPrograms.length - MAX_VISIBLE_LOGOS); const extra = hiddenCount > 0 ? Math.min(hiddenCount, MAX_OVERFLOW_LABEL) : 0; - const overflowLabel = - programAccess === "all" && hiddenCount > 0 - ? hiddenCount > MAX_OVERFLOW_LABEL - ? `All ${MAX_OVERFLOW_LABEL}+` - : `All ${hiddenCount}` - : null; - return (
@@ -94,13 +124,8 @@ export function PartnerMemberProgramsCell({ /> ))} {extra > 0 ? ( -
- {overflowLabel ?? `+${extra}`} +
+ +{extra}
) : null}
From 4159f052a5cd15931026a291737d1f80a836dae5 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 7 Apr 2026 11:24:02 +0530 Subject: [PATCH 34/86] Update partner.ts --- apps/web/lib/auth/partner.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/web/lib/auth/partner.ts b/apps/web/lib/auth/partner.ts index a96caf84447..c583d84f6da 100644 --- a/apps/web/lib/auth/partner.ts +++ b/apps/web/lib/auth/partner.ts @@ -284,9 +284,10 @@ export const withPartnerProfile = ( partnerUser.programAccess === "all" ? undefined : partnerUser.assignedPrograms.map(({ program }) => program.slug); - const assignedLinkIds = partnerUser.assignedLinks.map( - ({ linkId }) => linkId, - ); + const assignedLinkIds = + partnerUser.programAccess === "all" + ? undefined + : partnerUser.assignedLinks.map(({ linkId }) => linkId); // If the user is scoped to specific programs and the route has a programId param, // verify they have access to this program (param may be program id or slug) @@ -345,7 +346,9 @@ export const withPartnerProfile = ( assignedProgramIds, assignedProgramSlugs, assignedLinkIds: - assignedLinkIds.length > 0 ? assignedLinkIds : undefined, + assignedLinkIds && assignedLinkIds.length > 0 + ? assignedLinkIds + : undefined, }, headers: responseHeaders, }); From f1a82963f870bedccdcfd6a13775a63048ee5555 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 7 Apr 2026 11:34:48 +0530 Subject: [PATCH 35/86] Update route.ts --- .../(ee)/api/partner-profile/programs/route.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(ee)/api/partner-profile/programs/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/route.ts index 13ecdb17ca5..7d73bfcff0a 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/route.ts @@ -9,7 +9,11 @@ import * as z from "zod/v4"; // GET /api/partner-profile/programs - get all program enrollments for a given partnerId export const GET = withPartnerProfile( - async ({ partner, searchParams, partnerUser: { assignedProgramIds } }) => { + async ({ + partner, + searchParams, + partnerUser: { assignedProgramIds, assignedLinkIds }, + }) => { const { includeRewardsDiscounts, status } = partnerProfileProgramsQuerySchema.parse(searchParams); @@ -24,6 +28,15 @@ export const GET = withPartnerProfile( }, include: { links: { + ...(assignedLinkIds + ? { + where: { + id: { + in: assignedLinkIds, + }, + }, + } + : undefined), take: 1, orderBy: { createdAt: "asc", From de0c8f2a8e004b606148077069309a03e4198000 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 7 Apr 2026 17:22:36 +0530 Subject: [PATCH 36/86] Update route.ts --- .../programs/[programId]/customers/[customerId]/route.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts index 224c19ec772..00ba51d0abc 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts @@ -4,6 +4,7 @@ import { DubApiError } from "@/lib/api/errors"; import { obfuscateCustomerEmail } from "@/lib/api/partner-profile/obfuscate-customer-email"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { linkIncludeFilter } from "@/lib/auth/partner-users/link-scope-filter"; import { LARGE_PROGRAM_IDS, LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS, @@ -27,7 +28,7 @@ export const GET = withPartnerProfile( programId: programId, include: { program: true, - links: true, + links: linkIncludeFilter(assignedLinkIds), }, }); From b713410552afc6208eecb103416ca21f9b9defd1 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 7 Apr 2026 17:27:07 +0530 Subject: [PATCH 37/86] Update route.ts --- .../programs/[programId]/earnings/count/route.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts index d98c769762c..43833b752a7 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts @@ -1,4 +1,5 @@ import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates"; +import { DubApiError } from "@/lib/api/errors"; import { obfuscateCustomerEmail } from "@/lib/api/partner-profile/obfuscate-customer-email"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; @@ -45,6 +46,13 @@ export const GET = withPartnerProfile( timezone, }); + if (linkId && assignedLinkIds && !assignedLinkIds.includes(linkId)) { + throw new DubApiError({ + code: "forbidden", + message: "You are not authorized to view this link.", + }); + } + const where: Prisma.CommissionWhereInput = { earnings: { not: 0, @@ -67,6 +75,7 @@ export const GET = withPartnerProfile( ...(status && groupBy !== "status" && { status }), ...(linkId && groupBy !== "linkId" && { linkId }), ...(customerId && groupBy !== "customerId" && { customerId }), + ...linkScopeFilter(assignedLinkIds), }, _count: true, orderBy: { From c3326d8118e4265699bfd04c49fad518f7aa7c8f Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 7 Apr 2026 17:31:10 +0530 Subject: [PATCH 38/86] some cleanup --- .../programs/[programId]/earnings/route.ts | 12 ++++ .../members/partner-member-programs-cell.tsx | 55 ++++++++++++++----- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts index ec417371883..5cf3f86cc89 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts @@ -1,3 +1,4 @@ +import { DubApiError } from "@/lib/api/errors"; import { getEarningsForPartner } from "@/lib/api/partner-profile/get-earnings-for-partner"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; @@ -21,6 +22,17 @@ export const GET = withPartnerProfile( const parsedQuery = getPartnerEarningsQuerySchema.parse(searchParams); + if ( + parsedQuery.linkId && + assignedLinkIds && + !assignedLinkIds.includes(parsedQuery.linkId) + ) { + throw new DubApiError({ + code: "forbidden", + message: "You are not authorized to view this link.", + }); + } + const earnings = await getEarningsForPartner({ ...parsedQuery, programId, diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx index 26bd382bfe7..f857c1a79eb 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx @@ -13,18 +13,29 @@ const MAX_OVERFLOW_LABEL = 9; function ProgramsHover({ children, onClick, + "aria-label": ariaLabel, }: { children: ReactNode; onClick?: () => void; + "aria-label"?: string; }) { - return ( -
- {children} -
- ); + const className = + "group w-fit rounded-lg border-0 bg-transparent p-2 font-inherit text-inherit transition-colors duration-150 hover:cursor-pointer hover:bg-neutral-100"; + + if (onClick) { + return ( + + ); + } + + return
{children}
; } export function PartnerMemberProgramsCell({ @@ -65,7 +76,14 @@ export function PartnerMemberProgramsCell({ const program = displayPrograms[0]; return ( - + {program ? ( ) : (
- +
)}
@@ -87,7 +105,10 @@ export function PartnerMemberProgramsCell({ : `All ${totalCount}`; return ( - +
{allLabel}
@@ -97,9 +118,12 @@ export function PartnerMemberProgramsCell({ if (displayPrograms.length === 0) { return ( - +
- +
); @@ -110,7 +134,10 @@ export function PartnerMemberProgramsCell({ const extra = hiddenCount > 0 ? Math.min(hiddenCount, MAX_OVERFLOW_LABEL) : 0; return ( - +
{visible.map((p, index) => ( Date: Tue, 7 Apr 2026 17:32:11 +0530 Subject: [PATCH 39/86] Update route.ts --- .../[programId]/earnings/timeseries/route.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts index be249fb8557..0b37e5099b9 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts @@ -1,3 +1,4 @@ +import { DubApiError } from "@/lib/api/errors"; import { getPartnerEarningsTimeseries } from "@/lib/api/partner-profile/get-partner-earnings-timeseries"; import { withPartnerProfile } from "@/lib/auth/partner"; import { getPartnerEarningsTimeseriesSchema } from "@/lib/zod/schemas/partner-profile"; @@ -13,6 +14,17 @@ export const GET = withPartnerProfile( }) => { const filters = getPartnerEarningsTimeseriesSchema.parse(searchParams); + if ( + filters.linkId && + assignedLinkIds && + !assignedLinkIds.includes(filters.linkId) + ) { + throw new DubApiError({ + code: "forbidden", + message: "You are not authorized to view this link.", + }); + } + const timeseries = await getPartnerEarningsTimeseries({ partnerId: partner.id, programId: params.programId, From fd73d3d4d9dbcfe48ca9673c30e0812f80eedef7 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 7 Apr 2026 17:38:24 +0530 Subject: [PATCH 40/86] Update get-earnings-for-partner.ts --- .../api/partner-profile/get-earnings-for-partner.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/api/partner-profile/get-earnings-for-partner.ts b/apps/web/lib/api/partner-profile/get-earnings-for-partner.ts index 97eefa6787f..b27dd3ebe7f 100644 --- a/apps/web/lib/api/partner-profile/get-earnings-for-partner.ts +++ b/apps/web/lib/api/partner-profile/get-earnings-for-partner.ts @@ -46,6 +46,8 @@ export async function getEarningsForPartner( timezone, }); + const finalLinkIds = linkId ? [linkId] : linkIds ? linkIds : []; + const earnings = await prisma.commission.findMany({ where: { earnings: { @@ -55,8 +57,13 @@ export async function getEarningsForPartner( partnerId, status, type, - linkId, - ...(linkIds ? { linkId: { in: linkIds } } : {}), + ...(finalLinkIds.length > 0 + ? { + linkId: { + in: finalLinkIds, + }, + } + : {}), customerId, payoutId, createdAt: { From b92bf4a4c9fbd90f8a1fc560ee85881f8d5a9aed Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 7 Apr 2026 17:46:27 +0530 Subject: [PATCH 41/86] fix messages API --- .../app/(ee)/api/partner-profile/messages/count/route.ts | 2 +- apps/web/app/(ee)/api/partner-profile/messages/route.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts b/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts index ddb243c3752..5ab20d9d1ad 100644 --- a/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts @@ -22,8 +22,8 @@ export const GET = withPartnerProfile( // Only count read messages not: null, }, - ...programScopeFilter(assignedProgramIds), }), + ...programScopeFilter(assignedProgramIds), }, }); diff --git a/apps/web/app/(ee)/api/partner-profile/messages/route.ts b/apps/web/app/(ee)/api/partner-profile/messages/route.ts index cbdc4bbf3ff..fad219ae16c 100644 --- a/apps/web/app/(ee)/api/partner-profile/messages/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/messages/route.ts @@ -30,8 +30,8 @@ export const GET = withPartnerProfile( !assignedProgramSlugs.includes(programSlug) ) { throw new DubApiError({ - code: "not_found", - message: `Program ${programSlug} not found.`, + code: "forbidden", + message: `You're not authorized to view messages for program ${programSlug}.`, }); } @@ -92,8 +92,8 @@ export const GET = withPartnerProfile( }, }, ], + ...programScopeFilter(assignedProgramIds), }), - ...programScopeFilter(assignedProgramIds), }, include: { messages: { From a147724820b099b2bd6bdaa19dac9ede358fd7a5 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 7 Apr 2026 17:51:18 +0530 Subject: [PATCH 42/86] fix the earning filters --- .../programs/[programId]/earnings/count/route.ts | 1 - .../programs/[programId]/earnings/timeseries/route.ts | 2 +- .../partner-profile/get-partner-earnings-timeseries.ts | 9 +++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts index 43833b752a7..e1289dfecc3 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts @@ -75,7 +75,6 @@ export const GET = withPartnerProfile( ...(status && groupBy !== "status" && { status }), ...(linkId && groupBy !== "linkId" && { linkId }), ...(customerId && groupBy !== "customerId" && { customerId }), - ...linkScopeFilter(assignedLinkIds), }, _count: true, orderBy: { diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts index 0b37e5099b9..94890b26c8b 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts @@ -29,7 +29,7 @@ export const GET = withPartnerProfile( partnerId: partner.id, programId: params.programId, filters, - linkIds: assignedLinkIds, + assignedLinkIds, }); return NextResponse.json(timeseries); diff --git a/apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts b/apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts index 09893413e67..4b31b4717a5 100644 --- a/apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts +++ b/apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts @@ -1,5 +1,6 @@ import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; +import { linkIncludeFilter } from "@/lib/auth/partner-users/link-scope-filter"; import { sqlGranularityMap } from "@/lib/planetscale/granularity"; import { getPartnerEarningsTimeseriesSchema } from "@/lib/zod/schemas/partner-profile"; import { prisma } from "@dub/prisma"; @@ -11,14 +12,14 @@ interface GetPartnerEarningsTimeseriesParams { partnerId: string; programId: string; filters: z.infer; - linkIds?: string[]; + assignedLinkIds?: string[]; } export async function getPartnerEarningsTimeseries({ partnerId, programId, filters, - linkIds, + assignedLinkIds, }: GetPartnerEarningsTimeseriesParams) { const { groupBy, @@ -38,7 +39,7 @@ export async function getPartnerEarningsTimeseries({ programId: programId, include: { program: true, - links: true, + links: linkIncludeFilter(assignedLinkIds), }, }); @@ -68,7 +69,7 @@ export async function getPartnerEarningsTimeseries({ ${type ? Prisma.sql`AND type = ${type}` : Prisma.sql``} ${payoutId ? Prisma.sql`AND payoutId = ${payoutId}` : Prisma.sql``} ${linkId ? Prisma.sql`AND linkId = ${linkId}` : Prisma.sql``} - ${linkIds && linkIds.length > 0 ? Prisma.sql`AND linkId IN (${Prisma.join(linkIds)})` : Prisma.sql``} + ${assignedLinkIds && assignedLinkIds.length > 0 ? Prisma.sql`AND linkId IN (${Prisma.join(assignedLinkIds)})` : Prisma.sql``} ${customerId ? Prisma.sql`AND customerId = ${customerId}` : Prisma.sql``} ${status ? Prisma.sql`AND status = ${status}` : Prisma.sql``} GROUP BY start${groupBy ? (groupBy === "type" ? Prisma.sql`, type` : Prisma.sql`, linkId`) : Prisma.sql``} From a96c405c530122d8827632238fa3678382abc680 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 7 Apr 2026 17:56:42 +0530 Subject: [PATCH 43/86] Update route.ts --- .../(ee)/api/partner-profile/payouts/count/route.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts b/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts index 8bd250584d9..0b12c0ac454 100644 --- a/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts @@ -1,3 +1,4 @@ +import { DubApiError } from "@/lib/api/errors"; import { withPartnerProfile } from "@/lib/auth/partner"; import { programScopeFilter } from "@/lib/auth/partner-users/program-scope-filter"; import { payoutsCountQuerySchema } from "@/lib/zod/schemas/payouts"; @@ -11,6 +12,17 @@ export const GET = withPartnerProfile( const { programId, groupBy, status } = payoutsCountQuerySchema.parse(searchParams); + if ( + programId && + assignedProgramIds && + !assignedProgramIds.includes(programId) + ) { + throw new DubApiError({ + code: "forbidden", + message: "You are not authorized to view this program.", + }); + } + const where: Prisma.PayoutWhereInput = { partnerId: partner.id, ...(programId && { programId }), From b88590b148c974b1e87b7e0426219db7939264bc Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 7 Apr 2026 17:56:46 +0530 Subject: [PATCH 44/86] Update route.ts --- apps/web/app/(ee)/api/partner-profile/payouts/route.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/payouts/route.ts b/apps/web/app/(ee)/api/partner-profile/payouts/route.ts index 4f48ed817f4..8a0c110d219 100644 --- a/apps/web/app/(ee)/api/partner-profile/payouts/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/payouts/route.ts @@ -1,6 +1,10 @@ +import { DubApiError } from "@/lib/api/errors"; import { getEffectivePayoutMode } from "@/lib/api/payouts/get-effective-payout-mode"; import { withPartnerProfile } from "@/lib/auth/partner"; -import { programScopeFilter } from "@/lib/auth/partner-users/program-scope-filter"; +import { + programScopeFilter, + resolveScopedProgramQueryToId, +} from "@/lib/auth/partner-users/program-scope-filter"; import { partnerProfilePayoutsQuerySchema } from "@/lib/zod/schemas/partner-profile"; import { PartnerPayoutResponseSchema } from "@/lib/zod/schemas/payouts"; import { prisma } from "@dub/prisma"; @@ -23,8 +27,8 @@ export const GET = withPartnerProfile( where: { partnerId: partner.id, ...(programId && { programId }), - ...programScopeFilter(assignedProgramIds), ...(status && { status }), + ...programScopeFilter(assignedProgramIds), }, include: { program: true, From 51300815e0ae35df239d30238b4fa2f48d8f76e2 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 7 Apr 2026 17:59:38 +0530 Subject: [PATCH 45/86] Update route.ts --- apps/web/app/(ee)/api/partner-profile/payouts/route.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/payouts/route.ts b/apps/web/app/(ee)/api/partner-profile/payouts/route.ts index 8a0c110d219..3062ad9dbe0 100644 --- a/apps/web/app/(ee)/api/partner-profile/payouts/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/payouts/route.ts @@ -1,10 +1,6 @@ -import { DubApiError } from "@/lib/api/errors"; import { getEffectivePayoutMode } from "@/lib/api/payouts/get-effective-payout-mode"; import { withPartnerProfile } from "@/lib/auth/partner"; -import { - programScopeFilter, - resolveScopedProgramQueryToId, -} from "@/lib/auth/partner-users/program-scope-filter"; +import { programScopeFilter } from "@/lib/auth/partner-users/program-scope-filter"; import { partnerProfilePayoutsQuerySchema } from "@/lib/zod/schemas/partner-profile"; import { PartnerPayoutResponseSchema } from "@/lib/zod/schemas/payouts"; import { prisma } from "@dub/prisma"; From ba737c3e981bc607c8a850a9ea32517099b5dde4 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 7 Apr 2026 18:14:51 +0530 Subject: [PATCH 46/86] extract reusable throwIfNoProgramAccess and throwIfNoLinkAccess helpers Move repeated program/link access checks from individual route handlers into shared helpers in throw-if-no-access.ts. Updates 7 route files to use the new helpers, reducing duplication and ensuring consistent forbidden responses. --- .../api/partner-profile/messages/route.ts | 24 +++----- .../partner-profile/payouts/count/route.ts | 20 +++--- .../(ee)/api/partner-profile/payouts/route.ts | 10 ++- .../[programId]/earnings/count/route.ts | 21 +++---- .../programs/[programId]/earnings/route.ts | 25 +++----- .../[programId]/earnings/timeseries/route.ts | 25 +++----- .../[programId]/links/[linkId]/route.ts | 31 ++++------ .../auth/partner-users/throw-if-no-access.ts | 61 +++++++++++++++++++ 8 files changed, 115 insertions(+), 102 deletions(-) create mode 100644 apps/web/lib/auth/partner-users/throw-if-no-access.ts diff --git a/apps/web/app/(ee)/api/partner-profile/messages/route.ts b/apps/web/app/(ee)/api/partner-profile/messages/route.ts index fad219ae16c..028a6b697ba 100644 --- a/apps/web/app/(ee)/api/partner-profile/messages/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/messages/route.ts @@ -1,6 +1,6 @@ -import { DubApiError } from "@/lib/api/errors"; import { withPartnerProfile } from "@/lib/auth/partner"; import { programScopeFilter } from "@/lib/auth/partner-users/program-scope-filter"; +import { throwIfNoProgramAccess } from "@/lib/auth/partner-users/throw-if-no-access"; import { ProgramMessagesSchema, getProgramMessagesQuerySchema, @@ -10,11 +10,7 @@ import { NextResponse } from "next/server"; // GET /api/partner-profile/messages - get messages grouped by program export const GET = withPartnerProfile( - async ({ - partner, - searchParams, - partnerUser: { assignedProgramSlugs, assignedProgramIds }, - }) => { + async ({ partner, searchParams, partnerUser }) => { const { programSlug, sortBy, @@ -24,16 +20,10 @@ export const GET = withPartnerProfile( const messagesLimit = messagesLimitArg ?? (programSlug ? undefined : 10); - if ( - programSlug && - assignedProgramSlugs && - !assignedProgramSlugs.includes(programSlug) - ) { - throw new DubApiError({ - code: "forbidden", - message: `You're not authorized to view messages for program ${programSlug}.`, - }); - } + throwIfNoProgramAccess({ + programSlug, + partnerUser, + }); const programs = await prisma.program.findMany({ where: { @@ -92,7 +82,7 @@ export const GET = withPartnerProfile( }, }, ], - ...programScopeFilter(assignedProgramIds), + ...programScopeFilter(partnerUser.assignedProgramIds), }), }, include: { diff --git a/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts b/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts index 0b12c0ac454..74a66b8d43a 100644 --- a/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts @@ -1,6 +1,6 @@ -import { DubApiError } from "@/lib/api/errors"; import { withPartnerProfile } from "@/lib/auth/partner"; import { programScopeFilter } from "@/lib/auth/partner-users/program-scope-filter"; +import { throwIfNoProgramAccess } from "@/lib/auth/partner-users/throw-if-no-access"; import { payoutsCountQuerySchema } from "@/lib/zod/schemas/payouts"; import { prisma } from "@dub/prisma"; import { PayoutStatus, Prisma } from "@dub/prisma/client"; @@ -8,25 +8,19 @@ import { NextResponse } from "next/server"; // GET /api/partner-profile/payouts/count – get payouts count for a partner export const GET = withPartnerProfile( - async ({ partner, searchParams, partnerUser: { assignedProgramIds } }) => { + async ({ partner, searchParams, partnerUser }) => { const { programId, groupBy, status } = payoutsCountQuerySchema.parse(searchParams); - if ( - programId && - assignedProgramIds && - !assignedProgramIds.includes(programId) - ) { - throw new DubApiError({ - code: "forbidden", - message: "You are not authorized to view this program.", - }); - } + throwIfNoProgramAccess({ + programId, + partnerUser, + }); const where: Prisma.PayoutWhereInput = { partnerId: partner.id, ...(programId && { programId }), - ...programScopeFilter(assignedProgramIds), + ...programScopeFilter(partnerUser.assignedProgramIds), }; if (groupBy === "status") { diff --git a/apps/web/app/(ee)/api/partner-profile/payouts/route.ts b/apps/web/app/(ee)/api/partner-profile/payouts/route.ts index 3062ad9dbe0..56323f3f985 100644 --- a/apps/web/app/(ee)/api/partner-profile/payouts/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/payouts/route.ts @@ -1,6 +1,7 @@ import { getEffectivePayoutMode } from "@/lib/api/payouts/get-effective-payout-mode"; import { withPartnerProfile } from "@/lib/auth/partner"; import { programScopeFilter } from "@/lib/auth/partner-users/program-scope-filter"; +import { throwIfNoProgramAccess } from "@/lib/auth/partner-users/throw-if-no-access"; import { partnerProfilePayoutsQuerySchema } from "@/lib/zod/schemas/partner-profile"; import { PartnerPayoutResponseSchema } from "@/lib/zod/schemas/payouts"; import { prisma } from "@dub/prisma"; @@ -9,7 +10,7 @@ import * as z from "zod/v4"; // GET /api/partner-profile/payouts - get all payouts for a partner export const GET = withPartnerProfile( - async ({ partner, searchParams, partnerUser: { assignedProgramIds } }) => { + async ({ partner, searchParams, partnerUser }) => { const { programId, status, @@ -19,12 +20,17 @@ export const GET = withPartnerProfile( pageSize, } = partnerProfilePayoutsQuerySchema.parse(searchParams); + throwIfNoProgramAccess({ + programId, + partnerUser, + }); + const payouts = await prisma.payout.findMany({ where: { partnerId: partner.id, ...(programId && { programId }), ...(status && { status }), - ...programScopeFilter(assignedProgramIds), + ...programScopeFilter(partnerUser.assignedProgramIds), }, include: { program: true, diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts index e1289dfecc3..6b662a2493b 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts @@ -1,9 +1,9 @@ import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates"; -import { DubApiError } from "@/lib/api/errors"; import { obfuscateCustomerEmail } from "@/lib/api/partner-profile/obfuscate-customer-email"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { linkScopeFilter } from "@/lib/auth/partner-users/link-scope-filter"; +import { throwIfNoLinkAccess } from "@/lib/auth/partner-users/throw-if-no-access"; import { generateRandomName } from "@/lib/names"; import { getPartnerEarningsCountQuerySchema } from "@/lib/zod/schemas/partner-profile"; import { prisma } from "@dub/prisma"; @@ -12,12 +12,7 @@ import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/[programId]/earnings/count – get earnings count for a partner in a program enrollment export const GET = withPartnerProfile( - async ({ - partner, - params, - searchParams, - partnerUser: { assignedLinkIds }, - }) => { + async ({ partner, params, searchParams, partnerUser }) => { const { program, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, @@ -46,12 +41,10 @@ export const GET = withPartnerProfile( timezone, }); - if (linkId && assignedLinkIds && !assignedLinkIds.includes(linkId)) { - throw new DubApiError({ - code: "forbidden", - message: "You are not authorized to view this link.", - }); - } + throwIfNoLinkAccess({ + linkId, + partnerUser, + }); const where: Prisma.CommissionWhereInput = { earnings: { @@ -64,7 +57,7 @@ export const GET = withPartnerProfile( gte: startDate, lte: endDate, }, - ...linkScopeFilter(assignedLinkIds), + ...linkScopeFilter(partnerUser.assignedLinkIds), }; if (groupBy) { diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts index 5cf3f86cc89..7396f9b5d6c 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts @@ -1,18 +1,13 @@ -import { DubApiError } from "@/lib/api/errors"; import { getEarningsForPartner } from "@/lib/api/partner-profile/get-earnings-for-partner"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { throwIfNoLinkAccess } from "@/lib/auth/partner-users/throw-if-no-access"; import { getPartnerEarningsQuerySchema } from "@/lib/zod/schemas/partner-profile"; import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/[programId]/earnings – get earnings for a partner in a program enrollment export const GET = withPartnerProfile( - async ({ - partner, - params, - searchParams, - partnerUser: { assignedLinkIds }, - }) => { + async ({ partner, params, searchParams, partnerUser }) => { const { programId, partnerId, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, @@ -22,23 +17,17 @@ export const GET = withPartnerProfile( const parsedQuery = getPartnerEarningsQuerySchema.parse(searchParams); - if ( - parsedQuery.linkId && - assignedLinkIds && - !assignedLinkIds.includes(parsedQuery.linkId) - ) { - throw new DubApiError({ - code: "forbidden", - message: "You are not authorized to view this link.", - }); - } + throwIfNoLinkAccess({ + linkId: parsedQuery.linkId, + partnerUser, + }); const earnings = await getEarningsForPartner({ ...parsedQuery, programId, partnerId, customerDataSharingEnabledAt, - linkIds: assignedLinkIds, + linkIds: partnerUser.assignedLinkIds, }); return NextResponse.json(earnings); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts index 94890b26c8b..a7594c4601e 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts @@ -1,35 +1,24 @@ -import { DubApiError } from "@/lib/api/errors"; import { getPartnerEarningsTimeseries } from "@/lib/api/partner-profile/get-partner-earnings-timeseries"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { throwIfNoLinkAccess } from "@/lib/auth/partner-users/throw-if-no-access"; import { getPartnerEarningsTimeseriesSchema } from "@/lib/zod/schemas/partner-profile"; import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/[programId]/earnings/timeseries - get timeseries chart for a partner's earnings export const GET = withPartnerProfile( - async ({ - partner, - params, - searchParams, - partnerUser: { assignedLinkIds }, - }) => { + async ({ partner, params, searchParams, partnerUser }) => { const filters = getPartnerEarningsTimeseriesSchema.parse(searchParams); - if ( - filters.linkId && - assignedLinkIds && - !assignedLinkIds.includes(filters.linkId) - ) { - throw new DubApiError({ - code: "forbidden", - message: "You are not authorized to view this link.", - }); - } + throwIfNoLinkAccess({ + linkId: filters.linkId, + partnerUser, + }); const timeseries = await getPartnerEarningsTimeseries({ partnerId: partner.id, programId: params.programId, filters, - assignedLinkIds, + assignedLinkIds: partnerUser.assignedLinkIds, }); return NextResponse.json(timeseries); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts index a98fc7d9dc4..5be01ae69f3 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts @@ -5,6 +5,7 @@ import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enro import { parseRequestBody } from "@/lib/api/utils"; import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { throwIfNoLinkAccess } from "@/lib/auth/partner-users/throw-if-no-access"; import { NewLinkProps } from "@/lib/types"; import { PartnerProfileLinkSchema } from "@/lib/zod/schemas/partner-profile"; import { createPartnerLinkSchema } from "@/lib/zod/schemas/partners"; @@ -14,25 +15,17 @@ import { NextResponse } from "next/server"; // PATCH /api/partner-profile/[programId]/links/[linkId] - update a link for a partner export const PATCH = withPartnerProfile( - async ({ - partner, - params, - req, - session, - partnerUser: { assignedLinkIds }, - }) => { + async ({ partner, params, req, session, partnerUser }) => { const { url, key, comments } = createPartnerLinkSchema .pick({ url: true, key: true, comments: true }) .parse(await parseRequestBody(req)); const { programId, linkId } = params; - if (assignedLinkIds && !assignedLinkIds.includes(linkId)) { - throw new DubApiError({ - code: "forbidden", - message: "You are not authorized to access this link.", - }); - } + throwIfNoLinkAccess({ + linkId, + partnerUser, + }); const { program, @@ -173,15 +166,13 @@ export const PATCH = withPartnerProfile( // DELETE /api/partner-profile/[programId]/links/[linkId] - delete a link for a partner export const DELETE = withPartnerProfile( - async ({ partner, params, partnerUser: { assignedLinkIds } }) => { + async ({ partner, params, partnerUser }) => { const { programId, linkId } = params; - if (assignedLinkIds && !assignedLinkIds.includes(linkId)) { - throw new DubApiError({ - code: "forbidden", - message: "You are not authorized to access this link.", - }); - } + throwIfNoLinkAccess({ + linkId, + partnerUser, + }); const { links, status } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, diff --git a/apps/web/lib/auth/partner-users/throw-if-no-access.ts b/apps/web/lib/auth/partner-users/throw-if-no-access.ts new file mode 100644 index 00000000000..2c5c056a061 --- /dev/null +++ b/apps/web/lib/auth/partner-users/throw-if-no-access.ts @@ -0,0 +1,61 @@ +import { DubApiError } from "@/lib/api/errors"; + +interface PartnerUserAccess { + assignedProgramIds: string[] | undefined; + assignedProgramSlugs: string[] | undefined; + assignedLinkIds: string[] | undefined; +} + +export function throwIfNoProgramAccess({ + programId, + programSlug, + partnerUser, +}: { + programId?: string; + programSlug?: string; + partnerUser: Pick< + PartnerUserAccess, + "assignedProgramIds" | "assignedProgramSlugs" + >; +}) { + if ( + programId && + partnerUser.assignedProgramIds && + !partnerUser.assignedProgramIds.includes(programId) + ) { + throw new DubApiError({ + code: "forbidden", + message: "You are not authorized to view this program.", + }); + } + + if ( + programSlug && + partnerUser.assignedProgramSlugs && + !partnerUser.assignedProgramSlugs.includes(programSlug) + ) { + throw new DubApiError({ + code: "forbidden", + message: "You are not authorized to view this program.", + }); + } +} + +export function throwIfNoLinkAccess({ + linkId, + partnerUser, +}: { + linkId: string | undefined | null; + partnerUser: Pick; +}) { + if ( + linkId && + partnerUser.assignedLinkIds && + !partnerUser.assignedLinkIds.includes(linkId) + ) { + throw new DubApiError({ + code: "forbidden", + message: "You are not authorized to view this link.", + }); + } +} From fc731dd160a218610afc8873bc924909e1567862 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 7 Apr 2026 18:40:26 +0530 Subject: [PATCH 47/86] Update playwright.yaml --- .github/workflows/playwright.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/playwright.yaml b/.github/workflows/playwright.yaml index fb53499af0d..356f609b2e5 100644 --- a/.github/workflows/playwright.yaml +++ b/.github/workflows/playwright.yaml @@ -38,9 +38,6 @@ jobs: TINYBIRD_API_KEY: "xx" TINYBIRD_API_URL: "xx" - AXIOM_TOKEN: "xx" - AXIOM_DATASET: "xx" - # serverless-redis-http (SRH) — must match jobs.e2e.services.srh env SRH_TOKEN: "e2e_srh_token" UPSTASH_REDIS_REST_URL: "http://127.0.0.1:8079" From d38e7c655155790cd8d167c5be5885f41e05c0a0 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 7 Apr 2026 21:58:18 +0530 Subject: [PATCH 48/86] Update route.ts --- .../api/cron/export/events/partner/route.ts | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/export/events/partner/route.ts b/apps/web/app/(ee)/api/cron/export/events/partner/route.ts index 5aab1c5430c..413cfa5c06e 100644 --- a/apps/web/app/(ee)/api/cron/export/events/partner/route.ts +++ b/apps/web/app/(ee)/api/cron/export/events/partner/route.ts @@ -49,30 +49,37 @@ export async function POST(req: Request) { const { columns, partnerId, programId, userId, ...parsedParams } = payloadSchema.parse(JSON.parse(rawBody)); - const user = await prisma.user.findUnique({ + const partnerUser = await prisma.partnerUser.findUnique({ where: { - id: userId, + userId_partnerId: { + userId, + partnerId, + }, }, select: { - email: true, - partners: { + assignedLinks: true, + user: { select: { - assignedLinks: true, + email: true, }, }, }, }); - if (!user) { - return logAndRespond(`User ${userId} not found. Skipping the export.`); + if (!partnerUser) { + return logAndRespond( + `Partner user ${userId} not found. Skipping the export.`, + ); } + const { user } = partnerUser; + if (!user.email) { return logAndRespond(`User ${userId} has no email. Skipping the export.`); } - const assignedLinkIds = user.partners.flatMap((partner) => - partner.assignedLinks.map((link) => link.linkId), + const assignedLinkIds = partnerUser.assignedLinks.map( + ({ linkId }) => linkId, ); const { program, links, customerDataSharingEnabledAt } = From 1ae540e50b83195ad2d6835293591226520fb002 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 7 Apr 2026 22:16:40 +0530 Subject: [PATCH 49/86] Address CR comments --- .../users/[userId]/programs/route.ts | 22 +++++++++++++++---- apps/web/lib/zod/schemas/partner-profile.ts | 14 ------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts b/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts index 24bd4e6f762..2cbbfa7e86a 100644 --- a/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts @@ -71,7 +71,7 @@ export const PUT = withPartnerProfile( } } - // Batch-validate all link IDs — each must exist and belong to one of the assigned programs + // Batch-validate all link IDs — each must exist and belong to the specific program it's listed under const allRequestedLinkIds = Object.entries(linkIds).flatMap( ([, ids]) => ids ?? [], ); @@ -88,14 +88,28 @@ export const PUT = withPartnerProfile( }, select: { id: true, + programId: true, }, }); - const validLinkIdSet = new Set(validLinks.map((l) => l.id)); - const invalidIds = allRequestedLinkIds.filter( - (id) => !validLinkIdSet.has(id), + const linkProgramMap = new Map( + validLinks.map((l) => [l.id, l.programId]), ); + const invalidIds: string[] = []; + + for (const [programId, programLinkIds] of Object.entries(linkIds)) { + if (!programLinkIds) continue; + + for (const linkId of programLinkIds) { + const actualProgramId = linkProgramMap.get(linkId); + + if (actualProgramId !== programId) { + invalidIds.push(linkId); + } + } + } + if (invalidIds.length > 0) { throw new DubApiError({ code: "bad_request", diff --git a/apps/web/lib/zod/schemas/partner-profile.ts b/apps/web/lib/zod/schemas/partner-profile.ts index b90e185a362..3316bc0f21a 100644 --- a/apps/web/lib/zod/schemas/partner-profile.ts +++ b/apps/web/lib/zod/schemas/partner-profile.ts @@ -206,20 +206,6 @@ export const assignedProgramOutputSchema = z.object({ createdAt: z.coerce.date(), }); -export const assignLinkInputSchema = z.object({ - linkIds: z.array(z.string()), -}); - -export const assignedLinkOutputSchema = z.object({ - link: LinkSchema.pick({ - id: true, - domain: true, - key: true, - shortLink: true, - }), - createdAt: z.coerce.date(), -}); - export const partnerUserSchema = z.object({ id: z.string().nullable(), name: z.string().nullable(), From 58e4d9346c273bdc92612c8eac1a0917e9c76da4 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 8 Apr 2026 15:51:47 +0530 Subject: [PATCH 50/86] Add "Edit programs" to member row menu and convert program access to card selector - Add "Edit programs" option to the three-dot row menu for non-owner members - Replace the program access dropdown with card-style radio selector matching the discount sheet pattern --- .../profile/members/page-client.tsx | 26 +++++++- .../members/partner-member-programs-sheet.tsx | 63 ++++++++++++++++--- 2 files changed, 77 insertions(+), 12 deletions(-) diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx index 14f38741425..9f29c39e0f9 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx @@ -25,6 +25,7 @@ import { CircleDotted, Dots, EnvelopeArrowRight, + GridIcon, Icon, User, UserCrown, @@ -204,7 +205,14 @@ export function ProfileMembersPageClient() { enableHiding: false, header: () => null, cell: ({ row }) => ( - + { + setSelectedUserForPrograms(user); + setShowProgramsSheet(true); + }} + /> ), }, ], @@ -388,9 +396,11 @@ function RoleCell({ function RowMenuButton({ row, isCurrentUserOwner, + onEditPrograms, }: { row: Row; isCurrentUserOwner: boolean; + onEditPrograms: (user: PartnerUserProps) => void; }) { const [isOpen, setIsOpen] = useState(false); const { data: session } = useSession(); @@ -411,6 +421,8 @@ function RowMenuButton({ return null; } + const isTargetOwner = user.role === "owner"; + return ( <> @@ -422,6 +434,16 @@ function RowMenuButton({ + {isCurrentUserOwner && !isTargetOwner && !isInvite && ( + { + onEditPrograms(user); + setIsOpen(false); + }} + /> + )} diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx index be6adefc5d2..ea9b21400a0 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx @@ -5,6 +5,7 @@ import useProgramEnrollments from "@/lib/swr/use-program-enrollments"; import { PartnerUserProps, ProgramProps } from "@/lib/types"; import { ProgramAccessScope } from "@dub/prisma/client"; import { BlurImage, Button, Sheet } from "@dub/ui"; +import { CircleCheckFill } from "@dub/ui/icons"; import { cn, OG_AVATAR_URL } from "@dub/utils"; import { X } from "lucide-react"; import Link from "next/link"; @@ -182,16 +183,58 @@ function PartnerMemberProgramsSheetContent({ - +
+ {( + [ + { + id: "all", + label: "All", + description: "User has access to all programs", + }, + { + id: "restricted", + label: "Restricted", + description: "Select program access individually", + }, + ] as const + ).map(({ id, label, description }) => { + const isSelected = programAccess === id; + + return ( + + ); + })} +
)} From 61d69475179f7dfb80ba54bdeef2c4d928ac6cb7 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 8 Apr 2026 16:05:40 +0530 Subject: [PATCH 51/86] Update route.ts --- .../programs/[programId]/customers/route.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/route.ts index f4240a3f9cb..400e74747d0 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/route.ts @@ -4,6 +4,7 @@ import { obfuscateCustomerEmail } from "@/lib/api/partner-profile/obfuscate-cust import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { linkScopeFilter } from "@/lib/auth/partner-users/link-scope-filter"; +import { throwIfNoLinkAccess } from "@/lib/auth/partner-users/throw-if-no-access"; import { LARGE_PROGRAM_IDS, LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS, @@ -21,12 +22,7 @@ import * as z from "zod/v4"; // GET /api/partner-profile/programs/:programId/customers – Get all customers for a partner program export const GET = withPartnerProfile( - async ({ - partner, - params, - searchParams, - partnerUser: { assignedLinkIds }, - }) => { + async ({ partner, params, searchParams, partnerUser }) => { const { programId } = params; const { search, @@ -38,6 +34,11 @@ export const GET = withPartnerProfile( pageSize, } = getPartnerCustomersQuerySchema.parse(searchParams); + throwIfNoLinkAccess({ + linkId, + partnerUser, + }); + const { program, totalCommissions, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, @@ -65,8 +66,7 @@ export const GET = withPartnerProfile( programId: program.id, projectId: program.workspaceId, ...(country && { country }), - ...(linkId && { linkId }), - ...linkScopeFilter(assignedLinkIds), + ...(linkId ? { linkId } : linkScopeFilter(partnerUser.assignedLinkIds)), // Only allow search if customer data sharing is enabled ...(search && customerDataSharingEnabledAt ? search.includes("@") From 3cd2983d3fdd7960df89b2ceed3753acb2e33ff3 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 8 Apr 2026 16:06:33 +0530 Subject: [PATCH 52/86] Update reject-partner-application-modal.tsx --- apps/web/ui/modals/reject-partner-application-modal.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/ui/modals/reject-partner-application-modal.tsx b/apps/web/ui/modals/reject-partner-application-modal.tsx index 92e4e5abc20..bd978a8514e 100644 --- a/apps/web/ui/modals/reject-partner-application-modal.tsx +++ b/apps/web/ui/modals/reject-partner-application-modal.tsx @@ -184,7 +184,8 @@ export function RejectPartnerApplicationModal({ Additional notes (optional) - {rejectionNote.length}/ + {rejectionNote.length} + {"/"} {PROGRAM_APPLICATION_REJECTION_NOTE_MAX_LENGTH}
From 3e095f1ad1d19720113bf442f4a80e1133fb4b8d Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 8 Apr 2026 16:10:01 +0530 Subject: [PATCH 53/86] Format --- .../members/partner-links-selector.tsx | 37 +++++++++++++++---- .../members/partner-member-programs-cell.tsx | 12 +++++- packages/ui/src/hooks/use-router-stuff.ts | 5 +-- packages/ui/src/icons/index.tsx | 2 +- 4 files changed, 41 insertions(+), 15 deletions(-) diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-links-selector.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-links-selector.tsx index 17c95cd3c57..3e38d6c8f50 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-links-selector.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-links-selector.tsx @@ -3,6 +3,7 @@ import usePartnerLinks from "@/lib/swr/use-partner-links"; import { Combobox, LinkLogo } from "@dub/ui"; import { getApexDomain, linkConstructor, truncate } from "@dub/utils"; +import { useEffect, useMemo } from "react"; const ALL_LINKS_VALUE = "__all__"; const MAX_DISPLAYED_LINKS = 5; @@ -36,6 +37,28 @@ export function PartnerLinksSelector({ meta: { url: link.url }, })); + const validLinkIds = useMemo( + () => new Set(linkOptions.map((o) => o.value)), + [linkOptions], + ); + + // Reconcile stale selectedLinkIds against loaded linkOptions + useEffect(() => { + if (loading || isAllLinks || !selectedLinkIds) return; + + const filtered = selectedLinkIds.filter((id) => validLinkIds.has(id)); + + if (filtered.length !== selectedLinkIds.length) { + setSelectedLinkIds(filtered.length > 0 ? filtered : undefined); + } + }, [loading, validLinkIds]); + + const validSelectedLinkIds = isAllLinks + ? undefined + : selectedLinkIds?.filter((id) => validLinkIds.has(id)); + + const isAllLinksResolved = isAllLinks || validSelectedLinkIds?.length === 0; + const options = [ { value: ALL_LINKS_VALUE, @@ -44,9 +67,9 @@ export function PartnerLinksSelector({ ...linkOptions, ]; - const selected = isAllLinks + const selected = isAllLinksResolved ? [{ value: ALL_LINKS_VALUE, label: "All links" }] - : linkOptions.filter((opt) => selectedLinkIds.includes(opt.value)); + : linkOptions.filter((opt) => validSelectedLinkIds?.includes(opt.value)); const handleSelect = ({ value }: { value: string }) => { if (value === ALL_LINKS_VALUE) { @@ -54,22 +77,20 @@ export function PartnerLinksSelector({ return; } - if (isAllLinks) { - // Switching from "All links" to a specific link + if (isAllLinksResolved) { setSelectedLinkIds([value]); return; } - if (selectedLinkIds.includes(value)) { - const remaining = selectedLinkIds.filter((id) => id !== value); - // If nothing is selected, revert to all links + if (validSelectedLinkIds?.includes(value)) { + const remaining = validSelectedLinkIds.filter((id) => id !== value); if (remaining.length === 0) { setSelectedLinkIds(undefined); } else { setSelectedLinkIds(remaining); } } else { - setSelectedLinkIds([...selectedLinkIds, value]); + setSelectedLinkIds([...(validSelectedLinkIds ?? []), value]); } }; diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx index f857c1a79eb..c64a7c6c508 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx @@ -92,7 +92,11 @@ export function PartnerMemberProgramsCell({ /> ) : (
- +
)}
@@ -123,7 +127,11 @@ export function PartnerMemberProgramsCell({ aria-label="Open programs to assign or view access" >
- +
); diff --git a/packages/ui/src/hooks/use-router-stuff.ts b/packages/ui/src/hooks/use-router-stuff.ts index 72a58d7effe..a532d3e4cb5 100644 --- a/packages/ui/src/hooks/use-router-stuff.ts +++ b/packages/ui/src/hooks/use-router-stuff.ts @@ -88,10 +88,7 @@ export function useRouterStuff() { if (getNewPath) return newPath; // Nested overflow container scroll is not preserved by Next's `scroll: false` (window-only). - if ( - scroll === false && - typeof document !== "undefined" - ) { + if (scroll === false && typeof document !== "undefined") { const el = document.getElementById(DUB_DASHBOARD_MAIN_SCROLL_ID); if (el) pendingDashboardScrollTop = el.scrollTop; } diff --git a/packages/ui/src/icons/index.tsx b/packages/ui/src/icons/index.tsx index f045ceeec04..393e558b1d2 100644 --- a/packages/ui/src/icons/index.tsx +++ b/packages/ui/src/icons/index.tsx @@ -25,8 +25,8 @@ export * from "./photo"; export * from "./sort-order"; export * from "./success"; export * from "./tick"; -export * from "./verified-badge"; export * from "./user-clock"; +export * from "./verified-badge"; // loaders export * from "./loading-circle"; From d02ed20e3e7fd087fa683b1e05a762dfe0b8c882 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 8 Apr 2026 17:20:36 +0530 Subject: [PATCH 54/86] Sync the UI with Figma design --- .../members/partner-links-selector.tsx | 11 +- .../members/partner-member-programs-sheet.tsx | 110 +++++++++++------- 2 files changed, 73 insertions(+), 48 deletions(-) diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-links-selector.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-links-selector.tsx index 3e38d6c8f50..95720b68ed1 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-links-selector.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-links-selector.tsx @@ -3,6 +3,7 @@ import usePartnerLinks from "@/lib/swr/use-partner-links"; import { Combobox, LinkLogo } from "@dub/ui"; import { getApexDomain, linkConstructor, truncate } from "@dub/utils"; +import { ChevronDown } from "lucide-react"; import { useEffect, useMemo } from "react"; const ALL_LINKS_VALUE = "__all__"; @@ -62,13 +63,13 @@ export function PartnerLinksSelector({ const options = [ { value: ALL_LINKS_VALUE, - label: "All links", + label: "All program links", }, ...linkOptions, ]; const selected = isAllLinksResolved - ? [{ value: ALL_LINKS_VALUE, label: "All links" }] + ? [{ value: ALL_LINKS_VALUE, label: "All program links" }] : linkOptions.filter((opt) => validSelectedLinkIds?.includes(opt.value)); const handleSelect = ({ value }: { value: string }) => { @@ -100,7 +101,7 @@ export function PartnerLinksSelector({ return ( } matchTriggerWidth side="bottom" options={loading ? [] : options} @@ -108,13 +109,13 @@ export function PartnerLinksSelector({ onSelect={handleSelect} buttonProps={{ className: - "h-auto py-1.5 px-2.5 w-full max-w-full text-neutral-700 border-neutral-300 items-start", + "h-auto min-h-10 w-full max-w-full rounded-lg border-neutral-200 bg-white px-3 py-1 text-sm font-normal tracking-[-0.28px] text-neutral-700 shadow-none hover:bg-white items-start", }} > {loading ? (
) : isAllLinks ? ( -
All links
+
All links
) : selected.length === 0 ? (
Select links...
) : ( diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx index ea9b21400a0..2d24d0f064a 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx @@ -7,7 +7,7 @@ import { ProgramAccessScope } from "@dub/prisma/client"; import { BlurImage, Button, Sheet } from "@dub/ui"; import { CircleCheckFill } from "@dub/ui/icons"; import { cn, OG_AVATAR_URL } from "@dub/utils"; -import { X } from "lucide-react"; +import { ChevronDown, X } from "lucide-react"; import Link from "next/link"; import { useEffect, useState } from "react"; import { toast } from "sonner"; @@ -238,7 +238,7 @@ function PartnerMemberProgramsSheetContent({
)} -
+
{isLoading ? ( [...Array(3)].map((_, i) => ) ) : !programEnrollments || programEnrollments.length === 0 ? ( @@ -319,48 +319,70 @@ function ProgramRow({ onLinkChange: (ids: string[] | undefined) => void; }) { return ( -
-
-
- - +
+
+
+
+ +
+ {program.name} -
- {canEdit ? ( - - ) : ( - -
{showLinkPicker && ( -
- +
+
+ + Links + +
-
-
-
+
+
+
+
+
+
+
-
); } From 76e3bd2315596ec81b9a6ea45f04d86c28e4067a Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 8 Apr 2026 17:29:13 +0530 Subject: [PATCH 55/86] adjust the UI --- .../profile/members/partner-links-selector.tsx | 6 +++--- .../members/partner-member-programs-sheet.tsx | 15 ++++++--------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-links-selector.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-links-selector.tsx index 95720b68ed1..f813968123f 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-links-selector.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-links-selector.tsx @@ -63,13 +63,13 @@ export function PartnerLinksSelector({ const options = [ { value: ALL_LINKS_VALUE, - label: "All program links", + label: "All links", }, ...linkOptions, ]; const selected = isAllLinksResolved - ? [{ value: ALL_LINKS_VALUE, label: "All program links" }] + ? [{ value: ALL_LINKS_VALUE, label: "All links" }] : linkOptions.filter((opt) => validSelectedLinkIds?.includes(opt.value)); const handleSelect = ({ value }: { value: string }) => { @@ -109,7 +109,7 @@ export function PartnerLinksSelector({ onSelect={handleSelect} buttonProps={{ className: - "h-auto min-h-10 w-full max-w-full rounded-lg border-neutral-200 bg-white px-3 py-1 text-sm font-normal tracking-[-0.28px] text-neutral-700 shadow-none hover:bg-white items-start", + "h-auto w-full max-w-full rounded-lg border-neutral-200 bg-white px-3 py-2 text-sm font-normal tracking-[-0.28px] text-neutral-700 shadow-none hover:bg-white items-start", }} > {loading ? ( diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx index 2d24d0f064a..f7c90d6c0b8 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx @@ -238,7 +238,7 @@ function PartnerMemberProgramsSheetContent({
)} -
+
{isLoading ? ( [...Array(3)].map((_, i) => ) ) : !programEnrollments || programEnrollments.length === 0 ? ( @@ -256,7 +256,6 @@ function PartnerMemberProgramsSheetContent({ return ( ; + program: Pick; hasAccess: boolean; canEdit: boolean; showLinkPicker: boolean; @@ -349,8 +346,8 @@ function ProgramRow({
+ + + +
) : (
- + {disabledLinkPicker ? ( +
+ All links +
+ ) : ( + + )}
)}
From 5d9ed5fbf14df34193baaeac89c635da3d1f4a83 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 8 Apr 2026 18:01:18 +0530 Subject: [PATCH 58/86] Update partner-member-programs-sheet.tsx --- .../members/partner-member-programs-sheet.tsx | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx index 7a9ee0905ac..a7f288ffc4d 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx @@ -249,8 +249,7 @@ function PartnerMemberProgramsSheetContent({ programEnrollments.map((enrollment) => { const isAllAccess = programAccess === "all"; const hasAccess = - isAllAccess || - (accessState[enrollment.programId] ?? false); + isAllAccess || (accessState[enrollment.programId] ?? false); const showLinkPicker = canEdit && hasAccess; return ( @@ -321,18 +320,18 @@ function ProgramRow({ return (
-
+
- + {program.name} @@ -353,11 +352,11 @@ function ProgramRow({ "text-sm font-medium leading-5", "outline-none focus:border-neutral-300 focus:ring-1 focus:ring-neutral-300", )} - value={hasAccess ? "access" : "no_access"} + value={hasAccess ? "access" : "noAccess"} onChange={(e) => onAccessChange(e.target.value === "access")} > - +
- - Links - + Links
{disabledLinkPicker ? (
From c9db81814c2ff4096b131f4cd9a2d54aeabf26cc Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 9 Apr 2026 19:47:32 +0530 Subject: [PATCH 59/86] Add workspace-scoped dev seed fixtures and -w/--workspace flag --- .../dev/{data.json => acme-workspace.json} | 0 apps/web/scripts/dev/example-workspace.json | 129 ++++++++++++++++++ apps/web/scripts/dev/seed.ts | 52 ++++++- 3 files changed, 174 insertions(+), 7 deletions(-) rename apps/web/scripts/dev/{data.json => acme-workspace.json} (100%) create mode 100644 apps/web/scripts/dev/example-workspace.json diff --git a/apps/web/scripts/dev/data.json b/apps/web/scripts/dev/acme-workspace.json similarity index 100% rename from apps/web/scripts/dev/data.json rename to apps/web/scripts/dev/acme-workspace.json diff --git a/apps/web/scripts/dev/example-workspace.json b/apps/web/scripts/dev/example-workspace.json new file mode 100644 index 00000000000..bf16e0a3d81 --- /dev/null +++ b/apps/web/scripts/dev/example-workspace.json @@ -0,0 +1,129 @@ +{ + "workspace": { + "id": "ws_2LFUA020G94ZKH7B91JXIGY8F", + "name": "Example, Inc.", + "slug": "example", + "logo": "https://assets.dub.co/logo.png", + "plan": "enterprise", + "billingCycleStart": 1, + "usageLimit": 100000, + "linksLimit": 100000, + "payoutsLimit": 100000, + "domainsLimit": 100, + "tagsLimit": 100, + "foldersLimit": 100, + "usersLimit": 100, + "aiLimit": 100, + "groupsLimit": 100, + "defaultProgramId": "prog_2LFUA020G94ZKH7B91JXIGY8G", + "invoicePrefix": "EXMPL", + "conversionEnabled": true, + "webhookEnabled": true, + "dotLinkClaimed": true, + "fastDirectDebitPayouts": true + }, + "users": [ + { + "id": "user_cludszk1h0000wmd2e0ea2b0p", + "name": "Owner", + "email": "owner@dub-internal-test.com", + "emailVerified": "2026-01-21T00:00:00.000Z", + "role": "owner" + }, + { + "id": "user_cludszk1h0000wmd2e0ea2b0q", + "name": "Member", + "email": "member@dub-internal-test.com", + "emailVerified": "2026-01-21T00:00:00.000Z", + "role": "member" + }, + { + "id": "user_cludszk1h0000wmd2e0ea2b0r", + "name": "Viewer", + "email": "viewer@dub-internal-test.com", + "emailVerified": "2026-01-21T00:00:00.000Z", + "role": "viewer" + }, + { + "id": "user_cludszk1h0000wmd2e0ea2b0s", + "name": "Billing", + "email": "billing@dub-internal-test.com", + "emailVerified": "2026-01-21T00:00:00.000Z", + "role": "billing" + } + ], + "domains": [ + { + "id": "dom_2LFUA020G94ZKH7B91JXIGY8H", + "slug": "example.local", + "verified": false + } + ], + "folders": [ + { + "id": "fold_2LFUA020G94ZKH7B91JXIGY8I", + "name": "Partner Links", + "description": "Default folder for partner links", + "accessLevel": "write" + } + ], + "rewards": [ + { + "id": "rw_2LFUA020G94ZKH7B91JXIGY8J", + "event": "lead", + "type": "flat", + "amountInCents": 1000, + "maxDuration": null, + "description": null, + "tooltipDescription": null + }, + { + "id": "rw_2LFUA020G94ZKH7B91JXIGY8K", + "event": "sale", + "type": "flat", + "amountInCents": 3000, + "maxDuration": 12, + "description": null, + "tooltipDescription": null + } + ], + "groups": [ + { + "id": "grp_2LFUA020G94ZKH7B91JXIGY8L", + "name": "Default Group", + "slug": "default", + "color": null, + "leadRewardId": "rw_2LFUA020G94ZKH7B91JXIGY8J", + "saleRewardId": "rw_2LFUA020G94ZKH7B91JXIGY8K", + "additionalLinks": [ + { + "domain": "example.com", + "validationMode": "domain" + } + ], + "defaultLinks": [ + { + "url": "https://example.com" + }, + { + "url": "https://example.com/docs" + } + ] + } + ], + "program": { + "id": "prog_2LFUA020G94ZKH7B91JXIGY8G", + "name": "Example", + "slug": "example", + "defaultFolderId": "fold_2LFUA020G94ZKH7B91JXIGY8I", + "defaultGroupId": "grp_2LFUA020G94ZKH7B91JXIGY8L", + "domain": "example.local", + "url": "https://example.com", + "termsUrl": "https://example.com/terms", + "helpUrl": "https://example.com/help", + "supportEmail": "support@example.com", + "logo": "https://assets.dub.co/logo.png" + }, + "partners": [], + "integrations": [] +} diff --git a/apps/web/scripts/dev/seed.ts b/apps/web/scripts/dev/seed.ts index fe066b7b64c..059e299c6e7 100644 --- a/apps/web/scripts/dev/seed.ts +++ b/apps/web/scripts/dev/seed.ts @@ -128,10 +128,48 @@ type SeedData = { >[]; }; -const parseJSON = (): SeedData => { - const jsonPath = path.join(__dirname, "data.json"); - const jsonData = JSON.parse(fs.readFileSync(jsonPath, "utf-8")); - return jsonData; +function parseCliArgs(argv: string[]) { + const args = argv.slice(2); + let shouldTruncate = false; + let workspaceSlug: string | undefined; + + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === "--truncate") { + shouldTruncate = true; + continue; + } + + if (a === "--workspace" || a === "-w") { + const next = args[i + 1]; + if (!next || next.startsWith("-")) { + throw new Error( + `Missing value for ${a}. Usage: -w or --workspace (loads {slug}-workspace.json; default slug is acme).`, + ); + } + workspaceSlug = next; + i++; + continue; + } + } + + return { + shouldTruncate, + workspaceSlug: workspaceSlug ?? "acme", + }; +} + +const parseJSON = (workspaceSlug: string): SeedData => { + const filename = `${workspaceSlug}-workspace.json`; + const jsonPath = path.join(__dirname, filename); + + if (!fs.existsSync(jsonPath)) { + throw new Error( + `Workspace seed file not found: ${filename} (expected path: ${jsonPath})`, + ); + } + + return JSON.parse(fs.readFileSync(jsonPath, "utf-8")); }; // Create workspace @@ -164,6 +202,7 @@ const createUsers = async (data: SeedData) => { emailVerified: new Date(user.emailVerified), passwordHash, })), + skipDuplicates: true, }); console.log(`Created ${count} users`); @@ -560,9 +599,8 @@ const askConfirmation = (question: string): Promise => { }; async function main() { - // Check for --truncate flag // process.argv[0] = node, process.argv[1] = script path, process.argv[2+] = arguments - const shouldTruncate = process.argv.slice(2).includes("--truncate"); + const { shouldTruncate, workspaceSlug } = parseCliArgs(process.argv); if (shouldTruncate) { console.log( @@ -583,7 +621,7 @@ async function main() { console.log("\n"); } - const data = parseJSON(); + const data = parseJSON(workspaceSlug); await createWorkspace(data); await createUsers(data); From e9d993e47b6fa2470e333aeb832d43819e005329 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 9 Apr 2026 20:13:29 +0530 Subject: [PATCH 60/86] Update example-workspace.json --- apps/web/scripts/dev/example-workspace.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/scripts/dev/example-workspace.json b/apps/web/scripts/dev/example-workspace.json index bf16e0a3d81..2ba0ce2faab 100644 --- a/apps/web/scripts/dev/example-workspace.json +++ b/apps/web/scripts/dev/example-workspace.json @@ -56,7 +56,7 @@ { "id": "dom_2LFUA020G94ZKH7B91JXIGY8H", "slug": "example.local", - "verified": false + "verified": true } ], "folders": [ From f6faabfe8ab6c34d65d294cdf4d137742210e874 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 9 Apr 2026 21:23:12 +0530 Subject: [PATCH 61/86] WIP adding tests --- apps/web/global-setup.ts | 26 +- apps/web/playwright.config.ts | 20 +- .../playwright/partners/rbac-auth.setup.ts | 36 +++ .../web/playwright/partners/rbac-constants.ts | 21 ++ apps/web/playwright/partners/rbac.spec.ts | 300 ++++++++++++++++++ apps/web/playwright/seed-rbac.ts | 268 ++++++++++++++++ 6 files changed, 669 insertions(+), 2 deletions(-) create mode 100644 apps/web/playwright/partners/rbac-auth.setup.ts create mode 100644 apps/web/playwright/partners/rbac-constants.ts create mode 100644 apps/web/playwright/partners/rbac.spec.ts create mode 100644 apps/web/playwright/seed-rbac.ts diff --git a/apps/web/global-setup.ts b/apps/web/global-setup.ts index 3d051cc0034..f6848cd1b08 100644 --- a/apps/web/global-setup.ts +++ b/apps/web/global-setup.ts @@ -1,7 +1,31 @@ import "dotenv-flow/config"; import type { FullConfig } from "@playwright/test"; +import { execSync } from "child_process"; -async function globalSetup(_config: FullConfig) {} +async function globalSetup(_config: FullConfig) { + // Seed workspaces + programs (from dev seed JSON files) + // execSync("npx tsx scripts/dev/seed.ts -w acme", { + // stdio: "inherit", + // cwd: __dirname, + // }); + + // execSync("npx tsx scripts/dev/seed.ts -w example", { + // stdio: "inherit", + // cwd: __dirname, + // }); + + // Seed existing partner test user + execSync("npx tsx playwright/seed.ts", { + stdio: "inherit", + cwd: __dirname, + }); + + // Seed RBAC test data (partner + users + enrollments + links) + execSync("npx tsx playwright/seed-rbac.ts", { + stdio: "inherit", + cwd: __dirname, + }); +} export default globalSetup; diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 9cb17074267..f5ebe405eb8 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -32,9 +32,27 @@ export default defineConfig({ storageState: "playwright/.auth/partner.json", }, testDir: "./playwright/partners", - testIgnore: /auth\.setup\.ts/, + testIgnore: /(auth\.setup|rbac)\.ts/, dependencies: ["partner-setup"], }, + // Partner RBAC tests + { + name: "rbac-setup", + testMatch: /partners\/rbac-auth\.setup\.ts/, + use: { + baseURL: "http://partners.localhost:8888", + }, + }, + { + name: "partner-rbac", + use: { + ...devices["Desktop Chrome"], + baseURL: "http://partners.localhost:8888", + }, + testDir: "./playwright/partners", + testMatch: /rbac\.spec\.ts/, + dependencies: ["rbac-setup"], + }, // Workspace tests { name: "workspace-setup", diff --git a/apps/web/playwright/partners/rbac-auth.setup.ts b/apps/web/playwright/partners/rbac-auth.setup.ts new file mode 100644 index 00000000000..46b21eb07c5 --- /dev/null +++ b/apps/web/playwright/partners/rbac-auth.setup.ts @@ -0,0 +1,36 @@ +import { expect, test } from "@playwright/test"; +import { RBAC_PASSWORD, RBAC_USERS } from "./rbac-constants"; + +const AUTH_FILES = { + owner: "playwright/.auth/partner-owner.json", + member: "playwright/.auth/partner-member.json", + viewer: "playwright/.auth/partner-viewer.json", +} as const; + +for (const [role, { email }] of Object.entries(RBAC_USERS)) { + test(`log in as ${role} partner user`, async ({ page }) => { + const authFile = AUTH_FILES[role as keyof typeof AUTH_FILES]; + + await page.goto("/login"); + + // Enter email + await page.locator('input[name="email"]').fill(email); + await page.getByRole("button", { name: "Log in with email" }).click(); + + // Enter password + const passwordInput = page.locator('input[type="password"]'); + await expect(passwordInput).toBeVisible(); + await passwordInput.fill(RBAC_PASSWORD); + await page.getByRole("button", { name: "Log in with password" }).click(); + + // Wait for redirect to authenticated area + await page.waitForURL( + (url) => /^\/(programs|onboarding)/.test(new URL(url).pathname), + { timeout: 30_000 }, + ); + await expect(page).not.toHaveURL(/\/login/); + + // Save authenticated state + await page.context().storageState({ path: authFile }); + }); +} diff --git a/apps/web/playwright/partners/rbac-constants.ts b/apps/web/playwright/partners/rbac-constants.ts new file mode 100644 index 00000000000..29d6da94e0d --- /dev/null +++ b/apps/web/playwright/partners/rbac-constants.ts @@ -0,0 +1,21 @@ +export const RBAC_PASSWORD = "Password123"; + +export const RBAC_USERS = { + owner: { + email: "partner+owner@dub-internal-test.com", + name: "Partner Owner", + }, + member: { + email: "partner+member@dub-internal-test.com", + name: "Partner Member", + }, + viewer: { + email: "partner+viewer@dub-internal-test.com", + name: "Partner Viewer", + }, +}; + +export const RBAC_PROGRAMS = { + acme: "acme", + example: "example", +}; diff --git a/apps/web/playwright/partners/rbac.spec.ts b/apps/web/playwright/partners/rbac.spec.ts new file mode 100644 index 00000000000..7d68c34e0b4 --- /dev/null +++ b/apps/web/playwright/partners/rbac.spec.ts @@ -0,0 +1,300 @@ +import { expect, test } from "@playwright/test"; +import { RBAC_PROGRAMS } from "./rbac-constants"; + +const { acme, example } = RBAC_PROGRAMS; + +// Helper to wait for sidebar to be ready +async function waitForSidebar(page: import("@playwright/test").Page) { + await expect( + page.getByRole("link", { name: "Programs", exact: true }), + ).toBeVisible({ timeout: 15_000 }); +} + +// Helper to check page loads without error +async function expectPageLoads(page: import("@playwright/test").Page) { + // Ensure page doesn't show a hard error + await expect(page.locator("body")).not.toContainText("Application error", { + timeout: 10_000, + }); +} + +test.describe("Owner role", () => { + test.use({ storageState: "playwright/.auth/partner-owner.json" }); + + test("can see Payouts and Messages in sidebar", async ({ page }) => { + await page.goto("/programs"); + await waitForSidebar(page); + + await expect( + page.getByRole("link", { name: "Payouts", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: "Messages", exact: true }), + ).toBeVisible(); + }); + + test("can see both programs in programs list", async ({ page }) => { + await page.goto("/programs"); + + await expect(page.getByText("Acme")).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText("Example")).toBeVisible(); + }); + + test("can see all 2 links on Acme program", async ({ page }) => { + await page.goto(`/programs/${acme}/links`); + + await expect(page.getByText("rbac-acme-link-1")).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByText("rbac-acme-link-2")).toBeVisible(); + }); + + test("can see all 2 links on Example program", async ({ page }) => { + await page.goto(`/programs/${example}/links`); + + await expect(page.getByText("rbac-example-link-1")).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByText("rbac-example-link-2")).toBeVisible(); + }); + + test("can access Acme program pages", async ({ page }) => { + // Overview + await page.goto(`/programs/${acme}`); + await expectPageLoads(page); + + // Links + await page.goto(`/programs/${acme}/links`); + await expectPageLoads(page); + + // Earnings + await page.goto(`/programs/${acme}/earnings`); + await expectPageLoads(page); + + // Bounties + await page.goto(`/programs/${acme}/bounties`); + await expectPageLoads(page); + + // Resources + await page.goto(`/programs/${acme}/resources`); + await expectPageLoads(page); + }); + + test("can access messages page", async ({ page }) => { + await page.goto(`/messages/${acme}`); + await expectPageLoads(page); + }); + + test("can access payouts page", async ({ page }) => { + await page.goto("/payouts"); + await expectPageLoads(page); + await expect(page.getByText("Payouts")).toBeVisible(); + }); + + test("can access profile with edit controls enabled", async ({ page }) => { + await page.goto("/profile"); + await expectPageLoads(page); + await expect(page.getByText("Profile")).toBeVisible(); + + // Three-dots menu button should be present and clickable + const menuButton = page.locator('button:has([class*="three-dots"])'); + // Fall back to finding by the specific variant button in controls area + const controlsButton = page + .locator("header") + .getByRole("button") + .filter({ has: page.locator("svg") }) + .last(); + await expect(controlsButton).toBeVisible({ timeout: 10_000 }); + await expect(controlsButton).toBeEnabled(); + }); + + test("can access members page", async ({ page }) => { + await page.goto("/profile/members"); + await expectPageLoads(page); + }); +}); + +test.describe("Viewer role", () => { + test.use({ storageState: "playwright/.auth/partner-viewer.json" }); + + test("cannot see Payouts or Messages in sidebar", async ({ page }) => { + await page.goto("/programs"); + await waitForSidebar(page); + + await expect( + page.getByRole("link", { name: "Payouts", exact: true }), + ).not.toBeVisible(); + await expect( + page.getByRole("link", { name: "Messages", exact: true }), + ).not.toBeVisible(); + }); + + test("can see both programs in programs list", async ({ page }) => { + await page.goto("/programs"); + + await expect(page.getByText("Acme")).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText("Example")).toBeVisible(); + }); + + test("can access Acme program pages", async ({ page }) => { + await page.goto(`/programs/${acme}`); + await expectPageLoads(page); + + await page.goto(`/programs/${acme}/links`); + await expectPageLoads(page); + + await page.goto(`/programs/${acme}/earnings`); + await expectPageLoads(page); + + await page.goto(`/programs/${acme}/bounties`); + await expectPageLoads(page); + + await page.goto(`/programs/${acme}/resources`); + await expectPageLoads(page); + }); + + test("can access Example program pages", async ({ page }) => { + await page.goto(`/programs/${example}`); + await expectPageLoads(page); + + await page.goto(`/programs/${example}/links`); + await expectPageLoads(page); + }); + + test("can see links on Acme program", async ({ page }) => { + await page.goto(`/programs/${acme}/links`); + + await expect(page.getByText("rbac-acme-link-1")).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByText("rbac-acme-link-2")).toBeVisible(); + }); + + test("profile page has disabled edit controls", async ({ page }) => { + await page.goto("/profile"); + await expectPageLoads(page); + await expect(page.getByText("Profile")).toBeVisible(); + + // The three-dots button should still render but the popover action inside should be disabled + // We check the merge accounts button is disabled when clicked + const controlsButton = page + .locator("header") + .getByRole("button") + .filter({ has: page.locator("svg") }) + .last(); + await expect(controlsButton).toBeVisible({ timeout: 10_000 }); + await controlsButton.click(); + + // The "Merge accounts" button inside the popover should be disabled + const mergeButton = page.getByRole("button", { name: "Merge accounts" }); + await expect(mergeButton).toBeVisible(); + await expect(mergeButton).toBeDisabled(); + }); + + test("can access members page", async ({ page }) => { + await page.goto("/profile/members"); + await expectPageLoads(page); + }); + + test("payouts page shows error state", async ({ page }) => { + await page.goto("/payouts"); + await expectPageLoads(page); + }); + + test("messages page loads", async ({ page }) => { + await page.goto(`/messages/${acme}`); + await expectPageLoads(page); + }); +}); + +test.describe("Member role (restricted access)", () => { + test.use({ storageState: "playwright/.auth/partner-member.json" }); + + test("can see Payouts and Messages in sidebar", async ({ page }) => { + await page.goto("/programs"); + await waitForSidebar(page); + + await expect( + page.getByRole("link", { name: "Payouts", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: "Messages", exact: true }), + ).toBeVisible(); + }); + + test("can see only Acme in programs list", async ({ page }) => { + await page.goto("/programs"); + + await expect(page.getByText("Acme")).toBeVisible({ timeout: 15_000 }); + // Example should not be visible since member has restricted access + await expect(page.getByText("Example")).not.toBeVisible(); + }); + + test("can see only 1 link on Acme program", async ({ page }) => { + await page.goto(`/programs/${acme}/links`); + + await expect(page.getByText("rbac-acme-link-1")).toBeVisible({ + timeout: 15_000, + }); + // Second link should not be visible (not assigned) + await expect(page.getByText("rbac-acme-link-2")).not.toBeVisible(); + }); + + test("can access Acme program pages", async ({ page }) => { + await page.goto(`/programs/${acme}`); + await expectPageLoads(page); + + await page.goto(`/programs/${acme}/links`); + await expectPageLoads(page); + + await page.goto(`/programs/${acme}/earnings`); + await expectPageLoads(page); + + await page.goto(`/programs/${acme}/bounties`); + await expectPageLoads(page); + + await page.goto(`/programs/${acme}/resources`); + await expectPageLoads(page); + }); + + test("can access Acme messages", async ({ page }) => { + await page.goto(`/messages/${acme}`); + await expectPageLoads(page); + }); + + test("cannot access Example program (not assigned)", async ({ page }) => { + await page.goto(`/programs/${example}`); + + // Should redirect to apply page since the enrollment API returns 404 + await page.waitForURL(/\/(apply|programs)/, { timeout: 15_000 }); + }); + + test("can access payouts page", async ({ page }) => { + await page.goto("/payouts"); + await expectPageLoads(page); + await expect(page.getByText("Payouts")).toBeVisible(); + }); + + test("profile page has disabled edit controls", async ({ page }) => { + await page.goto("/profile"); + await expectPageLoads(page); + await expect(page.getByText("Profile")).toBeVisible(); + + const controlsButton = page + .locator("header") + .getByRole("button") + .filter({ has: page.locator("svg") }) + .last(); + await expect(controlsButton).toBeVisible({ timeout: 10_000 }); + await controlsButton.click(); + + const mergeButton = page.getByRole("button", { name: "Merge accounts" }); + await expect(mergeButton).toBeVisible(); + await expect(mergeButton).toBeDisabled(); + }); + + test("can access members page", async ({ page }) => { + await page.goto("/profile/members"); + await expectPageLoads(page); + }); +}); diff --git a/apps/web/playwright/seed-rbac.ts b/apps/web/playwright/seed-rbac.ts new file mode 100644 index 00000000000..22ae06db145 --- /dev/null +++ b/apps/web/playwright/seed-rbac.ts @@ -0,0 +1,268 @@ +import { prisma } from "@dub/prisma"; +import { hash } from "bcryptjs"; +import "dotenv-flow/config"; +import { RBAC_PASSWORD, RBAC_USERS } from "./partners/rbac-constants"; + +async function getWorkspaceData(slug: string) { + const workspace = await prisma.project.findUniqueOrThrow({ + where: { slug }, + select: { + id: true, + programs: { + select: { + id: true, + domain: true, + defaultFolderId: true, + defaultGroupId: true, + }, + take: 1, + }, + }, + }); + + const program = workspace.programs[0]; + if (!program) { + throw new Error(`No program found for workspace "${slug}"`); + } + + return { + workspaceId: workspace.id, + programId: program.id, + groupId: program.defaultGroupId, + folderId: program.defaultFolderId, + domain: program.domain!, + }; +} + +const PARTNER = { + name: "RBAC Test Partner", + email: "partner+rbac@dub-internal-test.com", + country: "US", +}; + +async function main() { + // Query workspace data from the database + const ACME = await getWorkspaceData("acme"); + const EXAMPLE = await getWorkspaceData("example"); + + console.log("Resolved workspace data:", { ACME, EXAMPLE }); + + const passwordHash = await hash(RBAC_PASSWORD, 12); + + // Step 1: Create 3 users + const users: Record< + string, + Awaited> + > = {}; + + for (const [role, { email, name }] of Object.entries(RBAC_USERS)) { + users[role] = await prisma.user.upsert({ + where: { email }, + update: {}, + create: { + email, + name, + emailVerified: new Date(), + passwordHash, + }, + }); + } + + console.log( + "Created users:", + Object.fromEntries( + Object.entries(users).map(([role, user]) => [role, user.id]), + ), + ); + + // Step 2: Create partner + const partner = await prisma.partner.upsert({ + where: { email: PARTNER.email }, + update: {}, + create: { + name: PARTNER.name, + email: PARTNER.email, + country: PARTNER.country, + }, + }); + + console.log("Created partner:", partner.id); + + // Step 3: Create PartnerUser records + const PARTNER_USER_CONFIG = { + owner: { role: "owner", programAccess: "all" }, + member: { role: "member", programAccess: "restricted" }, + viewer: { role: "viewer", programAccess: "all" }, + } as const; + + const partnerUsers: Record< + string, + Awaited> + > = {}; + + for (const [role, config] of Object.entries(PARTNER_USER_CONFIG)) { + partnerUsers[role] = await prisma.partnerUser.upsert({ + where: { + userId_partnerId: { + userId: users[role].id, + partnerId: partner.id, + }, + }, + update: {}, + create: { + userId: users[role].id, + partnerId: partner.id, + role: config.role, + programAccess: config.programAccess, + }, + }); + } + + console.log( + "Created partner users:", + Object.fromEntries( + Object.entries(partnerUsers).map(([role, pu]) => [role, pu.id]), + ), + ); + + // Step 4: Set defaultPartnerId on all users + await Promise.all( + Object.values(users).map((user) => + prisma.user.update({ + where: { id: user.id }, + data: { defaultPartnerId: partner.id }, + }), + ), + ); + + // Step 5: Create program enrollments + const acmeEnrollment = await prisma.programEnrollment.upsert({ + where: { + partnerId_programId: { + partnerId: partner.id, + programId: ACME.programId, + }, + }, + update: {}, + create: { + partnerId: partner.id, + programId: ACME.programId, + groupId: ACME.groupId, + status: "approved", + }, + }); + + const exampleEnrollment = await prisma.programEnrollment.upsert({ + where: { + partnerId_programId: { + partnerId: partner.id, + programId: EXAMPLE.programId, + }, + }, + update: {}, + create: { + partnerId: partner.id, + programId: EXAMPLE.programId, + groupId: EXAMPLE.groupId, + status: "approved", + }, + }); + + console.log("Created enrollments:", { + acme: acmeEnrollment.id, + example: exampleEnrollment.id, + }); + + // Step 6: Create 4 links (2 per program) + const LINK_DEFS = [ + { key: "rbac-acme-link-1", url: "https://acme.com/ref1", ws: ACME }, + { key: "rbac-acme-link-2", url: "https://acme.com/ref2", ws: ACME }, + { + key: "rbac-example-link-1", + url: "https://example.com/ref1", + ws: EXAMPLE, + }, + { + key: "rbac-example-link-2", + url: "https://example.com/ref2", + ws: EXAMPLE, + }, + ]; + + const links: Record< + string, + Awaited> + > = {}; + + for (const { key, url, ws } of LINK_DEFS) { + links[key] = await prisma.link.upsert({ + where: { + domain_key: { + domain: ws.domain, + key, + }, + }, + update: {}, + create: { + domain: ws.domain, + key, + url, + shortLink: `https://${ws.domain}/${key}`, + projectId: ws.workspaceId, + programId: ws.programId, + partnerId: partner.id, + folderId: ws.folderId, + trackConversion: true, + }, + }); + } + + console.log( + "Created links:", + Object.fromEntries( + Object.entries(links).map(([key, link]) => [key, link.id]), + ), + ); + + // Step 7: Assign Acme program to member (restricted access) + await prisma.partnerUserProgram.upsert({ + where: { + partnerUserId_programId: { + partnerUserId: partnerUsers.member.id, + programId: ACME.programId, + }, + }, + update: {}, + create: { + partnerUserId: partnerUsers.member.id, + programId: ACME.programId, + }, + }); + + console.log("Assigned Acme program to member"); + + // Step 8: Assign only rbac-acme-link-1 to member + await prisma.partnerUserLink.upsert({ + where: { + partnerUserId_linkId: { + partnerUserId: partnerUsers.member.id, + linkId: links["rbac-acme-link-1"].id, + }, + }, + update: {}, + create: { + partnerUserId: partnerUsers.member.id, + linkId: links["rbac-acme-link-1"].id, + programId: ACME.programId, + }, + }); + + console.log("Assigned rbac-acme-link-1 to member"); + + console.log("\nRBAC seed complete!"); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); From cd79d50f01e7d30d65b92f36c8900161b56f5920 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 9 Apr 2026 22:31:49 +0530 Subject: [PATCH 62/86] Update rbac.spec.ts --- apps/web/playwright/partners/rbac.spec.ts | 439 +++++++++++----------- 1 file changed, 224 insertions(+), 215 deletions(-) diff --git a/apps/web/playwright/partners/rbac.spec.ts b/apps/web/playwright/partners/rbac.spec.ts index 7d68c34e0b4..2203479bf20 100644 --- a/apps/web/playwright/partners/rbac.spec.ts +++ b/apps/web/playwright/partners/rbac.spec.ts @@ -1,300 +1,309 @@ -import { expect, test } from "@playwright/test"; -import { RBAC_PROGRAMS } from "./rbac-constants"; - -const { acme, example } = RBAC_PROGRAMS; - -// Helper to wait for sidebar to be ready -async function waitForSidebar(page: import("@playwright/test").Page) { - await expect( - page.getByRole("link", { name: "Programs", exact: true }), - ).toBeVisible({ timeout: 15_000 }); +import { APIRequestContext, expect, test } from "@playwright/test"; + +const BASE = "http://partners.localhost:8888"; + +function api(request: APIRequestContext) { + return { + get: (path: string) => request.get(`${BASE}/api/partner-profile${path}`), + post: (path: string, data?: object) => + request.post(`${BASE}/api/partner-profile${path}`, { data }), + patch: (path: string, data?: object) => + request.patch(`${BASE}/api/partner-profile${path}`, { data }), + delete: (path: string) => + request.delete(`${BASE}/api/partner-profile${path}`), + }; } -// Helper to check page loads without error -async function expectPageLoads(page: import("@playwright/test").Page) { - // Ensure page doesn't show a hard error - await expect(page.locator("body")).not.toContainText("Application error", { - timeout: 10_000, - }); -} +// ─── Owner role ─────────────────────────────────────────────────────────────── test.describe("Owner role", () => { test.use({ storageState: "playwright/.auth/partner-owner.json" }); - test("can see Payouts and Messages in sidebar", async ({ page }) => { - await page.goto("/programs"); - await waitForSidebar(page); - - await expect( - page.getByRole("link", { name: "Payouts", exact: true }), - ).toBeVisible(); - await expect( - page.getByRole("link", { name: "Messages", exact: true }), - ).toBeVisible(); + test("GET / — partner profile", async ({ request }) => { + const res = await api(request).get("/"); + expect(res.status()).toBe(200); }); - test("can see both programs in programs list", async ({ page }) => { - await page.goto("/programs"); - - await expect(page.getByText("Acme")).toBeVisible({ timeout: 15_000 }); - await expect(page.getByText("Example")).toBeVisible(); + test("GET /programs — sees both programs", async ({ request }) => { + const res = await api(request).get("/programs"); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.length).toBe(2); }); - test("can see all 2 links on Acme program", async ({ page }) => { - await page.goto(`/programs/${acme}/links`); - - await expect(page.getByText("rbac-acme-link-1")).toBeVisible({ - timeout: 15_000, - }); - await expect(page.getByText("rbac-acme-link-2")).toBeVisible(); + test("GET /programs/acme — accessible", async ({ request }) => { + const res = await api(request).get("/programs/acme"); + expect(res.status()).toBe(200); }); - test("can see all 2 links on Example program", async ({ page }) => { - await page.goto(`/programs/${example}/links`); + test("GET /programs/example — accessible", async ({ request }) => { + const res = await api(request).get("/programs/example"); + expect(res.status()).toBe(200); + }); - await expect(page.getByText("rbac-example-link-1")).toBeVisible({ - timeout: 15_000, - }); - await expect(page.getByText("rbac-example-link-2")).toBeVisible(); + test("GET /programs/acme/links — sees 2 links", async ({ request }) => { + const res = await api(request).get("/programs/acme/links"); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.length).toBe(2); }); - test("can access Acme program pages", async ({ page }) => { - // Overview - await page.goto(`/programs/${acme}`); - await expectPageLoads(page); + test("GET /programs/example/links — sees 2 links", async ({ request }) => { + const res = await api(request).get("/programs/example/links"); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.length).toBe(2); + }); - // Links - await page.goto(`/programs/${acme}/links`); - await expectPageLoads(page); + test("GET /payouts — accessible", async ({ request }) => { + const res = await api(request).get("/payouts"); + expect(res.status()).toBe(200); + }); - // Earnings - await page.goto(`/programs/${acme}/earnings`); - await expectPageLoads(page); + test("GET /messages — accessible", async ({ request }) => { + const res = await api(request).get("/messages"); + expect(res.status()).toBe(200); + }); - // Bounties - await page.goto(`/programs/${acme}/bounties`); - await expectPageLoads(page); + test("GET /users — accessible", async ({ request }) => { + const res = await api(request).get("/users"); + expect(res.status()).toBe(200); + }); - // Resources - await page.goto(`/programs/${acme}/resources`); - await expectPageLoads(page); + test("GET /invites — accessible", async ({ request }) => { + const res = await api(request).get("/invites"); + expect(res.status()).toBe(200); }); - test("can access messages page", async ({ page }) => { - await page.goto(`/messages/${acme}`); - await expectPageLoads(page); + test("PATCH /users — not forbidden (has users.update)", async ({ + request, + }) => { + const res = await api(request).patch("/users", {}); + expect(res.status()).not.toBe(403); }); - test("can access payouts page", async ({ page }) => { - await page.goto("/payouts"); - await expectPageLoads(page); - await expect(page.getByText("Payouts")).toBeVisible(); + test("POST /invites — not forbidden (has user_invites.create)", async ({ + request, + }) => { + const res = await api(request).post("/invites", {}); + expect(res.status()).not.toBe(403); }); - test("can access profile with edit controls enabled", async ({ page }) => { - await page.goto("/profile"); - await expectPageLoads(page); - await expect(page.getByText("Profile")).toBeVisible(); + test("PATCH /invites — not forbidden (has user_invites.update)", async ({ + request, + }) => { + const res = await api(request).patch("/invites", {}); + expect(res.status()).not.toBe(403); + }); - // Three-dots menu button should be present and clickable - const menuButton = page.locator('button:has([class*="three-dots"])'); - // Fall back to finding by the specific variant button in controls area - const controlsButton = page - .locator("header") - .getByRole("button") - .filter({ has: page.locator("svg") }) - .last(); - await expect(controlsButton).toBeVisible({ timeout: 10_000 }); - await expect(controlsButton).toBeEnabled(); + test("DELETE /invites — not forbidden (has user_invites.delete)", async ({ + request, + }) => { + const res = await api(request).delete( + "/invites?email=fake@nonexistent.com", + ); + expect(res.status()).not.toBe(403); }); - test("can access members page", async ({ page }) => { - await page.goto("/profile/members"); - await expectPageLoads(page); + test("DELETE /users — not forbidden (has users.delete)", async ({ + request, + }) => { + const res = await api(request).delete("/users?userId=fake_user_id"); + expect(res.status()).not.toBe(403); }); }); +// ─── Viewer role ────────────────────────────────────────────────────────────── + test.describe("Viewer role", () => { test.use({ storageState: "playwright/.auth/partner-viewer.json" }); - test("cannot see Payouts or Messages in sidebar", async ({ page }) => { - await page.goto("/programs"); - await waitForSidebar(page); - - await expect( - page.getByRole("link", { name: "Payouts", exact: true }), - ).not.toBeVisible(); - await expect( - page.getByRole("link", { name: "Messages", exact: true }), - ).not.toBeVisible(); + test("GET / — partner profile", async ({ request }) => { + const res = await api(request).get("/"); + expect(res.status()).toBe(200); }); - test("can see both programs in programs list", async ({ page }) => { - await page.goto("/programs"); - - await expect(page.getByText("Acme")).toBeVisible({ timeout: 15_000 }); - await expect(page.getByText("Example")).toBeVisible(); + test("GET /programs — sees both programs", async ({ request }) => { + const res = await api(request).get("/programs"); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.length).toBe(2); }); - test("can access Acme program pages", async ({ page }) => { - await page.goto(`/programs/${acme}`); - await expectPageLoads(page); - - await page.goto(`/programs/${acme}/links`); - await expectPageLoads(page); - - await page.goto(`/programs/${acme}/earnings`); - await expectPageLoads(page); - - await page.goto(`/programs/${acme}/bounties`); - await expectPageLoads(page); + test("GET /programs/acme — accessible", async ({ request }) => { + const res = await api(request).get("/programs/acme"); + expect(res.status()).toBe(200); + }); - await page.goto(`/programs/${acme}/resources`); - await expectPageLoads(page); + test("GET /programs/example — accessible", async ({ request }) => { + const res = await api(request).get("/programs/example"); + expect(res.status()).toBe(200); }); - test("can access Example program pages", async ({ page }) => { - await page.goto(`/programs/${example}`); - await expectPageLoads(page); + test("GET /programs/acme/links — sees 2 links", async ({ request }) => { + const res = await api(request).get("/programs/acme/links"); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.length).toBe(2); + }); - await page.goto(`/programs/${example}/links`); - await expectPageLoads(page); + test("GET /payouts — accessible (no requiredPermission)", async ({ + request, + }) => { + const res = await api(request).get("/payouts"); + expect(res.status()).toBe(200); }); - test("can see links on Acme program", async ({ page }) => { - await page.goto(`/programs/${acme}/links`); + test("GET /messages — accessible (no requiredPermission)", async ({ + request, + }) => { + const res = await api(request).get("/messages"); + expect(res.status()).toBe(200); + }); - await expect(page.getByText("rbac-acme-link-1")).toBeVisible({ - timeout: 15_000, - }); - await expect(page.getByText("rbac-acme-link-2")).toBeVisible(); + test("GET /users — accessible", async ({ request }) => { + const res = await api(request).get("/users"); + expect(res.status()).toBe(200); }); - test("profile page has disabled edit controls", async ({ page }) => { - await page.goto("/profile"); - await expectPageLoads(page); - await expect(page.getByText("Profile")).toBeVisible(); + test("GET /invites — accessible", async ({ request }) => { + const res = await api(request).get("/invites"); + expect(res.status()).toBe(200); + }); - // The three-dots button should still render but the popover action inside should be disabled - // We check the merge accounts button is disabled when clicked - const controlsButton = page - .locator("header") - .getByRole("button") - .filter({ has: page.locator("svg") }) - .last(); - await expect(controlsButton).toBeVisible({ timeout: 10_000 }); - await controlsButton.click(); + test("PATCH /users — forbidden (no users.update)", async ({ request }) => { + const res = await api(request).patch("/users", {}); + expect(res.status()).toBe(403); + }); - // The "Merge accounts" button inside the popover should be disabled - const mergeButton = page.getByRole("button", { name: "Merge accounts" }); - await expect(mergeButton).toBeVisible(); - await expect(mergeButton).toBeDisabled(); + test("POST /invites — forbidden (no user_invites.create)", async ({ + request, + }) => { + const res = await api(request).post("/invites", {}); + expect(res.status()).toBe(403); }); - test("can access members page", async ({ page }) => { - await page.goto("/profile/members"); - await expectPageLoads(page); + test("PATCH /invites — forbidden (no user_invites.update)", async ({ + request, + }) => { + const res = await api(request).patch("/invites", {}); + expect(res.status()).toBe(403); }); - test("payouts page shows error state", async ({ page }) => { - await page.goto("/payouts"); - await expectPageLoads(page); + test("DELETE /invites — forbidden (no user_invites.delete)", async ({ + request, + }) => { + const res = await api(request).delete( + "/invites?email=fake@nonexistent.com", + ); + expect(res.status()).toBe(403); }); - test("messages page loads", async ({ page }) => { - await page.goto(`/messages/${acme}`); - await expectPageLoads(page); + test("DELETE /users — not found with fake userId (permission check is inline)", async ({ + request, + }) => { + // Permission check happens after user lookup, so fake userId returns 404 + const res = await api(request).delete("/users?userId=fake_user_id"); + expect(res.status()).toBe(404); }); }); +// ─── Member role (restricted program access) ───────────────────────────────── + test.describe("Member role (restricted access)", () => { test.use({ storageState: "playwright/.auth/partner-member.json" }); - test("can see Payouts and Messages in sidebar", async ({ page }) => { - await page.goto("/programs"); - await waitForSidebar(page); - - await expect( - page.getByRole("link", { name: "Payouts", exact: true }), - ).toBeVisible(); - await expect( - page.getByRole("link", { name: "Messages", exact: true }), - ).toBeVisible(); + test("GET / — partner profile", async ({ request }) => { + const res = await api(request).get("/"); + expect(res.status()).toBe(200); }); - test("can see only Acme in programs list", async ({ page }) => { - await page.goto("/programs"); - - await expect(page.getByText("Acme")).toBeVisible({ timeout: 15_000 }); - // Example should not be visible since member has restricted access - await expect(page.getByText("Example")).not.toBeVisible(); + test("GET /programs — sees only 1 program (Acme)", async ({ request }) => { + const res = await api(request).get("/programs"); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.length).toBe(1); }); - test("can see only 1 link on Acme program", async ({ page }) => { - await page.goto(`/programs/${acme}/links`); - - await expect(page.getByText("rbac-acme-link-1")).toBeVisible({ - timeout: 15_000, - }); - // Second link should not be visible (not assigned) - await expect(page.getByText("rbac-acme-link-2")).not.toBeVisible(); + test("GET /programs/acme — accessible", async ({ request }) => { + const res = await api(request).get("/programs/acme"); + expect(res.status()).toBe(200); }); - test("can access Acme program pages", async ({ page }) => { - await page.goto(`/programs/${acme}`); - await expectPageLoads(page); - - await page.goto(`/programs/${acme}/links`); - await expectPageLoads(page); + test("GET /programs/example — not found (not assigned)", async ({ + request, + }) => { + const res = await api(request).get("/programs/example"); + expect(res.status()).toBe(404); + }); - await page.goto(`/programs/${acme}/earnings`); - await expectPageLoads(page); + test("GET /programs/acme/links — sees only 1 link", async ({ request }) => { + const res = await api(request).get("/programs/acme/links"); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.length).toBe(1); + }); - await page.goto(`/programs/${acme}/bounties`); - await expectPageLoads(page); + test("GET /programs/example/links — not found (not assigned)", async ({ + request, + }) => { + const res = await api(request).get("/programs/example/links"); + expect(res.status()).toBe(404); + }); - await page.goto(`/programs/${acme}/resources`); - await expectPageLoads(page); + test("GET /payouts — accessible", async ({ request }) => { + const res = await api(request).get("/payouts"); + expect(res.status()).toBe(200); }); - test("can access Acme messages", async ({ page }) => { - await page.goto(`/messages/${acme}`); - await expectPageLoads(page); + test("GET /messages — accessible", async ({ request }) => { + const res = await api(request).get("/messages"); + expect(res.status()).toBe(200); }); - test("cannot access Example program (not assigned)", async ({ page }) => { - await page.goto(`/programs/${example}`); + test("GET /users — accessible", async ({ request }) => { + const res = await api(request).get("/users"); + expect(res.status()).toBe(200); + }); - // Should redirect to apply page since the enrollment API returns 404 - await page.waitForURL(/\/(apply|programs)/, { timeout: 15_000 }); + test("GET /invites — accessible", async ({ request }) => { + const res = await api(request).get("/invites"); + expect(res.status()).toBe(200); }); - test("can access payouts page", async ({ page }) => { - await page.goto("/payouts"); - await expectPageLoads(page); - await expect(page.getByText("Payouts")).toBeVisible(); + test("PATCH /users — forbidden (no users.update)", async ({ request }) => { + const res = await api(request).patch("/users", {}); + expect(res.status()).toBe(403); }); - test("profile page has disabled edit controls", async ({ page }) => { - await page.goto("/profile"); - await expectPageLoads(page); - await expect(page.getByText("Profile")).toBeVisible(); + test("POST /invites — forbidden (no user_invites.create)", async ({ + request, + }) => { + const res = await api(request).post("/invites", {}); + expect(res.status()).toBe(403); + }); - const controlsButton = page - .locator("header") - .getByRole("button") - .filter({ has: page.locator("svg") }) - .last(); - await expect(controlsButton).toBeVisible({ timeout: 10_000 }); - await controlsButton.click(); + test("PATCH /invites — forbidden (no user_invites.update)", async ({ + request, + }) => { + const res = await api(request).patch("/invites", {}); + expect(res.status()).toBe(403); + }); - const mergeButton = page.getByRole("button", { name: "Merge accounts" }); - await expect(mergeButton).toBeVisible(); - await expect(mergeButton).toBeDisabled(); + test("DELETE /invites — forbidden (no user_invites.delete)", async ({ + request, + }) => { + const res = await api(request).delete( + "/invites?email=fake@nonexistent.com", + ); + expect(res.status()).toBe(403); }); - test("can access members page", async ({ page }) => { - await page.goto("/profile/members"); - await expectPageLoads(page); + test("DELETE /users — not found with fake userId (permission check is inline)", async ({ + request, + }) => { + // Permission check happens after user lookup, so fake userId returns 404 + const res = await api(request).delete("/users?userId=fake_user_id"); + expect(res.status()).toBe(404); }); }); From 12fc9cb97d8a0600397bff433b1ac2427bcc7fec Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 10 Apr 2026 11:17:54 +0530 Subject: [PATCH 63/86] Consolidate Playwright partner RBAC data into main seed script --- apps/web/global-setup.ts | 6 - apps/web/playwright/partners/constants.ts | 50 ++++ .../playwright/partners/rbac-auth.setup.ts | 6 +- .../web/playwright/partners/rbac-constants.ts | 21 -- apps/web/playwright/seed-rbac.ts | 268 ------------------ apps/web/playwright/seed.ts | 192 ++++++++++--- apps/web/tsconfig.json | 28 +- 7 files changed, 228 insertions(+), 343 deletions(-) create mode 100644 apps/web/playwright/partners/constants.ts delete mode 100644 apps/web/playwright/partners/rbac-constants.ts delete mode 100644 apps/web/playwright/seed-rbac.ts diff --git a/apps/web/global-setup.ts b/apps/web/global-setup.ts index f6848cd1b08..6cfdeb70368 100644 --- a/apps/web/global-setup.ts +++ b/apps/web/global-setup.ts @@ -20,12 +20,6 @@ async function globalSetup(_config: FullConfig) { stdio: "inherit", cwd: __dirname, }); - - // Seed RBAC test data (partner + users + enrollments + links) - execSync("npx tsx playwright/seed-rbac.ts", { - stdio: "inherit", - cwd: __dirname, - }); } export default globalSetup; diff --git a/apps/web/playwright/partners/constants.ts b/apps/web/playwright/partners/constants.ts new file mode 100644 index 00000000000..2ba993b07df --- /dev/null +++ b/apps/web/playwright/partners/constants.ts @@ -0,0 +1,50 @@ +export const PASSWORD = "Password123"; + +export const PARTNER = { + name: "Partner 1", + email: "partner+1@dub-internal-test.com", + country: "US", +} as const; + +export const PARTNER_USERS = { + owner: { + email: "partner+owner@dub-internal-test.com", + name: "Partner Owner", + programAccess: "all", + }, + member: { + email: "partner+member@dub-internal-test.com", + name: "Partner Member", + programAccess: "restricted", + }, + viewer: { + email: "partner+viewer@dub-internal-test.com", + name: "Partner Viewer", + programAccess: "all", + }, +} as const; + +export const PARTNER_LINKS = { + acme: [ + { + key: "acme-link-1", + url: "https://acme.com", + }, + { + key: "acme-link-2", + url: "https://acme.com", + }, + ], + example: [ + { + key: "example-link-1", + url: "https://example.com", + }, + { + key: "example-link-2", + url: "https://example.com", + }, + ], +} as const; + +export const PARTNER_PROGRAMS = ["acme", "example"]; diff --git a/apps/web/playwright/partners/rbac-auth.setup.ts b/apps/web/playwright/partners/rbac-auth.setup.ts index 46b21eb07c5..17943a9da68 100644 --- a/apps/web/playwright/partners/rbac-auth.setup.ts +++ b/apps/web/playwright/partners/rbac-auth.setup.ts @@ -1,5 +1,5 @@ import { expect, test } from "@playwright/test"; -import { RBAC_PASSWORD, RBAC_USERS } from "./rbac-constants"; +import { PARTNER_USERS, PASSWORD } from "./constants"; const AUTH_FILES = { owner: "playwright/.auth/partner-owner.json", @@ -7,7 +7,7 @@ const AUTH_FILES = { viewer: "playwright/.auth/partner-viewer.json", } as const; -for (const [role, { email }] of Object.entries(RBAC_USERS)) { +for (const [role, { email }] of Object.entries(PARTNER_USERS)) { test(`log in as ${role} partner user`, async ({ page }) => { const authFile = AUTH_FILES[role as keyof typeof AUTH_FILES]; @@ -20,7 +20,7 @@ for (const [role, { email }] of Object.entries(RBAC_USERS)) { // Enter password const passwordInput = page.locator('input[type="password"]'); await expect(passwordInput).toBeVisible(); - await passwordInput.fill(RBAC_PASSWORD); + await passwordInput.fill(PASSWORD); await page.getByRole("button", { name: "Log in with password" }).click(); // Wait for redirect to authenticated area diff --git a/apps/web/playwright/partners/rbac-constants.ts b/apps/web/playwright/partners/rbac-constants.ts deleted file mode 100644 index 29d6da94e0d..00000000000 --- a/apps/web/playwright/partners/rbac-constants.ts +++ /dev/null @@ -1,21 +0,0 @@ -export const RBAC_PASSWORD = "Password123"; - -export const RBAC_USERS = { - owner: { - email: "partner+owner@dub-internal-test.com", - name: "Partner Owner", - }, - member: { - email: "partner+member@dub-internal-test.com", - name: "Partner Member", - }, - viewer: { - email: "partner+viewer@dub-internal-test.com", - name: "Partner Viewer", - }, -}; - -export const RBAC_PROGRAMS = { - acme: "acme", - example: "example", -}; diff --git a/apps/web/playwright/seed-rbac.ts b/apps/web/playwright/seed-rbac.ts deleted file mode 100644 index 22ae06db145..00000000000 --- a/apps/web/playwright/seed-rbac.ts +++ /dev/null @@ -1,268 +0,0 @@ -import { prisma } from "@dub/prisma"; -import { hash } from "bcryptjs"; -import "dotenv-flow/config"; -import { RBAC_PASSWORD, RBAC_USERS } from "./partners/rbac-constants"; - -async function getWorkspaceData(slug: string) { - const workspace = await prisma.project.findUniqueOrThrow({ - where: { slug }, - select: { - id: true, - programs: { - select: { - id: true, - domain: true, - defaultFolderId: true, - defaultGroupId: true, - }, - take: 1, - }, - }, - }); - - const program = workspace.programs[0]; - if (!program) { - throw new Error(`No program found for workspace "${slug}"`); - } - - return { - workspaceId: workspace.id, - programId: program.id, - groupId: program.defaultGroupId, - folderId: program.defaultFolderId, - domain: program.domain!, - }; -} - -const PARTNER = { - name: "RBAC Test Partner", - email: "partner+rbac@dub-internal-test.com", - country: "US", -}; - -async function main() { - // Query workspace data from the database - const ACME = await getWorkspaceData("acme"); - const EXAMPLE = await getWorkspaceData("example"); - - console.log("Resolved workspace data:", { ACME, EXAMPLE }); - - const passwordHash = await hash(RBAC_PASSWORD, 12); - - // Step 1: Create 3 users - const users: Record< - string, - Awaited> - > = {}; - - for (const [role, { email, name }] of Object.entries(RBAC_USERS)) { - users[role] = await prisma.user.upsert({ - where: { email }, - update: {}, - create: { - email, - name, - emailVerified: new Date(), - passwordHash, - }, - }); - } - - console.log( - "Created users:", - Object.fromEntries( - Object.entries(users).map(([role, user]) => [role, user.id]), - ), - ); - - // Step 2: Create partner - const partner = await prisma.partner.upsert({ - where: { email: PARTNER.email }, - update: {}, - create: { - name: PARTNER.name, - email: PARTNER.email, - country: PARTNER.country, - }, - }); - - console.log("Created partner:", partner.id); - - // Step 3: Create PartnerUser records - const PARTNER_USER_CONFIG = { - owner: { role: "owner", programAccess: "all" }, - member: { role: "member", programAccess: "restricted" }, - viewer: { role: "viewer", programAccess: "all" }, - } as const; - - const partnerUsers: Record< - string, - Awaited> - > = {}; - - for (const [role, config] of Object.entries(PARTNER_USER_CONFIG)) { - partnerUsers[role] = await prisma.partnerUser.upsert({ - where: { - userId_partnerId: { - userId: users[role].id, - partnerId: partner.id, - }, - }, - update: {}, - create: { - userId: users[role].id, - partnerId: partner.id, - role: config.role, - programAccess: config.programAccess, - }, - }); - } - - console.log( - "Created partner users:", - Object.fromEntries( - Object.entries(partnerUsers).map(([role, pu]) => [role, pu.id]), - ), - ); - - // Step 4: Set defaultPartnerId on all users - await Promise.all( - Object.values(users).map((user) => - prisma.user.update({ - where: { id: user.id }, - data: { defaultPartnerId: partner.id }, - }), - ), - ); - - // Step 5: Create program enrollments - const acmeEnrollment = await prisma.programEnrollment.upsert({ - where: { - partnerId_programId: { - partnerId: partner.id, - programId: ACME.programId, - }, - }, - update: {}, - create: { - partnerId: partner.id, - programId: ACME.programId, - groupId: ACME.groupId, - status: "approved", - }, - }); - - const exampleEnrollment = await prisma.programEnrollment.upsert({ - where: { - partnerId_programId: { - partnerId: partner.id, - programId: EXAMPLE.programId, - }, - }, - update: {}, - create: { - partnerId: partner.id, - programId: EXAMPLE.programId, - groupId: EXAMPLE.groupId, - status: "approved", - }, - }); - - console.log("Created enrollments:", { - acme: acmeEnrollment.id, - example: exampleEnrollment.id, - }); - - // Step 6: Create 4 links (2 per program) - const LINK_DEFS = [ - { key: "rbac-acme-link-1", url: "https://acme.com/ref1", ws: ACME }, - { key: "rbac-acme-link-2", url: "https://acme.com/ref2", ws: ACME }, - { - key: "rbac-example-link-1", - url: "https://example.com/ref1", - ws: EXAMPLE, - }, - { - key: "rbac-example-link-2", - url: "https://example.com/ref2", - ws: EXAMPLE, - }, - ]; - - const links: Record< - string, - Awaited> - > = {}; - - for (const { key, url, ws } of LINK_DEFS) { - links[key] = await prisma.link.upsert({ - where: { - domain_key: { - domain: ws.domain, - key, - }, - }, - update: {}, - create: { - domain: ws.domain, - key, - url, - shortLink: `https://${ws.domain}/${key}`, - projectId: ws.workspaceId, - programId: ws.programId, - partnerId: partner.id, - folderId: ws.folderId, - trackConversion: true, - }, - }); - } - - console.log( - "Created links:", - Object.fromEntries( - Object.entries(links).map(([key, link]) => [key, link.id]), - ), - ); - - // Step 7: Assign Acme program to member (restricted access) - await prisma.partnerUserProgram.upsert({ - where: { - partnerUserId_programId: { - partnerUserId: partnerUsers.member.id, - programId: ACME.programId, - }, - }, - update: {}, - create: { - partnerUserId: partnerUsers.member.id, - programId: ACME.programId, - }, - }); - - console.log("Assigned Acme program to member"); - - // Step 8: Assign only rbac-acme-link-1 to member - await prisma.partnerUserLink.upsert({ - where: { - partnerUserId_linkId: { - partnerUserId: partnerUsers.member.id, - linkId: links["rbac-acme-link-1"].id, - }, - }, - update: {}, - create: { - partnerUserId: partnerUsers.member.id, - linkId: links["rbac-acme-link-1"].id, - programId: ACME.programId, - }, - }); - - console.log("Assigned rbac-acme-link-1 to member"); - - console.log("\nRBAC seed complete!"); -} - -main().catch((e) => { - console.error(e); - process.exit(1); -}); diff --git a/apps/web/playwright/seed.ts b/apps/web/playwright/seed.ts index aaa09229c5d..4c3dbb4fbab 100644 --- a/apps/web/playwright/seed.ts +++ b/apps/web/playwright/seed.ts @@ -1,62 +1,178 @@ -import "dotenv-flow/config"; - -import { PrismaClient } from "@prisma/client"; +import { prisma } from "@dub/prisma"; +import { PartnerRole } from "@dub/prisma/client"; import { hashSync } from "bcryptjs"; - -const prisma = new PrismaClient(); - -const E2E_PARTNER = { - name: "Partner 1", - email: "partner1@dub-internal-test.com", - password: "password", -}; +import "dotenv-flow/config"; +import { + PARTNER, + PARTNER_LINKS, + PARTNER_PROGRAMS, + PARTNER_USERS, + PASSWORD, +} from "./partners/constants"; async function main() { - const passwordHash = hashSync(E2E_PARTNER.password, 10); - - const user = await prisma.user.upsert({ + // Upsert partner + const partner = await prisma.partner.upsert({ where: { - email: E2E_PARTNER.email, + email: PARTNER.email, }, update: {}, create: { - email: E2E_PARTNER.email, - name: E2E_PARTNER.name, - emailVerified: new Date(), - passwordHash, + name: PARTNER.name, + email: PARTNER.email, + country: PARTNER.country, }, }); - const partner = await prisma.partner.upsert({ - where: { - email: E2E_PARTNER.email, - }, - update: {}, - create: { - name: E2E_PARTNER.name, - email: E2E_PARTNER.email, - country: "US", - users: { - create: { + const partnerUsers: Record< + string, + Awaited> + > = {}; + + // Upsert partner users + for (const [role, { email, name, programAccess }] of Object.entries( + PARTNER_USERS, + )) { + const user = await prisma.user.upsert({ + where: { + email, + }, + update: {}, + create: { + email, + name, + emailVerified: new Date(), + passwordHash: hashSync(PASSWORD, 10), + defaultPartnerId: partner.id, + }, + }); + + partnerUsers[role] = await prisma.partnerUser.upsert({ + where: { + userId_partnerId: { userId: user.id, - role: "owner", + partnerId: partner.id, }, }, + update: {}, + create: { + userId: user.id, + partnerId: partner.id, + role: role as PartnerRole, + programAccess, + }, + }); + } + + // Upsert program enrollments + const programs = await prisma.program.findMany({ + where: { + slug: { + in: PARTNER_PROGRAMS, + }, + }, + include: { + groups: true, }, }); - await prisma.user.update({ + for (const program of programs) { + const defaultGroup = program.groups.find( + (group) => group.slug === "default", + ); + + if (!defaultGroup) { + throw new Error("Default group not found"); + } + + await prisma.programEnrollment.upsert({ + where: { + partnerId_programId: { + partnerId: partner.id, + programId: program.id, + }, + }, + update: {}, + create: { + partnerId: partner.id, + programId: program.id, + status: "approved", + groupId: defaultGroup.id, + clickRewardId: defaultGroup.clickRewardId, + leadRewardId: defaultGroup.leadRewardId, + saleRewardId: defaultGroup.saleRewardId, + discountId: defaultGroup.discountId, + }, + }); + } + + const links: Record< + string, + Awaited> + > = {}; + + // Upsert links + for (const program of programs) { + const partnerLinks = + PARTNER_LINKS[program.slug as keyof typeof PARTNER_LINKS]; + + for (const partnerLink of partnerLinks) { + links[partnerLink.key] = await prisma.link.upsert({ + where: { + domain_key: { + domain: program.domain!, + key: partnerLink.key, + }, + }, + update: {}, + create: { + domain: program.domain!, + key: partnerLink.key, + url: partnerLink.url, + shortLink: `https://${program.domain!}/${partnerLink.key}`, + projectId: program.workspaceId, + programId: program.id, + partnerId: partner.id, + folderId: program.defaultFolderId, + trackConversion: true, + }, + }); + } + } + + const programsBySlug = new Map( + programs.map((program) => [program.slug, program.id]), + ); + + // Assign Acme program to member (restricted access) + await prisma.partnerUserProgram.upsert({ where: { - id: user.id, + partnerUserId_programId: { + partnerUserId: partnerUsers.member.id, + programId: programsBySlug.get("acme")!, + }, }, - data: { - defaultPartnerId: partner.id, + update: {}, + create: { + partnerUserId: partnerUsers.member.id, + programId: programsBySlug.get("acme")!, }, }); - console.log("Seeded test partner:", { - userId: user.id, - partnerId: partner.id, + // Assign only acme-link-1 to member + await prisma.partnerUserLink.upsert({ + where: { + partnerUserId_linkId: { + partnerUserId: partnerUsers.member.id, + linkId: links["acme-link-1"].id, + }, + }, + update: {}, + create: { + partnerUserId: partnerUsers.member.id, + linkId: links["acme-link-1"].id, + programId: programsBySlug.get("acme")!, + }, }); } diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 8d9d479a08f..cb1ce2177d6 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -2,15 +2,27 @@ "extends": "tsconfig/nextjs.json", "compilerOptions": { "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "baseUrl": ".", "paths": { - "@/pages/*": ["pages/*"], - "@/styles/*": ["styles/*"], - "@/ui/*": ["ui/*"], - "@/lib/*": ["lib/*"] + "@/pages/*": [ + "pages/*" + ], + "@/styles/*": [ + "styles/*" + ], + "@/ui/*": [ + "ui/*" + ], + "@/lib/*": [ + "lib/*" + ] }, "downlevelIteration": true, "forceConsistentCasingInFileNames": true, @@ -38,5 +50,7 @@ "../../packages/blocks/src/event-list.tsx", "../../packages/ui/src/hooks/use-pagination.ts" ], - "exclude": ["node_modules", "playwright"] -} + "exclude": [ + "node_modules" + ] +} \ No newline at end of file From 1c24decc4ba95ffee736fe31b14d346aa04eada3 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 10 Apr 2026 12:38:51 +0530 Subject: [PATCH 64/86] Add defaultPartnerId to auth options and update seed scripts for new IDs --- apps/web/lib/auth/options.ts | 2 + .../playwright/partners/rbac-auth.setup.ts | 2 +- apps/web/playwright/partners/rbac.spec.ts | 209 +----------------- apps/web/playwright/seed.ts | 5 + apps/web/scripts/dev/seed.ts | 30 +++ 5 files changed, 45 insertions(+), 203 deletions(-) diff --git a/apps/web/lib/auth/options.ts b/apps/web/lib/auth/options.ts index ace8024fc49..b3020832f41 100644 --- a/apps/web/lib/auth/options.ts +++ b/apps/web/lib/auth/options.ts @@ -250,6 +250,7 @@ export const authOptions: NextAuthOptions = { image: true, invalidLoginAttempts: true, emailVerified: true, + defaultPartnerId: true, }, }); @@ -295,6 +296,7 @@ export const authOptions: NextAuthOptions = { name: user.name, email: user.email, image: user.image, + defaultPartnerId: user.defaultPartnerId, }; }, }), diff --git a/apps/web/playwright/partners/rbac-auth.setup.ts b/apps/web/playwright/partners/rbac-auth.setup.ts index 17943a9da68..269a6cb9385 100644 --- a/apps/web/playwright/partners/rbac-auth.setup.ts +++ b/apps/web/playwright/partners/rbac-auth.setup.ts @@ -26,7 +26,7 @@ for (const [role, { email }] of Object.entries(PARTNER_USERS)) { // Wait for redirect to authenticated area await page.waitForURL( (url) => /^\/(programs|onboarding)/.test(new URL(url).pathname), - { timeout: 30_000 }, + { timeout: process.env.CI ? 30_000 : 15_000 }, ); await expect(page).not.toHaveURL(/\/login/); diff --git a/apps/web/playwright/partners/rbac.spec.ts b/apps/web/playwright/partners/rbac.spec.ts index 2203479bf20..506eb0386b9 100644 --- a/apps/web/playwright/partners/rbac.spec.ts +++ b/apps/web/playwright/partners/rbac.spec.ts @@ -1,21 +1,21 @@ import { APIRequestContext, expect, test } from "@playwright/test"; -const BASE = "http://partners.localhost:8888"; +const BASE_URL = "http://partners.localhost:8888"; function api(request: APIRequestContext) { return { - get: (path: string) => request.get(`${BASE}/api/partner-profile${path}`), + get: (path: string) => + request.get(`${BASE_URL}/api/partner-profile${path}`), post: (path: string, data?: object) => - request.post(`${BASE}/api/partner-profile${path}`, { data }), + request.post(`${BASE_URL}/api/partner-profile${path}`, { data }), patch: (path: string, data?: object) => - request.patch(`${BASE}/api/partner-profile${path}`, { data }), + request.patch(`${BASE_URL}/api/partner-profile${path}`, { data }), delete: (path: string) => - request.delete(`${BASE}/api/partner-profile${path}`), + request.delete(`${BASE_URL}/api/partner-profile${path}`), }; } -// ─── Owner role ─────────────────────────────────────────────────────────────── - +// Owner role test.describe("Owner role", () => { test.use({ storageState: "playwright/.auth/partner-owner.json" }); @@ -112,198 +112,3 @@ test.describe("Owner role", () => { expect(res.status()).not.toBe(403); }); }); - -// ─── Viewer role ────────────────────────────────────────────────────────────── - -test.describe("Viewer role", () => { - test.use({ storageState: "playwright/.auth/partner-viewer.json" }); - - test("GET / — partner profile", async ({ request }) => { - const res = await api(request).get("/"); - expect(res.status()).toBe(200); - }); - - test("GET /programs — sees both programs", async ({ request }) => { - const res = await api(request).get("/programs"); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.length).toBe(2); - }); - - test("GET /programs/acme — accessible", async ({ request }) => { - const res = await api(request).get("/programs/acme"); - expect(res.status()).toBe(200); - }); - - test("GET /programs/example — accessible", async ({ request }) => { - const res = await api(request).get("/programs/example"); - expect(res.status()).toBe(200); - }); - - test("GET /programs/acme/links — sees 2 links", async ({ request }) => { - const res = await api(request).get("/programs/acme/links"); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.length).toBe(2); - }); - - test("GET /payouts — accessible (no requiredPermission)", async ({ - request, - }) => { - const res = await api(request).get("/payouts"); - expect(res.status()).toBe(200); - }); - - test("GET /messages — accessible (no requiredPermission)", async ({ - request, - }) => { - const res = await api(request).get("/messages"); - expect(res.status()).toBe(200); - }); - - test("GET /users — accessible", async ({ request }) => { - const res = await api(request).get("/users"); - expect(res.status()).toBe(200); - }); - - test("GET /invites — accessible", async ({ request }) => { - const res = await api(request).get("/invites"); - expect(res.status()).toBe(200); - }); - - test("PATCH /users — forbidden (no users.update)", async ({ request }) => { - const res = await api(request).patch("/users", {}); - expect(res.status()).toBe(403); - }); - - test("POST /invites — forbidden (no user_invites.create)", async ({ - request, - }) => { - const res = await api(request).post("/invites", {}); - expect(res.status()).toBe(403); - }); - - test("PATCH /invites — forbidden (no user_invites.update)", async ({ - request, - }) => { - const res = await api(request).patch("/invites", {}); - expect(res.status()).toBe(403); - }); - - test("DELETE /invites — forbidden (no user_invites.delete)", async ({ - request, - }) => { - const res = await api(request).delete( - "/invites?email=fake@nonexistent.com", - ); - expect(res.status()).toBe(403); - }); - - test("DELETE /users — not found with fake userId (permission check is inline)", async ({ - request, - }) => { - // Permission check happens after user lookup, so fake userId returns 404 - const res = await api(request).delete("/users?userId=fake_user_id"); - expect(res.status()).toBe(404); - }); -}); - -// ─── Member role (restricted program access) ───────────────────────────────── - -test.describe("Member role (restricted access)", () => { - test.use({ storageState: "playwright/.auth/partner-member.json" }); - - test("GET / — partner profile", async ({ request }) => { - const res = await api(request).get("/"); - expect(res.status()).toBe(200); - }); - - test("GET /programs — sees only 1 program (Acme)", async ({ request }) => { - const res = await api(request).get("/programs"); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.length).toBe(1); - }); - - test("GET /programs/acme — accessible", async ({ request }) => { - const res = await api(request).get("/programs/acme"); - expect(res.status()).toBe(200); - }); - - test("GET /programs/example — not found (not assigned)", async ({ - request, - }) => { - const res = await api(request).get("/programs/example"); - expect(res.status()).toBe(404); - }); - - test("GET /programs/acme/links — sees only 1 link", async ({ request }) => { - const res = await api(request).get("/programs/acme/links"); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.length).toBe(1); - }); - - test("GET /programs/example/links — not found (not assigned)", async ({ - request, - }) => { - const res = await api(request).get("/programs/example/links"); - expect(res.status()).toBe(404); - }); - - test("GET /payouts — accessible", async ({ request }) => { - const res = await api(request).get("/payouts"); - expect(res.status()).toBe(200); - }); - - test("GET /messages — accessible", async ({ request }) => { - const res = await api(request).get("/messages"); - expect(res.status()).toBe(200); - }); - - test("GET /users — accessible", async ({ request }) => { - const res = await api(request).get("/users"); - expect(res.status()).toBe(200); - }); - - test("GET /invites — accessible", async ({ request }) => { - const res = await api(request).get("/invites"); - expect(res.status()).toBe(200); - }); - - test("PATCH /users — forbidden (no users.update)", async ({ request }) => { - const res = await api(request).patch("/users", {}); - expect(res.status()).toBe(403); - }); - - test("POST /invites — forbidden (no user_invites.create)", async ({ - request, - }) => { - const res = await api(request).post("/invites", {}); - expect(res.status()).toBe(403); - }); - - test("PATCH /invites — forbidden (no user_invites.update)", async ({ - request, - }) => { - const res = await api(request).patch("/invites", {}); - expect(res.status()).toBe(403); - }); - - test("DELETE /invites — forbidden (no user_invites.delete)", async ({ - request, - }) => { - const res = await api(request).delete( - "/invites?email=fake@nonexistent.com", - ); - expect(res.status()).toBe(403); - }); - - test("DELETE /users — not found with fake userId (permission check is inline)", async ({ - request, - }) => { - // Permission check happens after user lookup, so fake userId returns 404 - const res = await api(request).delete("/users?userId=fake_user_id"); - expect(res.status()).toBe(404); - }); -}); diff --git a/apps/web/playwright/seed.ts b/apps/web/playwright/seed.ts index 4c3dbb4fbab..93630b3ba47 100644 --- a/apps/web/playwright/seed.ts +++ b/apps/web/playwright/seed.ts @@ -1,3 +1,4 @@ +import { createId } from "@/lib/api/create-id"; import { prisma } from "@dub/prisma"; import { PartnerRole } from "@dub/prisma/client"; import { hashSync } from "bcryptjs"; @@ -18,6 +19,7 @@ async function main() { }, update: {}, create: { + id: createId({ prefix: "pn_" }), name: PARTNER.name, email: PARTNER.email, country: PARTNER.country, @@ -39,6 +41,7 @@ async function main() { }, update: {}, create: { + id: createId({ prefix: "user_" }), email, name, emailVerified: new Date(), @@ -94,6 +97,7 @@ async function main() { }, update: {}, create: { + id: createId({ prefix: "pge_" }), partnerId: partner.id, programId: program.id, status: "approved", @@ -126,6 +130,7 @@ async function main() { }, update: {}, create: { + id: createId({ prefix: "link_" }), domain: program.domain!, key: partnerLink.key, url: partnerLink.url, diff --git a/apps/web/scripts/dev/seed.ts b/apps/web/scripts/dev/seed.ts index 059e299c6e7..b7f46490d7e 100644 --- a/apps/web/scripts/dev/seed.ts +++ b/apps/web/scripts/dev/seed.ts @@ -515,21 +515,28 @@ const truncate = async () => { "VerificationToken", "EmailVerificationToken", "NotificationPreference", + "OAuthCode", + "OAuthApp", "Integration", "Commission", "PartnerComment", "Payout", "Invoice", + "FraudEvent", + "PartnerReferral", "Customer", "Reward", "DiscountCode", "Discount", "FolderAccessRequest", "EmailDomain", + "NotificationEmail", "Message", "PartnerGroupDefaultLink", "FolderUser", "Folder", + "Dashboard", + "PartnerUserLink", "Link", "Workflow", "BountyGroup", @@ -538,15 +545,38 @@ const truncate = async () => { "CampaignGroup", "Campaign", "ProgramApplication", + "DiscoveredPartner", + "FraudAlert", + "FraudEventGroup", "ProgramEnrollment", "PartnerGroup", + "PartnerNotificationPreferences", + "PartnerUserProgram", "PartnerUser", + "Postback", + "PartnerPlatform", + "PartnerIndustryInterest", + "PartnerPreferredEarningStructure", + "PartnerSalesChannel", + "PartnerRewind", "Partner", "PartnerInvite", + "RegisteredDomain", "Domain", "ProjectUsers", + "ActivityLog", + "Account", + "Session", + "UserNotificationPreferences", "User", + "ProgramSimilarity", + "ProgramCategory", + "FraudRule", "Program", + "YearInReview", + "ProjectInvite", + "SentEmail", + "DefaultDomains", "Project", ]; From 48f0a5123e74da9179198126a7853b96e14c99d5 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 10 Apr 2026 13:09:18 +0530 Subject: [PATCH 65/86] Update route.ts --- apps/web/app/(ee)/api/partner-profile/messages/route.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/messages/route.ts b/apps/web/app/(ee)/api/partner-profile/messages/route.ts index 028a6b697ba..154f7677033 100644 --- a/apps/web/app/(ee)/api/partner-profile/messages/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/messages/route.ts @@ -1,5 +1,4 @@ import { withPartnerProfile } from "@/lib/auth/partner"; -import { programScopeFilter } from "@/lib/auth/partner-users/program-scope-filter"; import { throwIfNoProgramAccess } from "@/lib/auth/partner-users/throw-if-no-access"; import { ProgramMessagesSchema, @@ -82,7 +81,13 @@ export const GET = withPartnerProfile( }, }, ], - ...programScopeFilter(partnerUser.assignedProgramIds), + ...(partnerUser.assignedProgramIds + ? { + id: { + in: partnerUser.assignedProgramIds, + }, + } + : {}), }), }, include: { From 86fad7df3d4aa2dc0234b7de8133da72e6301b0e Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 10 Apr 2026 13:11:35 +0530 Subject: [PATCH 66/86] Refactor Playwright RBAC tests for partner roles, adding dynamic endpoint accessibility checks and enhancing assertions for program links and statuses. --- apps/web/playwright/partners/rbac.spec.ts | 176 +++++++++++++--------- 1 file changed, 108 insertions(+), 68 deletions(-) diff --git a/apps/web/playwright/partners/rbac.spec.ts b/apps/web/playwright/partners/rbac.spec.ts index 506eb0386b9..4e105c2dfe4 100644 --- a/apps/web/playwright/partners/rbac.spec.ts +++ b/apps/web/playwright/partners/rbac.spec.ts @@ -1,4 +1,7 @@ import { APIRequestContext, expect, test } from "@playwright/test"; +import { PARTNER_LINKS, PARTNER_PROGRAMS } from "./constants"; + +test.describe.configure({ mode: "parallel" }); const BASE_URL = "http://partners.localhost:8888"; @@ -19,96 +22,133 @@ function api(request: APIRequestContext) { test.describe("Owner role", () => { test.use({ storageState: "playwright/.auth/partner-owner.json" }); - test("GET / — partner profile", async ({ request }) => { - const res = await api(request).get("/"); - expect(res.status()).toBe(200); + const accessibleEndpoints = [ + "/", + "/payouts", + "/messages", + "/users", + "/programs/acme", + "/programs?status=invited", + "/programs/acme/earnings", + "/programs/acme/analytics", + "/programs/acme/events", + "/programs/acme/customers", + "/programs/acme/bounties", + "/programs/acme/resources", + ]; + + for (const endpoint of accessibleEndpoints) { + test(`GET ${endpoint} — accessible`, async ({ request }) => { + const response = await api(request).get(endpoint); + expect(response.status()).toBe(200); + }); + } + + test("GET /api/network/programs — accessible (program marketplace)", async ({ + request, + }) => { + const response = await request.get(`${BASE_URL}/api/network/programs`); + expect(response.status()).toBe(200); }); test("GET /programs — sees both programs", async ({ request }) => { - const res = await api(request).get("/programs"); - expect(res.status()).toBe(200); - const body = await res.json(); + const response = await api(request).get("/programs"); + expect(response.status()).toBe(200); + const body = await response.json(); expect(body.length).toBe(2); - }); - test("GET /programs/acme — accessible", async ({ request }) => { - const res = await api(request).get("/programs/acme"); - expect(res.status()).toBe(200); - }); - - test("GET /programs/example — accessible", async ({ request }) => { - const res = await api(request).get("/programs/example"); - expect(res.status()).toBe(200); + const slugs = body + .map((p: { program: { slug: string } }) => p.program.slug) + .sort(); + expect(slugs).toEqual([...PARTNER_PROGRAMS].sort()); }); test("GET /programs/acme/links — sees 2 links", async ({ request }) => { - const res = await api(request).get("/programs/acme/links"); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.length).toBe(2); - }); - - test("GET /programs/example/links — sees 2 links", async ({ request }) => { - const res = await api(request).get("/programs/example/links"); - expect(res.status()).toBe(200); - const body = await res.json(); + const response = await api(request).get("/programs/acme/links"); + expect(response.status()).toBe(200); + const body = await response.json(); expect(body.length).toBe(2); - }); - - test("GET /payouts — accessible", async ({ request }) => { - const res = await api(request).get("/payouts"); - expect(res.status()).toBe(200); - }); - - test("GET /messages — accessible", async ({ request }) => { - const res = await api(request).get("/messages"); - expect(res.status()).toBe(200); - }); - test("GET /users — accessible", async ({ request }) => { - const res = await api(request).get("/users"); - expect(res.status()).toBe(200); - }); - - test("GET /invites — accessible", async ({ request }) => { - const res = await api(request).get("/invites"); - expect(res.status()).toBe(200); + const keys = body.map((l: { key: string }) => l.key).sort(); + expect(keys).toEqual(PARTNER_LINKS.acme.map((l) => l.key).sort()); }); +}); - test("PATCH /users — not forbidden (has users.update)", async ({ +// Member role (restricted to acme program, acme-link-1 only) +test.describe("Member role", () => { + test.use({ storageState: "playwright/.auth/partner-member.json" }); + + const accessibleEndpoints = [ + "/", + "/payouts", + "/messages", + "/users", + "/programs/acme", + "/programs?status=invited", + "/programs/acme/earnings", + "/programs/acme/analytics", + "/programs/acme/events", + "/programs/acme/customers", + "/programs/acme/bounties", + "/programs/acme/resources", + ]; + + for (const endpoint of accessibleEndpoints) { + test(`GET ${endpoint} — accessible`, async ({ request }) => { + const response = await api(request).get(endpoint); + expect(response.status()).toBe(200); + }); + } + + test("GET /api/network/programs — accessible (program marketplace)", async ({ request, }) => { - const res = await api(request).patch("/users", {}); - expect(res.status()).not.toBe(403); + const response = await request.get(`${BASE_URL}/api/network/programs`); + expect(response.status()).toBe(200); }); - test("POST /invites — not forbidden (has user_invites.create)", async ({ - request, - }) => { - const res = await api(request).post("/invites", {}); - expect(res.status()).not.toBe(403); - }); + test("GET /programs — sees only acme", async ({ request }) => { + const response = await api(request).get("/programs"); + expect(response.status()).toBe(200); + const body = await response.json(); + expect(body.length).toBe(1); - test("PATCH /invites — not forbidden (has user_invites.update)", async ({ - request, - }) => { - const res = await api(request).patch("/invites", {}); - expect(res.status()).not.toBe(403); + const slugs = body.map( + (p: { program: { slug: string } }) => p.program.slug, + ); + expect(slugs).toEqual(["acme"]); }); - test("DELETE /invites — not forbidden (has user_invites.delete)", async ({ - request, - }) => { - const res = await api(request).delete( - "/invites?email=fake@nonexistent.com", - ); - expect(res.status()).not.toBe(403); + test("GET /programs/example — not accessible", async ({ request }) => { + const response = await api(request).get("/programs/example"); + expect(response.status()).toBe(404); }); - test("DELETE /users — not forbidden (has users.delete)", async ({ + test("GET /programs/acme/links — sees only acme-link-1", async ({ request, }) => { - const res = await api(request).delete("/users?userId=fake_user_id"); - expect(res.status()).not.toBe(403); + const response = await api(request).get("/programs/acme/links"); + expect(response.status()).toBe(200); + const body = await response.json(); + expect(body.length).toBe(1); + + const keys = body.map((l: { key: string }) => l.key); + expect(keys).toEqual(["acme-link-1"]); }); + + const unauthorizedLinkIdEndpoints = [ + { endpoint: "/programs/acme/earnings", expectedStatus: 403 }, + { endpoint: "/programs/acme/customers", expectedStatus: 403 }, + { endpoint: "/programs/acme/analytics", expectedStatus: 404 }, + { endpoint: "/programs/acme/events", expectedStatus: 404 }, + ]; + + for (const { endpoint, expectedStatus } of unauthorizedLinkIdEndpoints) { + test(`GET ${endpoint}?linkId=... — denied for unassigned link`, async ({ + request, + }) => { + const response = await api(request).get(`${endpoint}?linkId=acme-link-2`); + expect(response.status()).toBe(expectedStatus); + }); + } }); From 40664d38acb2fc2ed26e614e0bb78701c4d9fad4 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 10 Apr 2026 13:12:35 +0530 Subject: [PATCH 67/86] Skip partner signup test that conflicts with RBAC seed data --- apps/web/playwright/partners/auth.setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/playwright/partners/auth.setup.ts b/apps/web/playwright/partners/auth.setup.ts index 6c5b8f9b70b..d1bdfe01577 100644 --- a/apps/web/playwright/partners/auth.setup.ts +++ b/apps/web/playwright/partners/auth.setup.ts @@ -7,7 +7,7 @@ const SIGNUP_PASSWORD = "Password123"; const authFile = "playwright/.auth/partner.json"; -test("sign up and verify new partner", async ({ page }) => { +test.skip("sign up and verify new partner", async ({ page }) => { const email = `${nanoid(10)}@dub-internal-test.com`; // Go to registration page From 16c7da834291bec2489ab7d8c78d25e7a2837bec Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 10 Apr 2026 14:47:03 +0530 Subject: [PATCH 68/86] Refactor partner RBAC tests and add link limits Replace per-role repetitive Playwright tests with a parameterized RBAC matrix (RBAC_MATRIX) and runRbacSuite to cover owner/member/viewer permutations. Introduce RoleExpectation/RbacEntry types, centralize API_BASE_URL, and fetch an inaccessibleLinkId via prisma for query checks. Update tests to verify response bodies and error codes where applicable. Add maxPartnerLinks: 10 to dev workspace fixtures (acme and example) and update seed.ts to import DEFAULT_ADDITIONAL_PARTNER_LINKS and set PartnerGroup.maxPartnerLinks default when creating groups. --- apps/web/playwright/partners/rbac.spec.ts | 438 ++++++++++++++------ apps/web/scripts/dev/acme-workspace.json | 1 + apps/web/scripts/dev/example-workspace.json | 1 + apps/web/scripts/dev/seed.ts | 4 + 4 files changed, 317 insertions(+), 127 deletions(-) diff --git a/apps/web/playwright/partners/rbac.spec.ts b/apps/web/playwright/partners/rbac.spec.ts index 4e105c2dfe4..acccf5336a1 100644 --- a/apps/web/playwright/partners/rbac.spec.ts +++ b/apps/web/playwright/partners/rbac.spec.ts @@ -1,154 +1,338 @@ +import { prisma } from "@dub/prisma"; +import { PartnerRole } from "@dub/prisma/client"; import { APIRequestContext, expect, test } from "@playwright/test"; import { PARTNER_LINKS, PARTNER_PROGRAMS } from "./constants"; test.describe.configure({ mode: "parallel" }); -const BASE_URL = "http://partners.localhost:8888"; +const API_BASE_URL = "http://partners.localhost:8888/api/partner-profile"; + +type RoleExpectation = { + status: number; + code?: string; + verify?: (body: any) => void; +}; + +type RbacEntry = { + method: "GET" | "POST" | "PATCH" | "DELETE"; + endpoint: string; + body?: object; + queryParams?: Record; + roles: Record; +}; + +const RBAC_MATRIX: RbacEntry[] = [ + { + method: "GET", + endpoint: "/", + roles: { + owner: { status: 200 }, + member: { status: 200 }, + viewer: { status: 200 }, + }, + }, + { + method: "GET", + endpoint: "/users", + roles: { + owner: { status: 200 }, + member: { status: 200 }, + viewer: { status: 200 }, + }, + }, + { + method: "GET", + endpoint: "/programs/acme", + roles: { + owner: { status: 200 }, + member: { status: 200 }, + viewer: { status: 200 }, + }, + }, + { + method: "GET", + endpoint: "/programs?status=invited", + roles: { + owner: { status: 200 }, + member: { status: 200 }, + viewer: { status: 200 }, + }, + }, + { + method: "GET", + endpoint: "/programs/acme/earnings", + roles: { + owner: { status: 200 }, + member: { status: 200 }, + viewer: { status: 200 }, + }, + }, + { + method: "GET", + endpoint: "/programs/acme/analytics", + roles: { + owner: { status: 200 }, + member: { status: 200 }, + viewer: { status: 200 }, + }, + }, + { + method: "GET", + endpoint: "/programs/acme/events", + roles: { + owner: { status: 200 }, + member: { status: 200 }, + viewer: { status: 200 }, + }, + }, + { + method: "GET", + endpoint: "/programs/acme/customers", + roles: { + owner: { status: 200 }, + member: { status: 200 }, + viewer: { status: 200 }, + }, + }, + { + method: "GET", + endpoint: "/programs/acme/bounties", + roles: { + owner: { status: 200 }, + member: { status: 200 }, + viewer: { status: 200 }, + }, + }, + { + method: "GET", + endpoint: "/programs/acme/resources", + roles: { + owner: { status: 200 }, + member: { status: 200 }, + viewer: { status: 200 }, + }, + }, + { + method: "GET", + endpoint: "/programs", + roles: { + owner: { + status: 200, + verify: (body) => { + expect(body.length).toBe(2); + const slugs = body.map((p: any) => p.program.slug).sort(); + expect(slugs).toEqual([...PARTNER_PROGRAMS].sort()); + }, + }, + member: { + status: 200, + verify: (body) => { + expect(body.length).toBe(1); + expect(body[0].program.slug).toBe("acme"); + }, + }, + viewer: { + status: 200, + verify: (body) => { + expect(body.length).toBe(2); + const slugs = body.map((p: any) => p.program.slug).sort(); + expect(slugs).toEqual([...PARTNER_PROGRAMS].sort()); + }, + }, + }, + }, + { + method: "GET", + endpoint: "/programs/acme/links", + roles: { + owner: { + status: 200, + verify: (body) => { + expect(body.length).toBe(2); + const keys = body.map((l: any) => l.key).sort(); + expect(keys).toEqual(PARTNER_LINKS.acme.map((l) => l.key).sort()); + }, + }, + member: { + status: 200, + verify: (body) => { + expect(body.length).toBe(1); + expect(body[0].key).toBe("acme-link-1"); + }, + }, + viewer: { + status: 200, + verify: (body) => { + expect(body.length).toBe(2); + const keys = body.map((l: any) => l.key).sort(); + expect(keys).toEqual(PARTNER_LINKS.acme.map((l) => l.key).sort()); + }, + }, + }, + }, + { + method: "GET", + endpoint: "/payouts", + roles: { + owner: { status: 200 }, + member: { status: 200 }, + viewer: { status: 403, code: "forbidden" }, + }, + }, + { + method: "GET", + endpoint: "/messages", + roles: { + owner: { status: 200 }, + member: { status: 200 }, + viewer: { status: 403, code: "forbidden" }, + }, + }, + { + method: "GET", + endpoint: "/programs/example", + roles: { + owner: { status: 200 }, + member: { status: 404, code: "not_found" }, + viewer: { status: 200 }, + }, + }, + { + method: "POST", + endpoint: "/programs/acme/links", + body: { url: "https://example.com" }, + roles: { + owner: { status: 200 }, + member: { status: 200 }, + viewer: { status: 403, code: "forbidden" }, + }, + }, + { + method: "GET", + endpoint: "/programs/acme/earnings", + queryParams: { linkId: "{{inaccessibleLinkId}}" }, + roles: { + owner: { status: 200 }, + member: { status: 403, code: "forbidden" }, + viewer: { status: 200 }, + }, + }, + { + method: "GET", + endpoint: "/programs/acme/customers", + queryParams: { linkId: "{{inaccessibleLinkId}}" }, + roles: { + owner: { status: 200 }, + member: { status: 403, code: "forbidden" }, + viewer: { status: 200 }, + }, + }, + { + method: "GET", + endpoint: "/programs/acme/analytics", + queryParams: { linkId: "{{inaccessibleLinkId}}" }, + roles: { + owner: { status: 200 }, + member: { status: 404, code: "not_found" }, + viewer: { status: 200 }, + }, + }, + { + method: "GET", + endpoint: "/programs/acme/events", + queryParams: { linkId: "{{inaccessibleLinkId}}" }, + roles: { + owner: { status: 200 }, + member: { status: 404, code: "not_found" }, + viewer: { status: 200 }, + }, + }, +]; function api(request: APIRequestContext) { return { - get: (path: string) => - request.get(`${BASE_URL}/api/partner-profile${path}`), + get: (path: string) => request.get(`${API_BASE_URL}${path}`), post: (path: string, data?: object) => - request.post(`${BASE_URL}/api/partner-profile${path}`, { data }), + request.post(`${API_BASE_URL}${path}`, { data }), patch: (path: string, data?: object) => - request.patch(`${BASE_URL}/api/partner-profile${path}`, { data }), - delete: (path: string) => - request.delete(`${BASE_URL}/api/partner-profile${path}`), + request.patch(`${API_BASE_URL}${path}`, { data }), + delete: (path: string) => request.delete(`${API_BASE_URL}${path}`), }; } -// Owner role -test.describe("Owner role", () => { - test.use({ storageState: "playwright/.auth/partner-owner.json" }); +let inaccessibleLinkId: string; - const accessibleEndpoints = [ - "/", - "/payouts", - "/messages", - "/users", - "/programs/acme", - "/programs?status=invited", - "/programs/acme/earnings", - "/programs/acme/analytics", - "/programs/acme/events", - "/programs/acme/customers", - "/programs/acme/bounties", - "/programs/acme/resources", - ]; - - for (const endpoint of accessibleEndpoints) { - test(`GET ${endpoint} — accessible`, async ({ request }) => { - const response = await api(request).get(endpoint); - expect(response.status()).toBe(200); - }); - } - - test("GET /api/network/programs — accessible (program marketplace)", async ({ - request, - }) => { - const response = await request.get(`${BASE_URL}/api/network/programs`); - expect(response.status()).toBe(200); +function runRbacSuite(role: PartnerRole) { + test.beforeAll(async () => { + if (!inaccessibleLinkId) { + const link = await prisma.link.findFirstOrThrow({ + where: { key: "acme-link-2" }, + select: { id: true }, + }); + inaccessibleLinkId = link.id; + } }); - test("GET /programs — sees both programs", async ({ request }) => { - const response = await api(request).get("/programs"); - expect(response.status()).toBe(200); - const body = await response.json(); - expect(body.length).toBe(2); + for (const entry of RBAC_MATRIX) { + const expected = entry.roles[role]; + const label = + expected.status === 200 ? "accessible" : `denied (${expected.status})`; + const suffix = entry.queryParams ? " (with queryParams)" : ""; - const slugs = body - .map((p: { program: { slug: string } }) => p.program.slug) - .sort(); - expect(slugs).toEqual([...PARTNER_PROGRAMS].sort()); - }); + test(`${entry.method} ${entry.endpoint}${suffix} — ${label}`, async ({ + request, + }) => { + let url = `${API_BASE_URL}${entry.endpoint}`; - test("GET /programs/acme/links — sees 2 links", async ({ request }) => { - const response = await api(request).get("/programs/acme/links"); - expect(response.status()).toBe(200); - const body = await response.json(); - expect(body.length).toBe(2); + if (entry.queryParams) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(entry.queryParams)) { + params.set( + key, + value.replace("{{inaccessibleLinkId}}", inaccessibleLinkId), + ); + } + url += `?${params.toString()}`; + } - const keys = body.map((l: { key: string }) => l.key).sort(); - expect(keys).toEqual(PARTNER_LINKS.acme.map((l) => l.key).sort()); - }); -}); + let response; + if (entry.method === "GET") { + response = await request.get(url); + } else if (entry.method === "POST") { + response = await request.post(url, { data: entry.body }); + } else if (entry.method === "PATCH") { + response = await request.patch(url, { data: entry.body }); + } else { + response = await request.delete(url); + } -// Member role (restricted to acme program, acme-link-1 only) -test.describe("Member role", () => { - test.use({ storageState: "playwright/.auth/partner-member.json" }); + expect(response.status()).toBe(expected.status); + + if (expected.code) { + expect(await response.json()).toMatchObject({ + error: { code: expected.code }, + }); + } - const accessibleEndpoints = [ - "/", - "/payouts", - "/messages", - "/users", - "/programs/acme", - "/programs?status=invited", - "/programs/acme/earnings", - "/programs/acme/analytics", - "/programs/acme/events", - "/programs/acme/customers", - "/programs/acme/bounties", - "/programs/acme/resources", - ]; - - for (const endpoint of accessibleEndpoints) { - test(`GET ${endpoint} — accessible`, async ({ request }) => { - const response = await api(request).get(endpoint); - expect(response.status()).toBe(200); + if (expected.verify) { + const body = await response.json(); + expected.verify(body); + } }); } +} - test("GET /api/network/programs — accessible (program marketplace)", async ({ - request, - }) => { - const response = await request.get(`${BASE_URL}/api/network/programs`); - expect(response.status()).toBe(200); - }); - - test("GET /programs — sees only acme", async ({ request }) => { - const response = await api(request).get("/programs"); - expect(response.status()).toBe(200); - const body = await response.json(); - expect(body.length).toBe(1); - - const slugs = body.map( - (p: { program: { slug: string } }) => p.program.slug, - ); - expect(slugs).toEqual(["acme"]); - }); - - test("GET /programs/example — not accessible", async ({ request }) => { - const response = await api(request).get("/programs/example"); - expect(response.status()).toBe(404); - }); - - test("GET /programs/acme/links — sees only acme-link-1", async ({ - request, - }) => { - const response = await api(request).get("/programs/acme/links"); - expect(response.status()).toBe(200); - const body = await response.json(); - expect(body.length).toBe(1); - - const keys = body.map((l: { key: string }) => l.key); - expect(keys).toEqual(["acme-link-1"]); - }); +test.describe("Owner role", () => { + test.use({ storageState: "playwright/.auth/partner-owner.json" }); + runRbacSuite("owner"); +}); - const unauthorizedLinkIdEndpoints = [ - { endpoint: "/programs/acme/earnings", expectedStatus: 403 }, - { endpoint: "/programs/acme/customers", expectedStatus: 403 }, - { endpoint: "/programs/acme/analytics", expectedStatus: 404 }, - { endpoint: "/programs/acme/events", expectedStatus: 404 }, - ]; +test.describe("Member role", () => { + test.use({ storageState: "playwright/.auth/partner-member.json" }); + runRbacSuite("member"); +}); - for (const { endpoint, expectedStatus } of unauthorizedLinkIdEndpoints) { - test(`GET ${endpoint}?linkId=... — denied for unassigned link`, async ({ - request, - }) => { - const response = await api(request).get(`${endpoint}?linkId=acme-link-2`); - expect(response.status()).toBe(expectedStatus); - }); - } +test.describe("Viewer role", () => { + test.use({ storageState: "playwright/.auth/partner-viewer.json" }); + runRbacSuite("viewer"); }); diff --git a/apps/web/scripts/dev/acme-workspace.json b/apps/web/scripts/dev/acme-workspace.json index ec2e217563e..5385c266e9a 100644 --- a/apps/web/scripts/dev/acme-workspace.json +++ b/apps/web/scripts/dev/acme-workspace.json @@ -95,6 +95,7 @@ "color": null, "leadRewardId": "rw_1K2J9DRWPPJ2F1RX53N92TSGD", "saleRewardId": "rw_1K2J9DRWPPJ2F1RX53N92TSGE", + "maxPartnerLinks": 10, "additionalLinks": [ { "domain": "acme.com", diff --git a/apps/web/scripts/dev/example-workspace.json b/apps/web/scripts/dev/example-workspace.json index 2ba0ce2faab..f9a95eb5168 100644 --- a/apps/web/scripts/dev/example-workspace.json +++ b/apps/web/scripts/dev/example-workspace.json @@ -95,6 +95,7 @@ "color": null, "leadRewardId": "rw_2LFUA020G94ZKH7B91JXIGY8J", "saleRewardId": "rw_2LFUA020G94ZKH7B91JXIGY8K", + "maxPartnerLinks": 10, "additionalLinks": [ { "domain": "example.com", diff --git a/apps/web/scripts/dev/seed.ts b/apps/web/scripts/dev/seed.ts index b7f46490d7e..0a8946e036e 100644 --- a/apps/web/scripts/dev/seed.ts +++ b/apps/web/scripts/dev/seed.ts @@ -1,5 +1,6 @@ import { createId } from "@/lib/api/create-id"; import { hashPassword } from "@/lib/auth/password"; +import { DEFAULT_ADDITIONAL_PARTNER_LINKS } from "@/lib/zod/schemas/groups"; import { prisma } from "@dub/prisma"; import { Domain, @@ -72,6 +73,7 @@ type GroupSeed = Pick< | "saleRewardId" | "additionalLinks" > & { + maxPartnerLinks?: PartnerGroup["maxPartnerLinks"]; defaultLinks: Pick[]; }; @@ -342,6 +344,8 @@ const createGroups = async (data: SeedData) => { leadRewardId: group.leadRewardId ?? null, saleRewardId: group.saleRewardId ?? null, additionalLinks: group.additionalLinks as Prisma.JsonArray, + maxPartnerLinks: + group.maxPartnerLinks ?? DEFAULT_ADDITIONAL_PARTNER_LINKS, })), }); From 8d76bceae0dda882408186eba2e56fa790cd1871 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 10 Apr 2026 14:48:59 +0530 Subject: [PATCH 69/86] Add payouts/settings endpoint to RBAC test matrix --- apps/web/playwright/partners/rbac.spec.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/web/playwright/partners/rbac.spec.ts b/apps/web/playwright/partners/rbac.spec.ts index acccf5336a1..7930753a5ca 100644 --- a/apps/web/playwright/partners/rbac.spec.ts +++ b/apps/web/playwright/partners/rbac.spec.ts @@ -188,6 +188,15 @@ const RBAC_MATRIX: RbacEntry[] = [ viewer: { status: 403, code: "forbidden" }, }, }, + { + method: "GET", + endpoint: "/payouts/settings", + roles: { + owner: { status: 200 }, + member: { status: 200 }, + viewer: { status: 403, code: "forbidden" }, + }, + }, { method: "GET", endpoint: "/programs/example", From 342757b49555a0e7c60247b268a343ea57b4f32e Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 10 Apr 2026 14:55:22 +0530 Subject: [PATCH 70/86] Update global-setup.ts --- apps/web/global-setup.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/web/global-setup.ts b/apps/web/global-setup.ts index 6cfdeb70368..fa5ecf017e1 100644 --- a/apps/web/global-setup.ts +++ b/apps/web/global-setup.ts @@ -4,16 +4,18 @@ import type { FullConfig } from "@playwright/test"; import { execSync } from "child_process"; async function globalSetup(_config: FullConfig) { - // Seed workspaces + programs (from dev seed JSON files) - // execSync("npx tsx scripts/dev/seed.ts -w acme", { - // stdio: "inherit", - // cwd: __dirname, - // }); + // Seed workspaces + programs (from dev seed JSON files) — CI only; local dev assumes seeded DB + if (process.env.CI) { + execSync("npx tsx scripts/dev/seed.ts -w acme", { + stdio: "inherit", + cwd: __dirname, + }); - // execSync("npx tsx scripts/dev/seed.ts -w example", { - // stdio: "inherit", - // cwd: __dirname, - // }); + execSync("npx tsx scripts/dev/seed.ts -w example", { + stdio: "inherit", + cwd: __dirname, + }); + } // Seed existing partner test user execSync("npx tsx playwright/seed.ts", { From ee2c4ed86de25ea00f68d48e70b37ef0936cc24d Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 10 Apr 2026 14:56:46 +0530 Subject: [PATCH 71/86] Update rbac.spec.ts --- apps/web/playwright/partners/rbac.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/playwright/partners/rbac.spec.ts b/apps/web/playwright/partners/rbac.spec.ts index 7930753a5ca..c75bb7b6bd8 100644 --- a/apps/web/playwright/partners/rbac.spec.ts +++ b/apps/web/playwright/partners/rbac.spec.ts @@ -211,8 +211,8 @@ const RBAC_MATRIX: RbacEntry[] = [ endpoint: "/programs/acme/links", body: { url: "https://example.com" }, roles: { - owner: { status: 200 }, - member: { status: 200 }, + owner: { status: 201 }, + member: { status: 201 }, viewer: { status: 403, code: "forbidden" }, }, }, From b949614e1ba7e3b7bf0ee87a2fac6b4bb7513c1d Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 10 Apr 2026 15:02:24 +0530 Subject: [PATCH 72/86] Update global-setup.ts --- apps/web/global-setup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/global-setup.ts b/apps/web/global-setup.ts index fa5ecf017e1..7b3fc9439f8 100644 --- a/apps/web/global-setup.ts +++ b/apps/web/global-setup.ts @@ -5,7 +5,7 @@ import { execSync } from "child_process"; async function globalSetup(_config: FullConfig) { // Seed workspaces + programs (from dev seed JSON files) — CI only; local dev assumes seeded DB - if (process.env.CI) { + if (process.env.GITHUB_ACTION) { execSync("npx tsx scripts/dev/seed.ts -w acme", { stdio: "inherit", cwd: __dirname, From e05f2e7994f890e2036e6c8ea5e4084c6956c622 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 10 Apr 2026 15:05:01 +0530 Subject: [PATCH 73/86] Update rbac.spec.ts --- apps/web/playwright/partners/rbac.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/playwright/partners/rbac.spec.ts b/apps/web/playwright/partners/rbac.spec.ts index c75bb7b6bd8..7560e57e873 100644 --- a/apps/web/playwright/partners/rbac.spec.ts +++ b/apps/web/playwright/partners/rbac.spec.ts @@ -209,7 +209,7 @@ const RBAC_MATRIX: RbacEntry[] = [ { method: "POST", endpoint: "/programs/acme/links", - body: { url: "https://example.com" }, + body: { url: "https://acme.com" }, roles: { owner: { status: 201 }, member: { status: 201 }, From 013cdebca8625f32792f169d88439fee212831bd Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 10 Apr 2026 15:08:36 +0530 Subject: [PATCH 74/86] Update rbac.spec.ts --- apps/web/playwright/partners/rbac.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/playwright/partners/rbac.spec.ts b/apps/web/playwright/partners/rbac.spec.ts index 7560e57e873..41987852041 100644 --- a/apps/web/playwright/partners/rbac.spec.ts +++ b/apps/web/playwright/partners/rbac.spec.ts @@ -148,7 +148,6 @@ const RBAC_MATRIX: RbacEntry[] = [ owner: { status: 200, verify: (body) => { - expect(body.length).toBe(2); const keys = body.map((l: any) => l.key).sort(); expect(keys).toEqual(PARTNER_LINKS.acme.map((l) => l.key).sort()); }, @@ -163,7 +162,6 @@ const RBAC_MATRIX: RbacEntry[] = [ viewer: { status: 200, verify: (body) => { - expect(body.length).toBe(2); const keys = body.map((l: any) => l.key).sort(); expect(keys).toEqual(PARTNER_LINKS.acme.map((l) => l.key).sort()); }, @@ -272,6 +270,8 @@ function api(request: APIRequestContext) { let inaccessibleLinkId: string; function runRbacSuite(role: PartnerRole) { + test.describe.configure({ mode: "parallel" }); + test.beforeAll(async () => { if (!inaccessibleLinkId) { const link = await prisma.link.findFirstOrThrow({ From 1daa8c54b635ca978d02b786fc77edbfb03c19ec Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 10 Apr 2026 15:17:54 +0530 Subject: [PATCH 75/86] fix the tests --- apps/web/playwright/partners/rbac.spec.ts | 10 +++++----- apps/web/playwright/seed.ts | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/web/playwright/partners/rbac.spec.ts b/apps/web/playwright/partners/rbac.spec.ts index 41987852041..28bb0ee75b9 100644 --- a/apps/web/playwright/partners/rbac.spec.ts +++ b/apps/web/playwright/partners/rbac.spec.ts @@ -1,7 +1,7 @@ import { prisma } from "@dub/prisma"; import { PartnerRole } from "@dub/prisma/client"; import { APIRequestContext, expect, test } from "@playwright/test"; -import { PARTNER_LINKS, PARTNER_PROGRAMS } from "./constants"; +import { PARTNER_PROGRAMS } from "./constants"; test.describe.configure({ mode: "parallel" }); @@ -148,8 +148,8 @@ const RBAC_MATRIX: RbacEntry[] = [ owner: { status: 200, verify: (body) => { - const keys = body.map((l: any) => l.key).sort(); - expect(keys).toEqual(PARTNER_LINKS.acme.map((l) => l.key).sort()); + expect(body.some((l: any) => l.key === "acme-link-1")).toBe(true); + expect(body.some((l: any) => l.key === "acme-link-2")).toBe(true); }, }, member: { @@ -162,8 +162,8 @@ const RBAC_MATRIX: RbacEntry[] = [ viewer: { status: 200, verify: (body) => { - const keys = body.map((l: any) => l.key).sort(); - expect(keys).toEqual(PARTNER_LINKS.acme.map((l) => l.key).sort()); + expect(body.some((l: any) => l.key === "acme-link-1")).toBe(true); + expect(body.some((l: any) => l.key === "acme-link-2")).toBe(true); }, }, }, diff --git a/apps/web/playwright/seed.ts b/apps/web/playwright/seed.ts index 93630b3ba47..b5cccc94122 100644 --- a/apps/web/playwright/seed.ts +++ b/apps/web/playwright/seed.ts @@ -1,8 +1,9 @@ +import "dotenv-flow/config"; + import { createId } from "@/lib/api/create-id"; import { prisma } from "@dub/prisma"; import { PartnerRole } from "@dub/prisma/client"; import { hashSync } from "bcryptjs"; -import "dotenv-flow/config"; import { PARTNER, PARTNER_LINKS, From f892451346c7c33ea7c83c4726d067cc73443118 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 10 Apr 2026 15:23:36 +0530 Subject: [PATCH 76/86] fix tests one more time --- apps/web/playwright/partners/auth.setup.ts | 8 +++----- apps/web/playwright/workspaces/auth.setup.ts | 6 ++---- apps/web/scripts/dev/acme-workspace.json | 2 +- apps/web/scripts/dev/example-workspace.json | 2 +- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/web/playwright/partners/auth.setup.ts b/apps/web/playwright/partners/auth.setup.ts index d1bdfe01577..6680c95ee2c 100644 --- a/apps/web/playwright/partners/auth.setup.ts +++ b/apps/web/playwright/partners/auth.setup.ts @@ -1,13 +1,11 @@ import { nanoid } from "@dub/utils"; import { expect, test } from "@playwright/test"; import { extractOtp, waitForEmail } from "../mailhog"; - -// Must satisfy: 8+ chars, uppercase, lowercase, digit -const SIGNUP_PASSWORD = "Password123"; +import { PASSWORD } from "./constants"; const authFile = "playwright/.auth/partner.json"; -test.skip("sign up and verify new partner", async ({ page }) => { +test("sign up and verify new partner", async ({ page }) => { const email = `${nanoid(10)}@dub-internal-test.com`; // Go to registration page @@ -20,7 +18,7 @@ test.skip("sign up and verify new partner", async ({ page }) => { // Step 2: Enter password and submit const passwordInput = page.locator('input[name="password"]'); await expect(passwordInput).toBeVisible(); - await passwordInput.fill(SIGNUP_PASSWORD); + await passwordInput.fill(PASSWORD); await page.getByRole("button", { name: "Sign Up" }).click(); // Step 3: Verify email via OTP from MailHog diff --git a/apps/web/playwright/workspaces/auth.setup.ts b/apps/web/playwright/workspaces/auth.setup.ts index 428547239f8..0ff3fb0f6d8 100644 --- a/apps/web/playwright/workspaces/auth.setup.ts +++ b/apps/web/playwright/workspaces/auth.setup.ts @@ -1,9 +1,7 @@ import { nanoid } from "@dub/utils"; import { expect, test } from "@playwright/test"; import { extractOtp, waitForEmail } from "../mailhog"; - -// Must satisfy: 8+ chars, uppercase, lowercase, digit -const SIGNUP_PASSWORD = "Password123"; +import { PASSWORD } from "../partners/constants"; const authFile = "playwright/.auth/workspace.json"; @@ -20,7 +18,7 @@ test("sign up new user for workspace onboarding", async ({ page }) => { // Step 2: Enter password and submit const passwordInput = page.locator('input[name="password"]'); await expect(passwordInput).toBeVisible(); - await passwordInput.fill(SIGNUP_PASSWORD); + await passwordInput.fill(PASSWORD); await page.getByRole("button", { name: "Sign Up" }).click(); // Step 3: Verify email via OTP from MailHog diff --git a/apps/web/scripts/dev/acme-workspace.json b/apps/web/scripts/dev/acme-workspace.json index 5385c266e9a..b687ac13844 100644 --- a/apps/web/scripts/dev/acme-workspace.json +++ b/apps/web/scripts/dev/acme-workspace.json @@ -95,7 +95,7 @@ "color": null, "leadRewardId": "rw_1K2J9DRWPPJ2F1RX53N92TSGD", "saleRewardId": "rw_1K2J9DRWPPJ2F1RX53N92TSGE", - "maxPartnerLinks": 10, + "maxPartnerLinks": 50, "additionalLinks": [ { "domain": "acme.com", diff --git a/apps/web/scripts/dev/example-workspace.json b/apps/web/scripts/dev/example-workspace.json index f9a95eb5168..41aa4478330 100644 --- a/apps/web/scripts/dev/example-workspace.json +++ b/apps/web/scripts/dev/example-workspace.json @@ -95,7 +95,7 @@ "color": null, "leadRewardId": "rw_2LFUA020G94ZKH7B91JXIGY8J", "saleRewardId": "rw_2LFUA020G94ZKH7B91JXIGY8K", - "maxPartnerLinks": 10, + "maxPartnerLinks": 50, "additionalLinks": [ { "domain": "example.com", From eabef5995966ce93406a4193792921102550b63e Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 10 Apr 2026 15:28:22 +0530 Subject: [PATCH 77/86] Update global-setup.ts --- apps/web/global-setup.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/global-setup.ts b/apps/web/global-setup.ts index 7b3fc9439f8..2bcbfd97142 100644 --- a/apps/web/global-setup.ts +++ b/apps/web/global-setup.ts @@ -6,19 +6,19 @@ import { execSync } from "child_process"; async function globalSetup(_config: FullConfig) { // Seed workspaces + programs (from dev seed JSON files) — CI only; local dev assumes seeded DB if (process.env.GITHUB_ACTION) { - execSync("npx tsx scripts/dev/seed.ts -w acme", { + execSync("pnpm exec tsx scripts/dev/seed.ts -w acme", { stdio: "inherit", cwd: __dirname, }); - execSync("npx tsx scripts/dev/seed.ts -w example", { + execSync("pnpm exec tsx scripts/dev/seed.ts -w example", { stdio: "inherit", cwd: __dirname, }); } // Seed existing partner test user - execSync("npx tsx playwright/seed.ts", { + execSync("pnpm exec tsx playwright/seed.ts", { stdio: "inherit", cwd: __dirname, }); From 47c6488f07088eb37aaf36f71547d86e6937453b Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 10 Apr 2026 15:31:46 +0530 Subject: [PATCH 78/86] Update playwright.yaml --- .github/workflows/playwright.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/playwright.yaml b/.github/workflows/playwright.yaml index ff756dff15d..97bcea3a08c 100644 --- a/.github/workflows/playwright.yaml +++ b/.github/workflows/playwright.yaml @@ -152,10 +152,6 @@ jobs: working-directory: apps/web run: pnpm prisma:push - - name: Seed test data - working-directory: apps/web - run: pnpm tsx playwright/seed.ts - - name: Build application run: pnpm turbo build --filter=web From 3af5c9e41f6ec7f50980532e0e1e8d40403bd9e1 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 10 Apr 2026 15:46:12 +0530 Subject: [PATCH 79/86] Update rbac.spec.ts --- apps/web/playwright/partners/rbac.spec.ts | 76 +++++++++++------------ 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/apps/web/playwright/partners/rbac.spec.ts b/apps/web/playwright/partners/rbac.spec.ts index 28bb0ee75b9..28423fc3cec 100644 --- a/apps/web/playwright/partners/rbac.spec.ts +++ b/apps/web/playwright/partners/rbac.spec.ts @@ -67,24 +67,24 @@ const RBAC_MATRIX: RbacEntry[] = [ viewer: { status: 200 }, }, }, - { - method: "GET", - endpoint: "/programs/acme/analytics", - roles: { - owner: { status: 200 }, - member: { status: 200 }, - viewer: { status: 200 }, - }, - }, - { - method: "GET", - endpoint: "/programs/acme/events", - roles: { - owner: { status: 200 }, - member: { status: 200 }, - viewer: { status: 200 }, - }, - }, + // { + // method: "GET", + // endpoint: "/programs/acme/analytics", + // roles: { + // owner: { status: 200 }, + // member: { status: 200 }, + // viewer: { status: 200 }, + // }, + // }, + // { + // method: "GET", + // endpoint: "/programs/acme/events", + // roles: { + // owner: { status: 200 }, + // member: { status: 200 }, + // viewer: { status: 200 }, + // }, + // }, { method: "GET", endpoint: "/programs/acme/customers", @@ -234,26 +234,26 @@ const RBAC_MATRIX: RbacEntry[] = [ viewer: { status: 200 }, }, }, - { - method: "GET", - endpoint: "/programs/acme/analytics", - queryParams: { linkId: "{{inaccessibleLinkId}}" }, - roles: { - owner: { status: 200 }, - member: { status: 404, code: "not_found" }, - viewer: { status: 200 }, - }, - }, - { - method: "GET", - endpoint: "/programs/acme/events", - queryParams: { linkId: "{{inaccessibleLinkId}}" }, - roles: { - owner: { status: 200 }, - member: { status: 404, code: "not_found" }, - viewer: { status: 200 }, - }, - }, + // { + // method: "GET", + // endpoint: "/programs/acme/analytics", + // queryParams: { linkId: "{{inaccessibleLinkId}}" }, + // roles: { + // owner: { status: 200 }, + // member: { status: 404, code: "not_found" }, + // viewer: { status: 200 }, + // }, + // }, + // { + // method: "GET", + // endpoint: "/programs/acme/events", + // queryParams: { linkId: "{{inaccessibleLinkId}}" }, + // roles: { + // owner: { status: 200 }, + // member: { status: 404, code: "not_found" }, + // viewer: { status: 200 }, + // }, + // }, ]; function api(request: APIRequestContext) { From efa952d46d4f2dcf6de755b3bb2e67fbc1a20125 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 10 Apr 2026 15:50:50 +0530 Subject: [PATCH 80/86] Update playwright.yaml --- .github/workflows/playwright.yaml | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/playwright.yaml b/.github/workflows/playwright.yaml index 97bcea3a08c..f35821dcc04 100644 --- a/.github/workflows/playwright.yaml +++ b/.github/workflows/playwright.yaml @@ -35,9 +35,6 @@ jobs: E2E_PARTNER_EMAIL: "partner1@dub-internal-test.com" E2E_PARTNER_PASSWORD: "password" - TINYBIRD_API_KEY: "xx" - TINYBIRD_API_URL: "xx" - # serverless-redis-http (SRH) — must match jobs.e2e.services.srh env SRH_TOKEN: "e2e_srh_token" UPSTASH_REDIS_REST_URL: "http://127.0.0.1:8079" @@ -106,6 +103,13 @@ jobs: ports: - 8079:80 + tinybird: + image: tinybirdco/tinybird-local:latest + env: + COMPATIBILITY_MODE: "1" + ports: + - 7181:7181 + steps: - name: Check out code uses: actions/checkout@v4 @@ -133,6 +137,15 @@ jobs: - name: Install dependencies run: pnpm install + - name: Setup Tinybird Local + working-directory: packages/tinybird + run: | + TINYBIRD_TOKEN=$(curl -s http://localhost:7181/tokens | jq -r ".workspace_admin_token") + echo "TINYBIRD_API_KEY=${TINYBIRD_TOKEN}" >> $GITHUB_ENV + echo "TINYBIRD_API_URL=http://localhost:7181" >> $GITHUB_ENV + npx @tinybirdco/tinybird-cli auth --host http://localhost:7181 --token $TINYBIRD_TOKEN + npx @tinybirdco/tinybird-cli push + - name: Cache Playwright browsers id: playwright-cache uses: actions/cache@v4 From d6a50ec0b36264a065970bff6d8a6d724a57e6f6 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 10 Apr 2026 16:08:36 +0530 Subject: [PATCH 81/86] Refactor RBAC tests to restore commented-out endpoints and enhance onboarding test for visibility checks --- apps/web/playwright/partners/rbac.spec.ts | 76 +++++++++---------- .../playwright/workspaces/onboarding.spec.ts | 4 +- 2 files changed, 41 insertions(+), 39 deletions(-) diff --git a/apps/web/playwright/partners/rbac.spec.ts b/apps/web/playwright/partners/rbac.spec.ts index 28423fc3cec..28bb0ee75b9 100644 --- a/apps/web/playwright/partners/rbac.spec.ts +++ b/apps/web/playwright/partners/rbac.spec.ts @@ -67,24 +67,24 @@ const RBAC_MATRIX: RbacEntry[] = [ viewer: { status: 200 }, }, }, - // { - // method: "GET", - // endpoint: "/programs/acme/analytics", - // roles: { - // owner: { status: 200 }, - // member: { status: 200 }, - // viewer: { status: 200 }, - // }, - // }, - // { - // method: "GET", - // endpoint: "/programs/acme/events", - // roles: { - // owner: { status: 200 }, - // member: { status: 200 }, - // viewer: { status: 200 }, - // }, - // }, + { + method: "GET", + endpoint: "/programs/acme/analytics", + roles: { + owner: { status: 200 }, + member: { status: 200 }, + viewer: { status: 200 }, + }, + }, + { + method: "GET", + endpoint: "/programs/acme/events", + roles: { + owner: { status: 200 }, + member: { status: 200 }, + viewer: { status: 200 }, + }, + }, { method: "GET", endpoint: "/programs/acme/customers", @@ -234,26 +234,26 @@ const RBAC_MATRIX: RbacEntry[] = [ viewer: { status: 200 }, }, }, - // { - // method: "GET", - // endpoint: "/programs/acme/analytics", - // queryParams: { linkId: "{{inaccessibleLinkId}}" }, - // roles: { - // owner: { status: 200 }, - // member: { status: 404, code: "not_found" }, - // viewer: { status: 200 }, - // }, - // }, - // { - // method: "GET", - // endpoint: "/programs/acme/events", - // queryParams: { linkId: "{{inaccessibleLinkId}}" }, - // roles: { - // owner: { status: 200 }, - // member: { status: 404, code: "not_found" }, - // viewer: { status: 200 }, - // }, - // }, + { + method: "GET", + endpoint: "/programs/acme/analytics", + queryParams: { linkId: "{{inaccessibleLinkId}}" }, + roles: { + owner: { status: 200 }, + member: { status: 404, code: "not_found" }, + viewer: { status: 200 }, + }, + }, + { + method: "GET", + endpoint: "/programs/acme/events", + queryParams: { linkId: "{{inaccessibleLinkId}}" }, + roles: { + owner: { status: 200 }, + member: { status: 404, code: "not_found" }, + viewer: { status: 200 }, + }, + }, ]; function api(request: APIRequestContext) { diff --git a/apps/web/playwright/workspaces/onboarding.spec.ts b/apps/web/playwright/workspaces/onboarding.spec.ts index 842e567a102..64558701f85 100644 --- a/apps/web/playwright/workspaces/onboarding.spec.ts +++ b/apps/web/playwright/workspaces/onboarding.spec.ts @@ -54,7 +54,9 @@ test("complete workspace onboarding", async ({ page }) => { // Success page await page.waitForURL(/\/onboarding\/success/); await expect( - page.getByText(`The ${workspaceName} workspace has been created`), + page.getByRole("heading", { + name: `The ${workspaceName} workspace has been created`, + }), ).toBeVisible(); await expect(page.getByText("Complete setup")).toBeVisible(); From 97c0398a2267773ee1f509786554a1074d4467d3 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Fri, 10 Apr 2026 16:19:33 +0530 Subject: [PATCH 82/86] Update playwright.yaml --- .github/workflows/playwright.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/playwright.yaml b/.github/workflows/playwright.yaml index f35821dcc04..8b55854a3fb 100644 --- a/.github/workflows/playwright.yaml +++ b/.github/workflows/playwright.yaml @@ -140,11 +140,12 @@ jobs: - name: Setup Tinybird Local working-directory: packages/tinybird run: | + pip install tinybird-cli TINYBIRD_TOKEN=$(curl -s http://localhost:7181/tokens | jq -r ".workspace_admin_token") echo "TINYBIRD_API_KEY=${TINYBIRD_TOKEN}" >> $GITHUB_ENV echo "TINYBIRD_API_URL=http://localhost:7181" >> $GITHUB_ENV - npx @tinybirdco/tinybird-cli auth --host http://localhost:7181 --token $TINYBIRD_TOKEN - npx @tinybirdco/tinybird-cli push + tb auth --host http://localhost:7181 --token $TINYBIRD_TOKEN + tb push - name: Cache Playwright browsers id: playwright-cache From 2805ece7adc5cd8b58a221b98c2644f377fad3a9 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 13 Apr 2026 19:42:55 +0530 Subject: [PATCH 83/86] Update Playwright config to refine test ignore pattern, exclude Playwright from TypeScript compilation, and improve conditional logic in partner profile API route. Refactor PartnerLinksSelector dependencies and adjust loading state rendering in PartnerMemberProgramsCell. --- .../partner-profile/payouts/count/route.ts | 5 +++-- .../members/partner-links-selector.tsx | 2 +- .../members/partner-member-programs-cell.tsx | 22 +++++++++---------- apps/web/playwright.config.ts | 2 +- apps/web/scripts/dev/seed.ts | 6 +++++ apps/web/tsconfig.json | 3 ++- 6 files changed, 24 insertions(+), 16 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts b/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts index 295bd056f14..d45fda63690 100644 --- a/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts @@ -19,8 +19,9 @@ export const GET = withPartnerProfile( const where: Prisma.PayoutWhereInput = { partnerId: partner.id, - ...(programId && { programId }), - ...programScopeFilter(partnerUser.assignedProgramIds), + ...(programId + ? { programId } + : programScopeFilter(partnerUser.assignedProgramIds)), }; if (groupBy === "status") { diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-links-selector.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-links-selector.tsx index 67b3037081e..5d24472e7ef 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-links-selector.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-links-selector.tsx @@ -51,7 +51,7 @@ export function PartnerLinksSelector({ if (filtered.length !== selectedLinkIds.length) { setSelectedLinkIds(filtered.length > 0 ? filtered : undefined); } - }, [loading, validLinkIds]); + }, [loading, isAllLinks, selectedLinkIds, validLinkIds, setSelectedLinkIds]); const validSelectedLinkIds = isAllLinks ? undefined diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx index f046188c7e4..5fe662299e8 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx @@ -64,17 +64,6 @@ export function PartnerMemberProgramsCell({ const displayPrograms = programAccess === "all" ? programEnrollments ?? [] : programs; - if (enrollmentsLoading && programAccess === "all") { - return ( -
-
-
- ); - } - // Owner: always show "All" badge, disabled with tooltip if (isOwner) { return ( @@ -90,6 +79,17 @@ export function PartnerMemberProgramsCell({ ); } + if (enrollmentsLoading && programAccess === "all") { + return ( +
+
+
+ ); + } + // Member/Viewer with "all" access if (programAccess === "all") { return ( diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index f5ebe405eb8..fc8d7c18cb9 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -32,7 +32,7 @@ export default defineConfig({ storageState: "playwright/.auth/partner.json", }, testDir: "./playwright/partners", - testIgnore: /(auth\.setup|rbac)\.ts/, + testIgnore: /(auth\.setup|rbac)(\.spec)?\.ts$/, dependencies: ["partner-setup"], }, // Partner RBAC tests diff --git a/apps/web/scripts/dev/seed.ts b/apps/web/scripts/dev/seed.ts index 0a8946e036e..3ccf9879de8 100644 --- a/apps/web/scripts/dev/seed.ts +++ b/apps/web/scripts/dev/seed.ts @@ -153,6 +153,12 @@ function parseCliArgs(argv: string[]) { i++; continue; } + + if (a.startsWith("-")) { + throw new Error( + `Unknown argument: ${a}. Supported flags: --truncate, --workspace / -w .`, + ); + } } return { diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index cb1ce2177d6..56598fc482e 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -51,6 +51,7 @@ "../../packages/ui/src/hooks/use-pagination.ts" ], "exclude": [ - "node_modules" + "node_modules", + "playwright" ] } \ No newline at end of file From 6a14bb56aa42367c2e7f43e539ce4c3ad3442f9c Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 13 Apr 2026 21:08:54 +0530 Subject: [PATCH 84/86] Pass program and link scope objects through partner profile auth --- .../api/cron/export/events/partner/route.ts | 8 +--- .../partner-profile/messages/count/route.ts | 4 +- .../api/partner-profile/messages/route.ts | 4 +- .../partner-profile/payouts/count/route.ts | 2 +- .../(ee)/api/partner-profile/payouts/route.ts | 2 +- .../[programId]/analytics/export/route.ts | 11 ++--- .../programs/[programId]/analytics/route.ts | 11 ++--- .../customers/[customerId]/route.ts | 8 ++-- .../[programId]/customers/count/route.ts | 11 ++--- .../programs/[programId]/customers/route.ts | 2 +- .../[programId]/earnings/count/route.ts | 2 +- .../programs/[programId]/earnings/route.ts | 2 +- .../[programId]/earnings/timeseries/route.ts | 2 +- .../[programId]/events/export/route.ts | 14 ++----- .../programs/[programId]/events/route.ts | 11 ++--- .../programs/[programId]/links/route.ts | 4 +- .../programs/[programId]/route.ts | 4 +- .../partner-profile/programs/count/route.ts | 4 +- .../api/partner-profile/programs/route.ts | 12 ++---- .../get-partner-earnings-timeseries.ts | 10 ++--- .../auth/partner-users/link-scope-filter.ts | 18 ++++---- .../partner-users/program-scope-filter.ts | 10 +++-- .../auth/partner-users/throw-if-no-access.ts | 25 +++++------ apps/web/lib/auth/partner.ts | 42 +++++++++---------- 24 files changed, 95 insertions(+), 128 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/export/events/partner/route.ts b/apps/web/app/(ee)/api/cron/export/events/partner/route.ts index 413cfa5c06e..0315796b4af 100644 --- a/apps/web/app/(ee)/api/cron/export/events/partner/route.ts +++ b/apps/web/app/(ee)/api/cron/export/events/partner/route.ts @@ -78,17 +78,13 @@ export async function POST(req: Request) { return logAndRespond(`User ${userId} has no email. Skipping the export.`); } - const assignedLinkIds = partnerUser.assignedLinks.map( - ({ linkId }) => linkId, - ); - const { program, links, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId, programId, include: { program: true, - links: linkIncludeFilter(assignedLinkIds), + links: linkIncludeFilter(partnerUser.assignedLinks), }, }); @@ -134,7 +130,7 @@ export async function POST(req: Request) { ...(parsedParams.linkId ? { linkId: parsedParams.linkId } : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING && - assignedLinkIds.length === 0 + partnerUser.assignedLinks.length === 0 ? { partnerId } : { linkId: parseFilterValue(links.map((link) => link.id)) }), dataAvailableFrom: program.startedAt ?? program.createdAt, diff --git a/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts b/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts index 5ab20d9d1ad..6e718fc21d7 100644 --- a/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts @@ -6,7 +6,7 @@ import { NextResponse } from "next/server"; // GET /api/partner-profile/messages/count - count messages for a partner export const GET = withPartnerProfile( - async ({ partner, searchParams, partnerUser: { assignedProgramIds } }) => { + async ({ partner, searchParams, partnerUser }) => { const { unread } = countMessagesQuerySchema.parse(searchParams); const count = await prisma.message.count({ @@ -23,7 +23,7 @@ export const GET = withPartnerProfile( not: null, }, }), - ...programScopeFilter(assignedProgramIds), + ...programScopeFilter(partnerUser.assignedPrograms), }, }); diff --git a/apps/web/app/(ee)/api/partner-profile/messages/route.ts b/apps/web/app/(ee)/api/partner-profile/messages/route.ts index 154f7677033..59b2c2648de 100644 --- a/apps/web/app/(ee)/api/partner-profile/messages/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/messages/route.ts @@ -81,10 +81,10 @@ export const GET = withPartnerProfile( }, }, ], - ...(partnerUser.assignedProgramIds + ...(partnerUser.assignedPrograms ? { id: { - in: partnerUser.assignedProgramIds, + in: partnerUser.assignedPrograms.map(({ id }) => id), }, } : {}), diff --git a/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts b/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts index d45fda63690..3a65be44e89 100644 --- a/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts @@ -21,7 +21,7 @@ export const GET = withPartnerProfile( partnerId: partner.id, ...(programId ? { programId } - : programScopeFilter(partnerUser.assignedProgramIds)), + : programScopeFilter(partnerUser.assignedPrograms)), }; if (groupBy === "status") { diff --git a/apps/web/app/(ee)/api/partner-profile/payouts/route.ts b/apps/web/app/(ee)/api/partner-profile/payouts/route.ts index 56323f3f985..a1e4ac49438 100644 --- a/apps/web/app/(ee)/api/partner-profile/payouts/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/payouts/route.ts @@ -30,7 +30,7 @@ export const GET = withPartnerProfile( partnerId: partner.id, ...(programId && { programId }), ...(status && { status }), - ...programScopeFilter(partnerUser.assignedProgramIds), + ...programScopeFilter(partnerUser.assignedPrograms), }, include: { program: true, diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts index 5e007c5aaf9..c64a826307f 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts @@ -17,19 +17,14 @@ import JSZip from "jszip"; // GET /api/partner-profile/programs/[programId]/analytics/export – get export data for partner profile analytics export const GET = withPartnerProfile( - async ({ - partner, - params, - searchParams, - partnerUser: { assignedLinkIds }, - }) => { + async ({ partner, params, searchParams, partnerUser }) => { const { program, links, totalCommissions } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, - links: linkIncludeFilter(assignedLinkIds), + links: linkIncludeFilter(partnerUser.assignedLinks), }, }); @@ -122,7 +117,7 @@ export const GET = withPartnerProfile( ...(parsedParams.linkId ? { linkId: parsedParams.linkId } : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING && - assignedLinkIds === undefined + partnerUser.assignedLinks === undefined ? { partnerId: partner.id } : { linkId: parseFilterValue(links.map((link) => link.id)) }), dataAvailableFrom: program.startedAt ?? program.createdAt, diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts index 67e0e23de84..e71d2e8d826 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts @@ -15,19 +15,14 @@ import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/[programId]/analytics – get analytics for a program enrollment link export const GET = withPartnerProfile( - async ({ - partner, - params, - searchParams, - partnerUser: { assignedLinkIds }, - }) => { + async ({ partner, params, searchParams, partnerUser }) => { const { program, links, totalCommissions } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, - links: linkIncludeFilter(assignedLinkIds), + links: linkIncludeFilter(partnerUser.assignedLinks), }, }); @@ -97,7 +92,7 @@ export const GET = withPartnerProfile( ...(parsedParams.linkId ? { linkId: parsedParams.linkId } : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING && - assignedLinkIds === undefined + partnerUser.assignedLinks === undefined ? { partnerId: partner.id } : { linkId: parseFilterValue(links.map((link) => link.id)) }), dataAvailableFrom: program.startedAt ?? program.createdAt, diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts index 00ba51d0abc..0778a81b2ee 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts @@ -19,7 +19,7 @@ import * as z from "zod/v4"; // GET /api/partner-profile/programs/:programId/customers/:customerId – Get a customer by ID export const GET = withPartnerProfile( - async ({ partner, params, partnerUser: { assignedLinkIds } }) => { + async ({ partner, params, partnerUser }) => { const { customerId, programId } = params; const { program, links, totalCommissions, customerDataSharingEnabledAt } = @@ -28,7 +28,7 @@ export const GET = withPartnerProfile( programId: programId, include: { program: true, - links: linkIncludeFilter(assignedLinkIds), + links: linkIncludeFilter(partnerUser.assignedLinks), }, }); @@ -70,9 +70,9 @@ export const GET = withPartnerProfile( } if ( - assignedLinkIds && + partnerUser.assignedLinks && customer.linkId && - !assignedLinkIds.includes(customer.linkId) + !partnerUser.assignedLinks.some(({ id }) => id === customer.linkId) ) { throw new DubApiError({ code: "forbidden", diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/count/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/count/route.ts index 428711c9094..7a14c0c3690 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/count/route.ts @@ -17,12 +17,7 @@ import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/:programId/customers/count – Get customer counts grouped by a field export const GET = withPartnerProfile( - async ({ - partner, - params, - searchParams, - partnerUser: { assignedLinkIds }, - }) => { + async ({ partner, params, searchParams, partnerUser }) => { const { programId } = params; const { search, country, linkId, groupBy } = getPartnerCustomersCountQuerySchema.parse(searchParams); @@ -33,7 +28,7 @@ export const GET = withPartnerProfile( programId: programId, include: { program: true, - links: linkIncludeFilter(assignedLinkIds), + links: linkIncludeFilter(partnerUser.assignedLinks), }, }); @@ -71,7 +66,7 @@ export const GET = withPartnerProfile( name: { search: sanitizeFullTextSearch(search) }, } : {}), - ...linkScopeFilter(assignedLinkIds), + ...linkScopeFilter(partnerUser.assignedLinks), }; // Get customer count by country diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/route.ts index 400e74747d0..44ec16a8cf8 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/route.ts @@ -66,7 +66,7 @@ export const GET = withPartnerProfile( programId: program.id, projectId: program.workspaceId, ...(country && { country }), - ...(linkId ? { linkId } : linkScopeFilter(partnerUser.assignedLinkIds)), + ...(linkId ? { linkId } : linkScopeFilter(partnerUser.assignedLinks)), // Only allow search if customer data sharing is enabled ...(search && customerDataSharingEnabledAt ? search.includes("@") diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts index 6b662a2493b..27da4f2169e 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts @@ -57,7 +57,7 @@ export const GET = withPartnerProfile( gte: startDate, lte: endDate, }, - ...linkScopeFilter(partnerUser.assignedLinkIds), + ...linkScopeFilter(partnerUser.assignedLinks), }; if (groupBy) { diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts index 7396f9b5d6c..5b66fd6a298 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts @@ -27,7 +27,7 @@ export const GET = withPartnerProfile( programId, partnerId, customerDataSharingEnabledAt, - linkIds: partnerUser.assignedLinkIds, + linkIds: partnerUser.assignedLinks?.map(({ id }) => id), }); return NextResponse.json(earnings); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts index a7594c4601e..b977d2e8ec4 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts @@ -18,7 +18,7 @@ export const GET = withPartnerProfile( partnerId: partner.id, programId: params.programId, filters, - assignedLinkIds: partnerUser.assignedLinkIds, + assignedLinks: partnerUser.assignedLinks, }); return NextResponse.json(timeseries); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts index 98d020c975a..87593998f5f 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts @@ -35,20 +35,14 @@ const MAX_EVENTS_TO_EXPORT = 1000; // GET /api/partner-profile/programs/[programId]/events/export – get export data for partner profile events export const GET = withPartnerProfile( - async ({ - partner, - params, - searchParams, - session, - partnerUser: { assignedLinkIds }, - }) => { + async ({ partner, params, searchParams, session, partnerUser }) => { const { program, links, totalCommissions, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, - links: linkIncludeFilter(assignedLinkIds), + links: linkIncludeFilter(partnerUser.assignedLinks), }, }); @@ -163,7 +157,7 @@ export const GET = withPartnerProfile( ...(parsedParams.linkId ? { linkId: parsedParams.linkId } : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING && - assignedLinkIds === undefined + partnerUser.assignedLinks === undefined ? { partnerId: partner.id } : { linkId: parseFilterValue(links.map((link) => link.id)) }), dataAvailableFrom: program.startedAt ?? program.createdAt, @@ -204,7 +198,7 @@ export const GET = withPartnerProfile( ...(parsedParams.linkId ? { linkId: parsedParams.linkId } : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING && - assignedLinkIds === undefined + partnerUser.assignedLinks === undefined ? { partnerId: partner.id } : { linkId: parseFilterValue(links.map((link) => link.id)) }), limit: MAX_EVENTS_TO_EXPORT, diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/route.ts index 2412be191c8..2e3988ce098 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/route.ts @@ -21,19 +21,14 @@ import * as z from "zod/v4"; // GET /api/partner-profile/programs/[programId]/events – get events for a program enrollment link export const GET = withPartnerProfile( - async ({ - partner, - params, - searchParams, - partnerUser: { assignedLinkIds }, - }) => { + async ({ partner, params, searchParams, partnerUser }) => { const { program, links, totalCommissions, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, - links: linkIncludeFilter(assignedLinkIds), + links: linkIncludeFilter(partnerUser.assignedLinks), }, }); @@ -110,7 +105,7 @@ export const GET = withPartnerProfile( ...(parsedParams.linkId ? { linkId: parsedParams.linkId } : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING && - assignedLinkIds === undefined + partnerUser.assignedLinks === undefined ? { partnerId: partner.id } : { linkId: parseFilterValue(links.map((link) => link.id)) }), dataAvailableFrom: program.startedAt ?? program.createdAt, diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts index 59d84668b4d..13c9d9b7406 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts @@ -18,12 +18,12 @@ import * as z from "zod/v4"; // GET /api/partner-profile/programs/[programId]/links - get a partner's links in a program export const GET = withPartnerProfile( - async ({ partner, params, partnerUser: { assignedLinkIds } }) => { + async ({ partner, params, partnerUser }) => { const { links, discountCodes } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { - links: linkIncludeFilter(assignedLinkIds), + links: linkIncludeFilter(partnerUser.assignedLinks), discountCodes: true, }, }); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/route.ts index c74b383e3c4..0672b9cd9f8 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/route.ts @@ -7,14 +7,14 @@ import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/[programId] – get a partner's enrollment in a program export const GET = withPartnerProfile( - async ({ partner, params, partnerUser: { assignedLinkIds } }) => { + async ({ partner, params, partnerUser }) => { const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, partner: true, - links: linkIncludeFilter(assignedLinkIds), + links: linkIncludeFilter(partnerUser.assignedLinks), clickReward: true, leadReward: true, saleReward: true, diff --git a/apps/web/app/(ee)/api/partner-profile/programs/count/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/count/route.ts index f8b73c90879..be3168b10db 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/count/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/count/route.ts @@ -6,7 +6,7 @@ import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/count - count program enrollments for a given partnerId export const GET = withPartnerProfile( - async ({ partner, searchParams, partnerUser: { assignedProgramIds } }) => { + async ({ partner, searchParams, partnerUser }) => { const { status } = partnerProfileProgramsCountQuerySchema.parse(searchParams); @@ -14,7 +14,7 @@ export const GET = withPartnerProfile( where: { partnerId: partner.id, ...(status && { status }), - ...programScopeFilter(assignedProgramIds), + ...programScopeFilter(partnerUser.assignedPrograms), }, }); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/route.ts index 7d73bfcff0a..090701916ee 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/route.ts @@ -9,11 +9,7 @@ import * as z from "zod/v4"; // GET /api/partner-profile/programs - get all program enrollments for a given partnerId export const GET = withPartnerProfile( - async ({ - partner, - searchParams, - partnerUser: { assignedProgramIds, assignedLinkIds }, - }) => { + async ({ partner, searchParams, partnerUser }) => { const { includeRewardsDiscounts, status } = partnerProfileProgramsQuerySchema.parse(searchParams); @@ -24,15 +20,15 @@ export const GET = withPartnerProfile( program: { deactivatedAt: null, }, - ...programScopeFilter(assignedProgramIds), + ...programScopeFilter(partnerUser.assignedPrograms), }, include: { links: { - ...(assignedLinkIds + ...(partnerUser.assignedLinks ? { where: { id: { - in: assignedLinkIds, + in: partnerUser.assignedLinks.map(({ id }) => id), }, }, } diff --git a/apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts b/apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts index 4b31b4717a5..7f5d99d34c3 100644 --- a/apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts +++ b/apps/web/lib/api/partner-profile/get-partner-earnings-timeseries.ts @@ -4,7 +4,7 @@ import { linkIncludeFilter } from "@/lib/auth/partner-users/link-scope-filter"; import { sqlGranularityMap } from "@/lib/planetscale/granularity"; import { getPartnerEarningsTimeseriesSchema } from "@/lib/zod/schemas/partner-profile"; import { prisma } from "@dub/prisma"; -import { Prisma } from "@dub/prisma/client"; +import { Link, Prisma } from "@dub/prisma/client"; import { format } from "date-fns"; import * as z from "zod/v4"; @@ -12,14 +12,14 @@ interface GetPartnerEarningsTimeseriesParams { partnerId: string; programId: string; filters: z.infer; - assignedLinkIds?: string[]; + assignedLinks?: Pick[]; } export async function getPartnerEarningsTimeseries({ partnerId, programId, filters, - assignedLinkIds, + assignedLinks, }: GetPartnerEarningsTimeseriesParams) { const { groupBy, @@ -39,7 +39,7 @@ export async function getPartnerEarningsTimeseries({ programId: programId, include: { program: true, - links: linkIncludeFilter(assignedLinkIds), + links: linkIncludeFilter(assignedLinks), }, }); @@ -69,7 +69,7 @@ export async function getPartnerEarningsTimeseries({ ${type ? Prisma.sql`AND type = ${type}` : Prisma.sql``} ${payoutId ? Prisma.sql`AND payoutId = ${payoutId}` : Prisma.sql``} ${linkId ? Prisma.sql`AND linkId = ${linkId}` : Prisma.sql``} - ${assignedLinkIds && assignedLinkIds.length > 0 ? Prisma.sql`AND linkId IN (${Prisma.join(assignedLinkIds)})` : Prisma.sql``} + ${assignedLinks && assignedLinks.length > 0 ? Prisma.sql`AND linkId IN (${Prisma.join(assignedLinks.map(({ id }) => id))})` : Prisma.sql``} ${customerId ? Prisma.sql`AND customerId = ${customerId}` : Prisma.sql``} ${status ? Prisma.sql`AND status = ${status}` : Prisma.sql``} GROUP BY start${groupBy ? (groupBy === "type" ? Prisma.sql`, type` : Prisma.sql`, linkId`) : Prisma.sql``} diff --git a/apps/web/lib/auth/partner-users/link-scope-filter.ts b/apps/web/lib/auth/partner-users/link-scope-filter.ts index c4d5ec61071..ca15da68ba1 100644 --- a/apps/web/lib/auth/partner-users/link-scope-filter.ts +++ b/apps/web/lib/auth/partner-users/link-scope-filter.ts @@ -1,17 +1,21 @@ +import { Link } from "@dub/prisma/client"; + /** * Prisma `where` fragment for filtering by linkId. - * Use in direct queries: where: { ...linkScopeFilter(assignedLinkIds) } + * Use in direct queries: where: { ...linkScopeFilter(assignedLinks) } */ -export function linkScopeFilter(assignedLinkIds: string[] | undefined): { +export function linkScopeFilter( + assignedLinks: Pick[] | undefined, +): { linkId?: { in: string[] }; } { - if (assignedLinkIds === undefined) { + if (assignedLinks === undefined) { return {}; } return { linkId: { - in: assignedLinkIds, + in: assignedLinks.map(({ id }) => id), }, }; } @@ -21,16 +25,16 @@ export function linkScopeFilter(assignedLinkIds: string[] | undefined): { * Returns `true` (all links) or `{ where: { id: { in: ... } } }` (scoped). */ export function linkIncludeFilter( - assignedLinkIds: string[] | undefined, + assignedLinks: Pick[] | undefined, ): true | { where: { id: { in: string[] } } } { - if (assignedLinkIds === undefined) { + if (assignedLinks === undefined) { return true; } return { where: { id: { - in: assignedLinkIds, + in: assignedLinks.map(({ id }) => id), }, }, }; diff --git a/apps/web/lib/auth/partner-users/program-scope-filter.ts b/apps/web/lib/auth/partner-users/program-scope-filter.ts index 0f4a298b193..d0d0bc7a34a 100644 --- a/apps/web/lib/auth/partner-users/program-scope-filter.ts +++ b/apps/web/lib/auth/partner-users/program-scope-filter.ts @@ -1,13 +1,17 @@ -export function programScopeFilter(assignedProgramIds: string[] | undefined): { +import { Program } from "@dub/prisma/client"; + +export function programScopeFilter( + assignedPrograms: Pick[] | undefined, +): { programId?: { in: string[] }; } { - if (assignedProgramIds === undefined) { + if (assignedPrograms === undefined) { return {}; } return { programId: { - in: assignedProgramIds, + in: assignedPrograms.map(({ id }) => id), }, }; } diff --git a/apps/web/lib/auth/partner-users/throw-if-no-access.ts b/apps/web/lib/auth/partner-users/throw-if-no-access.ts index 2c5c056a061..f82a3a5d0e4 100644 --- a/apps/web/lib/auth/partner-users/throw-if-no-access.ts +++ b/apps/web/lib/auth/partner-users/throw-if-no-access.ts @@ -1,9 +1,9 @@ import { DubApiError } from "@/lib/api/errors"; +import { Link, Program } from "@dub/prisma/client"; interface PartnerUserAccess { - assignedProgramIds: string[] | undefined; - assignedProgramSlugs: string[] | undefined; - assignedLinkIds: string[] | undefined; + assignedPrograms: Pick[] | undefined; + assignedLinks: Pick[] | undefined; } export function throwIfNoProgramAccess({ @@ -13,15 +13,12 @@ export function throwIfNoProgramAccess({ }: { programId?: string; programSlug?: string; - partnerUser: Pick< - PartnerUserAccess, - "assignedProgramIds" | "assignedProgramSlugs" - >; + partnerUser: Partial>; }) { if ( programId && - partnerUser.assignedProgramIds && - !partnerUser.assignedProgramIds.includes(programId) + partnerUser.assignedPrograms && + !partnerUser.assignedPrograms.some(({ id }) => id === programId) ) { throw new DubApiError({ code: "forbidden", @@ -31,8 +28,8 @@ export function throwIfNoProgramAccess({ if ( programSlug && - partnerUser.assignedProgramSlugs && - !partnerUser.assignedProgramSlugs.includes(programSlug) + partnerUser.assignedPrograms && + !partnerUser.assignedPrograms.some(({ slug }) => slug === programSlug) ) { throw new DubApiError({ code: "forbidden", @@ -46,12 +43,12 @@ export function throwIfNoLinkAccess({ partnerUser, }: { linkId: string | undefined | null; - partnerUser: Pick; + partnerUser: Partial>; }) { if ( linkId && - partnerUser.assignedLinkIds && - !partnerUser.assignedLinkIds.includes(linkId) + partnerUser.assignedLinks && + !partnerUser.assignedLinks.some(({ id }) => id === linkId) ) { throw new DubApiError({ code: "forbidden", diff --git a/apps/web/lib/auth/partner.ts b/apps/web/lib/auth/partner.ts index e0a113474ab..8b1ac62c48c 100644 --- a/apps/web/lib/auth/partner.ts +++ b/apps/web/lib/auth/partner.ts @@ -3,7 +3,7 @@ import { withAxiom } from "@/lib/axiom/server"; import { PartnerBetaFeatures, PartnerProps } from "@/lib/types"; import { flattenVeriffMetadata } from "@/lib/veriff/veriff-metadata"; import { prisma } from "@dub/prisma"; -import { PartnerUser } from "@dub/prisma/client"; +import { Link, PartnerUser, Program } from "@dub/prisma/client"; import { getSearchParams, PARTNERS_DOMAIN } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { headers } from "next/headers"; @@ -37,9 +37,8 @@ interface WithPartnerProfileHandler { PartnerUser, "id" | "userId" | "role" | "programAccess" > & { - assignedProgramIds: string[] | undefined; - assignedProgramSlugs: string[] | undefined; - assignedLinkIds: string[] | undefined; + assignedPrograms?: Pick[]; + assignedLinks?: Pick[]; }; }): Promise; } @@ -280,38 +279,39 @@ export const withPartnerProfile = ( } } - const assignedProgramIds = + const assignedPrograms = partnerUser.programAccess === "all" ? undefined - : partnerUser.assignedPrograms.map(({ program }) => program.id); - const assignedProgramSlugs = - partnerUser.programAccess === "all" - ? undefined - : partnerUser.assignedPrograms.map(({ program }) => program.slug); - const assignedLinkIds = + : partnerUser.assignedPrograms.map(({ program }) => program); + + const assignedLinks = partnerUser.programAccess === "all" ? undefined - : partnerUser.assignedLinks.map(({ linkId }) => linkId); + : partnerUser.assignedLinks.map(({ linkId }) => ({ id: linkId })); // If the user is scoped to specific programs and the route has a programId param, // verify they have access to this program (param may be program id or slug) if ( params.programId && - assignedProgramIds !== undefined && - assignedProgramSlugs !== undefined + assignedPrograms !== undefined && + assignedPrograms.length > 0 ) { let hasAccess = false; if (params.programId.startsWith("prog_")) { - hasAccess = assignedProgramIds.includes(params.programId); + hasAccess = assignedPrograms.some( + ({ id }) => id === params.programId, + ); } else { - hasAccess = assignedProgramSlugs?.includes(params.programId); + hasAccess = assignedPrograms.some( + ({ slug }) => slug === params.programId, + ); } if (!hasAccess) { throw new DubApiError({ code: "not_found", - message: "Program not found.", + message: `You don't have access to this program.`, }); } } @@ -347,12 +347,8 @@ export const withPartnerProfile = ( userId: partnerUser.userId, role: partnerUser.role, programAccess: partnerUser.programAccess, - assignedProgramIds, - assignedProgramSlugs, - assignedLinkIds: - assignedLinkIds && assignedLinkIds.length > 0 - ? assignedLinkIds - : undefined, + assignedPrograms, + assignedLinks, }, headers: responseHeaders, }); From 236f38f7fc9ffcfa25694fd1d0d0d980ab5fd35e Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 13 Apr 2026 21:09:05 +0530 Subject: [PATCH 85/86] Format --- apps/web/lib/api/payouts/get-eligible-payouts.ts | 5 ++--- apps/web/ui/partners/confirm-payouts-sheet.tsx | 16 +++++++++------- apps/web/ui/shared/inline-badge-popover.tsx | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/apps/web/lib/api/payouts/get-eligible-payouts.ts b/apps/web/lib/api/payouts/get-eligible-payouts.ts index c49109211f8..9f25a4811b9 100644 --- a/apps/web/lib/api/payouts/get-eligible-payouts.ts +++ b/apps/web/lib/api/payouts/get-eligible-payouts.ts @@ -10,9 +10,8 @@ import { getEffectivePayoutMode } from "./get-effective-payout-mode"; import { getPayoutEligibilityFilter } from "./payout-eligibility-filter"; import { payoutIdSelectionWhere } from "./payout-id-selection-where"; -interface GetEligiblePayoutsProps extends z.output< - typeof eligiblePayoutsQuerySchema -> { +interface GetEligiblePayoutsProps + extends z.output { program: Pick; workspace: Pick; } diff --git a/apps/web/ui/partners/confirm-payouts-sheet.tsx b/apps/web/ui/partners/confirm-payouts-sheet.tsx index a2f062390d9..0c6eec2c62f 100644 --- a/apps/web/ui/partners/confirm-payouts-sheet.tsx +++ b/apps/web/ui/partners/confirm-payouts-sheet.tsx @@ -185,8 +185,8 @@ function ConfirmPayoutsSheetContent() { ); const eligiblePayoutsTableRowCount = isExplicitSelectionMode - ? (eligiblePayoutsSummaryCount?.count ?? 0) - : (eligiblePayoutsTableTotalCount?.count ?? 0); + ? eligiblePayoutsSummaryCount?.count ?? 0 + : eligiblePayoutsTableTotalCount?.count ?? 0; const { data: payoutsCount } = useSWR< { @@ -233,10 +233,12 @@ function ConfirmPayoutsSheetContent() { isLoading: eligiblePayoutsLoading, } = useSWR( defaultProgramId - ? `/api/programs/${defaultProgramId}/payouts/eligible?${new URLSearchParams({ - ...tableQuery, - page: pagination.pageIndex.toString(), - }).toString()}` + ? `/api/programs/${defaultProgramId}/payouts/eligible?${new URLSearchParams( + { + ...tableQuery, + page: pagination.pageIndex.toString(), + }, + ).toString()}` : null, fetcher, { @@ -594,7 +596,7 @@ function ConfirmPayoutsSheetContent() { ), }, ...(payoutsIncludedInInvoice.length > 0 && - payoutsIncludedInInvoice.some(isExternalPayout) + payoutsIncludedInInvoice.some(isExternalPayout) ? [ { key: "External Amount", diff --git a/apps/web/ui/shared/inline-badge-popover.tsx b/apps/web/ui/shared/inline-badge-popover.tsx index 1b9f78d637a..61932f84626 100644 --- a/apps/web/ui/shared/inline-badge-popover.tsx +++ b/apps/web/ui/shared/inline-badge-popover.tsx @@ -62,7 +62,7 @@ export function InlineBadgePopover({ align="start" content={ - + {children} From e9f5bc427f74c2de0dfccf18727e9847853b1065 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 13 Apr 2026 21:52:11 +0530 Subject: [PATCH 86/86] Update route.ts --- apps/web/app/(ee)/api/partner-profile/payouts/route.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/payouts/route.ts b/apps/web/app/(ee)/api/partner-profile/payouts/route.ts index a1e4ac49438..b1de7b79291 100644 --- a/apps/web/app/(ee)/api/partner-profile/payouts/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/payouts/route.ts @@ -28,9 +28,10 @@ export const GET = withPartnerProfile( const payouts = await prisma.payout.findMany({ where: { partnerId: partner.id, - ...(programId && { programId }), + ...(programId + ? { programId } + : programScopeFilter(partnerUser.assignedPrograms)), ...(status && { status }), - ...programScopeFilter(partnerUser.assignedPrograms), }, include: { program: true,