From 3519eb0168ec662650927df65553ff2e3eab8238 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 8 Sep 2025 14:13:17 +0530 Subject: [PATCH 01/16] FirstPromoter importer --- .../api/cron/import/firstpromoter/route.ts | 46 ++ apps/web/lib/firstpromoter/api.ts | 117 ++++++ .../lib/firstpromoter/import-commissions.ts | 392 ++++++++++++++++++ .../web/lib/firstpromoter/import-customers.ts | 252 +++++++++++ apps/web/lib/firstpromoter/import-partners.ts | 193 +++++++++ apps/web/lib/firstpromoter/importer.ts | 41 ++ apps/web/lib/firstpromoter/schemas.ts | 141 +++++++ apps/web/lib/firstpromoter/types.ts | 27 ++ apps/web/lib/zod/schemas/import-error-log.ts | 2 +- .../email/src/templates/program-imported.tsx | 2 +- 10 files changed, 1211 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts create mode 100644 apps/web/lib/firstpromoter/api.ts create mode 100644 apps/web/lib/firstpromoter/import-commissions.ts create mode 100644 apps/web/lib/firstpromoter/import-customers.ts create mode 100644 apps/web/lib/firstpromoter/import-partners.ts create mode 100644 apps/web/lib/firstpromoter/importer.ts create mode 100644 apps/web/lib/firstpromoter/schemas.ts create mode 100644 apps/web/lib/firstpromoter/types.ts diff --git a/apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts b/apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts new file mode 100644 index 00000000000..584f07a14d9 --- /dev/null +++ b/apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts @@ -0,0 +1,46 @@ +import { handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { importCommissions } from "@/lib/firstpromoter/import-commissions"; +import { importPartners } from "@/lib/firstpromoter/import-partners"; +import { firstPromoterImportPayloadSchema } from "@/lib/firstpromoter/schemas"; +import { importCustomers } from "@/lib/partnerstack/import-customers"; +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +export async function POST(req: Request) { + try { + const rawBody = await req.text(); + + await verifyQstashSignature({ + req, + rawBody, + }); + + const payload = firstPromoterImportPayloadSchema.parse(JSON.parse(rawBody)); + + switch (payload.action) { + // case "import-groups": + // await importGroups(payload); + // break; + case "import-partners": + await importPartners(payload); + break; + // case "import-links": + // await importLinks(payload); + // break; + case "import-customers": + await importCustomers(payload); + break; + case "import-commissions": + await importCommissions(payload); + break; + default: + throw new Error(`Unknown action: ${payload.action}`); + } + + return NextResponse.json("OK"); + } catch (error) { + return handleAndReturnErrorResponse(error); + } +} diff --git a/apps/web/lib/firstpromoter/api.ts b/apps/web/lib/firstpromoter/api.ts new file mode 100644 index 00000000000..6ec794e5fd6 --- /dev/null +++ b/apps/web/lib/firstpromoter/api.ts @@ -0,0 +1,117 @@ +import { partnerStackLink } from "../partnerstack/schemas"; +import { + firstPromoterCampaignSchema, + firstPromoterCommissionSchema, + firstPromoterCustomerSchema, + firstPromoterPartnerSchema, +} from "./schemas"; + +const PAGE_LIMIT = 100; + +export class FirstPromoterApi { + private readonly baseUrl = "https://v2.firstpromoter.com/api/v2/company"; + private readonly apiKey: string; + private readonly accountId: string; + + constructor({ apiKey, accountId }: { apiKey: string; accountId: string }) { + this.apiKey = apiKey; + this.accountId = accountId; + } + + private async fetch(path: string): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + headers: { + Authorization: `Basic ${this.apiKey}`, + "ACCOUNT-ID": this.accountId, + }, + }); + + if (!response.ok) { + const error = await response.json(); + + console.error("FirstPromoter API Error:", error); + + throw new Error(error.message || "Unknown error from FirstPromoter API."); + } + + return await response.json(); + } + + // TODO: + // Fix this + async testConnection() { + try { + await this.fetch("/promoters?limit=1"); + return true; + } catch (error) { + throw new Error("Invalid FirstPromoter API token."); + } + } + + async listGroups() { + const campaigns = await this.fetch("/promoter_campaigns"); + + return firstPromoterCampaignSchema.array().parse(campaigns); + } + + async listPartners({ + campaignId, + page, + }: { + campaignId: string; + page?: number; + }) { + const filters = { + campaign_id: campaignId, + archived: false, + }; + + const params: Record = { + filters: JSON.stringify(filters), + per_page: PAGE_LIMIT.toString(), + ...(page ? { page: page.toString() } : {}), + }; + + const searchParams = new URLSearchParams(params); + + const partners = await this.fetch(`/promoters?${searchParams.toString()}`); + + return firstPromoterPartnerSchema.array().parse(partners); + } + + // TODO: + // Fix this + async listLinks({ identifier }: { identifier: string }) { + const links = await this.fetch(`/links/partnership/${identifier}`); + + return partnerStackLink.array().parse(links); + } + + async listCustomers({ page }: { page?: number }) { + const params: Record = { + per_page: PAGE_LIMIT.toString(), + ...(page ? { page: page.toString() } : {}), + }; + + const searchParams = new URLSearchParams(params); + + const customers = await this.fetch(`/referrals?${searchParams.toString()}`); + + return firstPromoterCustomerSchema.array().parse(customers); + } + + async listCommissions({ page }: { page?: number }) { + const params: Record = { + per_page: PAGE_LIMIT.toString(), + ...(page ? { page: page.toString() } : {}), + }; + + const searchParams = new URLSearchParams(params); + + const commissions = await this.fetch( + `/commissions?${searchParams.toString()}`, + ); + + return firstPromoterCommissionSchema.array().parse(commissions); + } +} diff --git a/apps/web/lib/firstpromoter/import-commissions.ts b/apps/web/lib/firstpromoter/import-commissions.ts new file mode 100644 index 00000000000..dfa2787323c --- /dev/null +++ b/apps/web/lib/firstpromoter/import-commissions.ts @@ -0,0 +1,392 @@ +import { sendEmail } from "@dub/email"; +import ProgramImported from "@dub/email/templates/program-imported"; +import { prisma } from "@dub/prisma"; +import { nanoid } from "@dub/utils"; +import { CommissionStatus, Customer, Link, Program } from "@prisma/client"; +import { convertCurrencyWithFxRates } from "../analytics/convert-currency"; +import { isFirstConversion } from "../analytics/is-first-conversion"; +import { createId } from "../api/create-id"; +import { syncTotalCommissions } from "../api/partners/sync-total-commissions"; +import { getLeadEvents } from "../tinybird/get-lead-events"; +import { logImportError } from "../tinybird/log-import-error"; +import { recordSaleWithTimestamp } from "../tinybird/record-sale"; +import { LeadEventTB } from "../types"; +import { redis } from "../upstash"; +import { clickEventSchemaTB } from "../zod/schemas/clicks"; +import { FirstPromoterApi } from "./api"; +import { firstPromoterImporter, MAX_BATCHES } from "./importer"; +import { FirstPromoterCommission, FirstPromoterImportPayload } from "./types"; + +const toDubStatus: Record = + { + pending: "pending", + approved: "paid", + denied: "canceled", + }; + +export async function importCommissions(payload: FirstPromoterImportPayload) { + const { importId, programId, userId, campaignId, page = 1 } = payload; + + const program = await prisma.program.findUniqueOrThrow({ + where: { + id: programId, + }, + }); + + const credentials = await firstPromoterImporter.getCredentials( + program.workspaceId, + ); + const firstPromoterApi = new FirstPromoterApi(credentials); + + const fxRates = await redis.hgetall>("fxRates:usd"); + + let hasMore = true; + let processedBatches = 0; + let currentPage = page; + + while (hasMore && processedBatches < MAX_BATCHES) { + const commissions = await firstPromoterApi.listCommissions({ + page: currentPage, + }); + + if (commissions.length === 0) { + hasMore = false; + break; + } + + const customersData = await prisma.customer.findMany({ + where: { + projectId: program.workspaceId, + email: { + in: commissions + .map(({ referral }) => referral?.email) + .filter((email): email is string => email !== null), + }, + }, + include: { + link: true, + }, + orderBy: { + createdAt: "asc", + }, + }); + + const customerLeadEvents = await getLeadEvents({ + customerIds: customersData.map(({ id }) => id), + }).then((res) => res.data); + + await Promise.all( + commissions.map((commission) => + createCommission({ + commission, + program, + campaignId, + fxRates, + importId, + customersData, + customerLeadEvents, + }), + ), + ); + + currentPage++; + processedBatches++; + } + + if (hasMore) { + await firstPromoterImporter.queue({ + ...payload, + action: "import-commissions", + page: currentPage, + }); + + return; + } + + // Imports finished + await firstPromoterImporter.deleteCredentials(program.workspaceId); + + const workspaceUser = await prisma.projectUsers.findUniqueOrThrow({ + where: { + userId_projectId: { + userId, + projectId: program.workspaceId, + }, + }, + include: { + project: true, + user: true, + }, + }); + + if (workspaceUser && workspaceUser.user.email) { + await sendEmail({ + email: workspaceUser.user.email, + subject: "FirstPromoter campaign imported", + react: ProgramImported({ + email: workspaceUser.user.email, + workspace: workspaceUser.project, + program, + provider: "FirstPromoter", + importId, + }), + }); + } +} + +async function createCommission({ + commission, + program, + campaignId, + fxRates, + importId, + customersData, + customerLeadEvents, +}: { + commission: FirstPromoterCommission; + program: Program; + campaignId: string; + fxRates: Record | null; + importId: string; + customersData: (Customer & { link: Link | null })[]; + customerLeadEvents: LeadEventTB[]; +}) { + const commonImportLogInputs = { + workspace_id: program.workspaceId, + import_id: importId, + source: "firstpromoter", + entity: "commission", + entity_id: commission.id, + } as const; + + if (commission.promoter_campaign.campaign.id !== campaignId) { + console.log( + `Affiliate ${commission.promoter_campaign.promoter.email} for commission ${commission.id}) not in campaign ${campaignId} (they're in ${commission.promoter_campaign.campaign.id}). Skipping...`, + ); + + return; + } + + // if ( + // !sale.referral.stripe_customer_id || + // !sale.referral.stripe_customer_id.startsWith("cus_") + // ) { + // await logImportError({ + // ...commonImportLogInputs, + // code: "STRIPE_CUSTOMER_NOT_FOUND", + // message: `No Stripe customer ID provided for referral ${sale.referral.id}`, + // }); + + // return; + // } + + // Find the commission + const commissionFound = await prisma.commission.findUnique({ + where: { + invoiceId_programId: { + invoiceId: commission.id, // this is not the actual invoice ID, but we use this to deduplicate the sales + programId: program.id, + }, + }, + }); + + if (commissionFound) { + console.log(`Commission ${commission.id} already exists, skipping...`); + return; + } + + // Find the customer + const customerFound = customersData.find( + ({ email }) => email === commission.referral?.email, + ); + + if (!customerFound) { + await logImportError({ + ...commonImportLogInputs, + code: "CUSTOMER_NOT_FOUND", + message: `No customer ${commission.referral?.email} found for commission ${commission.id}.`, + }); + + return; + } + + // Sale amount (can potentially be null) + let saleAmount = Number(commission.original_sale_amount ?? 0); + + const saleCurrency = commission.original_sale_currency ?? "usd"; + + if (saleCurrency.toUpperCase() !== "USD" && fxRates) { + const { amount: convertedAmount } = convertCurrencyWithFxRates({ + currency: saleCurrency, + amount: saleAmount, + fxRates, + }); + + saleAmount = convertedAmount; + } + + // Earnings + let earnings = commission.amount; + + // if (earningsCurrency !== "USD" && fxRates) { + // const { amount: convertedAmount } = convertCurrencyWithFxRates({ + // currency: earningsCurrency, + // amount: earnings, + // fxRates, + // }); + + // earnings = convertedAmount; + // } + + // here, we also check for commissions that have already been recorded on Dub + // e.g. during the transition period + // since we don't have the Stripe invoiceId from Rewardful, we use the referral's Stripe customer ID + // and check for commissions that were created with the same amount and within a +-1 hour window + const chargedAt = new Date(commission.amount); + const trackedCommission = await prisma.commission.findFirst({ + where: { + programId: program.id, + createdAt: { + gte: new Date(chargedAt.getTime() - 60 * 60 * 1000), // 1 hour before + lte: new Date(chargedAt.getTime() + 60 * 60 * 1000), // 1 hour after + }, + customerId: customerFound.id, + type: "sale", + amount: saleAmount, + }, + }); + + if (trackedCommission) { + console.log( + `Commission ${trackedCommission.id} with sale amount ${saleAmount} was already recorded on Dub. Skipping...`, + ); + + return; + } + + if (!customerFound.linkId) { + await logImportError({ + ...commonImportLogInputs, + code: "LINK_NOT_FOUND", + message: `No link found for customer ${customerFound.id}.`, + }); + + return; + } + + if (!customerFound.clickId) { + await logImportError({ + ...commonImportLogInputs, + code: "CLICK_NOT_FOUND", + message: `No click ID found for customer ${customerFound.id}.`, + }); + + return; + } + + if (!customerFound.link?.partnerId) { + await logImportError({ + ...commonImportLogInputs, + code: "PARTNER_NOT_FOUND", + message: `No partner ID found for customer ${customerFound.id}.`, + }); + + return; + } + + const leadEvent = customerLeadEvents.find( + (event) => event.customer_id === customerFound.id, + ); + + if (!leadEvent) { + await logImportError({ + ...commonImportLogInputs, + code: "LEAD_NOT_FOUND", + message: `No lead event found for customer ${customerFound.id}.`, + }); + + return; + } + + const clickData = clickEventSchemaTB + .omit({ timestamp: true }) + .parse(leadEvent); + + const eventId = nanoid(16); + + await Promise.all([ + prisma.commission.create({ + data: { + id: createId({ prefix: "cm_" }), + eventId, + type: "sale", + programId: program.id, + partnerId: customerFound.link.partnerId, + linkId: customerFound.linkId, + customerId: customerFound.id, + amount: saleAmount, + earnings, + currency: "usd", + quantity: 1, + status: toDubStatus[commission.status], + invoiceId: commission.id, // this is not the actual invoice ID, but we use this to deduplicate the sales + createdAt: new Date(commission.created_at), + }, + }), + + recordSaleWithTimestamp({ + ...clickData, + event_id: eventId, + event_name: "Invoice paid", + amount: saleAmount, + customer_id: customerFound.id, + payment_processor: "stripe", + currency: "usd", + metadata: JSON.stringify(commission.metadata), + timestamp: new Date(commission.created_at).toISOString(), + }), + + // update link stats + prisma.link.update({ + where: { + id: customerFound.linkId, + }, + data: { + ...(isFirstConversion({ + customer: customerFound, + linkId: customerFound.linkId, + }) && { + conversions: { + increment: 1, + }, + }), + sales: { + increment: 1, + }, + saleAmount: { + increment: saleAmount, + }, + }, + }), + + // update customer stats + prisma.customer.update({ + where: { + id: customerFound.id, + }, + data: { + sales: { + increment: 1, + }, + saleAmount: { + increment: saleAmount, + }, + }, + }), + ]); + + await syncTotalCommissions({ + partnerId: customerFound.link.partnerId, + programId: program.id, + }); +} diff --git a/apps/web/lib/firstpromoter/import-customers.ts b/apps/web/lib/firstpromoter/import-customers.ts new file mode 100644 index 00000000000..3aa669e2345 --- /dev/null +++ b/apps/web/lib/firstpromoter/import-customers.ts @@ -0,0 +1,252 @@ +import { prisma } from "@dub/prisma"; +import { nanoid } from "@dub/utils"; +import { Link, Project } from "@prisma/client"; +import { createId } from "../api/create-id"; +import { recordClick, recordLeadWithTimestamp } from "../tinybird"; +import { logImportError } from "../tinybird/log-import-error"; +import { clickEventSchemaTB } from "../zod/schemas/clicks"; +import { FirstPromoterApi } from "./api"; +import { firstPromoterImporter, MAX_BATCHES } from "./importer"; +import { FirstPromoterCustomer, FirstPromoterImportPayload } from "./types"; + +export async function importCustomers(payload: FirstPromoterImportPayload) { + const { importId, programId, page = 1 } = payload; + + const program = await prisma.program.findUniqueOrThrow({ + where: { + id: programId, + }, + select: { + workspace: { + select: { + id: true, + stripeConnectId: true, + }, + }, + }, + }); + + const { workspace } = program; + + const credentials = await firstPromoterImporter.getCredentials(workspace.id); + const firstPromoterApi = new FirstPromoterApi(credentials); + + let hasMore = true; + let processedBatches = 0; + let currentPage = page; + + while (hasMore && processedBatches < MAX_BATCHES) { + const customers = await firstPromoterApi.listCustomers({ + page: currentPage, + }); + + if (customers.length === 0) { + hasMore = false; + break; + } + + const promoters = customers.map( + ({ promoter_campaign }) => promoter_campaign.promoter, + ); + + const partners = await prisma.partner.findMany({ + where: { + email: { + in: promoters.map(({ email }) => email), + }, + }, + select: { + id: true, + }, + }); + + const partnerIds = partners.map(({ id }) => id); + + if (partnerIds.length > 0) { + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + partnerId: { + in: partnerIds, + }, + programId, + }, + select: { + partner: { + select: { + email: true, + }, + }, + links: { + select: { + id: true, + key: true, + domain: true, + url: true, + }, + }, + }, + }); + + const partnerEmailToLinks = programEnrollments.reduce( + (acc, { partner, links }) => { + const email = partner.email!; // assert non-null + acc[email] = (acc[email] ?? []).concat(links); + return acc; + }, + {} as Record, + ); + + await Promise.allSettled( + customers.map((customer) => { + const links = + partnerEmailToLinks[customer.promoter_campaign.promoter.email] ?? + []; + + return createCustomer({ + workspace, + links, + customer, + importId, + }); + }), + ); + } + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + currentPage++; + processedBatches++; + } + + await firstPromoterImporter.queue({ + ...payload, + page: hasMore ? currentPage : undefined, + action: hasMore ? "import-customers" : "import-commissions", + }); +} + +async function createCustomer({ + workspace, + links, + customer, + importId, +}: { + workspace: Pick; + links: Pick[]; + customer: FirstPromoterCustomer; + importId: string; +}) { + const commonImportLogInputs = { + workspace_id: workspace.id, + import_id: importId, + source: "firstpromoter", + entity: "customer", + entity_id: customer.uid, + } as const; + + if (links.length === 0) { + await logImportError({ + ...commonImportLogInputs, + code: "LINK_NOT_FOUND", + message: `Link not found for customer ${customer.uid}.`, + }); + + return; + } + + if (!customer.email) { + await logImportError({ + ...commonImportLogInputs, + code: "CUSTOMER_EMAIL_NOT_FOUND", + message: `Email not found for customer ${customer.uid}.`, + }); + + return; + } + + // Find the customer by email address + const customerFound = await prisma.customer.findFirst({ + where: { + projectId: workspace.id, + OR: [{ externalId: customer.uid }, { email: customer.email }], + }, + }); + + if (customerFound) { + console.log(`A customer already exists with email ${customer.email}`); + return; + } + + const link = links[0]; + + const dummyRequest = new Request(link.url, { + headers: new Headers({ + "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + "x-forwarded-for": "127.0.0.1", + "x-vercel-ip-country": "US", + "x-vercel-ip-country-region": "CA", + "x-vercel-ip-continent": "NA", + }), + }); + + const clickData = await recordClick({ + req: dummyRequest, + linkId: link.id, + clickId: nanoid(16), + url: link.url, + domain: link.domain, + key: link.key, + workspaceId: workspace.id, + skipRatelimit: true, + timestamp: new Date(customer.created_at).toISOString(), + }); + + const clickEvent = clickEventSchemaTB.parse({ + ...clickData, + bot: 0, + qr: 0, + }); + + const customerId = createId({ prefix: "cus_" }); + + try { + await prisma.customer.create({ + data: { + id: customerId, + name: customer.email, + email: customer.email, + projectId: workspace.id, + projectConnectId: workspace.stripeConnectId, + clickId: clickEvent.click_id, + linkId: link.id, + country: clickEvent.country, + clickedAt: new Date(customer.created_at), + createdAt: new Date(customer.created_at), + externalId: customer.uid || customer.email, + }, + }); + + await Promise.all([ + recordLeadWithTimestamp({ + ...clickEvent, + event_id: nanoid(16), + event_name: "Sign up", + customer_id: customerId, + timestamp: new Date(customer.created_at).toISOString(), + }), + + prisma.link.update({ + where: { + id: link.id, + }, + data: { + leads: { + increment: 1, + }, + }, + }), + ]); + } catch (error) { + console.error("Error creating customer", customer, error); + } +} diff --git a/apps/web/lib/firstpromoter/import-partners.ts b/apps/web/lib/firstpromoter/import-partners.ts new file mode 100644 index 00000000000..f3809933229 --- /dev/null +++ b/apps/web/lib/firstpromoter/import-partners.ts @@ -0,0 +1,193 @@ +import { prisma } from "@dub/prisma"; +import { PartnerGroup, 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 { DEFAULT_PARTNER_GROUP } from "../zod/schemas/groups"; +import { FirstPromoterApi } from "./api"; +import { firstPromoterImporter, MAX_BATCHES } from "./importer"; +import { FirstPromoterImportPayload, FirstPromoterPartner } from "./types"; + +export async function importPartners(payload: FirstPromoterImportPayload) { + const { importId, userId, programId, groupId, campaignId, page = 1 } = 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 group = program.groups[0]; + + const credentials = await firstPromoterImporter.getCredentials( + program.workspaceId, + ); + + const firstPromoterApi = new FirstPromoterApi(credentials); + + let hasMore = true; + let processedBatches = 0; + let currentPage = page; + + const commonImportLogInputs = { + workspace_id: program.workspaceId, + import_id: importId, + source: "firstpromoter", + entity: "partner", + } as const; + + while (hasMore && processedBatches < MAX_BATCHES) { + const affiliates = await firstPromoterApi.listPartners({ + campaignId, + page: currentPage, + }); + + if (affiliates.length === 0) { + hasMore = false; + break; + } + + const activeAffiliates: typeof affiliates = []; + const notImportedAffiliates: typeof affiliates = []; + + for (const affiliate of affiliates) { + if ( + affiliate.state === "accepted" && + affiliate.stats.referrals_count > 0 + ) { + activeAffiliates.push(affiliate); + } else { + notImportedAffiliates.push(affiliate); + } + } + + if (activeAffiliates.length > 0) { + await Promise.all( + activeAffiliates.map((affiliate) => + createPartnerAndLinks({ + program, + affiliate, + userId, + group, + }), + ), + ); + } + + if (notImportedAffiliates.length > 0) { + await logImportError( + notImportedAffiliates.map((affiliate) => ({ + ...commonImportLogInputs, + entity_id: affiliate.id, + code: "INACTIVE_PARTNER", + message: `Partner ${affiliate.email} not imported because it is not active or has no leads.`, + })), + ); + } + + currentPage++; + processedBatches++; + } + + const action = hasMore ? "import-partners" : "import-customers"; + + await firstPromoterImporter.queue({ + ...payload, + action, + ...(action === "import-partners" && groupId && { groupId }), + page: hasMore ? currentPage : undefined, + }); +} + +// Create partner and their links +async function createPartnerAndLinks({ + program, + affiliate, + userId, + group, +}: { + program: Program; + affiliate: FirstPromoterPartner; + userId: string; + group: Pick< + PartnerGroup, + "id" | "clickRewardId" | "leadRewardId" | "saleRewardId" | "discountId" + >; +}) { + const partner = await prisma.partner.upsert({ + where: { + email: affiliate.email, + }, + create: { + id: createId({ prefix: "pn_" }), + name: affiliate.name, + email: affiliate.email, + }, + update: {}, + }); + + const programEnrollment = await prisma.programEnrollment.upsert({ + where: { + partnerId_programId: { + partnerId: partner.id, + programId: program.id, + }, + }, + create: { + id: createId({ prefix: "pge_" }), + programId: program.id, + partnerId: partner.id, + status: "approved", + groupId: group.id, + clickRewardId: group.clickRewardId, + leadRewardId: group.leadRewardId, + saleRewardId: group.saleRewardId, + discountId: group.discountId, + }, + update: { + status: "approved", + }, + include: { + links: true, + }, + }); + + if (!program.domain || !program.url) { + console.error("Program domain or url not found", program.id); + return; + } + + if (programEnrollment.links.length > 0) { + console.log("Partner already has links", partner.id); + return; + } + + await bulkCreateLinks({ + links: affiliate.links.map((link) => ({ + domain: program.domain!, + key: link.token || nanoid(), + url: program.url!, + trackConversion: true, + programId: program.id, + partnerId: partner.id, + folderId: program.defaultFolderId, + userId, + projectId: program.workspaceId, + })), + }); +} diff --git a/apps/web/lib/firstpromoter/importer.ts b/apps/web/lib/firstpromoter/importer.ts new file mode 100644 index 00000000000..b22feda0737 --- /dev/null +++ b/apps/web/lib/firstpromoter/importer.ts @@ -0,0 +1,41 @@ +import { qstash } from "@/lib/cron"; +import { redis } from "@/lib/upstash"; +import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; +import { FirstPromoterCredentials, FirstPromoterImportPayload } from "./types"; + +export const MAX_BATCHES = 5; +export const CACHE_EXPIRY = 60 * 60 * 24; +export const CACHE_KEY_PREFIX = "firstpromoter:import"; + +class FirstPromoterImporter { + async setCredentials(workspaceId: string, payload: FirstPromoterCredentials) { + await redis.set(`${CACHE_KEY_PREFIX}:${workspaceId}`, payload, { + ex: CACHE_EXPIRY, + }); + } + + async getCredentials(workspaceId: string): Promise { + const config = await redis.get( + `${CACHE_KEY_PREFIX}:${workspaceId}`, + ); + + if (!config) { + throw new Error("FirstPromoter configuration not found."); + } + + return config; + } + + async deleteCredentials(workspaceId: string) { + return await redis.del(`${CACHE_KEY_PREFIX}:${workspaceId}`); + } + + async queue(body: FirstPromoterImportPayload) { + return await qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/firstpromoter`, + body, + }); + } +} + +export const firstPromoterImporter = new FirstPromoterImporter(); diff --git a/apps/web/lib/firstpromoter/schemas.ts b/apps/web/lib/firstpromoter/schemas.ts new file mode 100644 index 00000000000..c8f5c11c063 --- /dev/null +++ b/apps/web/lib/firstpromoter/schemas.ts @@ -0,0 +1,141 @@ +import { z } from "zod"; + +export const firstPromoterImportSteps = z.enum([ + "import-partners", + "import-links", + "import-customers", + "import-commissions", +]); + +export const firstPromoterCredentialsSchema = z.object({ + apiKey: z.string().min(1), + accountId: z.string().min(1), +}); + +export const firstPromoterImportPayloadSchema = z.object({ + action: firstPromoterImportSteps, + importId: z.string(), + userId: z.string(), + programId: z.string(), + groupId: z.string().optional(), + campaignId: z.string(), + page: z.number().optional(), +}); + +export const firstPromoterCampaignSchema = z.object({ + id: z.string(), + campaign: z.object({ + id: z.string(), + name: z.string(), + }), +}); + +export const firstPromoterPartnerSchema = z.object({ + id: z.string(), + email: z.string(), + name: z.string(), + cust_id: z.string().nullable(), + state: z.enum([ + "pending", + "accepted", + "rejected", + "blocked", + "inactive", + "not_set", + ]), + profile: z.object({ + id: z.string(), + first_name: z.string(), + last_name: z.string(), + website: z.string().nullable(), + company_name: z.string().nullable(), + company_number: z.string().nullable(), + vat_id: z.string().nullable(), + country: z.string().nullable(), + address: z.string().nullable(), + avatar: z.string().nullable(), + description: z.string().nullable(), + youtube_url: z.string().nullable(), + twitter_url: z.string().nullable(), + linkedin_url: z.string().nullable(), + instagram_url: z.string().nullable(), + tiktok_url: z.string().nullable(), + joined_at: z.string(), + }), + stats: z.object({ + referrals_count: z.number(), + }), +}); + +export const firstPromoterCustomerSchema = z.object({ + id: z.string(), + email: z.string(), + uid: z.string(), + state: z.enum([ + "subscribed", + "signup", + "active", + "cancelled", + "refunded", + "denied", + "pending", + "moved", + ]), + metadata: z.record(z.any()).nullable(), + created_at: z.string(), + customer_since: z.string(), + promoter_campaign: z.object({ + promoter: firstPromoterPartnerSchema.pick({ + id: true, + email: true, + }), + }), +}); + +export const firstPromoterCommissionSchema = z.object({ + id: z.string(), + status: z.enum(["pending", "approved", "denied"]), + metadata: z.record(z.any()).nullable(), + is_self_referral: z.boolean(), + commission_type: z.enum(["sale", "custom"]), + sale_amount: z.number(), + amount: z.number(), + is_paid: z.boolean(), + is_split: z.boolean(), + created_at: z.string(), + original_sale_amount: z.number(), + original_sale_currency: z.string().nullable(), + external_note: z.string().nullable(), + unit: z.enum([ + "cash", + "credits", + "points", + "free_months", + "mon_discount", + "discount_per", + ]), + fraud_check: z + .enum([ + "no_suspicion", + "same_ip_suspicion", + "same_promoter_email", + "ad_source", + ]) + .nullable(), + referral: firstPromoterCustomerSchema + .pick({ + id: true, + email: true, + uid: true, + }) + .nullable(), + promoter_campaign: z.object({ + campaign: firstPromoterCampaignSchema.pick({ + id: true, + }), + promoter: firstPromoterPartnerSchema.pick({ + id: true, + email: true, + }), + }), +}); diff --git a/apps/web/lib/firstpromoter/types.ts b/apps/web/lib/firstpromoter/types.ts new file mode 100644 index 00000000000..ce75ff10834 --- /dev/null +++ b/apps/web/lib/firstpromoter/types.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import { + firstPromoterCampaignSchema, + firstPromoterCommissionSchema, + firstPromoterCredentialsSchema, + firstPromoterCustomerSchema, + firstPromoterImportPayloadSchema, + firstPromoterPartnerSchema, +} from "./schemas"; + +export type FirstPromoterCredentials = z.infer< + typeof firstPromoterCredentialsSchema +>; + +export type FirstPromoterImportPayload = z.infer< + typeof firstPromoterImportPayloadSchema +>; + +export type FirstPromoterCampaign = z.infer; + +export type FirstPromoterPartner = z.infer; + +export type FirstPromoterCustomer = z.infer; + +export type FirstPromoterCommission = z.infer< + typeof firstPromoterCommissionSchema +>; diff --git a/apps/web/lib/zod/schemas/import-error-log.ts b/apps/web/lib/zod/schemas/import-error-log.ts index 3a6d86ee194..b3ce823a8ea 100644 --- a/apps/web/lib/zod/schemas/import-error-log.ts +++ b/apps/web/lib/zod/schemas/import-error-log.ts @@ -3,7 +3,7 @@ import { z } from "zod"; export const importErrorLogSchema = z.object({ workspace_id: z.string(), import_id: z.string(), - source: z.enum(["rewardful", "tolt", "partnerstack"]), + source: z.enum(["rewardful", "tolt", "partnerstack", "firstpromoter"]), entity: z.enum(["partner", "link", "customer", "commission"]), entity_id: z.string(), code: z.enum([ diff --git a/packages/email/src/templates/program-imported.tsx b/packages/email/src/templates/program-imported.tsx index 807cafd87c7..7b76be8ca09 100644 --- a/packages/email/src/templates/program-imported.tsx +++ b/packages/email/src/templates/program-imported.tsx @@ -26,7 +26,7 @@ export default function ProgramImported({ importId = "1K1QFYS3W9CJTEJ325SQKWCHF", }: { email: string; - provider: "Rewardful" | "Tolt" | "PartnerStack"; + provider: "Rewardful" | "Tolt" | "PartnerStack" | "FirstPromoter"; workspace: { slug: string; }; From 703351e788259a89aae617587382bb8e2e7e010f Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 8 Sep 2025 14:41:36 +0530 Subject: [PATCH 02/16] add import modal --- .../api/cron/import/firstpromoter/route.ts | 2 +- .../programs/firstpromoter/campaigns/route.ts | 24 ++ .../partners/import-export-buttons.tsx | 3 + .../partners/set-firstpromoter-token.ts | 41 +++ .../partners/start-firstpromoter-import.ts | 43 +++ apps/web/lib/firstpromoter/api.ts | 2 +- apps/web/lib/partners/constants.ts | 6 + .../ui/modals/import-firstpromoter-modal.tsx | 348 ++++++++++++++++++ 8 files changed, 467 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/(ee)/api/programs/firstpromoter/campaigns/route.ts create mode 100644 apps/web/lib/actions/partners/set-firstpromoter-token.ts create mode 100644 apps/web/lib/actions/partners/start-firstpromoter-import.ts create mode 100644 apps/web/ui/modals/import-firstpromoter-modal.tsx diff --git a/apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts b/apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts index 584f07a14d9..7edfd2641ca 100644 --- a/apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts +++ b/apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts @@ -3,7 +3,7 @@ import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { importCommissions } from "@/lib/firstpromoter/import-commissions"; import { importPartners } from "@/lib/firstpromoter/import-partners"; import { firstPromoterImportPayloadSchema } from "@/lib/firstpromoter/schemas"; -import { importCustomers } from "@/lib/partnerstack/import-customers"; +import { importCustomers } from "@/lib/firstpromoter/import-customers"; import { NextResponse } from "next/server"; export const dynamic = "force-dynamic"; diff --git a/apps/web/app/(ee)/api/programs/firstpromoter/campaigns/route.ts b/apps/web/app/(ee)/api/programs/firstpromoter/campaigns/route.ts new file mode 100644 index 00000000000..c4619c7b680 --- /dev/null +++ b/apps/web/app/(ee)/api/programs/firstpromoter/campaigns/route.ts @@ -0,0 +1,24 @@ +import { DubApiError } from "@/lib/api/errors"; +import { withWorkspace } from "@/lib/auth"; +import { FirstPromoterApi } from "@/lib/firstpromoter/api"; +import { firstPromoterImporter } from "@/lib/firstpromoter/importer"; +import { NextResponse } from "next/server"; + +// GET /api/programs/firstpromoter/campaigns - list FirstPromoter campaigns +export const GET = withWorkspace(async ({ workspace }) => { + const credentials = await firstPromoterImporter.getCredentials(workspace.id); + + if (!credentials) { + throw new DubApiError({ + code: "bad_request", + message: "FirstPromoter credentials not found.", + }); + } + + const firstPromoterApi = new FirstPromoterApi(credentials); + + const campaigns = await firstPromoterApi.listCampaigns(); + const campaignsFormatted = campaigns.map(({ campaign }) => campaign); + + return NextResponse.json(campaignsFormatted); +}); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/import-export-buttons.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/import-export-buttons.tsx index d43065552bd..ef80524ddcb 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/import-export-buttons.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/import-export-buttons.tsx @@ -3,6 +3,7 @@ import { PROGRAM_IMPORT_SOURCES } from "@/lib/partners/constants"; import useWorkspace from "@/lib/swr/use-workspace"; import { useExportPartnersModal } from "@/ui/modals/export-partners-modal"; +import { useImportFirstPromoterModal } from "@/ui/modals/import-firstpromoter-modal"; import { useImportPartnerStackModal } from "@/ui/modals/import-partnerstack-modal"; import { useImportRewardfulModal } from "@/ui/modals/import-rewardful-modal"; import { useImportToltModal } from "@/ui/modals/import-tolt-modal"; @@ -19,6 +20,7 @@ export function ImportExportButtons() { const { ImportToltModal } = useImportToltModal(); const { ImportRewardfulModal } = useImportRewardfulModal(); const { ImportPartnerStackModal } = useImportPartnerStackModal(); + const { ImportFirstPromoterModal } = useImportFirstPromoterModal(); const { ExportPartnersModal, setShowExportPartnersModal } = useExportPartnersModal(); @@ -27,6 +29,7 @@ export function ImportExportButtons() { <> + { + const { workspace } = ctx; + const { apiKey, accountId } = parsedInput; + + const firstPromoterApi = new FirstPromoterApi({ apiKey, accountId }); + + try { + await firstPromoterApi.listCampaigns(); + } catch (error) { + throw new Error( + error instanceof Error + ? error.message + : "Invalid FirstPromoter credentials.", + ); + } + + await firstPromoterImporter.setCredentials(workspace.id, { + apiKey, + accountId, + }); + + return { + maskedApiKey: + apiKey.slice(0, 3) + "*".repeat(Math.max(0, apiKey.length - 3)), + }; + }); diff --git a/apps/web/lib/actions/partners/start-firstpromoter-import.ts b/apps/web/lib/actions/partners/start-firstpromoter-import.ts new file mode 100644 index 00000000000..19d5da9eea2 --- /dev/null +++ b/apps/web/lib/actions/partners/start-firstpromoter-import.ts @@ -0,0 +1,43 @@ +"use server"; + +import { createId } from "@/lib/api/create-id"; +import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; +import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; +import { firstPromoterImporter } from "@/lib/firstpromoter/importer"; +import { z } from "zod"; +import { authActionClient } from "../safe-action"; + +const schema = z.object({ + workspaceId: z.string(), + campaignId: z.string().trim().min(1), +}); + +export const startFirstPromoterImportAction = authActionClient + .schema(schema) + .action(async ({ parsedInput, ctx }) => { + const { workspace, user } = ctx; + const { campaignId } = parsedInput; + + const programId = getDefaultProgramIdOrThrow(workspace); + + const program = await getProgramOrThrow({ + workspaceId: workspace.id, + programId, + }); + + if (!program.domain) { + throw new Error("Program domain is not set."); + } + + if (!program.url) { + throw new Error("Program URL is not set."); + } + + await firstPromoterImporter.queue({ + importId: createId({ prefix: "import_" }), + action: "import-partners", + userId: user.id, + programId, + campaignId, + }); + }); diff --git a/apps/web/lib/firstpromoter/api.ts b/apps/web/lib/firstpromoter/api.ts index 6ec794e5fd6..77953fd5525 100644 --- a/apps/web/lib/firstpromoter/api.ts +++ b/apps/web/lib/firstpromoter/api.ts @@ -48,7 +48,7 @@ export class FirstPromoterApi { } } - async listGroups() { + async listCampaigns() { const campaigns = await this.fetch("/promoter_campaigns"); return firstPromoterCampaignSchema.array().parse(campaigns); diff --git a/apps/web/lib/partners/constants.ts b/apps/web/lib/partners/constants.ts index c508af9d2ea..b9371d54dde 100644 --- a/apps/web/lib/partners/constants.ts +++ b/apps/web/lib/partners/constants.ts @@ -84,6 +84,12 @@ export const PROGRAM_IMPORT_SOURCES = [ image: "https://assets.dub.co/misc/icons/partnerstack.svg", helpUrl: "https://dub.co/help/article/migrating-from-partnerstack", }, + { + id: "firstpromoter", + value: "FirstPromoter", + image: "https://assets.dub.co/misc/icons/firstpromoter.svg", + helpUrl: "https://dub.co/help/article/migrating-from-firstpromoter", + }, ] as const; export const INVOICE_AVAILABLE_PAYOUT_STATUSES = [ diff --git a/apps/web/ui/modals/import-firstpromoter-modal.tsx b/apps/web/ui/modals/import-firstpromoter-modal.tsx new file mode 100644 index 00000000000..eb72cb38683 --- /dev/null +++ b/apps/web/ui/modals/import-firstpromoter-modal.tsx @@ -0,0 +1,348 @@ +import { setFirstPromoterTokenAction } from "@/lib/actions/partners/set-firstpromoter-token"; +import { startFirstPromoterImportAction } from "@/lib/actions/partners/start-firstpromoter-import"; +import useProgram from "@/lib/swr/use-program"; +import useWorkspace from "@/lib/swr/use-workspace"; +import { + Button, + LoadingSpinner, + Logo, + Modal, + useMediaQuery, + useRouterStuff, +} from "@dub/ui"; +import { fetcher } from "@dub/utils"; +import { ArrowRight, ServerOff } from "lucide-react"; +import { useAction } from "next-safe-action/hooks"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; +import { toast } from "sonner"; +import useSWRImmutable from "swr/immutable"; + +type Campaign = { id: string; name: string }; + +function ImportFirstPromoterModal({ + showImportFirstPromoterModal, + setShowImportFirstPromoterModal, +}: { + showImportFirstPromoterModal: boolean; + setShowImportFirstPromoterModal: Dispatch>; +}) { + const { slug } = useParams() as { slug?: string }; + + const router = useRouter(); + const { queryParams } = useRouterStuff(); + const searchParams = useSearchParams(); + + const { program } = useProgram(); + const { id: workspaceId } = useWorkspace(); + + const [apiKey, setApiKey] = useState(""); + const [accountId, setAccountId] = useState(""); + const [step, setStep] = useState<"token" | "campaigns">("token"); + const [selectedCampaign, setSelectedCampaign] = useState( + null, + ); + + useEffect(() => { + if (searchParams?.get("import") === "firstpromoter") { + setShowImportFirstPromoterModal(true); + } else { + setShowImportFirstPromoterModal(false); + } + }, [searchParams]); + + const { executeAsync: setCredentials, isPending: isSettingToken } = useAction( + setFirstPromoterTokenAction, + { + onError: ({ error }) => toast.error(error.serverError), + onSuccess: () => { + setStep("campaigns"); + mutate(); + }, + }, + ); + + const { executeAsync: startImport, isPending: isStartingImport } = useAction( + startFirstPromoterImportAction, + { + onError: ({ error }) => toast.error(error.serverError), + onSuccess: () => { + toast.success( + "Successfully added campaign to import queue! We will send you an email when your campaign has been fully imported.", + ); + router.push(`/${slug}/program/partners`); + }, + }, + ); + + const { + data: campaigns, + isLoading: isLoadingCampaigns, + mutate, + } = useSWRImmutable( + showImportFirstPromoterModal && + program?.id && + workspaceId && + step === "campaigns" + ? `/api/programs/firstpromoter/campaigns?workspaceId=${workspaceId}` + : null, + fetcher, + ); + + const onSubmitToken = async (e: React.FormEvent) => { + e.preventDefault(); + if (!workspaceId || !apiKey || !accountId) return; + await setCredentials({ workspaceId, apiKey, accountId }); + }; + + const onSubmitCampaign = async (e: React.FormEvent) => { + e.preventDefault(); + if (!workspaceId || !program?.id || !selectedCampaign) return; + await startImport({ workspaceId, campaignId: selectedCampaign.id }); + }; + + return ( + queryParams({ del: "import" })} + > +
+
+ FirstPromoter logo + + +
+

+ Import Your FirstPromoter Campaign +

+

+ Import your existing FirstPromoter campaign into{" "} + {process.env.NEXT_PUBLIC_APP_NAME}. +

+
+ +
+ {step === "token" ? ( + + ) : isLoadingCampaigns || !workspaceId ? ( +
+ +

Loading campaigns...

+
+ ) : campaigns ? ( + + ) : ( +
+ +

+ Failed to load campaigns. Please try again. +

+
+ )} +
+
+ ); +} + +function TokenStep({ + apiKey, + setApiKey, + accountId, + setAccountId, + submitting, + onSubmit, +}: { + apiKey: string; + setApiKey: (v: string) => void; + accountId: string; + setAccountId: (v: string) => void; + submitting: boolean; + onSubmit: (e: React.FormEvent) => Promise; +}) { + const { isMobile } = useMediaQuery(); + return ( +
+
+ + setApiKey(e.target.value)} + placeholder="Enter your FirstPromoter API key" + className="mt-1 block w-full rounded-md border border-neutral-200 px-3 py-2 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm" + required + /> +

+ Find your FirstPromoter API key in{" "} + + Settings + +

+
+
+ + setAccountId(e.target.value)} + placeholder="Enter your FirstPromoter Account ID" + className="mt-1 block w-full rounded-md border border-neutral-200 px-3 py-2 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm" + required + /> +

+ Find your FirstPromoter Account ID in{" "} + + Settings + +

+
+