From ef0e24e9965158f7f0b86a8ba749ac9adc40e8c0 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Sun, 13 Jul 2025 14:07:31 +0530 Subject: [PATCH 01/31] wip --- .../api/cron/import/partnerstack/route.ts | 53 +++++++++++++++++++ apps/web/lib/partnerstack/api.ts | 0 .../web/lib/partnerstack/import-affiliates.ts | 0 .../lib/partnerstack/import-commissions.ts | 0 apps/web/lib/partnerstack/import-referrals.ts | 0 apps/web/lib/partnerstack/importer.ts | 0 apps/web/lib/partnerstack/schemas.ts | 0 apps/web/lib/partnerstack/types.ts | 0 .../partnerstack/update-stripe-customers.ts | 0 9 files changed, 53 insertions(+) create mode 100644 apps/web/app/(ee)/api/cron/import/partnerstack/route.ts create mode 100644 apps/web/lib/partnerstack/api.ts create mode 100644 apps/web/lib/partnerstack/import-affiliates.ts create mode 100644 apps/web/lib/partnerstack/import-commissions.ts create mode 100644 apps/web/lib/partnerstack/import-referrals.ts create mode 100644 apps/web/lib/partnerstack/importer.ts create mode 100644 apps/web/lib/partnerstack/schemas.ts create mode 100644 apps/web/lib/partnerstack/types.ts create mode 100644 apps/web/lib/partnerstack/update-stripe-customers.ts diff --git a/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts b/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts new file mode 100644 index 00000000000..73f9b5c0970 --- /dev/null +++ b/apps/web/app/(ee)/api/cron/import/partnerstack/route.ts @@ -0,0 +1,53 @@ +import { handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; +import { importAffiliates } from "@/lib/tolt/import-affiliates"; +import { importCommissions } from "@/lib/tolt/import-commissions"; +import { importLinks } from "@/lib/tolt/import-links"; +import { importReferrals } from "@/lib/tolt/import-referrals"; +import { importSteps } from "@/lib/tolt/importer"; +import { updateStripeCustomers } from "@/lib/tolt/update-stripe-customers"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +export const dynamic = "force-dynamic"; + +const schema = z.object({ + action: importSteps, + programId: z.string(), + startingAfter: z.string().optional(), +}); + +export async function POST(req: Request) { + try { + const rawBody = await req.text(); + + await verifyQstashSignature({ + req, + rawBody, + }); + + const { action, ...payload } = schema.parse(JSON.parse(rawBody)); + + switch (action) { + case "import-affiliates": + await importAffiliates(payload); + break; + case "import-links": + await importLinks(payload); + break; + case "import-referrals": + await importReferrals(payload); + break; + case "import-commissions": + await importCommissions(payload); + break; + case "update-stripe-customers": + await updateStripeCustomers(payload); + break; + } + + return NextResponse.json("OK"); + } catch (error) { + return handleAndReturnErrorResponse(error); + } +} diff --git a/apps/web/lib/partnerstack/api.ts b/apps/web/lib/partnerstack/api.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/web/lib/partnerstack/import-affiliates.ts b/apps/web/lib/partnerstack/import-affiliates.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/web/lib/partnerstack/import-commissions.ts b/apps/web/lib/partnerstack/import-commissions.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/web/lib/partnerstack/import-referrals.ts b/apps/web/lib/partnerstack/import-referrals.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/web/lib/partnerstack/importer.ts b/apps/web/lib/partnerstack/importer.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/web/lib/partnerstack/schemas.ts b/apps/web/lib/partnerstack/schemas.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/web/lib/partnerstack/types.ts b/apps/web/lib/partnerstack/types.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/web/lib/partnerstack/update-stripe-customers.ts b/apps/web/lib/partnerstack/update-stripe-customers.ts new file mode 100644 index 00000000000..e69de29bb2d From 7bc5cd7d3e2742a6fb513e7e4739a7483f4dbc8b Mon Sep 17 00:00:00 2001 From: Kiran K Date: Sun, 13 Jul 2025 14:33:51 +0530 Subject: [PATCH 02/31] add modal --- .../partners/set-partnerstack-token.ts | 47 ++++ .../partners/start-partnerstack-import.ts | 45 ++++ apps/web/lib/partners/constants.ts | 6 + apps/web/lib/partnerstack/api.ts | 39 +++ apps/web/lib/partnerstack/importer.ts | 90 +++++++ apps/web/lib/partnerstack/types.ts | 53 ++++ .../ui/modals/import-partnerstack-modal.tsx | 231 ++++++++++++++++++ apps/web/ui/modals/modal-provider.tsx | 7 + 8 files changed, 518 insertions(+) create mode 100644 apps/web/lib/actions/partners/set-partnerstack-token.ts create mode 100644 apps/web/lib/actions/partners/start-partnerstack-import.ts create mode 100644 apps/web/ui/modals/import-partnerstack-modal.tsx diff --git a/apps/web/lib/actions/partners/set-partnerstack-token.ts b/apps/web/lib/actions/partners/set-partnerstack-token.ts new file mode 100644 index 00000000000..0eb395c7af9 --- /dev/null +++ b/apps/web/lib/actions/partners/set-partnerstack-token.ts @@ -0,0 +1,47 @@ +"use server"; + +import { PartnerStackApi } from "@/lib/partnerstack/api"; +import { partnerstackImporter } from "@/lib/partnerstack/importer"; +import { z } from "zod"; +import { authActionClient } from "../safe-action"; + +const schema = z.object({ + workspaceId: z.string(), + partnerstackProgramId: z.string().describe("PartnerStack program ID to import."), + token: z.string(), +}); + +export const setPartnerStackTokenAction = authActionClient + .schema(schema) + .action(async ({ parsedInput, ctx }) => { + const { workspace, user } = ctx; + const { token, partnerstackProgramId } = parsedInput; + + if (!workspace.partnersEnabled) { + throw new Error("You are not allowed to perform this action."); + } + + const partnerstackApi = new PartnerStackApi({ token }); + + try { + // Test the API connection by attempting to fetch program info + // Note: PartnerStack doesn't return detailed program info, so we'll just validate the token + await partnerstackApi.testConnection(); + } catch (error) { + throw new Error( + error instanceof Error + ? error.message + : "Invalid PartnerStack token or program ID.", + ); + } + + await partnerstackImporter.setCredentials(workspace.id, { + userId: user.id, + token, + partnerstackProgramId, + }); + + return { + success: true, + }; + }); \ No newline at end of file diff --git a/apps/web/lib/actions/partners/start-partnerstack-import.ts b/apps/web/lib/actions/partners/start-partnerstack-import.ts new file mode 100644 index 00000000000..e7c82e333d3 --- /dev/null +++ b/apps/web/lib/actions/partners/start-partnerstack-import.ts @@ -0,0 +1,45 @@ +"use server"; + +import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; +import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; +import { partnerstackImporter } from "@/lib/partnerstack/importer"; +import { authActionClient } from "../safe-action"; +import { z } from "zod"; + +const schema = z.object({ + workspaceId: z.string(), +}); + +export const startPartnerStackImportAction = authActionClient + .schema(schema) + .action(async ({ ctx }) => { + const { workspace } = ctx; + + 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."); + } + + const credentials = await partnerstackImporter.getCredentials(workspace.id); + + if (!credentials) { + throw new Error( + "PartnerStack credentials not found. Please restart the import process.", + ); + } + + await partnerstackImporter.queue({ + action: "import-affiliates", + programId, + }); + }); \ No newline at end of file diff --git a/apps/web/lib/partners/constants.ts b/apps/web/lib/partners/constants.ts index f7c1c880475..26286e40cca 100644 --- a/apps/web/lib/partners/constants.ts +++ b/apps/web/lib/partners/constants.ts @@ -78,4 +78,10 @@ export const PROGRAM_IMPORT_SOURCES = [ image: "https://assets.dub.co/misc/icons/tolt.svg", helpUrl: "https://dub.co/help/article/migrating-from-tolt", }, + { + id: "partnerstack", + value: "PartnerStack", + image: "https://assets.dub.co/misc/icons/partnerstack.svg", + helpUrl: "https://dub.co/help/article/migrating-from-partnerstack", + }, ] as const; diff --git a/apps/web/lib/partnerstack/api.ts b/apps/web/lib/partnerstack/api.ts index e69de29bb2d..0c609d41286 100644 --- a/apps/web/lib/partnerstack/api.ts +++ b/apps/web/lib/partnerstack/api.ts @@ -0,0 +1,39 @@ +const PAGE_LIMIT = 100; + +export class PartnerStackApi { + private readonly baseUrl = "https://api.partnerstack.com/api/v2"; + private readonly token: string; + + constructor({ token }: { token: string }) { + this.token = token; + } + + private async fetch(url: string): Promise { + const response = await fetch(url, { + headers: { + Authorization: `Bearer ${this.token}`, + }, + }); + + if (!response.ok) { + const error = await response.json(); + + console.error("PartnerStack API Error:", error); + + throw new Error(error.message || "Unknown error from PartnerStack API."); + } + + return await response.json(); + } + + async testConnection(): Promise { + try { + // Test the API connection by making a simple request + // We'll use a basic endpoint to validate the token + await this.fetch(`${this.baseUrl}/test`); + return true; + } catch (error) { + throw new Error("Invalid PartnerStack API token."); + } + } +} diff --git a/apps/web/lib/partnerstack/importer.ts b/apps/web/lib/partnerstack/importer.ts index e69de29bb2d..833c1ea0e21 100644 --- a/apps/web/lib/partnerstack/importer.ts +++ b/apps/web/lib/partnerstack/importer.ts @@ -0,0 +1,90 @@ +import { qstash } from "@/lib/cron"; +import { redis } from "@/lib/upstash"; +import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; +import { z } from "zod"; +import { PartnerStackConfig } from "./types"; + +export const MAX_BATCHES = 5; +export const CACHE_EXPIRY = 60 * 60 * 24; +export const CACHE_KEY_PREFIX = "partnerstack:import"; +export const PARTNER_IDS_KEY_PREFIX = "partnerstack:import:partnerIds"; + +export const importSteps = z.enum([ + "import-affiliates", + "import-links", + "import-referrals", + "import-commissions", + "update-stripe-customers", // update the customers with their stripe customer ID + "cleanup-partners", // remove partners with 0 leads +]); + +class PartnerStackImporter { + async setCredentials(workspaceId: string, payload: PartnerStackConfig) { + await redis.set(`${CACHE_KEY_PREFIX}:${workspaceId}`, payload, { + ex: CACHE_EXPIRY, + }); + } + + async getCredentials(workspaceId: string) { + const config = await redis.get( + `${CACHE_KEY_PREFIX}:${workspaceId}`, + ); + + if (!config) { + throw new Error("PartnerStack configuration not found."); + } + + return config; + } + + async deleteCredentials(workspaceId: string) { + return await redis.del(`${CACHE_KEY_PREFIX}:${workspaceId}`); + } + + async queue(body: { + action: z.infer; + programId: string; + startingAfter?: string; + }) { + return await qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/partnerstack`, + body, + }); + } + + async addPartners({ + programId, + partnerIds, + }: { + programId: string; + partnerIds: string[]; + }) { + if (!partnerIds || partnerIds.length === 0) { + return; + } + + await redis.lpush(`${PARTNER_IDS_KEY_PREFIX}:${programId}`, ...partnerIds); + } + + async scanPartnerIds({ + programId, + start, + end, + }: { + programId: string; + start: number; + end: number; + }) { + return await redis.lrange( + `${PARTNER_IDS_KEY_PREFIX}:${programId}`, + start, + end, + ); + } + + async deletePartnerIds(programId: string) { + return await redis.del(`${PARTNER_IDS_KEY_PREFIX}:${programId}`); + } +} + +export const partnerstackImporter = new PartnerStackImporter(); diff --git a/apps/web/lib/partnerstack/types.ts b/apps/web/lib/partnerstack/types.ts index e69de29bb2d..83bc015500b 100644 --- a/apps/web/lib/partnerstack/types.ts +++ b/apps/web/lib/partnerstack/types.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +export interface PartnerStackConfig { + token: string; + userId: string; + partnerstackProgramId: string; +} + +export interface PartnerStackListResponse { + success: true; + total_count: number; + data: T[]; +} + +// Basic types - these will be expanded as we implement the API +export interface PartnerStackAffiliate { + id: string; + email: string; + first_name: string; + last_name: string; + company_name?: string; + country_code?: string; + profile_type?: string; + created_at: string; +} + +export interface PartnerStackLink { + id: string; + tracking_url: string; + token: string; + partner_id: string; + created_at: string; +} + +export interface PartnerStackReferral { + id: string; + email: string; + first_name: string; + last_name: string; + company_name?: string; + partner_id: string; + created_at: string; +} + +export interface PartnerStackCommission { + id: string; + amount: number; + commission_amount: number; + status: string; + partner_id: string; + customer_id: string; + created_at: string; +} diff --git a/apps/web/ui/modals/import-partnerstack-modal.tsx b/apps/web/ui/modals/import-partnerstack-modal.tsx new file mode 100644 index 00000000000..5a045475a4d --- /dev/null +++ b/apps/web/ui/modals/import-partnerstack-modal.tsx @@ -0,0 +1,231 @@ +import { setPartnerStackTokenAction } from "@/lib/actions/partners/set-partnerstack-token"; +import { startPartnerStackImportAction } from "@/lib/actions/partners/start-partnerstack-import"; +import useWorkspace from "@/lib/swr/use-workspace"; +import { Button, Logo, Modal, useMediaQuery, useRouterStuff } from "@dub/ui"; +import { ArrowRight } from "lucide-react"; +import { useAction } from "next-safe-action/hooks"; +import { useRouter, useSearchParams } from "next/navigation"; +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useState, +} from "react"; +import { toast } from "sonner"; + +function ImportPartnerStackModal({ + showImportPartnerStackModal, + setShowImportPartnerStackModal, +}: { + showImportPartnerStackModal: boolean; + setShowImportPartnerStackModal: Dispatch>; +}) { + const searchParams = useSearchParams(); + const { queryParams } = useRouterStuff(); + + useEffect(() => { + if (searchParams?.get("import") === "partnerstack") { + setShowImportPartnerStackModal(true); + } else { + setShowImportPartnerStackModal(false); + } + }, [searchParams]); + + return ( + + queryParams({ + del: "import", + }) + } + > +
+
+ PartnerStack logo + + +
+

Import Your PartnerStack Program

+

+ Import your existing PartnerStack program into{" "} + {process.env.NEXT_PUBLIC_APP_NAME} with just a few clicks. +

+
+ +
+ { + setShowImportPartnerStackModal(false); + queryParams({ + del: "import", + }); + }} + /> +
+
+ ); +} + +function TokenForm({ + onClose, +}: { + onClose: () => void; +}) { + const { isMobile } = useMediaQuery(); + const router = useRouter(); + const { id: workspaceId, slug } = useWorkspace(); + + const [token, setToken] = useState(""); + const [programId, setProgramId] = useState(""); + + const { executeAsync: setTokenAsync, isPending: isSettingToken } = useAction( + setPartnerStackTokenAction, + { + onError: ({ error }) => { + toast.error(error.serverError); + }, + }, + ); + + const { executeAsync: startImportAsync, isPending: isStartingImport } = + useAction(startPartnerStackImportAction, { + onSuccess: () => { + onClose(); + toast.success( + "Successfully added program to import queue! We will send you an email when your program has been fully imported.", + ); + router.push(`/${slug}/program/partners`); + }, + onError: ({ error }) => { + toast.error(error.serverError); + }, + }); + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!workspaceId || !token || !programId) { + return; + } + + try { + // First set the token + await setTokenAsync({ + workspaceId, + partnerstackProgramId: programId, + token, + }); + + // Then start the import + await startImportAsync({ + workspaceId, + }); + } catch (error) { + // Error handling is done in the action callbacks + console.error("Import error:", error); + } + }; + + const isLoading = isSettingToken || isStartingImport; + + return ( +
+
+ + setToken(e.target.value)} + 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 + /> +

+ You can find your PartnerStack API key in your{" "} + + API settings + +

+
+ +
+ + setProgramId(e.target.value)} + 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 + /> +

+ You can find your program ID in your{" "} + + Programs page + +

+
+ +