diff --git a/.github/workflows/playwright.yaml b/.github/workflows/playwright.yaml index b6790749e3..8b55854a3f 100644 --- a/.github/workflows/playwright.yaml +++ b/.github/workflows/playwright.yaml @@ -35,12 +35,6 @@ jobs: E2E_PARTNER_EMAIL: "partner1@dub-internal-test.com" E2E_PARTNER_PASSWORD: "password" - 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" @@ -109,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 @@ -136,6 +137,16 @@ jobs: - name: Install dependencies run: pnpm install + - 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 + tb auth --host http://localhost:7181 --token $TINYBIRD_TOKEN + tb push + - name: Cache Playwright browsers id: playwright-cache uses: actions/cache@v4 @@ -155,10 +166,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 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 c59cfd9372..0315796b4a 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"; @@ -48,19 +49,31 @@ 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, + assignedLinks: true, + user: { + select: { + 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.`); } @@ -71,7 +84,7 @@ export async function POST(req: Request) { programId, include: { program: true, - links: true, + links: linkIncludeFilter(partnerUser.assignedLinks), }, }); @@ -116,7 +129,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 && + 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/invites/route.ts b/apps/web/app/(ee)/api/partner-profile/invites/route.ts index 7487a46fdd..27b8ea5126 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,8 @@ 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/messages/count/route.ts b/apps/web/app/(ee)/api/partner-profile/messages/count/route.ts index c1d422742b..6e718fc21d 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,28 +1,35 @@ 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 }) => { - const { unread } = countMessagesQuerySchema.parse(searchParams); +export const GET = withPartnerProfile( + async ({ partner, searchParams, partnerUser }) => { + 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, + ...(unread !== undefined && { + // Only count messages from the program + senderPartnerId: null, + readInApp: unread + ? // Only count unread messages + null + : { + // Only count read messages + not: null, + }, + }), + ...programScopeFilter(partnerUser.assignedPrograms), + }, + }); - return NextResponse.json(count); -}); + 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 25fb19a366..59b2c2648d 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,5 @@ import { withPartnerProfile } from "@/lib/auth/partner"; +import { throwIfNoProgramAccess } from "@/lib/auth/partner-users/throw-if-no-access"; import { ProgramMessagesSchema, getProgramMessagesQuerySchema, @@ -7,120 +8,136 @@ 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, partnerUser }) => { + 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", - }, - }, + throwIfNoProgramAccess({ + programSlug, + partnerUser, + }); - ...(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, + const programs = await prisma.program.findMany({ + where: { + // Partner is not banned from the program + partners: { + none: { + partnerId: partner.id, + status: "banned", + }, + }, + ...(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, + ], + ...(partnerUser.assignedPrograms + ? { + id: { + in: partnerUser.assignedPrograms.map(({ id }) => id), + }, + } + : {}), + }), + }, + 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, + })), + ), + ); + }, + { + 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 7875d552e7..3a65be44e8 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,64 +1,78 @@ 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"; 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); +export const GET = withPartnerProfile( + async ({ partner, searchParams, partnerUser }) => { + const { programId, groupBy, status } = + payoutsCountQuerySchema.parse(searchParams); - const where: Prisma.PayoutWhereInput = { - partnerId: partner.id, - ...(programId && { programId }), - }; + throwIfNoProgramAccess({ + programId, + partnerUser, + }); + + const where: Prisma.PayoutWhereInput = { + partnerId: partner.id, + ...(programId + ? { programId } + : programScopeFilter(partnerUser.assignedPrograms)), + }; + + 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, + })); - if (groupBy === "status") { - const payouts = await prisma.payout.groupBy({ - by: ["status"], - where: where, + 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.aggregate({ + where: { + ...where, + status, + }, _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.aggregate({ - where: { - ...where, - status, - }, - _count: true, - _sum: { - amount: true, - }, - }); - - return NextResponse.json([ - { - count: count._count ?? 0, - amount: count._sum?.amount ?? 0, - status: status ?? "all", - }, - ]); -}); + return NextResponse.json([ + { + count: count._count ?? 0, + amount: count._sum?.amount ?? 0, + status: status ?? "all", + }, + ]); + }, + { + 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 f952ef9288..b1de7b7929 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,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"; @@ -7,48 +9,60 @@ 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, partnerUser }) => { + 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, - }, - }); + throwIfNoProgramAccess({ + programId, + partnerUser, + }); - const transformedPayouts = payouts.map((payout) => { - const mode = - payout.mode ?? - getEffectivePayoutMode({ - payoutMode: payout.program.payoutMode, - payoutsEnabledAt: partner.payoutsEnabledAt, - }); + const payouts = await prisma.payout.findMany({ + where: { + partnerId: partner.id, + ...(programId + ? { programId } + : programScopeFilter(partnerUser.assignedPrograms)), + ...(status && { status }), + }, + include: { + program: true, + }, + skip: (page - 1) * pageSize, + take: pageSize, + orderBy: { + [sortBy]: sortOrder, + }, + }); - return { - ...payout, - mode, - traceId: payout.stripePayoutTraceId, - }; - }); + const transformedPayouts = payouts.map((payout) => { + const mode = + payout.mode ?? + getEffectivePayoutMode({ + payoutMode: payout.program.payoutMode, + payoutsEnabledAt: partner.payoutsEnabledAt, + }); - return NextResponse.json( - z.array(PartnerPayoutResponseSchema).parse(transformedPayouts), - ); -}); + return { + ...payout, + mode, + traceId: payout.stripePayoutTraceId, + }; + }); + + return NextResponse.json( + z.array(PartnerPayoutResponseSchema).parse(transformedPayouts), + ); + }, + { + requiredPermission: "payouts.read", + }, +); 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 4f38143433..160be79217 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]/analytics/export/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts index fdab58d685..c64a826307 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, @@ -16,14 +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 }) => { + async ({ partner, params, searchParams, partnerUser }) => { const { program, links, totalCommissions } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, - links: true, + links: linkIncludeFilter(partnerUser.assignedLinks), }, }); @@ -115,7 +116,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 && + 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 442fe0c2ba..e71d2e8d82 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, @@ -14,14 +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 }) => { + async ({ partner, params, searchParams, partnerUser }) => { const { program, links, totalCommissions } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, - links: true, + links: linkIncludeFilter(partnerUser.assignedLinks), }, }); @@ -90,7 +91,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 && + 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 fa95f850c0..0778a81b2e 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, @@ -17,92 +18,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 }) => { + 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: linkIncludeFilter(partnerUser.assignedLinks), + }, + }); - 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 ( + partnerUser.assignedLinks && + customer.linkId && + !partnerUser.assignedLinks.some(({ id }) => id === 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]/customers/count/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/count/route.ts index 7a9160e81b..7a14c0c369 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, @@ -13,7 +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 }) => { + async ({ partner, params, searchParams, partnerUser }) => { const { programId } = params; const { search, country, linkId, groupBy } = getPartnerCustomersCountQuerySchema.parse(searchParams); @@ -24,6 +28,7 @@ export const GET = withPartnerProfile( programId: programId, include: { program: true, + links: linkIncludeFilter(partnerUser.assignedLinks), }, }); @@ -61,6 +66,7 @@ export const GET = withPartnerProfile( name: { search: sanitizeFullTextSearch(search) }, } : {}), + ...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 972dc2be0d..44ec16a8cf 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,8 @@ 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 { LARGE_PROGRAM_IDS, LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS, @@ -20,7 +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 }) => { + async ({ partner, params, searchParams, partnerUser }) => { const { programId } = params; const { search, @@ -32,6 +34,11 @@ export const GET = withPartnerProfile( pageSize, } = getPartnerCustomersQuerySchema.parse(searchParams); + throwIfNoLinkAccess({ + linkId, + partnerUser, + }); + const { program, totalCommissions, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, @@ -59,7 +66,7 @@ export const GET = withPartnerProfile( programId: program.id, projectId: program.workspaceId, ...(country && { country }), - ...(linkId && { linkId }), + ...(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 05b0a467ce..27da4f2169 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,8 @@ 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 { 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"; @@ -10,7 +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 }) => { + async ({ partner, params, searchParams, partnerUser }) => { const { program, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, @@ -39,6 +41,11 @@ export const GET = withPartnerProfile( timezone, }); + throwIfNoLinkAccess({ + linkId, + partnerUser, + }); + const where: Prisma.CommissionWhereInput = { earnings: { not: 0, @@ -50,6 +57,7 @@ export const GET = withPartnerProfile( gte: startDate, lte: endDate, }, + ...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 ab10116d8a..5b66fd6a29 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,12 +1,13 @@ 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 }) => { + async ({ partner, params, searchParams, partnerUser }) => { const { programId, partnerId, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, @@ -16,11 +17,17 @@ export const GET = withPartnerProfile( const parsedQuery = getPartnerEarningsQuerySchema.parse(searchParams); + throwIfNoLinkAccess({ + linkId: parsedQuery.linkId, + partnerUser, + }); + const earnings = await getEarningsForPartner({ ...parsedQuery, programId, partnerId, customerDataSharingEnabledAt, + 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 d1243b97c9..b977d2e8ec 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,17 +1,24 @@ 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 }) => { + async ({ partner, params, searchParams, partnerUser }) => { const filters = getPartnerEarningsTimeseriesSchema.parse(searchParams); + throwIfNoLinkAccess({ + linkId: filters.linkId, + partnerUser, + }); + const timeseries = await getPartnerEarningsTimeseries({ partnerId: partner.id, programId: params.programId, filters, + 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 c004073f05..87593998f5 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, @@ -34,14 +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 }) => { + async ({ partner, params, searchParams, session, partnerUser }) => { const { program, links, totalCommissions, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, - links: true, + links: linkIncludeFilter(partnerUser.assignedLinks), }, }); @@ -155,7 +156,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 && + partnerUser.assignedLinks === undefined ? { partnerId: partner.id } : { linkId: parseFilterValue(links.map((link) => link.id)) }), dataAvailableFrom: program.startedAt ?? program.createdAt, @@ -195,7 +197,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 && + 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 af6281286f..2e3988ce09 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,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 }) => { + async ({ partner, params, searchParams, partnerUser }) => { const { program, links, totalCommissions, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, - links: true, + links: linkIncludeFilter(partnerUser.assignedLinks), }, }); @@ -103,7 +104,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 && + 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/[linkId]/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts index 3eebfcc258..5be01ae69f 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,13 +15,18 @@ 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 }) => { const { url, key, comments } = createPartnerLinkSchema .pick({ url: true, key: true, comments: true }) .parse(await parseRequestBody(req)); const { programId, linkId } = params; + throwIfNoLinkAccess({ + linkId, + partnerUser, + }); + const { program, links, @@ -153,55 +159,72 @@ 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.", - }); - } - - const link = links.find((link) => link.id === linkId); +export const DELETE = withPartnerProfile( + async ({ partner, params, partnerUser }) => { + const { programId, linkId } = params; - if (!link) { - throw new DubApiError({ - code: "not_found", - message: "Link not found.", + throwIfNoLinkAccess({ + linkId, + partnerUser, }); - } - // 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.", + const { links, status } = await getProgramEnrollmentOrThrow({ + partnerId: partner.id, + programId, + include: { + links: true, + }, }); - } - // Delete the link - await deleteLink(link.id); + if (["banned", "deactivated"].includes(status)) { + throw new DubApiError({ + code: "forbidden", + message: "You are banned from this program.", + }); + } + + const link = links.find((link) => link.id === linkId); + + if (!link) { + throw new DubApiError({ + code: "not_found", + message: "Link not found.", + }); + } + + // 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 cfcab2c5da..13c9d9b740 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, @@ -16,32 +17,34 @@ 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 }) => { + const { links, discountCodes } = await getProgramEnrollmentOrThrow({ + partnerId: partner.id, + programId: params.programId, + include: { + links: linkIncludeFilter(partnerUser.assignedLinks), + 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( @@ -151,4 +154,7 @@ export const POST = withPartnerProfile( status: 201, }); }, + { + requiredPermission: "links.write", + }, ); 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 c3e1d9c479..0672b9cd9f 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,51 +1,54 @@ 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"; // 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 }) => { + const programEnrollment = await getProgramEnrollmentOrThrow({ + partnerId: partner.id, + programId: params.programId, + include: { + program: true, + partner: true, + links: linkIncludeFilter(partnerUser.assignedLinks), + 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/app/(ee)/api/partner-profile/programs/count/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/count/route.ts index 7a1183b490..be3168b10d 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,18 +1,23 @@ 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"; // 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, partnerUser }) => { + 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 }), + ...programScopeFilter(partnerUser.assignedPrograms), + }, + }); - 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 e2625420ac..090701916e 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"; @@ -7,77 +8,89 @@ 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, partnerUser }) => { + 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, }, + ...programScopeFilter(partnerUser.assignedPrograms), }, - program: { - include: { - workspace: { - select: { - plan: true, + include: { + links: { + ...(partnerUser.assignedLinks + ? { + where: { + id: { + in: partnerUser.assignedLinks.map(({ id }) => id), + }, + }, + } + : undefined), + 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)/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 0000000000..2cbbfa7e86 --- /dev/null +++ b/apps/web/app/(ee)/api/partner-profile/users/[userId]/programs/route.ts @@ -0,0 +1,201 @@ +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"; +import * as z from "zod/v4"; + +// PUT /api/partner-profile/users/[userId]/programs +export const PUT = withPartnerProfile( + async ({ partner, params, req }) => { + const { userId } = params; + const { programAccess, programIds, linkIds } = + 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.", + }); + } + + const effectiveProgramIds = programAccess === "all" ? [] : programIds; + + // Validate all programIds are programs the partner is enrolled in + if (effectiveProgramIds.length > 0) { + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + partnerId: partner.id, + programId: { + in: effectiveProgramIds, + }, + }, + select: { + programId: true, + }, + }); + + const enrolledProgramIds = new Set( + programEnrollments.map(({ programId }) => programId), + ); + + const invalidIds = effectiveProgramIds.filter( + (id) => !enrolledProgramIds.has(id), + ); + + if (invalidIds.length > 0) { + throw new DubApiError({ + code: "bad_request", + message: `Invalid program IDs: ${invalidIds.join(", ")}`, + }); + } + } + + // 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 ?? [], + ); + + if (allRequestedLinkIds.length > 0) { + const validLinks = await prisma.link.findMany({ + where: { + id: { + in: allRequestedLinkIds, + }, + programId: { + in: effectiveProgramIds, + }, + }, + select: { + id: true, + programId: true, + }, + }); + + 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", + message: `Invalid link IDs: ${invalidIds.join(", ")}`, + }); + } + } + + // 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, + })); + }); + + // Transaction: delete old, create new + await prisma.$transaction(async (tx) => { + await tx.partnerUser.update({ + where: { + id: targetUser.id, + }, + data: { + programAccess, + }, + }); + + // Replace program assignments + await tx.partnerUserProgram.deleteMany({ + where: { + partnerUserId: targetUser.id, + }, + }); + + if (effectiveProgramIds.length > 0) { + await tx.partnerUserProgram.createMany({ + data: effectiveProgramIds.map((programId) => ({ + partnerUserId: targetUser.id, + programId, + })), + }); + } + + // Replace link assignments + await tx.partnerUserLink.deleteMany({ + where: { + partnerUserId: targetUser.id, + }, + }); + + 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( + z.array(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 e993df4a99..85ba622ddd 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"; @@ -40,15 +41,56 @@ export const GET = withPartnerProfile(async ({ partner, searchParams }) => { }, include: { user: true, + assignedPrograms: { + orderBy: { + createdAt: "asc", + }, + include: { + program: { + select: { + id: true, + name: true, + slug: true, + logo: true, + }, + }, + }, + }, + assignedLinks: { + include: { + link: { + select: { + id: true, + domain: true, + key: true, + shortLink: true, + }, + }, + }, + }, }, }); - const parsedUsers = users.map(({ user, ...rest }) => - partnerUserSchema.parse({ - ...rest, - ...user, - createdAt: rest.createdAt, // preserve the createdAt field from PartnerUser - }), + 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/page-client.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/page-client.tsx index 0e5b411beb..aa2d4467f7 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,18 +25,21 @@ import { CircleDotted, Dots, EnvelopeArrowRight, + GridIcon, Icon, 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"; import useSWR from "swr"; +import { PartnerMemberProgramsCell } from "./partner-member-programs-cell"; +import { PartnerMemberProgramsSheet } from "./partner-member-programs-sheet"; export function ProfileMembersPageClient() { const { partner } = usePartnerProfile(); @@ -79,6 +82,10 @@ export function ProfileMembersPageClient() { const { InvitePartnerUserModal, setShowInvitePartnerUserModal } = useInvitePartnerUserModal(); + const [selectedUserForPrograms, setSelectedUserForPrograms] = + useState(null); + const [showProgramsSheet, setShowProgramsSheet] = useState(false); + // Combined filter configuration const filters = useMemo( () => [ @@ -89,6 +96,7 @@ export function ProfileMembersPageClient() { options: [ { value: "owner", label: "Owner", icon: UserCrown }, { value: "member", label: "Member", icon: User }, + { value: "viewer", label: "Viewer", icon: EyeClosed }, ], }, { @@ -136,20 +144,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} @@ -159,13 +167,29 @@ export function ProfileMembersPageClient() { ); }, }, + { + id: "programs", + header: "Programs", + minSize: 80, + maxSize: 80, + meta: { disableTruncate: true }, + cell: ({ row }) => ( + { + setSelectedUserForPrograms(row.original); + setShowProgramsSheet(true); + }} + /> + ), + }, { id: "role", header: "Role", accessorFn: (row) => row.role, - minSize: 120, - size: 150, - maxSize: 200, + minSize: 50, + maxSize: 50, + meta: { disableTruncate: true }, cell: ({ row }) => ( null, cell: ({ row }) => ( - + { + setSelectedUserForPrograms(user); + setShowProgramsSheet(true); + }} + /> ), }, ], @@ -224,6 +255,14 @@ export function ProfileMembersPageClient() { return ( <> + {selectedUserForPrograms && ( + + )} - - + {Object.values(PartnerRole).map((role) => ( + + ))} ); @@ -352,9 +394,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(); @@ -375,9 +419,12 @@ function RowMenuButton({ return null; } + const isTargetOwner = user.role === "owner"; + return ( <> + + {isCurrentUserOwner && !isTargetOwner && !isInvite && ( + { + onEditPrograms(user); + setIsOpen(false); + }} + /> + )} 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 0000000000..5d24472e7e --- /dev/null +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-links-selector.tsx @@ -0,0 +1,141 @@ +"use client"; + +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__"; + +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 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, isAllLinks, selectedLinkIds, validLinkIds, setSelectedLinkIds]); + + const validSelectedLinkIds = isAllLinks + ? undefined + : selectedLinkIds?.filter((id) => validLinkIds.has(id)); + + const isAllLinksResolved = isAllLinks || validSelectedLinkIds?.length === 0; + + const options = [ + { + value: ALL_LINKS_VALUE, + label: "All links", + }, + ...linkOptions, + ]; + + const selected = isAllLinksResolved + ? [{ value: ALL_LINKS_VALUE, label: "All links" }] + : linkOptions.filter((opt) => validSelectedLinkIds?.includes(opt.value)); + + const handleSelect = ({ value }: { value: string }) => { + if (value === ALL_LINKS_VALUE) { + setSelectedLinkIds(undefined); + return; + } + + if (isAllLinksResolved) { + setSelectedLinkIds([value]); + return; + } + + if (validSelectedLinkIds?.includes(value)) { + const remaining = validSelectedLinkIds.filter((id) => id !== value); + if (remaining.length === 0) { + setSelectedLinkIds(undefined); + } else { + setSelectedLinkIds(remaining); + } + } else { + setSelectedLinkIds([...(validSelectedLinkIds ?? []), value]); + } + }; + + return ( + } + matchTriggerWidth + side="bottom" + options={loading ? [] : options} + selected={selected} + onSelect={handleSelect} + buttonProps={{ + className: + "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 ? ( +

+ ) : isAllLinks ? ( +
All links
+ ) : selected.length === 0 ? ( +
Select links...
+ ) : ( +
+ {selected.map((option) => ( + + {"meta" in option && option.meta ? ( + + ) : null} + + {truncate(option.label, 32)} + + + ))} +
+ )} + + ); +} 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 new file mode 100644 index 0000000000..5fe662299e --- /dev/null +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-cell.tsx @@ -0,0 +1,132 @@ +"use client"; + +import type { ReactNode } from "react"; + +import useProgramEnrollments from "@/lib/swr/use-program-enrollments"; +import { PartnerUserProps } from "@/lib/types"; +import { Tooltip } from "@dub/ui"; +import { cn, OG_AVATAR_URL } from "@dub/utils"; + +const MAX_VISIBLE_LOGOS = 3; + +function ProgramsHover({ + children, + onClick, + disabled, + tooltip, + "aria-label": ariaLabel, +}: { + children: ReactNode; + onClick?: () => void; + disabled?: boolean; + tooltip?: string; + "aria-label"?: string; +}) { + const className = cn( + "group w-fit rounded-lg border-0 bg-transparent p-2 font-inherit text-inherit transition-colors duration-150", + disabled ? "cursor-default" : "hover:cursor-pointer hover:bg-neutral-100", + ); + + const content = + onClick && !disabled ? ( + + ) : ( +
{children}
+ ); + + if (tooltip) { + return {content}; + } + + return content; +} + +export function PartnerMemberProgramsCell({ + partnerUser, + onClick, +}: { + partnerUser: PartnerUserProps; + onClick?: () => void; +}) { + const { programAccess, programs, role } = partnerUser; + const isOwner = role === "owner"; + + const { programEnrollments, isLoading: enrollmentsLoading } = + useProgramEnrollments(); + + const displayPrograms = + programAccess === "all" ? programEnrollments ?? [] : programs; + + // Owner: always show "All" badge, disabled with tooltip + if (isOwner) { + return ( + +
+ All +
+
+ ); + } + + if (enrollmentsLoading && programAccess === "all") { + return ( +
+
+
+ ); + } + + // Member/Viewer with "all" access + if (programAccess === "all") { + return ( + +
+ All +
+
+ ); + } + + const visible = displayPrograms.slice(0, MAX_VISIBLE_LOGOS); + const hiddenCount = Math.max(0, displayPrograms.length - MAX_VISIBLE_LOGOS); + + return ( + +
+ {visible.map((p, index) => ( + 0 && "-ml-1.5", + )} + /> + ))} + {hiddenCount > 0 && ( +
+ +{hiddenCount} +
+ )} +
+
+ ); +} 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 new file mode 100644 index 0000000000..a7f288ffc4 --- /dev/null +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/profile/members/partner-member-programs-sheet.tsx @@ -0,0 +1,461 @@ +"use client"; + +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 { CircleCheckFill } from "@dub/ui/icons"; +import { cn, OG_AVATAR_URL } from "@dub/utils"; +import { ChevronDown, X } from "lucide-react"; +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; + isCurrentUserOwner: boolean; + showSheet: boolean; + setShowSheet: (show: boolean) => void; +} + +function PartnerMemberProgramsSheetContent({ + user, + isCurrentUserOwner, + setShowSheet, +}: PartnerMemberProgramsSheetProps) { + const [isSaving, setIsSaving] = useState(false); + const { programEnrollments, isLoading } = useProgramEnrollments(); + const [accessState, setAccessState] = useState>({}); + + const [programAccess, setProgramAccess] = useState( + user.programAccess, + ); + + const [linkState, setLinkState] = useState< + Record + >({}); + + const isTargetOwner = user.role === "owner"; + const canEdit = isCurrentUserOwner && !isTargetOwner; + + useEffect(() => { + if (!programEnrollments) return; + + const programMap = new Map(user.programs.map((p) => [p.id, p])); + const allAccess = isTargetOwner || user.programAccess === "all"; + + const initialAccess: Record = {}; + const initialLinks: Record = {}; + + for (const enrollment of programEnrollments) { + 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(initialAccess); + setLinkState(initialLinks); + setProgramAccess(isTargetOwner ? "all" : user.programAccess); + }, [programEnrollments, user.programs, user.programAccess, isTargetOwner]); + + const hasChanges = (() => { + if (programAccess !== user.programAccess) return true; + if (programAccess === "all") return false; + if (!programEnrollments) return false; + + const programMap = new Map(user.programs.map((p) => [p.id, p])); + + return programEnrollments.some((enrollment) => { + const current = accessState[enrollment.programId] ?? false; + 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; + }); + })(); + + const handleSave = async () => { + if (!user.id) return; + + const programIds = + 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, + programIds, + linkIds, + }, + 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 + +
+
+ +
+ {canEdit && ( +
+ +
+ {( + [ + { + 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 ( + + ); + })} +
+
+ )} + +
+ {isLoading ? ( + [...Array(3)].map((_, i) => ) + ) : !programEnrollments || programEnrollments.length === 0 ? ( +
+ No programs available. +
+ ) : ( + programEnrollments.map((enrollment) => { + const isAllAccess = programAccess === "all"; + const hasAccess = + isAllAccess || (accessState[enrollment.programId] ?? false); + const showLinkPicker = canEdit && hasAccess; + + return ( + + setAccessState((prev) => ({ + ...prev, + [enrollment.programId]: newAccess, + })) + } + onLinkChange={(ids) => + setLinkState((prev) => ({ + ...prev, + [enrollment.programId]: ids, + })) + } + /> + ); + }) + )} +
+
+ + {canEdit && ( +
+ +
+ )} + + ); +} + +function ProgramRow({ + program, + hasAccess, + canEdit, + showLinkPicker, + disabledLinkPicker, + selectedLinkIds, + onAccessChange, + onLinkChange, +}: { + program: Pick; + hasAccess: boolean; + canEdit: boolean; + showLinkPicker: boolean; + disabledLinkPicker?: boolean; + selectedLinkIds: string[] | undefined; + onAccessChange: (hasAccess: boolean) => void; + onLinkChange: (ids: string[] | undefined) => void; +}) { + return ( +
+
+
+
+ +
+ + {program.name} + + + {canEdit ? ( +
+ + +
+ ) : showLinkPicker ? ( +
+ + +
+ ) : ( + +
+
+ + {showLinkPicker && ( +
+
+ Links +
+ {disabledLinkPicker ? ( +
+ All links +
+ ) : ( + + )} +
+ )} +
+ ); +} + +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/global-setup.ts b/apps/web/global-setup.ts index 3d051cc003..2bcbfd9714 100644 --- a/apps/web/global-setup.ts +++ b/apps/web/global-setup.ts @@ -1,7 +1,27 @@ 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) — CI only; local dev assumes seeded DB + if (process.env.GITHUB_ACTION) { + execSync("pnpm exec tsx scripts/dev/seed.ts -w acme", { + stdio: "inherit", + cwd: __dirname, + }); + + execSync("pnpm exec tsx scripts/dev/seed.ts -w example", { + stdio: "inherit", + cwd: __dirname, + }); + } + + // Seed existing partner test user + execSync("pnpm exec tsx playwright/seed.ts", { + stdio: "inherit", + cwd: __dirname, + }); +} export default globalSetup; diff --git a/apps/web/lib/actions/partners/accept-program-invite.ts b/apps/web/lib/actions/partners/accept-program-invite.ts index 01838fd1ed..58deecd3a0 100644 --- a/apps/web/lib/actions/partners/accept-program-invite.ts +++ b/apps/web/lib/actions/partners/accept-program-invite.ts @@ -2,6 +2,7 @@ import { generateDiscountCodeForPartner } from "@/lib/api/discounts/generate-discount-code-for-partner"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; +import { throwIfNoPermission } from "@/lib/auth/partner-users/throw-if-no-permission"; import { triggerDraftBountySubmissionCreation } from "@/lib/bounty/api/trigger-draft-bounty-submissions"; import { polyfillSocialMediaFields } from "@/lib/social-utils"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; @@ -18,9 +19,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 5e5d91da67..366a402109 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 7403c6bc2c..db374862f1 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 d817fe14ab..e56e98de60 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 05fbd08b4e..5d6f513c50 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.write", + }); + 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 6c8c177c3f..b807435897 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 718a7e2c2c..d1cd49d3cb 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/upload-bounty-submission-file.ts b/apps/web/lib/actions/partners/upload-bounty-submission-file.ts index 05b1b83621..f9270ab74e 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,13 @@ 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/partners/verify-partner-website.ts b/apps/web/lib/actions/partners/verify-partner-website.ts index 6284ceee13..01487ed87b 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 389039df45..c0d671ebed 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 @@ -2,6 +2,7 @@ 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"; @@ -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 c2c559b9b3..d2e42829d5 100644 --- a/apps/web/lib/actions/partners/withdraw-partner-application.ts +++ b/apps/web/lib/actions/partners/withdraw-partner-application.ts @@ -1,26 +1,30 @@ "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"; +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 { partner, partnerUser } = ctx; - const programEnrollment = await prisma.programEnrollment.findUniqueOrThrow({ - where: { - partnerId_programId: { - partnerId: partner.id, - programId, - }, - }, + throwIfNoPermission({ + role: partnerUser.role, + permission: "program_enrollments.withdraw", + }); + + const programEnrollment = await getProgramEnrollmentOrThrow({ + partnerId: partner.id, + programId, + include: {}, }); if (programEnrollment.status !== "pending") { diff --git a/apps/web/lib/actions/referrals/submit-referral.ts b/apps/web/lib/actions/referrals/submit-referral.ts index 1a63e15c7a..8a5028b9d1 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/api/partner-profile/client.ts b/apps/web/lib/api/partner-profile/client.ts index 1e13f6fa4f..35122722a9 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/api/partner-profile/get-earnings-for-partner.ts b/apps/web/lib/api/partner-profile/get-earnings-for-partner.ts index 88b4b3c8ac..b27dd3ebe7 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({ @@ -44,6 +46,8 @@ export async function getEarningsForPartner( timezone, }); + const finalLinkIds = linkId ? [linkId] : linkIds ? linkIds : []; + const earnings = await prisma.commission.findMany({ where: { earnings: { @@ -53,7 +57,13 @@ export async function getEarningsForPartner( partnerId, status, type, - linkId, + ...(finalLinkIds.length > 0 + ? { + linkId: { + in: finalLinkIds, + }, + } + : {}), 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 c3d9152266..7f5d99d34c 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,21 +1,26 @@ 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"; -import { Prisma } from "@dub/prisma/client"; +import { Link, 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; + assignedLinks?: Pick[]; +} + export async function getPartnerEarningsTimeseries({ partnerId, programId, filters, -}: { - partnerId: string; - programId: string; - filters: z.infer; -}) { + assignedLinks, +}: GetPartnerEarningsTimeseriesParams) { const { groupBy, type, @@ -34,7 +39,7 @@ export async function getPartnerEarningsTimeseries({ programId: programId, include: { program: true, - links: true, + links: linkIncludeFilter(assignedLinks), }, }); @@ -64,6 +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``} + ${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/api/programs/get-program-enrollment-or-throw.ts b/apps/web/lib/api/programs/get-program-enrollment-or-throw.ts index 815a0aa58d..5ae06465ea 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/options.ts b/apps/web/lib/auth/options.ts index ace8024fc4..b3020832f4 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/lib/auth/partner-users/link-scope-filter.ts b/apps/web/lib/auth/partner-users/link-scope-filter.ts new file mode 100644 index 0000000000..ca15da68ba --- /dev/null +++ b/apps/web/lib/auth/partner-users/link-scope-filter.ts @@ -0,0 +1,41 @@ +import { Link } from "@dub/prisma/client"; + +/** + * Prisma `where` fragment for filtering by linkId. + * Use in direct queries: where: { ...linkScopeFilter(assignedLinks) } + */ +export function linkScopeFilter( + assignedLinks: Pick[] | undefined, +): { + linkId?: { in: string[] }; +} { + if (assignedLinks === undefined) { + return {}; + } + + return { + linkId: { + in: assignedLinks.map(({ id }) => id), + }, + }; +} + +/** + * Prisma `include.links` value for getProgramEnrollmentOrThrow calls. + * Returns `true` (all links) or `{ where: { id: { in: ... } } }` (scoped). + */ +export function linkIncludeFilter( + assignedLinks: Pick[] | undefined, +): true | { where: { id: { in: string[] } } } { + if (assignedLinks === undefined) { + return true; + } + + return { + where: { + id: { + in: assignedLinks.map(({ id }) => id), + }, + }, + }; +} 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 e271af335e..730501cc36 100644 --- a/apps/web/lib/auth/partner-users/partner-user-permissions.ts +++ b/apps/web/lib/auth/partner-users/partner-user-permissions.ts @@ -10,27 +10,37 @@ const PERMISSIONS = [ "user_invites.update", "partner_profile.update", "payout_settings.update", + "payout_settings.read", "postbacks.read", "postbacks.write", + "messages.read", + "messages.write", + "messages.mark_as_read", + "program_invites.accept", + "program_enrollments.withdraw", + "bounties.submit", + "links.write", + "referrals.submit", + "payouts.read", ] 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: [], +const ROLE_PERMISSIONS: Record> = { + owner: new Set(PERMISSIONS), + member: new Set([ + "payout_settings.read", + "messages.read", + "messages.write", + "messages.mark_as_read", + "program_invites.accept", + "program_enrollments.withdraw", + "bounties.submit", + "links.write", + "referrals.submit", + "payouts.read", + ]), + 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; } 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 0000000000..d0d0bc7a34 --- /dev/null +++ b/apps/web/lib/auth/partner-users/program-scope-filter.ts @@ -0,0 +1,17 @@ +import { Program } from "@dub/prisma/client"; + +export function programScopeFilter( + assignedPrograms: Pick[] | undefined, +): { + programId?: { in: string[] }; +} { + if (assignedPrograms === undefined) { + return {}; + } + + return { + programId: { + 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 new file mode 100644 index 0000000000..f82a3a5d0e --- /dev/null +++ b/apps/web/lib/auth/partner-users/throw-if-no-access.ts @@ -0,0 +1,58 @@ +import { DubApiError } from "@/lib/api/errors"; +import { Link, Program } from "@dub/prisma/client"; + +interface PartnerUserAccess { + assignedPrograms: Pick[] | undefined; + assignedLinks: Pick[] | undefined; +} + +export function throwIfNoProgramAccess({ + programId, + programSlug, + partnerUser, +}: { + programId?: string; + programSlug?: string; + partnerUser: Partial>; +}) { + if ( + programId && + partnerUser.assignedPrograms && + !partnerUser.assignedPrograms.some(({ id }) => id === programId) + ) { + throw new DubApiError({ + code: "forbidden", + message: "You are not authorized to view this program.", + }); + } + + if ( + programSlug && + partnerUser.assignedPrograms && + !partnerUser.assignedPrograms.some(({ slug }) => slug === 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: Partial>; +}) { + if ( + linkId && + partnerUser.assignedLinks && + !partnerUser.assignedLinks.some(({ id }) => id === linkId) + ) { + throw new DubApiError({ + code: "forbidden", + message: "You are not authorized to view this link.", + }); + } +} diff --git a/apps/web/lib/auth/partner.ts b/apps/web/lib/auth/partner.ts index 84031b2854..8b1ac62c48 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"; @@ -33,7 +33,13 @@ interface WithPartnerProfileHandler { headers?: Headers; session: Session; partner: Omit; - partnerUser: Pick; + partnerUser: Pick< + PartnerUser, + "id" | "userId" | "role" | "programAccess" + > & { + assignedPrograms?: Pick[]; + assignedLinks?: Pick[]; + }; }): Promise; } @@ -228,6 +234,21 @@ export const withPartnerProfile = ( veriffIdentityHash: true, }, }, + assignedPrograms: { + select: { + program: { + select: { + id: true, + slug: true, + }, + }, + }, + }, + assignedLinks: { + select: { + linkId: true, + }, + }, }, }); @@ -258,6 +279,43 @@ export const withPartnerProfile = ( } } + const assignedPrograms = + partnerUser.programAccess === "all" + ? undefined + : partnerUser.assignedPrograms.map(({ program }) => program); + + const assignedLinks = + partnerUser.programAccess === "all" + ? undefined + : 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 && + assignedPrograms !== undefined && + assignedPrograms.length > 0 + ) { + let hasAccess = false; + + if (params.programId.startsWith("prog_")) { + hasAccess = assignedPrograms.some( + ({ id }) => id === params.programId, + ); + } else { + hasAccess = assignedPrograms.some( + ({ slug }) => slug === params.programId, + ); + } + + if (!hasAccess) { + throw new DubApiError({ + code: "not_found", + message: `You don't have access to this program.`, + }); + } + } + const { industryInterests, preferredEarningStructures, @@ -285,8 +343,12 @@ export const withPartnerProfile = ( platforms: partnerPlatformSchema.array().parse(platforms), } as Omit, partnerUser: { + id: partnerUser.id, userId: partnerUser.userId, role: partnerUser.role, + programAccess: partnerUser.programAccess, + assignedPrograms, + assignedLinks, }, 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 def8157a2c..898ac7bf85 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 c7702dbfb8..fcf344e9e3 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()}` diff --git a/apps/web/lib/zod/schemas/partner-profile.ts b/apps/web/lib/zod/schemas/partner-profile.ts index 9452200645..3316bc0f21 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"; @@ -28,6 +29,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"; @@ -188,13 +190,47 @@ export const getPartnerUsersQuerySchema = z.object({ role: z.enum(PartnerRole).optional(), }); +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({ + 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(), email: z.string(), role: z.enum(PartnerRole), + programAccess: z.enum(ProgramAccessScope), image: z.string().nullish(), createdAt: z.date(), + programs: z.array( + ProgramSchema.pick({ + id: true, + name: true, + slug: true, + logo: true, + }).extend({ + links: z.array( + LinkSchema.pick({ + id: true, + domain: true, + key: true, + shortLink: true, + }), + ), + }), + ), }); export const partnerProfileChangeHistoryLogSchema = z.array( diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 9cb1707426..fc8d7c18cb 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)(\.spec)?\.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/auth.setup.ts b/apps/web/playwright/partners/auth.setup.ts index 6c5b8f9b70..6680c95ee2 100644 --- a/apps/web/playwright/partners/auth.setup.ts +++ b/apps/web/playwright/partners/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 "./constants"; const authFile = "playwright/.auth/partner.json"; @@ -20,7 +18,7 @@ test("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/partners/constants.ts b/apps/web/playwright/partners/constants.ts new file mode 100644 index 0000000000..2ba993b07d --- /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 new file mode 100644 index 0000000000..269a6cb938 --- /dev/null +++ b/apps/web/playwright/partners/rbac-auth.setup.ts @@ -0,0 +1,36 @@ +import { expect, test } from "@playwright/test"; +import { PARTNER_USERS, PASSWORD } from "./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(PARTNER_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(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: process.env.CI ? 30_000 : 15_000 }, + ); + await expect(page).not.toHaveURL(/\/login/); + + // Save authenticated state + await page.context().storageState({ path: authFile }); + }); +} diff --git a/apps/web/playwright/partners/rbac.spec.ts b/apps/web/playwright/partners/rbac.spec.ts new file mode 100644 index 0000000000..28bb0ee75b --- /dev/null +++ b/apps/web/playwright/partners/rbac.spec.ts @@ -0,0 +1,347 @@ +import { prisma } from "@dub/prisma"; +import { PartnerRole } from "@dub/prisma/client"; +import { APIRequestContext, expect, test } from "@playwright/test"; +import { PARTNER_PROGRAMS } from "./constants"; + +test.describe.configure({ mode: "parallel" }); + +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.some((l: any) => l.key === "acme-link-1")).toBe(true); + expect(body.some((l: any) => l.key === "acme-link-2")).toBe(true); + }, + }, + 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.some((l: any) => l.key === "acme-link-1")).toBe(true); + expect(body.some((l: any) => l.key === "acme-link-2")).toBe(true); + }, + }, + }, + }, + { + 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: "/payouts/settings", + 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://acme.com" }, + roles: { + owner: { status: 201 }, + member: { status: 201 }, + 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(`${API_BASE_URL}${path}`), + post: (path: string, data?: object) => + request.post(`${API_BASE_URL}${path}`, { data }), + patch: (path: string, data?: object) => + request.patch(`${API_BASE_URL}${path}`, { data }), + delete: (path: string) => request.delete(`${API_BASE_URL}${path}`), + }; +} + +let inaccessibleLinkId: string; + +function runRbacSuite(role: PartnerRole) { + test.describe.configure({ mode: "parallel" }); + + test.beforeAll(async () => { + if (!inaccessibleLinkId) { + const link = await prisma.link.findFirstOrThrow({ + where: { key: "acme-link-2" }, + select: { id: true }, + }); + inaccessibleLinkId = link.id; + } + }); + + 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)" : ""; + + test(`${entry.method} ${entry.endpoint}${suffix} — ${label}`, async ({ + request, + }) => { + let url = `${API_BASE_URL}${entry.endpoint}`; + + 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()}`; + } + + 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); + } + + expect(response.status()).toBe(expected.status); + + if (expected.code) { + expect(await response.json()).toMatchObject({ + error: { code: expected.code }, + }); + } + + if (expected.verify) { + const body = await response.json(); + expected.verify(body); + } + }); + } +} + +test.describe("Owner role", () => { + test.use({ storageState: "playwright/.auth/partner-owner.json" }); + runRbacSuite("owner"); +}); + +test.describe("Member role", () => { + test.use({ storageState: "playwright/.auth/partner-member.json" }); + runRbacSuite("member"); +}); + +test.describe("Viewer role", () => { + test.use({ storageState: "playwright/.auth/partner-viewer.json" }); + runRbacSuite("viewer"); +}); diff --git a/apps/web/playwright/seed.ts b/apps/web/playwright/seed.ts index aaa09229c5..b5cccc9412 100644 --- a/apps/web/playwright/seed.ts +++ b/apps/web/playwright/seed.ts @@ -1,62 +1,184 @@ import "dotenv-flow/config"; -import { PrismaClient } from "@prisma/client"; +import { createId } from "@/lib/api/create-id"; +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 { + 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, + id: createId({ prefix: "pn_" }), + 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: { + id: createId({ prefix: "user_" }), + 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: { + id: createId({ prefix: "pge_" }), + 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: { + id: createId({ prefix: "link_" }), + 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/playwright/workspaces/auth.setup.ts b/apps/web/playwright/workspaces/auth.setup.ts index 428547239f..0ff3fb0f6d 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/playwright/workspaces/onboarding.spec.ts b/apps/web/playwright/workspaces/onboarding.spec.ts index 842e567a10..64558701f8 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(); diff --git a/apps/web/scripts/dev/data.json b/apps/web/scripts/dev/acme-workspace.json similarity index 99% rename from apps/web/scripts/dev/data.json rename to apps/web/scripts/dev/acme-workspace.json index ec2e217563..b687ac1384 100644 --- a/apps/web/scripts/dev/data.json +++ b/apps/web/scripts/dev/acme-workspace.json @@ -95,6 +95,7 @@ "color": null, "leadRewardId": "rw_1K2J9DRWPPJ2F1RX53N92TSGD", "saleRewardId": "rw_1K2J9DRWPPJ2F1RX53N92TSGE", + "maxPartnerLinks": 50, "additionalLinks": [ { "domain": "acme.com", diff --git a/apps/web/scripts/dev/example-workspace.json b/apps/web/scripts/dev/example-workspace.json new file mode 100644 index 0000000000..41aa447833 --- /dev/null +++ b/apps/web/scripts/dev/example-workspace.json @@ -0,0 +1,130 @@ +{ + "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": true + } + ], + "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", + "maxPartnerLinks": 50, + "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 fe066b7b64..3ccf9879de 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[]; }; @@ -128,10 +130,54 @@ 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; + } + + if (a.startsWith("-")) { + throw new Error( + `Unknown argument: ${a}. Supported flags: --truncate, --workspace / -w .`, + ); + } + } + + 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 +210,7 @@ const createUsers = async (data: SeedData) => { emailVerified: new Date(user.emailVerified), passwordHash, })), + skipDuplicates: true, }); console.log(`Created ${count} users`); @@ -303,6 +350,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, })), }); @@ -476,21 +525,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", @@ -499,15 +555,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", ]; @@ -560,9 +639,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 +661,7 @@ async function main() { console.log("\n"); } - const data = parseJSON(); + const data = parseJSON(workspaceSlug); await createWorkspace(data); await createUsers(data); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 8d9d479a08..56598fc482 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,8 @@ "../../packages/blocks/src/event-list.tsx", "../../packages/ui/src/hooks/use-pagination.ts" ], - "exclude": ["node_modules", "playwright"] -} + "exclude": [ + "node_modules", + "playwright" + ] +} \ No newline at end of file diff --git a/apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx b/apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx index c696705c84..0f2b74866a 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.read") + ? [ + { + 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.read") + ? [ + { + 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") && ( + + )} ) } diff --git a/apps/web/ui/modals/invite-partner-user-modal.tsx b/apps/web/ui/modals/invite-partner-user-modal.tsx index f810b4cf76..66a15f7db1 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"; @@ -85,7 +86,6 @@ function InvitePartnerUserModal({

Invite Teammates

@@ -98,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 && ( -
- ))} - -
-
+
); diff --git a/packages/prisma/schema/link.prisma b/packages/prisma/schema/link.prisma index cd5c7d5a91..49798c9ba2 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 cbad0c3ac3..5fa309d70a 100644 --- a/packages/prisma/schema/partner.prisma +++ b/packages/prisma/schema/partner.prisma @@ -1,6 +1,12 @@ enum PartnerRole { owner member + viewer +} + +enum ProgramAccessScope { + all + restricted } enum PartnerProfileType { @@ -120,17 +126,20 @@ 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) notificationPreferences PartnerNotificationPreferences? + assignedPrograms PartnerUserProgram[] + assignedLinks PartnerUserLink[] @@unique([userId, partnerId]) @@index(partnerId) @@ -180,3 +189,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 a8d65bc46b..b4fae3cb9a 100644 --- a/packages/prisma/schema/program.prisma +++ b/packages/prisma/schema/program.prisma @@ -90,6 +90,8 @@ model Program { sourceFraudEvents FraudEvent[] @relation("SourceFraudEvents") fraudAlerts FraudAlert[] referrals PartnerReferral[] + partnerUserPrograms PartnerUserProgram[] + partnerUserLinks PartnerUserLink[] @@index(workspaceId) @@index(addedToMarketplaceAt)