From 5687a8164ae44a8f8ae799babe99ba23ec87803e Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Fri, 15 Aug 2025 17:25:11 -0700 Subject: [PATCH 1/3] Account for partner groups in program creation --- .../programs/[programId]/discounts/route.ts | 36 -------- .../api/programs/[programId]/rewards/route.ts | 30 ------- .../(ee)/api/programs/[programId]/route.ts | 2 - .../lib/actions/partners/create-program.ts | 43 ++++------ .../lib/actions/partners/invite-partner.ts | 2 +- .../api/partners/create-and-enroll-partner.ts | 69 +++------------ .../lib/api/programs/get-program-or-throw.ts | 31 +------ apps/web/lib/partnerstack/import-partners.ts | 35 +++++--- apps/web/lib/rewardful/import-campaign.ts | 86 ++++++++++++------- apps/web/lib/rewardful/import-partners.ts | 48 +++++++---- apps/web/lib/rewardful/schemas.ts | 2 +- apps/web/lib/tolt/import-partners.ts | 35 +++++--- .../migrations/backfill-partner-groups.ts | 1 + .../{ => migrations}/migrate-discounts.ts | 0 .../{ => migrations}/migrate-domains.ts | 0 .../{ => migrations}/migrate-images.ts | 0 .../{ => migrations}/migrate-integrations.ts | 0 .../migrate-links-to-workspaces.ts | 0 .../{ => migrations}/migrate-partner-links.ts | 0 .../migrate-program-invites.ts | 4 +- .../migrate-rewards-remainder.ts | 2 + .../{ => migrations}/migrate-rewards.ts | 0 .../scripts/{ => migrations}/migrate-sales.ts | 0 packages/prisma/schema/discount.prisma | 1 - packages/prisma/schema/reward.prisma | 1 - 25 files changed, 165 insertions(+), 263 deletions(-) delete mode 100644 apps/web/app/(ee)/api/programs/[programId]/discounts/route.ts delete mode 100644 apps/web/app/(ee)/api/programs/[programId]/rewards/route.ts rename apps/web/scripts/{ => migrations}/migrate-discounts.ts (100%) rename apps/web/scripts/{ => migrations}/migrate-domains.ts (100%) rename apps/web/scripts/{ => migrations}/migrate-images.ts (100%) rename apps/web/scripts/{ => migrations}/migrate-integrations.ts (100%) rename apps/web/scripts/{ => migrations}/migrate-links-to-workspaces.ts (100%) rename apps/web/scripts/{ => migrations}/migrate-partner-links.ts (100%) rename apps/web/scripts/{ => migrations}/migrate-program-invites.ts (97%) rename apps/web/scripts/{ => migrations}/migrate-rewards-remainder.ts (99%) rename apps/web/scripts/{ => migrations}/migrate-rewards.ts (100%) rename apps/web/scripts/{ => migrations}/migrate-sales.ts (100%) diff --git a/apps/web/app/(ee)/api/programs/[programId]/discounts/route.ts b/apps/web/app/(ee)/api/programs/[programId]/discounts/route.ts deleted file mode 100644 index 624057fd1bc..00000000000 --- a/apps/web/app/(ee)/api/programs/[programId]/discounts/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; -import { withWorkspace } from "@/lib/auth"; -import { DiscountSchema } from "@/lib/zod/schemas/discount"; -import { prisma } from "@dub/prisma"; -import { NextResponse } from "next/server"; -import { z } from "zod"; - -// GET /api/programs/[programId]/discounts - get all discounts for a program -export const GET = withWorkspace(async ({ workspace }) => { - const programId = getDefaultProgramIdOrThrow(workspace); - - const discounts = await prisma.discount.findMany({ - where: { - programId, - }, - include: { - _count: { - select: { - programEnrollments: true, - }, - }, - }, - orderBy: { - createdAt: "desc", - }, - }); - - const discountsWithPartnersCount = discounts.map((discount) => ({ - ...discount, - partnersCount: discount._count.programEnrollments, - })); - - return NextResponse.json( - z.array(DiscountSchema).parse(discountsWithPartnersCount), - ); -}); diff --git a/apps/web/app/(ee)/api/programs/[programId]/rewards/route.ts b/apps/web/app/(ee)/api/programs/[programId]/rewards/route.ts deleted file mode 100644 index 2578ee8d4cf..00000000000 --- a/apps/web/app/(ee)/api/programs/[programId]/rewards/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; -import { withWorkspace } from "@/lib/auth"; -import { RewardSchema } from "@/lib/zod/schemas/rewards"; -import { prisma } from "@dub/prisma"; -import { NextResponse } from "next/server"; -import { z } from "zod"; - -// GET /api/programs/[programId]/rewards - get all rewards for a program -export const GET = withWorkspace(async ({ workspace }) => { - const programId = getDefaultProgramIdOrThrow(workspace); - - const rewards = await prisma.reward.findMany({ - where: { - programId, - }, - orderBy: [ - { - default: "desc", - }, - { - event: "desc", - }, - { - createdAt: "desc", - }, - ], - }); - - return NextResponse.json(z.array(RewardSchema).parse(rewards)); -}); diff --git a/apps/web/app/(ee)/api/programs/[programId]/route.ts b/apps/web/app/(ee)/api/programs/[programId]/route.ts index 66a8cc43b5b..9e670112a47 100644 --- a/apps/web/app/(ee)/api/programs/[programId]/route.ts +++ b/apps/web/app/(ee)/api/programs/[programId]/route.ts @@ -14,8 +14,6 @@ export const GET = withWorkspace( programId: params.programId, }, { - includeDefaultDiscount: true, - includeDefaultRewards: true, includeLanderData: includeLanderData || false, }, ); diff --git a/apps/web/lib/actions/partners/create-program.ts b/apps/web/lib/actions/partners/create-program.ts index 6943fbfcc21..6910e538593 100644 --- a/apps/web/lib/actions/partners/create-program.ts +++ b/apps/web/lib/actions/partners/create-program.ts @@ -10,12 +10,13 @@ import { toltImporter } from "@/lib/tolt/importer"; import { WorkspaceProps } from "@/lib/types"; import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; import { programDataSchema } from "@/lib/zod/schemas/program-onboarding"; +import { REWARD_EVENT_COLUMN_MAPPING } from "@/lib/zod/schemas/rewards"; import { sendEmail } from "@dub/email"; import PartnerInvite from "@dub/email/templates/partner-invite"; import ProgramWelcome from "@dub/email/templates/program-welcome"; import { prisma } from "@dub/prisma"; import { generateRandomString, nanoid, R2_URL } from "@dub/utils"; -import { Program, Project, Reward, User } from "@prisma/client"; +import { Program, Project, User } from "@prisma/client"; import { waitUntil } from "@vercel/functions"; import { redirect } from "next/navigation"; @@ -83,12 +84,19 @@ export const createProgram = async ({ const programId = createId({ prefix: "prog_" }); const defaultGroupId = createId({ prefix: "grp_" }); + const logoUrl = uploadedLogo + ? await storage + .upload(`programs/${programId}/logo_${nanoid(7)}`, uploadedLogo) + .then(({ url }) => url) + : null; + const programData = await tx.program.create({ data: { id: programId, workspaceId: workspace.id, name, slug: workspace.slug, + ...(logoUrl && { logo: logoUrl }), domain, url, defaultFolderId: programFolder.id, @@ -106,7 +114,6 @@ export const createProgram = async ({ amount, maxDuration, event: defaultRewardType, - default: true, }, }, }), @@ -116,6 +123,8 @@ export const createProgram = async ({ }, }); + const createdReward = programData.rewards?.[0]; + await tx.partnerGroup.upsert({ where: { programId_slug: { @@ -129,6 +138,9 @@ export const createProgram = async ({ slug: DEFAULT_PARTNER_GROUP.slug, name: DEFAULT_PARTNER_GROUP.name, color: DEFAULT_PARTNER_GROUP.color, + ...(createdReward && { + [REWARD_EVENT_COLUMN_MAPPING[createdReward.event]]: createdReward.id, + }), }, update: {}, // noop }); @@ -156,12 +168,6 @@ export const createProgram = async ({ return programData; }); - const logoUrl = uploadedLogo - ? await storage - .upload(`programs/${program.id}/logo_${nanoid(7)}`, uploadedLogo) - .then(({ url }) => url) - : null; - // Start the import process if the import source is set if (importSource === "rewardful" && rewardful?.id) { await rewardfulImporter.queue({ @@ -188,8 +194,6 @@ export const createProgram = async ({ }); } - const reward = program.rewards?.[0]; - waitUntil( Promise.allSettled([ // invite partners @@ -198,23 +202,12 @@ export const createProgram = async ({ invitePartner({ workspace, program, - reward, partner, userId: user.id, }), ) : []), - // update the program with the logo and default reward - prisma.program.update({ - where: { - id: program.id, - }, - data: { - ...(logoUrl && { logo: logoUrl }), - }, - }), - // delete the temporary uploaded logo uploadedLogo && isStored(uploadedLogo) && @@ -227,10 +220,7 @@ export const createProgram = async ({ react: ProgramWelcome({ email: user.email!, workspace, - program: { - ...program, - logo: logoUrl, - }, + program, }), }), @@ -257,13 +247,11 @@ export const createProgram = async ({ // Invite a partner to the program async function invitePartner({ program, - reward, workspace, partner, userId, }: { program: Program; - reward?: Pick; workspace: Pick; partner: { email: string; @@ -290,7 +278,6 @@ async function invitePartner({ }, skipEnrollmentCheck: true, status: "invited", - ...(reward && { reward }), }); waitUntil( diff --git a/apps/web/lib/actions/partners/invite-partner.ts b/apps/web/lib/actions/partners/invite-partner.ts index 9114d080c4f..a7184ec7d74 100644 --- a/apps/web/lib/actions/partners/invite-partner.ts +++ b/apps/web/lib/actions/partners/invite-partner.ts @@ -91,7 +91,7 @@ export const invitePartnerAction = authActionClient }, skipEnrollmentCheck: true, status: "invited", - groupId: groupId || program.defaultGroupId, + groupId, }); waitUntil( diff --git a/apps/web/lib/api/partners/create-and-enroll-partner.ts b/apps/web/lib/api/partners/create-and-enroll-partner.ts index aebf94846f7..ccc57b983cd 100644 --- a/apps/web/lib/api/partners/create-and-enroll-partner.ts +++ b/apps/web/lib/api/partners/create-and-enroll-partner.ts @@ -7,12 +7,10 @@ import { CreatePartnerProps, ProgramPartnerLinkProps, ProgramProps, - RewardProps, WorkspaceProps, } from "@/lib/types"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { EnrolledPartnerSchema } from "@/lib/zod/schemas/partners"; -import { REWARD_EVENT_COLUMN_MAPPING } from "@/lib/zod/schemas/rewards"; import { prisma } from "@dub/prisma"; import { Prisma, ProgramEnrollmentStatus } from "@dub/prisma/client"; import { nanoid } from "@dub/utils"; @@ -28,13 +26,11 @@ export const createAndEnrollPartner = async ({ workspace, link, partner, - reward, - discountId, tenantId, + groupId, status = "approved", skipEnrollmentCheck = false, enrolledAt, - groupId, }: { program: Pick; workspace: Pick; @@ -43,13 +39,11 @@ export const createAndEnrollPartner = async ({ CreatePartnerProps, "email" | "name" | "image" | "country" | "description" >; - reward?: Pick; - discountId?: string; tenantId?: string; + groupId?: string | null; status?: ProgramEnrollmentStatus; skipEnrollmentCheck?: boolean; enrolledAt?: Date; - groupId?: string | null; }) => { if (!skipEnrollmentCheck && partner.email) { const programEnrollment = await prisma.programEnrollment.findFirst({ @@ -88,44 +82,11 @@ export const createAndEnrollPartner = async ({ } } - const [defaultRewards, allDiscounts, group] = await Promise.all([ - prisma.reward.findMany({ - where: { - programId: program.id, - // if a specific reward is provided, exclude it from the default rewards because it'll be added below - ...(reward && { - event: { - not: reward.event, - }, - }), - default: true, - }, - }), - - prisma.discount.findMany({ - where: { - programId: program.id, - }, - }), - - getGroupOrThrow({ - programId: program.id, - groupId: groupId || program.defaultGroupId!, - }), - ]); - - const finalAssignedRewards = { - ...Object.fromEntries( - defaultRewards.map((r) => [REWARD_EVENT_COLUMN_MAPPING[r.event], r.id]), - ), - ...(reward && { - [REWARD_EVENT_COLUMN_MAPPING[reward.event]]: reward.id, - }), - }; - - const finalAssignedDiscount = discountId - ? allDiscounts.find((d) => d.id === discountId)?.id // we need to filter by this in case an invalid discountId is passed - : allDiscounts.find((d) => d.default)?.id; + const group = await getGroupOrThrow({ + programId: program.id, + groupId: groupId || program.defaultGroupId!, + includeRewardsAndDiscount: true, + }); const payload: Pick = { programs: { @@ -138,20 +99,14 @@ export const createAndEnrollPartner = async ({ id: link.id, }, }, - ...finalAssignedRewards, - ...(finalAssignedDiscount && { - discountId: finalAssignedDiscount, - }), + groupId: group.id, + clickRewardId: group.clickRewardId, + leadRewardId: group.leadRewardId, + saleRewardId: group.saleRewardId, + discountId: group.discountId, ...(enrolledAt && { createdAt: enrolledAt, }), - ...(group && { - groupId: group.id, - clickRewardId: group.clickRewardId, - leadRewardId: group.leadRewardId, - saleRewardId: group.saleRewardId, - discountId: group.discountId, - }), }, }, }; diff --git a/apps/web/lib/api/programs/get-program-or-throw.ts b/apps/web/lib/api/programs/get-program-or-throw.ts index 8a3c819eee6..d5d9979d57a 100644 --- a/apps/web/lib/api/programs/get-program-or-throw.ts +++ b/apps/web/lib/api/programs/get-program-or-throw.ts @@ -1,4 +1,3 @@ -import { sortRewardsByEventOrder } from "@/lib/partners/sort-rewards-by-event-order"; import { ProgramProps } from "@/lib/types"; import { ProgramSchema, @@ -16,12 +15,8 @@ export const getProgramOrThrow = async ( programId: string; }, { - includeDefaultDiscount = false, - includeDefaultRewards = false, includeLanderData = false, }: { - includeDefaultRewards?: boolean; - includeDefaultDiscount?: boolean; includeLanderData?: boolean; } = {}, ) => { @@ -30,22 +25,6 @@ export const getProgramOrThrow = async ( id: programId, workspaceId, }, - include: { - ...(includeDefaultRewards && { - rewards: { - where: { - default: true, - }, - }, - }), - ...(includeDefaultDiscount && { - discounts: { - where: { - default: true, - }, - }, - }), - }, })) as ProgramProps | null; if (!program) { @@ -57,13 +36,5 @@ export const getProgramOrThrow = async ( return ( includeLanderData ? ProgramWithLanderDataSchema : ProgramSchema - ).parse({ - ...program, - ...(includeDefaultRewards && program.rewards?.length - ? { rewards: sortRewardsByEventOrder(program.rewards) } - : {}), - ...(includeDefaultDiscount && program.discounts?.length - ? { discounts: [program.discounts[0]] } - : {}), - }); + ).parse(program); }; diff --git a/apps/web/lib/partnerstack/import-partners.ts b/apps/web/lib/partnerstack/import-partners.ts index 2ee999dbfc7..b5520d01ba8 100644 --- a/apps/web/lib/partnerstack/import-partners.ts +++ b/apps/web/lib/partnerstack/import-partners.ts @@ -1,10 +1,10 @@ import { prisma } from "@dub/prisma"; -import { Program, Reward } from "@dub/prisma/client"; +import { Program } from "@dub/prisma/client"; import { COUNTRIES } from "@dub/utils"; import { createId } from "../api/create-id"; import { logImportError } from "../tinybird/log-import-error"; import { redis } from "../upstash"; -import { REWARD_EVENT_COLUMN_MAPPING } from "../zod/schemas/rewards"; +import { DEFAULT_PARTNER_GROUP } from "../zod/schemas/groups"; import { PartnerStackApi } from "./api"; import { MAX_BATCHES, @@ -21,14 +21,16 @@ export async function importPartners(payload: PartnerStackImportPayload) { id: programId, }, include: { - rewards: { + groups: { where: { - default: true, + slug: DEFAULT_PARTNER_GROUP.slug, }, }, }, }); + const defaultGroup = program.groups[0]; + const { publicKey, secretKey } = await partnerStackImporter.getCredentials( program.workspaceId, ); @@ -38,11 +40,6 @@ export async function importPartners(payload: PartnerStackImportPayload) { secretKey, }); - const saleReward = program.rewards.find((r) => r.event === "sale"); - const leadReward = program.rewards.find((r) => r.event === "lead"); - const clickReward = program.rewards.find((r) => r.event === "click"); - const reward = saleReward || leadReward || clickReward; - let hasMore = true; let processedBatches = 0; let currentStartingAfter = startingAfter; @@ -62,7 +59,13 @@ export async function importPartners(payload: PartnerStackImportPayload) { createPartner({ program, partner, - reward, + defaultGroupAttributes: { + groupId: defaultGroup.id, + saleRewardId: defaultGroup.saleRewardId, + leadRewardId: defaultGroup.leadRewardId, + clickRewardId: defaultGroup.clickRewardId, + discountId: defaultGroup.discountId, + }, importId, }), ), @@ -84,12 +87,18 @@ export async function importPartners(payload: PartnerStackImportPayload) { async function createPartner({ program, partner, - reward, + defaultGroupAttributes, importId, }: { program: Program; partner: PartnerStackPartner; - reward?: Pick; + defaultGroupAttributes: { + groupId: string; + saleRewardId: string | null; + leadRewardId: string | null; + clickRewardId: string | null; + discountId: string | null; + }; importId: string; }) { const commonImportLogInputs = { @@ -148,7 +157,7 @@ async function createPartner({ programId: program.id, partnerId, status: "approved", - ...(reward && { [REWARD_EVENT_COLUMN_MAPPING[reward.event]]: reward.id }), + ...defaultGroupAttributes, }, update: { status: "approved", diff --git a/apps/web/lib/rewardful/import-campaign.ts b/apps/web/lib/rewardful/import-campaign.ts index b16dd6c0f63..efb80dbd8fc 100644 --- a/apps/web/lib/rewardful/import-campaign.ts +++ b/apps/web/lib/rewardful/import-campaign.ts @@ -1,6 +1,9 @@ +import { RESOURCE_COLORS } from "@/ui/colors"; import { prisma } from "@dub/prisma"; import { EventType, RewardStructure } from "@dub/prisma/client"; +import { randomValue } from "@dub/utils"; import { createId } from "../api/create-id"; +import { DEFAULT_PARTNER_GROUP } from "../zod/schemas/groups"; import { RewardfulApi } from "./api"; import { rewardfulImporter } from "./importer"; import { RewardfulImportPayload } from "./types"; @@ -8,20 +11,18 @@ import { RewardfulImportPayload } from "./types"; export async function importCampaign(payload: RewardfulImportPayload) { const { programId, campaignId } = payload; - const { workspaceId, rewards: defaultSaleReward } = - await prisma.program.findUniqueOrThrow({ - where: { - id: programId, - }, - include: { - rewards: { - where: { - event: EventType.sale, - default: true, - }, + const { workspaceId, groups } = await prisma.program.findUniqueOrThrow({ + where: { + id: programId, + }, + include: { + groups: { + where: { + slug: DEFAULT_PARTNER_GROUP.slug, }, }, - }); + }, + }); const { token } = await rewardfulImporter.getCredentials(workspaceId); @@ -38,7 +39,7 @@ export async function importCampaign(payload: RewardfulImportPayload) { reward_type, } = campaign; - const newReward = { + const rewardProps = { programId, event: EventType.sale, maxDuration: max_commission_period_months, @@ -50,42 +51,67 @@ export async function importCampaign(payload: RewardfulImportPayload) { reward_type === "amount" ? commission_amount_cents : commission_percent, }; - let rewardId: string | null = null; + let groupId: string | null = null; - const rewardFound = await prisma.reward.findFirst({ - where: newReward, + const existingReward = await prisma.reward.findFirst({ + where: { ...rewardProps, event: EventType.sale }, + include: { + salePartnerGroup: true, // rewardful only supports sale rewards + }, }); - if (!rewardFound) { - const reward = await prisma.reward.create({ + if (!existingReward) { + // if no existing reward, create a new one + group + const createdReward = await prisma.reward.create({ data: { - ...newReward, + ...rewardProps, id: createId({ prefix: "rw_" }), - default: !defaultSaleReward.length, }, }); - // if there's no default reward, means that this is a newly imported program - if (!defaultSaleReward.length) { - await prisma.program.update({ + // if the default group has an associated sale reward already, we need to create a new group + if (groups[0].saleRewardId) { + const createdGroup = await prisma.partnerGroup.create({ + data: { + id: createId({ prefix: "grp_" }), + programId, + name: `(Rewardful) ${campaign.name}`, + slug: `rewardful-${campaignId}`, + color: randomValue(RESOURCE_COLORS), + saleRewardId: createdReward.id, + }, + }); + groupId = createdGroup.id; + + // else we just update the existing group with the newly created sale reward + } else { + const updatedGroup = await prisma.partnerGroup.update({ where: { - id: programId, + id: groups[0].id, }, data: { - minPayoutAmount: minimum_payout_cents, - holdingPeriodDays: days_until_commissions_are_due, + saleRewardId: createdReward.id, }, }); + groupId = updatedGroup.id; } - - rewardId = reward.id; } else { - rewardId = rewardFound.id; + groupId = existingReward.salePartnerGroup?.id!; } + await prisma.program.update({ + where: { + id: programId, + }, + data: { + minPayoutAmount: minimum_payout_cents, + holdingPeriodDays: days_until_commissions_are_due, + }, + }); + return await rewardfulImporter.queue({ ...payload, - ...(rewardId && { rewardId }), + ...(groupId && { groupId }), action: "import-partners", }); } diff --git a/apps/web/lib/rewardful/import-partners.ts b/apps/web/lib/rewardful/import-partners.ts index 89deb22f813..79f2b10b094 100644 --- a/apps/web/lib/rewardful/import-partners.ts +++ b/apps/web/lib/rewardful/import-partners.ts @@ -1,10 +1,10 @@ import { prisma } from "@dub/prisma"; -import { Program, Reward } from "@dub/prisma/client"; +import { Program } from "@dub/prisma/client"; import { nanoid } from "@dub/utils"; import { createId } from "../api/create-id"; import { bulkCreateLinks } from "../api/links"; import { logImportError } from "../tinybird/log-import-error"; -import { REWARD_EVENT_COLUMN_MAPPING } from "../zod/schemas/rewards"; +import { DEFAULT_PARTNER_GROUP } from "../zod/schemas/groups"; import { RewardfulApi } from "./api"; import { MAX_BATCHES, rewardfulImporter } from "./importer"; import { RewardfulAffiliate, RewardfulImportPayload } from "./types"; @@ -13,18 +13,28 @@ export async function importPartners(payload: RewardfulImportPayload) { const { importId, programId, + groupId, userId, campaignId, page = 1, - rewardId, } = payload; const program = await prisma.program.findUniqueOrThrow({ where: { id: programId, }, + include: { + groups: { + // if groupId is provided, use it, otherwise use the default group + where: { + ...(groupId ? { id: groupId } : { slug: DEFAULT_PARTNER_GROUP.slug }), + }, + }, + }, }); + const defaultGroup = program.groups[0]; + const { token } = await rewardfulImporter.getCredentials(program.workspaceId); const rewardfulApi = new RewardfulApi({ token }); @@ -33,16 +43,6 @@ export async function importPartners(payload: RewardfulImportPayload) { let hasMore = true; let processedBatches = 0; - const reward = await prisma.reward.findUniqueOrThrow({ - where: { - id: rewardId, - }, - select: { - id: true, - event: true, - }, - }); - const commonImportLogInputs = { workspace_id: program.workspaceId, import_id: importId, @@ -79,7 +79,13 @@ export async function importPartners(payload: RewardfulImportPayload) { program, affiliate, userId, - reward, + defaultGroupAttributes: { + groupId: defaultGroup.id, + saleRewardId: defaultGroup.saleRewardId, + leadRewardId: defaultGroup.leadRewardId, + clickRewardId: defaultGroup.clickRewardId, + discountId: defaultGroup.discountId, + }, }), ), ); @@ -105,7 +111,7 @@ export async function importPartners(payload: RewardfulImportPayload) { await rewardfulImporter.queue({ ...payload, action, - ...(action === "import-partners" && rewardId && { rewardId }), + ...(action === "import-partners" && groupId && { groupId }), page: hasMore ? currentPage : undefined, }); } @@ -115,12 +121,18 @@ async function createPartnerAndLinks({ program, affiliate, userId, - reward, + defaultGroupAttributes, }: { program: Program; affiliate: RewardfulAffiliate; userId: string; - reward: Pick; + defaultGroupAttributes: { + groupId: string; + saleRewardId: string | null; + leadRewardId: string | null; + clickRewardId: string | null; + discountId: string | null; + }; }) { const partner = await prisma.partner.upsert({ where: { @@ -145,7 +157,7 @@ async function createPartnerAndLinks({ programId: program.id, partnerId: partner.id, status: "approved", - ...(reward && { [REWARD_EVENT_COLUMN_MAPPING[reward.event]]: reward.id }), + ...defaultGroupAttributes, }, update: { status: "approved", diff --git a/apps/web/lib/rewardful/schemas.ts b/apps/web/lib/rewardful/schemas.ts index 38a7247fea3..a57cd954fdf 100644 --- a/apps/web/lib/rewardful/schemas.ts +++ b/apps/web/lib/rewardful/schemas.ts @@ -11,7 +11,7 @@ export const rewardfulImportPayloadSchema = z.object({ importId: z.string(), userId: z.string(), programId: z.string(), - rewardId: z.string().optional(), + groupId: z.string().optional(), campaignId: z.string(), action: rewardfulImportSteps, page: z.number().optional(), diff --git a/apps/web/lib/tolt/import-partners.ts b/apps/web/lib/tolt/import-partners.ts index 4c0ced154f7..73cdca60150 100644 --- a/apps/web/lib/tolt/import-partners.ts +++ b/apps/web/lib/tolt/import-partners.ts @@ -1,8 +1,8 @@ import { prisma } from "@dub/prisma"; -import { Partner, Program, Reward } from "@dub/prisma/client"; +import { Partner, Program } from "@dub/prisma/client"; import { createId } from "../api/create-id"; import { logImportError } from "../tinybird/log-import-error"; -import { REWARD_EVENT_COLUMN_MAPPING } from "../zod/schemas/rewards"; +import { DEFAULT_PARTNER_GROUP } from "../zod/schemas/groups"; import { ToltApi } from "./api"; import { MAX_BATCHES, toltImporter } from "./importer"; import { ToltAffiliate, ToltImportPayload } from "./types"; @@ -15,14 +15,16 @@ export async function importPartners(payload: ToltImportPayload) { id: programId, }, include: { - rewards: { + groups: { where: { - default: true, + slug: DEFAULT_PARTNER_GROUP.slug, }, }, }, }); + const defaultGroup = program.groups[0]; + const { token } = await toltImporter.getCredentials(program.workspaceId); const toltApi = new ToltApi({ token }); @@ -30,11 +32,6 @@ export async function importPartners(payload: ToltImportPayload) { let hasMore = true; let processedBatches = 0; - const saleReward = program.rewards.find((r) => r.event === "sale"); - const leadReward = program.rewards.find((r) => r.event === "lead"); - const clickReward = program.rewards.find((r) => r.event === "click"); - const defaultReward = saleReward || leadReward || clickReward; - const commonImportLogInputs = { workspace_id: program.workspaceId, import_id: importId, @@ -69,7 +66,13 @@ export async function importPartners(payload: ToltImportPayload) { createPartner({ program, affiliate, - reward: defaultReward, + defaultGroupAttributes: { + groupId: defaultGroup.id, + saleRewardId: defaultGroup.saleRewardId, + leadRewardId: defaultGroup.leadRewardId, + clickRewardId: defaultGroup.clickRewardId, + discountId: defaultGroup.discountId, + }, }), ), ); @@ -117,11 +120,17 @@ export async function importPartners(payload: ToltImportPayload) { async function createPartner({ program, affiliate, - reward, + defaultGroupAttributes, }: { program: Program; affiliate: ToltAffiliate; - reward?: Pick; + defaultGroupAttributes: { + groupId: string; + saleRewardId: string | null; + leadRewardId: string | null; + clickRewardId: string | null; + discountId: string | null; + }; }) { const partner = await prisma.partner.upsert({ where: { @@ -150,7 +159,7 @@ async function createPartner({ programId: program.id, partnerId: partner.id, status: "approved", - ...(reward && { [REWARD_EVENT_COLUMN_MAPPING[reward.event]]: reward.id }), + ...defaultGroupAttributes, }, update: { status: "approved", diff --git a/apps/web/scripts/migrations/backfill-partner-groups.ts b/apps/web/scripts/migrations/backfill-partner-groups.ts index d7d29ef6a21..3dc60bd08b7 100644 --- a/apps/web/scripts/migrations/backfill-partner-groups.ts +++ b/apps/web/scripts/migrations/backfill-partner-groups.ts @@ -141,6 +141,7 @@ async function main() { const hasDefaultReward = rewards.some( (reward) => + // @ts-ignore (old reward schema) reward.default && (reward.id === group.saleRewardId || reward.id === group.leadRewardId || diff --git a/apps/web/scripts/migrate-discounts.ts b/apps/web/scripts/migrations/migrate-discounts.ts similarity index 100% rename from apps/web/scripts/migrate-discounts.ts rename to apps/web/scripts/migrations/migrate-discounts.ts diff --git a/apps/web/scripts/migrate-domains.ts b/apps/web/scripts/migrations/migrate-domains.ts similarity index 100% rename from apps/web/scripts/migrate-domains.ts rename to apps/web/scripts/migrations/migrate-domains.ts diff --git a/apps/web/scripts/migrate-images.ts b/apps/web/scripts/migrations/migrate-images.ts similarity index 100% rename from apps/web/scripts/migrate-images.ts rename to apps/web/scripts/migrations/migrate-images.ts diff --git a/apps/web/scripts/migrate-integrations.ts b/apps/web/scripts/migrations/migrate-integrations.ts similarity index 100% rename from apps/web/scripts/migrate-integrations.ts rename to apps/web/scripts/migrations/migrate-integrations.ts diff --git a/apps/web/scripts/migrate-links-to-workspaces.ts b/apps/web/scripts/migrations/migrate-links-to-workspaces.ts similarity index 100% rename from apps/web/scripts/migrate-links-to-workspaces.ts rename to apps/web/scripts/migrations/migrate-links-to-workspaces.ts diff --git a/apps/web/scripts/migrate-partner-links.ts b/apps/web/scripts/migrations/migrate-partner-links.ts similarity index 100% rename from apps/web/scripts/migrate-partner-links.ts rename to apps/web/scripts/migrations/migrate-partner-links.ts diff --git a/apps/web/scripts/migrate-program-invites.ts b/apps/web/scripts/migrations/migrate-program-invites.ts similarity index 97% rename from apps/web/scripts/migrate-program-invites.ts rename to apps/web/scripts/migrations/migrate-program-invites.ts index 7eebefbeaa5..8058fb832cf 100644 --- a/apps/web/scripts/migrate-program-invites.ts +++ b/apps/web/scripts/migrations/migrate-program-invites.ts @@ -7,8 +7,8 @@ import { SaleEvent } from "@/lib/types"; import { prisma } from "@dub/prisma"; import { EventType } from "@prisma/client"; import "dotenv-flow/config"; -import { getEvents } from "../lib/analytics/get-events"; -import { recordLink } from "../lib/tinybird"; +import { getEvents } from "../../lib/analytics/get-events"; +import { recordLink } from "../../lib/tinybird"; async function main() { const programInvites = await prisma.programInvite.findMany({ diff --git a/apps/web/scripts/migrate-rewards-remainder.ts b/apps/web/scripts/migrations/migrate-rewards-remainder.ts similarity index 99% rename from apps/web/scripts/migrate-rewards-remainder.ts rename to apps/web/scripts/migrations/migrate-rewards-remainder.ts index c1b9d77e3c1..a81871f1e18 100644 --- a/apps/web/scripts/migrate-rewards-remainder.ts +++ b/apps/web/scripts/migrations/migrate-rewards-remainder.ts @@ -1,3 +1,5 @@ +// @ts-nocheck + import { prisma } from "@dub/prisma"; import "dotenv-flow/config"; diff --git a/apps/web/scripts/migrate-rewards.ts b/apps/web/scripts/migrations/migrate-rewards.ts similarity index 100% rename from apps/web/scripts/migrate-rewards.ts rename to apps/web/scripts/migrations/migrate-rewards.ts diff --git a/apps/web/scripts/migrate-sales.ts b/apps/web/scripts/migrations/migrate-sales.ts similarity index 100% rename from apps/web/scripts/migrate-sales.ts rename to apps/web/scripts/migrations/migrate-sales.ts diff --git a/packages/prisma/schema/discount.prisma b/packages/prisma/schema/discount.prisma index 3af1a8882c4..d4cab8f6a3f 100644 --- a/packages/prisma/schema/discount.prisma +++ b/packages/prisma/schema/discount.prisma @@ -7,7 +7,6 @@ model Discount { description String? couponId String? couponTestId String? - default Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/packages/prisma/schema/reward.prisma b/packages/prisma/schema/reward.prisma index 1a1b80fb5f0..97b907f931a 100644 --- a/packages/prisma/schema/reward.prisma +++ b/packages/prisma/schema/reward.prisma @@ -18,7 +18,6 @@ model Reward { amount Int @default(0) maxDuration Int? // in months (0 -> not recurring, null -> infinite) maxAmount Int? // how much a partner can receive payouts (in cents) - default Boolean @default(false) modifiers Json? @db.Json createdAt DateTime @default(now()) updatedAt DateTime @updatedAt From 196bd6038cec9bd7b21241a11bb44ae3fcea2919 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Fri, 15 Aug 2025 18:22:51 -0700 Subject: [PATCH 2/3] idempotency, backlinks, redirects --- apps/web/lib/actions/partners/update-program.ts | 1 - apps/web/lib/middleware/utils/app-redirect.ts | 3 +++ apps/web/lib/rewardful/import-campaign.ts | 14 +++++++++++--- apps/web/ui/partners/partner-details-sheet.tsx | 8 ++++++-- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/web/lib/actions/partners/update-program.ts b/apps/web/lib/actions/partners/update-program.ts index f061af060f6..cb98c0af146 100644 --- a/apps/web/lib/actions/partners/update-program.ts +++ b/apps/web/lib/actions/partners/update-program.ts @@ -137,7 +137,6 @@ export const updateProgramAction = authActionClient ? [ revalidatePath(`/partners.dub.co/${program.slug}`), revalidatePath(`/partners.dub.co/${program.slug}/apply`), - revalidatePath(`/partners.dub.co/${program.slug}/apply/form`), revalidatePath(`/partners.dub.co/${program.slug}/apply/success`), ] : []), diff --git a/apps/web/lib/middleware/utils/app-redirect.ts b/apps/web/lib/middleware/utils/app-redirect.ts index 7d74c939529..e0bcb035d0b 100644 --- a/apps/web/lib/middleware/utils/app-redirect.ts +++ b/apps/web/lib/middleware/utils/app-redirect.ts @@ -14,6 +14,9 @@ const PROGRAM_REDIRECTS = { "/program/sales": "/program/commissions", "/program/communication": "/program/resources", "/program/branding/resources": "/program/resources", + "/program/rewards": "/program/groups/default/rewards", + "/program/discount": "/program/groups/default/discount", + "/program/discounts": "/program/groups/default/discount", }; export const appRedirect = (path: string) => { diff --git a/apps/web/lib/rewardful/import-campaign.ts b/apps/web/lib/rewardful/import-campaign.ts index efb80dbd8fc..7ec1d726621 100644 --- a/apps/web/lib/rewardful/import-campaign.ts +++ b/apps/web/lib/rewardful/import-campaign.ts @@ -71,12 +71,20 @@ export async function importCampaign(payload: RewardfulImportPayload) { // if the default group has an associated sale reward already, we need to create a new group if (groups[0].saleRewardId) { - const createdGroup = await prisma.partnerGroup.create({ - data: { + const groupSlug = `rewardful-${campaignId}`; + const createdGroup = await prisma.partnerGroup.upsert({ + where: { + programId_slug: { + programId, + slug: groupSlug, + }, + }, + update: {}, + create: { id: createId({ prefix: "grp_" }), programId, name: `(Rewardful) ${campaign.name}`, - slug: `rewardful-${campaignId}`, + slug: groupSlug, color: randomValue(RESOURCE_COLORS), saleRewardId: createdReward.id, }, diff --git a/apps/web/ui/partners/partner-details-sheet.tsx b/apps/web/ui/partners/partner-details-sheet.tsx index f867e32df7a..8bda2150ef5 100644 --- a/apps/web/ui/partners/partner-details-sheet.tsx +++ b/apps/web/ui/partners/partner-details-sheet.tsx @@ -106,9 +106,13 @@ function PartnerDetailsSheetContent({ partner }: PartnerDetailsSheetProps) {
)} {group ? ( - + {group.name} - + ) : (
)} From f8756916ce71e7d40e9683c0a80e14a1f83f54fd Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Fri, 15 Aug 2025 18:27:10 -0700 Subject: [PATCH 3/3] address coderabbit feedback --- .../lib/api/partners/create-and-enroll-partner.ts | 12 +++++++++++- apps/web/lib/rewardful/import-campaign.ts | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/api/partners/create-and-enroll-partner.ts b/apps/web/lib/api/partners/create-and-enroll-partner.ts index ccc57b983cd..45d76c45054 100644 --- a/apps/web/lib/api/partners/create-and-enroll-partner.ts +++ b/apps/web/lib/api/partners/create-and-enroll-partner.ts @@ -82,9 +82,19 @@ export const createAndEnrollPartner = async ({ } } + const finalGroupId = groupId || program.defaultGroupId; + // this should never happen, but just in case + if (!finalGroupId) { + throw new DubApiError({ + message: + "There was no group ID provided, and the program does not have a default group. Please contact support.", + code: "bad_request", + }); + } + const group = await getGroupOrThrow({ programId: program.id, - groupId: groupId || program.defaultGroupId!, + groupId: finalGroupId, includeRewardsAndDiscount: true, }); diff --git a/apps/web/lib/rewardful/import-campaign.ts b/apps/web/lib/rewardful/import-campaign.ts index 7ec1d726621..b929c33ef17 100644 --- a/apps/web/lib/rewardful/import-campaign.ts +++ b/apps/web/lib/rewardful/import-campaign.ts @@ -70,7 +70,7 @@ export async function importCampaign(payload: RewardfulImportPayload) { }); // if the default group has an associated sale reward already, we need to create a new group - if (groups[0].saleRewardId) { + if (groups.length > 0 && groups[0].saleRewardId) { const groupSlug = `rewardful-${campaignId}`; const createdGroup = await prisma.partnerGroup.upsert({ where: {