Repository: dubinc/dub Branch: main Commit: 055ca7e99042 Files: 3991 Total size: 12.1 MB Directory structure: gitextract_j4v7jeo4/ ├── .github/ │ └── workflows/ │ ├── apply-issue-labels-to-pr.yml │ ├── deploy-embed-script.yml │ ├── e2e.yaml │ ├── playwright.yaml │ └── prettier.yaml ├── .gitignore ├── .prettierignore ├── LICENSE.md ├── README.md ├── SECURITY.md ├── apps/ │ └── web/ │ ├── app/ │ │ ├── (ee)/ │ │ │ ├── LICENSE.md │ │ │ ├── README.md │ │ │ ├── admin.dub.co/ │ │ │ │ ├── (auth)/ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── login/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── (dashboard)/ │ │ │ │ │ ├── analytics/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── commissions/ │ │ │ │ │ │ ├── client.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── ban-link.tsx │ │ │ │ │ │ ├── delete-partner-account.tsx │ │ │ │ │ │ ├── impersonate-user.tsx │ │ │ │ │ │ ├── impersonate-workspace.tsx │ │ │ │ │ │ ├── refresh-domain.tsx │ │ │ │ │ │ ├── reset-login-attempts.tsx │ │ │ │ │ │ └── user-info.tsx │ │ │ │ │ ├── events/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── layout-nav-client.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── links/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── payouts/ │ │ │ │ │ │ ├── client.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ └── paypal/ │ │ │ │ │ │ ├── client.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── revenue/ │ │ │ │ │ ├── client.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── layout.tsx │ │ │ ├── api/ │ │ │ │ ├── admin/ │ │ │ │ │ ├── analytics/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── ban/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── commissions/ │ │ │ │ │ │ ├── get-commissions-timeseries.ts │ │ │ │ │ │ ├── get-top-program-by-commissions.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── delete-partner-account/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── events/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── impersonate/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── links/ │ │ │ │ │ │ ├── [linkId]/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── ban/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── count/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── payouts/ │ │ │ │ │ │ ├── paypal/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── refresh-domain/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── reset-login-attempts/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── revenue/ │ │ │ │ │ ├── get-top-programs-by-sales.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── audit-logs/ │ │ │ │ │ └── export/ │ │ │ │ │ └── route.ts │ │ │ │ ├── auth/ │ │ │ │ │ └── saml/ │ │ │ │ │ ├── authorize/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── callback/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── token/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── userinfo/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── verify/ │ │ │ │ │ └── route.tsx │ │ │ │ ├── bounties/ │ │ │ │ │ ├── [bountyId]/ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ ├── submissions/ │ │ │ │ │ │ │ ├── [submissionId]/ │ │ │ │ │ │ │ │ ├── approve/ │ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ │ └── reject/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── sync-social-metrics/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── count/ │ │ │ │ │ │ └── submissions/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── campaigns/ │ │ │ │ │ ├── [campaignId]/ │ │ │ │ │ │ ├── duplicate/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── events/ │ │ │ │ │ │ │ ├── count/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── preview/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ └── summary/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── count/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── commissions/ │ │ │ │ │ ├── [commissionId]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── count/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── export/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── route.ts │ │ │ │ │ └── timeseries/ │ │ │ │ │ └── route.ts │ │ │ │ ├── cron/ │ │ │ │ │ ├── aggregate-clicks/ │ │ │ │ │ │ ├── resolve-click-reward-amount.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── bounties/ │ │ │ │ │ │ ├── create-draft-submissions/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── notify-partners/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── queue-sync-social-metrics/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── sync-social-metrics/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── campaigns/ │ │ │ │ │ │ └── broadcast/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── cleanup/ │ │ │ │ │ │ ├── demo-embed-partners/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── e2e-tests/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── expired-tokens/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── link-retention/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── rejected-applications/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── unenrolled-partners/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── discount-codes/ │ │ │ │ │ │ ├── create/ │ │ │ │ │ │ │ ├── queue-batches/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── delete/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── disposable-emails/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── domains/ │ │ │ │ │ │ ├── delete/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── renewal-payments/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── renewal-reminders/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── transfer/ │ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ │ └── utils.ts │ │ │ │ │ │ ├── update/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── verify/ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── email-domains/ │ │ │ │ │ │ ├── update/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── verify/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── export/ │ │ │ │ │ │ ├── commissions/ │ │ │ │ │ │ │ ├── fetch-commissions-batch.ts │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── customers/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── events/ │ │ │ │ │ │ │ ├── fetch-events-batch.ts │ │ │ │ │ │ │ ├── partner/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ └── workspace/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── links/ │ │ │ │ │ │ │ ├── fetch-links-batch.ts │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── partners/ │ │ │ │ │ │ ├── fetch-partners-batch.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── folders/ │ │ │ │ │ │ └── delete/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── framer/ │ │ │ │ │ │ └── backfill-leads-batch/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── fraud/ │ │ │ │ │ │ └── summary/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── fx-rates/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── groups/ │ │ │ │ │ │ ├── create-default-links/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── remap-default-links/ │ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ │ └── utils.ts │ │ │ │ │ │ ├── remap-discount-codes/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── sync-utm/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── update-default-links/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── import/ │ │ │ │ │ │ ├── bitly/ │ │ │ │ │ │ │ ├── fetch-utils.ts │ │ │ │ │ │ │ ├── queue-import.ts │ │ │ │ │ │ │ ├── rate-limit.ts │ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ │ ├── sanitize-json.ts │ │ │ │ │ │ │ └── utils.ts │ │ │ │ │ │ ├── csv/ │ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ │ └── utils.ts │ │ │ │ │ │ ├── firstpromoter/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── partnerstack/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── rebrandly/ │ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ │ └── utils.ts │ │ │ │ │ │ ├── rewardful/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── short/ │ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ │ └── utils.ts │ │ │ │ │ │ └── tolt/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── invoices/ │ │ │ │ │ │ └── retry-failed/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── links/ │ │ │ │ │ │ ├── [linkId]/ │ │ │ │ │ │ │ └── complete-tests/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── delete/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── invalidate-for-discounts/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── invalidate-for-partners/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── messages/ │ │ │ │ │ │ ├── notify-partner/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── notify-program/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── network/ │ │ │ │ │ │ ├── calculate-program-similarities/ │ │ │ │ │ │ │ ├── calculate-category-similarity.ts │ │ │ │ │ │ │ ├── calculate-partner-similarity.ts │ │ │ │ │ │ │ ├── calculate-performance-similarity.ts │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── update-partner-discoverability/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── partner-platforms/ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ └── youtube/ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ └── youtube-channel-schema.ts │ │ │ │ │ ├── partner-program-summary/ │ │ │ │ │ │ ├── process/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── partners/ │ │ │ │ │ │ ├── auto-approve/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── auto-reject/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── ban/ │ │ │ │ │ │ │ ├── cancel-commissions.ts │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── deactivate/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── merge-accounts/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── payouts/ │ │ │ │ │ │ ├── aggregate-due-commissions/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── balance-available/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── charge-succeeded/ │ │ │ │ │ │ │ ├── queue-external-payouts.ts │ │ │ │ │ │ │ ├── queue-stripe-payouts.ts │ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ │ ├── send-paypal-payouts.ts │ │ │ │ │ │ │ └── utils.ts │ │ │ │ │ │ ├── force-withdrawals/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── payout-failed/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── payout-paid/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── process/ │ │ │ │ │ │ │ ├── process-payouts.ts │ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ │ ├── split-payouts.ts │ │ │ │ │ │ │ └── updates/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── reminders/ │ │ │ │ │ │ │ ├── partners/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ └── program-owners/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── send-stripe-payout/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── pending-applications-summary/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── program-application-reminder/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── programs/ │ │ │ │ │ │ └── deactivate/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── send-batch-email/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── shopify/ │ │ │ │ │ │ └── order-paid/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── streams/ │ │ │ │ │ │ ├── update-partner-stats/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── update-workspace-clicks/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── trigger-withdrawal/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── usage/ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ ├── welcome-user/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── workflows/ │ │ │ │ │ │ └── [workflowId]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── workspaces/ │ │ │ │ │ └── delete/ │ │ │ │ │ ├── delete-workspace-customers.ts │ │ │ │ │ ├── delete-workspace-domains.ts │ │ │ │ │ ├── delete-workspace-folders.ts │ │ │ │ │ ├── delete-workspace-links.ts │ │ │ │ │ ├── delete-workspace.ts │ │ │ │ │ ├── route.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── customers/ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ ├── activity/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ └── stripe-invoices/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── count/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── export/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── route.ts │ │ │ │ │ └── search-stripe/ │ │ │ │ │ └── route.ts │ │ │ │ ├── discount-codes/ │ │ │ │ │ ├── [discountCodeId]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── domains/ │ │ │ │ │ ├── register/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── status/ │ │ │ │ │ └── route.ts │ │ │ │ ├── e2e/ │ │ │ │ │ ├── bounties/ │ │ │ │ │ │ └── [bountyId]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── enrollments/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── guard.ts │ │ │ │ │ ├── notification-emails/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── trigger-workflow/ │ │ │ │ │ │ └── [workflowId]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── workflows/ │ │ │ │ │ ├── [workflowId]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── email-domains/ │ │ │ │ │ ├── [domain]/ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ └── verify/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── embed/ │ │ │ │ │ └── referrals/ │ │ │ │ │ ├── analytics/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── earnings/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── leaderboard/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── links/ │ │ │ │ │ │ ├── [linkId]/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── token/ │ │ │ │ │ └── route.ts │ │ │ │ ├── events/ │ │ │ │ │ ├── export/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── fraud/ │ │ │ │ │ ├── events/ │ │ │ │ │ │ ├── count/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── groups/ │ │ │ │ │ │ ├── count/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── rules/ │ │ │ │ │ └── route.ts │ │ │ │ ├── groups/ │ │ │ │ │ ├── [groupIdOrSlug]/ │ │ │ │ │ │ ├── default/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── default-links/ │ │ │ │ │ │ │ ├── [defaultLinkId]/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── partners/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── count/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── route.ts │ │ │ │ │ └── rules/ │ │ │ │ │ └── route.ts │ │ │ │ ├── hubspot/ │ │ │ │ │ ├── callback/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── webhook/ │ │ │ │ │ └── route.ts │ │ │ │ ├── messages/ │ │ │ │ │ ├── count/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── mock/ │ │ │ │ │ └── rewardful/ │ │ │ │ │ ├── affiliates/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── campaigns/ │ │ │ │ │ │ ├── [campaignId]/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── campaigns.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── commissions/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── referrals/ │ │ │ │ │ └── route.ts │ │ │ │ ├── network/ │ │ │ │ │ ├── partners/ │ │ │ │ │ │ ├── count/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── invites-usage/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── programs/ │ │ │ │ │ ├── count/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── partner-profile/ │ │ │ │ │ ├── invites/ │ │ │ │ │ │ ├── accept/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── messages/ │ │ │ │ │ │ ├── count/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── notification-preferences/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── payouts/ │ │ │ │ │ │ ├── count/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ └── settings/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── postbacks/ │ │ │ │ │ │ ├── [postbackId]/ │ │ │ │ │ │ │ ├── events/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── rotate-secret/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ │ └── send-test/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── programs/ │ │ │ │ │ │ ├── [programId]/ │ │ │ │ │ │ │ ├── activity-logs/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── analytics/ │ │ │ │ │ │ │ │ ├── export/ │ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── bounties/ │ │ │ │ │ │ │ │ ├── [bountyId]/ │ │ │ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ │ │ │ └── social-content-stats/ │ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── customers/ │ │ │ │ │ │ │ │ ├── [customerId]/ │ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ │ ├── count/ │ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── earnings/ │ │ │ │ │ │ │ │ ├── count/ │ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ │ │ └── timeseries/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── events/ │ │ │ │ │ │ │ │ ├── export/ │ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── groups/ │ │ │ │ │ │ │ │ └── [groupIdOrSlug]/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── links/ │ │ │ │ │ │ │ │ ├── [linkId]/ │ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── referrals/ │ │ │ │ │ │ │ │ ├── count/ │ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ ├── resources/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── count/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── rewind/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── route.ts │ │ │ │ │ └── users/ │ │ │ │ │ └── route.ts │ │ │ │ ├── partners/ │ │ │ │ │ ├── [partnerId]/ │ │ │ │ │ │ ├── application-risks/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── comments/ │ │ │ │ │ │ │ ├── count/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── cross-program-summary/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── analytics/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── ban/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── count/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── deactivate/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── export/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── links/ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ └── upsert/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── platforms/ │ │ │ │ │ │ └── callback/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── payouts/ │ │ │ │ │ ├── [payoutId]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── count/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── paypal/ │ │ │ │ │ ├── callback/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── webhook/ │ │ │ │ │ ├── payouts-item-failed.ts │ │ │ │ │ ├── payouts-item-succeeded.ts │ │ │ │ │ ├── route.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── verify-signature.ts │ │ │ │ ├── programs/ │ │ │ │ │ ├── [programId]/ │ │ │ │ │ │ ├── applications/ │ │ │ │ │ │ │ ├── [applicationId]/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ └── export/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── discounts/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── payouts/ │ │ │ │ │ │ │ └── eligible/ │ │ │ │ │ │ │ ├── count/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── referrals/ │ │ │ │ │ │ │ ├── count/ │ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── resources/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── rewardful/ │ │ │ │ │ └── campaigns/ │ │ │ │ │ └── route.ts │ │ │ │ ├── rewards/ │ │ │ │ │ ├── [rewardId]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── scim/ │ │ │ │ │ └── v2.0/ │ │ │ │ │ └── [...directory]/ │ │ │ │ │ └── route.ts │ │ │ │ ├── shopify/ │ │ │ │ │ ├── integration/ │ │ │ │ │ │ ├── callback/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── webhook/ │ │ │ │ │ │ ├── app-uninstalled.ts │ │ │ │ │ │ ├── customers-data-request.ts │ │ │ │ │ │ ├── customers-redact.ts │ │ │ │ │ │ ├── orders-paid.ts │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ └── shop-redact.ts │ │ │ │ │ └── pixel/ │ │ │ │ │ └── route.ts │ │ │ │ ├── singular/ │ │ │ │ │ └── webhook/ │ │ │ │ │ └── route.ts │ │ │ │ ├── stripe/ │ │ │ │ │ ├── connect/ │ │ │ │ │ │ ├── v2/ │ │ │ │ │ │ │ └── webhook/ │ │ │ │ │ │ │ ├── outbound-payment-failed.ts │ │ │ │ │ │ │ ├── outbound-payment-posted.ts │ │ │ │ │ │ │ ├── outbound-payment-returned.ts │ │ │ │ │ │ │ ├── recipient-account-closed.ts │ │ │ │ │ │ │ ├── recipient-configuration-updated.ts │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── webhook/ │ │ │ │ │ │ ├── account-application-deauthorized.ts │ │ │ │ │ │ ├── account-updated.ts │ │ │ │ │ │ ├── balance-available.ts │ │ │ │ │ │ ├── payout-failed.ts │ │ │ │ │ │ ├── payout-paid.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── integration/ │ │ │ │ │ │ ├── callback/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ └── webhook/ │ │ │ │ │ │ ├── account-application-deauthorized.ts │ │ │ │ │ │ ├── charge-refunded.ts │ │ │ │ │ │ ├── checkout-session-completed.ts │ │ │ │ │ │ ├── coupon-deleted.ts │ │ │ │ │ │ ├── customer-created.ts │ │ │ │ │ │ ├── customer-subscription-created.ts │ │ │ │ │ │ ├── customer-subscription-deleted.ts │ │ │ │ │ │ ├── customer-updated.ts │ │ │ │ │ │ ├── invoice-paid.ts │ │ │ │ │ │ ├── promotion-code-updated.ts │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ ├── sandbox/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── test/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ ├── create-new-customer.ts │ │ │ │ │ │ ├── get-connected-customer.ts │ │ │ │ │ │ ├── get-promotion-code.ts │ │ │ │ │ │ ├── get-subscription-product-id.ts │ │ │ │ │ │ └── update-customer-with-stripe-customer-id.ts │ │ │ │ │ └── webhook/ │ │ │ │ │ ├── charge-failed.ts │ │ │ │ │ ├── charge-refunded.ts │ │ │ │ │ ├── charge-succeeded.ts │ │ │ │ │ ├── checkout-session-completed.ts │ │ │ │ │ ├── customer-subscription-deleted.ts │ │ │ │ │ ├── customer-subscription-updated.ts │ │ │ │ │ ├── invoice-payment-failed.tsx │ │ │ │ │ ├── payment-intent-requires-action.ts │ │ │ │ │ ├── route.ts │ │ │ │ │ ├── transfer-reversed.ts │ │ │ │ │ └── utils/ │ │ │ │ │ ├── process-domain-renewal-failure.ts │ │ │ │ │ ├── process-payout-invoice-failure.ts │ │ │ │ │ ├── send-cancellation-feedback.ts │ │ │ │ │ └── update-workspace-plan.ts │ │ │ │ ├── track/ │ │ │ │ │ ├── click/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── lead/ │ │ │ │ │ │ ├── client/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── open/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── sale/ │ │ │ │ │ │ ├── client/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── visit/ │ │ │ │ │ └── route.ts │ │ │ │ └── workflows/ │ │ │ │ └── partner-approved/ │ │ │ │ └── route.ts │ │ │ ├── app.dub.co/ │ │ │ │ ├── (new-program)/ │ │ │ │ │ ├── [slug]/ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ └── program/ │ │ │ │ │ │ └── new/ │ │ │ │ │ │ ├── form.tsx │ │ │ │ │ │ ├── overview/ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ ├── partners/ │ │ │ │ │ │ │ ├── form.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── rewards/ │ │ │ │ │ │ │ ├── form.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── step-page.tsx │ │ │ │ │ │ └── support/ │ │ │ │ │ │ ├── form.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── header.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── sidebar-context.tsx │ │ │ │ │ └── steps.tsx │ │ │ │ ├── embed/ │ │ │ │ │ ├── referrals/ │ │ │ │ │ │ ├── activity.tsx │ │ │ │ │ │ ├── add-edit-link.tsx │ │ │ │ │ │ ├── dynamic-height-messenger.tsx │ │ │ │ │ │ ├── earnings-summary.tsx │ │ │ │ │ │ ├── earnings.tsx │ │ │ │ │ │ ├── faq.tsx │ │ │ │ │ │ ├── leaderboard.tsx │ │ │ │ │ │ ├── links-list.tsx │ │ │ │ │ │ ├── links.tsx │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ ├── quickstart.tsx │ │ │ │ │ │ ├── resources.tsx │ │ │ │ │ │ ├── theme-options.ts │ │ │ │ │ │ ├── token.tsx │ │ │ │ │ │ ├── types.ts │ │ │ │ │ │ └── utils.ts │ │ │ │ │ └── use-embed-token.ts │ │ │ │ ├── invoices/ │ │ │ │ │ └── [invoiceId]/ │ │ │ │ │ ├── domain-renewal-invoice.tsx │ │ │ │ │ ├── partner-payout-invoice.tsx │ │ │ │ │ └── route.tsx │ │ │ │ └── layout.tsx │ │ │ └── partners.dub.co/ │ │ │ ├── (apply)/ │ │ │ │ └── [programSlug]/ │ │ │ │ ├── (default)/ │ │ │ │ │ ├── apply/ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ └── success/ │ │ │ │ │ │ ├── cta-buttons.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ ├── pixel-conversion.tsx │ │ │ │ │ │ └── screenshot.tsx │ │ │ │ │ ├── apply-button.tsx │ │ │ │ │ ├── header.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── (group-level)/ │ │ │ │ └── [groupSlug]/ │ │ │ │ ├── apply/ │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── success/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── (auth-login-register)/ │ │ │ │ ├── (generic)/ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── login/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── register/ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── (program)/ │ │ │ │ │ └── [programSlug]/ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── login/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── register/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── partner-banner.tsx │ │ │ │ ├── program-logos.tsx │ │ │ │ └── side-panel.tsx │ │ │ ├── (auth-other)/ │ │ │ │ ├── auth/ │ │ │ │ │ ├── confirm-email-change/ │ │ │ │ │ │ └── [token]/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── reset-password/ │ │ │ │ │ └── [token]/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── forgot-password/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── invite/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── logo.tsx │ │ │ │ └── unsubscribe/ │ │ │ │ └── [token]/ │ │ │ │ └── page.tsx │ │ │ ├── (dashboard)/ │ │ │ │ ├── account/ │ │ │ │ │ └── settings/ │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── security/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── auth.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── messages/ │ │ │ │ │ ├── [programSlug]/ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── payouts/ │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── partner-payout-details-sheet.tsx │ │ │ │ │ ├── partner-payout-settings-button.tsx │ │ │ │ │ ├── partner-payout-settings-sheet.tsx │ │ │ │ │ ├── payout-stats.tsx │ │ │ │ │ ├── payout-table.tsx │ │ │ │ │ └── use-payout-filters.tsx │ │ │ │ ├── profile/ │ │ │ │ │ ├── about-you-form.tsx │ │ │ │ │ ├── how-you-work-form.tsx │ │ │ │ │ ├── industry-interests-modal.tsx │ │ │ │ │ ├── members/ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── notifications/ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── postbacks/ │ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── add-postback-button.tsx │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── profile-details-form.tsx │ │ │ │ │ ├── profile-discovery-guide.tsx │ │ │ │ │ ├── settings-row.tsx │ │ │ │ │ └── use-partner-discovery-requirements.ts │ │ │ │ ├── programs/ │ │ │ │ │ ├── [programSlug]/ │ │ │ │ │ │ ├── (enrolled)/ │ │ │ │ │ │ │ ├── analytics/ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── auth.tsx │ │ │ │ │ │ │ ├── bounties/ │ │ │ │ │ │ │ │ ├── [bountyId]/ │ │ │ │ │ │ │ │ │ ├── bounty-performance-section.tsx │ │ │ │ │ │ │ │ │ ├── bounty-submissions-table.tsx │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ ├── bounty-card.tsx │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── customers/ │ │ │ │ │ │ │ │ ├── (index)/ │ │ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ │ │ ├── referrals/ │ │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ │ └── use-partner-customer-filters.tsx │ │ │ │ │ │ │ │ └── [customerId]/ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── earnings/ │ │ │ │ │ │ │ │ ├── earnings-composite-chart.tsx │ │ │ │ │ │ │ │ ├── earnings-table.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── events/ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── hide-program-details-button.tsx │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── links/ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ │ ├── partner-link-card.tsx │ │ │ │ │ │ │ │ └── partner-link-controls.tsx │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ ├── payouts-card.tsx │ │ │ │ │ │ │ ├── resources/ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── share-earnings-modal.tsx │ │ │ │ │ │ │ └── unapproved-program-page.tsx │ │ │ │ │ │ ├── apply/ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ └── program-sidebar.tsx │ │ │ │ │ │ └── invite/ │ │ │ │ │ │ ├── accept-program-invite-button.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ └── program-invite-confetti.tsx │ │ │ │ │ ├── invitations/ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── marketplace/ │ │ │ │ │ │ ├── [programSlug]/ │ │ │ │ │ │ │ ├── header-controls.tsx │ │ │ │ │ │ │ ├── loading.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── featured-program-card.tsx │ │ │ │ │ │ ├── featured-programs.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ ├── marketplace-empty-state.tsx │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ ├── program-card.tsx │ │ │ │ │ │ ├── program-sort.tsx │ │ │ │ │ │ ├── program-status-badge.tsx │ │ │ │ │ │ └── use-program-network-filters.tsx │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── rewind/ │ │ │ │ └── 2025/ │ │ │ │ ├── conclusion.tsx │ │ │ │ ├── intro.tsx │ │ │ │ ├── page-client.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── rewind.tsx │ │ │ │ └── share-rewind-modal.tsx │ │ │ ├── (onboarding)/ │ │ │ │ ├── layout.tsx │ │ │ │ └── onboarding/ │ │ │ │ ├── onboarding-form.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── payouts/ │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── payout-provider.tsx │ │ │ │ └── platforms/ │ │ │ │ ├── page-client.tsx │ │ │ │ └── page.tsx │ │ │ ├── (redirects)/ │ │ │ │ └── apply/ │ │ │ │ └── [programSlug]/ │ │ │ │ └── [[...slug]]/ │ │ │ │ └── page.tsx │ │ │ ├── invoices/ │ │ │ │ └── [payoutId]/ │ │ │ │ └── route.tsx │ │ │ └── layout.tsx │ │ ├── [domain]/ │ │ │ ├── browser-graphic.tsx │ │ │ ├── layout.tsx │ │ │ ├── not-found/ │ │ │ │ └── page.tsx │ │ │ ├── page.tsx │ │ │ ├── placeholder.tsx │ │ │ └── stats/ │ │ │ └── [key]/ │ │ │ └── page.tsx │ │ ├── api/ │ │ │ ├── (old)/ │ │ │ │ └── projects/ │ │ │ │ ├── [slug]/ │ │ │ │ │ ├── domains/ │ │ │ │ │ │ ├── [domain]/ │ │ │ │ │ │ │ ├── route.ts │ │ │ │ │ │ │ └── verify/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── default/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── links/ │ │ │ │ │ │ ├── [linkId]/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── bulk/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── count/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── info/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ ├── random/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── route.ts │ │ │ │ │ └── tags/ │ │ │ │ │ ├── [id]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── activity-logs/ │ │ │ │ └── route.ts │ │ │ ├── ai/ │ │ │ │ ├── completion/ │ │ │ │ │ └── route.ts │ │ │ │ ├── support-chat/ │ │ │ │ │ ├── route.ts │ │ │ │ │ └── upload/ │ │ │ │ │ └── route.ts │ │ │ │ └── sync-embeddings/ │ │ │ │ ├── fetch-plausible-pageviews.ts │ │ │ │ └── route.ts │ │ │ ├── analytics/ │ │ │ │ ├── [eventType]/ │ │ │ │ │ ├── [endpoint]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── dashboard/ │ │ │ │ │ └── route.ts │ │ │ │ ├── export/ │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── auth/ │ │ │ │ ├── [...nextauth]/ │ │ │ │ │ └── route.tsx │ │ │ │ └── reset-password/ │ │ │ │ └── route.ts │ │ │ ├── callback/ │ │ │ │ ├── bitly/ │ │ │ │ │ └── route.ts │ │ │ │ ├── plain/ │ │ │ │ │ ├── partner/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── utils.ts │ │ │ │ │ └── workspace/ │ │ │ │ │ └── route.ts │ │ │ │ └── stripe/ │ │ │ │ └── route.ts │ │ │ ├── dashboards/ │ │ │ │ ├── [id]/ │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── docs/ │ │ │ │ └── guides/ │ │ │ │ └── [guide]/ │ │ │ │ └── route.ts │ │ │ ├── domains/ │ │ │ │ ├── [domain]/ │ │ │ │ │ ├── primary/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── route.ts │ │ │ │ │ ├── transfer/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── validate/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── verify/ │ │ │ │ │ └── route.ts │ │ │ │ ├── client/ │ │ │ │ │ ├── register/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── saved/ │ │ │ │ │ └── route.ts │ │ │ │ ├── count/ │ │ │ │ │ └── route.ts │ │ │ │ ├── default/ │ │ │ │ │ └── route.ts │ │ │ │ ├── route.ts │ │ │ │ └── search-availability/ │ │ │ │ └── route.ts │ │ │ ├── dub/ │ │ │ │ └── webhook/ │ │ │ │ ├── lead-created.ts │ │ │ │ ├── route.ts │ │ │ │ └── sale-created.ts │ │ │ ├── folders/ │ │ │ │ ├── [folderId]/ │ │ │ │ │ ├── dashboard/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── route.ts │ │ │ │ │ └── users/ │ │ │ │ │ └── route.ts │ │ │ │ ├── access-requests/ │ │ │ │ │ └── route.ts │ │ │ │ ├── count/ │ │ │ │ │ └── route.ts │ │ │ │ ├── permissions/ │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── integrations/ │ │ │ │ ├── route.ts │ │ │ │ └── uninstall/ │ │ │ │ └── route.ts │ │ │ ├── links/ │ │ │ │ ├── [linkId]/ │ │ │ │ │ ├── dashboard/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── route.ts │ │ │ │ │ └── transfer/ │ │ │ │ │ └── route.ts │ │ │ │ ├── bulk/ │ │ │ │ │ └── route.ts │ │ │ │ ├── count/ │ │ │ │ │ └── route.ts │ │ │ │ ├── exists/ │ │ │ │ │ └── route.ts │ │ │ │ ├── export/ │ │ │ │ │ └── route.ts │ │ │ │ ├── iframeable/ │ │ │ │ │ └── route.ts │ │ │ │ ├── info/ │ │ │ │ │ └── route.ts │ │ │ │ ├── metatags/ │ │ │ │ │ ├── route.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── random/ │ │ │ │ │ └── route.ts │ │ │ │ ├── route.ts │ │ │ │ ├── sync/ │ │ │ │ │ └── route.ts │ │ │ │ └── upsert/ │ │ │ │ └── route.ts │ │ │ ├── me/ │ │ │ │ └── route.ts │ │ │ ├── misc/ │ │ │ │ ├── check-favicon/ │ │ │ │ │ └── route.ts │ │ │ │ └── check-workspace-slug/ │ │ │ │ └── route.ts │ │ │ ├── oauth/ │ │ │ │ ├── apps/ │ │ │ │ │ ├── [appId]/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── authorize/ │ │ │ │ │ └── route.ts │ │ │ │ ├── token/ │ │ │ │ │ ├── exchange-code-for-token.ts │ │ │ │ │ ├── refresh-access-token.ts │ │ │ │ │ └── route.ts │ │ │ │ └── userinfo/ │ │ │ │ └── route.ts │ │ │ ├── og/ │ │ │ │ ├── analytics/ │ │ │ │ │ └── route.tsx │ │ │ │ ├── avatar/ │ │ │ │ │ └── [[...seed]]/ │ │ │ │ │ └── route.tsx │ │ │ │ ├── load-google-font.ts │ │ │ │ ├── partner-earnings/ │ │ │ │ │ └── route.tsx │ │ │ │ ├── partner-rewind/ │ │ │ │ │ └── route.tsx │ │ │ │ └── program/ │ │ │ │ └── route.tsx │ │ │ ├── postbacks/ │ │ │ │ └── callback/ │ │ │ │ └── route.ts │ │ │ ├── providers/ │ │ │ │ └── route.ts │ │ │ ├── qr/ │ │ │ │ └── route.tsx │ │ │ ├── resend/ │ │ │ │ └── webhook/ │ │ │ │ ├── email-bounced.ts │ │ │ │ ├── email-delivered.ts │ │ │ │ ├── email-opened.ts │ │ │ │ └── route.ts │ │ │ ├── resumes/ │ │ │ │ └── upload-url/ │ │ │ │ └── route.ts │ │ │ ├── route.ts │ │ │ ├── slack/ │ │ │ │ ├── callback/ │ │ │ │ │ └── route.ts │ │ │ │ └── slash-commands/ │ │ │ │ └── route.ts │ │ │ ├── supported-countries/ │ │ │ │ └── route.ts │ │ │ ├── tags/ │ │ │ │ ├── [id]/ │ │ │ │ │ └── route.ts │ │ │ │ ├── count/ │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── tokens/ │ │ │ │ ├── [id]/ │ │ │ │ │ └── route.ts │ │ │ │ ├── embed/ │ │ │ │ │ └── referrals/ │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── unsplash/ │ │ │ │ ├── download/ │ │ │ │ │ └── route.ts │ │ │ │ ├── search/ │ │ │ │ │ └── route.ts │ │ │ │ └── utils.ts │ │ │ ├── user/ │ │ │ │ ├── notification-preferences/ │ │ │ │ │ └── route.ts │ │ │ │ ├── password/ │ │ │ │ │ └── route.ts │ │ │ │ ├── referrals-token/ │ │ │ │ │ └── route.ts │ │ │ │ ├── route.ts │ │ │ │ ├── set-password/ │ │ │ │ │ └── route.ts │ │ │ │ └── tokens/ │ │ │ │ └── route.ts │ │ │ ├── utm/ │ │ │ │ ├── [id]/ │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── webhooks/ │ │ │ │ ├── [webhookId]/ │ │ │ │ │ ├── events/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── callback/ │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ └── workspaces/ │ │ │ ├── [idOrSlug]/ │ │ │ │ ├── billing/ │ │ │ │ │ ├── cancel/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── invoices/ │ │ │ │ │ │ ├── [invoiceId]/ │ │ │ │ │ │ │ └── route.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── manage/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── payment-methods/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── upgrade/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── usage/ │ │ │ │ │ └── route.ts │ │ │ │ ├── import/ │ │ │ │ │ ├── [importId]/ │ │ │ │ │ │ └── download/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── bitly/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── csv/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── rebrandly/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── short/ │ │ │ │ │ └── route.ts │ │ │ │ ├── invites/ │ │ │ │ │ ├── accept/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── decline/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── reset/ │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ ├── notification-preferences/ │ │ │ │ │ └── route.ts │ │ │ │ ├── route.ts │ │ │ │ ├── saml/ │ │ │ │ │ └── route.ts │ │ │ │ ├── scim/ │ │ │ │ │ └── route.ts │ │ │ │ ├── stats/ │ │ │ │ │ └── [endpoint]/ │ │ │ │ │ └── route.ts │ │ │ │ ├── upload-url/ │ │ │ │ │ └── route.ts │ │ │ │ └── users/ │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── app.dub.co/ │ │ │ ├── (auth)/ │ │ │ │ ├── auth/ │ │ │ │ │ ├── confirm-email-change/ │ │ │ │ │ │ └── [token]/ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── reset-password/ │ │ │ │ │ │ └── [token]/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── saml/ │ │ │ │ │ ├── form.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── customer-logos.tsx │ │ │ │ ├── forgot-password/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── invites/ │ │ │ │ │ └── [code]/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── login/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── oauth/ │ │ │ │ │ └── authorize/ │ │ │ │ │ ├── authorize-form.tsx │ │ │ │ │ ├── loading.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── scopes-requested.tsx │ │ │ │ ├── register/ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── side-panel.tsx │ │ │ │ └── unsubscribe/ │ │ │ │ └── [token]/ │ │ │ │ ├── page.tsx │ │ │ │ └── unsubscribe-form.tsx │ │ │ ├── (dashboard)/ │ │ │ │ ├── [slug]/ │ │ │ │ │ ├── (ee)/ │ │ │ │ │ │ ├── customers/ │ │ │ │ │ │ │ ├── [customerId]/ │ │ │ │ │ │ │ │ ├── earnings/ │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ │ └── sales/ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── events/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── program/ │ │ │ │ │ │ │ ├── analytics/ │ │ │ │ │ │ │ │ ├── analytics-chart.tsx │ │ │ │ │ │ │ │ ├── analytics-partners-table.tsx │ │ │ │ │ │ │ │ ├── analytics-timeseries-chart.tsx │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ │ └── partner-analytics-filter-cell.tsx │ │ │ │ │ │ │ ├── auth.tsx │ │ │ │ │ │ │ ├── bounties/ │ │ │ │ │ │ │ │ ├── [bountyId]/ │ │ │ │ │ │ │ │ │ ├── bounty-header.tsx │ │ │ │ │ │ │ │ │ ├── bounty-info.tsx │ │ │ │ │ │ │ │ │ ├── bounty-submission-details-sheet.tsx │ │ │ │ │ │ │ │ │ ├── bounty-submission-row-menu.tsx │ │ │ │ │ │ │ │ │ ├── bounty-submissions-table.tsx │ │ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ │ │ └── use-bounty-submission-filters.tsx │ │ │ │ │ │ │ │ ├── add-edit-bounty/ │ │ │ │ │ │ │ │ │ ├── add-edit-bounty-sheet.tsx │ │ │ │ │ │ │ │ │ ├── bounty-amount-input.tsx │ │ │ │ │ │ │ │ │ ├── bounty-criteria-manual-submission.tsx │ │ │ │ │ │ │ │ │ ├── bounty-criteria-social-metrics.tsx │ │ │ │ │ │ │ │ │ ├── bounty-criteria.tsx │ │ │ │ │ │ │ │ │ ├── bounty-form-context.tsx │ │ │ │ │ │ │ │ │ ├── bounty-logic.tsx │ │ │ │ │ │ │ │ │ ├── confirm-create-bounty-modal.tsx │ │ │ │ │ │ │ │ │ └── use-add-edit-bounty-form.ts │ │ │ │ │ │ │ │ ├── bounty-action-button.tsx │ │ │ │ │ │ │ │ ├── bounty-card.tsx │ │ │ │ │ │ │ │ ├── bounty-list.tsx │ │ │ │ │ │ │ │ ├── create-bounty-button.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── campaigns/ │ │ │ │ │ │ │ │ ├── [campaignId]/ │ │ │ │ │ │ │ │ │ ├── campaign-action-bar.tsx │ │ │ │ │ │ │ │ │ ├── campaign-controls.tsx │ │ │ │ │ │ │ │ │ ├── campaign-editor-skeleton.tsx │ │ │ │ │ │ │ │ │ ├── campaign-editor.tsx │ │ │ │ │ │ │ │ │ ├── campaign-events-columns.tsx │ │ │ │ │ │ │ │ │ ├── campaign-events-modal.tsx │ │ │ │ │ │ │ │ │ ├── campaign-events.tsx │ │ │ │ │ │ │ │ │ ├── campaign-form-context.tsx │ │ │ │ │ │ │ │ │ ├── campaign-groups-selector.tsx │ │ │ │ │ │ │ │ │ ├── campaign-metrics.tsx │ │ │ │ │ │ │ │ │ ├── duplicate-logic-warning.tsx │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ │ │ ├── send-email-preview-modal.tsx │ │ │ │ │ │ │ │ │ ├── transactional-campaign-logic.tsx │ │ │ │ │ │ │ │ │ ├── use-campaign-confirmation-modals.tsx │ │ │ │ │ │ │ │ │ └── utils.ts │ │ │ │ │ │ │ │ ├── campaign-stats.tsx │ │ │ │ │ │ │ │ ├── campaign-status-badges.tsx │ │ │ │ │ │ │ │ ├── campaign-type-badges.tsx │ │ │ │ │ │ │ │ ├── campaign-type-icon.tsx │ │ │ │ │ │ │ │ ├── campaigns-page-content.tsx │ │ │ │ │ │ │ │ ├── campaigns-table.tsx │ │ │ │ │ │ │ │ ├── campaigns-upsell.tsx │ │ │ │ │ │ │ │ ├── create-campaign-button.tsx │ │ │ │ │ │ │ │ ├── delete-campaign-modal.tsx │ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ │ ├── use-campaign.tsx │ │ │ │ │ │ │ │ ├── use-campaigns-count.tsx │ │ │ │ │ │ │ │ └── use-campaigns-filters.tsx │ │ │ │ │ │ │ ├── coming-soon-page.tsx │ │ │ │ │ │ │ ├── commissions/ │ │ │ │ │ │ │ │ ├── [commissionId]/ │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ ├── commission-popover-buttons.tsx │ │ │ │ │ │ │ │ ├── commissions-stats.tsx │ │ │ │ │ │ │ │ ├── commissions-table.tsx │ │ │ │ │ │ │ │ ├── create-clawback-sheet.tsx │ │ │ │ │ │ │ │ ├── create-commission-button.tsx │ │ │ │ │ │ │ │ ├── create-commission-sheet.tsx │ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ │ └── use-commission-filters.tsx │ │ │ │ │ │ │ ├── customers/ │ │ │ │ │ │ │ │ ├── (index)/ │ │ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ │ │ └── referrals/ │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ ├── [customerId]/ │ │ │ │ │ │ │ │ │ ├── earnings/ │ │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ │ │ └── sales/ │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ └── customers-dropdown-menu.tsx │ │ │ │ │ │ │ ├── fraud/ │ │ │ │ │ │ │ │ ├── example-fraud-events.tsx │ │ │ │ │ │ │ │ ├── fraud-group-table.tsx │ │ │ │ │ │ │ │ ├── fraud-paid-traffic-settings.tsx │ │ │ │ │ │ │ │ ├── fraud-referral-source-settings.tsx │ │ │ │ │ │ │ │ ├── fraud-rule-toggle-settings.tsx │ │ │ │ │ │ │ │ ├── fraud-upsell.tsx │ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ │ ├── program-fraud-actions-menu.tsx │ │ │ │ │ │ │ │ ├── program-fraud-settings-button.tsx │ │ │ │ │ │ │ │ ├── program-fraud-settings-sheet.tsx │ │ │ │ │ │ │ │ ├── resolved/ │ │ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ │ │ └── resolved-fraud-group-table.tsx │ │ │ │ │ │ │ │ └── use-fraud-group-filters.tsx │ │ │ │ │ │ │ ├── groups/ │ │ │ │ │ │ │ │ ├── [groupSlug]/ │ │ │ │ │ │ │ │ │ ├── branding/ │ │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ │ ├── discounts/ │ │ │ │ │ │ │ │ │ │ ├── group-discounts.tsx │ │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ │ ├── group-header.tsx │ │ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ │ │ ├── links/ │ │ │ │ │ │ │ │ │ │ ├── add-edit-group-additional-link-modal.tsx │ │ │ │ │ │ │ │ │ │ ├── add-edit-group-default-link-sheet.tsx │ │ │ │ │ │ │ │ │ │ ├── change-program-domain-modal.tsx │ │ │ │ │ │ │ │ │ │ ├── group-additional-links.tsx │ │ │ │ │ │ │ │ │ │ ├── group-default-links.tsx │ │ │ │ │ │ │ │ │ │ ├── group-link-settings.tsx │ │ │ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ │ │ │ └── partner-link-preview.tsx │ │ │ │ │ │ │ │ │ ├── rewards/ │ │ │ │ │ │ │ │ │ │ ├── group-rewards.tsx │ │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ │ └── settings/ │ │ │ │ │ │ │ │ │ ├── group-additional-settings.tsx │ │ │ │ │ │ │ │ │ ├── group-move-rules.tsx │ │ │ │ │ │ │ │ │ ├── group-settings.tsx │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ ├── create-group-button.tsx │ │ │ │ │ │ │ │ ├── create-group-modal.tsx │ │ │ │ │ │ │ │ ├── groups-table.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── messages/ │ │ │ │ │ │ │ │ ├── [partnerId]/ │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ │ ├── messages-disabled.tsx │ │ │ │ │ │ │ │ ├── messages-upsell.tsx │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── network/ │ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ │ ├── network-empty-state.tsx │ │ │ │ │ │ │ │ ├── network-upsell.tsx │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ │ └── use-partner-network-filters.tsx │ │ │ │ │ │ │ ├── overview-chart.tsx │ │ │ │ │ │ │ ├── overview-links.tsx │ │ │ │ │ │ │ ├── overview-tasks.tsx │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ ├── partners/ │ │ │ │ │ │ │ │ ├── [partnerId]/ │ │ │ │ │ │ │ │ │ ├── comments/ │ │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ │ ├── customers/ │ │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ │ │ ├── links/ │ │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ │ ├── partner-nav.tsx │ │ │ │ │ │ │ │ │ ├── partner-stats.tsx │ │ │ │ │ │ │ │ │ └── payouts/ │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ ├── applications/ │ │ │ │ │ │ │ │ │ ├── applications-menu.tsx │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ │ │ └── rejected/ │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ ├── import-export-buttons.tsx │ │ │ │ │ │ │ │ ├── invite-partner-button.tsx │ │ │ │ │ │ │ │ ├── invite-partner-sheet.tsx │ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ │ ├── partners-table.tsx │ │ │ │ │ │ │ │ └── use-partner-filters.tsx │ │ │ │ │ │ │ ├── partners-graphic.tsx │ │ │ │ │ │ │ ├── partners-upgrade-cta.tsx │ │ │ │ │ │ │ ├── payouts/ │ │ │ │ │ │ │ │ ├── [payoutId]/ │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ │ ├── payout-paid-cell.tsx │ │ │ │ │ │ │ │ ├── payout-stats.tsx │ │ │ │ │ │ │ │ ├── payout-table.tsx │ │ │ │ │ │ │ │ ├── program-payout-methods.tsx │ │ │ │ │ │ │ │ ├── program-payout-mode-section.tsx │ │ │ │ │ │ │ │ ├── program-payout-settings-button.tsx │ │ │ │ │ │ │ │ ├── program-payout-settings-sheet.tsx │ │ │ │ │ │ │ │ ├── success/ │ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ └── use-payout-filters.tsx │ │ │ │ │ │ │ ├── program-settings-row.tsx │ │ │ │ │ │ │ └── resources/ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ ├── program-brand-assets/ │ │ │ │ │ │ │ │ ├── add-color-modal.tsx │ │ │ │ │ │ │ │ ├── add-file-modal.tsx │ │ │ │ │ │ │ │ ├── add-link-modal.tsx │ │ │ │ │ │ │ │ ├── add-logo-modal.tsx │ │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ │ └── use-upload-program-resource.ts │ │ │ │ │ │ │ └── program-help-and-support.tsx │ │ │ │ │ │ └── settings/ │ │ │ │ │ │ ├── billing/ │ │ │ │ │ │ │ ├── invoices/ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ ├── payment-method-types.ts │ │ │ │ │ │ │ ├── payment-methods.tsx │ │ │ │ │ │ │ ├── plan-usage.tsx │ │ │ │ │ │ │ ├── upgrade/ │ │ │ │ │ │ │ │ ├── adjust-usage-row.tsx │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ └── usage-chart.tsx │ │ │ │ │ │ ├── domains/ │ │ │ │ │ │ │ ├── default/ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── email/ │ │ │ │ │ │ │ │ ├── constants.ts │ │ │ │ │ │ │ │ ├── email-domain-card.tsx │ │ │ │ │ │ │ │ ├── email-domain-dns-records.tsx │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── header.tsx │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── integrations/ │ │ │ │ │ │ │ ├── [integrationSlug]/ │ │ │ │ │ │ │ │ ├── loading.tsx │ │ │ │ │ │ │ │ ├── manage/ │ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── enabled/ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── enabled-integrations.tsx │ │ │ │ │ │ │ ├── featured-integrations.tsx │ │ │ │ │ │ │ ├── integrations-cards.tsx │ │ │ │ │ │ │ ├── integrations-list.tsx │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── new/ │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── members/ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── notifications/ │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── oauth-apps/ │ │ │ │ │ │ │ ├── [appId]/ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── create-oauth-app-button.tsx │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── new/ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ ├── security/ │ │ │ │ │ │ │ ├── audit-logs.tsx │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ ├── saml.tsx │ │ │ │ │ │ │ └── scim.tsx │ │ │ │ │ │ ├── tokens/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── tracking/ │ │ │ │ │ │ │ ├── add-hostname-modal.tsx │ │ │ │ │ │ │ ├── base-script-section.tsx │ │ │ │ │ │ │ ├── complete-step-button.tsx │ │ │ │ │ │ │ ├── connection-instructions.tsx │ │ │ │ │ │ │ ├── conversion-tracking-section.tsx │ │ │ │ │ │ │ ├── conversion-tracking-toggle.tsx │ │ │ │ │ │ │ ├── guide.tsx │ │ │ │ │ │ │ ├── hostname-menu.tsx │ │ │ │ │ │ │ ├── hostname-section.tsx │ │ │ │ │ │ │ ├── outbound-domain-tracking-section.tsx │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ ├── publishable-key-form.tsx │ │ │ │ │ │ │ ├── publishable-key-menu.tsx │ │ │ │ │ │ │ ├── site-visit-tracking-section.tsx │ │ │ │ │ │ │ ├── step.tsx │ │ │ │ │ │ │ ├── track-lead-guides-section.tsx │ │ │ │ │ │ │ ├── track-sales-guides-section.tsx │ │ │ │ │ │ │ ├── use-dynamic-guide.ts │ │ │ │ │ │ │ ├── use-selected-guide.ts │ │ │ │ │ │ │ └── verify-install.tsx │ │ │ │ │ │ └── webhooks/ │ │ │ │ │ │ ├── [webhookId]/ │ │ │ │ │ │ │ ├── edit/ │ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── create-webhook-button.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ ├── new/ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── analytics/ │ │ │ │ │ │ ├── client.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── auth.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── links/ │ │ │ │ │ ├── [...link]/ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── domains/ │ │ │ │ │ │ ├── default/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── email/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── folders/ │ │ │ │ │ │ ├── [folderId]/ │ │ │ │ │ │ │ └── members/ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── tags/ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ ├── tag-card-placeholder.tsx │ │ │ │ │ │ └── tag-card.tsx │ │ │ │ │ └── utm/ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── template-card-placeholder.tsx │ │ │ │ │ └── template-card.tsx │ │ │ │ ├── account/ │ │ │ │ │ └── settings/ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── referrals/ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── security/ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ ├── request-set-password.tsx │ │ │ │ │ │ └── update-password.tsx │ │ │ │ │ └── tokens/ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── loading.tsx │ │ │ ├── (deeplink)/ │ │ │ │ └── deeplink/ │ │ │ │ └── [domain]/ │ │ │ │ └── [[...key]]/ │ │ │ │ ├── action-buttons.tsx │ │ │ │ ├── brand-logo-badge.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── translations.ts │ │ │ ├── (invites)/ │ │ │ │ ├── [slug]/ │ │ │ │ │ └── invite/ │ │ │ │ │ ├── accept-invite-button.tsx │ │ │ │ │ ├── close-invite-button.tsx │ │ │ │ │ ├── invite-confetti.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── layout.tsx │ │ │ ├── (onboarding)/ │ │ │ │ ├── [slug]/ │ │ │ │ │ └── wrapped/ │ │ │ │ │ ├── [year]/ │ │ │ │ │ │ ├── client.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── onboarding/ │ │ │ │ │ ├── (steps)/ │ │ │ │ │ │ ├── domain/ │ │ │ │ │ │ │ ├── custom/ │ │ │ │ │ │ │ │ ├── form.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ ├── default-domain-selector.tsx │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ └── register/ │ │ │ │ │ │ │ ├── form.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ ├── plan/ │ │ │ │ │ │ │ ├── enterprise-link.tsx │ │ │ │ │ │ │ ├── free-plan-button.tsx │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ └── plan-selector.tsx │ │ │ │ │ │ ├── products/ │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ └── product-selector.tsx │ │ │ │ │ │ ├── program/ │ │ │ │ │ │ │ ├── form.tsx │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ ├── page.tsx │ │ │ │ │ │ │ ├── reward/ │ │ │ │ │ │ │ │ ├── form.tsx │ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ │ └── use-onboarding-program.tsx │ │ │ │ │ │ ├── step-page.tsx │ │ │ │ │ │ ├── success/ │ │ │ │ │ │ │ ├── page-client.tsx │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ └── workspace/ │ │ │ │ │ │ ├── form.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── later-button.tsx │ │ │ │ │ ├── next-button.tsx │ │ │ │ │ ├── use-onboarding-product.ts │ │ │ │ │ ├── use-onboarding-progress.ts │ │ │ │ │ └── welcome/ │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── track-signup.tsx │ │ │ │ └── signed-in-hint.tsx │ │ │ ├── (redirects)/ │ │ │ │ ├── [slug]/ │ │ │ │ │ ├── domains/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── settings/ │ │ │ │ │ ├── referrals/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── tags/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── analytics/ │ │ │ │ │ └── page.tsx │ │ │ │ └── loading.tsx │ │ │ ├── (share)/ │ │ │ │ └── share/ │ │ │ │ └── [dashboardId]/ │ │ │ │ ├── action.ts │ │ │ │ ├── form.tsx │ │ │ │ └── page.tsx │ │ │ ├── embed/ │ │ │ │ └── support-chat/ │ │ │ │ ├── dynamic-height-messenger.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── banned/ │ │ │ └── page.tsx │ │ ├── cloaked/ │ │ │ └── [url]/ │ │ │ └── page.tsx │ │ ├── custom-uri-scheme/ │ │ │ └── [url]/ │ │ │ └── page.tsx │ │ ├── expired/ │ │ │ └── [domain]/ │ │ │ └── page.tsx │ │ ├── inspect/ │ │ │ └── [domain]/ │ │ │ └── [key]/ │ │ │ ├── card.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── manifest.ts │ │ ├── not-found-hint.tsx │ │ ├── not-found.tsx │ │ ├── password/ │ │ │ └── [linkId]/ │ │ │ ├── action.ts │ │ │ ├── form.tsx │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ │ ├── providers.tsx │ │ ├── proxy/ │ │ │ └── [domain]/ │ │ │ └── [key]/ │ │ │ └── page.tsx │ │ ├── robots.ts │ │ ├── sitemap.ts │ │ └── wellknown/ │ │ └── [domain]/ │ │ └── [file]/ │ │ └── route.ts │ ├── docker-compose.yml │ ├── global-setup.ts │ ├── guides/ │ │ ├── appwrite.md │ │ ├── auth0.md │ │ ├── better-auth.md │ │ ├── clerk.md │ │ ├── framer.md │ │ ├── gtm-client-sdk.md │ │ ├── gtm-track-lead.md │ │ ├── gtm-track-sale.md │ │ ├── manual-client-sdk.md │ │ ├── manual-track-lead.md │ │ ├── manual-track-sale.md │ │ ├── next-auth.md │ │ ├── react.md │ │ ├── rest-api.md │ │ ├── segment-track-lead.md │ │ ├── segment-track-sale.md │ │ ├── shopify.md │ │ ├── stripe-checkout.md │ │ ├── stripe-customers.md │ │ ├── stripe-payment-links.md │ │ ├── supabase.md │ │ ├── webflow.md │ │ └── wordpress.md │ ├── instrumentation.ts │ ├── lib/ │ │ ├── actions/ │ │ │ ├── add-edit-integration.ts │ │ │ ├── auth/ │ │ │ │ └── throw-if-authenticated.ts │ │ │ ├── check-account-exists.ts │ │ │ ├── create-oauth-url.ts │ │ │ ├── create-user-account.ts │ │ │ ├── enable-disable-webhook.ts │ │ │ ├── folders/ │ │ │ │ ├── request-folder-edit-access.ts │ │ │ │ ├── set-default-folder.ts │ │ │ │ └── update-folder-user-role.ts │ │ │ ├── fraud/ │ │ │ │ ├── bulk-resolve-fraud-groups.ts │ │ │ │ └── resolve-fraud-group.ts │ │ │ ├── generate-client-secret.ts │ │ │ ├── generate-unsubscribe-url.ts │ │ │ ├── get-integration-install-url.ts │ │ │ ├── parse-action-errors.ts │ │ │ ├── partners/ │ │ │ │ ├── accept-program-invite.ts │ │ │ │ ├── approve-bounty-submission.ts │ │ │ │ ├── approve-partner.ts │ │ │ │ ├── archive-partner.ts │ │ │ │ ├── ban-partner.ts │ │ │ │ ├── bulk-approve-partners.ts │ │ │ │ ├── bulk-archive-partners.ts │ │ │ │ ├── bulk-ban-partners.ts │ │ │ │ ├── bulk-deactivate-partners.ts │ │ │ │ ├── bulk-invite-partners.ts │ │ │ │ ├── bulk-reject-partner-applications.ts │ │ │ │ ├── confirm-payouts.ts │ │ │ │ ├── create-bounty-submission.ts │ │ │ │ ├── create-clawback.ts │ │ │ │ ├── create-discount.ts │ │ │ │ ├── create-manual-commission.ts │ │ │ │ ├── create-partner-comment.ts │ │ │ │ ├── create-program-application.ts │ │ │ │ ├── create-program.ts │ │ │ │ ├── create-reward.ts │ │ │ │ ├── deactivate-partner.ts │ │ │ │ ├── delete-discount.ts │ │ │ │ ├── delete-partner-comment.ts │ │ │ │ ├── delete-program-invite.ts │ │ │ │ ├── delete-reward.ts │ │ │ │ ├── force-withdrawal.ts │ │ │ │ ├── generate-lander.ts │ │ │ │ ├── generate-paypal-oauth-url.ts │ │ │ │ ├── generate-stripe-account-link.ts │ │ │ │ ├── generate-stripe-recipient-account-link.ts │ │ │ │ ├── get-conversion-score.ts │ │ │ │ ├── invite-partner-from-network.ts │ │ │ │ ├── invite-partner.ts │ │ │ │ ├── mark-commission-duplicate.ts │ │ │ │ ├── mark-commission-fraud-or-canceled.ts │ │ │ │ ├── mark-partner-messages-read.ts │ │ │ │ ├── mark-program-messages-read.ts │ │ │ │ ├── merge-partner-accounts.ts │ │ │ │ ├── message-partner.ts │ │ │ │ ├── message-program.ts │ │ │ │ ├── onboard-partner.ts │ │ │ │ ├── onboard-program.ts │ │ │ │ ├── program-resources/ │ │ │ │ │ ├── add-program-resource.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── delete-program-resource.ts │ │ │ │ │ ├── get-program-resource-upload-url.ts │ │ │ │ │ └── update-program-resource.ts │ │ │ │ ├── reactivate-partner.ts │ │ │ │ ├── reject-bounty-submission.ts │ │ │ │ ├── reject-partner-application.ts │ │ │ │ ├── reopen-bounty-submission.ts │ │ │ │ ├── resend-program-invite.ts │ │ │ │ ├── retry-failed-paypal-payouts.ts │ │ │ │ ├── revoke-program-invite.ts │ │ │ │ ├── save-invite-email-data.ts │ │ │ │ ├── set-rewardful-token.ts │ │ │ │ ├── set-tolt-token.ts │ │ │ │ ├── start-firstpromoter-import.ts │ │ │ │ ├── start-partner-platform-verification.ts │ │ │ │ ├── start-partnerstack-import.ts │ │ │ │ ├── start-rewardful-import.ts │ │ │ │ ├── start-tolt-import.ts │ │ │ │ ├── trigger-aggregate-due-commissions.ts │ │ │ │ ├── unban-partner.ts │ │ │ │ ├── update-application-settings.ts │ │ │ │ ├── update-discount.ts │ │ │ │ ├── update-discovered-partner.ts │ │ │ │ ├── update-group-branding.ts │ │ │ │ ├── update-partner-comment.ts │ │ │ │ ├── update-partner-enrollment.ts │ │ │ │ ├── update-partner-notification-preference.ts │ │ │ │ ├── update-partner-payout-settings.ts │ │ │ │ ├── update-partner-platforms.ts │ │ │ │ ├── update-partner-profile.ts │ │ │ │ ├── update-program.ts │ │ │ │ ├── update-reward.ts │ │ │ │ ├── upload-bounty-submission-file.ts │ │ │ │ ├── upload-campaign-image.ts │ │ │ │ ├── upload-lander-image.ts │ │ │ │ ├── upload-program-application-image.ts │ │ │ │ ├── verify-partner-website.ts │ │ │ │ ├── verify-social-account-by-code.ts │ │ │ │ └── withdraw-partner-application.ts │ │ │ ├── referrals/ │ │ │ │ ├── submit-referral.ts │ │ │ │ ├── update-referral-status.ts │ │ │ │ └── update-referral.ts │ │ │ ├── request-password-reset.ts │ │ │ ├── safe-action.ts │ │ │ ├── send-invite-referral-email.ts │ │ │ ├── send-otp.ts │ │ │ ├── send-test-webhook.ts │ │ │ ├── set-onboarding-progress.ts │ │ │ ├── submit-oauth-app-for-review.ts │ │ │ ├── throw-if-no-permission.ts │ │ │ ├── update-workspace-notification-preference.ts │ │ │ ├── update-workspace-preferences.ts │ │ │ ├── update-workspace-store.ts │ │ │ └── verify-workspace-setup.ts │ │ ├── ai/ │ │ │ ├── build-system-prompt.ts │ │ │ ├── create-support-ticket.ts │ │ │ ├── find-relevant-docs.ts │ │ │ ├── generate-csv-mapping.ts │ │ │ ├── generate-filters.ts │ │ │ ├── get-program-performance.ts │ │ │ ├── get-workspace-details.ts │ │ │ ├── request-support-ticket.ts │ │ │ └── upsert-docs-embedding.ts │ │ ├── analytics/ │ │ │ ├── allowed-hostnames-cache.ts │ │ │ ├── constants.ts │ │ │ ├── convert-currency.ts │ │ │ ├── events-export-helpers.ts │ │ │ ├── filter-helpers.ts │ │ │ ├── format-date-tooltip.ts │ │ │ ├── get-analytics.ts │ │ │ ├── get-customer-events.ts │ │ │ ├── get-events.ts │ │ │ ├── get-folder-ids-to-filter.ts │ │ │ ├── is-first-conversion.ts │ │ │ ├── metadata-query-parser.ts │ │ │ ├── types.ts │ │ │ ├── utils/ │ │ │ │ ├── convert-to-csv.ts │ │ │ │ ├── edit-query-string.ts │ │ │ │ ├── format-utc-datetime-clickhouse.ts │ │ │ │ ├── get-interval-data.ts │ │ │ │ ├── get-start-end-dates.ts │ │ │ │ ├── index.ts │ │ │ │ └── valid-date-range-for-plan.ts │ │ │ └── verify-analytics-allowed-hostnames.ts │ │ ├── api/ │ │ │ ├── activity-log/ │ │ │ │ ├── build-program-enrollment-change-set.ts │ │ │ │ ├── get-resource-diff.ts │ │ │ │ ├── track-activity-log.ts │ │ │ │ └── track-reward-activity-log.ts │ │ │ ├── audit-logs/ │ │ │ │ ├── get-audit-logs.ts │ │ │ │ ├── record-audit-log.ts │ │ │ │ └── schemas.ts │ │ │ ├── campaigns/ │ │ │ │ ├── constants.ts │ │ │ │ ├── get-campaign-events.ts │ │ │ │ ├── get-campaign-or-throw.ts │ │ │ │ ├── get-campaign-summary.ts │ │ │ │ ├── schedule-campaigns.ts │ │ │ │ └── validate-campaign.ts │ │ │ ├── commissions/ │ │ │ │ ├── format-commissions-for-export.ts │ │ │ │ ├── get-commissions-count.ts │ │ │ │ └── get-commissions.ts │ │ │ ├── conversions/ │ │ │ │ ├── track-lead.ts │ │ │ │ └── track-sale.ts │ │ │ ├── cors.ts │ │ │ ├── create-downloadable-export.ts │ │ │ ├── create-id.ts │ │ │ ├── customers/ │ │ │ │ ├── get-customer-or-throw.ts │ │ │ │ ├── get-customer-stripe-invoices.ts │ │ │ │ └── transform-customer.ts │ │ │ ├── discounts/ │ │ │ │ ├── construct-discount-code.ts │ │ │ │ ├── create-discount-code.ts │ │ │ │ ├── delete-discount-code.ts │ │ │ │ └── is-discount-equivalent.ts │ │ │ ├── domains/ │ │ │ │ ├── add-domain-vercel.ts │ │ │ │ ├── claim-dot-link-domain.ts │ │ │ │ ├── configure-vercel-nameservers.ts │ │ │ │ ├── get-config-response.ts │ │ │ │ ├── get-domain-or-throw.ts │ │ │ │ ├── get-domain-response.ts │ │ │ │ ├── get-email-domain-or-throw.ts │ │ │ │ ├── is-valid-domain.ts │ │ │ │ ├── mark-domain-deleted.ts │ │ │ │ ├── queue-domain-update.ts │ │ │ │ ├── remove-domain-vercel.ts │ │ │ │ ├── transform-domain.ts │ │ │ │ ├── utils.ts │ │ │ │ └── verify-domain.ts │ │ │ ├── environment.ts │ │ │ ├── error-codes.ts │ │ │ ├── errors.ts │ │ │ ├── folders/ │ │ │ │ ├── delete-workspace-folders.ts │ │ │ │ └── queue-folder-deletion.ts │ │ │ ├── fraud/ │ │ │ │ ├── constants.ts │ │ │ │ ├── create-fraud-events.ts │ │ │ │ ├── define-fraud-rule.ts │ │ │ │ ├── detect-duplicate-payout-method-fraud.ts │ │ │ │ ├── detect-record-fraud-application.ts │ │ │ │ ├── detect-record-fraud-event.ts │ │ │ │ ├── execute-fraud-rule.ts │ │ │ │ ├── get-merged-fraud-rules.ts │ │ │ │ ├── get-partner-application-risks.ts │ │ │ │ ├── report-cross-program-ban-to-network.ts │ │ │ │ ├── report-fraud-to-network.ts │ │ │ │ ├── resolve-fraud-groups.ts │ │ │ │ ├── rules/ │ │ │ │ │ ├── check-customer-email-match.ts │ │ │ │ │ ├── check-customer-email-suspicious.ts │ │ │ │ │ ├── check-paid-traffic-detected.ts │ │ │ │ │ ├── check-partner-email-domain-mismatch.ts │ │ │ │ │ ├── check-partner-email-masked.ts │ │ │ │ │ ├── check-partner-no-social-links.ts │ │ │ │ │ ├── check-partner-no-verified-social-links.ts │ │ │ │ │ └── check-referral-source-banned.ts │ │ │ │ └── utils.ts │ │ │ ├── get-ratelimit-for-plan.ts │ │ │ ├── get-workspace-users.ts │ │ │ ├── groups/ │ │ │ │ ├── find-groups-with-matching-rules.ts │ │ │ │ ├── get-group-move-rules.ts │ │ │ │ ├── get-group-or-throw.ts │ │ │ │ ├── get-groups.ts │ │ │ │ ├── move-partners-to-group.ts │ │ │ │ ├── throw-if-invalid-group-ids.ts │ │ │ │ ├── upsert-group-move-rules.ts │ │ │ │ └── validate-group-move-rules.ts │ │ │ ├── links/ │ │ │ │ ├── ab-test-scheduler.ts │ │ │ │ ├── archive-link.ts │ │ │ │ ├── bulk-create-links.ts │ │ │ │ ├── bulk-delete-links.ts │ │ │ │ ├── bulk-update-links.ts │ │ │ │ ├── cache.ts │ │ │ │ ├── case-sensitivity.ts │ │ │ │ ├── complete-ab-tests.ts │ │ │ │ ├── create-link.ts │ │ │ │ ├── delete-link.ts │ │ │ │ ├── format-links-for-export.ts │ │ │ │ ├── get-link-or-throw.ts │ │ │ │ ├── get-links-count.ts │ │ │ │ ├── get-links-for-workspace.ts │ │ │ │ ├── include-program-enrollment.ts │ │ │ │ ├── include-tags.ts │ │ │ │ ├── index.ts │ │ │ │ ├── plan-features-check.ts │ │ │ │ ├── process-link.ts │ │ │ │ ├── propagate-bulk-link-changes.ts │ │ │ │ ├── record-click-cache.ts │ │ │ │ ├── update-link-stats-for-importer.ts │ │ │ │ ├── update-link.ts │ │ │ │ ├── update-links-usage.ts │ │ │ │ ├── usage-checks.ts │ │ │ │ ├── utils/ │ │ │ │ │ ├── check-if-links-have-folders.ts │ │ │ │ │ ├── check-if-links-have-tags.ts │ │ │ │ │ ├── check-if-links-have-webhooks.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── key-checks.ts │ │ │ │ │ ├── process-key.ts │ │ │ │ │ └── transform-link.ts │ │ │ │ ├── validate-links-query-filters.ts │ │ │ │ └── validate-partner-link-url.ts │ │ │ ├── network/ │ │ │ │ └── calculate-partner-ranking.ts │ │ │ ├── oauth/ │ │ │ │ ├── actions.ts │ │ │ │ ├── constants.ts │ │ │ │ └── utils.ts │ │ │ ├── pagination.ts │ │ │ ├── partner-profile/ │ │ │ │ ├── client.ts │ │ │ │ ├── get-partner-earnings-timeseries.ts │ │ │ │ ├── get-partner-for-program.ts │ │ │ │ ├── obfuscate-customer-email.ts │ │ │ │ ├── partner-platforms-providers.ts │ │ │ │ └── upsert-partner-platform.ts │ │ │ ├── partners/ │ │ │ │ ├── bulk-deactivate-partners.ts │ │ │ │ ├── bulk-delete-partners.ts │ │ │ │ ├── create-and-enroll-partner.ts │ │ │ │ ├── create-partner-default-links.ts │ │ │ │ ├── deactivate-partner.ts │ │ │ │ ├── format-partners-for-export.ts │ │ │ │ ├── generate-partner-link.ts │ │ │ │ ├── get-discount-or-throw.ts │ │ │ │ ├── get-group-rewards-and-bounties.ts │ │ │ │ ├── get-network-invites-usage.ts │ │ │ │ ├── get-partner-rewind.ts │ │ │ │ ├── get-partner-users.ts │ │ │ │ ├── get-partners-count.ts │ │ │ │ ├── get-partners.ts │ │ │ │ ├── get-reward-or-throw.ts │ │ │ │ ├── invite-partner-user.ts │ │ │ │ ├── notify-partner-application.ts │ │ │ │ ├── notify-partner-commission.ts │ │ │ │ ├── notify-partner-group-change.ts │ │ │ │ ├── process-partner-deactivation.ts │ │ │ │ ├── serialize-reward.ts │ │ │ │ ├── sync-partner-links-stats.ts │ │ │ │ ├── sync-total-commissions.ts │ │ │ │ └── throw-if-existing-tenant-id-exists.ts │ │ │ ├── payouts/ │ │ │ │ ├── get-effective-payout-mode.ts │ │ │ │ ├── get-eligible-payouts.ts │ │ │ │ ├── get-payout-or-throw.ts │ │ │ │ └── payout-eligibility-filter.ts │ │ │ ├── postbacks/ │ │ │ │ └── get-postback-or-throw.ts │ │ │ ├── programs/ │ │ │ │ ├── deactivate-program.ts │ │ │ │ ├── get-default-program-id-or-throw.ts │ │ │ │ ├── get-program-enrollment-or-throw.ts │ │ │ │ └── get-program-or-throw.ts │ │ │ ├── rbac/ │ │ │ │ ├── permissions.ts │ │ │ │ └── resources.ts │ │ │ ├── referrals/ │ │ │ │ ├── get-referral-or-throw.ts │ │ │ │ ├── mark-referral-closed-won.ts │ │ │ │ ├── mark-referral-qualified.ts │ │ │ │ ├── notify-partner-referral-submitted.ts │ │ │ │ └── notify-referral-status-update.ts │ │ │ ├── rewards/ │ │ │ │ └── validate-reward.ts │ │ │ ├── sales/ │ │ │ │ ├── calculate-sale-earnings.ts │ │ │ │ ├── construct-discount-amount.ts │ │ │ │ └── construct-reward-amount.ts │ │ │ ├── scrape-creators/ │ │ │ │ ├── client.ts │ │ │ │ ├── get-linkedin-post.ts │ │ │ │ ├── get-social-content.ts │ │ │ │ ├── get-social-profile.ts │ │ │ │ └── schema.ts │ │ │ ├── tags/ │ │ │ │ └── combine-tag-ids.ts │ │ │ ├── tokens/ │ │ │ │ ├── scopes.ts │ │ │ │ └── throw-if-no-access.ts │ │ │ ├── users.ts │ │ │ ├── utils/ │ │ │ │ ├── assert-valid-date-range-for-plan.ts │ │ │ │ ├── generate-export-filename.ts │ │ │ │ ├── generate-random-string.ts │ │ │ │ ├── get-ip.ts │ │ │ │ ├── is-non-empty-json.ts │ │ │ │ └── with-prisma-retry.ts │ │ │ ├── utils.ts │ │ │ ├── utm/ │ │ │ │ └── extract-utm-params.ts │ │ │ ├── validate-allowed-hostnames.ts │ │ │ ├── workflows/ │ │ │ │ ├── evaluate-workflow-conditions.ts │ │ │ │ ├── execute-complete-bounty-workflow.ts │ │ │ │ ├── execute-move-group-workflow.ts │ │ │ │ ├── execute-send-campaign-workflow.ts │ │ │ │ ├── execute-workflows.ts │ │ │ │ ├── interpolate-email-template.ts │ │ │ │ ├── parse-workflow-config.ts │ │ │ │ ├── render-campaign-email-html.ts │ │ │ │ ├── render-campaign-email-markdown.ts │ │ │ │ └── utils.ts │ │ │ └── workspaces/ │ │ │ ├── assert-role-plan.ts │ │ │ ├── create-workspace-id.ts │ │ │ ├── delete-workspace.ts │ │ │ ├── is-saml-enforced-for-email-domain.ts │ │ │ ├── onboarding-step-cache.ts │ │ │ └── workspace-id.ts │ │ ├── auth/ │ │ │ ├── admin.ts │ │ │ ├── confirm-email-change.ts │ │ │ ├── constants.ts │ │ │ ├── hash-token.ts │ │ │ ├── index.ts │ │ │ ├── lock-account.ts │ │ │ ├── options.ts │ │ │ ├── partner-users/ │ │ │ │ ├── partner-user-permissions.ts │ │ │ │ └── throw-if-no-permission.ts │ │ │ ├── partner.ts │ │ │ ├── password.ts │ │ │ ├── publishable-key.ts │ │ │ ├── rate-limit-request.ts │ │ │ ├── session.ts │ │ │ ├── token-cache.ts │ │ │ ├── track-dub-lead.ts │ │ │ ├── utils.ts │ │ │ └── workspace.ts │ │ ├── axiom/ │ │ │ ├── axiom.ts │ │ │ └── server.ts │ │ ├── bounty/ │ │ │ ├── api/ │ │ │ │ ├── approve-bounty-submission.ts │ │ │ │ ├── create-bounty-submission.ts │ │ │ │ ├── generate-performance-bounty-name.ts │ │ │ │ ├── get-bounties-by-groups.ts │ │ │ │ ├── get-bounty-or-throw.ts │ │ │ │ ├── get-bounty-with-details.ts │ │ │ │ ├── get-group-bounty-summaries.ts │ │ │ │ ├── get-social-metrics-updates.ts │ │ │ │ ├── performance-bounty-scope-attributes.ts │ │ │ │ ├── reject-bounty-submission.ts │ │ │ │ ├── trigger-draft-bounty-submissions.ts │ │ │ │ └── validate-bounty.ts │ │ │ ├── constants.ts │ │ │ ├── periods.ts │ │ │ ├── rewards.ts │ │ │ ├── social-content.ts │ │ │ ├── submission-status.ts │ │ │ └── utils.ts │ │ ├── client-access-check.ts │ │ ├── constants/ │ │ │ ├── misc.ts │ │ │ ├── notification-preferences.ts │ │ │ ├── partner-profile.ts │ │ │ ├── payouts-supported-countries.ts │ │ │ ├── payouts.ts │ │ │ └── program.ts │ │ ├── cron/ │ │ │ ├── enqueue-batch-jobs.ts │ │ │ ├── index.ts │ │ │ ├── limiter.ts │ │ │ ├── qstash-workflow-logger.ts │ │ │ ├── qstash-workflow.ts │ │ │ ├── send-limit-email.ts │ │ │ ├── verify-qstash.ts │ │ │ ├── verify-vercel.ts │ │ │ └── with-cron.ts │ │ ├── customers/ │ │ │ └── api/ │ │ │ ├── customer-count-where.ts │ │ │ ├── fetch-customers-batch.ts │ │ │ ├── format-customers-export.ts │ │ │ └── get-customers.ts │ │ ├── dub.ts │ │ ├── dynadot/ │ │ │ ├── constants.ts │ │ │ ├── register-domain.ts │ │ │ ├── search-domains.ts │ │ │ └── set-renew-option.ts │ │ ├── edge-config/ │ │ │ ├── get-feature-flags.ts │ │ │ ├── get-partner-feature-flags.ts │ │ │ ├── index.ts │ │ │ ├── is-blacklisted-domain.ts │ │ │ ├── is-blacklisted-email.ts │ │ │ ├── is-blacklisted-key.ts │ │ │ ├── is-blacklisted-referrer.ts │ │ │ ├── is-reserved-username.ts │ │ │ └── update.ts │ │ ├── email/ │ │ │ ├── email-templates-map.ts │ │ │ ├── extract-email-domain.ts │ │ │ ├── queue-batch-email.ts │ │ │ └── unsubscribe-token.ts │ │ ├── embed/ │ │ │ ├── constants.ts │ │ │ └── referrals/ │ │ │ ├── auth.ts │ │ │ └── token-class.ts │ │ ├── exceeded-limit-error.ts │ │ ├── fetchers/ │ │ │ ├── get-content-api.ts │ │ │ ├── get-dashboard.ts │ │ │ ├── get-network-program.ts │ │ │ ├── get-program-slugs.ts │ │ │ ├── get-program.ts │ │ │ └── index.ts │ │ ├── firstpromoter/ │ │ │ ├── api.ts │ │ │ ├── import-campaigns.ts │ │ │ ├── import-commissions.ts │ │ │ ├── import-customers.ts │ │ │ ├── import-partners.ts │ │ │ ├── importer.ts │ │ │ ├── schemas.ts │ │ │ ├── types.ts │ │ │ └── update-stripe-customers.ts │ │ ├── folder/ │ │ │ ├── constants.ts │ │ │ ├── get-folder-or-throw.ts │ │ │ ├── get-folders.ts │ │ │ └── permissions.ts │ │ ├── form-utils.ts │ │ ├── get-highest-severity.ts │ │ ├── get-integration-guide-markdown.ts │ │ ├── hooks/ │ │ │ └── use-synced-local-storage.ts │ │ ├── integrations/ │ │ │ ├── bitly/ │ │ │ │ └── oauth.ts │ │ │ ├── common/ │ │ │ │ └── ui/ │ │ │ │ └── configure-webhook.tsx │ │ │ ├── hubspot/ │ │ │ │ ├── api.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── oauth.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── track-lead.ts │ │ │ │ ├── track-sale.ts │ │ │ │ ├── ui/ │ │ │ │ │ └── settings.tsx │ │ │ │ └── update-hubspot-settings.ts │ │ │ ├── install.ts │ │ │ ├── oauth-provider.ts │ │ │ ├── segment/ │ │ │ │ ├── install.ts │ │ │ │ ├── transform.ts │ │ │ │ ├── ui/ │ │ │ │ │ ├── set-write-key.tsx │ │ │ │ │ └── settings.tsx │ │ │ │ └── utils.ts │ │ │ ├── shopify/ │ │ │ │ ├── create-lead.ts │ │ │ │ ├── create-sale.ts │ │ │ │ ├── process-order.ts │ │ │ │ └── schema.ts │ │ │ ├── singular/ │ │ │ │ ├── track-lead.ts │ │ │ │ └── track-sale.ts │ │ │ ├── slack/ │ │ │ │ ├── commands.ts │ │ │ │ ├── oauth.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── transform.ts │ │ │ │ ├── ui/ │ │ │ │ │ └── settings.tsx │ │ │ │ └── verify-request.ts │ │ │ ├── stripe/ │ │ │ │ ├── schema.ts │ │ │ │ ├── ui/ │ │ │ │ │ └── settings.tsx │ │ │ │ └── update-stripe-settings.ts │ │ │ ├── types.ts │ │ │ ├── utils.ts │ │ │ └── zapier/ │ │ │ └── ui/ │ │ │ └── settings.tsx │ │ ├── is-generic-email.ts │ │ ├── jackson.ts │ │ ├── links/ │ │ │ └── links-display.ts │ │ ├── middleware/ │ │ │ ├── admin.ts │ │ │ ├── api.ts │ │ │ ├── app.ts │ │ │ ├── create-link.ts │ │ │ ├── embed.ts │ │ │ ├── link.ts │ │ │ ├── new-link.ts │ │ │ ├── partners.ts │ │ │ ├── utils/ │ │ │ │ ├── app-redirect.ts │ │ │ │ ├── bots-list.ts │ │ │ │ ├── cache-deeplink-click-data.ts │ │ │ │ ├── crawl-bitly.ts │ │ │ │ ├── create-response-with-cookies.ts │ │ │ │ ├── detect-bot.ts │ │ │ │ ├── detect-qr.ts │ │ │ │ ├── get-default-partner.ts │ │ │ │ ├── get-default-workspace.ts │ │ │ │ ├── get-final-url.ts │ │ │ │ ├── get-identity-hash.ts │ │ │ │ ├── get-user-via-token.ts │ │ │ │ ├── get-workspace-product.ts │ │ │ │ ├── handle-not-found-link.ts │ │ │ │ ├── has-pending-invites.ts │ │ │ │ ├── is-google-play-store-url.ts │ │ │ │ ├── is-ios-app-store-url.ts │ │ │ │ ├── is-ip-in-range.ts │ │ │ │ ├── is-singular-tracking-url.ts │ │ │ │ ├── is-supported-custom-uri-scheme.ts │ │ │ │ ├── is-top-level-settings-redirect.ts │ │ │ │ ├── is-valid-internal-redirect.ts │ │ │ │ ├── parse.ts │ │ │ │ ├── partners-redirect.ts │ │ │ │ └── resolve-ab-test-url.ts │ │ │ └── workspaces.ts │ │ ├── names.ts │ │ ├── network/ │ │ │ ├── get-discoverability-requirements.ts │ │ │ ├── get-partner-profile-checklist-progress.ts │ │ │ └── program-categories.ts │ │ ├── next-auth.d.ts │ │ ├── onboarding/ │ │ │ └── types.ts │ │ ├── openapi/ │ │ │ ├── analytics/ │ │ │ │ └── index.ts │ │ │ ├── bounties/ │ │ │ │ ├── approve-bounty-submission.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-bounty-submissions.ts │ │ │ │ └── reject-bounty-submission.ts │ │ │ ├── commissions/ │ │ │ │ ├── index.ts │ │ │ │ ├── list-commissions.ts │ │ │ │ └── update-commission.ts │ │ │ ├── customers/ │ │ │ │ ├── delete-customer.ts │ │ │ │ ├── get-customer.ts │ │ │ │ ├── get-customers.ts │ │ │ │ ├── index.ts │ │ │ │ └── update-customer.ts │ │ │ ├── domains/ │ │ │ │ ├── check-domain-status.ts │ │ │ │ ├── create-domain.ts │ │ │ │ ├── delete-domain.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-domains.ts │ │ │ │ ├── register-domain.ts │ │ │ │ └── update-domain.ts │ │ │ ├── embed-tokens/ │ │ │ │ ├── create-referrals-embed-token.ts │ │ │ │ └── index.ts │ │ │ ├── events/ │ │ │ │ └── index.ts │ │ │ ├── folders/ │ │ │ │ ├── create-folder.ts │ │ │ │ ├── delete-folder.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-folders.ts │ │ │ │ └── update-folder.ts │ │ │ ├── index.ts │ │ │ ├── links/ │ │ │ │ ├── bulk-create-links.ts │ │ │ │ ├── bulk-delete-links.ts │ │ │ │ ├── bulk-update-links.ts │ │ │ │ ├── create-link.ts │ │ │ │ ├── delete-link.ts │ │ │ │ ├── get-link-info.ts │ │ │ │ ├── get-links-count.ts │ │ │ │ ├── get-links.ts │ │ │ │ ├── index.ts │ │ │ │ ├── update-link.ts │ │ │ │ └── upsert-link.ts │ │ │ ├── partners/ │ │ │ │ ├── ban-partner.ts │ │ │ │ ├── create-partner-link.ts │ │ │ │ ├── create-partner.ts │ │ │ │ ├── deactivate-partner.ts │ │ │ │ ├── index.ts │ │ │ │ ├── list-partners.ts │ │ │ │ ├── retrieve-analytics.ts │ │ │ │ ├── retrieve-partner-links.ts │ │ │ │ └── upsert-partner-link.ts │ │ │ ├── payouts/ │ │ │ │ ├── index.ts │ │ │ │ └── list-payouts.ts │ │ │ ├── qr/ │ │ │ │ └── index.ts │ │ │ ├── responses.ts │ │ │ ├── tags/ │ │ │ │ ├── create-tag.ts │ │ │ │ ├── delete-tag.ts │ │ │ │ ├── get-tags.ts │ │ │ │ ├── index.ts │ │ │ │ └── update-tag.ts │ │ │ └── track/ │ │ │ ├── index.ts │ │ │ ├── lead.ts │ │ │ ├── open.ts │ │ │ └── sale.ts │ │ ├── partners/ │ │ │ ├── aggregate-partner-links-stats.ts │ │ │ ├── approve-partner-enrollment.ts │ │ │ ├── calculate-payout-fee-with-waiver.ts │ │ │ ├── complete-program-applications.ts │ │ │ ├── construct-partner-link.ts │ │ │ ├── create-partner-commission.ts │ │ │ ├── create-stablecoin-payout.ts │ │ │ ├── create-stripe-transfer.ts │ │ │ ├── cutoff-period.ts │ │ │ ├── determine-partner-reward.ts │ │ │ ├── evaluate-application-requirements.ts │ │ │ ├── evaluate-reward-conditions.ts │ │ │ ├── format-application-form-data.ts │ │ │ ├── get-group-rewards-and-discount.ts │ │ │ ├── get-link-structure-options.ts │ │ │ ├── get-partner-bank-account.ts │ │ │ ├── get-payout-methods-for-country.ts │ │ │ ├── get-reward-amount.ts │ │ │ ├── partner-platforms.ts │ │ │ ├── partner-profile.ts │ │ │ ├── query-link-structure-help-text.tsx │ │ │ ├── sanitize-markdown.ts │ │ │ ├── sort-rewards-by-event-order.ts │ │ │ └── throw-if-no-partnerid-tenantid.ts │ │ ├── partnerstack/ │ │ │ ├── api.ts │ │ │ ├── import-commissions.ts │ │ │ ├── import-customers.ts │ │ │ ├── import-groups.ts │ │ │ ├── import-links.ts │ │ │ ├── import-partners.ts │ │ │ ├── importer.ts │ │ │ ├── schemas.ts │ │ │ ├── types.ts │ │ │ └── update-stripe-customers.ts │ │ ├── payouts/ │ │ │ ├── create-payouts-idempotency-key.ts │ │ │ ├── get-partner-payout-methods.ts │ │ │ ├── mark-payouts-as-processed.ts │ │ │ └── recompute-partner-payout-state.ts │ │ ├── paypal/ │ │ │ ├── create-batch-payout.ts │ │ │ ├── create-paypal-token.ts │ │ │ ├── env.ts │ │ │ ├── get-pending-payouts.ts │ │ │ ├── oauth.ts │ │ │ └── schema.ts │ │ ├── plain/ │ │ │ ├── client.ts │ │ │ ├── create-plain-thread.ts │ │ │ ├── sync-user-plan.ts │ │ │ └── upsert-plain-customer.ts │ │ ├── plan-capabilities.ts │ │ ├── planetscale/ │ │ │ ├── check-if-key-exists.ts │ │ │ ├── check-if-user-exists.ts │ │ │ ├── connection.ts │ │ │ ├── get-domain-via-edge.ts │ │ │ ├── get-link-via-edge.ts │ │ │ ├── get-link-with-partner.ts │ │ │ ├── get-partner-enrollment-info.ts │ │ │ ├── get-random-key.ts │ │ │ ├── get-shortlink-via-edge.ts │ │ │ ├── get-workspace-via-edge.ts │ │ │ ├── granularity.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── plans/ │ │ │ └── has-partner-access.ts │ │ ├── postback/ │ │ │ ├── api/ │ │ │ │ ├── get-postback-events.ts │ │ │ │ ├── postback-adapter-custom.ts │ │ │ │ ├── postback-adapter-slack.ts │ │ │ │ ├── postback-adapters.ts │ │ │ │ ├── postback-event-enrichers.ts │ │ │ │ ├── postback-event-transformers.ts │ │ │ │ ├── record-postback-event.ts │ │ │ │ ├── send-partner-postback.ts │ │ │ │ └── utils.ts │ │ │ ├── constants.ts │ │ │ ├── sample-events/ │ │ │ │ ├── commission-created.json │ │ │ │ ├── lead-created.json │ │ │ │ └── sale-created.json │ │ │ └── schemas.ts │ │ ├── qr/ │ │ │ ├── api.tsx │ │ │ ├── codegen.ts │ │ │ ├── constants.ts │ │ │ ├── index.tsx │ │ │ ├── types.ts │ │ │ └── utils.tsx │ │ ├── referrals/ │ │ │ └── constants.ts │ │ ├── rewardful/ │ │ │ ├── api.ts │ │ │ ├── import-affiliate-coupons.ts │ │ │ ├── import-campaigns.ts │ │ │ ├── import-commissions.ts │ │ │ ├── import-customers.ts │ │ │ ├── import-partners.ts │ │ │ ├── importer.ts │ │ │ ├── schemas.ts │ │ │ └── types.ts │ │ ├── social-utils.ts │ │ ├── storage.ts │ │ ├── stripe/ │ │ │ ├── cancel-subscription.ts │ │ │ ├── check-payment-method-mandate.ts │ │ │ ├── client.ts │ │ │ ├── coupon-discount-converter.ts │ │ │ ├── create-connected-account.ts │ │ │ ├── create-fx-quote.ts │ │ │ ├── create-payment-intent.ts │ │ │ ├── create-stripe-discount-code.ts │ │ │ ├── create-stripe-outbound-payment.ts │ │ │ ├── create-stripe-recipient-account-link.ts │ │ │ ├── create-stripe-recipient-account.ts │ │ │ ├── disable-stripe-discount-code.ts │ │ │ ├── fund-financial-account.ts │ │ │ ├── get-stripe-outbound-payment.ts │ │ │ ├── get-stripe-recipient-account.ts │ │ │ ├── get-stripe-recipient-payout-method.ts │ │ │ ├── index.ts │ │ │ ├── payment-methods.ts │ │ │ ├── stripe-v2-client.ts │ │ │ └── stripe-v2-schemas.ts │ │ ├── swr/ │ │ │ ├── mutate.ts │ │ │ ├── use-activity-logs.ts │ │ │ ├── use-api-mutation.ts │ │ │ ├── use-bounty-submissions-count.ts │ │ │ ├── use-bounty.ts │ │ │ ├── use-commission.ts │ │ │ ├── use-commissions-count.ts │ │ │ ├── use-commissions-timeseries.ts │ │ │ ├── use-current-folder-id.ts │ │ │ ├── use-customer-activity.ts │ │ │ ├── use-customer.ts │ │ │ ├── use-customers-count.ts │ │ │ ├── use-customers.ts │ │ │ ├── use-default-domains.ts │ │ │ ├── use-discount-codes.ts │ │ │ ├── use-discounts.ts │ │ │ ├── use-domain.ts │ │ │ ├── use-domains-count.ts │ │ │ ├── use-domains.ts │ │ │ ├── use-email-domains.ts │ │ │ ├── use-folder-access-requests.ts │ │ │ ├── use-folder-link-count.ts │ │ │ ├── use-folder-permissions.ts │ │ │ ├── use-folder-users.ts │ │ │ ├── use-folder.ts │ │ │ ├── use-folders-count.ts │ │ │ ├── use-folders.ts │ │ │ ├── use-fraud-events-count.ts │ │ │ ├── use-fraud-events-paginated.ts │ │ │ ├── use-fraud-events.ts │ │ │ ├── use-fraud-groups-count.ts │ │ │ ├── use-fraud-groups.ts │ │ │ ├── use-group-move-rules.ts │ │ │ ├── use-group.ts │ │ │ ├── use-groups-count.ts │ │ │ ├── use-groups.ts │ │ │ ├── use-guide.ts │ │ │ ├── use-integrations.ts │ │ │ ├── use-link.ts │ │ │ ├── use-links-count.ts │ │ │ ├── use-links.ts │ │ │ ├── use-network-partners-count.ts │ │ │ ├── use-network-programs-count.ts │ │ │ ├── use-partner-activity-logs.ts │ │ │ ├── use-partner-analytics.ts │ │ │ ├── use-partner-application-risks.ts │ │ │ ├── use-partner-bounty.ts │ │ │ ├── use-partner-comments-count.ts │ │ │ ├── use-partner-comments.ts │ │ │ ├── use-partner-cross-program-summary.ts │ │ │ ├── use-partner-customer.ts │ │ │ ├── use-partner-customers-count.ts │ │ │ ├── use-partner-customers.ts │ │ │ ├── use-partner-earnings-count.ts │ │ │ ├── use-partner-earnings-timeseries.ts │ │ │ ├── use-partner-group-default-links.ts │ │ │ ├── use-partner-links.ts │ │ │ ├── use-partner-messages-count.ts │ │ │ ├── use-partner-messages.ts │ │ │ ├── use-partner-network-invites-usage.ts │ │ │ ├── use-partner-payout-settings.ts │ │ │ ├── use-partner-payouts-count.ts │ │ │ ├── use-partner-payouts.ts │ │ │ ├── use-partner-profile.ts │ │ │ ├── use-partner-program-bounties.ts │ │ │ ├── use-partner-referrals-count.ts │ │ │ ├── use-partner-referrals.ts │ │ │ ├── use-partner-rewind.ts │ │ │ ├── use-partner.ts │ │ │ ├── use-partners-count-by-groupids.ts │ │ │ ├── use-partners-count.ts │ │ │ ├── use-partners.ts │ │ │ ├── use-payment-methods.ts │ │ │ ├── use-payout.ts │ │ │ ├── use-payouts-count.ts │ │ │ ├── use-payouts.ts │ │ │ ├── use-program-enrollment.ts │ │ │ ├── use-program-enrollments-count.ts │ │ │ ├── use-program-enrollments.ts │ │ │ ├── use-program-messages-count.ts │ │ │ ├── use-program-messages.ts │ │ │ ├── use-program-referrals-count.ts │ │ │ ├── use-program-resources.ts │ │ │ ├── use-program.ts │ │ │ ├── use-refresh-session.ts │ │ │ ├── use-rewardful-campaigns.ts │ │ │ ├── use-rewards.ts │ │ │ ├── use-saml.ts │ │ │ ├── use-scim.ts │ │ │ ├── use-tags-count.ts │ │ │ ├── use-tags.ts │ │ │ ├── use-usage-timeseries.ts │ │ │ ├── use-user.ts │ │ │ ├── use-webhook.ts │ │ │ ├── use-webhooks.ts │ │ │ ├── use-workspace-preferences.ts │ │ │ ├── use-workspace-store.ts │ │ │ ├── use-workspace-users.ts │ │ │ ├── use-workspace.ts │ │ │ └── use-workspaces.ts │ │ ├── tinybird/ │ │ │ ├── client.ts │ │ │ ├── get-click-event.ts │ │ │ ├── get-customer-events-tb.ts │ │ │ ├── get-import-error-logs.ts │ │ │ ├── get-lead-event.ts │ │ │ ├── get-lead-events.ts │ │ │ ├── get-top-links-by-countries.ts │ │ │ ├── get-webhook-events.ts │ │ │ ├── index.ts │ │ │ ├── log-conversion-events.ts │ │ │ ├── log-import-error.ts │ │ │ ├── record-click-zod.ts │ │ │ ├── record-click.ts │ │ │ ├── record-fake-click.ts │ │ │ ├── record-lead.ts │ │ │ ├── record-link.ts │ │ │ ├── record-sale.ts │ │ │ └── record-webhook-event.ts │ │ ├── tolt/ │ │ │ ├── api.ts │ │ │ ├── cleanup-partners.ts │ │ │ ├── import-commissions.ts │ │ │ ├── import-customers.ts │ │ │ ├── import-links.ts │ │ │ ├── import-partners.ts │ │ │ ├── importer.ts │ │ │ ├── schemas.ts │ │ │ ├── types.ts │ │ │ └── update-stripe-customers.ts │ │ ├── types.ts │ │ ├── upstash/ │ │ │ ├── format-redis-link.ts │ │ │ ├── index.ts │ │ │ ├── ratelimit-policy.ts │ │ │ ├── ratelimit.ts │ │ │ ├── record-metatags.ts │ │ │ ├── redis-streams.ts │ │ │ ├── redis.ts │ │ │ └── vector.ts │ │ ├── webhook/ │ │ │ ├── cache.ts │ │ │ ├── constants.ts │ │ │ ├── create-webhook.ts │ │ │ ├── failure.ts │ │ │ ├── get-webhooks.ts │ │ │ ├── handle-external-payout-event.ts │ │ │ ├── publish.ts │ │ │ ├── qstash.ts │ │ │ ├── sample-events/ │ │ │ │ ├── bounty-created.json │ │ │ │ ├── bounty-updated.json │ │ │ │ ├── commission-created.json │ │ │ │ ├── lead-created.json │ │ │ │ ├── link-clicked.json │ │ │ │ ├── link-created.json │ │ │ │ ├── link-deleted.json │ │ │ │ ├── link-updated.json │ │ │ │ ├── partner-application-submitted.json │ │ │ │ ├── partner-enrolled.json │ │ │ │ ├── payload.ts │ │ │ │ ├── payout-confirmed.json │ │ │ │ └── sale-created.json │ │ │ ├── schemas.ts │ │ │ ├── secret.ts │ │ │ ├── signature.ts │ │ │ ├── transform.ts │ │ │ ├── types.ts │ │ │ ├── update-webhook.ts │ │ │ ├── utils.ts │ │ │ └── validate-webhook.ts │ │ ├── well-known.ts │ │ ├── workspace-roles.ts │ │ └── zod/ │ │ └── schemas/ │ │ ├── activity-log.ts │ │ ├── analytics-response.ts │ │ ├── analytics.ts │ │ ├── auth.ts │ │ ├── bounties.ts │ │ ├── campaigns.ts │ │ ├── clicks.ts │ │ ├── commissions.ts │ │ ├── customer-activity.ts │ │ ├── customers.ts │ │ ├── dashboard.ts │ │ ├── deep-links.ts │ │ ├── deprecated.ts │ │ ├── discount.ts │ │ ├── domains.ts │ │ ├── email-domains.ts │ │ ├── folders.ts │ │ ├── fraud.ts │ │ ├── group-bounties.ts │ │ ├── group-with-program.ts │ │ ├── groups.ts │ │ ├── import-csv.ts │ │ ├── import-error-log.ts │ │ ├── integration.ts │ │ ├── invites.ts │ │ ├── invoices.ts │ │ ├── leads.ts │ │ ├── links.ts │ │ ├── messages.ts │ │ ├── misc.ts │ │ ├── oauth.ts │ │ ├── opens.ts │ │ ├── partner-network.ts │ │ ├── partner-profile.ts │ │ ├── partners.ts │ │ ├── payouts.ts │ │ ├── program-application-form.ts │ │ ├── program-application.ts │ │ ├── program-embed.ts │ │ ├── program-invite-email.ts │ │ ├── program-lander.ts │ │ ├── program-network.ts │ │ ├── program-onboarding.ts │ │ ├── program-resources.ts │ │ ├── programs.ts │ │ ├── qr.ts │ │ ├── referral-form.ts │ │ ├── referrals-embed.ts │ │ ├── referrals.ts │ │ ├── rewards.ts │ │ ├── sales.ts │ │ ├── schemas.ts │ │ ├── tags.ts │ │ ├── token.ts │ │ ├── usage.ts │ │ ├── users.ts │ │ ├── utils.ts │ │ ├── utm.ts │ │ ├── webhooks.ts │ │ ├── workflows.ts │ │ ├── workspace-preferences.ts │ │ └── workspaces.ts │ ├── middleware.ts │ ├── next.config.js │ ├── package.json │ ├── playwright/ │ │ ├── README.md │ │ ├── auth.setup.ts │ │ ├── env.ts │ │ ├── partner-login.spec.ts │ │ ├── partner-onboarding.spec.ts │ │ └── seed.ts │ ├── playwright.config.ts │ ├── postcss.config.js │ ├── public/ │ │ └── .well-known/ │ │ └── security.txt │ ├── scripts/ │ │ ├── analyze-bundle.ts │ │ ├── analyze-domains.ts │ │ ├── analyze-link-webhooks.ts │ │ ├── analyze-top-utms.ts │ │ ├── analyze-utm-usage.ts │ │ ├── annature/ │ │ │ └── import-domains.ts │ │ ├── buffer/ │ │ │ ├── delete-old-links.ts │ │ │ └── migrate-to-case-sensitive.ts │ │ ├── bulk-archive-links.ts │ │ ├── bulk-create-domains.ts │ │ ├── bulk-create-links.ts │ │ ├── bulk-delete-links.ts │ │ ├── bulk-update-links.ts │ │ ├── cache-popular-urls.ts │ │ ├── cal/ │ │ │ └── backfill-referral-links.ts │ │ ├── check-customers.ts │ │ ├── conversion-customers.ts │ │ ├── convert-case-sensitive.ts │ │ ├── convert-manual-commissions.ts │ │ ├── create-integration.ts │ │ ├── create-key.ts │ │ ├── deactivate-programs.ts │ │ ├── delete-link-cache.ts │ │ ├── dev/ │ │ │ ├── data.json │ │ │ └── seed.ts │ │ ├── download-links.ts │ │ ├── download-top-links.ts │ │ ├── dub-domain-users.ts │ │ ├── dub-partner-rewind.ts │ │ ├── dub-sdk.ts │ │ ├── dub-wrapped.ts │ │ ├── find-link.ts │ │ ├── find-workspaces-without-users.ts │ │ ├── fix-broken-applications.ts │ │ ├── fix-broken-link-tags.ts │ │ ├── fix-broken-partner-users.ts │ │ ├── fix-broken-root-domains.ts │ │ ├── fix-broken-workspace-users.ts │ │ ├── fix-usage-count.ts │ │ ├── format-clicks.ts │ │ ├── format-links.ts │ │ ├── framer/ │ │ │ ├── 1-process-framer-combined.ts │ │ │ ├── 2-sort-lead-events-by-date.ts │ │ │ ├── 3-backfill-tb-events.ts │ │ │ ├── backfill-commissions.ts │ │ │ ├── check-pending-payout-totals.ts │ │ │ ├── get-links-to-backfill.ts │ │ │ ├── get-remaining-links-to-backfill.ts │ │ │ ├── mark-commissions-paid.ts │ │ │ ├── mark-commissions-pending.ts │ │ │ ├── process-lead-events.ts │ │ │ └── tally-commissions.ts │ │ ├── generate-openapi.ts │ │ ├── get-api-users.ts │ │ ├── get-customers.ts │ │ ├── get-inactive-users.ts │ │ ├── get-premium-workspaces.ts │ │ ├── get-top-domains-for-links.ts │ │ ├── get-top-links-for-workspace.ts │ │ ├── get-users-by-links.ts │ │ ├── get-users-with-multiple-free-workspaces.ts │ │ ├── get-users.ts │ │ ├── get-workspaces-by-clicks.ts │ │ ├── get-workspaces-by-links.ts │ │ ├── hash-speed.ts │ │ ├── lua-convert.ts │ │ ├── migrate-commission-attributes.ts │ │ ├── migrations/ │ │ │ ├── backfill-application-groupId.ts │ │ │ ├── backfill-attribution.ts │ │ │ ├── backfill-banned-partner-links.ts │ │ │ ├── backfill-click-commissions.ts │ │ │ ├── backfill-commissions-rewardId.ts │ │ │ ├── backfill-cross-program-ban-fraud-events.ts │ │ │ ├── backfill-customer-first-sale.ts │ │ │ ├── backfill-customer-partner-ids.ts │ │ │ ├── backfill-customer-sales.ts │ │ │ ├── backfill-customer-subscription-cancellation.ts │ │ │ ├── backfill-customers.ts │ │ │ ├── backfill-dashboards.ts │ │ │ ├── backfill-deepview.ts │ │ │ ├── backfill-default-payout-method.ts │ │ │ ├── backfill-default-program-ids.ts │ │ │ ├── backfill-discoverableat.ts │ │ │ ├── backfill-domain-logo.ts │ │ │ ├── backfill-folders-limit.ts │ │ │ ├── backfill-folders-usage.ts │ │ │ ├── backfill-group-links-pgdl-acme.ts │ │ │ ├── backfill-group-links-pgdl.ts │ │ │ ├── backfill-group-links-settings.ts │ │ │ ├── backfill-group-settings.ts │ │ │ ├── backfill-invoice-paid-at.ts │ │ │ ├── backfill-invoice-payment-method.ts │ │ │ ├── backfill-invoice-prefixes.ts │ │ │ ├── backfill-link-commissions.ts │ │ │ ├── backfill-link-partner-group-ids.ts │ │ │ ├── backfill-link-stats.ts │ │ │ ├── backfill-link-webhooks.ts │ │ │ ├── backfill-missing-lead-commissions.ts │ │ │ ├── backfill-missing-sales.ts │ │ │ ├── backfill-notification-email-columns.ts │ │ │ ├── backfill-notification-email-deliveredat.ts │ │ │ ├── backfill-notification-preferences.ts │ │ │ ├── backfill-partner-groupid-logs.ts │ │ │ ├── backfill-partner-groups-verify.ts │ │ │ ├── backfill-partner-groups.ts │ │ │ ├── backfill-partner-platforms.ts │ │ │ ├── backfill-payout-initiated-at.ts │ │ │ ├── backfill-payout-method-hash.ts │ │ │ ├── backfill-payout-method.ts │ │ │ ├── backfill-payout-mode.ts │ │ │ ├── backfill-performance-bounty-submissions.ts │ │ │ ├── backfill-plain-customers.ts │ │ │ ├── backfill-program-categories.ts │ │ │ ├── backfill-program-marketplace-descriptions.ts │ │ │ ├── backfill-program-marketplace.ts │ │ │ ├── backfill-referral-links.ts │ │ │ ├── backfill-reward-activity-log.ts │ │ │ ├── backfill-reward-modifier-ids.ts │ │ │ ├── backfill-saml-sso.ts │ │ │ ├── backfill-short-links.ts │ │ │ ├── backfill-stripe-connect.ts │ │ │ ├── backfill-submission-completedat.ts │ │ │ ├── backfill-total-commissions.ts │ │ │ ├── migrate-application-formdata.ts │ │ │ ├── migrate-application-submissions.ts │ │ │ ├── migrate-bounties-submission-requirements.ts │ │ │ ├── migrate-campaign-message-to-markdown.ts │ │ │ ├── migrate-discounts.ts │ │ │ ├── migrate-domains.ts │ │ │ ├── migrate-images.ts │ │ │ ├── migrate-integrations.ts │ │ │ ├── migrate-lander-data.ts │ │ │ ├── migrate-links-to-workspaces.ts │ │ │ ├── migrate-partner-links.ts │ │ │ ├── migrate-partners-with-tenantids.ts │ │ │ ├── migrate-reward-amounts.ts │ │ │ ├── migrate-rewards-remainder.ts │ │ │ ├── migrate-rewards.ts │ │ │ ├── migrate-sales.ts │ │ │ ├── migrate-workflow-triggers.ts │ │ │ ├── remove-duplicate-notification-emails.ts │ │ │ ├── restore-group-ids.ts │ │ │ ├── sanitize-partner-platform.ts │ │ │ ├── update-discoverable-partners.ts │ │ │ └── update-payout-mode-to-internal.ts │ │ ├── misc/ │ │ │ ├── cleanup-fraud-events.ts │ │ │ ├── cleanup-generic-email-fraud-events.ts │ │ │ ├── fraud-campaign-ids.ts │ │ │ ├── remove-fraud-events.ts │ │ │ ├── restore-link-analytics.ts │ │ │ ├── restore-links.ts │ │ │ ├── restore-program-enrollments.ts │ │ │ └── restore-program-folders.ts │ │ ├── move-links-to-folder.ts │ │ ├── partners/ │ │ │ ├── aggregate-stats-seeding.ts │ │ │ ├── check-pending-paypal-payouts.ts │ │ │ ├── combine-payouts.ts │ │ │ ├── delete-partner-profile.ts │ │ │ ├── delete-partners-for-program.ts │ │ │ ├── delete-program-application.ts │ │ │ ├── delete-program-enrollment.ts │ │ │ ├── delete-program.ts │ │ │ ├── export-partners.ts │ │ │ ├── fix-partner-groups.ts │ │ │ ├── fix-partner-payouts.ts │ │ │ ├── get-largest-programs.ts │ │ │ ├── invalidate-partner-links.ts │ │ │ ├── merge-partner-profile.ts │ │ │ ├── update-links.ts │ │ │ ├── update-partner-country.ts │ │ │ └── update-payout-dates.ts │ │ ├── perplexity/ │ │ │ ├── backfill-leads.ts │ │ │ ├── backfill-tenantids.ts │ │ │ ├── ban-partners.ts │ │ │ ├── deactivate-partners.ts │ │ │ ├── move-partners.ts │ │ │ ├── partners-updated-countries.ts │ │ │ ├── review-bounties.ts │ │ │ ├── update-commissions.ts │ │ │ └── update-notifications.ts │ │ ├── persist-customer-avatars.ts │ │ ├── processed-payouts.ts │ │ ├── programs/ │ │ │ ├── 1-import-partners.ts │ │ │ ├── 2-import-partner-links.ts │ │ │ ├── 3-import-customer-leads.ts │ │ │ ├── 4-export-stripe-invoices.ts │ │ │ ├── 5-import-customer-sales.ts │ │ │ ├── add-to-marketplace.ts │ │ │ ├── backfill-custom-commissions.ts │ │ │ ├── backfill-discount-codes.ts │ │ │ ├── backfill-reuse-commission.ts │ │ │ ├── delete-program-enrollments.ts │ │ │ ├── update-commissions-canceled.ts │ │ │ └── update-commissions-paid.ts │ │ ├── referral-form-sample.json │ │ ├── remove-workspace-scopes.ts │ │ ├── restore-backup.ts │ │ ├── revert-partner-payout-demo.ts │ │ ├── reward-conditions.ts │ │ ├── run.ts │ │ ├── seed-invite-codes.ts │ │ ├── seed-support-embeddings.ts │ │ ├── send-batch-emails.ts │ │ ├── sent-mail-reset.ts │ │ ├── ship30/ │ │ │ └── backfill-leads.ts │ │ ├── sitemap-importer.ts │ │ ├── stripe/ │ │ │ ├── backfill-stripe-webhook-events.ts │ │ │ ├── backfill-trace-id.ts │ │ │ ├── connect-client.ts │ │ │ ├── delete-connected-account.ts │ │ │ ├── fix-processed-payouts.ts │ │ │ ├── get-connected-customer.ts │ │ │ ├── manual-payouts.ts │ │ │ ├── retrieve-balance.ts │ │ │ ├── search-customers.ts │ │ │ ├── update-payouts-schedule.ts │ │ │ └── update-stripe-customers.ts │ │ ├── sync-conversions.ts │ │ ├── sync-domain-clicks.ts │ │ ├── sync-expired-links.ts │ │ ├── sync-limits.ts │ │ ├── sync-link-clicks.ts │ │ ├── sync-link-tags.ts │ │ ├── sync-links-metadata.ts │ │ ├── sync-tag-analytics.ts │ │ ├── tella/ │ │ │ ├── remind-applications.ts │ │ │ ├── update-commission-flat.ts │ │ │ ├── update-commission-percentage.ts │ │ │ ├── update-commissions.ts │ │ │ └── update-reward-tier.ts │ │ ├── test-paypal-payouts.ts │ │ ├── testimonial/ │ │ │ ├── final-sync-commissions.ts │ │ │ ├── sync-commissions.ts │ │ │ └── update-commissions.ts │ │ ├── tinybird/ │ │ │ ├── delete-lead-event.ts │ │ │ ├── delete-links.ts │ │ │ ├── delete-sale-event.ts │ │ │ ├── update-click-event.ts │ │ │ ├── update-lead-event.ts │ │ │ └── update-sale-event.ts │ │ ├── trigger-update-partner-stats.ts │ │ ├── unban-links.ts │ │ ├── update-integrations.ts │ │ ├── update-link-owner.ts │ │ ├── update-not-found.ts │ │ ├── update-payment-failed.ts │ │ ├── update-payouts-limits.ts │ │ ├── update-referral-form-data.ts │ │ ├── update-spam-links.ts │ │ ├── update-subscribers.ts │ │ ├── update-user-notifications.ts │ │ ├── update-webhook-cache.ts │ │ ├── update-workspace-dates.ts │ │ ├── update-workspace-tags.ts │ │ ├── upload-users.ts │ │ └── wispr-flow/ │ │ └── update-links.ts │ ├── styles/ │ │ ├── fonts.ts │ │ └── globals.css │ ├── tailwind.config.ts │ ├── tests/ │ │ ├── analytics/ │ │ │ ├── advanced-filter-helpers.test.ts │ │ │ ├── get-analytics-advanced.test.ts │ │ │ ├── get-analytics.test.ts │ │ │ ├── get-events.test.ts │ │ │ ├── metadata-query-parser.test.ts │ │ │ ├── partner-analytics.test.ts │ │ │ └── public-analytics-dashboard.test.ts │ │ ├── bounties/ │ │ │ └── index.test.ts │ │ ├── campaigns/ │ │ │ └── index.test.ts │ │ ├── commissions/ │ │ │ ├── index.test.ts │ │ │ └── pagination.test.ts │ │ ├── customers/ │ │ │ ├── index.test.ts │ │ │ └── pagination.test.ts │ │ ├── discounts/ │ │ │ └── index.test.ts │ │ ├── domains/ │ │ │ └── index.test.ts │ │ ├── embed-tokens/ │ │ │ └── referrals.test.ts │ │ ├── folders/ │ │ │ └── index.test.ts │ │ ├── fraud/ │ │ │ ├── fraud-groups.test.ts │ │ │ └── index.test.ts │ │ ├── links/ │ │ │ ├── bulk-create-link.test.ts │ │ │ ├── bulk-delete-link.test.ts │ │ │ ├── bulk-update-link.test.ts │ │ │ ├── count-links.test.ts │ │ │ ├── create-link-error.test.ts │ │ │ ├── create-link.test.ts │ │ │ ├── delete-link.test.ts │ │ │ ├── folder-link-access.test.ts │ │ │ ├── list-links.test.ts │ │ │ ├── retrieve-link.test.ts │ │ │ ├── retrieve-metatags.test.ts │ │ │ ├── update-link.test.ts │ │ │ └── upsert-link.test.ts │ │ ├── misc/ │ │ │ ├── allowed-hostnames.test.ts │ │ │ ├── base64.test.ts │ │ │ ├── calculate-payout-fee-with-waiver.test.ts │ │ │ ├── case-sensitive-keys.test.ts │ │ │ ├── check-eligibility-requirements.test.ts │ │ │ ├── create-id.test.ts │ │ │ ├── eligibility-condition-schema.test.ts │ │ │ ├── email-domain-validation.test.ts │ │ │ ├── filter-active-group-bounties.test.ts │ │ │ ├── interpolate-email-template.test.ts │ │ │ └── ip-cidr.test.ts │ │ ├── partner-groups/ │ │ │ └── index.test.ts │ │ ├── partners/ │ │ │ ├── analytics.test.ts │ │ │ ├── ban-partner.test.ts │ │ │ ├── create-partner-link.test.ts │ │ │ ├── create-partner.test.ts │ │ │ ├── deactivate-partner.test.ts │ │ │ ├── list-partners.test.ts │ │ │ ├── resource.ts │ │ │ └── upsert-partner-link.test.ts │ │ ├── payouts/ │ │ │ └── index.test.ts │ │ ├── redirects/ │ │ │ └── index.test.ts │ │ ├── rewards/ │ │ │ ├── click-reward.test.ts │ │ │ ├── lead-reward.test.ts │ │ │ ├── reward-conditions.test.ts │ │ │ └── sale-reward.test.ts │ │ ├── setupTests.ts │ │ ├── tags/ │ │ │ ├── create-tag-error.test.ts │ │ │ ├── create-tag.test.ts │ │ │ └── list-tags.test.ts │ │ ├── tracks/ │ │ │ ├── track-click.test.ts │ │ │ ├── track-lead-client.test.ts │ │ │ ├── track-lead.test.ts │ │ │ ├── track-open.test.ts │ │ │ ├── track-sale-client.test.ts │ │ │ └── track-sale.test.ts │ │ ├── utils/ │ │ │ ├── env.ts │ │ │ ├── fetch-partner.ts │ │ │ ├── helpers.ts │ │ │ ├── http.ts │ │ │ ├── integration-member.ts │ │ │ ├── integration-old.ts │ │ │ ├── integration.ts │ │ │ ├── resource.ts │ │ │ ├── schema.ts │ │ │ └── verify-commission.ts │ │ ├── webhooks/ │ │ │ └── index.test.ts │ │ ├── workflows/ │ │ │ ├── award-bounty-workflow.test.ts │ │ │ ├── e2e-endpoints-guard.test.ts │ │ │ ├── move-group-workflow.test.ts │ │ │ ├── send-campaign-workflow.test.ts │ │ │ └── utils/ │ │ │ ├── delete-bounty-and-submissions.ts │ │ │ ├── track-e2e-lead.ts │ │ │ ├── verify-bounty-submission.ts │ │ │ ├── verify-campaign-sent.ts │ │ │ └── verify-partner-group-move.ts │ │ └── workspaces/ │ │ ├── retrieve-workspace.error.test.ts │ │ └── retrieve-workspace.test.ts │ ├── tsconfig.json │ ├── ui/ │ │ ├── account/ │ │ │ ├── delete-account.tsx │ │ │ ├── update-default-workspace.tsx │ │ │ ├── update-subscription.tsx │ │ │ ├── upload-avatar.tsx │ │ │ └── user-id.tsx │ │ ├── activity-logs/ │ │ │ ├── action-renderers/ │ │ │ │ ├── partner-group-changed-renderer.tsx │ │ │ │ ├── referral-created-renderer.tsx │ │ │ │ ├── referral-status-changed-renderer.tsx │ │ │ │ └── reward-activity-renderer.tsx │ │ │ ├── activity-entry-chips.tsx │ │ │ ├── activity-feed.tsx │ │ │ ├── activity-log-context.tsx │ │ │ ├── activity-log-description.tsx │ │ │ ├── activity-log-registry.tsx │ │ │ ├── partner-group-activity-item.tsx │ │ │ ├── partner-group-activity-section.tsx │ │ │ ├── partner-group-history-sheet.tsx │ │ │ ├── partner-referral-activity-section.tsx │ │ │ ├── referral-activity-item.tsx │ │ │ ├── referral-activity-section.tsx │ │ │ ├── reward-activity-item.tsx │ │ │ ├── reward-activity-section.tsx │ │ │ └── reward-history-sheet.tsx │ │ ├── analytics/ │ │ │ ├── analytics-area-chart.tsx │ │ │ ├── analytics-card.tsx │ │ │ ├── analytics-export-button.tsx │ │ │ ├── analytics-funnel-chart.tsx │ │ │ ├── analytics-loading-spinner.tsx │ │ │ ├── analytics-options.tsx │ │ │ ├── analytics-provider.tsx │ │ │ ├── analytics-tabs.tsx │ │ │ ├── bar-list.tsx │ │ │ ├── chart-section.tsx │ │ │ ├── chart-view-switcher.tsx │ │ │ ├── continent-icon.tsx │ │ │ ├── device-icon.tsx │ │ │ ├── device-section.tsx │ │ │ ├── events/ │ │ │ │ ├── events-export-button.tsx │ │ │ │ ├── events-provider.tsx │ │ │ │ ├── events-table.tsx │ │ │ │ ├── events-tabs.tsx │ │ │ │ ├── example-data.ts │ │ │ │ ├── index.tsx │ │ │ │ ├── metadata-viewer.tsx │ │ │ │ └── row-menu-button.tsx │ │ │ ├── index.tsx │ │ │ ├── link-preview.tsx │ │ │ ├── location-section.tsx │ │ │ ├── partner-section.tsx │ │ │ ├── referrer-icon.tsx │ │ │ ├── referrers-utms.tsx │ │ │ ├── share-button.tsx │ │ │ ├── toggle.tsx │ │ │ ├── top-links.tsx │ │ │ ├── trigger-display.tsx │ │ │ ├── use-analytics-connected-status.ts │ │ │ ├── use-analytics-filters.tsx │ │ │ ├── use-analytics-query.tsx │ │ │ └── utils.ts │ │ ├── auth/ │ │ │ ├── auth-alternative-banner.tsx │ │ │ ├── auth-methods-separator.tsx │ │ │ ├── forgot-password-form.tsx │ │ │ ├── login/ │ │ │ │ ├── email-sign-in.tsx │ │ │ │ ├── framer-button.tsx │ │ │ │ ├── github-button.tsx │ │ │ │ ├── google-button.tsx │ │ │ │ ├── login-form.tsx │ │ │ │ └── sso-sign-in.tsx │ │ │ ├── register/ │ │ │ │ ├── context.tsx │ │ │ │ ├── resend-otp.tsx │ │ │ │ ├── signup-email.tsx │ │ │ │ ├── signup-form.tsx │ │ │ │ ├── signup-oauth.tsx │ │ │ │ └── verify-email-form.tsx │ │ │ └── reset-password-form.tsx │ │ ├── colors.ts │ │ ├── customers/ │ │ │ ├── customer-activity-list.tsx │ │ │ ├── customer-avatar.tsx │ │ │ ├── customer-details-column.tsx │ │ │ ├── customer-partner-earnings-table.tsx │ │ │ ├── customer-row-item.tsx │ │ │ ├── customer-sales-table.tsx │ │ │ ├── customer-selector.tsx │ │ │ ├── customer-stats.tsx │ │ │ ├── customer-tabs.tsx │ │ │ ├── customers-table/ │ │ │ │ ├── customers-table.tsx │ │ │ │ ├── example-data.ts │ │ │ │ └── use-customer-filters.tsx │ │ │ └── export-customers-button.tsx │ │ ├── domains/ │ │ │ ├── add-edit-domain-form.tsx │ │ │ ├── domain-card-placeholder.tsx │ │ │ ├── domain-card-title-column.tsx │ │ │ ├── domain-card.tsx │ │ │ ├── domain-configuration.tsx │ │ │ ├── domain-selector.tsx │ │ │ ├── free-dot-link-banner.tsx │ │ │ └── register-domain-form.tsx │ │ ├── dub-partners-logo.tsx │ │ ├── folders/ │ │ │ ├── add-folder-form.tsx │ │ │ ├── edit-folder-form.tsx │ │ │ ├── edit-folder-sheet.tsx │ │ │ ├── folder-actions.tsx │ │ │ ├── folder-card-placeholder.tsx │ │ │ ├── folder-card.tsx │ │ │ ├── folder-dropdown.tsx │ │ │ ├── folder-icon.tsx │ │ │ ├── folder-info-panel.tsx │ │ │ ├── move-link-form.tsx │ │ │ ├── rename-folder-form.tsx │ │ │ ├── request-edit-button.tsx │ │ │ ├── simple-folder-card.tsx │ │ │ └── utils.ts │ │ ├── guides/ │ │ │ ├── guide-action-button.tsx │ │ │ ├── guide-list.tsx │ │ │ ├── guide-selector.tsx │ │ │ ├── guide.tsx │ │ │ ├── icons/ │ │ │ │ ├── appwrite.tsx │ │ │ │ ├── auth-js.tsx │ │ │ │ ├── auth0.tsx │ │ │ │ ├── better-auth.tsx │ │ │ │ ├── clerk.tsx │ │ │ │ ├── code-editor.tsx │ │ │ │ ├── custom.tsx │ │ │ │ ├── framer.tsx │ │ │ │ ├── gtm.tsx │ │ │ │ ├── next-auth.tsx │ │ │ │ ├── react.tsx │ │ │ │ ├── segment.tsx │ │ │ │ ├── shopify.tsx │ │ │ │ ├── supabase.tsx │ │ │ │ ├── webflow.tsx │ │ │ │ └── wordpress.tsx │ │ │ ├── install-stripe-integration-button.tsx │ │ │ ├── integrations.ts │ │ │ └── markdown.tsx │ │ ├── integrations/ │ │ │ ├── integration-card.tsx │ │ │ └── integration-logo.tsx │ │ ├── layout/ │ │ │ ├── auth-layout.tsx │ │ │ ├── changelog-popup.tsx │ │ │ ├── layout-loader.tsx │ │ │ ├── main-nav.tsx │ │ │ ├── page-content/ │ │ │ │ ├── index.tsx │ │ │ │ ├── nav-button.tsx │ │ │ │ ├── page-content-header.tsx │ │ │ │ ├── page-content-old.tsx │ │ │ │ ├── page-content-with-side-panel.tsx │ │ │ │ └── toggle-side-panel-button.tsx │ │ │ ├── page-nav-tabs.tsx │ │ │ ├── page-width-wrapper.tsx │ │ │ ├── settings-layout.tsx │ │ │ ├── sidebar/ │ │ │ │ ├── affiliate-program-popup.tsx │ │ │ │ ├── app-sidebar-nav.tsx │ │ │ │ ├── dub-partners-popup.tsx │ │ │ │ ├── help-button.tsx │ │ │ │ ├── icons/ │ │ │ │ │ ├── compass.tsx │ │ │ │ │ ├── connected-dots4.tsx │ │ │ │ │ ├── cursor-rays.tsx │ │ │ │ │ ├── gear.tsx │ │ │ │ │ ├── hyperlink.tsx │ │ │ │ │ ├── lines-y.tsx │ │ │ │ │ └── user.tsx │ │ │ │ ├── news-rsc.tsx │ │ │ │ ├── news.tsx │ │ │ │ ├── partner-program-dropdown.tsx │ │ │ │ ├── partners-sidebar-nav.tsx │ │ │ │ ├── payout-stats.tsx │ │ │ │ ├── program-help-support.tsx │ │ │ │ ├── refer-button.tsx │ │ │ │ ├── sidebar-nav.tsx │ │ │ │ ├── sidebar-usage.tsx │ │ │ │ ├── use-program-applications-count.tsx │ │ │ │ ├── user-dropdown.tsx │ │ │ │ ├── workspace-dropdown.tsx │ │ │ │ └── year-in-review-card.tsx │ │ │ ├── toolbar/ │ │ │ │ ├── onboarding/ │ │ │ │ │ └── onboarding-button.tsx │ │ │ │ └── toolbar.tsx │ │ │ ├── upgrade-banner.tsx │ │ │ └── user-survey/ │ │ │ ├── index.tsx │ │ │ └── survey-form.tsx │ │ ├── links/ │ │ │ ├── archived-links-hint.tsx │ │ │ ├── comments-badge.tsx │ │ │ ├── destination-url-input.tsx │ │ │ ├── disabled-link-tooltip.tsx │ │ │ ├── link-analytics-badge.tsx │ │ │ ├── link-builder/ │ │ │ │ ├── constants.ts │ │ │ │ ├── controls/ │ │ │ │ │ ├── link-builder-destination-url-input.tsx │ │ │ │ │ ├── link-builder-folder-selector.tsx │ │ │ │ │ ├── link-builder-short-link-input.tsx │ │ │ │ │ └── link-comments-input.tsx │ │ │ │ ├── conversion-tracking-toggle.tsx │ │ │ │ ├── draft-controls.tsx │ │ │ │ ├── link-action-bar.tsx │ │ │ │ ├── link-builder-header.tsx │ │ │ │ ├── link-builder-provider.tsx │ │ │ │ ├── link-creator-info.tsx │ │ │ │ ├── link-feature-buttons.tsx │ │ │ │ ├── link-partner-details.tsx │ │ │ │ ├── link-preview.tsx │ │ │ │ ├── more-dropdown.tsx │ │ │ │ ├── multi-tags-icon.tsx │ │ │ │ ├── options-list.tsx │ │ │ │ ├── qr-code-preview.tsx │ │ │ │ ├── tag-select.tsx │ │ │ │ ├── use-link-builder-keyboard-shortcut.ts │ │ │ │ ├── use-link-builder-submit.tsx │ │ │ │ ├── use-metatags.ts │ │ │ │ └── utm-templates-button.tsx │ │ │ ├── link-card-placeholder.tsx │ │ │ ├── link-card.tsx │ │ │ ├── link-controls.tsx │ │ │ ├── link-details-column.tsx │ │ │ ├── link-display.tsx │ │ │ ├── link-icon.tsx │ │ │ ├── link-not-found.tsx │ │ │ ├── link-selection-provider.tsx │ │ │ ├── link-sort.tsx │ │ │ ├── link-tests.tsx │ │ │ ├── link-title-column.tsx │ │ │ ├── links-container.tsx │ │ │ ├── links-display-provider.tsx │ │ │ ├── links-toolbar.tsx │ │ │ ├── short-link-input.tsx │ │ │ ├── simple-link-card.tsx │ │ │ ├── tag-badge.tsx │ │ │ ├── tests-badge.tsx │ │ │ ├── use-available-domains.ts │ │ │ ├── use-folder-filter-options.ts │ │ │ └── use-link-filters.tsx │ │ ├── messages/ │ │ │ ├── message-markdown.tsx │ │ │ ├── messages-context.tsx │ │ │ ├── messages-list.tsx │ │ │ ├── messages-panel.tsx │ │ │ └── toggle-side-panel-button.tsx │ │ ├── modals/ │ │ │ ├── add-customer-modal.tsx │ │ │ ├── add-discount-code-modal.tsx │ │ │ ├── add-edit-domain-modal.tsx │ │ │ ├── add-edit-email-domain-modal.tsx │ │ │ ├── add-edit-tag-modal.tsx │ │ │ ├── add-edit-token-modal.tsx │ │ │ ├── add-edit-utm-template.modal.tsx │ │ │ ├── add-folder-modal.tsx │ │ │ ├── add-partner-link-modal.tsx │ │ │ ├── add-payment-method-modal.tsx │ │ │ ├── add-workspace-modal.tsx │ │ │ ├── application-settings-modal.tsx │ │ │ ├── archive-domain-modal.tsx │ │ │ ├── archive-link-modal.tsx │ │ │ ├── archive-partner-modal.tsx │ │ │ ├── ban-partner-modal.tsx │ │ │ ├── bulk-approve-partners-modal.tsx │ │ │ ├── bulk-archive-partners-modal.tsx │ │ │ ├── bulk-ban-partners-modal.tsx │ │ │ ├── bulk-deactivate-partners-modal.tsx │ │ │ ├── bulk-reject-partners-modal.tsx │ │ │ ├── bulk-resolve-fraud-groups-modal.tsx │ │ │ ├── change-group-modal.tsx │ │ │ ├── confirm-approve-bounty-submission-modal.tsx │ │ │ ├── confirm-modal.tsx │ │ │ ├── confirm-referral-status-change-modal.tsx │ │ │ ├── confirm-set-default-group-modal.tsx │ │ │ ├── deactivate-partner-modal.tsx │ │ │ ├── delete-account-modal.tsx │ │ │ ├── delete-discount-code-modal.tsx │ │ │ ├── delete-domain-modal.tsx │ │ │ ├── delete-email-domain-modal.tsx │ │ │ ├── delete-folder-modal.tsx │ │ │ ├── delete-group-modal.tsx │ │ │ ├── delete-link-modal.tsx │ │ │ ├── delete-partner-link-modal.tsx │ │ │ ├── delete-token-modal.tsx │ │ │ ├── delete-webhook-modal.tsx │ │ │ ├── delete-workspace-modal.tsx │ │ │ ├── disable-fraud-rules-modal.tsx │ │ │ ├── domain-auto-renewal-modal.tsx │ │ │ ├── domain-verification-modal.tsx │ │ │ ├── dot-link-offer-modal.tsx │ │ │ ├── edit-customer-modal.tsx │ │ │ ├── edit-referral-modal.tsx │ │ │ ├── export-applications-modal.tsx │ │ │ ├── export-commissions-modal.tsx │ │ │ ├── export-customers-modal.tsx │ │ │ ├── export-links-modal.tsx │ │ │ ├── export-partners-modal.tsx │ │ │ ├── google-oauth-modal.tsx │ │ │ ├── import-bitly-modal.tsx │ │ │ ├── import-csv-modal/ │ │ │ │ ├── field-mapping.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── select-file.tsx │ │ │ ├── import-firstpromoter-modal.tsx │ │ │ ├── import-partnerstack-modal.tsx │ │ │ ├── import-rebrandly-modal.tsx │ │ │ ├── import-rewardful-modal.tsx │ │ │ ├── import-short-modal.tsx │ │ │ ├── import-tolt-modal.tsx │ │ │ ├── invite-code-modal.tsx │ │ │ ├── invite-partner-user-modal.tsx │ │ │ ├── invite-referral-modal.tsx │ │ │ ├── invite-workspace-user-modal.tsx │ │ │ ├── link-builder/ │ │ │ │ ├── ab-testing/ │ │ │ │ │ ├── ab-testing-modal.tsx │ │ │ │ │ ├── end-ab-testing-modal.tsx │ │ │ │ │ └── traffic-split-slider.tsx │ │ │ │ ├── ab-testing-modal.tsx │ │ │ │ ├── advanced-modal.tsx │ │ │ │ ├── expiration-modal.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── og-modal.tsx │ │ │ │ ├── partners-modal.tsx │ │ │ │ ├── password-modal.tsx │ │ │ │ ├── targeting-modal.tsx │ │ │ │ ├── unsplash-search.tsx │ │ │ │ ├── use-link-drafts.ts │ │ │ │ ├── utm-modal.tsx │ │ │ │ ├── utm-templates-combo.tsx │ │ │ │ └── webhooks-modal.tsx │ │ │ ├── link-conversion-tracking-modal.tsx │ │ │ ├── link-qr-modal.tsx │ │ │ ├── manage-usage-modal.tsx │ │ │ ├── modal-provider.tsx │ │ │ ├── move-link-to-folder-modal.tsx │ │ │ ├── oauth-app-created-modal.tsx │ │ │ ├── partner-link-modal.tsx │ │ │ ├── partner-link-qr-modal.tsx │ │ │ ├── plan-change-confirmation-modal.tsx │ │ │ ├── primary-domain-modal.tsx │ │ │ ├── program-welcome-modal.tsx │ │ │ ├── prompt-modal.tsx │ │ │ ├── reactivate-partner-modal.tsx │ │ │ ├── register-domain-modal.tsx │ │ │ ├── register-domain-success-modal.tsx │ │ │ ├── reject-partner-application-modal.tsx │ │ │ ├── remove-oauth-app-modal.tsx │ │ │ ├── remove-partner-user-modal.tsx │ │ │ ├── remove-saml-modal.tsx │ │ │ ├── remove-scim-modal.tsx │ │ │ ├── remove-workspace-user-modal.tsx │ │ │ ├── rename-folder-modal.tsx │ │ │ ├── saml-modal.tsx │ │ │ ├── scim-modal.tsx │ │ │ ├── send-test-webhook-modal.tsx │ │ │ ├── set-default-folder-modal.tsx │ │ │ ├── share-dashboard-modal.tsx │ │ │ ├── social-verification-by-code-modal.tsx │ │ │ ├── submit-oauth-app-modal.tsx │ │ │ ├── tag-link-modal.tsx │ │ │ ├── token-created-modal.tsx │ │ │ ├── transfer-domain-modal.tsx │ │ │ ├── transfer-link-modal.tsx │ │ │ ├── unban-partner-modal.tsx │ │ │ ├── uninstall-integration-modal.tsx │ │ │ ├── update-partner-user-modal.tsx │ │ │ ├── update-workspace-user-role.tsx │ │ │ └── upgraded-modal.tsx │ │ ├── oauth-apps/ │ │ │ ├── add-edit-app-form.tsx │ │ │ ├── add-edit-integration-form.tsx │ │ │ ├── oauth-app-card.tsx │ │ │ ├── oauth-app-credentials.tsx │ │ │ └── oauth-app-placeholder.tsx │ │ ├── partners/ │ │ │ ├── activity-event.tsx │ │ │ ├── bounties/ │ │ │ │ ├── bounty-description.tsx │ │ │ │ ├── bounty-incremental-bonus-tooltip.tsx │ │ │ │ ├── bounty-performance.tsx │ │ │ │ ├── bounty-platform-icons.ts │ │ │ │ ├── bounty-progress-bar-row.tsx │ │ │ │ ├── bounty-reward-criteria.tsx │ │ │ │ ├── bounty-reward-description.tsx │ │ │ │ ├── bounty-social-content-preview.tsx │ │ │ │ ├── bounty-social-content.tsx │ │ │ │ ├── bounty-social-metrics-rewards-table.tsx │ │ │ │ ├── bounty-status-badge.tsx │ │ │ │ ├── bounty-submission-details-sheet.tsx │ │ │ │ ├── bounty-submission-requirements.tsx │ │ │ │ ├── bounty-thumbnail-image.tsx │ │ │ │ ├── claim-bounty-context.tsx │ │ │ │ ├── claim-bounty-sheet.tsx │ │ │ │ ├── reject-bounty-submission-modal.tsx │ │ │ │ ├── use-claim-bounty-form.ts │ │ │ │ └── use-social-content.ts │ │ │ ├── comission-type-icon.tsx │ │ │ ├── commission-row-menu.tsx │ │ │ ├── commission-status-badges.tsx │ │ │ ├── commission-type-badge.tsx │ │ │ ├── confirm-payouts-sheet.tsx │ │ │ ├── constants.ts │ │ │ ├── conversion-score-icon.tsx │ │ │ ├── country-combobox.tsx │ │ │ ├── discounts/ │ │ │ │ ├── add-edit-discount-sheet.tsx │ │ │ │ └── discount-code-badge.tsx │ │ │ ├── eligibility-requirements.tsx │ │ │ ├── external-payouts-indicator.tsx │ │ │ ├── format-discount-description.ts │ │ │ ├── format-reward-description.ts │ │ │ ├── fraud-risks/ │ │ │ │ ├── commissions-on-hold-table.tsx │ │ │ │ ├── fraud-disclaimer-banner.tsx │ │ │ │ ├── fraud-events-tables/ │ │ │ │ │ ├── fraud-cross-program-ban-table.tsx │ │ │ │ │ ├── fraud-matching-customer-email-table.tsx │ │ │ │ │ ├── fraud-paid-traffic-detected-table.tsx │ │ │ │ │ ├── fraud-partner-info-table.tsx │ │ │ │ │ ├── fraud-referral-source-banned-table.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── fraud-review-sheet.tsx │ │ │ │ ├── partner-application-fraud-severity-indicator.tsx │ │ │ │ ├── partner-application-risk-summary-modal.tsx │ │ │ │ ├── partner-application-risk-summary.tsx │ │ │ │ ├── partner-cross-program-summary.tsx │ │ │ │ ├── partner-fraud-banner.tsx │ │ │ │ ├── partner-fraud-indicator.tsx │ │ │ │ ├── resolve-fraud-group-modal.tsx │ │ │ │ └── resolved-fraud-group-table.tsx │ │ │ ├── groups/ │ │ │ │ ├── design/ │ │ │ │ │ ├── application-form/ │ │ │ │ │ │ ├── application-hero-preview.tsx │ │ │ │ │ │ ├── fields/ │ │ │ │ │ │ │ ├── form-control.tsx │ │ │ │ │ │ │ ├── image-upload-field.tsx │ │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ │ ├── long-text-field.tsx │ │ │ │ │ │ │ ├── max-character-count.tsx │ │ │ │ │ │ │ ├── multiple-choice-field.tsx │ │ │ │ │ │ │ ├── select-field.tsx │ │ │ │ │ │ │ ├── short-text-field.tsx │ │ │ │ │ │ │ └── website-and-socials-field.tsx │ │ │ │ │ │ ├── form-data-for-application-form-data.ts │ │ │ │ │ │ ├── modals/ │ │ │ │ │ │ │ ├── add-field-modal.tsx │ │ │ │ │ │ │ ├── edit-application-hero-modal.tsx │ │ │ │ │ │ │ ├── generate-lander-modal.tsx │ │ │ │ │ │ │ ├── image-upload-field-modal.tsx │ │ │ │ │ │ │ ├── long-text-field-modal.tsx │ │ │ │ │ │ │ ├── multiple-choice-field-modal.tsx │ │ │ │ │ │ │ ├── select-field-modal.tsx │ │ │ │ │ │ │ ├── short-text-field-modal.tsx │ │ │ │ │ │ │ └── website-and-socials-field-modal.tsx │ │ │ │ │ │ ├── program-application-form.tsx │ │ │ │ │ │ ├── program-terms-preview.tsx │ │ │ │ │ │ └── required-fields-preview.tsx │ │ │ │ │ ├── branding-context-provider.tsx │ │ │ │ │ ├── branding-form.tsx │ │ │ │ │ ├── branding-settings-form.tsx │ │ │ │ │ ├── edit-list.tsx │ │ │ │ │ ├── lander/ │ │ │ │ │ │ ├── lander-ai-banner.tsx │ │ │ │ │ │ ├── lander-preview-controls.tsx │ │ │ │ │ │ └── modals/ │ │ │ │ │ │ ├── accordion-block-modal.tsx │ │ │ │ │ │ ├── add-block-modal.tsx │ │ │ │ │ │ ├── earnings-calculator-block-modal.tsx │ │ │ │ │ │ ├── edit-hero-modal.tsx │ │ │ │ │ │ ├── files-block-modal.tsx │ │ │ │ │ │ ├── generate-lander-modal.tsx │ │ │ │ │ │ ├── image-block-modal.tsx │ │ │ │ │ │ └── text-block-modal.tsx │ │ │ │ │ ├── preview-window.tsx │ │ │ │ │ ├── previews/ │ │ │ │ │ │ ├── application-preview.tsx │ │ │ │ │ │ ├── embed-preview.tsx │ │ │ │ │ │ ├── lander-preview.tsx │ │ │ │ │ │ └── portal-preview.tsx │ │ │ │ │ └── studs-pattern.tsx │ │ │ │ ├── group-color-circle.tsx │ │ │ │ ├── group-color-picker.tsx │ │ │ │ ├── group-selector.tsx │ │ │ │ ├── group-settings-row.tsx │ │ │ │ ├── groups-multi-select.tsx │ │ │ │ └── reward-discount-partners-card.tsx │ │ │ ├── hero-background.tsx │ │ │ ├── lander/ │ │ │ │ ├── blocks/ │ │ │ │ │ ├── accordion-block.tsx │ │ │ │ │ ├── block-description.tsx │ │ │ │ │ ├── block-markdown.tsx │ │ │ │ │ ├── block-title.tsx │ │ │ │ │ ├── earnings-calculator-block.tsx │ │ │ │ │ ├── files-block.tsx │ │ │ │ │ ├── image-block.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── text-block.tsx │ │ │ │ │ └── wave-pattern.tsx │ │ │ │ ├── lander-hero.tsx │ │ │ │ └── lander-rewards.tsx │ │ │ ├── mark-commission-duplicate-modal.tsx │ │ │ ├── mark-commission-fraud-or-canceled-modal.tsx │ │ │ ├── merge-accounts/ │ │ │ │ ├── account-input-group.tsx │ │ │ │ ├── form-context.tsx │ │ │ │ ├── merge-account-form.tsx │ │ │ │ ├── merge-partner-accounts-modal.tsx │ │ │ │ ├── otp-input-field.tsx │ │ │ │ ├── send-verification-code-form.tsx │ │ │ │ ├── step-progress-bar.tsx │ │ │ │ └── verify-code-form.tsx │ │ │ ├── overview/ │ │ │ │ ├── blocks/ │ │ │ │ │ ├── commissions-block.tsx │ │ │ │ │ ├── conversion-block.tsx │ │ │ │ │ ├── countries-block.tsx │ │ │ │ │ ├── links-block.tsx │ │ │ │ │ ├── partners-block.tsx │ │ │ │ │ ├── sale-type-block.tsx │ │ │ │ │ └── traffic-sources-block.tsx │ │ │ │ ├── exceeded-events-limit.tsx │ │ │ │ ├── program-overview-block.tsx │ │ │ │ └── program-overview-card.tsx │ │ │ ├── partner-about.tsx │ │ │ ├── partner-advanced-settings-modal.tsx │ │ │ ├── partner-application-details.tsx │ │ │ ├── partner-application-sheet.tsx │ │ │ ├── partner-avatar.tsx │ │ │ ├── partner-comments.tsx │ │ │ ├── partner-info-cards.tsx │ │ │ ├── partner-info-group.tsx │ │ │ ├── partner-info-section.tsx │ │ │ ├── partner-info-stats.tsx │ │ │ ├── partner-link-selector.tsx │ │ │ ├── partner-network/ │ │ │ │ ├── conversion-score-tooltip.tsx │ │ │ │ ├── invites-usage.tsx │ │ │ │ └── network-partner-sheet.tsx │ │ │ ├── partner-platform-card.tsx │ │ │ ├── partner-platform-summary.tsx │ │ │ ├── partner-platforms-form.tsx │ │ │ ├── partner-profile-sheet.tsx │ │ │ ├── partner-row-item.tsx │ │ │ ├── partner-selector.tsx │ │ │ ├── partner-sheet-tabs.tsx │ │ │ ├── partner-social-column.tsx │ │ │ ├── partner-star-button.tsx │ │ │ ├── partner-status-badge-with-tooltip.tsx │ │ │ ├── partner-status-badges.ts │ │ │ ├── partners-upgrade-modal.tsx │ │ │ ├── payout-row-menu.tsx │ │ │ ├── payout-status-badge-partner.tsx │ │ │ ├── payout-status-badges.tsx │ │ │ ├── payout-status-descriptions.ts │ │ │ ├── payouts/ │ │ │ │ ├── bank-account-requirements-modal.tsx │ │ │ │ ├── connect-payout-button.tsx │ │ │ │ ├── connect-payout-modal.tsx │ │ │ │ ├── payout-method-cards.tsx │ │ │ │ ├── payout-method-config.ts │ │ │ │ ├── payout-method-dropdown.tsx │ │ │ │ ├── stablecoin-payout-banner.tsx │ │ │ │ ├── stablecoin-payout-card.tsx │ │ │ │ ├── stablecoin-payout-icon.tsx │ │ │ │ ├── stablecoin-payout-modal.tsx │ │ │ │ ├── use-payout-connect-flow.tsx │ │ │ │ └── use-stablecoin-payout-promo.tsx │ │ │ ├── program-application-sheet.tsx │ │ │ ├── program-card.tsx │ │ │ ├── program-category-select.tsx │ │ │ ├── program-color-picker.tsx │ │ │ ├── program-eligibility-card.tsx │ │ │ ├── program-help-links.tsx │ │ │ ├── program-invite-card.tsx │ │ │ ├── program-link-configuration.tsx │ │ │ ├── program-marketplace/ │ │ │ │ ├── program-category.tsx │ │ │ │ ├── program-marketplace-banner.tsx │ │ │ │ ├── program-marketplace-card.tsx │ │ │ │ ├── program-marketplace-logos.tsx │ │ │ │ ├── program-reward-icon.tsx │ │ │ │ ├── program-rewards-display.tsx │ │ │ │ ├── programs-promo-banner.tsx │ │ │ │ ├── programs-promo-card.tsx │ │ │ │ └── use-program-marketplace-promo.tsx │ │ │ ├── program-onboarding-form-wrapper.tsx │ │ │ ├── program-reward-description.tsx │ │ │ ├── program-reward-list.tsx │ │ │ ├── program-reward-modifiers-tooltip.tsx │ │ │ ├── program-reward-terms.tsx │ │ │ ├── program-rewards-panel.tsx │ │ │ ├── program-selector.tsx │ │ │ ├── program-sheet-accordion.tsx │ │ │ ├── program-stats-filter.tsx │ │ │ ├── resources/ │ │ │ │ ├── resource-card.tsx │ │ │ │ └── resource-section.tsx │ │ │ ├── rewards/ │ │ │ │ ├── add-edit-reward-sheet.tsx │ │ │ │ ├── reward-icon-square.tsx │ │ │ │ ├── reward-preview-card.tsx │ │ │ │ └── rewards-logic.tsx │ │ │ ├── rewind/ │ │ │ │ ├── constants.ts │ │ │ │ ├── partner-rewind-banner.tsx │ │ │ │ ├── partner-rewind-card.tsx │ │ │ │ └── use-partner-rewind-status.tsx │ │ │ ├── trusted-partner-badge.tsx │ │ │ └── use-country-change-warning-modal.tsx │ │ ├── placeholders/ │ │ │ ├── bubble-icon.tsx │ │ │ ├── button-link.tsx │ │ │ ├── cta.tsx │ │ │ ├── feature-graphics/ │ │ │ │ ├── analytics.tsx │ │ │ │ ├── collaboration.tsx │ │ │ │ ├── domains.tsx │ │ │ │ ├── personalization.tsx │ │ │ │ └── qr.tsx │ │ │ ├── features-section.tsx │ │ │ ├── hero.tsx │ │ │ └── logos.tsx │ │ ├── postbacks/ │ │ │ ├── add-edit-postback-modal.tsx │ │ │ ├── partner-postback-actions.tsx │ │ │ ├── postback-card.tsx │ │ │ ├── postback-detail-skeleton.tsx │ │ │ ├── postback-event-details-sheet.tsx │ │ │ ├── postback-event-list-skeleton.tsx │ │ │ ├── postback-event-list.tsx │ │ │ ├── postback-placeholder.tsx │ │ │ ├── postback-secret-modal.tsx │ │ │ ├── postback-status.tsx │ │ │ └── send-test-postback-modal.tsx │ │ ├── referrals/ │ │ │ ├── form-fields/ │ │ │ │ ├── country-field.tsx │ │ │ │ ├── date-field.tsx │ │ │ │ ├── form-control.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── max-character-count.tsx │ │ │ │ ├── multi-select-field.tsx │ │ │ │ ├── number-field.tsx │ │ │ │ ├── phone-field.tsx │ │ │ │ ├── select-field.tsx │ │ │ │ ├── text-field.tsx │ │ │ │ └── textarea-field.tsx │ │ │ ├── partner-profile-referral-sheet.tsx │ │ │ ├── partner-profile-referrals-empty-state.tsx │ │ │ ├── partner-referral-sheet.tsx │ │ │ ├── partner-referral-table.tsx │ │ │ ├── referral-details.tsx │ │ │ ├── referral-form.tsx │ │ │ ├── referral-lead-details.tsx │ │ │ ├── referral-partner-details.tsx │ │ │ ├── referral-status-badge.tsx │ │ │ ├── referral-status-badges.ts │ │ │ ├── referral-status-dropdown.tsx │ │ │ ├── referral-utils.ts │ │ │ ├── submit-referral-sheet.tsx │ │ │ └── use-program-referral-filters.tsx │ │ ├── shared/ │ │ │ ├── amount-input.tsx │ │ │ ├── animated-empty-state.tsx │ │ │ ├── back-link.tsx │ │ │ ├── business-badge-tooltip.tsx │ │ │ ├── conditional-link.tsx │ │ │ ├── custom-toast.tsx │ │ │ ├── emoji-picker.tsx │ │ │ ├── empty-state.tsx │ │ │ ├── filter-button-table-row.tsx │ │ │ ├── icons/ │ │ │ │ ├── airplay.tsx │ │ │ │ ├── alert-circle-fill.tsx │ │ │ │ ├── chart.tsx │ │ │ │ ├── check-circle-fill.tsx │ │ │ │ ├── clipboard.tsx │ │ │ │ ├── delete.tsx │ │ │ │ ├── devices.tsx │ │ │ │ ├── divider.tsx │ │ │ │ ├── download.tsx │ │ │ │ ├── drag.tsx │ │ │ │ ├── edit.tsx │ │ │ │ ├── external-link.tsx │ │ │ │ ├── eye-off.tsx │ │ │ │ ├── eye.tsx │ │ │ │ ├── filter.tsx │ │ │ │ ├── heart.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── infinity.tsx │ │ │ │ ├── link.tsx │ │ │ │ ├── lock.tsx │ │ │ │ ├── logout.tsx │ │ │ │ ├── message.tsx │ │ │ │ ├── qr.tsx │ │ │ │ ├── random.tsx │ │ │ │ ├── repeat.tsx │ │ │ │ ├── save.tsx │ │ │ │ ├── search.tsx │ │ │ │ ├── sort.tsx │ │ │ │ ├── three-dots.tsx │ │ │ │ ├── upload-cloud.tsx │ │ │ │ ├── users.tsx │ │ │ │ ├── x-circle-fill.tsx │ │ │ │ └── x.tsx │ │ │ ├── inline-badge-popover.tsx │ │ │ ├── markdown-description.tsx │ │ │ ├── markdown.tsx │ │ │ ├── max-characters-counter.tsx │ │ │ ├── message-input.tsx │ │ │ ├── modal-hero.tsx │ │ │ ├── new-background.tsx │ │ │ ├── password-requirements.tsx │ │ │ ├── pro-badge-tooltip.tsx │ │ │ ├── qr-code.tsx │ │ │ ├── search-box.tsx │ │ │ ├── simple-date-range-picker.tsx │ │ │ ├── simple-empty-state.tsx │ │ │ ├── upgrade-required-toast.tsx │ │ │ └── zoom-image.tsx │ │ ├── support/ │ │ │ ├── chat-bubble.tsx │ │ │ ├── chat-interface.tsx │ │ │ ├── clear-chat-button.tsx │ │ │ ├── code-block.tsx │ │ │ ├── embedded-chat.tsx │ │ │ ├── message.tsx │ │ │ ├── program-combobox.tsx │ │ │ ├── source-citations.tsx │ │ │ ├── starter-questions.tsx │ │ │ ├── status-indicator.tsx │ │ │ ├── ticket-upload.tsx │ │ │ ├── types.ts │ │ │ └── workspace-combobox.tsx │ │ ├── token-avatar.tsx │ │ ├── users/ │ │ │ ├── user-avatar.tsx │ │ │ └── user-row-item.tsx │ │ ├── webhooks/ │ │ │ ├── add-edit-webhook-form.tsx │ │ │ ├── link-selector.tsx │ │ │ ├── loading-events-skelton.tsx │ │ │ ├── no-events-placeholder.tsx │ │ │ ├── webhook-card.tsx │ │ │ ├── webhook-event-details-sheet.tsx │ │ │ ├── webhook-event-list.tsx │ │ │ ├── webhook-header.tsx │ │ │ ├── webhook-placeholder.tsx │ │ │ └── webhook-status.tsx │ │ └── workspaces/ │ │ ├── create-workspace-button.tsx │ │ ├── create-workspace-form.tsx │ │ ├── delete-workspace.tsx │ │ ├── invite-teammates-form.tsx │ │ ├── manage-subscription-button.tsx │ │ ├── plan-badge.tsx │ │ ├── plan-features.tsx │ │ ├── subscription-menu.tsx │ │ ├── upgrade-plan-button.tsx │ │ ├── upload-logo.tsx │ │ ├── workspace-arrow.tsx │ │ ├── workspace-exceeded-events.tsx │ │ └── workspace-selector.tsx │ ├── vercel.json │ └── vitest.config.ts ├── package.json ├── packages/ │ ├── cli/ │ │ ├── .gitignore │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── api/ │ │ │ │ ├── callback.ts │ │ │ │ ├── domains.ts │ │ │ │ └── links.ts │ │ │ ├── commands/ │ │ │ │ ├── config.ts │ │ │ │ ├── domains.ts │ │ │ │ ├── links.ts │ │ │ │ ├── login.ts │ │ │ │ └── shorten.ts │ │ │ ├── index.ts │ │ │ ├── types/ │ │ │ │ └── index.ts │ │ │ └── utils/ │ │ │ ├── config.ts │ │ │ ├── get-nanoid.ts │ │ │ ├── get-package-info.ts │ │ │ ├── handle-error.ts │ │ │ ├── logger.ts │ │ │ ├── oauth.ts │ │ │ └── parser.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── email/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ ├── bounty-thumbnail.tsx │ │ │ │ └── footer.tsx │ │ │ ├── index.ts │ │ │ ├── react-email.d.ts │ │ │ ├── resend/ │ │ │ │ ├── client.ts │ │ │ │ ├── constants.ts │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ ├── send-via-nodemailer.ts │ │ │ ├── send-via-resend.ts │ │ │ ├── templates/ │ │ │ │ ├── api-key-created.tsx │ │ │ │ ├── bounty-approved.tsx │ │ │ │ ├── bounty-completed.tsx │ │ │ │ ├── bounty-new-submission.tsx │ │ │ │ ├── bounty-rejected.tsx │ │ │ │ ├── bounty-submitted.tsx │ │ │ │ ├── broadcasts/ │ │ │ │ │ ├── dub-product-update-mar26.tsx │ │ │ │ │ ├── dub-wrapped.tsx │ │ │ │ │ ├── payout-auto-withdrawals.tsx │ │ │ │ │ ├── program-marketplace-announcement.tsx │ │ │ │ │ └── stablecoin-payouts-announcement.tsx │ │ │ │ ├── campaign-email.tsx │ │ │ │ ├── clicks-exceeded.tsx │ │ │ │ ├── clicks-summary.tsx │ │ │ │ ├── confirm-email-change.tsx │ │ │ │ ├── connect-payout-reminder.tsx │ │ │ │ ├── connect-platforms-reminder.tsx │ │ │ │ ├── connected-payout-method.tsx │ │ │ │ ├── connected-paypal-account.tsx │ │ │ │ ├── discount-deleted.tsx │ │ │ │ ├── domain-claimed.tsx │ │ │ │ ├── domain-deleted.tsx │ │ │ │ ├── domain-expired.tsx │ │ │ │ ├── domain-renewal-failed.tsx │ │ │ │ ├── domain-renewal-reminder.tsx │ │ │ │ ├── domain-renewed.tsx │ │ │ │ ├── domain-transferred.tsx │ │ │ │ ├── dub-partner-rewind.tsx │ │ │ │ ├── duplicate-payout-method.tsx │ │ │ │ ├── email-domain-status-changed.tsx │ │ │ │ ├── email-updated.tsx │ │ │ │ ├── export-ready.tsx │ │ │ │ ├── failed-payment.tsx │ │ │ │ ├── feedback-email.tsx │ │ │ │ ├── folder-edit-access-requested.tsx │ │ │ │ ├── integration-installed.tsx │ │ │ │ ├── invalid-domain.tsx │ │ │ │ ├── links-import-errors.tsx │ │ │ │ ├── links-imported.tsx │ │ │ │ ├── links-limit.tsx │ │ │ │ ├── login-link.tsx │ │ │ │ ├── new-bounty-available.tsx │ │ │ │ ├── new-commission-alert-partner.tsx │ │ │ │ ├── new-message-from-partner.tsx │ │ │ │ ├── new-message-from-program.tsx │ │ │ │ ├── new-referral-signup.tsx │ │ │ │ ├── new-sale-alert-program-owner.tsx │ │ │ │ ├── notify-partner-reapply.tsx │ │ │ │ ├── partner-account-merged.tsx │ │ │ │ ├── partner-application-approved.tsx │ │ │ │ ├── partner-application-received.tsx │ │ │ │ ├── partner-application-rejected.tsx │ │ │ │ ├── partner-banned.tsx │ │ │ │ ├── partner-deactivated.tsx │ │ │ │ ├── partner-group-changed.tsx │ │ │ │ ├── partner-payout-confirmed.tsx │ │ │ │ ├── partner-payout-failed.tsx │ │ │ │ ├── partner-payout-force-withdrawal.tsx │ │ │ │ ├── partner-payout-processed.tsx │ │ │ │ ├── partner-payout-withdrawal-completed.tsx │ │ │ │ ├── partner-payout-withdrawal-failed.tsx │ │ │ │ ├── partner-payout-withdrawal-initiated.tsx │ │ │ │ ├── partner-paypal-payout-failed.tsx │ │ │ │ ├── partner-program-summary.tsx │ │ │ │ ├── partner-referral-submitted.tsx │ │ │ │ ├── partner-user-invited.tsx │ │ │ │ ├── password-updated.tsx │ │ │ │ ├── pending-applications-summary.tsx │ │ │ │ ├── program-application-reminder.tsx │ │ │ │ ├── program-imported.tsx │ │ │ │ ├── program-invite.tsx │ │ │ │ ├── program-network-invite.tsx │ │ │ │ ├── program-payout-reminder.tsx │ │ │ │ ├── program-payout-thank-you.tsx │ │ │ │ ├── program-welcome.tsx │ │ │ │ ├── referral-invite.tsx │ │ │ │ ├── referral-status-update.tsx │ │ │ │ ├── reset-password-link.tsx │ │ │ │ ├── unresolved-fraud-events-summary.tsx │ │ │ │ ├── upgrade-email.tsx │ │ │ │ ├── verify-email-for-account-merge.tsx │ │ │ │ ├── verify-email.tsx │ │ │ │ ├── webhook-added.tsx │ │ │ │ ├── webhook-disabled.tsx │ │ │ │ ├── webhook-failed.tsx │ │ │ │ ├── welcome-email-partner.tsx │ │ │ │ ├── welcome-email.tsx │ │ │ │ └── workspace-invite.tsx │ │ │ └── types.ts │ │ └── tsconfig.json │ ├── embeds/ │ │ ├── core/ │ │ │ ├── README.md │ │ │ ├── package.json │ │ │ ├── src/ │ │ │ │ ├── constants.ts │ │ │ │ ├── core.ts │ │ │ │ ├── embed.ts │ │ │ │ ├── error.ts │ │ │ │ ├── example/ │ │ │ │ │ └── index.html │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ ├── tsconfig.json │ │ │ └── tsup.config.ts │ │ └── react/ │ │ ├── README.md │ │ ├── package.json │ │ ├── postcss.config.js │ │ ├── prepublish.js │ │ ├── src/ │ │ │ ├── embed.tsx │ │ │ ├── example/ │ │ │ │ └── app.tsx │ │ │ └── index.ts │ │ ├── tailwind.config.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ ├── hubspot-app/ │ │ ├── CLAUDE.md │ │ ├── README.md │ │ ├── hsproject.json │ │ ├── package.json │ │ └── src/ │ │ └── app/ │ │ ├── app-hsmeta.json │ │ └── webhooks/ │ │ └── webhooks-hsmeta.json │ ├── prisma/ │ │ ├── client.ts │ │ ├── edge.ts │ │ ├── index.ts │ │ ├── package.json │ │ ├── schema/ │ │ │ ├── activity.prisma │ │ │ ├── bounty.prisma │ │ │ ├── campaign.prisma │ │ │ ├── comment.prisma │ │ │ ├── commission.prisma │ │ │ ├── customer.prisma │ │ │ ├── dashboard.prisma │ │ │ ├── discount.prisma │ │ │ ├── domain.prisma │ │ │ ├── folder.prisma │ │ │ ├── fraud.prisma │ │ │ ├── group.prisma │ │ │ ├── integration.prisma │ │ │ ├── invoice.prisma │ │ │ ├── jackson.prisma │ │ │ ├── link.prisma │ │ │ ├── message.prisma │ │ │ ├── misc.prisma │ │ │ ├── network.prisma │ │ │ ├── notification.prisma │ │ │ ├── oauth.prisma │ │ │ ├── partner.prisma │ │ │ ├── payout.prisma │ │ │ ├── platform.prisma │ │ │ ├── postback.prisma │ │ │ ├── program.prisma │ │ │ ├── referral.prisma │ │ │ ├── reward.prisma │ │ │ ├── schema.prisma │ │ │ ├── tag.prisma │ │ │ ├── token.prisma │ │ │ ├── utm.prisma │ │ │ ├── webhook.prisma │ │ │ ├── workflow.prisma │ │ │ └── workspace.prisma │ │ └── tsconfig.json │ ├── stripe-app/ │ │ ├── README.md │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── src/ │ │ │ ├── hooks/ │ │ │ │ └── use-workspace.ts │ │ │ ├── utils/ │ │ │ │ ├── constants.ts │ │ │ │ ├── dub.ts │ │ │ │ ├── oauth.ts │ │ │ │ ├── secrets.ts │ │ │ │ ├── stripe.ts │ │ │ │ └── types.ts │ │ │ └── views/ │ │ │ └── AppSettings.tsx │ │ ├── stripe-app.dev.json │ │ ├── stripe-app.json │ │ ├── tsconfig.json │ │ └── ui-extensions.d.ts │ ├── tailwind-config/ │ │ ├── package.json │ │ ├── tailwind.config.ts │ │ └── themes.css │ ├── tinybird/ │ │ ├── README.md │ │ ├── datasources/ │ │ │ ├── dub_audit_logs.datasource │ │ │ ├── dub_click_events.datasource │ │ │ ├── dub_click_events_id.datasource │ │ │ ├── dub_click_events_mv.datasource │ │ │ ├── dub_conversion_events_log.datasource │ │ │ ├── dub_first_sale_mv.datasource │ │ │ ├── dub_import_error_logs.datasource │ │ │ ├── dub_lead_events.datasource │ │ │ ├── dub_lead_events_mv.datasource │ │ │ ├── dub_links_metadata.datasource │ │ │ ├── dub_links_metadata_latest.datasource │ │ │ ├── dub_postback_events.datasource │ │ │ ├── dub_sale_events.datasource │ │ │ ├── dub_sale_events_mv.datasource │ │ │ └── dub_webhook_events.datasource │ │ └── pipes/ │ │ ├── all_stats.pipe │ │ ├── coordinates_all.pipe │ │ ├── coordinates_sales.pipe │ │ ├── dub_click_events_id_pipe.pipe │ │ ├── dub_click_events_pipe.pipe │ │ ├── dub_first_sale_pipe.pipe │ │ ├── dub_lead_events_pipe.pipe │ │ ├── dub_links_metadata_pipe.pipe │ │ ├── dub_sale_events_pipe.pipe │ │ ├── get_audit_logs.pipe │ │ ├── get_click_event.pipe │ │ ├── get_framer_lead_events.pipe │ │ ├── get_import_error_logs.pipe │ │ ├── get_lead_event.pipe │ │ ├── get_lead_events.pipe │ │ ├── get_postback_events.pipe │ │ ├── get_webhook_events.pipe │ │ ├── v2_customer_events.pipe │ │ ├── v2_top_programs.pipe │ │ ├── v3_count.pipe │ │ ├── v3_events.pipe │ │ ├── v3_group_by.pipe │ │ ├── v3_group_by_link_country.pipe │ │ ├── v3_group_by_link_metadata.pipe │ │ ├── v3_timeseries.pipe │ │ ├── v3_usage.pipe │ │ ├── v3_usage_latest.pipe │ │ ├── v4_count.pipe │ │ ├── v4_events.pipe │ │ ├── v4_group_by.pipe │ │ ├── v4_group_by_link_metadata.pipe │ │ └── v4_timeseries.pipe │ ├── tsconfig/ │ │ ├── base.json │ │ ├── nextjs.json │ │ ├── package.json │ │ └── react-library.json │ ├── ui/ │ │ ├── README.md │ │ ├── package.json │ │ ├── postcss.config.js │ │ ├── src/ │ │ │ ├── accordion.tsx │ │ │ ├── activity-ring.tsx │ │ │ ├── alert.tsx │ │ │ ├── animated-size-container.tsx │ │ │ ├── avatar.tsx │ │ │ ├── background.tsx │ │ │ ├── badge.tsx │ │ │ ├── blur-image.tsx │ │ │ ├── button.tsx │ │ │ ├── card-list/ │ │ │ │ ├── card-list-card.tsx │ │ │ │ ├── card-list.tsx │ │ │ │ └── index.ts │ │ │ ├── card-selector.tsx │ │ │ ├── carousel/ │ │ │ │ ├── carousel.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── nav-bar.tsx │ │ │ │ └── thumbnails.tsx │ │ │ ├── charts/ │ │ │ │ ├── areas.tsx │ │ │ │ ├── bars.tsx │ │ │ │ ├── chart-context.ts │ │ │ │ ├── funnel-chart.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── time-series-chart.tsx │ │ │ │ ├── tooltip-sync.tsx │ │ │ │ ├── types.ts │ │ │ │ ├── use-tooltip.ts │ │ │ │ ├── utils.ts │ │ │ │ ├── x-axis.tsx │ │ │ │ └── y-axis.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── client-only.tsx │ │ │ ├── combobox/ │ │ │ │ └── index.tsx │ │ │ ├── composite-logo.tsx │ │ │ ├── content.ts │ │ │ ├── copy-button.tsx │ │ │ ├── copy-text.tsx │ │ │ ├── date-picker/ │ │ │ │ ├── calendar.tsx │ │ │ │ ├── date-picker.tsx │ │ │ │ ├── date-range-picker.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── presets.tsx │ │ │ │ ├── shared.ts │ │ │ │ ├── trigger.tsx │ │ │ │ └── types.ts │ │ │ ├── dots-pattern.tsx │ │ │ ├── dub-status-badge.tsx │ │ │ ├── empty-state.tsx │ │ │ ├── file-upload.tsx │ │ │ ├── filter/ │ │ │ │ ├── filter-list.tsx │ │ │ │ ├── filter-select.tsx │ │ │ │ ├── index.ts │ │ │ │ └── types.ts │ │ │ ├── footer.tsx │ │ │ ├── form.tsx │ │ │ ├── grid.tsx │ │ │ ├── hooks/ │ │ │ │ ├── index.ts │ │ │ │ ├── use-click-handlers.ts │ │ │ │ ├── use-column-visibility.ts │ │ │ │ ├── use-cookies.ts │ │ │ │ ├── use-copy-to-clipboard.tsx │ │ │ │ ├── use-current-anchor.ts │ │ │ │ ├── use-current-subdomain.ts │ │ │ │ ├── use-enter-submit.ts │ │ │ │ ├── use-in-viewport.tsx │ │ │ │ ├── use-input-focused.ts │ │ │ │ ├── use-intersection-observer.ts │ │ │ │ ├── use-keyboard-shortcut.tsx │ │ │ │ ├── use-local-storage.ts │ │ │ │ ├── use-media-query.ts │ │ │ │ ├── use-optimistic-update.ts │ │ │ │ ├── use-pagination.ts │ │ │ │ ├── use-remove-ga-params.ts │ │ │ │ ├── use-resize-observer.ts │ │ │ │ ├── use-router-stuff.ts │ │ │ │ ├── use-scroll-progress.ts │ │ │ │ ├── use-scroll.ts │ │ │ │ └── use-toast-with-undo.tsx │ │ │ ├── icon-menu.tsx │ │ │ ├── icons/ │ │ │ │ ├── anthropic.tsx │ │ │ │ ├── arrow-up-right-2.tsx │ │ │ │ ├── bing.tsx │ │ │ │ ├── continents/ │ │ │ │ │ ├── af.tsx │ │ │ │ │ ├── as.tsx │ │ │ │ │ ├── eu.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── na.tsx │ │ │ │ │ ├── oc.tsx │ │ │ │ │ └── sa.tsx │ │ │ │ ├── copy.tsx │ │ │ │ ├── crown-small.tsx │ │ │ │ ├── default-domains/ │ │ │ │ │ ├── amazon.tsx │ │ │ │ │ ├── chatgpt.tsx │ │ │ │ │ ├── figma.tsx │ │ │ │ │ ├── github-enhanced.tsx │ │ │ │ │ ├── google-enhanced.tsx │ │ │ │ │ └── spotify.tsx │ │ │ │ ├── dub-analytics.tsx │ │ │ │ ├── dub-api.tsx │ │ │ │ ├── dub-crafted-shield.tsx │ │ │ │ ├── dub-links.tsx │ │ │ │ ├── dub-partners.tsx │ │ │ │ ├── dub-product-icon.tsx │ │ │ │ ├── expanding-arrow.tsx │ │ │ │ ├── facebook.tsx │ │ │ │ ├── file-pen.tsx │ │ │ │ ├── file-send.tsx │ │ │ │ ├── github.tsx │ │ │ │ ├── go.tsx │ │ │ │ ├── google.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── instagram.tsx │ │ │ │ ├── ios-app-store.tsx │ │ │ │ ├── linkedin.tsx │ │ │ │ ├── loading-circle.tsx │ │ │ │ ├── loading-dots.tsx │ │ │ │ ├── loading-spinner.tsx │ │ │ │ ├── lock-small.tsx │ │ │ │ ├── magic.tsx │ │ │ │ ├── markdown-icon.tsx │ │ │ │ ├── matrix-lines.tsx │ │ │ │ ├── nucleo/ │ │ │ │ │ ├── README.md │ │ │ │ │ ├── android-logo.tsx │ │ │ │ │ ├── apple-logo.tsx │ │ │ │ │ ├── apple.tsx │ │ │ │ │ ├── arrow-bold-up.tsx │ │ │ │ │ ├── arrow-right.tsx │ │ │ │ │ ├── arrow-trend-up.tsx │ │ │ │ │ ├── arrow-turn-left.tsx │ │ │ │ │ ├── arrow-turn-right2.tsx │ │ │ │ │ ├── arrow-up-right.tsx │ │ │ │ │ ├── arrows-opposite-direction-x.tsx │ │ │ │ │ ├── arrows-opposite-direction-y.tsx │ │ │ │ │ ├── at-sign.tsx │ │ │ │ │ ├── badge-check.tsx │ │ │ │ │ ├── badge-check2-fill.tsx │ │ │ │ │ ├── bell.tsx │ │ │ │ │ ├── blog.tsx │ │ │ │ │ ├── bolt-fill.tsx │ │ │ │ │ ├── bolt.tsx │ │ │ │ │ ├── book-open.tsx │ │ │ │ │ ├── book2-fill.tsx │ │ │ │ │ ├── book2-small.tsx │ │ │ │ │ ├── book2.tsx │ │ │ │ │ ├── books2.tsx │ │ │ │ │ ├── box-archive.tsx │ │ │ │ │ ├── brackets-curly.tsx │ │ │ │ │ ├── briefcase-fill.tsx │ │ │ │ │ ├── brush.tsx │ │ │ │ │ ├── bullet-list-fill.tsx │ │ │ │ │ ├── bullet-list.tsx │ │ │ │ │ ├── calculator.tsx │ │ │ │ │ ├── calendar-days.tsx │ │ │ │ │ ├── calendar-refresh.tsx │ │ │ │ │ ├── calendar.tsx │ │ │ │ │ ├── calendar6.tsx │ │ │ │ │ ├── cards.tsx │ │ │ │ │ ├── caret-up-fill.tsx │ │ │ │ │ ├── chart-activity2.tsx │ │ │ │ │ ├── chart-area2.tsx │ │ │ │ │ ├── chart-line.tsx │ │ │ │ │ ├── check.tsx │ │ │ │ │ ├── check2.tsx │ │ │ │ │ ├── checkbox-checked-fill.tsx │ │ │ │ │ ├── checkbox-unchecked.tsx │ │ │ │ │ ├── chevron-left.tsx │ │ │ │ │ ├── chevron-right.tsx │ │ │ │ │ ├── chevron-up.tsx │ │ │ │ │ ├── circle-arrow-right.tsx │ │ │ │ │ ├── circle-check-fill.tsx │ │ │ │ │ ├── circle-check.tsx │ │ │ │ │ ├── circle-dollar-out.tsx │ │ │ │ │ ├── circle-dollar.tsx │ │ │ │ │ ├── circle-dollar3.tsx │ │ │ │ │ ├── circle-dotted.tsx │ │ │ │ │ ├── circle-half-dotted-check.tsx │ │ │ │ │ ├── circle-half-dotted-clock.tsx │ │ │ │ │ ├── circle-info.tsx │ │ │ │ │ ├── circle-percentage.tsx │ │ │ │ │ ├── circle-play-fill.tsx │ │ │ │ │ ├── circle-play.tsx │ │ │ │ │ ├── circle-question.tsx │ │ │ │ │ ├── circle-user.tsx │ │ │ │ │ ├── circle-warning.tsx │ │ │ │ │ ├── circle-xmark.tsx │ │ │ │ │ ├── circles.tsx │ │ │ │ │ ├── circles3.tsx │ │ │ │ │ ├── cloud-upload.tsx │ │ │ │ │ ├── cloud.tsx │ │ │ │ │ ├── code.tsx │ │ │ │ │ ├── color-palette2.tsx │ │ │ │ │ ├── connected-dots-fill.tsx │ │ │ │ │ ├── connected-dots.tsx │ │ │ │ │ ├── connected-dots4.tsx │ │ │ │ │ ├── connections3.tsx │ │ │ │ │ ├── credit-card.tsx │ │ │ │ │ ├── crosshairs3.tsx │ │ │ │ │ ├── crown.tsx │ │ │ │ │ ├── cube-settings-fill.tsx │ │ │ │ │ ├── cube-settings.tsx │ │ │ │ │ ├── cube.tsx │ │ │ │ │ ├── currency-dollar.tsx │ │ │ │ │ ├── cursor-rays.tsx │ │ │ │ │ ├── database-key.tsx │ │ │ │ │ ├── desktop.tsx │ │ │ │ │ ├── diamond-turn-right-fill.tsx │ │ │ │ │ ├── diamond-turn-right.tsx │ │ │ │ │ ├── directions.tsx │ │ │ │ │ ├── discount.tsx │ │ │ │ │ ├── dots.tsx │ │ │ │ │ ├── download.tsx │ │ │ │ │ ├── duplicate.tsx │ │ │ │ │ ├── earth-position.tsx │ │ │ │ │ ├── earth.tsx │ │ │ │ │ ├── envelope-alert.tsx │ │ │ │ │ ├── envelope-arrow-right.tsx │ │ │ │ │ ├── envelope-ban.tsx │ │ │ │ │ ├── envelope-check.tsx │ │ │ │ │ ├── envelope-fill.tsx │ │ │ │ │ ├── envelope-open.tsx │ │ │ │ │ ├── envelope.tsx │ │ │ │ │ ├── eye-slash.tsx │ │ │ │ │ ├── eye.tsx │ │ │ │ │ ├── face-smile.tsx │ │ │ │ │ ├── feather-fill.tsx │ │ │ │ │ ├── file-content.tsx │ │ │ │ │ ├── file-zip2.tsx │ │ │ │ │ ├── filter-bars.tsx │ │ │ │ │ ├── filter2.tsx │ │ │ │ │ ├── flag-wavy.tsx │ │ │ │ │ ├── flag.tsx │ │ │ │ │ ├── flag2.tsx │ │ │ │ │ ├── flag6.tsx │ │ │ │ │ ├── flask-small.tsx │ │ │ │ │ ├── flask.tsx │ │ │ │ │ ├── folder-bookmark.tsx │ │ │ │ │ ├── folder-lock.tsx │ │ │ │ │ ├── folder-plus.tsx │ │ │ │ │ ├── folder-shield.tsx │ │ │ │ │ ├── folder.tsx │ │ │ │ │ ├── folder5.tsx │ │ │ │ │ ├── gaming-console.tsx │ │ │ │ │ ├── gauge6.tsx │ │ │ │ │ ├── gear.tsx │ │ │ │ │ ├── gear2.tsx │ │ │ │ │ ├── gear3.tsx │ │ │ │ │ ├── gem.tsx │ │ │ │ │ ├── gift-fill.tsx │ │ │ │ │ ├── gift.tsx │ │ │ │ │ ├── globe-pointer.tsx │ │ │ │ │ ├── globe-search.tsx │ │ │ │ │ ├── globe.tsx │ │ │ │ │ ├── globe2.tsx │ │ │ │ │ ├── greek-temple.tsx │ │ │ │ │ ├── grid-layout-rows.tsx │ │ │ │ │ ├── grid-plus.tsx │ │ │ │ │ ├── grid.tsx │ │ │ │ │ ├── grip-dots-vertical.tsx │ │ │ │ │ ├── heading-1.tsx │ │ │ │ │ ├── heading-2.tsx │ │ │ │ │ ├── headset.tsx │ │ │ │ │ ├── heart-fill.tsx │ │ │ │ │ ├── heart.tsx │ │ │ │ │ ├── hexadecagon-star.tsx │ │ │ │ │ ├── history.tsx │ │ │ │ │ ├── hyperlink.tsx │ │ │ │ │ ├── icosahedron.tsx │ │ │ │ │ ├── image-icon.tsx │ │ │ │ │ ├── incognito.tsx │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── infinity-icon.tsx │ │ │ │ │ ├── input-field.tsx │ │ │ │ │ ├── input-password-pointer.tsx │ │ │ │ │ ├── input-password.tsx │ │ │ │ │ ├── input-search.tsx │ │ │ │ │ ├── invoice-dollar.tsx │ │ │ │ │ ├── key.tsx │ │ │ │ │ ├── layout-sidebar.tsx │ │ │ │ │ ├── license.tsx │ │ │ │ │ ├── life-ring-fill.tsx │ │ │ │ │ ├── life-ring.tsx │ │ │ │ │ ├── lines-y.tsx │ │ │ │ │ ├── link-broken.tsx │ │ │ │ │ ├── link4.tsx │ │ │ │ │ ├── location-pin.tsx │ │ │ │ │ ├── lock-fill.tsx │ │ │ │ │ ├── lock.tsx │ │ │ │ │ ├── magnifier.tsx │ │ │ │ │ ├── map-position.tsx │ │ │ │ │ ├── marketing-target.tsx │ │ │ │ │ ├── media-pause.tsx │ │ │ │ │ ├── media-play.tsx │ │ │ │ │ ├── megaphone.tsx │ │ │ │ │ ├── menu3.tsx │ │ │ │ │ ├── message-smile.tsx │ │ │ │ │ ├── microphone-fill.tsx │ │ │ │ │ ├── minus.tsx │ │ │ │ │ ├── mobile-phone.tsx │ │ │ │ │ ├── money-bill.tsx │ │ │ │ │ ├── money-bill2.tsx │ │ │ │ │ ├── money-bills2.tsx │ │ │ │ │ ├── msg.tsx │ │ │ │ │ ├── msgs-dotted.tsx │ │ │ │ │ ├── msgs-fill.tsx │ │ │ │ │ ├── msgs.tsx │ │ │ │ │ ├── note.tsx │ │ │ │ │ ├── office-building.tsx │ │ │ │ │ ├── page2.tsx │ │ │ │ │ ├── paintbrush.tsx │ │ │ │ │ ├── palette-2.tsx │ │ │ │ │ ├── paper-plane.tsx │ │ │ │ │ ├── pen-writing.tsx │ │ │ │ │ ├── pen2.tsx │ │ │ │ │ ├── percentage-arrow-down.tsx │ │ │ │ │ ├── photo.tsx │ │ │ │ │ ├── plug2.tsx │ │ │ │ │ ├── plus.tsx │ │ │ │ │ ├── plus2.tsx │ │ │ │ │ ├── post.tsx │ │ │ │ │ ├── pyramid.tsx │ │ │ │ │ ├── qrcode.tsx │ │ │ │ │ ├── receipt2.tsx │ │ │ │ │ ├── referred-via.tsx │ │ │ │ │ ├── refresh2.tsx │ │ │ │ │ ├── robot.tsx │ │ │ │ │ ├── satellite-dish.tsx │ │ │ │ │ ├── scan-text.tsx │ │ │ │ │ ├── scribble.tsx │ │ │ │ │ ├── shield-alert.tsx │ │ │ │ │ ├── shield-check.tsx │ │ │ │ │ ├── shield-keyhole.tsx │ │ │ │ │ ├── shield-slash.tsx │ │ │ │ │ ├── shield-user.tsx │ │ │ │ │ ├── shop.tsx │ │ │ │ │ ├── shuffle.tsx │ │ │ │ │ ├── sitemap.tsx │ │ │ │ │ ├── sliders.tsx │ │ │ │ │ ├── sort-alpha-ascending.tsx │ │ │ │ │ ├── sort-alpha-descending.tsx │ │ │ │ │ ├── sparkle3.tsx │ │ │ │ │ ├── square-chart.tsx │ │ │ │ │ ├── square-check.tsx │ │ │ │ │ ├── square-layout-grid5.tsx │ │ │ │ │ ├── square-layout-grid6.tsx │ │ │ │ │ ├── square-user-sparkle2.tsx │ │ │ │ │ ├── square-xmark.tsx │ │ │ │ │ ├── star-fill.tsx │ │ │ │ │ ├── star.tsx │ │ │ │ │ ├── stars2.tsx │ │ │ │ │ ├── suitcase.tsx │ │ │ │ │ ├── table-icon.tsx │ │ │ │ │ ├── table-rows2.tsx │ │ │ │ │ ├── tablet.tsx │ │ │ │ │ ├── tag.tsx │ │ │ │ │ ├── tags.tsx │ │ │ │ │ ├── text-bold.tsx │ │ │ │ │ ├── text-italic.tsx │ │ │ │ │ ├── text-strike.tsx │ │ │ │ │ ├── timer2.tsx │ │ │ │ │ ├── toggle2-fill.tsx │ │ │ │ │ ├── toggles.tsx │ │ │ │ │ ├── trash.tsx │ │ │ │ │ ├── triangle-warning.tsx │ │ │ │ │ ├── trophy.tsx │ │ │ │ │ ├── tv.tsx │ │ │ │ │ ├── ui-card.tsx │ │ │ │ │ ├── user-arrow-right.tsx │ │ │ │ │ ├── user-check.tsx │ │ │ │ │ ├── user-crown.tsx │ │ │ │ │ ├── user-delete.tsx │ │ │ │ │ ├── user-focus.tsx │ │ │ │ │ ├── user-minus.tsx │ │ │ │ │ ├── user-plus.tsx │ │ │ │ │ ├── user-search.tsx │ │ │ │ │ ├── user-xmark.tsx │ │ │ │ │ ├── user.tsx │ │ │ │ │ ├── users-fill.tsx │ │ │ │ │ ├── users-settings.tsx │ │ │ │ │ ├── users.tsx │ │ │ │ │ ├── users2.tsx │ │ │ │ │ ├── users6.tsx │ │ │ │ │ ├── versions2.tsx │ │ │ │ │ ├── views.tsx │ │ │ │ │ ├── watch.tsx │ │ │ │ │ ├── webhook.tsx │ │ │ │ │ ├── window-search.tsx │ │ │ │ │ ├── window-settings.tsx │ │ │ │ │ ├── window.tsx │ │ │ │ │ ├── workflow.tsx │ │ │ │ │ └── xmark.tsx │ │ │ │ ├── openai.tsx │ │ │ │ ├── payment-platforms/ │ │ │ │ │ ├── card-amex.tsx │ │ │ │ │ ├── card-discover.tsx │ │ │ │ │ ├── card-mastercard.tsx │ │ │ │ │ ├── card-visa.tsx │ │ │ │ │ ├── paypal.tsx │ │ │ │ │ ├── stablecoin.tsx │ │ │ │ │ ├── stripe-icon.tsx │ │ │ │ │ └── stripe-link.tsx │ │ │ │ ├── photo.tsx │ │ │ │ ├── php.tsx │ │ │ │ ├── plan-feature-icons.tsx │ │ │ │ ├── product-hunt.tsx │ │ │ │ ├── python.tsx │ │ │ │ ├── raycast.tsx │ │ │ │ ├── reddit.tsx │ │ │ │ ├── ruby.tsx │ │ │ │ ├── slack.tsx │ │ │ │ ├── sort-order.tsx │ │ │ │ ├── success.tsx │ │ │ │ ├── tick.tsx │ │ │ │ ├── tiktok.tsx │ │ │ │ ├── twitter.tsx │ │ │ │ ├── typescript.tsx │ │ │ │ ├── unsplash.tsx │ │ │ │ ├── user-clock.tsx │ │ │ │ └── youtube.tsx │ │ │ ├── index.tsx │ │ │ ├── inline-snippet.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── link-logo.tsx │ │ │ ├── link-preview.tsx │ │ │ ├── logo.tsx │ │ │ ├── max-width-wrapper.tsx │ │ │ ├── menu-item.tsx │ │ │ ├── mini-area-chart.tsx │ │ │ ├── modal.tsx │ │ │ ├── motion-constants.tsx │ │ │ ├── multi-value-input.tsx │ │ │ ├── nav/ │ │ │ │ ├── content/ │ │ │ │ │ ├── graphics/ │ │ │ │ │ │ ├── analytics-graphic.tsx │ │ │ │ │ │ ├── dub-wireframe-graphic.tsx │ │ │ │ │ │ ├── links-graphic.tsx │ │ │ │ │ │ └── partners-graphic.tsx │ │ │ │ │ ├── product-content.tsx │ │ │ │ │ ├── resources-content.tsx │ │ │ │ │ ├── shared.tsx │ │ │ │ │ └── solutions-content.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── nav-mobile.tsx │ │ │ │ └── nav.tsx │ │ │ ├── nav-wordmark.tsx │ │ │ ├── number-stepper.tsx │ │ │ ├── pagination-controls.tsx │ │ │ ├── popover.tsx │ │ │ ├── popup.tsx │ │ │ ├── progress-bar.tsx │ │ │ ├── progress-circle.tsx │ │ │ ├── progressive-blur.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── rich-text-area/ │ │ │ │ ├── index.tsx │ │ │ │ ├── rich-text-provider.tsx │ │ │ │ ├── rich-text-toolbar.tsx │ │ │ │ └── variables.tsx │ │ │ ├── scroll-container.tsx │ │ │ ├── sheet.tsx │ │ │ ├── shimmer-dots.tsx │ │ │ ├── slider.tsx │ │ │ ├── smart-datetime-picker.tsx │ │ │ ├── status-badge.tsx │ │ │ ├── styles.css │ │ │ ├── switch.tsx │ │ │ ├── tab-select.tsx │ │ │ ├── table/ │ │ │ │ ├── edit-columns-button.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── selection-toolbar.tsx │ │ │ │ ├── table.tsx │ │ │ │ ├── types.ts │ │ │ │ └── use-table-pagination.tsx │ │ │ ├── timestamp-tooltip.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── tooltip-advanced-link-features.tsx │ │ │ ├── tooltip.tsx │ │ │ ├── utm-builder.tsx │ │ │ └── wordmark.tsx │ │ ├── tailwind.config.ts │ │ ├── tsconfig.json │ │ └── tsup.config.ts │ └── utils/ │ ├── README.md │ ├── package.json │ ├── src/ │ │ ├── constants/ │ │ │ ├── cctlds.ts │ │ │ ├── connect-supported-countries.ts │ │ │ ├── continents.ts │ │ │ ├── countries.ts │ │ │ ├── country-currency-codes.ts │ │ │ ├── country-phone-codes.ts │ │ │ ├── domains.ts │ │ │ ├── dub-domains.ts │ │ │ ├── index.ts │ │ │ ├── integrations.ts │ │ │ ├── layout.ts │ │ │ ├── localhost.ts │ │ │ ├── main.ts │ │ │ ├── middleware.ts │ │ │ ├── misc.ts │ │ │ ├── paypal-supported-countries.ts │ │ │ ├── pricing/ │ │ │ │ ├── pricing-plan-compare-features.tsx │ │ │ │ ├── pricing-plan-main-features.ts │ │ │ │ ├── pricing-plan-taglines.ts │ │ │ │ └── pricing-plans.tsx │ │ │ ├── regions.ts │ │ │ ├── reserved-slugs.ts │ │ │ ├── saml.ts │ │ │ └── stablecoin-supported-countries.ts │ │ ├── functions/ │ │ │ ├── array-equal.ts │ │ │ ├── avatar.ts │ │ │ ├── camel-case.ts │ │ │ ├── capitalize.ts │ │ │ ├── chunk.ts │ │ │ ├── cn.ts │ │ │ ├── combine-words.ts │ │ │ ├── construct-metadata.ts │ │ │ ├── currency-formatter.ts │ │ │ ├── currency-zero-decimal.ts │ │ │ ├── datetime/ │ │ │ │ ├── billing-utils.ts │ │ │ │ ├── format-date-smart.ts │ │ │ │ ├── format-date.ts │ │ │ │ ├── format-datetime-smart.ts │ │ │ │ ├── format-datetime.ts │ │ │ │ ├── format-period.ts │ │ │ │ ├── get-datetime-local.ts │ │ │ │ ├── get-days-difference.ts │ │ │ │ ├── get-first-and-last-day.ts │ │ │ │ ├── index.ts │ │ │ │ └── parse-datetime.ts │ │ │ ├── deep-equal.ts │ │ │ ├── domains.ts │ │ │ ├── fetch-with-retry.ts │ │ │ ├── fetch-with-timeout.ts │ │ │ ├── fetcher.ts │ │ │ ├── format-file-size.ts │ │ │ ├── group-by.ts │ │ │ ├── hash-string.ts │ │ │ ├── index.ts │ │ │ ├── is-click-on-interactive-child.ts │ │ │ ├── is-iframeable.ts │ │ │ ├── keys.ts │ │ │ ├── link-constructor.ts │ │ │ ├── log.ts │ │ │ ├── nanoid.ts │ │ │ ├── nformatter.ts │ │ │ ├── normalize-string.ts │ │ │ ├── parse-filter-value.ts │ │ │ ├── pick.ts │ │ │ ├── pluralize.ts │ │ │ ├── pretty-print.ts │ │ │ ├── promises.ts │ │ │ ├── punycode.ts │ │ │ ├── random-value.ts │ │ │ ├── regex-escape.ts │ │ │ ├── resize-image.ts │ │ │ ├── smart-truncate.ts │ │ │ ├── stable-sort.ts │ │ │ ├── text-fetcher.ts │ │ │ ├── time-ago.ts │ │ │ ├── to-cents-number.ts │ │ │ ├── trim.ts │ │ │ ├── truncate.ts │ │ │ └── urls.ts │ │ └── index.ts │ ├── tsconfig.json │ └── tsup.config.ts ├── pnpm-workspace.yaml ├── prettier.config.js └── turbo.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/apply-issue-labels-to-pr.yml ================================================ name: "Apply issue labels to PR" on: pull_request_target: types: - opened jobs: label_on_pr: runs-on: ubuntu-latest permissions: contents: none issues: read pull-requests: write steps: - name: Apply labels from linked issue to PR uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | async function getLinkedIssues(owner, repo, prNumber) { const query = `query GetLinkedIssues($owner: String!, $repo: String!, $prNumber: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $prNumber) { closingIssuesReferences(first: 10) { nodes { number labels(first: 10) { nodes { name } } } } } } }`; const variables = { owner: owner, repo: repo, prNumber: prNumber, }; const result = await github.graphql(query, variables); return result.repository.pullRequest.closingIssuesReferences.nodes; } const pr = context.payload.pull_request; const linkedIssues = await getLinkedIssues( context.repo.owner, context.repo.repo, pr.number ); const labelsToAdd = new Set(); for (const issue of linkedIssues) { if (issue.labels && issue.labels.nodes) { for (const label of issue.labels.nodes) { labelsToAdd.add(label.name); } } } if (labelsToAdd.size) { await github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number, labels: Array.from(labelsToAdd), }); } ================================================ FILE: .github/workflows/deploy-embed-script.yml ================================================ name: "Deploy embed script" on: push: branches: - main paths: - "packages/embeds/core/**" workflow_dispatch: jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 - name: Setup pnpm uses: pnpm/action-setup@v3 with: version: 8 - name: Install dependencies & build run: pnpm --filter @dub/embed-core build # - name: Deploy to Cloudflare Pages (https://www.dubcdn.com/embed/script.js) # uses: cloudflare/wrangler-action@v3 # with: # apiToken: ${{ secrets.CLOUDFLARE_PAGES_API_KEY }} # accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} # command: pages deploy dist/embed/script.js --project-name=dub-cdn --commit-dirty=true # workingDirectory: packages/embeds/core # packageManager: pnpm ================================================ FILE: .github/workflows/e2e.yaml ================================================ name: Public API Tests on: deployment_status: jobs: api-tests: timeout-minutes: 30 if: github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success' runs-on: ubuntu-latest steps: - name: Check out code uses: actions/checkout@v2 - name: Setup pnpm uses: pnpm/action-setup@v3 - name: Setup Node.js environment uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" - name: Install dependencies run: pnpm install - name: Build utils working-directory: packages/utils run: pnpm build - name: Run tests working-directory: apps/web env: E2E_BASE_URL: ${{ github.event.deployment_status.environment_url }} E2E_TOKEN: ${{ secrets.E2E_TOKEN }} E2E_TOKEN_MEMBER: ${{ secrets.E2E_TOKEN_MEMBER }} E2E_TOKEN_OLD: ${{ secrets.E2E_TOKEN_OLD }} E2E_PUBLISHABLE_KEY: ${{ secrets.E2E_PUBLISHABLE_KEY }} QSTASH_TOKEN: ${{ secrets.QSTASH_TOKEN }} QSTASH_CURRENT_SIGNING_KEY: ${{ secrets.QSTASH_CURRENT_SIGNING_KEY }} NEXT_PUBLIC_NGROK_URL: ${{ github.event.deployment_status.environment_url }} run: pnpm test ================================================ FILE: .github/workflows/playwright.yaml ================================================ name: Playwright E2E Tests on: pull_request: branches: [main] paths: - "apps/web/**" - "packages/**" - ".github/workflows/playwright.yaml" concurrency: group: e2e-${{ github.head_ref }} cancel-in-progress: true jobs: e2e: permissions: contents: read timeout-minutes: 20 runs-on: ubuntu-latest env: CI: "true" NODE_OPTIONS: "--max-old-space-size=8192" DATABASE_URL: "mysql://root:@localhost:3306/planetscale" PLANETSCALE_DATABASE_URL: "http://root:unused@localhost:3900/planetscale" NEXTAUTH_SECRET: "e2e-test-secret-at-least-32-chars-long" NEXTAUTH_URL: "http://partners.localhost:8888" NEXT_PUBLIC_APP_NAME: "Dub" NEXT_PUBLIC_APP_DOMAIN: "dub.co" NEXT_PUBLIC_APP_SHORT_DOMAIN: "dub.sh" E2E_PARTNER_EMAIL: "partner1@dub-internal-test.com" E2E_PARTNER_PASSWORD: "password" TINYBIRD_API_KEY: "xx" TINYBIRD_API_URL: "xx" UPSTASH_REDIS_REST_URL: "https://sensible-camel-xxxx.upstash.io" UPSTASH_REDIS_REST_TOKEN: "xx" UPSTASH_VECTOR_REST_URL: "https://sensible-camel-xxxx.upstash.io" UPSTASH_VECTOR_REST_TOKEN: "xx" QSTASH_TOKEN: "xx" QSTASH_CURRENT_SIGNING_KEY: "xx" QSTASH_NEXT_SIGNING_KEY: "xx" AXIOM_TOKEN: "" AXIOM_DATASET: "" RESEND_API_KEY: "xx" EMBEDDING_SYNC_SECRET: "xx" ANTHROPIC_API_KEY: "xx" STRIPE_SECRET_KEY: "xx" STRIPE_WEBHOOK_SECRET: "xx" STRIPE_CONNECT_WEBHOOK_SECRET: "xx" STRIPE_APP_WEBHOOK_SECRET_TEST: "xx" STRIPE_APP_SECRET_KEY_TEST: "xx" STRIPE_CONNECT_V2_WEBHOOK_SECRET: "xx" STRIPE_APP_SECRET_KEY_SANDBOX: "xx" services: mysql: image: mysql:8.0 env: MYSQL_DATABASE: planetscale MYSQL_ROOT_HOST: "%" MYSQL_ALLOW_EMPTY_PASSWORD: "yes" options: >- --health-cmd="mysqladmin ping -h 127.0.0.1" --health-interval=10s --health-timeout=5s --health-retries=5 ports: - 3306:3306 steps: - name: Check out code uses: actions/checkout@v4 - name: Start PlanetScale simulator run: | docker run -d --name ps-http-sim \ --network host \ ghcr.io/mattrobenolt/ps-http-sim:latest \ -listen-addr=0.0.0.0 \ -listen-port=3900 \ -mysql-dbname=planetscale \ -mysql-no-pass \ -mysql-addr=127.0.0.1 - name: Setup pnpm uses: pnpm/action-setup@v3 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" - name: Install dependencies run: pnpm install - name: Cache Playwright browsers id: playwright-cache uses: actions/cache@v4 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('apps/web/package.json') }} - name: Install Playwright Chromium if: steps.playwright-cache.outputs.cache-hit != 'true' run: pnpm --filter web exec playwright install chromium - name: Generate Prisma client working-directory: apps/web run: pnpm prisma:generate - name: Push database schema working-directory: apps/web run: pnpm prisma:push - name: Seed test data working-directory: apps/web run: pnpm tsx playwright/seed.ts # - name: Cache Next.js build # uses: actions/cache@v4 # with: # path: apps/web/.next/cache # key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('apps/web/**/*.ts', 'apps/web/**/*.tsx') }} # restore-keys: | # nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}- # nextjs-${{ runner.os }}- # - name: Cache Turbo # uses: actions/cache@v4 # with: # path: .turbo # key: turbo-${{ runner.os }}-${{ github.sha }} # restore-keys: | # turbo-${{ runner.os }}- - name: Build application run: pnpm turbo build --filter=web - name: Run Playwright tests working-directory: apps/web run: pnpm test:e2e ================================================ FILE: .github/workflows/prettier.yaml ================================================ name: Prettier Check on: push: branches: - main pull_request: branches: - main workflow_dispatch: jobs: build: runs-on: ubuntu-latest steps: - name: Check out code uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v3 - name: Install dependencies run: pnpm install - name: Fix prettier issues run: pnpm run format - name: Check prettier format run: pnpm run prettier-check ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies node_modules dist/ # next.js .next/ /out/ # production /build # misc .DS_Store *.pem # debug .pnpm-debug.log* # local env files .env*.local .env # vercel .vercel # tinybird .venv .tinyb # turbo .turbo # typescript *.tsbuildinfo next-env.d.ts # miscellaneous /pages/api/scripts* packages/stripe-app/.build/* .react-email .contentlayer .vscode *.csv *.ndjson .vitest # playwright playwright-report/ **/playwright/.auth/ test-results/ blob-report/ ================================================ FILE: .prettierignore ================================================ node_modules pnpm-lock.yaml .next .turbo dist ================================================ FILE: LICENSE.md ================================================ Copyright (c) 2024-present Dub Technologies, Inc. Portions of this software – namely all files that reside under the following directories of this repository – are licensed under the license defined in "[ee/LICENSE.md]()". - [apps/web/app/(ee)]() - [apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)]() All third-party components incorporated into the Dub Software are licensed under the original license provided by the owner of the applicable component. Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below. --- GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: README.md ================================================ Dub is the modern, open-source link attribution platform for short links, conversion tracking, and affiliate programs.

Dub

The open-source link attribution platform.
Learn more »

Introduction · Tech Stack · Self-hosting · Contributing

Twitter Hacker News License


## Introduction Dub is the modern, open-source link attribution platform for [short links](https://dub.co/home), [conversion tracking](https://dub.co/analytics), and [affiliate programs](https://dub.co/partners). Our platform powers 100M+ clicks and 2M+ links monthly, and is used by world-class marketing teams from companies like Twilio, Buffer, Framer, Perplexity, Vercel, Laravel, and [more](https://dub.co/customers). ## Tech Stack - [Next.js](https://nextjs.org/) – framework - [TypeScript](https://www.typescriptlang.org/) – language - [Tailwind](https://tailwindcss.com/) – CSS - [Prisma](https://www.prisma.io/) – ORM - [Upstash](https://upstash.com/) – redis - [Tinybird](https://tinybird.com/) – analytics - [PlanetScale](https://planetscale.com/) – database - [NextAuth.js](https://next-auth.js.org/) – auth - [BoxyHQ](https://boxyhq.com/enterprise-sso) – SSO/SAML - [Turborepo](https://turbo.build/repo) – monorepo - [Stripe](https://stripe.com/) – payments - [Resend](https://resend.com/) – emails - [Vercel](https://vercel.com/) – deployments ## Self-Hosting You can self-host Dub for greater control over your data and design. [Read this guide](https://dub.co/docs/self-hosting/guide) to learn more. ## Contributing We love our contributors! Here's how you can contribute: - [Open an issue](https://github.com/dubinc/dub/issues) if you believe you've encountered a bug. - Follow the [local development guide](https://dub.co/docs/local-development) to get your local dev environment set up. - Make a [pull request](https://github.com/dubinc/dub/pull) to add new features/make quality-of-life improvements/fix bugs. ### Recommended Versions | Package | Version | | ------- | -------- | | node | v23.11.0 | | pnpm | 9.15.9 | ### Common Local Development Issues - `The table does not exist in the current database.` - Run `pnpm prisma:push` push the state of the Prisma schema file to the database without using migrations files. - The project is not building correctly locally - verify your versions of `node` and `pnpm` match the recommended versions above. Delete all `node_modules`, `.next`, and `.turbo` directories in the `apps` and `packages` directory. You may now reinstall `node_modules` by running `pnpm install` and attempt to rebuild the project with `pnpm build`. ### Dev Seed Script This script seeds the database with development data for testing and development purposes. **Basic seeding (adds data without deleting existing data):** ```bash cd apps/web pnpm run script dev/seed ``` **Truncate database before seeding (deletes all existing data first):** ```bash cd apps/web pnpm run script dev/seed --truncate ``` When using `--truncate`, the script will ask for confirmation before deleting any data. ## Repo Activity ![Dub repo activity – generated by Axiom](https://repobeats.axiom.co/api/embed/6ac4c94a89ea20e2e10032b932a128b6d8442e66.svg "Repobeats analytics image") ## License Dub Technologies, Inc. is a commercial open-source company, which means some parts of this open-source repository require a commercial license. The concept is called "Open Core" where the core technology (99%) is fully open source, licensed under [AGPLv3](https://opensource.org/license/agpl-v3) and the last 1% is covered under a commercial license (["/ee" Enterprise Edition]()) which we believe is entirely relevant for larger organisations that require enterprise features. Enterprise features are built by the core engineering team of Dub Technologies, Inc., which is hired full-time. ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions All versions of Dub are currently being supported with security updates. ## Reporting a Vulnerability To report a vulnerability, send an email to security@dub.co. We will respond within 48 hours acknowledging your report with details about next steps and potential rewards/compensation for responsible disclosure. ================================================ FILE: apps/web/app/(ee)/LICENSE.md ================================================ The Dub.co Commercial License (the “Commercial License”) Copyright (c) 2024-present Dub Technologies, Inc With regard to the Dub.co Software: This software and associated documentation files (the "Software") may only be used in production, if you (and any entity that you represent) have agreed to, and are in compliance with, the Dub.co Subscription Terms available at https://dub.co/legal/terms, or other agreements governing the use of the Software, as mutually agreed by you and Dub.co, Inc ("Dub.co"), and otherwise have a valid Dub.co Enterprise Edition subscription ("Commercial Subscription") for the correct number of hosts as defined in the "Commercial Terms ("Hosts"). Subject to the foregoing sentence, you are free to modify this Software and publish patches to the Software. You agree that Dub.co and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications and/or patches, and all such modifications and/or patches may only be used, copied, modified, displayed, distributed, or otherwise exploited with a valid Commercial Subscription for the correct number of hosts. Notwithstanding the foregoing, you may copy and modify the Software for development and testing purposes, without requiring a subscription. You agree that Dub.co and/or its licensors (as applicable) retain all right, title and interest in and to all such modifications. You are not granted any other rights beyond what is expressly stated herein. Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, and/or sell the Software. This Commercial License applies only to the part of this Software that is not distributed under the AGPLv3 license. Any part of this Software distributed under the MIT license or which is served client-side as an image, font, cascading stylesheet (CSS), file which produces or is compiled, arranged, augmented, or combined into client-side JavaScript, in whole or in part, is copyrighted under the AGPLv3 license. The full text of this Commercial License shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. For all third party components incorporated into the Dub.co Software, those components are licensed under the original license provided by the owner of the applicable component. ================================================ FILE: apps/web/app/(ee)/README.md ================================================ # Enterprise Edition Welcome to the Enterprise Edition of Dub.co. The [/(ee)]() subfolder is the place for all the **Enterprise Edition** features from our [hosted](https://dub.co/pricing) plan and enterprise-grade features for [Enterprise](https://dub.co/enterprise), included but not limited to the following: - [Dub Conversions](https://dub.co/help/article/dub-conversions) - [Dub Partners](https://dub.co/help/article/dub-partners) - [SAML/SSO + SCIM Directory sync ](https://dub.co/help/category/saml-sso) > _❗ WARNING: This repository is copyrighted (unlike our [main repo](https://github.com/dubinc/dub)). You are not allowed to use this code to host your own version of app.dub.co without obtaining a proper [license](https://dub.co/enterprise) first❗_ ================================================ FILE: apps/web/app/(ee)/admin.dub.co/(auth)/layout.tsx ================================================ import { Background } from "@dub/ui"; export default function AdminAuthLayout({ children, }: { children: React.ReactNode; }) { return ( <>
{children}
); } ================================================ FILE: apps/web/app/(ee)/admin.dub.co/(auth)/login/page.tsx ================================================ export { default } from "../../../../app.dub.co/(auth)/login/page"; ================================================ FILE: apps/web/app/(ee)/admin.dub.co/(dashboard)/analytics/page.tsx ================================================ import Analytics from "@/ui/analytics"; import LayoutLoader from "@/ui/layout/layout-loader"; import { Suspense } from "react"; export default function AdminAnalytics() { return ( }>
); } ================================================ FILE: apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/client.tsx ================================================ "use client"; import { formatDateTooltip } from "@/lib/analytics/format-date-tooltip"; import { AnalyticsLoadingSpinner } from "@/ui/analytics/analytics-loading-spinner"; import { FilterButtonTableRow } from "@/ui/shared/filter-button-table-row"; import SimpleDateRangePicker from "@/ui/shared/simple-date-range-picker"; import { CrownSmall, Filter, Table, usePagination, useRouterStuff, useTable, } from "@dub/ui"; import { Areas, TimeSeriesChart, XAxis, YAxis } from "@dub/ui/charts"; import { GridIcon } from "@dub/ui/icons"; import { cn, currencyFormatter, DUB_FOUNDING_DATE, fetcher, OG_AVATAR_URL, } from "@dub/utils"; import NumberFlow from "@number-flow/react"; import { Fragment, useCallback, useMemo, useState } from "react"; import useSWR from "swr"; export default function CommissionsPageClient() { const { queryParams, getQueryString, searchParamsObj } = useRouterStuff(); const { interval, start, end, programId } = searchParamsObj; const { data: { programs, timeseries } = {}, isLoading } = useSWR<{ programs: { id: string; name: string; logo: string; commissions: number; fees: number; }[]; timeseries: { start: Date; commissions: number; }[]; }>( `/api/admin/commissions${getQueryString({ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, })}`, fetcher, { keepPreviousData: true, }, ); // Filter configuration const filters = useMemo( () => [ { key: "programId", icon: GridIcon, label: "Program", options: programs?.map((program) => ({ value: program.id, label: program.name, icon: ( {`${program.name} ), })) ?? null, }, ], [programs], ); const activeFilters = useMemo(() => { return [...(programId ? [{ key: "programId", value: programId }] : [])]; }, [programId]); const onSelect = useCallback( (key: string, value: any) => queryParams({ set: { [key]: value, }, del: "page", }), [queryParams], ); const onRemove = useCallback( (key: string) => queryParams({ del: [key, "page"], }), [queryParams], ); const onRemoveAll = useCallback( () => queryParams({ del: ["programId"], }), [queryParams], ); const [selectedFilter, setSelectedFilter] = useState(null); const [search, setSearch] = useState(""); const tabs: { id: string; label: string; colorClassName: string; disabled?: boolean; }[] = [ { id: "commissions", label: "Commissions", colorClassName: "text-teal-500 bg-teal-500/50 border-teal-500", }, { id: "fees", label: "Fees", colorClassName: "text-red-500 bg-red-500/50 border-red-500", disabled: true, }, ]; const tab = tabs[0]; const selectedTab = tab.id; const chartData = timeseries?.map(({ start, commissions }) => ({ date: start ? new Date(start) : new Date(), values: { commissions: commissions || 0, }, })) ?? null; const totals = useMemo(() => { return { commissions: timeseries?.reduce( (acc, { commissions }) => acc + (commissions || 0), 0, ) ?? 0, fees: programs?.reduce((acc, { fees }) => acc + (fees || 0), 0) ?? 0, }; }, [timeseries, programs]); const { pagination, setPagination } = usePagination(); const { table, ...tableProps } = useTable({ data: programs ?? [], columns: [ { id: "position", header: "Position", size: 12, minSize: 12, maxSize: 12, cell: ({ row }) => { return (
{row.index + 1} {row.index <= 2 && ( )}
); }, }, { id: "program", header: "Program", cell: ({ row }) => (
{row.original.name} {row.original.name}
), meta: { filterParams: ({ row }) => ({ programId: row.original.id, }), }, }, { id: "commissions", header: "Commissions", accessorKey: "commissions", cell: ({ row }) => currencyFormatter(row.original.commissions), }, { id: "fees", header: "Fees", accessorKey: "fees", cell: ({ row }) => currencyFormatter(row.original.fees), }, ], pagination, onPaginationChange: setPagination, resourceName: (plural) => `program${plural ? "s" : ""}`, rowCount: programs?.length ?? 0, loading: isLoading, cellRight: (cell) => { const meta = cell.column.columnDef.meta as | { filterParams?: any; } | undefined; return ( meta?.filterParams && ( ) ); }, }); return (
{activeFilters.length > 0 && (
)}
{tabs.map(({ id, label, colorClassName, disabled }) => { return ( ); })}
{chartData ? ( chartData.length > 0 ? ( d.values.commissions, isActive: selectedTab === "commissions", colorClassName: tab.colorClassName, }, ]} tooltipClassName="p-0" tooltipContent={(d) => ( <>

{formatDateTooltip(d.date, { interval, start, end, })}

{tab.label}

{currencyFormatter(d.values[tab.id])}

)} > formatDateTooltip(d, { interval, start, end, dataAvailableFrom: DUB_FOUNDING_DATE, }) } /> currencyFormatter(value)} /> ) : (
No data available.
) ) : ( )}
); } ================================================ FILE: apps/web/app/(ee)/admin.dub.co/(dashboard)/commissions/page.tsx ================================================ import { Suspense } from "react"; import CommissionsPageClient from "./client"; export default async function CommissionsPage() { return ( ); } ================================================ FILE: apps/web/app/(ee)/admin.dub.co/(dashboard)/components/ban-link.tsx ================================================ "use client"; import { LoadingSpinner } from "@dub/ui"; import { cn } from "@dub/utils"; import { useState } from "react"; import { toast } from "sonner"; export function BanLink() { const [pending, setPending] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const form = e.currentTarget; const key = new FormData(form).get("key"); if (!key || typeof key !== "string") return; if (!window.confirm("Are you sure you want to ban this link?")) return; setPending(true); try { const res = await fetch( `/api/admin/links/ban?domain=dub.sh&key=${encodeURIComponent(key)}`, { method: "DELETE" }, ).then((r) => r.json()); if (res.error) { toast.error(res.error); } else { toast.success("Link has been banned"); } } finally { setPending(false); } }; return (
); } const Form = ({ pending }: { pending: boolean }) => { return (
dub.sh ) => { e.preventDefault(); // if pasting in https://dub.sh/xxx or dub.sh/xxx, extract xxx const text = e.clipboardData.getData("text/plain"); if ( text.startsWith("https://dub.sh/") || text.startsWith("dub.sh/") ) { e.currentTarget.value = text .replace("https://dub.sh/", "") .replace("dub.sh/", ""); } else { e.currentTarget.value = text; } }} /> {pending && ( )}
); }; ================================================ FILE: apps/web/app/(ee)/admin.dub.co/(dashboard)/components/delete-partner-account.tsx ================================================ "use client"; import { LoadingSpinner } from "@dub/ui"; import { cn } from "@dub/utils"; import { useFormStatus } from "react-dom"; import { toast } from "sonner"; export function DeletePartnerAccount() { return (
{ const deletePartnerAccount = formData.get("deletePartnerAccount") === "on"; const message = deletePartnerAccount ? "Are you sure you want to delete this partner account completely? This will also delete the partner account along with their Stripe express account. This action cannot be undone." : "Are you sure you want to delete this partner's Stripe express account? This action cannot be undone."; const confirmed = window.confirm(message); if (!confirmed) { return; } await fetch("/api/admin/delete-partner-account", { method: "POST", body: JSON.stringify({ email: formData.get("email"), deletePartnerAccount: formData.get("deletePartnerAccount") === "on", }), }).then(async (res) => { if (res.ok) { toast.success( deletePartnerAccount ? "Partner account deleted!" : "Stripe express account deleted!", ); } else { const error = await res.text(); toast.error(error); } }); }} >
); } const Form = () => { const { pending } = useFormStatus(); return (
) => { // remove mailto: on paste e.preventDefault(); const text = e.clipboardData.getData("text/plain"); if (text.startsWith("mailto:")) { e.currentTarget.value = text.replace("mailto:", ""); } else { e.currentTarget.value = text; } }} placeholder="panic@thedis.co" aria-invalid="true" /> {pending && ( )}
); }; ================================================ FILE: apps/web/app/(ee)/admin.dub.co/(dashboard)/components/impersonate-user.tsx ================================================ "use client"; import { Button, LoadingSpinner } from "@dub/ui"; import { cn } from "@dub/utils"; import { useState } from "react"; import { useFormStatus } from "react-dom"; import { toast } from "sonner"; import UserInfo, { UserInfoProps } from "./user-info"; export function ImpersonateUser() { const [data, setData] = useState(null); return (
{ await fetch("/api/admin/impersonate", { method: "POST", body: JSON.stringify({ email: formData.get("email"), }), }).then(async (res) => { if (res.ok) { setData(await res.json()); } else { const error = await res.text(); toast.error(error); } }); }} > {data && (
{ if ( !confirm( `This will ban the user ${data.email} and delete all their workspaces and links. Are you sure?`, ) ) { return; } await fetch("/api/admin/ban", { method: "POST", body: JSON.stringify({ email: data.email, }), }).then(async (res) => { if (res.ok) { toast.success("User has been banned"); } else { const error = await res.text(); toast.error(error); } }); }} >
)}
); } const Form = () => { const { pending } = useFormStatus(); return (
) => { // remove mailto: on paste e.preventDefault(); const text = e.clipboardData.getData("text/plain"); if (text.startsWith("mailto:")) { e.currentTarget.value = text.replace("mailto:", ""); } else { e.currentTarget.value = text; } }} placeholder="panic@thedis.co" aria-invalid="true" /> {pending && ( )}
); }; const BanButton = () => { const { pending } = useFormStatus(); return ); }; ================================================ FILE: apps/web/app/(ee)/admin.dub.co/(dashboard)/events/page.tsx ================================================ import Events from "@/ui/analytics/events"; import { EventsProvider } from "@/ui/analytics/events/events-provider"; import LayoutLoader from "@/ui/layout/layout-loader"; import AnalyticsClient from "app/app.dub.co/(dashboard)/[slug]/analytics/client"; import { Suspense } from "react"; export default function AdminEvents() { return ( }> ); } ================================================ FILE: apps/web/app/(ee)/admin.dub.co/(dashboard)/layout-nav-client.tsx ================================================ "use client"; import { ClientOnly, MaxWidthWrapper, NavWordmark, Popover, useMediaQuery, } from "@dub/ui"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useState } from "react"; const tabs = [ { href: "/links", label: "Links", }, { href: "/analytics", label: "Analytics", }, { href: "/commissions", label: "Commissions", }, { href: "/payouts", label: "Payouts", }, { href: "/revenue", label: "Revenue", }, ]; export function AdminNav() { const [openPopover, setOpenPopover] = useState(false); const { isMobile } = useMediaQuery(); const pathname = usePathname(); const NavContent = () => (
{tabs.map((tab) => { const isActive = pathname === tab.href || pathname?.startsWith(`${tab.href}/`); return ( setOpenPopover(false)} > {tab.label} ); })}
); return (
{isMobile ? (
} openPopover={openPopover} setOpenPopover={setOpenPopover} mobileOnly >
) : (
{tabs.map((tab) => { const isActive = pathname === tab.href || pathname?.startsWith(`${tab.href}/`); return ( {tab.label} ); })}
)}
); } ================================================ FILE: apps/web/app/(ee)/admin.dub.co/(dashboard)/layout.tsx ================================================ import { constructMetadata } from "@dub/utils"; import { ReactNode } from "react"; import { AdminNav } from "./layout-nav-client"; export const metadata = constructMetadata({ noIndex: true }); export default function AdminLayout({ children }: { children: ReactNode }) { return ( <>
{children}
); } ================================================ FILE: apps/web/app/(ee)/admin.dub.co/(dashboard)/links/page.tsx ================================================ import AdminLinksClient from "app/app.dub.co/(dashboard)/[slug]/links/page-client"; import { Suspense } from "react"; export default function AdminLinks() { return ( ); } ================================================ FILE: apps/web/app/(ee)/admin.dub.co/(dashboard)/page.tsx ================================================ import { constructMetadata } from "@dub/utils"; import { BanLink } from "./components/ban-link"; import { DeletePartnerAccount } from "./components/delete-partner-account"; import { ImpersonateUser } from "./components/impersonate-user"; import { ImpersonateWorkspace } from "./components/impersonate-workspace"; import { RefreshDomain } from "./components/refresh-domain"; import { ResetLoginAttempts } from "./components/reset-login-attempts"; export const metadata = constructMetadata({ title: "Dub Admin", noIndex: true, }); export default function AdminPage() { return (

Impersonate User

Get a login link for a user

Impersonate Workspace

Get a login link for the owner of a workspace

Ban Link

Ban a dub.sh link

Delete Stripe Express Account

Delete a partner's Stripe express account (and potentially their partner account as well).

Caveats:
- If the partner has already received payouts via Stripe, their Stripe Express account won't be deleted.
- If the partner has already received commissions or leads on Dub, their partner account won't be deleted.

Refresh Domain

Remove and re-add domain from Vercel

Reset Login Attempts

Reset a user's invalidLoginAttempts and lockedAt fields

); } ================================================ FILE: apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/client.tsx ================================================ "use client"; import { formatDateTooltip } from "@/lib/analytics/format-date-tooltip"; import { AnalyticsLoadingSpinner } from "@/ui/analytics/analytics-loading-spinner"; import { PayoutStatusBadges } from "@/ui/partners/payout-status-badges"; import { FilterButtonTableRow } from "@/ui/shared/filter-button-table-row"; import SimpleDateRangePicker from "@/ui/shared/simple-date-range-picker"; import { InvoiceStatus } from "@dub/prisma/client"; import { Button, Filter, StatusBadge, Table, usePagination, useRouterStuff, useTable, } from "@dub/ui"; import { Areas, TimeSeriesChart, XAxis, YAxis } from "@dub/ui/charts"; import { CircleDotted, GridIcon, Paypal } from "@dub/ui/icons"; import { cn, currencyFormatter, fetcher, formatDateTime, OG_AVATAR_URL, } from "@dub/utils"; import NumberFlow from "@number-flow/react"; import Link from "next/link"; import { Fragment, useCallback, useMemo, useState } from "react"; import useSWR from "swr"; interface TimeseriesData { date: Date; payouts: number; fees: number; total: number; } interface InvoiceData { date: Date; programId: string; programName: string; programLogo: string; status: InvoiceStatus; amount: number; fee: number; total: number; } type Tab = { id: "payouts" | "fees" | "total"; label: string; colorClassName: string; }; export default function PayoutsPageClient() { const { queryParams, getQueryString, searchParamsObj } = useRouterStuff(); const { interval, start, end, status, programId } = searchParamsObj; const { data: { invoices, timeseriesData } = {}, isLoading } = useSWR<{ invoices: InvoiceData[]; timeseriesData: TimeseriesData[]; }>(`/api/admin/payouts${getQueryString()}`, fetcher, { keepPreviousData: true, }); // Extract unique programs from invoices const programs = useMemo(() => { if (!invoices) return []; const programMap = new Map< string, { id: string; name: string; logo: string } >(); invoices.forEach((invoice) => { if (!programMap.has(invoice.programId)) { programMap.set(invoice.programId, { id: invoice.programId, name: invoice.programName, logo: invoice.programLogo, }); } }); return Array.from(programMap.values()).sort((a, b) => a.name.localeCompare(b.name), ); }, [invoices]); // Filter configuration const filters = useMemo( () => [ { key: "programId", icon: GridIcon, label: "Program", options: programs.map((program) => ({ value: program.id, label: program.name, icon: ( {`${program.name} ), })) ?? null, }, { key: "status", icon: CircleDotted, label: "Status", options: Object.entries(PayoutStatusBadges) .filter(([key]) => ["processing", "completed", "failed"].includes(key), ) .map(([value, { label }]) => { const Icon = PayoutStatusBadges[value as keyof typeof PayoutStatusBadges].icon; return { value, label, icon: ( ), }; }), }, ], [programs], ); const activeFilters = useMemo(() => { return [ ...(programId ? [{ key: "programId", value: programId }] : []), ...(status ? [{ key: "status", value: status }] : []), ]; }, [programId, status]); const onSelect = useCallback( (key: string, value: any) => queryParams({ set: { [key]: value, }, del: "page", }), [queryParams], ); const onRemove = useCallback( (key: string) => queryParams({ del: [key, "page"], }), [queryParams], ); const onRemoveAll = useCallback( () => queryParams({ del: ["status", "programId"], }), [queryParams], ); const tabs: Tab[] = [ { id: "payouts", label: "Payouts", colorClassName: "text-blue-500/50 bg-blue-500/50 border-blue-500", }, { id: "fees", label: "Fees", colorClassName: "text-red-500/50 bg-red-500/50 border-red-500", }, { id: "total", label: "Total", colorClassName: "text-green-500/50 bg-green-500/50 border-green-500", }, ]; const [selectedTab, setSelectedTab] = useState<"payouts" | "fees" | "total">( "payouts", ); const tab = tabs.find(({ id }) => id === selectedTab) ?? tabs[0]; // take the last 12 months const chartData = timeseriesData?.map(({ date, ...rest }) => ({ date: new Date(date), values: { value: rest[selectedTab], }, })) ?? null; const dateFormatter = (date: Date) => date.toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: "UTC", }); const totals = useMemo(() => { return { payouts: timeseriesData?.reduce((acc, { payouts }) => acc + payouts, 0) ?? 0, fees: timeseriesData?.reduce((acc, { fees }) => acc + fees, 0) ?? 0, total: timeseriesData?.reduce((acc, { total }) => acc + total, 0) ?? 0, }; }, [timeseriesData]); const { pagination, setPagination } = usePagination(); const { table, ...tableProps } = useTable({ data: invoices ?? [], columns: [ { id: "date", header: "Payment Date (UTC)", accessorKey: "date", cell: ({ row }) => formatDateTime(row.original.date, { timeZone: "UTC", }), }, { id: "program", header: "Program", cell: ({ row }) => (
{row.original.programName} {row.original.programName}
), meta: { filterParams: ({ row }) => ({ programId: row.original.programId, }), }, }, { id: "status", header: "Status", cell: ({ row }) => { const badge = PayoutStatusBadges[row.original.status]; return badge ? ( {badge.label} ) : ( "-" ); }, meta: { filterParams: ({ row }) => ({ status: row.original.status, }), }, }, { id: "amount", header: "Amount", accessorKey: "amount", cell: ({ row }) => currencyFormatter(row.original.amount), }, { id: "fee", header: "Fee", accessorKey: "fee", cell: ({ row }) => currencyFormatter(row.original.fee), }, { id: "total", header: "Total", accessorKey: "total", cell: ({ row }) => currencyFormatter(row.original.total), }, ], pagination, onPaginationChange: setPagination, resourceName: (plural) => `invoice${plural ? "s" : ""}`, rowCount: invoices?.length ?? 0, loading: isLoading, cellRight: (cell) => { const meta = cell.column.columnDef.meta as | { filterParams?: any; } | undefined; return ( meta?.filterParams && ( ) ); }, }); return (
{activeFilters.length > 0 && (
)}
{tabs.map(({ id, label, colorClassName }) => { return ( ); })}
{chartData ? ( chartData.length > 0 ? ( d.values.value, isActive: true, colorClassName: tab.colorClassName, }, ]} tooltipClassName="p-0" tooltipContent={(d) => ( <>

{formatDateTooltip(d.date, { interval, start, end, timezone: "UTC", })}

{tab.label}

{currencyFormatter(d.values.value)}

)} > currencyFormatter(value)} /> ) : (
No data available.
) ) : ( )}
); } ================================================ FILE: apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/page.tsx ================================================ import { Suspense } from "react"; import PayoutsPageClient from "./client"; export default async function PayoutsPage() { return ( ); } ================================================ FILE: apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/paypal/client.tsx ================================================ "use client"; import type { PaypalPayoutResponse } from "@/lib/paypal/get-pending-payouts"; import { PartnerAvatar } from "@/ui/partners/partner-avatar"; import { PayoutStatusBadges } from "@/ui/partners/payout-status-badges"; import { FilterButtonTableRow } from "@/ui/shared/filter-button-table-row"; import { Button, StatusBadge, Table, usePagination, useRouterStuff, useTable, } from "@dub/ui"; import { Globe } from "@dub/ui/icons"; import { cn, COUNTRIES, currencyFormatter, fetcher, nFormatter, OG_AVATAR_URL, } from "@dub/utils"; import NumberFlow from "@number-flow/react"; import { ChevronLeft } from "lucide-react"; import Link from "next/link"; import { useMemo } from "react"; import useSWR from "swr"; export default function PaypalPayoutsPageClient() { const { getQueryString } = useRouterStuff(); const { data: payouts = [], isLoading } = useSWR( `/api/admin/payouts/paypal${getQueryString()}`, fetcher, { keepPreviousData: true, }, ); const { pagination, setPagination } = usePagination(100); // Client-side pagination const paginatedPayouts = useMemo(() => { const start = (pagination.pageIndex - 1) * pagination.pageSize; const end = start + pagination.pageSize; return payouts.slice(start, end); }, [payouts, pagination.pageIndex, pagination.pageSize]); const { table, ...tableProps } = useTable({ data: paginatedPayouts, columns: [ { id: "partner", header: "Partner", cell: ({ row }) => (
{row.original.partner.email || "-"}
), }, { id: "country", header: "Country", accessorKey: "partner.country", meta: { filterParams: ({ getValue }) => ({ country: getValue() }), }, cell: ({ row }) => { const country = row.original.partner.country; if (!country || country === "Unknown") { return (
Unknown
); } return (
{country} {COUNTRIES[country] ?? country}
); }, }, { id: "program", header: "Program", accessorKey: "program.id", meta: { filterParams: ({ getValue }) => ({ programId: getValue() }), }, cell: ({ row }) => (
{row.original.program.name} {row.original.program.name}
), }, { id: "status", header: "Status", cell: ({ row }) => { const badge = PayoutStatusBadges[row.original.status]; return badge ? ( {badge.label} ) : ( "-" ); }, }, { id: "amount", header: "Amount", accessorKey: "amount", cell: ({ row }) => currencyFormatter(row.original.amount), }, ], pagination, onPaginationChange: setPagination, resourceName: (plural) => `payout${plural ? "s" : ""}`, rowCount: payouts.length, loading: isLoading, cellRight: (cell) => { const meta = cell.column.columnDef.meta as | { filterParams?: any; } | undefined; return ( meta?.filterParams && ( ) ); }, }); const stats = useMemo(() => { const allPayouts = payouts; const processingPayouts = payouts.filter((p) => p.status === "processing"); const pendingPayouts = payouts.filter((p) => p.status === "pending"); return [ { id: "all", label: "Total payouts", amount: allPayouts.reduce((acc, p) => acc + p.amount, 0), count: allPayouts.length, colorClassName: "bg-blue-500", }, { id: "processing", label: "Processing payouts", amount: processingPayouts.reduce((acc, p) => acc + p.amount, 0), count: processingPayouts.length, colorClassName: "bg-purple-500", }, { id: "pending", label: "Pending payouts", amount: pendingPayouts.reduce((acc, p) => acc + p.amount, 0), count: pendingPayouts.length, colorClassName: "bg-orange-500", }, ]; }, [payouts]); return (
{stats.map(({ id, label, amount, count, colorClassName }) => (
{label}
{!isLoading ? (
({nFormatter(count, { full: true })})
) : (
)}
))}
); } ================================================ FILE: apps/web/app/(ee)/admin.dub.co/(dashboard)/payouts/paypal/page.tsx ================================================ import { Suspense } from "react"; import PaypalPayoutsPageClient from "./client"; export default async function PaypalPayoutsPage() { return ( ); } ================================================ FILE: apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/client.tsx ================================================ "use client"; import SimpleDateRangePicker from "@/ui/shared/simple-date-range-picker"; import { CrownSmall, Table, usePagination, useRouterStuff, useTable, } from "@dub/ui"; import { cn, currencyFormatter, fetcher, nFormatter } from "@dub/utils"; import NumberFlow from "@number-flow/react"; import { useMemo } from "react"; import useSWR from "swr"; export default function RevenuePageClient() { const { getQueryString } = useRouterStuff(); const { data: { programs } = {}, isLoading } = useSWR<{ programs: { id: string; name: string; logo: string; partners: number; sales: number; saleAmount: number; }[]; }>(`/api/admin/revenue${getQueryString()}`, fetcher, { keepPreviousData: true, }); const { pagination, setPagination } = usePagination(); const { table, ...tableProps } = useTable({ data: programs ?? [], columns: [ { id: "position", header: "Position", size: 12, minSize: 12, maxSize: 12, cell: ({ row }) => { return (
{row.index + 1} {row.index <= 2 && ( )}
); }, }, { id: "program", header: "Program", cell: ({ row }) => (
{row.original.name} {row.original.name}
), }, { id: "partners", header: "Active Partners", accessorKey: "partners", cell: ({ row }) => nFormatter(row.original.partners, { full: true }), }, { id: "sales", header: "Total Sales", accessorKey: "sales", cell: ({ row }) => nFormatter(row.original.sales, { full: true }), }, { id: "revenue", header: "Affiliate Revenue", accessorKey: "revenue", cell: ({ row }) => currencyFormatter(row.original.saleAmount), }, ], pagination, onPaginationChange: setPagination, resourceName: (plural) => `program${plural ? "s" : ""}`, rowCount: programs?.length ?? 0, loading: isLoading, }); const stats = useMemo( () => [ { id: "partners", label: "Active Partners", value: programs?.reduce( (acc, { partners }) => acc + (partners || 0), 0, ), colorClassName: "bg-blue-500", }, { id: "sales", label: "Total Sales", value: programs?.reduce((acc, { sales }) => acc + (sales || 0), 0), colorClassName: "bg-green-500", }, { id: "revenue", label: "Affiliate Revenue", value: programs?.reduce( (acc, { saleAmount }) => acc + (saleAmount || 0), 0, ), colorClassName: "bg-purple-500", }, ], [programs], ); return (
{stats.map(({ id, label, value, colorClassName }) => (
{label}
{value !== undefined ? ( id === "revenue" ? ( ) : ( ) ) : (
)}
))}
); } ================================================ FILE: apps/web/app/(ee)/admin.dub.co/(dashboard)/revenue/page.tsx ================================================ import { Suspense } from "react"; import RevenuePageClient from "./client"; export default async function RevenuePage() { return ( ); } ================================================ FILE: apps/web/app/(ee)/admin.dub.co/layout.tsx ================================================ "use client"; import { SessionProvider } from "next-auth/react"; import { ReactNode } from "react"; export default function AdminLayout({ children }: { children: ReactNode }) { return {children}; } ================================================ FILE: apps/web/app/(ee)/api/admin/analytics/route.ts ================================================ import { getAnalytics } from "@/lib/analytics/get-analytics"; import { withAdmin } from "@/lib/auth"; import { parseAnalyticsQuery } from "@/lib/zod/schemas/analytics"; import { NextResponse } from "next/server"; // GET /api/admin/analytics – get analytics for admin export const GET = withAdmin(async ({ searchParams }) => { const parsedParams = parseAnalyticsQuery(searchParams); const response = await getAnalytics(parsedParams); return NextResponse.json(response); }); ================================================ FILE: apps/web/app/(ee)/api/admin/ban/route.ts ================================================ import { deleteWorkspaceAdmin } from "@/lib/api/workspaces/delete-workspace"; import { withAdmin } from "@/lib/auth"; import { updateConfig } from "@/lib/edge-config"; import { isStored, storage } from "@/lib/storage"; import { prisma } from "@dub/prisma"; import { R2_URL } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // POST /api/admin/ban export const POST = withAdmin(async ({ req }) => { const { email } = await req.json(); const user = await prisma.user.findUniqueOrThrow({ where: { email, }, select: { id: true, email: true, image: true, projects: { where: { role: "owner", }, select: { project: { select: { id: true, slug: true, logo: true, stripeId: true, }, }, }, }, }, }); console.log( `Found user ${user.email} with ${user.projects.length} workspaces`, ); waitUntil( Promise.all( user.projects.map(({ project }) => deleteWorkspaceAdmin(project)), ).then(async () => { await Promise.all([ user.image && isStored(user.image) && storage.delete({ key: user.image.replace(`${R2_URL}/`, "") }), updateConfig({ key: "emails", value: email, }), ]); // delete user await prisma.user.delete({ where: { id: user.id, }, }); }), ); return NextResponse.json({ success: true }); }); ================================================ FILE: apps/web/app/(ee)/api/admin/commissions/get-commissions-timeseries.ts ================================================ import { sqlGranularityMap } from "@/lib/planetscale/granularity"; import { TZDate } from "@date-fns/tz"; import { prisma } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; import { ACME_PROGRAM_ID } from "@dub/utils"; import { format } from "date-fns"; interface Commission { start: string; commissions: number; } export async function getCommissionsTimeseries({ programId, startDate, endDate, granularity, timezone, }: { programId?: string; startDate: Date; endDate: Date; granularity: string; timezone: string; }) { const { dateFormat, dateIncrement, startFunction, formatString } = sqlGranularityMap[granularity]; const commissions = await prisma.$queryRaw` SELECT DATE_FORMAT(CONVERT_TZ(createdAt, "UTC", ${timezone || "UTC"}), ${dateFormat}) AS start, SUM(earnings) AS commissions FROM Commission WHERE createdAt >= ${startDate} AND createdAt < ${endDate} AND status IN ("pending", "processed", "paid") AND ${programId ? Prisma.sql`programId = ${programId}` : Prisma.sql`programId != ${ACME_PROGRAM_ID}`} GROUP BY start ORDER BY start ASC;`; // Convert dates to TZDate with the specified timezone const tzStartDate = new TZDate(startDate, timezone || "UTC"); const tzEndDate = new TZDate(endDate, timezone || "UTC"); let currentDate = startFunction(tzStartDate); const commissionsLookup = Object.fromEntries( commissions.map((item) => [ item.start, { commissions: Number(item.commissions), }, ]), ); const timeseries: Commission[] = []; while (currentDate < tzEndDate) { const periodKey = format(currentDate, formatString); timeseries.push({ start: currentDate.toISOString(), ...(commissionsLookup[periodKey] || { commissions: 0, }), }); currentDate = dateIncrement(currentDate); } return timeseries; } ================================================ FILE: apps/web/app/(ee)/api/admin/commissions/get-top-program-by-commissions.ts ================================================ import { prisma } from "@dub/prisma"; import { ACME_PROGRAM_ID } from "@dub/utils"; export async function getTopProgramsByCommissions({ programId, startDate, endDate, }: { programId?: string; startDate: Date; endDate: Date; }) { const programCommissions = await prisma.commission.groupBy({ by: ["programId"], _sum: { earnings: true, }, where: { createdAt: { gte: startDate, lte: endDate, }, status: { in: ["pending", "processed", "paid"], }, programId: programId || { not: ACME_PROGRAM_ID, }, }, orderBy: { _sum: { earnings: "desc", }, }, take: 50, }); const topPrograms = await prisma.program.findMany({ where: { id: { in: programCommissions.map(({ programId }) => programId), }, }, include: { workspace: { select: { payoutFee: true, }, }, }, }); const programIdMap = Object.fromEntries( topPrograms.map((program) => [program.id, program]), ); const topProgramsWithCommissions = programCommissions .map(({ programId, _sum }) => { const program = programIdMap[programId]; if (!program) return null; const commissions = _sum.earnings || 0; const payoutFee = program.workspace?.payoutFee || 0; return { ...program, commissions, fees: commissions * payoutFee, }; }) .filter(Boolean); return topProgramsWithCommissions; } ================================================ FILE: apps/web/app/(ee)/api/admin/commissions/route.ts ================================================ import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates"; import { withAdmin } from "@/lib/auth"; import { analyticsQuerySchema } from "@/lib/zod/schemas/analytics"; import { DUB_FOUNDING_DATE } from "@dub/utils"; import { endOfDay, startOfDay } from "date-fns"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; import { getCommissionsTimeseries } from "./get-commissions-timeseries"; import { getTopProgramsByCommissions } from "./get-top-program-by-commissions"; const adminCommissionsQuerySchema = z .object({ programId: z.string().optional(), timezone: z.string().optional().default("UTC"), }) .extend( analyticsQuerySchema.pick({ interval: true, start: true, end: true }).shape, ); export const GET = withAdmin(async ({ searchParams }) => { const { programId, interval = "mtd", start, end, timezone = "UTC", } = adminCommissionsQuerySchema.parse(searchParams); const { startDate, endDate, granularity } = getStartEndDates({ interval, start: start ? startOfDay(new Date(start)) : undefined, end: end ? endOfDay(new Date(end)) : undefined, dataAvailableFrom: DUB_FOUNDING_DATE, timezone, }); const [programs, timeseries] = await Promise.all([ getTopProgramsByCommissions({ programId, startDate, endDate }), getCommissionsTimeseries({ programId, startDate, endDate, granularity, timezone, }), ]); return NextResponse.json({ programs, timeseries, }); }); ================================================ FILE: apps/web/app/(ee)/api/admin/delete-partner-account/route.ts ================================================ import { withAdmin } from "@/lib/auth"; import { conn } from "@/lib/planetscale"; import { stripe } from "@/lib/stripe"; import { recordLink } from "@/lib/tinybird"; import { prisma } from "@dub/prisma"; import { prettyPrint } from "@dub/utils"; import { NextResponse } from "next/server"; // POST /api/admin/delete-partner-account export const POST = withAdmin(async ({ req }) => { const { email, deletePartnerAccount } = await req.json(); const partner = await prisma.partner.findUnique({ where: { email, }, include: { commissions: true, programs: { select: { program: true, links: true, groupId: true, }, }, }, }); if (!partner) { return new Response("Partner not found", { status: 404 }); } if (partner.stripeConnectId) { try { // check if stripe express account has received payouts before const transfers = await stripe.transfers.list({ destination: partner.stripeConnectId, limit: 1, }); if (transfers.data.length > 0) { return new Response( "Stripe express account has received payouts before and cannot be deleted.", { status: 400, }, ); } const res = await stripe.accounts.del(partner.stripeConnectId); console.log( `Deleted Stripe express account for partner ${partner.email}: `, prettyPrint(res), ); } catch (error) { console.log( "Error deleting Stripe express account (probably already deleted): ", error, ); } await prisma.partner.update({ where: { id: partner.id, }, data: { stripeConnectId: null, payoutsEnabledAt: null, payoutMethodHash: null, }, }); console.log(`Updated partner ${partner.email} with stripeConnectId null`); } if (deletePartnerAccount) { if (partner.commissions.length > 0) { return new Response( "Partner has already received commissions and cannot be deleted.", { status: 400, }, ); } if ( partner.programs.some(({ links }) => links.some((link) => link.leads > 0)) ) { return new Response( "Partner has already received leads and cannot be deleted.", { status: 400, }, ); } if (partner.programs.length > 0) { for (const { program, links, groupId } of partner.programs) { if (links.length > 0) { await Promise.allSettled([ prisma.link.deleteMany({ where: { id: { in: links.map((link) => link.id), }, }, }), recordLink( links.map((link) => ({ ...link, programEnrollment: { groupId }, })), { deleted: true }, ), ]); console.log( `Deleted ${links.length} links for program ${program.name} (${program.slug})`, ); } } await prisma.programEnrollment.deleteMany({ where: { partnerId: partner.id, programId: { in: partner.programs.map(({ program }) => program.id), }, }, }); console.log( `Deleted ${partner.programs.length} program enrollments for partner ${partner.email} (${partner.id})`, ); } await conn.execute(`DELETE FROM Partner WHERE id = ?`, [partner.id]); console.log(`Deleted partner ${partner.email} (${partner.id})`); } return NextResponse.json({ success: true }); }); ================================================ FILE: apps/web/app/(ee)/api/admin/events/route.ts ================================================ import { getEvents } from "@/lib/analytics/get-events"; import { withAdmin } from "@/lib/auth"; import { eventsQuerySchema } from "@/lib/zod/schemas/analytics"; import { NextResponse } from "next/server"; // GET /api/admin/events – get events for admin export const GET = withAdmin(async ({ searchParams }) => { const parsedParams = eventsQuerySchema.parse(searchParams); const response = await getEvents(parsedParams); return NextResponse.json(response); }); ================================================ FILE: apps/web/app/(ee)/api/admin/impersonate/route.ts ================================================ import { hashToken, withAdmin } from "@/lib/auth"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN, PARTNERS_DOMAIN } from "@dub/utils"; import { randomBytes } from "crypto"; import { NextResponse } from "next/server"; // POST /api/admin/impersonate export const POST = withAdmin(async ({ req }) => { const { email, slug } = await req.json(); const response = await prisma.user.findFirst({ where: email ? { email } : { projects: { some: { project: { slug, }, role: "owner", }, }, }, select: { email: true, projects: { select: { project: { select: { id: true, name: true, slug: true, plan: true, usage: true, linksUsage: true, totalClicks: true, totalLinks: true, }, }, }, orderBy: { project: { totalClicks: "desc", }, }, }, partners: { select: { partner: { select: { programs: { select: { program: { select: { id: true, name: true, slug: true, }, }, status: true, totalClicks: true, totalLeads: true, totalConversions: true, totalSaleAmount: true, totalCommissions: true, }, orderBy: { totalCommissions: "desc", }, }, }, }, }, }, }, }); if (!response?.email) { return new Response("User not found", { status: 404 }); } const data = { email: response.email, workspaces: response.projects.map(({ project }) => ({ ...project, clicks: project.usage, links: project.linksUsage, totalClicks: project.totalClicks, totalLinks: project.totalLinks, })), programs: response.partners.length > 0 ? response.partners[0].partner.programs.map(({ program, ...rest }) => ({ ...program, ...rest, })) : [], impersonateUrl: await getImpersonateUrl(response.email), }; return NextResponse.json(data); }); async function getImpersonateUrl(email: string) { const token = randomBytes(32).toString("hex"); await prisma.verificationToken.create({ data: { identifier: email, token: await hashToken(token, { secret: true }), expires: new Date(Date.now() + 60000), }, }); return { app: `${APP_DOMAIN}/api/auth/callback/email?${new URLSearchParams({ callbackUrl: APP_DOMAIN, email, token, })}`, partners: `${PARTNERS_DOMAIN}/api/auth/callback/email?${new URLSearchParams( { callbackUrl: PARTNERS_DOMAIN, email, token, }, )}`, }; } ================================================ FILE: apps/web/app/(ee)/api/admin/links/[linkId]/route.ts ================================================ import { transformLink } from "@/lib/api/links"; import { withAdmin } from "@/lib/auth"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/admin/links/[linkId] – get a link as an admin export const GET = withAdmin(async ({ params }) => { const { linkId } = params; const link = await prisma.link.findUnique({ where: { id: linkId, }, }); if (!link) { return NextResponse.json({ error: "Link not found" }, { status: 404 }); } return NextResponse.json(transformLink(link)); }); ================================================ FILE: apps/web/app/(ee)/api/admin/links/ban/route.ts ================================================ import { linkCache } from "@/lib/api/links/cache"; import { withAdmin } from "@/lib/auth"; import { updateConfig } from "@/lib/edge-config"; import { domainKeySchema } from "@/lib/zod/schemas/links"; import { prisma } from "@dub/prisma"; import { LEGAL_USER_ID, LEGAL_WORKSPACE_ID, getDomainWithoutWWW, } from "@dub/utils"; import { NextResponse } from "next/server"; // DELETE /api/admin/links/ban – ban a dub.sh link by key export const DELETE = withAdmin(async ({ searchParams }) => { const { domain, key } = domainKeySchema.parse(searchParams); const link = await prisma.link.findUnique({ where: { domain_key: { domain, key } }, }); if (!link) { return NextResponse.json({ error: "Link not found" }, { status: 404 }); } const urlDomain = getDomainWithoutWWW(link.url); const response = await Promise.all([ prisma.link.update({ where: { id: link.id, }, data: { userId: LEGAL_USER_ID, projectId: LEGAL_WORKSPACE_ID, }, }), linkCache.set({ ...link, projectId: LEGAL_WORKSPACE_ID }), urlDomain && updateConfig({ key: "domains", value: urlDomain, }), ]); return NextResponse.json(response); }); ================================================ FILE: apps/web/app/(ee)/api/admin/links/count/route.ts ================================================ import { withAdmin } from "@/lib/auth"; import { prisma } from "@dub/prisma"; import { DUB_DOMAINS_ARRAY, LEGAL_USER_ID } from "@dub/utils"; import { NextResponse } from "next/server"; // GET /api/admin/links/count export const GET = withAdmin(async ({ searchParams }) => { let { groupBy, search, domain, tagId } = searchParams as { groupBy?: "domain" | "tagId" | "userId"; search?: string; domain?: string; tagId?: string; }; let response; const tagIds = tagId ? tagId.split(",") : []; const linksWhere = { // when filtering by domain, only filter by domain if the filter group is not "Domains" ...(domain && groupBy !== "domain" ? { domain, } : { domain: { in: DUB_DOMAINS_ARRAY, }, }), userId: { not: LEGAL_USER_ID, }, ...(search && { OR: [ { shortLink: { contains: search }, }, { url: { contains: search }, }, ], }), }; if (groupBy === "tagId") { response = await prisma.linkTag.groupBy({ by: ["tagId"], where: { link: linksWhere, }, _count: true, orderBy: { _count: { tagId: "desc", }, }, }); } else { const where = { ...linksWhere, ...(tagIds.length > 0 && { tags: { some: { tagId: { in: tagIds, }, }, }, }), }; if (groupBy) { response = await prisma.link.groupBy({ by: [groupBy], where, _count: true, orderBy: { _count: { [groupBy]: "desc", }, }, take: 500, }); } else { response = await prisma.link.count({ where, }); } } return NextResponse.json(response); }); ================================================ FILE: apps/web/app/(ee)/api/admin/links/route.ts ================================================ import { transformLink } from "@/lib/api/links"; import { withAdmin } from "@/lib/auth"; import { prisma } from "@dub/prisma"; import { DUB_DOMAINS_ARRAY, LEGAL_USER_ID } from "@dub/utils"; import { NextResponse } from "next/server"; // GET /api/admin/links export const GET = withAdmin(async ({ searchParams }) => { const { domain, search, sort = "createdAt", page, } = searchParams as { domain?: string; search?: string; sort?: "createdAt" | "clicks" | "lastClicked"; page?: string; }; const response = await prisma.link.findMany({ where: { ...(domain ? { domain } : { domain: { in: DUB_DOMAINS_ARRAY, }, }), ...(!search && { createdAt: { gte: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30), // 30 days ago }, }), OR: [ { userId: { not: LEGAL_USER_ID, }, }, { userId: null, }, ], ...(search && (search.startsWith("https://") ? { shortLink: search, } : { OR: [ { shortLink: { contains: search }, }, { url: { contains: search }, }, ], })), }, include: { user: true, tags: { include: { tag: { select: { id: true, name: true, color: true, }, }, }, }, }, orderBy: { [sort]: "desc", }, take: 100, ...(page && { skip: (parseInt(page) - 1) * 100, }), }); return NextResponse.json(response.map((link) => transformLink(link))); }); ================================================ FILE: apps/web/app/(ee)/api/admin/payouts/paypal/route.ts ================================================ import { withAdmin } from "@/lib/auth/admin"; import { getPendingPaypalPayouts } from "@/lib/paypal/get-pending-payouts"; import { NextResponse } from "next/server"; export const GET = withAdmin(async ({ searchParams }) => { const { country, programId } = searchParams; const pendingPaypalPayouts = await getPendingPaypalPayouts({ country, programId, }); return NextResponse.json(pendingPaypalPayouts); }); ================================================ FILE: apps/web/app/(ee)/api/admin/payouts/route.ts ================================================ import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates"; import { withAdmin } from "@/lib/auth"; import { sqlGranularityMap } from "@/lib/planetscale/granularity"; import { analyticsQuerySchema } from "@/lib/zod/schemas/analytics"; import { prisma } from "@dub/prisma"; import { InvoiceStatus, Prisma } from "@dub/prisma/client"; import { ACME_PROGRAM_ID } from "@dub/utils"; import { format } from "date-fns"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; interface TimeseriesPoint { payouts: number; fees: number; total: number; } interface FormattedTimeseriesPoint extends TimeseriesPoint { date: Date; } const adminPayoutsQuerySchema = z .object({ programId: z.string().optional(), status: z.enum(InvoiceStatus).optional(), }) .extend( analyticsQuerySchema.pick({ interval: true, start: true, end: true }).shape, ); export const GET = withAdmin(async ({ searchParams }) => { const { programId, status, interval = "mtd", start, end, } = adminPayoutsQuerySchema.parse(searchParams); const timezone = "UTC"; const { startDate, endDate, granularity } = getStartEndDates({ interval, start, end, timezone, }); // Fetch invoices const invoices = await prisma.invoice.findMany({ where: { ...(programId ? { programId } : { AND: [ { programId: { not: ACME_PROGRAM_ID, }, }, { program: { isNot: null, }, }, ], }), status: status || { not: "failed", }, createdAt: { gte: startDate, lte: endDate, }, }, include: { program: { select: { name: true, logo: true, }, }, }, orderBy: { createdAt: "desc", }, }); const { dateFormat, dateIncrement, startFunction, formatString } = sqlGranularityMap[granularity]; // Calculate timeseries data for payouts and fees const timeseriesData = await prisma.$queryRaw< { date: Date; payouts: number; fees: number; total: number }[] >` SELECT DATE_FORMAT(CONVERT_TZ(createdAt, "UTC", ${timezone}), ${dateFormat}) as date, SUM(amount) as payouts, SUM(fee) as fees, SUM(total) as total FROM Invoice WHERE ${programId ? Prisma.sql`programId = ${programId}` : Prisma.sql`programId != ${ACME_PROGRAM_ID}`} AND ${status ? Prisma.sql`status = ${status}` : Prisma.sql`status != 'failed'`} AND createdAt >= ${startDate} AND createdAt <= ${endDate} GROUP BY DATE_FORMAT(CONVERT_TZ(createdAt, "UTC", ${timezone}), ${dateFormat}) ORDER BY date ASC; `; const formattedInvoices = invoices.map((invoice) => ({ date: invoice.createdAt, // we're coercing this cause we've filtered out invoices without a programId above programId: invoice.programId!, programName: invoice.program!.name, programLogo: invoice.program!.logo, status: invoice.status, amount: invoice.amount, fee: invoice.fee, total: invoice.total, })); // Create a lookup object for the timeseries data const timeseriesLookup: Record = Object.fromEntries( timeseriesData.map((item) => [ item.date, { payouts: Number(item.payouts), fees: Number(item.fees), total: Number(item.total), }, ]), ); // Backfill missing dates with 0 values let currentDate = startFunction(startDate); const formattedTimeseriesData: FormattedTimeseriesPoint[] = []; while (currentDate < endDate) { const periodKey = format(currentDate, formatString); formattedTimeseriesData.push({ date: currentDate, ...(timeseriesLookup[periodKey] || { payouts: 0, fees: 0, total: 0, }), }); currentDate = dateIncrement(currentDate); } return NextResponse.json({ invoices: formattedInvoices, timeseriesData: formattedTimeseriesData, }); }); ================================================ FILE: apps/web/app/(ee)/api/admin/refresh-domain/route.ts ================================================ import { addDomainToVercel } from "@/lib/api/domains/add-domain-vercel"; import { withAdmin } from "@/lib/auth"; import { NextResponse } from "next/server"; // POST /api/admin/refresh-domain export const POST = withAdmin(async ({ req }) => { const { domain } = await req.json(); const remove = await fetch( `https://api.vercel.com/v9/projects/${process.env.PROJECT_ID_VERCEL}/domains/${domain}?teamId=${process.env.TEAM_ID_VERCEL}`, { headers: { Authorization: `Bearer ${process.env.AUTH_BEARER_TOKEN}`, }, method: "DELETE", }, ).then((res) => res.json()); const add = await addDomainToVercel(domain); console.log({ remove, add }); return NextResponse.json({ success: true }); }); ================================================ FILE: apps/web/app/(ee)/api/admin/reset-login-attempts/route.ts ================================================ import { withAdmin } from "@/lib/auth"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // POST /api/admin/reset-login-attempts export const POST = withAdmin(async ({ req }) => { const { email } = await req.json(); if (!email) { return NextResponse.json({ error: "Email is required" }, { status: 400 }); } const user = await prisma.user.findUnique({ where: { email }, select: { id: true, email: true, invalidLoginAttempts: true, lockedAt: true, }, }); if (!user) { return NextResponse.json({ error: "User not found" }, { status: 404 }); } const updatedUser = await prisma.user.update({ where: { email }, data: { invalidLoginAttempts: 0, lockedAt: null, }, select: { id: true, email: true, invalidLoginAttempts: true, lockedAt: true, }, }); return NextResponse.json({ success: true, user: updatedUser, }); }); ================================================ FILE: apps/web/app/(ee)/api/admin/revenue/get-top-programs-by-sales.ts ================================================ import { formatUTCDateTimeClickhouse } from "@/lib/analytics/utils/format-utc-datetime-clickhouse"; import { tb } from "@/lib/tinybird"; import { prisma } from "@dub/prisma"; import { ACME_PROGRAM_ID } from "@dub/utils"; import * as z from "zod/v4"; export async function getTopProgramsBySales({ startDate, endDate, }: { startDate: Date; endDate: Date; }) { const pipe = tb.buildPipe({ pipe: "v2_top_programs", parameters: z.any(), data: z.any(), }); const response = await pipe({ eventType: "sales", start: formatUTCDateTimeClickhouse(startDate), end: formatUTCDateTimeClickhouse(endDate), }); const topProgramsData = response.data as { programId: string; }[]; const programIds = topProgramsData .map((item) => item.programId) .filter((id) => id !== ACME_PROGRAM_ID); const programs = await prisma.program.findMany({ where: { id: { in: programIds, }, }, select: { id: true, name: true, logo: true, _count: { select: { partners: { where: { totalCommissions: { gt: 0, }, }, }, }, }, }, }); return topProgramsData .map((item) => { const program = programs.find((program) => program.id === item.programId); if (!program) return null; const { _count, ...rest } = program; return { ...rest, ...item, partners: _count.partners, }; }) .filter(Boolean); } ================================================ FILE: apps/web/app/(ee)/api/admin/revenue/route.ts ================================================ import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates"; import { withAdmin } from "@/lib/auth"; import { DUB_FOUNDING_DATE } from "@dub/utils"; import { endOfDay, startOfDay } from "date-fns"; import { NextResponse } from "next/server"; import { getTopProgramsBySales } from "./get-top-programs-by-sales"; export const GET = withAdmin(async ({ searchParams }) => { const { interval = "mtd", start, end } = searchParams; const { startDate, endDate } = getStartEndDates({ interval, start: start ? startOfDay(new Date(start)) : undefined, end: end ? endOfDay(new Date(end)) : undefined, dataAvailableFrom: DUB_FOUNDING_DATE, }); const programs = await getTopProgramsBySales({ startDate, endDate, }); return NextResponse.json({ programs, }); }); ================================================ FILE: apps/web/app/(ee)/api/audit-logs/export/route.ts ================================================ import { convertToCSV } from "@/lib/analytics/utils"; import { getAuditLogs } from "@/lib/api/audit-logs/get-audit-logs"; import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { getPlanCapabilities } from "@/lib/plan-capabilities"; import * as z from "zod/v4"; const auditLogExportQuerySchema = z.object({ start: z.string(), end: z.string(), }); // POST /api/audit-logs/export – export audit logs to CSV export const POST = withWorkspace( async ({ req, workspace }) => { const { start, end } = auditLogExportQuerySchema.parse( await parseRequestBody(req), ); if (!start || !end) { throw new DubApiError({ code: "bad_request", message: "Must provide start and end dates.", }); } const { canExportAuditLogs } = getPlanCapabilities(workspace.plan); if (!canExportAuditLogs) { throw new DubApiError({ code: "forbidden", message: "You are not authorized to export audit logs.", }); } const programId = getDefaultProgramIdOrThrow(workspace); const auditLogs = await getAuditLogs({ workspaceId: workspace.id, programId, start: new Date(start), end: new Date(end), }); const csvData = convertToCSV(auditLogs); return new Response(csvData, { headers: { "Content-Type": "application/csv", "Content-Disposition": `attachment;`, }, }); }, { requiredRoles: ["owner", "member"], requiredPlan: ["enterprise"], }, ); ================================================ FILE: apps/web/app/(ee)/api/auth/saml/authorize/route.ts ================================================ import { jackson } from "@/lib/jackson"; import { getSearchParams } from "@dub/utils"; import { NextResponse } from "next/server"; const handler = async (req: Request) => { const { oauthController } = await jackson(); const requestParams = req.method === "GET" ? getSearchParams(req.url) : await req.json(); const { redirect_url, authorize_form } = await oauthController.authorize(requestParams); if (redirect_url) { return NextResponse.redirect(redirect_url, { status: 302, }); } else { return new Response(authorize_form, { headers: { "Content-Type": "text/html; charset=utf-8", }, }); } }; export { handler as GET, handler as POST }; ================================================ FILE: apps/web/app/(ee)/api/auth/saml/callback/route.ts ================================================ import { jackson } from "@/lib/jackson"; import { NextResponse } from "next/server"; export async function POST(req: Request) { const { oauthController } = await jackson(); const formData = await req.formData(); const RelayState = formData.get("RelayState") || ""; const SAMLResponse = formData.get("SAMLResponse") || ""; const { redirect_url } = await oauthController.samlResponse({ RelayState: RelayState as string, SAMLResponse: SAMLResponse as string, }); if (!redirect_url) { return new Response("No redirect URL found.", { status: 400, }); } return NextResponse.redirect(redirect_url, { status: 302, }); } ================================================ FILE: apps/web/app/(ee)/api/auth/saml/token/route.ts ================================================ import { jackson } from "@/lib/jackson"; import * as jose from "jose"; import { NextResponse } from "next/server"; import * as dummy from "openid-client"; export async function POST(req: Request) { console.log("token route"); // Need these imports to fix import errors with jackson // https://github.com/ory/polis/blob/main/pages/api/import-hack.ts const unused = dummy; // eslint-disable-line @typescript-eslint/no-unused-vars const unused2 = jose; // eslint-disable-line @typescript-eslint/no-unused-vars const { oauthController } = await jackson(); const formData = await req.formData(); const body = Object.fromEntries(formData.entries()); const token = await oauthController.token(body as any); return NextResponse.json(token); } ================================================ FILE: apps/web/app/(ee)/api/auth/saml/userinfo/route.ts ================================================ import { jackson } from "@/lib/jackson"; import { NextResponse } from "next/server"; export async function GET(req: Request) { const { oauthController } = await jackson(); const authHeader = req.headers.get("Authorization"); if (!authHeader) { return new Response("Unauthorized", { status: 401, }); } const token = authHeader.split(" ")[1]; const user = await oauthController.userInfo(token); return NextResponse.json(user); } ================================================ FILE: apps/web/app/(ee)/api/auth/saml/verify/route.tsx ================================================ import { jackson } from "@/lib/jackson"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; export async function POST(req: Request) { const { apiController } = await jackson(); const { slug } = await req.json(); if (!slug) { return NextResponse.json( { error: "No workspace slug provided." }, { status: 400 }, ); } const workspace = await prisma.project.findUnique({ where: { slug }, select: { id: true }, }); if (!workspace) { return NextResponse.json( { error: "Workspace not found." }, { status: 404 }, ); } const connections = await apiController.getConnections({ tenant: workspace.id, product: "Dub", }); if (!connections || connections.length === 0) { return NextResponse.json( { error: "No SSO connections found for this workspace." }, { status: 404 }, ); } const data = { workspaceId: workspace.id, }; return NextResponse.json({ data }); } ================================================ FILE: apps/web/app/(ee)/api/bounties/[bountyId]/route.ts ================================================ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { DubApiError } from "@/lib/api/errors"; import { throwIfInvalidGroupIds } from "@/lib/api/groups/throw-if-invalid-group-ids"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { generatePerformanceBountyName } from "@/lib/bounty/api/generate-performance-bounty-name"; import { getBountyWithDetails } from "@/lib/bounty/api/get-bounty-with-details"; import { PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES } from "@/lib/bounty/api/performance-bounty-scope-attributes"; import { validateBounty } from "@/lib/bounty/api/validate-bounty"; import { getPlanCapabilities } from "@/lib/plan-capabilities"; import { WorkflowCondition } from "@/lib/types"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { BountySchema, submissionRequirementsSchema, updateBountySchema, } from "@/lib/zod/schemas/bounties"; import { prisma } from "@dub/prisma"; import { PartnerGroup, Prisma } from "@dub/prisma/client"; import { arrayEqual, deepEqual } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // GET /api/bounties/[bountyId] - get a bounty export const GET = withWorkspace( async ({ workspace, params }) => { const { bountyId } = params; const programId = getDefaultProgramIdOrThrow(workspace); console.time("getBountyWithDetails"); const bounty = await getBountyWithDetails({ bountyId, programId, }); console.timeEnd("getBountyWithDetails"); return NextResponse.json(BountySchema.parse(bounty)); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], }, ); // PATCH /api/bounties/[bountyId] - update a bounty export const PATCH = withWorkspace( async ({ workspace, params, req, session }) => { const { bountyId } = params; const programId = getDefaultProgramIdOrThrow(workspace); const { name, description, startsAt, endsAt, submissionsOpenAt, submissionFrequency, maxSubmissions, rewardAmount, rewardDescription, submissionRequirements, performanceCondition, groupIds, } = updateBountySchema.parse(await parseRequestBody(req)); const bounty = await prisma.bounty.findUniqueOrThrow({ where: { id: bountyId, programId, }, include: { groups: true, workflow: true, _count: { select: { submissions: true, }, }, }, }); validateBounty({ type: bounty.type, startsAt, endsAt: endsAt !== undefined ? endsAt : bounty.endsAt, submissionsOpenAt, submissionFrequency: submissionFrequency !== undefined ? submissionFrequency : bounty.submissionFrequency, maxSubmissions: maxSubmissions !== undefined ? maxSubmissions : bounty.maxSubmissions, submissionRequirements, rewardAmount, rewardDescription, performanceScope: bounty.performanceScope, }); if ( submissionRequirements !== undefined && submissionRequirements?.socialMetrics && !getPlanCapabilities(workspace.plan).canUseBountySocialMetrics ) { throw new DubApiError({ code: "forbidden", message: "Social metrics criteria require Advanced plan or above.", }); } // TODO: // When we do archive, make sure it disables the workflow // if groupIds is provided and is different from the current groupIds, update the groups let updatedPartnerGroups: PartnerGroup[] | undefined = undefined; if ( groupIds && !arrayEqual( bounty.groups.map((group) => group.groupId), groupIds, ) ) { updatedPartnerGroups = await throwIfInvalidGroupIds({ programId, groupIds, }); } // Prevent updates if `performanceCondition.attribute` differs from the current value if there are existing submissions if (performanceCondition && bounty.workflow) { const submissionCount = bounty._count.submissions; const currentCondition = bounty.workflow .triggerConditions?.[0] as WorkflowCondition; if ( currentCondition && currentCondition.attribute !== performanceCondition.attribute && submissionCount > 0 ) { throw new DubApiError({ code: "bad_request", message: `You cannot change the performance condition from "${PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES[currentCondition.attribute].toLowerCase()}" to "${PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES[performanceCondition.attribute].toLowerCase()}" because the bounty has submissions.`, }); } } // Prevent update if `submissionRequirements.socialMetrics` differs from the current value if there are existing submissions if (submissionRequirements) { const submissionCount = bounty._count.submissions; const currentSocialMetrics = bounty.submissionRequirements ? submissionRequirementsSchema.parse(bounty.submissionRequirements) .socialMetrics ?? {} : {}; const incomingSocialMetrics = submissionRequirementsSchema.parse(submissionRequirements) .socialMetrics ?? {}; if ( !deepEqual(currentSocialMetrics, incomingSocialMetrics) && submissionCount > 0 ) { throw new DubApiError({ code: "bad_request", message: "You cannot change the social metrics criteria because the bounty has submissions.", }); } } // Bounty name let bountyName = name; if (bounty.type === "performance" && performanceCondition) { bountyName = generatePerformanceBountyName({ rewardAmount: rewardAmount ?? 0, // this shouldn't happen since we return early if rewardAmount is null condition: performanceCondition, }); } const data = await prisma.$transaction(async (tx) => { const updatedBounty = await tx.bounty.update({ where: { id: bounty.id, }, data: { name: bountyName ?? undefined, description, startsAt: startsAt!, // Can remove the ! when we're on a newer TS version (currently 5.4.4) endsAt, submissionsOpenAt: bounty.type === "submission" ? submissionsOpenAt : null, ...(bounty.type === "submission" && submissionFrequency !== undefined && { submissionFrequency }), ...(bounty.type === "submission" && maxSubmissions !== undefined && { maxSubmissions: maxSubmissions ?? 1, }), rewardAmount: rewardAmount !== undefined ? rewardAmount : bounty.rewardAmount, rewardDescription, ...(bounty.type === "submission" && submissionRequirements !== undefined && { submissionRequirements: submissionRequirements ?? Prisma.DbNull, }), ...(updatedPartnerGroups && { groups: { deleteMany: {}, create: updatedPartnerGroups.map((group) => ({ groupId: group.id, })), }, }), }, include: { workflow: true, groups: true, }, }); if (updatedBounty.workflowId && performanceCondition) { await tx.workflow.update({ where: { id: updatedBounty.workflowId, }, data: { triggerConditions: [performanceCondition], }, }); } return { ...updatedBounty, performanceCondition, }; }); const updatedBounty = BountySchema.parse({ ...data, groups: data.groups.map(({ groupId }) => ({ id: groupId })), performanceCondition: data.workflow?.triggerConditions?.[0], }); waitUntil( Promise.allSettled([ recordAuditLog({ workspaceId: workspace.id, programId, action: "bounty.updated", description: `Bounty ${bounty.id} updated`, actor: session?.user, targets: [ { type: "bounty", id: bounty.id, metadata: updatedBounty, }, ], }), sendWorkspaceWebhook({ workspace, trigger: "bounty.updated", data: updatedBounty, }), ]), ); return NextResponse.json(updatedBounty); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], requiredRoles: ["owner", "member"], }, ); // DELETE /api/bounties/[bountyId] - delete a bounty export const DELETE = withWorkspace( async ({ workspace, params, session }) => { const { bountyId } = params; const programId = getDefaultProgramIdOrThrow(workspace); const bounty = await prisma.bounty.findUniqueOrThrow({ where: { id: bountyId, programId, }, include: { groups: true, workflow: true, _count: { select: { submissions: true, }, }, }, }); if (bounty._count.submissions > 0) { throw new DubApiError({ message: "Bounties with submissions cannot be deleted. You can archive them instead.", code: "bad_request", }); } await prisma.$transaction(async (tx) => { const bounty = await tx.bounty.delete({ where: { id: bountyId, }, }); if (bounty.workflowId) { await tx.workflow.delete({ where: { id: bounty.workflowId, }, }); } }); const deletedBounty = BountySchema.parse({ ...bounty, groups: bounty.groups.map(({ groupId }) => ({ id: groupId })), performanceCondition: bounty.workflow?.triggerConditions?.[0], }); waitUntil( recordAuditLog({ workspaceId: workspace.id, programId, action: "bounty.deleted", description: `Bounty ${bountyId} deleted`, actor: session?.user, targets: [ { type: "bounty", id: bountyId, metadata: deletedBounty, }, ], }), ); return NextResponse.json({ id: bountyId }); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], requiredRoles: ["owner", "member"], }, ); ================================================ FILE: apps/web/app/(ee)/api/bounties/[bountyId]/submissions/[submissionId]/approve/route.ts ================================================ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { approveBountySubmission } from "@/lib/bounty/api/approve-bounty-submission"; import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; import { approveBountySubmissionBodySchema } from "@/lib/zod/schemas/bounties"; import { NextResponse } from "next/server"; // POST /api/bounties/[bountyId]/submissions/[submissionId]/approve - approve a submission export const POST = withWorkspace( async ({ workspace, params, req, session }) => { const { bountyId, submissionId } = params; const programId = getDefaultProgramIdOrThrow(workspace); let body; try { body = await parseRequestBody(req); } catch (e) { // If body is empty or invalid, use empty object since body is optional body = {}; } const { rewardAmount } = approveBountySubmissionBodySchema.parse(body); await getBountyOrThrow({ bountyId, programId, }); const approvedSubmission = await approveBountySubmission({ programId, bountyId, submissionId, rewardAmount, user: session.user, }); return NextResponse.json(approvedSubmission); }, { requiredPlan: ["advanced", "enterprise"], requiredRoles: ["owner", "member"], }, ); ================================================ FILE: apps/web/app/(ee)/api/bounties/[bountyId]/submissions/[submissionId]/reject/route.ts ================================================ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; import { rejectBountySubmission } from "@/lib/bounty/api/reject-bounty-submission"; import { rejectBountySubmissionBodySchema } from "@/lib/zod/schemas/bounties"; import { NextResponse } from "next/server"; // POST /api/bounties/[bountyId]/submissions/[submissionId]/reject - reject a submission export const POST = withWorkspace( async ({ workspace, params, req, session }) => { const { bountyId, submissionId } = params; const programId = getDefaultProgramIdOrThrow(workspace); let body; try { body = await parseRequestBody(req); } catch (e) { // If body is empty or invalid, use empty object since body is optional body = {}; } const { rejectionReason, rejectionNote } = rejectBountySubmissionBodySchema.parse(body); await getBountyOrThrow({ bountyId, programId, }); const rejectedSubmission = await rejectBountySubmission({ programId, bountyId, submissionId, rejectionReason, rejectionNote, user: session.user, }); return NextResponse.json(rejectedSubmission); }, { requiredPlan: ["advanced", "enterprise"], requiredRoles: ["owner", "member"], }, ); ================================================ FILE: apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts ================================================ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; import { BountySubmissionExtendedSchema, getBountySubmissionsQuerySchema, } from "@/lib/zod/schemas/bounties"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/bounties/[bountyId]/submissions - get all submissions for a bounty export const GET = withWorkspace( async ({ workspace, params, searchParams }) => { const { bountyId } = params; const programId = getDefaultProgramIdOrThrow(workspace); await getBountyOrThrow({ bountyId, programId, include: { groups: true, }, }); const { status, groupId, partnerId, sortOrder, sortBy, page = 1, pageSize, } = getBountySubmissionsQuerySchema.parse(searchParams); const submissions = await prisma.bountySubmission.findMany({ where: { bountyId, status: status ?? { in: ["draft", "submitted", "approved"], }, ...(groupId && { programEnrollment: { groupId, }, }), ...(partnerId && { partnerId, }), }, include: { user: true, commission: true, partner: true, programEnrollment: true, }, orderBy: { [sortBy]: sortOrder, }, skip: (page - 1) * pageSize, take: pageSize, }); const bountySubmissions = submissions.map( ({ partner, programEnrollment, commission, user, ...submissionData }) => BountySubmissionExtendedSchema.parse({ ...submissionData, partner: { ...partner, ...(programEnrollment || {}), id: partner.id, status: programEnrollment?.status ?? null, }, commission, user, }), ); return NextResponse.json(bountySubmissions); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], }, ); ================================================ FILE: apps/web/app/(ee)/api/bounties/[bountyId]/sync-social-metrics/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; import { getSocialMetricsUpdates } from "@/lib/bounty/api/get-social-metrics-updates"; import { resolveBountyDetails } from "@/lib/bounty/utils"; import { qstash } from "@/lib/cron"; import { sendEmail } from "@dub/email"; import BountyCompleted from "@dub/email/templates/bounty-completed"; import { prisma } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; const inputSchema = z.object({ submissionId: z .string() .optional() .describe( "The ID of the submission to sync social metrics for. If not provided, all submissions will be synced.", ), }); // POST /api/bounties/[bountyId]/sync-social-metrics - sync social metrics for a bounty export const POST = withWorkspace( async ({ workspace, params, req }) => { const { bountyId } = params; const programId = getDefaultProgramIdOrThrow(workspace); const { submissionId } = inputSchema.parse(await parseRequestBody(req)); const bounty = await getBountyOrThrow({ bountyId, programId, include: submissionId ? { program: { select: { name: true, slug: true, supportEmail: true, }, }, submissions: { where: { id: submissionId, }, select: { id: true, urls: true, status: true, partner: true, }, }, } : undefined, }); const bountyInfo = resolveBountyDetails(bounty); if (!bountyInfo?.socialMetrics) { throw new DubApiError({ code: "bad_request", message: "This bounty does not have social metrics requirements.", }); } const submission = submissionId ? bounty.submissions?.[0] : undefined; if (submissionId) { if (!submission) { throw new DubApiError({ code: "not_found", message: `Submission ${submissionId} not found.`, }); } if (submission.status === "approved") { throw new DubApiError({ code: "bad_request", message: "Social metrics can't be synced for an approved submission.", }); } } const now = new Date(); if (bounty.startsAt && bounty.startsAt > now) { throw new DubApiError({ code: "bad_request", message: "Social metrics can only be synced after the bounty starts.", }); } if (bounty.endsAt && bounty.endsAt < now) { throw new DubApiError({ code: "bad_request", message: "Social metrics can't be synced after the bounty ends.", }); } // Do the sync in a background job if no submissionId is provided if (!submissionId) { const response = await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/sync-social-metrics`, method: "POST", body: { bountyId, }, }); if (!response.messageId) { throw new DubApiError({ code: "bad_request", message: "Could not sync social metrics for this bounty now.", }); } return NextResponse.json({}); } // Otherwise, do the sync for the specific submission const toUpdate = await getSocialMetricsUpdates({ bounty, submissions: bounty.submissions![0], }); if (toUpdate.length > 0) { const update = toUpdate.find((s) => s.id === submissionId); if (!update) { return NextResponse.json({}); } const { socialMetricCount, socialMetricsLastSyncedAt } = update; const submission = bounty.submissions![0]; const updateData: Prisma.BountySubmissionUpdateInput = { socialMetricCount, socialMetricsLastSyncedAt, }; const hasMetCriteria = socialMetricCount != null && bountyInfo.socialMetrics?.minCount != null && socialMetricCount >= bountyInfo.socialMetrics.minCount; const shouldTransitionToSubmitted = submission.status === "draft" && hasMetCriteria; if (shouldTransitionToSubmitted) { updateData.status = "submitted"; updateData.completedAt = new Date(); } await prisma.bountySubmission.update({ where: { id: submissionId, }, data: { ...updateData, }, }); const { partner } = submission; if (shouldTransitionToSubmitted && partner.email) { await sendEmail({ subject: "Bounty completed!", to: partner.email, variant: "notifications", replyTo: bounty.program.supportEmail || "noreply", react: BountyCompleted({ email: partner.email, bounty: { name: bounty.name, type: bounty.type, }, program: { name: bounty.program.name, slug: bounty.program.slug, }, }), headers: { "Idempotency-Key": `bounty-completed-${submissionId}`, }, }); } } return NextResponse.json({}); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], }, ); ================================================ FILE: apps/web/app/(ee)/api/bounties/count/submissions/route.ts ================================================ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { prisma } from "@dub/prisma"; import { BountySubmissionStatus } from "@dub/prisma/client"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; const bountiesSubmissionsCountQuerySchema = z.object({ bountyId: z.string().optional(), groupId: z.string().optional(), partnerId: z.string().optional(), }); const statuses = Object.values(BountySubmissionStatus); // GET /api/bounties/count/submissions – get the total bounty submissions count by status (potentially filtered by bountyId) export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { bountyId, groupId, partnerId } = bountiesSubmissionsCountQuerySchema.parse(searchParams); const count = await prisma.bountySubmission.groupBy({ by: ["status"], where: { programId, bountyId, ...(groupId && { programEnrollment: { groupId, }, }), ...(partnerId && { partnerId, }), }, _count: true, }); const counts = count.map((c) => ({ status: c.status, count: c._count, })); statuses.forEach((status) => { if (!counts.some((c) => c.status === status)) { counts.push({ status, count: 0, }); } }); return NextResponse.json(counts); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], }, ); ================================================ FILE: apps/web/app/(ee)/api/bounties/route.ts ================================================ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { createId } from "@/lib/api/create-id"; import { DubApiError } from "@/lib/api/errors"; import { throwIfInvalidGroupIds } from "@/lib/api/groups/throw-if-invalid-group-ids"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { generatePerformanceBountyName } from "@/lib/bounty/api/generate-performance-bounty-name"; import { validateBounty } from "@/lib/bounty/api/validate-bounty"; import { qstash } from "@/lib/cron"; import { getPlanCapabilities } from "@/lib/plan-capabilities"; import { WorkflowAction } from "@/lib/types"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { BountyListSchema, BountySchema, createBountySchema, getBountiesQuerySchema, } from "@/lib/zod/schemas/bounties"; import { WORKFLOW_ACTION_TYPES, WORKFLOW_ATTRIBUTE_TRIGGER, } from "@/lib/zod/schemas/workflows"; import { prisma } from "@dub/prisma"; import { Workflow } from "@dub/prisma/client"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // GET /api/bounties - get all bounties for a program export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { partnerId, includeSubmissionsCount } = getBountiesQuerySchema.parse(searchParams); const programEnrollment = partnerId ? await getProgramEnrollmentOrThrow({ partnerId, programId, include: { program: true, }, }) : null; const [bounties, allBountiesSubmissionsCount] = await Promise.all([ prisma.bounty.findMany({ where: { programId, // Filter only bounties the specified partner is eligible for ...(programEnrollment && { AND: [ // Filter out expired bounties { OR: [{ endsAt: null }, { endsAt: { gt: new Date() } }], }, // Filter by partner's group eligibility { OR: [ { groups: { none: {}, }, }, { groups: { some: { groupId: programEnrollment.groupId || programEnrollment.program.defaultGroupId, }, }, }, ], }, ], }), }, include: { groups: { select: { groupId: true, }, }, }, }), includeSubmissionsCount ? prisma.bountySubmission.groupBy({ by: ["bountyId", "status"], where: { programId, status: { in: ["submitted", "approved"], }, }, _count: { status: true, }, }) : null, ]); const aggregateSubmissionsCountForBounty = (bountyId: string) => { if (!allBountiesSubmissionsCount) { return null; } const bountySubmissions = allBountiesSubmissionsCount.filter( (s) => s.bountyId === bountyId, ); const total = bountySubmissions.reduce( (sum, s) => sum + s._count.status, 0, ); const submitted = bountySubmissions.find((s) => s.status === "submitted")?._count .status ?? 0; const approved = bountySubmissions.find((s) => s.status === "approved")?._count.status ?? 0; return { total, submitted, approved, }; }; const data = bounties.map((bounty) => { return BountyListSchema.parse({ ...bounty, groups: bounty.groups.map(({ groupId }) => ({ id: groupId })), ...(allBountiesSubmissionsCount && { submissionsCountData: aggregateSubmissionsCountForBounty(bounty.id), }), }); }); return NextResponse.json(data); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], }, ); // POST /api/bounties - create a bounty export const POST = withWorkspace( async ({ workspace, req, session }) => { const programId = getDefaultProgramIdOrThrow(workspace); const parsedBody = createBountySchema.parse(await parseRequestBody(req)); let { name, description, type, rewardAmount, rewardDescription, startsAt, endsAt, submissionsOpenAt, submissionFrequency, maxSubmissions, submissionRequirements, groupIds, performanceCondition, performanceScope, sendNotificationEmails, } = parsedBody; // Use current date as default if startsAt is not provided startsAt = startsAt || new Date(); validateBounty(parsedBody); const { canUseBountySocialMetrics, canSendEmailCampaigns } = getPlanCapabilities(workspace.plan); if (submissionRequirements?.socialMetrics && !canUseBountySocialMetrics) { throw new DubApiError({ code: "forbidden", message: "Social metrics criteria require Advanced plan or above.", }); } const partnerGroups = await throwIfInvalidGroupIds({ programId, groupIds, }); // Bounty name let bountyName = name; if (type === "performance" && performanceCondition) { bountyName = generatePerformanceBountyName({ rewardAmount: rewardAmount ?? 0, // this shouldn't happen since we return early if rewardAmount is null condition: performanceCondition, }); } if (!bountyName) { throw new DubApiError({ code: "bad_request", message: "Bounty name is required.", }); } const bounty = await prisma.$transaction(async (tx) => { let workflow: Workflow | null = null; const bountyId = createId({ prefix: "bnty_" }); // Create a workflow if there is a performance condition if (performanceCondition && type === "performance") { const action: WorkflowAction = { type: WORKFLOW_ACTION_TYPES.AwardBounty, data: { bountyId, }, }; workflow = await tx.workflow.create({ data: { id: createId({ prefix: "wf_" }), programId, trigger: WORKFLOW_ATTRIBUTE_TRIGGER[performanceCondition.attribute], triggerConditions: [performanceCondition], actions: [action], }, }); } // Create a bounty return await tx.bounty.create({ data: { id: bountyId, programId, workflowId: workflow?.id, name: bountyName, description, type, startsAt, endsAt, submissionsOpenAt: type === "submission" ? submissionsOpenAt : null, submissionFrequency: type === "submission" ? submissionFrequency : null, maxSubmissions: type === "submission" ? maxSubmissions ?? 1 : 1, rewardAmount, rewardDescription, performanceScope: type === "performance" ? performanceScope : null, ...(submissionRequirements && type === "submission" && { submissionRequirements, }), ...(partnerGroups.length && { groups: { createMany: { data: partnerGroups.map(({ id }) => ({ groupId: id, })), }, }, }), }, include: { workflow: true, groups: true, }, }); }); const createdBounty = BountySchema.parse({ ...bounty, groups: bounty.groups.map(({ groupId }) => ({ id: groupId })), performanceCondition: bounty.workflow?.triggerConditions?.[0], }); const shouldScheduleDraftSubmissions = bounty.type === "performance" && bounty.performanceScope === "lifetime"; waitUntil( Promise.allSettled([ recordAuditLog({ workspaceId: workspace.id, programId, action: "bounty.created", description: `Bounty ${bounty.id} created`, actor: session?.user, targets: [ { type: "bounty", id: bounty.id, metadata: createdBounty, }, ], }), sendWorkspaceWebhook({ workspace, trigger: "bounty.created", data: createdBounty, }), sendNotificationEmails && canSendEmailCampaigns && qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/notify-partners`, body: { bountyId: bounty.id, }, notBefore: Math.floor(bounty.startsAt.getTime() / 1000), }), shouldScheduleDraftSubmissions && qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/create-draft-submissions`, body: { bountyId: bounty.id, }, notBefore: Math.floor(bounty.startsAt.getTime() / 1000), }), ]), ); return NextResponse.json(createdBounty); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], requiredRoles: ["owner", "member"], }, ); ================================================ FILE: apps/web/app/(ee)/api/campaigns/[campaignId]/duplicate/route.ts ================================================ import { DEFAULT_CAMPAIGN_BODY } from "@/lib/api/campaigns/constants"; import { getCampaignOrThrow } from "@/lib/api/campaigns/get-campaign-or-throw"; import { createId } from "@/lib/api/create-id"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseWorkflowConfig } from "@/lib/api/workflows/parse-workflow-config"; import { withWorkspace } from "@/lib/auth"; import { prisma } from "@dub/prisma"; import { CampaignStatus } from "@dub/prisma/client"; import { NextResponse } from "next/server"; // POST /api/campaigns/[campaignId]/duplicate - duplicate an existing campaign export const POST = withWorkspace( async ({ workspace, session, params }) => { const { campaignId } = params; const programId = getDefaultProgramIdOrThrow(workspace); const campaign = await getCampaignOrThrow({ programId, campaignId, includeWorkflow: true, includeGroups: true, }); const duplicatedCampaign = await prisma.$transaction(async (tx) => { let workflowId: string | null = null; const campaignId = createId({ prefix: "cmp_" }); if (campaign.workflow) { const { action, condition } = parseWorkflowConfig(campaign.workflow); const newWorkflow = await tx.workflow.create({ data: { id: createId({ prefix: "wf_" }), programId, name: campaign.name, trigger: campaign.workflow.trigger, triggerConditions: [condition], actions: [ { ...action, data: { ...action.data, campaignId, }, }, ], disabledAt: new Date(), }, }); workflowId = newWorkflow.id; } return await tx.campaign.create({ data: { id: campaignId, programId, workflowId, userId: session.user.id, status: CampaignStatus.draft, from: campaign.from, name: `${campaign.name} (copy)`, subject: campaign.subject, bodyJson: campaign.bodyJson ?? DEFAULT_CAMPAIGN_BODY, type: campaign.type, groups: { createMany: { data: campaign.groups.map(({ groupId }) => ({ groupId, })), }, }, }, }); }); return NextResponse.json({ id: duplicatedCampaign.id }); }, { requiredPlan: ["advanced", "enterprise"], requiredRoles: ["owner", "member"], }, ); ================================================ FILE: apps/web/app/(ee)/api/campaigns/[campaignId]/events/count/route.ts ================================================ import { getCampaignOrThrow } from "@/lib/api/campaigns/get-campaign-or-throw"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { getCampaignEventsCountQuerySchema } from "@/lib/zod/schemas/campaigns"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/campaigns/[campaignId]/events/count export const GET = withWorkspace( async ({ workspace, params, searchParams }) => { const { campaignId } = params; const programId = getDefaultProgramIdOrThrow(workspace); await getCampaignOrThrow({ programId, campaignId, }); const { status, search } = getCampaignEventsCountQuerySchema.parse(searchParams); const count = await prisma.notificationEmail.count({ where: { campaignId, ...(status === "delivered" && { deliveredAt: { not: null } }), ...(status === "opened" && { openedAt: { not: null } }), ...(status === "bounced" && { bouncedAt: { not: null } }), ...(search && { OR: [ { partner: { name: { contains: search } } }, { partner: { email: { contains: search } } }, ], }), }, }); return NextResponse.json(count); }, { requiredPlan: ["advanced", "enterprise"], }, ); ================================================ FILE: apps/web/app/(ee)/api/campaigns/[campaignId]/events/route.ts ================================================ import { getCampaignEvents } from "@/lib/api/campaigns/get-campaign-events"; import { getCampaignOrThrow } from "@/lib/api/campaigns/get-campaign-or-throw"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { getCampaignsEventsQuerySchema } from "@/lib/zod/schemas/campaigns"; import { NextResponse } from "next/server"; // GET /api/campaigns/[campaignId]/events export const GET = withWorkspace( async ({ workspace, params, searchParams }) => { const { campaignId } = params; const programId = getDefaultProgramIdOrThrow(workspace); await getCampaignOrThrow({ programId, campaignId, }); const events = await getCampaignEvents({ ...getCampaignsEventsQuerySchema.parse(searchParams), campaignId, }); return NextResponse.json(events); }, { requiredPlan: ["advanced", "enterprise"], }, ); ================================================ FILE: apps/web/app/(ee)/api/campaigns/[campaignId]/preview/route.ts ================================================ import { getCampaignOrThrow } from "@/lib/api/campaigns/get-campaign-or-throw"; import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { renderCampaignEmailHTML } from "@/lib/api/workflows/render-campaign-email-html"; import { withWorkspace } from "@/lib/auth"; import { TiptapNode } from "@/lib/types"; import { CampaignSchema } from "@/lib/zod/schemas/campaigns"; import { sendBatchEmail } from "@dub/email"; import CampaignEmail from "@dub/email/templates/campaign-email"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; const sendPreviewEmailSchema = CampaignSchema.pick({ subject: true, preview: true, bodyJson: true, }).extend({ from: z.email().optional(), emailAddresses: z .array(z.email()) .min(1) .max(10, "Maximum 10 email addresses allowed."), }); // POST /api/campaigns/[campaignId]/preview - send preview email for a campaign export const POST = withWorkspace( async ({ workspace, params, req }) => { const { campaignId } = params; const { subject, preview, from, bodyJson, emailAddresses } = sendPreviewEmailSchema.parse(await parseRequestBody(req)); const programId = getDefaultProgramIdOrThrow(workspace); const [program, campaign] = await Promise.all([ getProgramOrThrow({ programId, workspaceId: workspace.id, include: { emailDomains: { where: { status: "verified", }, }, }, }), getCampaignOrThrow({ programId, campaignId, }), ]); // check if from email is a valid email domain if ( from && !program.emailDomains.some( ({ slug: emailDomain }) => from.split("@")[1] === emailDomain, ) ) { throw new DubApiError({ code: "bad_request", message: "Invalid `from` email address.", }); } const { data, error } = await sendBatchEmail( emailAddresses.map((email) => ({ variant: campaign.type === "marketing" ? "marketing" : "notifications", to: email, ...(from && { from: `${program.name} <${from}>` }), ...(program.supportEmail ? { replyTo: program.supportEmail } : {}), subject: `[TEST] ${subject}`, react: CampaignEmail({ program: { name: program.name, slug: program.slug, logo: program.logo, messagingEnabledAt: program.messagingEnabledAt, }, campaign: { type: campaign.type, preview, body: renderCampaignEmailHTML({ content: bodyJson as unknown as TiptapNode, variables: { PartnerName: "Partner", PartnerEmail: "partner@acme.com", PartnerLink: "https://acme.com/partner", }, }), }, }), })), ); console.log("Resend response:", data); if (error) { throw new DubApiError({ code: "bad_request", message: error.message, }); } return NextResponse.json({ success: true }); }, { requiredPlan: ["advanced", "enterprise"], requiredRoles: ["owner", "member"], }, ); ================================================ FILE: apps/web/app/(ee)/api/campaigns/[campaignId]/route.ts ================================================ import { getCampaignOrThrow } from "@/lib/api/campaigns/get-campaign-or-throw"; import { scheduleMarketingCampaign, scheduleTransactionalCampaign, } from "@/lib/api/campaigns/schedule-campaigns"; import { validateCampaign } from "@/lib/api/campaigns/validate-campaign"; import { throwIfInvalidGroupIds } from "@/lib/api/groups/throw-if-invalid-group-ids"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { parseWorkflowConfig } from "@/lib/api/workflows/parse-workflow-config"; import { withWorkspace } from "@/lib/auth"; import { qstash } from "@/lib/cron"; import { CampaignSchema, updateCampaignSchema, } from "@/lib/zod/schemas/campaigns"; import { WORKFLOW_ATTRIBUTE_TRIGGER } from "@/lib/zod/schemas/workflows"; import { prisma } from "@dub/prisma"; import { PartnerGroup } from "@dub/prisma/client"; import { arrayEqual } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // GET /api/campaigns/[campaignId] - get an email campaign export const GET = withWorkspace( async ({ workspace, params }) => { const { campaignId } = params; const programId = getDefaultProgramIdOrThrow(workspace); const campaign = await getCampaignOrThrow({ programId, campaignId, includeWorkflow: true, includeGroups: true, }); const parsedCampaign = CampaignSchema.parse({ ...campaign, groups: campaign.groups.map(({ groupId }) => ({ id: groupId })), triggerCondition: campaign.workflow?.triggerConditions?.[0], }); return NextResponse.json(parsedCampaign); }, { requiredPlan: ["advanced", "enterprise"], requiredRoles: ["owner", "member"], }, ); // PATCH /api/campaigns/[campaignId] - update an email campaign export const PATCH = withWorkspace( async ({ workspace, params, req }) => { const { campaignId } = params; const programId = getDefaultProgramIdOrThrow(workspace); const campaign = await getCampaignOrThrow({ programId, campaignId, includeWorkflow: true, includeGroups: true, }); const { name, subject, preview, from, status, bodyJson, groupIds, triggerCondition, scheduledAt, } = await validateCampaign({ input: updateCampaignSchema.parse(await parseRequestBody(req)), campaign, }); // if groupIds is provided and is different from the current groupIds, update the groups let updatedPartnerGroups: PartnerGroup[] | undefined = undefined; let shouldUpdateGroups = false; if (groupIds !== undefined) { const currentGroupIds = campaign.groups.map(({ groupId }) => groupId); const newGroupIds = groupIds || []; // treat null as empty array (all groups) if (!arrayEqual(currentGroupIds, newGroupIds)) { if (newGroupIds.length > 0) { updatedPartnerGroups = await throwIfInvalidGroupIds({ programId, groupIds: newGroupIds, }); } shouldUpdateGroups = true; } } const updatedCampaign = await prisma.$transaction(async (tx) => { if (campaign.workflowId) { await tx.workflow.update({ where: { id: campaign.workflowId, }, data: { ...(triggerCondition && { triggerConditions: [triggerCondition], trigger: WORKFLOW_ATTRIBUTE_TRIGGER[triggerCondition.attribute], }), ...(status && { disabledAt: status === "paused" ? new Date() : null, }), }, }); } return await tx.campaign.update({ where: { id: campaignId, programId, }, data: { ...(name && { name }), ...(subject && { subject }), ...(preview !== undefined && { preview }), ...(from && { from }), ...(status && { status }), ...(bodyJson && { bodyJson }), ...(scheduledAt !== undefined && { scheduledAt }), ...(shouldUpdateGroups && { groups: { deleteMany: {}, ...(updatedPartnerGroups && updatedPartnerGroups.length > 0 && { create: updatedPartnerGroups.map((group) => ({ groupId: group.id, })), }), }, }), }, include: { groups: true, workflow: true, }, }); }); waitUntil( (async () => { if (updatedCampaign.type === "marketing") { await scheduleMarketingCampaign({ campaign, updatedCampaign, }); } else if (updatedCampaign.type === "transactional") { await scheduleTransactionalCampaign({ campaign, updatedCampaign, }); } })(), ); const response = CampaignSchema.parse({ ...updatedCampaign, groups: updatedCampaign.groups.map(({ groupId }) => ({ id: groupId })), triggerCondition: updatedCampaign.workflow?.triggerConditions?.[0], }); return NextResponse.json(response); }, { requiredPlan: ["advanced", "enterprise"], requiredRoles: ["owner", "member"], }, ); // DELETE /api/campaigns/[campaignId] - delete a campaign export const DELETE = withWorkspace( async ({ workspace, params }) => { const { campaignId } = params; const programId = getDefaultProgramIdOrThrow(workspace); const campaign = await getCampaignOrThrow({ programId, campaignId, includeWorkflow: true, }); await prisma.$transaction(async (tx) => { await tx.campaign.delete({ where: { id: campaignId, }, }); if (campaign.workflowId) { await tx.workflow.delete({ where: { id: campaign.workflowId, }, }); } }); waitUntil( (async () => { if (campaign.type === "marketing" && campaign.qstashMessageId) { await qstash.messages.delete(campaign.qstashMessageId); } else if (campaign.type === "transactional" && campaign.workflow) { const { condition } = parseWorkflowConfig(campaign.workflow); if (condition.attribute === "partnerJoined") { return; } await qstash.schedules.delete(campaign.workflow.id); } })(), ); return NextResponse.json({ id: campaignId }); }, { requiredPlan: ["advanced", "enterprise"], requiredRoles: ["owner", "member"], }, ); ================================================ FILE: apps/web/app/(ee)/api/campaigns/[campaignId]/summary/route.ts ================================================ import { getCampaignOrThrow } from "@/lib/api/campaigns/get-campaign-or-throw"; import { getCampaignSummary } from "@/lib/api/campaigns/get-campaign-summary"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { NextResponse } from "next/server"; // GET /api/campaigns/[campaignId]/summary export const GET = withWorkspace( async ({ workspace, params }) => { const { campaignId } = params; const programId = getDefaultProgramIdOrThrow(workspace); await getCampaignOrThrow({ programId, campaignId, }); const metrics = await getCampaignSummary(campaignId); return NextResponse.json(metrics); }, { requiredPlan: ["advanced", "enterprise"], }, ); ================================================ FILE: apps/web/app/(ee)/api/campaigns/count/route.ts ================================================ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { getCampaignsCountQuerySchema } from "@/lib/zod/schemas/campaigns"; import { prisma } from "@dub/prisma"; import { CampaignStatus, CampaignType, Prisma } from "@dub/prisma/client"; import { NextResponse } from "next/server"; // GET /api/campaigns/count - get the count of campaigns for a program export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { type, status, groupBy, search } = getCampaignsCountQuerySchema.parse(searchParams); const commonWhere: Prisma.CampaignWhereInput = { programId, type, status, ...(search && { OR: [{ name: { contains: search } }, { subject: { contains: search } }], }), }; // Group by the type of campaign if (groupBy === "type") { const campaigns = await prisma.campaign.groupBy({ by: ["type"], where: { ...commonWhere, }, _count: true, orderBy: { _count: { type: "desc", }, }, }); Object.values(CampaignType).forEach((type) => { if (!campaigns.some((c) => c.type === type)) { campaigns.push({ _count: 0, type }); } }); return NextResponse.json(campaigns); } // Group by the status of campaign if (groupBy === "status") { const campaigns = await prisma.campaign.groupBy({ by: ["status"], where: { ...commonWhere, }, _count: true, orderBy: { _count: { status: "desc", }, }, }); Object.values(CampaignStatus).forEach((status) => { if (!campaigns.some((c) => c.status === status)) { campaigns.push({ _count: 0, status }); } }); return NextResponse.json(campaigns); } // Get the absolute count of campaigns const count = await prisma.campaign.count({ where: { ...commonWhere, }, }); return NextResponse.json(count); }, { requiredPlan: ["advanced", "enterprise"], }, ); ================================================ FILE: apps/web/app/(ee)/api/campaigns/route.ts ================================================ import { DEFAULT_CAMPAIGN_BODY } from "@/lib/api/campaigns/constants"; import { createId } from "@/lib/api/create-id"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { WorkflowAction, WorkflowCondition } from "@/lib/types"; import { CampaignSchema, createCampaignSchema, getCampaignsQuerySchema, } from "@/lib/zod/schemas/campaigns"; import { WORKFLOW_ACTION_TYPES, WORKFLOW_ATTRIBUTE_TRIGGER, } from "@/lib/zod/schemas/workflows"; import { prisma } from "@dub/prisma"; import { CampaignStatus } from "@dub/prisma/client"; import { NextResponse } from "next/server"; // GET /api/campaigns - get all email campaigns for a program export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { type, status, search, triggerCondition, page = 1, pageSize, } = getCampaignsQuerySchema.parse(searchParams); const campaigns = await prisma.campaign.findMany({ where: { programId, type, status, ...(search && { OR: [ { name: { contains: search } }, { subject: { contains: search } }, ], }), ...(triggerCondition && { workflow: { triggerConditions: { equals: [triggerCondition], }, }, }), }, include: { groups: true, workflow: true, }, orderBy: { createdAt: "desc", }, skip: (page - 1) * pageSize, take: pageSize, }); return NextResponse.json( campaigns.map((campaign) => CampaignSchema.parse({ ...campaign, groups: campaign.groups.map(({ groupId }) => ({ id: groupId })), triggerCondition: campaign.workflow?.triggerConditions?.[0], }), ), ); }, { requiredPlan: ["advanced", "enterprise"], }, ); // POST /api/campaigns - create a draft email campaign export const POST = withWorkspace( async ({ workspace, session, req }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { type } = createCampaignSchema.parse(await parseRequestBody(req)); const campaign = await prisma.$transaction(async (tx) => { const campaignId = createId({ prefix: "cmp_" }); const workflowId = createId({ prefix: "wf_" }); const campaign = await tx.campaign.create({ data: { id: campaignId, programId, userId: session.user.id, status: CampaignStatus.draft, name: "Untitled", subject: "", bodyJson: DEFAULT_CAMPAIGN_BODY, type, ...(type === "transactional" && { workflowId }), }, }); if (type === "transactional") { const trigger = WORKFLOW_ATTRIBUTE_TRIGGER["partnerJoined"]; const triggerCondition: WorkflowCondition = { attribute: "partnerJoined", operator: "gte", value: 0, }; const action: WorkflowAction = { type: WORKFLOW_ACTION_TYPES.SendCampaign, data: { campaignId, }, }; await tx.workflow.create({ data: { id: workflowId, programId, trigger, triggerConditions: [triggerCondition], actions: [action], disabledAt: new Date(), // TODO: Replace this with publishedAt }, }); } return campaign; }); return NextResponse.json( { id: campaign.id, }, { status: 201 }, ); }, { requiredPlan: ["advanced", "enterprise"], requiredRoles: ["owner", "member"], }, ); ================================================ FILE: apps/web/app/(ee)/api/commissions/[commissionId]/route.ts ================================================ import { convertCurrency } from "@/lib/analytics/convert-currency"; import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { transformCustomerForCommission } from "@/lib/api/customers/transform-customer"; import { DubApiError } from "@/lib/api/errors"; import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { calculateSaleEarnings } from "@/lib/api/sales/calculate-sale-earnings"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { determinePartnerReward } from "@/lib/partners/determine-partner-reward"; import { CommissionDetailSchema, CommissionEnrichedSchema, updateCommissionSchema, } from "@/lib/zod/schemas/commissions"; import { prisma } from "@dub/prisma"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // GET /api/commissions/:commissionId - get a single commission by ID export const GET = withWorkspace(async ({ workspace, params }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { commissionId } = params; const commission = await prisma.commission.findUnique({ where: { id: commissionId, programId, }, include: { partner: true, programEnrollment: { select: { partnerGroup: { select: { id: true, holdingPeriodDays: true, }, }, }, }, customer: true, reward: { select: { description: true, type: true, event: true, amountInCents: true, amountInPercentage: true, }, }, user: true, payout: { select: { id: true, paidAt: true, initiatedAt: true, user: true, }, }, }, }); if (!commission) { throw new DubApiError({ code: "not_found", message: `Commission ${commissionId} not found.`, }); } const { partner, programEnrollment, customer, reward, ...rest } = commission; return NextResponse.json( CommissionDetailSchema.parse({ ...rest, partner: { ...partner, groupId: programEnrollment.partnerGroup?.id ?? null, }, customer: transformCustomerForCommission(customer), reward: reward ? { ...reward, amountInPercentage: reward.amountInPercentage ? Number(reward.amountInPercentage) : null, } : null, holdingPeriodDays: rest.type === "custom" ? 0 : programEnrollment.partnerGroup?.holdingPeriodDays ?? 0, }), ); }); // PATCH /api/commissions/:commissionId - update a commission export const PATCH = withWorkspace( async ({ workspace, params, req, session }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { commissionId } = params; const commission = await prisma.commission.findUnique({ where: { id: commissionId, programId, }, include: { partner: true, }, }); if (!commission) { throw new DubApiError({ code: "not_found", message: `Commission ${commissionId} not found.`, }); } if (commission.status === "paid") { throw new DubApiError({ code: "bad_request", message: `Cannot update amount: Commission ${commissionId} has already been paid.`, }); } const { partner, amount: originalAmount } = commission; let { amount, modifyAmount, currency, status } = updateCommissionSchema.parse(await parseRequestBody(req)); let finalAmount: number | undefined; let finalEarnings: number | undefined; if (amount || modifyAmount) { if (commission.type !== "sale") { throw new DubApiError({ code: "bad_request", message: `Cannot update amount: Commission ${commissionId} is not a sale commission.`, }); } // if currency is not USD, convert it to USD based on the current FX rate // TODO: allow custom "defaultCurrency" on workspace table in the future if (currency !== "usd") { const valueToConvert = modifyAmount || amount; if (valueToConvert) { const { currency: convertedCurrency, amount: convertedAmount } = await convertCurrency({ currency, amount: valueToConvert }); if (modifyAmount) { modifyAmount = convertedAmount; } else { amount = convertedAmount; } currency = convertedCurrency; } } finalAmount = Math.max( modifyAmount ? originalAmount + modifyAmount : amount ?? originalAmount, 0, // Ensure the amount is not negative ); const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId, include: { partner: true, links: true, saleReward: true, }, }); const reward = determinePartnerReward({ event: "sale", programEnrollment, }); if (!reward) { throw new DubApiError({ code: "not_found", message: `No reward found for partner ${partner.id} in program ${programId}.`, }); } // Recalculate the earnings based on the new amount finalEarnings = calculateSaleEarnings({ reward, sale: { amount: finalAmount, quantity: commission.quantity, }, }); } const isRefunded = finalAmount === 0 || finalEarnings === 0; const updatedCommission = await prisma.commission.update({ where: { id: commission.id, }, data: { // if the sale/commission is fully refunded, we don't need to update the amount or earnings // we just update status to refunded and exclude it from the payout // same goes for updating status to refunded, duplicate, canceled, or fraudulent amount: isRefunded ? undefined : finalAmount, earnings: isRefunded ? undefined : finalEarnings, status: status ?? (isRefunded ? "refunded" : undefined), ...(status || isRefunded ? { payoutId: null } : {}), }, include: { customer: true, partner: true, }, }); // If the commission has already been added to a payout, we need to update the payout amount if (commission.status === "processed" && commission.payoutId) { waitUntil( prisma.$transaction(async (tx) => { const commissionAggregate = await tx.commission.aggregate({ where: { payoutId: commission.payoutId, }, _sum: { earnings: true, }, }); const newPayoutAmount = commissionAggregate._sum.earnings ?? 0; if (newPayoutAmount === 0) { console.log(`Deleting payout ${commission.payoutId}`); await tx.payout.delete({ where: { id: commission.payoutId! } }); } else { console.log( `Updating payout ${commission.payoutId} to ${newPayoutAmount}`, ); await tx.payout.update({ where: { id: commission.payoutId! }, data: { amount: newPayoutAmount }, }); } }), ); } waitUntil( Promise.allSettled([ syncTotalCommissions({ partnerId: commission.partnerId, programId: commission.programId, }), recordAuditLog({ workspaceId: workspace.id, programId, action: "commission.updated", description: `Commission ${commissionId} updated`, actor: session.user, targets: [ { type: "commission", id: commission.id, metadata: updatedCommission, }, ], }), ]), ); return NextResponse.json( CommissionEnrichedSchema.parse({ ...updatedCommission, customer: transformCustomerForCommission(updatedCommission.customer), }), ); }, { requiredRoles: ["owner", "member"], }, ); ================================================ FILE: apps/web/app/(ee)/api/commissions/count/route.ts ================================================ import { getCommissionsCount } from "@/lib/api/commissions/get-commissions-count"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { getCommissionsCountQuerySchema } from "@/lib/zod/schemas/commissions"; import { NextResponse } from "next/server"; // GET /api/commissions/count export const GET = withWorkspace(async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const isHoldStatus = searchParams.status === "hold"; const { status: _status, ...restSearchParams } = searchParams; const parsedParams = getCommissionsCountQuerySchema.parse( isHoldStatus ? restSearchParams : searchParams, ); const counts = await getCommissionsCount({ ...parsedParams, programId, isHoldStatus, }); return NextResponse.json(counts); }); ================================================ FILE: apps/web/app/(ee)/api/commissions/export/route.ts ================================================ import { convertToCSV } from "@/lib/analytics/utils"; import { formatCommissionsForExport } from "@/lib/api/commissions/format-commissions-for-export"; import { getCommissions } from "@/lib/api/commissions/get-commissions"; import { getCommissionsCount } from "@/lib/api/commissions/get-commissions-count"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { qstash } from "@/lib/cron"; import { commissionsExportQuerySchema } from "@/lib/zod/schemas/commissions"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { NextResponse } from "next/server"; const MAX_COMMISSIONS_TO_EXPORT = 1000; // GET /api/commissions/export – export commissions to CSV (with async support if >1000 commissions) export const GET = withWorkspace( async ({ searchParams, workspace, session }) => { const programId = getDefaultProgramIdOrThrow(workspace); const parsedParams = commissionsExportQuerySchema.parse(searchParams); let { columns, ...filters } = parsedParams; // Get the count of commissions to decide if we should process async const counts = await getCommissionsCount({ ...filters, programId, }); // Process the export in the background if the number of commissions is greater than MAX_COMMISSIONS_TO_EXPORT if (counts.all.count > MAX_COMMISSIONS_TO_EXPORT) { await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/export/commissions`, body: { ...parsedParams, columns: columns.join(","), programId, userId: session.user.id, }, }); return NextResponse.json({}, { status: 202 }); } // Find commissions that match the filters const commissions = await getCommissions({ ...filters, programId, page: 1, pageSize: MAX_COMMISSIONS_TO_EXPORT, }); const formattedCommissions = formatCommissionsForExport( commissions, columns, ); return new Response(convertToCSV(formattedCommissions), { headers: { "Content-Type": "text/csv", "Content-Disposition": "attachment", }, }); }, ); ================================================ FILE: apps/web/app/(ee)/api/commissions/route.ts ================================================ import { getCommissions } from "@/lib/api/commissions/get-commissions"; import { transformCustomerForCommission } from "@/lib/api/customers/transform-customer"; import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { CommissionEnrichedSchema, getCommissionsQuerySchema, } from "@/lib/zod/schemas/commissions"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/commissions - get all commissions for a program export const GET = withWorkspace(async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const isHoldStatus = searchParams.status === "hold"; const { status: _status, ...restSearchParams } = searchParams; let { partnerId, tenantId, ...filters } = getCommissionsQuerySchema.parse( isHoldStatus ? restSearchParams : searchParams, ); if (tenantId && !partnerId) { const partner = await prisma.programEnrollment.findUnique({ where: { tenantId_programId: { tenantId, programId, }, }, select: { partnerId: true, }, }); if (!partner) { throw new DubApiError({ code: "not_found", message: `Partner with specified tenantId ${tenantId} not found.`, }); } partnerId = partner.partnerId; } const commissions = await getCommissions({ ...filters, partnerId, programId, isHoldStatus, }); return NextResponse.json( z.array(CommissionEnrichedSchema).parse( commissions.map((c) => ({ ...c, customer: transformCustomerForCommission(c.customer), partner: { ...c.partner, groupId: c.programEnrollment.groupId, }, })), ), ); }); ================================================ FILE: apps/web/app/(ee)/api/commissions/timeseries/route.ts ================================================ import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { withWorkspace } from "@/lib/auth"; import { sqlGranularityMap } from "@/lib/planetscale/granularity"; import { analyticsQuerySchema } from "@/lib/zod/schemas/analytics"; import { prisma } from "@dub/prisma"; import { format } from "date-fns"; import { NextResponse } from "next/server"; const querySchema = analyticsQuerySchema.pick({ start: true, end: true, interval: true, timezone: true, }); interface Commission { start: string; earnings: number; } // GET /api/commissions/timeseries - get commissions timeseries for a program export const GET = withWorkspace(async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { start, end, interval, timezone } = querySchema.parse(searchParams); const { startDate, endDate, granularity } = getStartEndDates({ interval, start, end, dataAvailableFrom: // ideally we should get the first commission event date for dataAvailableFrom interval === "all" ? await getProgramOrThrow({ workspaceId: workspace.id, programId, }).then((program) => program.startedAt ?? program.createdAt) : undefined, timezone, }); const { dateFormat, dateIncrement, startFunction, formatString } = sqlGranularityMap[granularity]; console.time("getCommissionsTimeseries"); const commissions = await prisma.$queryRaw` SELECT DATE_FORMAT(CONVERT_TZ(createdAt, "UTC", ${timezone || "UTC"}), ${dateFormat}) AS start, SUM(earnings) AS earnings FROM Commission WHERE programId = ${programId} AND createdAt >= ${startDate} AND createdAt < ${endDate} AND status IN ("pending", "processed", "paid") GROUP BY start ORDER BY start ASC;`; console.timeEnd("getCommissionsTimeseries"); let currentDate = startFunction(startDate); const earningsLookup = Object.fromEntries( commissions.map((item) => [ item.start, { earnings: Number(item.earnings), }, ]), ); const timeseries: Commission[] = []; while (currentDate < endDate) { const periodKey = format(currentDate, formatString); timeseries.push({ start: currentDate.toISOString(), ...(earningsLookup[periodKey] || { earnings: 0, }), }); currentDate = dateIncrement(currentDate); } return NextResponse.json(timeseries); }); ================================================ FILE: apps/web/app/(ee)/api/cron/aggregate-clicks/resolve-click-reward-amount.ts ================================================ import { serializeReward } from "@/lib/api/partners/serialize-reward"; import { evaluateRewardConditions } from "@/lib/partners/evaluate-reward-conditions"; import { getRewardAmount } from "@/lib/partners/get-reward-amount"; import { rewardConditionsArraySchema } from "@/lib/zod/schemas/rewards"; import { Reward } from "@dub/prisma/client"; // Resolve the click reward amount for a given reward and country export function resolveClickRewardAmount({ reward, country, }: { reward: Reward; country: string; }): number { let partnerReward = reward; if (reward.modifiers) { const modifiers = rewardConditionsArraySchema.safeParse(reward.modifiers); if (modifiers.success) { const matchedCondition = evaluateRewardConditions({ conditions: modifiers.data, context: { customer: { country, source: "tracked", }, }, }); if (matchedCondition) { partnerReward = { ...partnerReward, amountInCents: matchedCondition.amountInCents != null ? matchedCondition.amountInCents : null, }; } } } return getRewardAmount(serializeReward(partnerReward)); } ================================================ FILE: apps/web/app/(ee)/api/cron/aggregate-clicks/route.ts ================================================ import { createId } from "@/lib/api/create-id"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { verifyVercelSignature } from "@/lib/cron/verify-vercel"; import { getTopLinksByCountries } from "@/lib/tinybird/get-top-links-by-countries"; import { prisma } from "@dub/prisma"; import { CommissionType, Prisma } from "@dub/prisma/client"; import { APP_DOMAIN_WITH_NGROK, currencyFormatter, getPrettyUrl, nFormatter, } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../utils"; import { resolveClickRewardAmount } from "./resolve-click-reward-amount"; export const dynamic = "force-dynamic"; const BATCH_SIZE = 200; const schema = z.object({ startingAfter: z.string().optional(), batchNumber: z.number().optional().default(1), }); // This route is used aggregate clicks events on daily basis for Program links and add to the Commission table // Runs every day at 00:00 (0 0 * * *) // GET /api/cron/aggregate-clicks async function handler(req: Request) { try { let { startingAfter, batchNumber } = schema.parse({ startingAfter: undefined, batchNumber: 1, }); if (req.method === "GET") { await verifyVercelSignature(req); } else if (req.method === "POST") { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); ({ startingAfter, batchNumber } = schema.parse(JSON.parse(rawBody))); } const now = new Date(); // set 'start' to the beginning of the previous day (00:00:00) const start = new Date(now); start.setDate(start.getDate() - 1); start.setHours(0, 0, 0, 0); // set 'end' to the end of the previous day (23:59:59) const end = new Date(now); end.setDate(end.getDate() - 1); end.setHours(23, 59, 59, 999); const linksWithClickRewards = await prisma.link.findMany({ where: { programEnrollment: { clickRewardId: { not: null, }, }, clicks: { gt: 0, }, lastClicked: { gte: start, // links that were clicked on after the start date }, }, select: { id: true, shortLink: true, programId: true, partnerId: true, programEnrollment: { select: { clickReward: true, }, }, }, take: BATCH_SIZE, skip: startingAfter ? 1 : 0, ...(startingAfter && { cursor: { id: startingAfter, }, }), orderBy: { id: "asc", }, }); const endMessage = `Finished aggregating clicks for ${batchNumber} batches (total ${nFormatter(batchNumber * (BATCH_SIZE - 1) + linksWithClickRewards.length, { full: true })} links)`; if (linksWithClickRewards.length === 0) { return logAndRespond(endMessage); } const clicksByCountries = await getTopLinksByCountries({ linkIds: linksWithClickRewards.map(({ id }) => id), start, end, }); // This should never happen, but just in case if (clicksByCountries.length === 0) { return logAndRespond(endMessage); } // Group clicks by link_id for easier iteration const clicksByLinkId = new Map(); for (const click of clicksByCountries) { const existing = clicksByLinkId.get(click.link_id) || []; existing.push(click); clicksByLinkId.set(click.link_id, existing); } const linkEarningsMap = new Map< string, { linkClicks: number; earnings: number } >(); // Calculate earnings per link considering geo CPC for (const { id: linkId, shortLink, programEnrollment, } of linksWithClickRewards) { if (!programEnrollment?.clickReward) { console.log(`No click reward for link ${linkId}.`); continue; } const linkClicksByCountry = clicksByLinkId.get(linkId) || []; // Calculate earnings per country for each link for (const { country, clicks } of linkClicksByCountry) { const rewardAmount = resolveClickRewardAmount({ reward: programEnrollment.clickReward, country, }); const existing = linkEarningsMap.get(linkId) || { linkClicks: 0, earnings: 0, }; linkEarningsMap.set(linkId, { linkClicks: existing.linkClicks + clicks, earnings: existing.earnings + rewardAmount * clicks, }); // only console.log if there are modifiers if (programEnrollment.clickReward.modifiers) { console.log( `Earnings for link ${getPrettyUrl(shortLink)} for ${country}: ${currencyFormatter(rewardAmount)} * ${clicks} = ${currencyFormatter( rewardAmount * clicks, )}`, ); } } } // Create commissions for each link const commissionsToCreate = linksWithClickRewards .map(({ id, programId, partnerId, programEnrollment }) => { if (!programId || !partnerId || !programEnrollment?.clickReward) { return null; } const { linkClicks, earnings } = linkEarningsMap.get(id) || { linkClicks: 0, earnings: 0, }; if (linkClicks === 0 || earnings === 0) { return null; } return { id: createId({ prefix: "cm_" }), programId, partnerId, linkId: id, quantity: linkClicks, type: CommissionType.click, amount: 0, earnings, }; }) .filter( (c): c is NonNullable => c !== null, ) satisfies Prisma.CommissionCreateManyInput[]; console.table(commissionsToCreate); // Create commissions await prisma.commission.createMany({ data: commissionsToCreate, }); // Sync total commissions for each partner that we created commissions for for (const { partnerId, programId } of commissionsToCreate) { await syncTotalCommissions({ partnerId, programId, }); } console.log( `Synced total commissions count for ${commissionsToCreate.length} partners`, ); // Schedule next batch if we have more links to process if (linksWithClickRewards.length === BATCH_SIZE) { const nextStartingAfter = linksWithClickRewards[linksWithClickRewards.length - 1].id; await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/aggregate-clicks`, method: "POST", body: { startingAfter: nextStartingAfter, batchNumber: batchNumber + 1, }, }); return logAndRespond( `Enqueued next batch (batch #${batchNumber + 1} for aggregate clicks cron (startingAfter: ${nextStartingAfter}).`, ); } return logAndRespond(endMessage); } catch (error) { return handleAndReturnErrorResponse(error); } } export { handler as GET, handler as POST }; ================================================ FILE: apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts ================================================ import { createId } from "@/lib/api/create-id"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { evaluateWorkflowConditions } from "@/lib/api/workflows/evaluate-workflow-conditions"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { aggregatePartnerLinksStats } from "@/lib/partners/aggregate-partner-links-stats"; import { workflowConditionSchema } from "@/lib/zod/schemas/workflows"; import { prisma } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; import { APP_DOMAIN_WITH_NGROK, log, toCentsNumber } from "@dub/utils"; import { differenceInMinutes } from "date-fns"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const schema = z.object({ bountyId: z.string(), partnerIds: z.array(z.string()).optional(), page: z.number().optional().default(0), }); const MAX_PAGE_SIZE = 100; // POST /api/cron/bounties/create-draft-submissions // Create draft bounty submissions for performance bounties with lifetime performance scope export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); const { bountyId, partnerIds, page } = schema.parse(JSON.parse(rawBody)); // Find bounty const bounty = await prisma.bounty.findUnique({ where: { id: bountyId, }, include: { groups: true, program: true, workflow: true, }, }); if (!bounty) { return logAndRespond(`Bounty ${bountyId} not found.`, { logLevel: "error", }); } let diffMinutes = differenceInMinutes(bounty.startsAt, new Date()); if (diffMinutes >= 10) { return logAndRespond( `Bounty ${bountyId} not started yet, it will start at ${bounty.startsAt.toISOString()}`, ); } if (bounty.type !== "performance") { return logAndRespond(`Bounty ${bountyId} is not a performance bounty.`); } if (bounty.performanceScope === "new") { return logAndRespond( `Bounty ${bountyId} is limited to new stats; submission creation skipped.`, ); } if (!bounty.workflow) { return logAndRespond(`Bounty ${bountyId} has no workflow.`); } // Find groupIds const groupIds = bounty.groups.map(({ groupId }) => groupId); // Find program enrollments const programEnrollments = await prisma.programEnrollment.findMany({ where: { programId: bounty.programId, ...(groupIds.length > 0 && { groupId: { in: groupIds, }, }), ...(partnerIds && { partnerId: { in: partnerIds, }, }), status: { in: ["approved", "invited"], }, }, select: { partnerId: true, totalCommissions: true, links: { select: { clicks: true, sales: true, leads: true, conversions: true, saleAmount: true, }, }, partner: { select: { name: true, }, }, }, orderBy: { createdAt: "asc", }, skip: page * MAX_PAGE_SIZE, take: MAX_PAGE_SIZE, }); if (programEnrollments.length === 0) { return logAndRespond( `No more program enrollments found for bounty ${bountyId}.`, ); } console.log( `Found ${programEnrollments.length} program enrollments eligible for bounty ${bountyId}.`, ); // Find the workflow condition const condition = z .array(workflowConditionSchema) .parse(bounty.workflow.triggerConditions)[0]; // Partners with their link metrics const partners = programEnrollments.map((programEnrollment) => { return { id: programEnrollment.partnerId, ...aggregatePartnerLinksStats(programEnrollment.links), totalCommissions: toCentsNumber(programEnrollment.totalCommissions), }; }); const bountySubmissionsToCreate: Prisma.BountySubmissionCreateManyInput[] = partners // only create submissions for partners that have at least 1 performanceCount .filter((partner) => partner[condition.attribute] > 0) .map((partner) => { const performanceCount = partner[condition.attribute]; const conditionMet = evaluateWorkflowConditions({ conditions: [condition], attributes: { [condition.attribute]: performanceCount, }, }); return { id: createId({ prefix: "bnty_sub_" }), programId: bounty.programId, partnerId: partner.id, bountyId: bounty.id, performanceCount, // If the condition is met, automatically submit the submission ...(conditionMet && { status: "submitted", completedAt: new Date(), }), }; }); console.table(bountySubmissionsToCreate); // Create bounty submissions const createdBountySubmissions = await prisma.bountySubmission.createMany({ data: bountySubmissionsToCreate, skipDuplicates: true, }); console.log( `Created ${createdBountySubmissions.count} bounty submissions for bounty ${bountyId}.`, ); if (programEnrollments.length === MAX_PAGE_SIZE) { const response = await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/create-draft-submissions`, body: { bountyId, partnerIds, page: page + 1, }, }); return logAndRespond( `Enqueued next page (${page + 1}) for bounty ${bountyId}. ${JSON.stringify(response, null, 2)}`, ); } return logAndRespond( `Finished creating submissions for ${createdBountySubmissions.count} partners for bounty ${bountyId}.`, ); } catch (error) { await log({ message: "New bounties submissions cron failed. Error: " + error.message, type: "errors", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts ================================================ import { createId } from "@/lib/api/create-id"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { ACTIVE_ENROLLMENT_STATUSES } from "@/lib/zod/schemas/partners"; import { sendBatchEmail } from "@dub/email"; import NewBountyAvailable from "@dub/email/templates/new-bounty-available"; import { prisma } from "@dub/prisma"; import { NotificationEmailType } from "@dub/prisma/client"; import { APP_DOMAIN_WITH_NGROK, log } from "@dub/utils"; import { differenceInMinutes } from "date-fns"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const schema = z.object({ bountyId: z.string(), startingAfter: z.string().optional(), batchNumber: z .number() .optional() .default(1) .describe("Keep track of the batches sent."), }); const EMAIL_BATCH_SIZE = 100; // Batch size const BATCH_DELAY_SECONDS = 2; // Delay between batches const EXTENDED_DELAY_SECONDS = 30; // Extended delay after 25 batches const EXTENDED_DELAY_INTERVAL = 25; // Number of batches after which to extend the delay // POST /api/cron/bounties/notify-partners // Send emails to eligible partners about new bounty that is published export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); let { bountyId, startingAfter, batchNumber } = schema.parse( JSON.parse(rawBody), ); // Find bounty const bounty = await prisma.bounty.findUnique({ where: { id: bountyId, }, include: { groups: true, program: { include: { emailDomains: { where: { status: "verified", }, }, }, }, }, }); if (!bounty) { return logAndRespond(`Bounty ${bountyId} not found.`, { logLevel: "error", }); } const diffMinutes = differenceInMinutes(bounty.startsAt, new Date()); if (diffMinutes >= 10) { return logAndRespond( `Bounty ${bountyId} not started yet, it will start at ${bounty.startsAt.toISOString()}`, ); } // Find groupIds const groupIds = bounty.groups.map(({ groupId }) => groupId); console.log( `Bounty ${bountyId} is applicable to ${ groupIds.length === 0 ? "all" : groupIds.length } groups (groupIds: ${JSON.stringify(groupIds)})`, ); const programEnrollments = await prisma.programEnrollment.findMany({ where: { programId: bounty.programId, ...(groupIds.length > 0 && { groupId: { in: groupIds, }, }), status: { in: ACTIVE_ENROLLMENT_STATUSES, }, partner: { email: { not: null, }, // only notify partners who have signed up for an account on partners.dub.co users: { some: {}, }, }, }, include: { partner: { include: { users: { take: 1, // TODO: update this to use partnerUsersToNotify approach }, }, }, }, take: EMAIL_BATCH_SIZE, skip: startingAfter ? 1 : 0, ...(startingAfter && { cursor: { id: startingAfter, }, }), orderBy: { id: "asc", }, }); if (programEnrollments.length === 0) { return logAndRespond( `No more program enrollments found for bounty ${bountyId}.`, ); } console.log( `Sending emails to ${programEnrollments.length} partners: ${programEnrollments.map(({ partner }) => partner.email).join(", ")}`, ); const { data } = await sendBatchEmail( programEnrollments.map(({ partner }) => ({ variant: "notifications", from: bounty.program.emailDomains.length > 0 ? `${bounty.program.name} ` : undefined, to: partner.email!, // coerce the type here because we've already filtered out partners with no email in the prisma query subject: `New bounty available for ${bounty.program.name}`, replyTo: bounty.program.supportEmail || "noreply", react: NewBountyAvailable({ email: partner.email!, bounty: { id: bounty.id, name: bounty.name, type: bounty.type, endsAt: bounty.endsAt, description: bounty.description, }, program: { name: bounty.program.name, slug: bounty.program.slug, }, }), tags: [{ name: "type", value: "notification-email" }], })), { idempotencyKey: `bounty-notify/${bountyId}-${startingAfter || "initial"}`, }, ); if (data) { await prisma.notificationEmail.createMany({ data: programEnrollments.map(({ partner }, idx) => ({ id: createId({ prefix: "em_" }), type: NotificationEmailType.Bounty, emailId: data.data[idx].id, bountyId: bounty.id, programId: bounty.programId, partnerId: partner.id, recipientUserId: partner.users[0].userId, // TODO: update this to use partnerUsersToNotify approach })), }); } if (programEnrollments.length === EMAIL_BATCH_SIZE) { startingAfter = programEnrollments[programEnrollments.length - 1].id; // Add BATCH_DELAY_SECONDS pause between each batch, and a longer EXTENDED_DELAY_SECONDS cooldown after every EXTENDED_DELAY_INTERVAL batches. let delay = 0; if (batchNumber > 0 && batchNumber % EXTENDED_DELAY_INTERVAL === 0) { delay = EXTENDED_DELAY_SECONDS; } else { delay = BATCH_DELAY_SECONDS; } await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/notify-partners`, method: "POST", delay, body: { bountyId, startingAfter, batchNumber: batchNumber + 1, }, }); return logAndRespond( `Enqueued next batch (${startingAfter}) for bounty ${bountyId} to run after ${delay} seconds.`, ); } return logAndRespond( `Finished sending emails to ${programEnrollments.length} partners for bounty ${bountyId}.`, ); } catch (error) { await log({ message: "New bounties published cron failed. Error: " + error.message, type: "errors", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/bounties/queue-sync-social-metrics/route.ts ================================================ import { enqueueBatchJobs } from "@/lib/cron/enqueue-batch-jobs"; import { withCron } from "@/lib/cron/with-cron"; import { prisma } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; // GET /api/cron/bounties/queue-sync-social-metrics - queue social metrics sync for bounties export const GET = withCron(async () => { const now = new Date(); const bounties = await prisma.bounty.findMany({ where: { type: "submission", startsAt: { lte: now, }, OR: [ { endsAt: null, }, { endsAt: { gt: now, }, }, ], submissionRequirements: { path: "$.socialMetrics", not: Prisma.JsonNull, }, }, select: { id: true, submissionRequirements: true, }, }); if (bounties.length === 0) { return logAndRespond("No bounties to sync social metrics for."); } await enqueueBatchJobs( bounties.map((bounty) => ({ queueName: "sync-bounty-social-metrics", url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/sync-social-metrics`, deduplicationId: bounty.id, body: { bountyId: bounty.id, }, })), ); return logAndRespond( `Queued ${bounties.length} bounties to sync social metrics.`, ); }); ================================================ FILE: apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts ================================================ import { getSocialMetricsUpdates } from "@/lib/bounty/api/get-social-metrics-updates"; import { resolveBountyDetails } from "@/lib/bounty/utils"; import { qstash } from "@/lib/cron"; import { withCron } from "@/lib/cron/with-cron"; import { sendBatchEmail } from "@dub/email"; import BountyCompleted from "@dub/email/templates/bounty-completed"; import { prisma } from "@dub/prisma"; import { Partner, Prisma } from "@dub/prisma/client"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const bodySchema = z.object({ bountyId: z.string(), startingAfter: z .string() .optional() .describe("The ID of the submission to start processing from."), }); const SUBMISSION_BATCH_SIZE = 50; // POST /api/cron/bounties/sync-social-metrics - sync social metrics for a bounty export const POST = withCron(async ({ rawBody }) => { const { bountyId, startingAfter } = bodySchema.parse(JSON.parse(rawBody)); const bounty = await prisma.bounty.findUnique({ where: { id: bountyId, }, include: { program: true, }, }); if (!bounty) { return logAndRespond(`Bounty ${bountyId} not found. Skipping...`); } const now = new Date(); if (bounty.startsAt && bounty.startsAt > now) { return logAndRespond(`Bounty ${bountyId} has not started yet. Skipping...`); } if (bounty.endsAt && bounty.endsAt < now) { return logAndRespond(`Bounty ${bountyId} has ended. Skipping...`); } const bountyInfo = resolveBountyDetails(bounty); if (!bountyInfo?.hasSocialMetrics) { return logAndRespond( `Bounty ${bountyId} has no social metrics requirements. Skipping...`, ); } const submissions = await prisma.bountySubmission.findMany({ where: { bountyId, status: { // We only want to process submissions that are not rejected or approved. notIn: ["rejected", "approved"], }, }, select: { id: true, urls: true, socialMetricCount: true, status: true, partner: { select: { email: true, }, }, }, orderBy: { id: "asc", }, ...(startingAfter && { skip: 1, cursor: { id: startingAfter, }, }), take: SUBMISSION_BATCH_SIZE, }); if (submissions.length === 0) { return logAndRespond( `No submissions found for bounty ${bountyId}. Skipping...`, ); } const newMetrics = await getSocialMetricsUpdates({ bounty, submissions, }); const minCount = bountyInfo.socialMetrics?.minCount; if (!minCount) { return logAndRespond( `Bounty ${bountyId} has no minimum social metrics count. Skipping...`, ); } const submissionById = new Map(submissions.map((s) => [s.id, s])); const updates: Prisma.PrismaPromise[] = []; const notifications: Pick[] = []; for (const { id, socialMetricCount, socialMetricsLastSyncedAt, } of newMetrics) { const submission = submissionById.get(id); if (!submission) { continue; } const hasMetCriteria = socialMetricCount != null && socialMetricCount >= minCount; const shouldTransitionToSubmitted = submission.status === "draft" && hasMetCriteria; const updateData: Prisma.BountySubmissionUpdateInput = { socialMetricCount, socialMetricsLastSyncedAt, }; if (shouldTransitionToSubmitted) { updateData.status = "submitted"; updateData.completedAt = now; if (submission.partner?.email) { notifications.push({ email: submission.partner.email, }); } } updates.push( prisma.bountySubmission.update({ where: { id, }, data: updateData, }), ); } await prisma.$transaction(updates); if (notifications.length > 0 && bounty.program) { await sendBatchEmail( notifications.map(({ email }) => ({ subject: "Bounty completed!", to: email!, variant: "notifications", replyTo: bounty.program.supportEmail || "noreply", react: BountyCompleted({ email: email!, bounty: { name: bounty.name, type: bounty.type, }, program: { name: bounty.program.name, slug: bounty.program.slug, }, }), })), ); } if (submissions.length === SUBMISSION_BATCH_SIZE) { const startingAfter = submissions[submissions.length - 1].id; await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/sync-social-metrics`, method: "POST", body: { bountyId, startingAfter, }, }); return logAndRespond( `Synced ${updates.length} submissions for bounty ${bountyId}. Queued next batch (startingAfter: ${startingAfter}).`, ); } await prisma.bounty.update({ where: { id: bountyId, }, data: { socialMetricsLastSyncedAt: new Date(), }, }); return logAndRespond( `Synced ${updates.length} submission(s) for bounty ${bountyId}.`, ); }); ================================================ FILE: apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts ================================================ import { validateCampaignFromAddress } from "@/lib/api/campaigns/validate-campaign"; import { createId } from "@/lib/api/create-id"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { renderCampaignEmailHTML } from "@/lib/api/workflows/render-campaign-email-html"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { TiptapNode } from "@/lib/types"; import { ACTIVE_ENROLLMENT_STATUSES } from "@/lib/zod/schemas/partners"; import { sendBatchEmail } from "@dub/email"; import CampaignEmail from "@dub/email/templates/campaign-email"; import { prisma } from "@dub/prisma"; import { NotificationEmailType } from "@dub/prisma/client"; import { APP_DOMAIN_WITH_NGROK, chunk, log } from "@dub/utils"; import { differenceInMinutes } from "date-fns"; import { headers } from "next/headers"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const schema = z.object({ campaignId: z.string(), startingAfter: z.string().optional(), batchNumber: z .number() .optional() .default(1) .describe("Keep track of the batches sent."), }); const EMAIL_BATCH_SIZE = 100; // Batch size const BATCH_DELAY_SECONDS = 2; // Delay between batches const EXTENDED_DELAY_SECONDS = 30; // Extended delay after 25 batches const EXTENDED_DELAY_INTERVAL = 25; // Number of batches after which to extend the delay // POST /api/cron/campaigns/broadcast // Send marketing campaigns to partners in batches export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); let { campaignId, startingAfter, batchNumber } = schema.parse( JSON.parse(rawBody), ); const campaign = await prisma.campaign.findUnique({ where: { id: campaignId, }, include: { groups: true, program: { include: { emailDomains: { where: { status: "verified", }, }, }, }, }, }); if (!campaign) { return logAndRespond(`Campaign ${campaignId} not found.`); } if (campaign.type !== "marketing") { return logAndRespond( `Campaign ${campaignId} is not a marketing campaign.`, ); } if (!["scheduled", "sending"].includes(campaign.status)) { return logAndRespond( `Campaign ${campaignId} must be in "sending" or "scheduled" status to broadcast.`, ); } // This is a safety check to ensure the campaign is not scheduled to broadcast too far in the future // Ideally this should not happen but just in case if (campaign.scheduledAt) { const diffMinutes = differenceInMinutes(campaign.scheduledAt, new Date()); if (diffMinutes >= 5) { await log({ message: `Campaign ${campaignId} broadcast was skipped because it is scheduled to broadcast in the future. This might be an error in the campaign scheduling.`, type: "errors", }); return logAndRespond( `Campaign ${campaignId} is not scheduled to broadcast yet.`, ); } } // This is a safety check to ensure the campaign broadcast is not "initiated" multiple times const headersList = await headers(); const upstashMessageId = headersList.get("Upstash-Message-Id"); if ( !startingAfter && // First run campaign.qstashMessageId && upstashMessageId !== campaign.qstashMessageId ) { return logAndRespond( `Campaign ${campaignId} broadcast was skipped because it is not the current message being processed.`, ); } const program = campaign.program; // TODO: We should make the from address required. There are existing campaign without from address if (campaign.from) { validateCampaignFromAddress({ campaign, emailDomains: program.emailDomains, }); } // Mark the campaign as sending try { await prisma.campaign.update({ where: { id: campaignId, status: "scheduled", }, data: { status: "sending", }, }); } catch (error) { // } const campaignGroupIds = campaign.groups.map(({ groupId }) => groupId); const programEnrollments = await prisma.programEnrollment.findMany({ where: { programId: campaign.programId, status: { in: ACTIVE_ENROLLMENT_STATUSES, }, ...(campaignGroupIds.length > 0 && { groupId: { in: campaignGroupIds, }, }), }, select: { id: true, links: { select: { shortLink: true, }, orderBy: { id: "asc" }, }, partner: { select: { id: true, name: true, email: true, users: { where: { notificationPreferences: { marketingCampaign: true, }, }, select: { user: { select: { id: true, email: true, }, }, }, }, }, }, }, take: EMAIL_BATCH_SIZE, skip: startingAfter ? 1 : 0, ...(startingAfter && { cursor: { id: startingAfter, }, }), orderBy: { id: "asc", }, }); const partnerUsers = programEnrollments.flatMap((enrollment) => enrollment.partner.users .filter(({ user }) => user.email) .map(({ user }) => ({ ...user, partner: { ...enrollment.partner, users: undefined, }, enrollment: { ...enrollment, partner: undefined, }, })), ); console.table( partnerUsers.map((partnerUser) => ({ id: partnerUser.partner.id, name: partnerUser.partner.name, email: partnerUser.email, })), ); if (partnerUsers.length > 0) { // Chunk partnerUsers even though the DB query limits enrollments to EMAIL_BATCH_SIZE. // Each enrollment can have multiple users (via partner.users), so the flattened // partnerUsers array can exceed EMAIL_BATCH_SIZE. const partnerUsersChunks = chunk(partnerUsers, EMAIL_BATCH_SIZE); for ( let chunkIndex = 0; chunkIndex < partnerUsersChunks.length; chunkIndex++ ) { const partnerUsersChunk = partnerUsersChunks[chunkIndex].filter( (partnerUser) => partnerUser.email, ); const batchIdentifier = startingAfter || "initial"; const idempotencyKey = `campaign-broadcast/${campaign.id}-${batchIdentifier}-${chunkIndex}`; const { data, error } = await sendBatchEmail( partnerUsersChunk.map((partnerUser) => ({ from: `${program.name} <${campaign.from}>`, to: partnerUser.email!, subject: campaign.subject, ...(program.supportEmail ? { replyTo: program.supportEmail } : {}), react: CampaignEmail({ program: { name: program.name, slug: program.slug, logo: program.logo, }, campaign: { type: campaign.type, preview: campaign.preview, body: renderCampaignEmailHTML({ content: campaign.bodyJson as unknown as TiptapNode, variables: { PartnerName: partnerUser.partner.name, PartnerEmail: partnerUser.partner.email, PartnerLink: partnerUser.enrollment.links?.[0]?.shortLink ?? null, }, }), }, }), tags: [{ name: "type", value: "notification-email" }], })), { idempotencyKey, }, ); if (error) { console.error(error); } if (data) { await prisma.notificationEmail.createMany({ data: partnerUsersChunk.map((partnerUser, idx) => ({ id: createId({ prefix: "em_" }), type: NotificationEmailType.Campaign, emailId: data.data[idx].id, campaignId: campaign.id, programId: campaign.programId, partnerId: partnerUser.partner.id, recipientUserId: partnerUser.id, })), skipDuplicates: true, }); } } } if (programEnrollments.length === EMAIL_BATCH_SIZE) { startingAfter = programEnrollments[programEnrollments.length - 1].id; // Add BATCH_DELAY_SECONDS pause between each batch, and a longer EXTENDED_DELAY_SECONDS cooldown after every EXTENDED_DELAY_INTERVAL batches. let delay = 0; if (batchNumber > 0 && batchNumber % EXTENDED_DELAY_INTERVAL === 0) { delay = EXTENDED_DELAY_SECONDS; } else { delay = BATCH_DELAY_SECONDS; } await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/campaigns/broadcast`, method: "POST", delay, body: { campaignId, startingAfter, batchNumber: batchNumber + 1, }, }); return logAndRespond( `Enqueued next page (${startingAfter}) for campaign ${campaignId} to run after ${delay} seconds.`, ); } // Mark the campaign as sent try { await prisma.campaign.update({ where: { id: campaignId, status: "sending", }, data: { status: "sent", }, }); } catch (error) { // } return logAndRespond(`Finished broadcasting campaign ${campaignId}.`); } catch (error) { await log({ message: "Campaign broadcast cron failed. Error: " + error.message, type: "errors", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/cleanup/demo-embed-partners/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { bulkDeletePartners } from "@/lib/api/partners/bulk-delete-partners"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { prisma } from "@dub/prisma"; import { ACME_PROGRAM_ID, log } from "@dub/utils"; import { subHours } from "date-fns"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; // This route is used to remove partners from the demo embed (acme.dub.sh) // Runs every hour (0 * * * *) export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); const programEnrollmentsToDelete = await prisma.programEnrollment.findMany({ where: { programId: ACME_PROGRAM_ID, createdAt: { lt: subHours(new Date(), 1), // 1 hour ago }, NOT: [ { partner: { email: { endsWith: "@dub.co", }, }, }, { partner: { email: { endsWith: "@dub-internal-test.com", }, }, }, { partner: { email: { in: [ "panic@thedis.co", "jasno@bourne.com", "michael@scofield.com", "steven@elegance.co", "mailtokirankk@gmail.com", "marcusljf@gmail.com", "tim@twilson.net", ], }, }, }, ], }, orderBy: { totalCommissions: "desc", }, }); console.log( `Found ${programEnrollmentsToDelete.length} program enrollments to delete.`, ); if (programEnrollmentsToDelete.length > 0) { await bulkDeletePartners({ partnerIds: programEnrollmentsToDelete.map((pe) => pe.partnerId), }); } return logAndRespond( `Removed ${programEnrollmentsToDelete.length} program enrollments from the demo embed.`, ); } catch (error) { await log({ message: `/api/cron/cleanup/demo-embed-partners failed - ${error.message}`, type: "errors", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/cleanup/e2e-tests/route.ts ================================================ import { markDomainAsDeleted } from "@/lib/api/domains/mark-domain-deleted"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { bulkDeleteLinks } from "@/lib/api/links/bulk-delete-links"; import { includeProgramEnrollment } from "@/lib/api/links/include-program-enrollment"; import { includeTags } from "@/lib/api/links/include-tags"; import { bulkDeletePartners } from "@/lib/api/partners/bulk-delete-partners"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { prisma } from "@dub/prisma"; import { log } from "@dub/utils"; import { NextResponse } from "next/server"; export const dynamic = "force-dynamic"; const E2E_USER_ID = "clxz1q7c7000hbqx5ckv4r82h"; const E2E_WORKSPACE_ID = "clrei1gld0002vs9mzn93p8ik"; // This route is used to remove links, domains and tags created during our E2E tests. // Runs every 6 hours (0 * / 6 * * *) export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); const oneDayAgo = new Date(Date.now() - 1000 * 60 * 60 * 24); const [links, domains, tags, partners, users] = await Promise.all([ prisma.link.findMany({ where: { userId: E2E_USER_ID, projectId: E2E_WORKSPACE_ID, createdAt: { lt: oneDayAgo, }, }, include: { ...includeTags, ...includeProgramEnrollment, discountCode: true, }, take: 100, }), prisma.domain.findMany({ where: { projectId: E2E_WORKSPACE_ID, slug: { endsWith: ".dub-internal-test.com", }, createdAt: { lt: oneDayAgo, }, }, select: { slug: true, }, }), prisma.tag.findMany({ where: { projectId: E2E_WORKSPACE_ID, name: { startsWith: "e2e-", }, createdAt: { lt: oneDayAgo, }, }, }), prisma.partner.findMany({ where: { email: { endsWith: "@dub-internal-test.com", }, createdAt: { lt: oneDayAgo, }, }, select: { id: true, }, }), prisma.user.findMany({ where: { email: { endsWith: "@dub-internal-test.com", }, createdAt: { lt: oneDayAgo, }, }, }), ]); // Delete the links if (links.length > 0) { const linkIds = links.map((link) => link.id); await prisma.discountCode.deleteMany({ where: { linkId: { in: linkIds, }, }, }); await prisma.link.deleteMany({ where: { id: { in: linkIds, }, }, }); // Post delete cleanup await bulkDeleteLinks(links); } // Delete the domains if (domains.length > 0) { await Promise.all( domains.map(({ slug }) => markDomainAsDeleted({ domain: slug, }), ), ); } // Delete the tags if (tags.length > 0) { await prisma.tag.deleteMany({ where: { id: { in: tags.map((tag) => tag.id), }, }, }); } // Delete the partners if (partners.length > 0) { await bulkDeletePartners({ partnerIds: partners.map((partner) => partner.id), deletePartners: true, }); } if (users.length > 0) { await prisma.user.deleteMany({ where: { id: { in: users.map((user) => user.id), }, }, }); } console.log("Removed the following items.", { links: links.length, domains: domains.length, tags: tags.length, partners: partners.length, users: users.length, }); return NextResponse.json({ status: "OK" }); } catch (error) { await log({ message: `/api/cron/cleanup/e2e-tests failed - ${error.message}`, type: "errors", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/cleanup/expired-tokens/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { prisma } from "@dub/prisma"; import { log } from "@dub/utils"; import { NextResponse } from "next/server"; export const dynamic = "force-dynamic"; // This route is used to remove expired tokens from the database // 1. VerificationToken // 2. EmailVerificationToken // 3. PasswordResetToken // Runs once every day at 02:00:00 AM UTC (0 2 * * *) // GET /api/cron/cleanup/expired-tokens export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); // tokens expired 1 day ago const cutoff = new Date(Date.now() - 1000 * 60 * 60 * 24); const [verificationTokens, emailVerificationTokens, passwordResetTokens] = await Promise.all([ prisma.verificationToken.deleteMany({ where: { expires: { lt: cutoff, }, }, }), prisma.emailVerificationToken.deleteMany({ where: { expires: { lt: cutoff, }, }, }), prisma.passwordResetToken.deleteMany({ where: { expires: { lt: cutoff, }, }, }), ]); console.log("Token cleanup deleted", { verificationTokens: verificationTokens.count, emailVerificationTokens: emailVerificationTokens.count, passwordResetTokens: passwordResetTokens.count, }); return NextResponse.json({ status: "OK" }); } catch (error) { await log({ message: `/api/cron/cleanup/expired-tokens failed - ${error.message}`, type: "errors", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/cleanup/link-retention/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { recordLink } from "@/lib/tinybird"; import { prisma } from "@dub/prisma"; import { Domain } from "@dub/prisma/client"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; export const dynamic = "force-dynamic"; // This route is used to delete old links for domains with linkRetentionDays set // Runs once every 12 hours (0 */12 * * *) // POST /api/cron/cleanup/link-retention export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); const { domain: passedDomain } = z .object({ domain: z.string().optional(), }) .parse(JSON.parse(rawBody)); if (!passedDomain) { const domains = await prisma.domain.findMany({ where: { linkRetentionDays: { not: null, }, }, }); await Promise.all(domains.map((domain) => deleteOldLinks(domain))); } else { const domain = await prisma.domain.findUniqueOrThrow({ where: { slug: passedDomain, }, }); await deleteOldLinks(domain); } return NextResponse.json("OK"); } catch (error) { return handleAndReturnErrorResponse(error); } } const LINKS_PER_BATCH = 100; const MAX_LINK_BATCHES = 10; async function deleteOldLinks( domain: Pick, ) { if ( !domain.linkRetentionDays || !domain.projectId || domain.linkRetentionDays <= 0 ) return; let processedBatches = 0; let hasMoreLinks = false; while (processedBatches < MAX_LINK_BATCHES) { const links = await prisma.link.findMany({ where: { domain: domain.slug, createdAt: { lt: new Date( Date.now() - 1000 * 60 * 60 * 24 * domain.linkRetentionDays, ), }, linkRetentionCleanupDisabledAt: null, }, orderBy: { createdAt: "asc", }, take: LINKS_PER_BATCH, }); console.log( `[Link retention cleanup] Found ${links.length} links to delete for ${domain.slug} that are older than ${domain.linkRetentionDays} days`, ); if (links.length === 0) break; // Check if we might have more links (if we got a full batch) hasMoreLinks = links.length === LINKS_PER_BATCH; console.log( `[Link retention cleanup] Deleting ${links.length} links for ${domain.slug} (batch ${processedBatches + 1})...`, ); console.table(links, ["shortLink", "createdAt"]); await prisma.$transaction(async (tx) => { await tx.link.deleteMany({ where: { id: { in: links.map(({ id }) => id), }, }, }); await tx.project.update({ where: { id: domain.projectId!, }, data: { totalLinks: { decrement: links.length }, }, }); }); // // Record the links deletion in Tinybird // // not 100% sure if we need this yet, maybe we should just delete the link completely from TB to save space? await recordLink(links, { deleted: true }); console.log( `[Link retention cleanup] Deleted ${links.length} links for ${domain.slug} that are older than ${domain.linkRetentionDays} days!`, ); ++processedBatches; // sleep for 250ms await new Promise((resolve) => setTimeout(resolve, 250)); } // Only schedule another run if we hit the batch limit AND we found a full batch // (indicating there might be more links to process) if (processedBatches >= MAX_LINK_BATCHES && hasMoreLinks) { await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/cleanup/link-retention`, method: "POST", body: { domain: domain.slug, }, }); } } ================================================ FILE: apps/web/app/(ee)/api/cron/cleanup/rejected-applications/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { prisma } from "@dub/prisma"; import { log } from "@dub/utils"; import { subDays } from "date-fns"; import { NextResponse } from "next/server"; export const dynamic = "force-dynamic"; // This route is used to remove rejected programEnrollments from the database after 30 days so partners can re-apply // Runs once every day at 02:00:00 AM UTC (0 2 * * *) // POST /api/cron/cleanup/rejected-applications export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); while (true) { // rejected programEnrollments more than 30 days ago const rejectedProgramEnrollments = await prisma.programEnrollment.findMany({ where: { status: "rejected", updatedAt: { lt: subDays(new Date(), 30), }, // only delete if there are no commissions or messages commissions: { none: {}, }, messages: { none: {}, }, }, take: 250, }); if (rejectedProgramEnrollments.length === 0) { console.log( "No more rejected programEnrollments to delete, skipping...", ); break; } const deletedRes = await prisma.programEnrollment.deleteMany({ where: { id: { in: rejectedProgramEnrollments.map(({ id }) => id), }, }, }); console.log( `Deleted ${deletedRes.count} rejected programEnrollments that are older than 30 days`, ); } return NextResponse.json({ status: "OK" }); } catch (error) { await log({ message: `/api/cron/cleanup/rejected-applications failed - ${error.message}`, type: "errors", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/cleanup/unenrolled-partners/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { bulkDeletePartners } from "@/lib/api/partners/bulk-delete-partners"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { prisma } from "@dub/prisma"; import { log } from "@dub/utils"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; // This route is used to remove partners that are not enrolled in any program // Runs once every day at 02:00:00 AM UTC (0 2 * * *) // POST /api/cron/cleanup/unenrolled-partners export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); let deletedPartnersCount = 0; while (true) { const partnersToDelete = await prisma.partner.findMany({ where: { stripeConnectId: null, programs: { none: {}, }, users: { none: {}, }, }, take: 250, }); if (partnersToDelete.length === 0) { console.log("No more partners to delete, skipping..."); break; } console.log(`Found ${partnersToDelete.length} partners to delete.`); if (partnersToDelete.length > 0) { await bulkDeletePartners({ partnerIds: partnersToDelete.map((partner) => partner.id), deletePartners: true, }); deletedPartnersCount += partnersToDelete.length; } } return logAndRespond( `Deleted ${deletedPartnersCount} partners that were not enrolled in any programs.`, ); } catch (error) { await log({ message: `/api/cron/cleanup/unenrolled-partners failed - ${error.message}`, type: "errors", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/discount-codes/create/queue-batches/route.ts ================================================ import { CRON_BATCH_SIZE, qstash } from "@/lib/cron"; import { enqueueBatchJobs } from "@/lib/cron/enqueue-batch-jobs"; import { withCron } from "@/lib/cron/with-cron"; import { ACTIVE_ENROLLMENT_STATUSES } from "@/lib/zod/schemas/partners"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../../utils"; export const dynamic = "force-dynamic"; const inputSchema = z.object({ discountId: z.string(), startingAfter: z.string().optional(), }); // POST /api/cron/discount-codes/create/queue-batches export const POST = withCron(async ({ rawBody }) => { const { discountId, startingAfter } = inputSchema.parse(JSON.parse(rawBody)); const discount = await prisma.discount.findUnique({ where: { id: discountId, }, include: { program: { select: { id: true, workspace: { select: { id: true, stripeConnectId: true, }, }, }, }, }, }); if (!discount) { return logAndRespond(`Discount ${discountId} not found. Skipping...`); } if (!discount.autoProvisionEnabledAt) { return logAndRespond( `Discount ${discountId} does not have auto-provision enabled. Skipping...`, ); } const { program } = discount; const { workspace } = program; if (!workspace.stripeConnectId) { return logAndRespond( `Workspace ${workspace.id} does not have stripeConnectId set. Skipping...`, ); } const programEnrollments = await prisma.programEnrollment.findMany({ where: { programId: program.id, discountId: discount.id, status: { in: ACTIVE_ENROLLMENT_STATUSES, }, }, select: { id: true, partnerId: true, discountId: true, links: { select: { id: true, }, where: { discountCode: null, partnerGroupDefaultLinkId: { not: null, }, }, }, }, ...(startingAfter && { skip: 1, cursor: { id: startingAfter, }, }), orderBy: { id: "asc", }, take: CRON_BATCH_SIZE, }); if (programEnrollments.length === 0) { return logAndRespond( `No more program enrollments found for discount ${discountId}.`, ); } const links = programEnrollments.flatMap(({ links }) => links); if (links.length > 0) { await enqueueBatchJobs( links.map((link) => ({ queueName: "create-discount-code", url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discount-codes/create`, deduplicationId: `${discountId}-${link.id}`, body: { linkId: link.id, }, })), ); } if (programEnrollments.length === CRON_BATCH_SIZE) { const startingAfter = programEnrollments[programEnrollments.length - 1].id; await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discount-codes/create/queue-batches`, method: "POST", body: { discountId, startingAfter, }, }); return logAndRespond( `Queued next batch for discount ${discountId} (startingAfter: ${startingAfter}).`, ); } return logAndRespond(`Finished queuing jobs for discount ${discountId}.`); }); ================================================ FILE: apps/web/app/(ee)/api/cron/discount-codes/create/route.ts ================================================ import { createDiscountCode } from "@/lib/api/discounts/create-discount-code"; import { withCron } from "@/lib/cron/with-cron"; import { prisma } from "@dub/prisma"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const inputSchema = z.object({ linkId: z .string() .describe("The ID of the link to create a discount code for."), }); // POST /api/cron/discount-codes/create export const POST = withCron(async ({ rawBody }) => { const { linkId } = inputSchema.parse(JSON.parse(rawBody)); const link = await prisma.link.findUnique({ where: { id: linkId, }, select: { id: true, discountCode: true, partnerGroupDefaultLinkId: true, programEnrollment: { select: { discount: true, partner: { select: { id: true, name: true, }, }, program: { select: { id: true, }, }, }, }, project: { select: { id: true, stripeConnectId: true, }, }, }, }); if (!link || !link.project) { return logAndRespond(`Link ${linkId} not found. Skipping...`); } if (link.discountCode) { return logAndRespond( `Link ${linkId} already has a discount code. Skipping...`, ); } if (link.partnerGroupDefaultLinkId === null) { return logAndRespond(`Link ${linkId} is not a default link. Skipping...`); } if (!link.programEnrollment) { return logAndRespond( `Link ${linkId} is not associated with a program enrollment. Skipping...`, ); } const { project: workspace, programEnrollment } = link; const { partner, discount, program } = programEnrollment; if (!workspace.stripeConnectId) { return logAndRespond( `Workspace ${workspace.id} does not have stripeConnectId set. Skipping...`, ); } if (!discount) { return logAndRespond( `Partner ${partner.id} does not have a discount with program ${program.id}. Skipping...`, ); } await createDiscountCode({ stripeConnectId: workspace.stripeConnectId, partner, link, discount, }); return logAndRespond(`Discount code created for link ${linkId}.`); }); ================================================ FILE: apps/web/app/(ee)/api/cron/discount-codes/delete/route.ts ================================================ import { withCron } from "@/lib/cron/with-cron"; import { disableStripeDiscountCode } from "@/lib/stripe/disable-stripe-discount-code"; import { prisma } from "@dub/prisma"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const inputSchema = z.object({ code: z.string(), programId: z.string(), }); // POST /api/cron/discount-codes/delete export const POST = withCron(async ({ rawBody }) => { const { code, programId } = inputSchema.parse(JSON.parse(rawBody)); const workspace = await prisma.project.findUniqueOrThrow({ where: { defaultProgramId: programId, }, select: { stripeConnectId: true, }, }); const disabledDiscountCode = await disableStripeDiscountCode({ code, stripeConnectId: workspace.stripeConnectId, }); if (!disabledDiscountCode) { return logAndRespond( `Failed to disable discount code ${code} in Stripe for ${workspace.stripeConnectId}.`, ); } return logAndRespond( `Discount code ${code} disabled from Stripe for ${workspace.stripeConnectId}.`, ); }); ================================================ FILE: apps/web/app/(ee)/api/cron/disposable-emails/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { redis } from "@/lib/upstash"; import { log } from "@dub/utils"; import { NextResponse } from "next/server"; export const dynamic = "force-dynamic"; // Cron to update the disposable email domains list in Redis // Runs every Monday at noon UTC (0 12 * * 1) export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); const disposableEmails = await fetch( "https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/master/disposable_email_blocklist.conf", ); const domains = (await disposableEmails.text()).split("\n").filter(Boolean); if (domains.length < 100) { throw new Error("Disposable email domains list is too short"); } // Use a temporary set to avoid emptying the old set await redis.del("disposableEmailDomainsTmp"); await redis.sadd("disposableEmailDomainsTmp", ...(domains as [string])); await redis.rename("disposableEmailDomainsTmp", "disposableEmailDomains"); return NextResponse.json({ status: "OK" }); } catch (error) { await log({ message: `Error updating disposable email domains list: ${error.message}`, type: "cron", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/domains/delete/route.ts ================================================ import { queueDomainDeletion } from "@/lib/api/domains/queue-domain-update"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { linkCache } from "@/lib/api/links/cache"; import { includeProgramEnrollment } from "@/lib/api/links/include-program-enrollment"; import { includeTags } from "@/lib/api/links/include-tags"; import { limiter } from "@/lib/cron/limiter"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { storage } from "@/lib/storage"; import { recordLink } from "@/lib/tinybird/record-link"; import { prisma } from "@dub/prisma"; import { R2_URL } from "@dub/utils"; import * as z from "zod/v4"; export const dynamic = "force-dynamic"; const schema = z.object({ domain: z.string(), }); // POST /api/cron/domains/delete export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const { domain } = schema.parse(JSON.parse(rawBody)); const domainRecord = await prisma.domain.findUnique({ where: { slug: domain, }, }); if (!domainRecord) { return new Response(`Domain ${domain} not found. Skipping...`); } const links = await prisma.link.findMany({ where: { domain, }, include: { ...includeTags, ...includeProgramEnrollment, }, take: 100, orderBy: { createdAt: "desc", }, }); console.log(`Found ${links.length} links to delete`); if (links.length === 0) { return new Response("No more links to delete. Exiting..."); } const response = await Promise.allSettled([ // Remove the link from Redis linkCache.deleteMany(links), // Record link in Tinybird recordLink(links, { deleted: true }), // Remove image from R2 storage if it exists links .filter((link) => link.image?.startsWith(`${R2_URL}/images/${link.id}`)) .map((link) => limiter.schedule(() => storage.delete({ key: link.image!.replace(`${R2_URL}/`, "") }), ), ), // Remove the link from MySQL prisma.link.deleteMany({ where: { id: { in: links.map((link) => link.id) }, }, }), // Update the project's total links count links[0].projectId && prisma.project.update({ where: { id: links[0].projectId, }, data: { totalLinks: { decrement: links.length }, }, }), ]); console.log(response); response.forEach((promise) => { if (promise.status === "rejected") { console.error("deleteDomainAndLinks", { reason: promise.reason, domain, }); } }); const remainingLinks = await prisma.link.count({ where: { domain, }, }); console.log("remainingLinks", remainingLinks); if (remainingLinks > 0) { await queueDomainDeletion({ domain, delay: 2, }); return new Response( `Deleted ${links.length} links, ${remainingLinks} remaining. Starting next batch...`, ); } // After all links are deleted, delete the domain and image await Promise.all([ prisma.domain.delete({ where: { slug: domain, }, }), domainRecord.logo && storage.delete({ key: domainRecord.logo.replace(`${R2_URL}/`, "") }), ]); return new Response( `Deleted ${links.length} links, no more links remaining. Domain deleted.`, ); } catch (error) { return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/domains/renewal-payments/route.ts ================================================ import { createId } from "@/lib/api/create-id"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyVercelSignature } from "@/lib/cron/verify-vercel"; import { createPaymentIntent } from "@/lib/stripe/create-payment-intent"; import { prisma } from "@dub/prisma"; import { Invoice, Project, RegisteredDomain } from "@dub/prisma/client"; import { log } from "@dub/utils"; import { addDays, endOfDay, startOfDay } from "date-fns"; import { NextResponse } from "next/server"; /** * Daily cron job to create payment intents for `.link` domain renewals. * * Payment intents are created 14 days before domain expiration to ensure * timely processing and avoid domain expiration. */ export const dynamic = "force-dynamic"; interface GroupedWorkspace { workspace: Pick; domains: Pick[]; } // GET /api/cron/domains/renewal-payments export async function GET(req: Request) { try { await verifyVercelSignature(req); const targetDate = addDays(new Date(), 14); console.log("targetDate", targetDate); // Find all domains expiring in 14 days const domains = await prisma.registeredDomain.findMany({ where: { autoRenewalDisabledAt: null, expiresAt: { gte: startOfDay(targetDate), lte: endOfDay(targetDate), }, }, include: { project: { select: { id: true, stripeId: true, invoicePrefix: true, }, }, }, }); if (domains.length === 0) { console.log( "No domains found expiring exactly 14 days from today. Skipping...", ); return NextResponse.json( "No domains found expiring exactly 14 days from today.", ); } console.table(domains, ["slug", "expiresAt", "renewalFee"]); // Group domains by workspaceId const groupedByWorkspace = domains.reduce( (acc, domain) => { const workspaceId = domain.projectId; if (!acc[workspaceId]) { acc[workspaceId] = { workspace: domain.project, domains: [], }; } acc[workspaceId].domains.push({ id: domain.id, slug: domain.slug, expiresAt: domain.expiresAt, renewalFee: domain.renewalFee, }); return acc; }, {} as Record, ); const invoices: Invoice[] = []; // Create invoice for each workspace + domains group for (const workspaceId in groupedByWorkspace) { const { workspace, domains } = groupedByWorkspace[workspaceId]; const invoice = await prisma.$transaction(async (tx) => { // Generate the next invoice number by counting the number of invoices for the workspace const totalInvoices = await tx.invoice.count({ where: { workspaceId: workspace.id, }, }); const paddedNumber = String(totalInvoices + 1).padStart(4, "0"); const invoiceNumber = `${workspace.invoicePrefix}-${paddedNumber}`; const totalAmount = domains.reduce( (acc, domain) => acc + domain.renewalFee, 0, ); return await tx.invoice.create({ data: { id: createId({ prefix: "inv_" }), workspaceId: workspace.id, number: invoiceNumber, type: "domainRenewal", amount: totalAmount, total: totalAmount, registeredDomains: domains.map(({ slug }) => slug), // array of domain slugs, }, }); }); console.log( `Invoice ${invoice.id} with total ${invoice.total} created for workspace ${workspace.id} to renew ${domains.length} domains.`, ); invoices.push(invoice); } // Create payment intent for each invoice for (const invoice of invoices) { const { workspace } = groupedByWorkspace[invoice.workspaceId]; if (!workspace.stripeId) { console.log(`Workspace ${workspace.id} has no stripeId, skipping...`); continue; } const res = await createPaymentIntent({ stripeId: workspace.stripeId!, amount: invoice.total, invoiceId: invoice.id, statementDescriptor: "Dub", description: `Domain renewal invoice (${invoice.id})`, idempotencyKey: `${invoice.id}-${invoice.failedAttempts}`, }); console.log(`Payment intent created for invoice ${invoice.id}`, res); } return NextResponse.json("OK"); } catch (error) { await log({ message: "Domains renewal cron failed. Error: " + error.message, type: "errors", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyVercelSignature } from "@/lib/cron/verify-vercel"; import { sendBatchEmail } from "@dub/email"; import DomainRenewalReminder from "@dub/email/templates/domain-renewal-reminder"; import { prisma } from "@dub/prisma"; import { chunk, log } from "@dub/utils"; import { differenceInCalendarDays, endOfDay, formatDistanceStrict, startOfDay, subDays, } from "date-fns"; import { NextResponse } from "next/server"; /** * Daily cron job to send `.link` domain renewal reminders. * * Reminders are sent at the following intervals before the domain expiration date: * - First reminder: 30 days prior * - Second reminder: 23 days prior * - Third reminder: 16 days prior */ export const dynamic = "force-dynamic"; const REMINDER_WINDOWS = [30, 23, 16]; // GET /api/cron/domains/renewal-reminders export async function GET(req: Request) { try { await verifyVercelSignature(req); const now = new Date(); const targetDates = REMINDER_WINDOWS.map((days) => { const date = subDays(now, -days); return { start: startOfDay(date), end: endOfDay(date), days, }; }); console.log("targetDates", targetDates); // Find all domains that are eligible for renewal reminders const domains = await prisma.registeredDomain.findMany({ where: { autoRenewalDisabledAt: null, OR: targetDates.map((t) => ({ expiresAt: { gte: t.start, lte: t.end, }, })), }, include: { project: { include: { users: { where: { role: "owner", }, include: { user: true, }, }, }, }, }, }); if (domains.length === 0) { console.log("No domains found to send reminders for. Skipping..."); return NextResponse.json("No domains found to send reminders for."); } const reminderDomains = domains.flatMap( ({ slug, expiresAt, renewalFee, project }) => { const reminderWindow = differenceInCalendarDays(expiresAt, now); // we charge 14 days before the expiration date to ensure timely processing const chargeAt: Date = subDays(expiresAt, 14); return project.users.map(({ user }) => ({ domain: { slug, renewalFee, expiresAt, reminderWindow, chargeAt, chargeAtInText: formatDistanceStrict(chargeAt, now), }, workspace: { slug: project.slug, }, user: { email: user.email, }, })); }, ); console.table(reminderDomains); const reminderDomainsChunks = chunk(reminderDomains, 100); for (const reminderDomainsChunk of reminderDomainsChunks) { const res = await sendBatchEmail( reminderDomainsChunk.map(({ workspace, user, domain }) => ({ to: user.email!, subject: "Your domain is expiring soon", variant: "notifications", react: DomainRenewalReminder({ email: user.email!, workspace, domain, }), })), ); console.log(`Sent ${reminderDomainsChunk.length} emails`, res); } return NextResponse.json(reminderDomains); } catch (error) { await log({ message: "Domains renewal reminders cron failed. Error: " + error.message, type: "errors", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/domains/transfer/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { linkCache } from "@/lib/api/links/cache"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { recordLink } from "@/lib/tinybird"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, log } from "@dub/utils"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; import { sendDomainTransferredEmail } from "./utils"; const schema = z.object({ currentWorkspaceId: z.string(), newWorkspaceId: z.string(), domain: z.string(), }); export const dynamic = "force-dynamic"; export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const { currentWorkspaceId, newWorkspaceId, domain } = schema.parse( JSON.parse(rawBody), ); const links = await prisma.link.findMany({ where: { domain, projectId: currentWorkspaceId }, take: 100, orderBy: { createdAt: "desc", }, }); // No remaining links to transfer if (!links || links.length === 0) { // Send email to the owner of the current workspace const linksCount = await prisma.link.count({ where: { domain, projectId: newWorkspaceId }, }); await sendDomainTransferredEmail({ domain, currentWorkspaceId, newWorkspaceId, linksCount, }); } else { // Transfer links to the new workspace const linkIds = links.map((link) => link.id); await Promise.all([ prisma.link.updateMany({ where: { domain, projectId: currentWorkspaceId, id: { in: linkIds, }, }, data: { projectId: newWorkspaceId, // reset all stats and folder clicks: 0, leads: 0, sales: 0, saleAmount: 0, conversions: 0, lastClicked: null, lastLeadAt: null, lastConversionAt: null, folderId: null, }, }), prisma.linkTag.deleteMany({ where: { linkId: { in: linkIds } }, }), // Update links in redis linkCache.mset( links.map((link) => ({ ...link, projectId: newWorkspaceId })), ), // Remove the webhooks associated with the links prisma.linkWebhook.deleteMany({ where: { linkId: { in: linkIds } }, }), // set the links with the old workspace ID to be deleted in Tinybird recordLink(links, { deleted: true }), // set the links with the new workspace ID to be created in Tinybird recordLink( links.map((link) => ({ ...link, projectId: newWorkspaceId, folderId: null, })), ), ]); // wait 500 ms before making another request await new Promise((resolve) => setTimeout(resolve, 500)); await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/domains/transfer`, body: { currentWorkspaceId, newWorkspaceId, domain, }, }); } return NextResponse.json({ response: "success", }); } catch (error) { await log({ message: `Error transferring domain: ${error.message}`, type: "cron", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/domains/transfer/utils.ts ================================================ import { sendEmail } from "@dub/email"; import DomainTransferred from "@dub/email/templates/domain-transferred"; import { prisma } from "@dub/prisma"; // Send email to the owner after the domain transfer is completed export const sendDomainTransferredEmail = async ({ domain, currentWorkspaceId, newWorkspaceId, linksCount, }: { domain: string; currentWorkspaceId: string; newWorkspaceId: string; linksCount: number; }) => { const currentWorkspace = await prisma.project.findUnique({ where: { id: currentWorkspaceId, }, select: { users: { where: { role: "owner", }, select: { user: { select: { email: true, }, }, }, }, }, }); const newWorkspace = await prisma.project.findUniqueOrThrow({ where: { id: newWorkspaceId, }, select: { name: true, slug: true, }, }); const ownerEmail = currentWorkspace?.users[0]?.user?.email!; await sendEmail({ subject: "Domain transfer completed", to: ownerEmail, react: DomainTransferred({ email: ownerEmail, domain, newWorkspace, linksCount, }), }); }; ================================================ FILE: apps/web/app/(ee)/api/cron/domains/update/route.ts ================================================ import { linkDomainUpdateSchema, queueDomainUpdate, } from "@/lib/api/domains/queue-domain-update"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { linkCache } from "@/lib/api/links/cache"; import { includeProgramEnrollment } from "@/lib/api/links/include-program-enrollment"; import { includeTags } from "@/lib/api/links/include-tags"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { recordLink } from "@/lib/tinybird"; import { prisma } from "@dub/prisma"; import { Link } from "@dub/prisma/client"; import { linkConstructorSimple } from "@dub/utils"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const LINK_BATCH_SIZE = 100; // POST /api/cron/domains/update export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); const payload = linkDomainUpdateSchema.parse(JSON.parse(rawBody)); const { newDomain, oldDomain, programId, startingAfter } = payload; const newDomainRecord = await prisma.domain.findUnique({ where: { slug: newDomain, }, }); if (!newDomainRecord) { return logAndRespond(`Domain ${newDomain} not found. Skipping...`); } const linksToUpdate = await prisma.link.findMany({ where: { domain: oldDomain, ...(programId && { programId }), }, take: LINK_BATCH_SIZE, ...(startingAfter && { skip: 1, cursor: { id: startingAfter, }, }), orderBy: { id: "asc", }, }); if (linksToUpdate.length === 0) { return logAndRespond( `No more links to update for domain ${oldDomain}. Exiting...`, ); } const linkIdsToUpdate = linksToUpdate.map((link) => link.id); try { await prisma.link.updateMany({ where: { id: { in: linkIdsToUpdate, }, }, data: { domain: newDomain, }, }); } catch (error) { console.error(error); } const updatedLinks = await prisma.link.findMany({ where: { id: { in: linkIdsToUpdate, }, }, include: { ...includeTags, ...includeProgramEnrollment, }, }); await Promise.allSettled([ // update the `shortLink` field for each of the short links updateShortLinks(updatedLinks), // record new link values in Tinybird (dub_links_metadata) recordLink(updatedLinks), // expire the redis cache for the old links linkCache.expireMany(linksToUpdate), ]); const response = await queueDomainUpdate({ ...payload, startingAfter: linksToUpdate[linksToUpdate.length - 1].id, delay: 1, }); if (response.messageId) { return logAndRespond(`Scheduled next batch ${response.messageId}.`); } else { return logAndRespond("Error scheduling next batch."); } } catch (error) { return handleAndReturnErrorResponse(error); } } // Update the shortLink column for a list of links const updateShortLinks = async ( links: Pick[], ) => { if (!links || links.length === 0) { return new Response("No links found."); } for (const link of links) { await prisma.link.update({ where: { id: link.id, }, data: { shortLink: linkConstructorSimple({ domain: link.domain, key: link.key, }), }, }); } }; ================================================ FILE: apps/web/app/(ee)/api/cron/domains/verify/route.ts ================================================ import { getConfigResponse } from "@/lib/api/domains/get-config-response"; import { getDomainResponse } from "@/lib/api/domains/get-domain-response"; import { verifyDomain } from "@/lib/api/domains/verify-domain"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyVercelSignature } from "@/lib/cron/verify-vercel"; import { prisma } from "@dub/prisma"; import { log } from "@dub/utils"; import { NextResponse } from "next/server"; import { handleDomainUpdates } from "./utils"; /** * Cron to check if domains are verified. * If a domain is invalid for more than 14 days, we send a reminder email to the workspace owner. * If a domain is invalid for more than 28 days, we send a second and final reminder email to the workspace owner. * If a domain is invalid for more than 30 days, we delete it from the database. **/ // Runs every hour (0 * * * *) export const dynamic = "force-dynamic"; export async function GET(req: Request) { try { await verifyVercelSignature(req); const domains = await prisma.domain.findMany({ where: { slug: { // exclude domains that belong to us notIn: [ "dub.sh", "chatg.pt", "amzn.id", "spti.fi", "stey.me", "steven.yt", "steven.blue", "owd.li", "elegance.ai", ], }, }, select: { slug: true, verified: true, primary: true, createdAt: true, }, orderBy: { lastChecked: "asc", }, take: 30, }); const results = await Promise.allSettled( domains.map(async (domain) => { const { slug, verified, primary, createdAt } = domain; const [domainJson, configJson] = await Promise.all([ getDomainResponse(slug), getConfigResponse(slug), ]); let newVerified; if (domainJson?.error?.code === "not_found") { newVerified = false; } else if (!domainJson.verified) { const verificationJson = await verifyDomain(slug); if (verificationJson && verificationJson.verified) { newVerified = true; } else { newVerified = false; } } else if (!configJson.misconfigured) { newVerified = true; } else { newVerified = false; } const prismaResponse = await prisma.domain.update({ where: { slug, }, data: { verified: newVerified, lastChecked: new Date(), }, }); const changed = newVerified !== verified; const updates = await handleDomainUpdates({ domain: slug, createdAt, verified: newVerified, primary, changed, }); return { domain, previousStatus: verified, currentStatus: newVerified, changed, updates, prismaResponse, }; }), ); return NextResponse.json(results); } catch (error) { await log({ message: "Domains cron failed. Error: " + error.message, type: "errors", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/domains/verify/utils.ts ================================================ import { markDomainAsDeleted } from "@/lib/api/domains/mark-domain-deleted"; import { sendBatchEmail } from "@dub/email"; import DomainDeleted from "@dub/email/templates/domain-deleted"; import InvalidDomain from "@dub/email/templates/invalid-domain"; import { prisma } from "@dub/prisma"; import { log } from "@dub/utils"; export const handleDomainUpdates = async ({ domain, createdAt, verified, primary, changed, }: { domain: string; createdAt: Date; verified: boolean; primary: boolean; changed: boolean; }) => { if (changed) { await log({ message: `Domain *${domain}* changed status to *${verified}*`, type: "cron", }); } if (verified) return; const invalidDays = Math.floor( (new Date().getTime() - new Date(createdAt).getTime()) / (1000 * 3600 * 24), ); // do nothing if domain is invalid for less than 14 days if (invalidDays < 14) return; const workspace = await prisma.project.findFirst({ where: { domains: { some: { slug: domain, }, }, }, select: { id: true, name: true, slug: true, sentEmails: true, usage: true, users: { select: { user: { select: { email: true, }, }, }, where: { user: { isMachine: false, }, notificationPreference: { domainConfigurationUpdates: true, }, }, }, }, }); if (!workspace) { await log({ message: `Domain *${domain}* is invalid but not associated with any workspace, skipping.`, type: "cron", }); return; } const workspaceSlug = workspace.slug; const sentEmails = workspace.sentEmails.map((email) => email.type); const emails = workspace.users.map((user) => user.user.email) as string[]; // if domain is invalid for more than 30 days, check if we can delete it if (invalidDays >= 30) { // Don't delete the domain (manual inspection required) // if the links for the domain have clicks recorded const linksClicks = await prisma.link.aggregate({ _sum: { clicks: true, }, where: { domain, }, }); if (linksClicks._sum.clicks && linksClicks._sum.clicks > 0) { return await log({ message: `Domain *${domain}* has been invalid for > 30 days but has links with clicks, skipping.`, type: "cron", }); } // else, delete the domain return await Promise.allSettled([ markDomainAsDeleted({ domain, }).then(async () => { // if the deleted domain was primary, make another domain primary if (primary) { const anotherDomain = await prisma.domain.findFirst({ where: { projectId: workspace.id, }, }); if (!anotherDomain) return; return prisma.domain.update({ where: { slug: anotherDomain.slug, }, data: { primary: true, }, }); } }), log({ message: `Domain *${domain}* has been invalid for > 30 days andhas links but no link clicks, deleting.`, type: "cron", }), sendBatchEmail( emails.map((email) => ({ subject: `Your domain ${domain} has been deleted`, to: email, react: DomainDeleted({ email, domain, workspaceSlug, }), variant: "notifications", })), ), ]); } if (invalidDays >= 28) { const sentSecondDomainInvalidEmail = sentEmails.includes( `secondDomainInvalidEmail:${domain}`, ); if (!sentSecondDomainInvalidEmail) { return sendDomainInvalidEmail({ workspaceSlug, domain, invalidDays, emails, type: "second", }); } } if (invalidDays >= 14) { const sentFirstDomainInvalidEmail = sentEmails.includes( `firstDomainInvalidEmail:${domain}`, ); if (!sentFirstDomainInvalidEmail) { return sendDomainInvalidEmail({ workspaceSlug, domain, invalidDays, emails, type: "first", }); } } return; }; const sendDomainInvalidEmail = async ({ workspaceSlug, domain, invalidDays, emails, type, }: { workspaceSlug: string; domain: string; invalidDays: number; emails: string[]; type: "first" | "second"; }) => { return await Promise.allSettled([ log({ message: `Domain *${domain}* is invalid for ${invalidDays} days, email sent.`, type: "cron", }), sendBatchEmail( emails.map((email) => ({ subject: `Your domain ${domain} needs to be configured`, to: email, react: InvalidDomain({ email, domain, workspaceSlug, invalidDays, }), variant: "notifications", })), ), prisma.sentEmail.create({ data: { project: { connect: { slug: workspaceSlug, }, }, type: `${type}DomainInvalidEmail:${domain}`, }, }), ]); }; ================================================ FILE: apps/web/app/(ee)/api/cron/email-domains/update/route.ts ================================================ import { withCron } from "@/lib/cron/with-cron"; import { resend } from "@dub/email/resend/client"; import { prisma } from "@dub/prisma"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; const schema = z.object({ domainId: z.string(), }); export const dynamic = "force-dynamic"; // POST /api/cron/email-domains/update // Update the Resend domain to enable click tracking export const POST = withCron(async ({ rawBody }) => { const { domainId } = schema.parse(JSON.parse(rawBody)); if (!resend) { return logAndRespond("Resend is not configured. Skipping update..."); } const domainRecord = await prisma.emailDomain.findUnique({ where: { id: domainId, }, select: { slug: true, resendDomainId: true, }, }); if (!domainRecord) { return logAndRespond(`Domain ${domainId} not found. Skipping update...`); } if (!domainRecord.resendDomainId) { return logAndRespond( `Resend domain ID is not found for domain ${domainRecord.slug}. Skipping update...`, ); } const { error } = await resend.domains.update({ id: domainRecord.resendDomainId, openTracking: true, clickTracking: false, tls: "enforced", }); // This will be retried by QStash if it fails. if (error) { throw new Error(error.message); } return logAndRespond(`Domain ${domainRecord.slug} updated successfully.`); }); ================================================ FILE: apps/web/app/(ee)/api/cron/email-domains/verify/route.ts ================================================ import { getWorkspaceUsers } from "@/lib/api/get-workspace-users"; import { withCron } from "@/lib/cron/with-cron"; import { sendBatchEmail } from "@dub/email"; import { resend } from "@dub/email/resend/client"; import EmailDomainStatusChanged from "@dub/email/templates/email-domain-status-changed"; import { prisma } from "@dub/prisma"; import { EmailDomain } from "@dub/prisma/client"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; // GET /api/cron/email-domains/verify // Runs every hour (0 * * * *) export const GET = withCron(async () => { if (!resend) { return logAndRespond("Resend is not configured. Skipping verification..."); } const domains = await prisma.emailDomain.findMany({ where: { resendDomainId: { not: null, }, }, orderBy: { lastChecked: "asc", }, take: 10, }); if (domains.length === 0) { return logAndRespond("No email domains to check the verification status."); } for (const domain of domains) { try { await verifyEmailDomain(domain); } catch (error) { console.error(`Failed to verify domain ${domain.slug}:`, error); } } return logAndRespond("Email domains verification status checked."); }); // Checks the verification status of an email domain async function verifyEmailDomain(domain: EmailDomain) { if (!domain.resendDomainId) { return; } const { data: resendDomain, error } = await resend!.domains.get( domain.resendDomainId, ); if (error) { return; } const updatedDomain = await prisma.emailDomain.update({ where: { id: domain.id, }, data: { status: resendDomain.status, lastChecked: new Date(), }, }); const statusChanged = updatedDomain.status !== domain.status; // Do nothing if the status has not changed if (!statusChanged) { console.log( `Email domain ${domain.slug} status has not changed. Skipping email notification...`, ); return; } console.log( `Email domain ${domain.slug} status changed from ${domain.status} to ${updatedDomain.status}`, ); const { users, ...workspace } = await getWorkspaceUsers({ role: "owner", workspaceId: domain.workspaceId, notificationPreference: "domainConfigurationUpdates", }); if (users.length === 0) { console.log( `No workspace owners found for domain ${domain.slug}. Skipping email notification...`, ); return; } const subject = updatedDomain.status === "verified" ? "Your email domain has been verified" : updatedDomain.status === "failed" ? "Your email domain verification has failed" : "Your email domain status has changed"; const resendResponse = await sendBatchEmail( users.map((user) => ({ variant: "notifications", subject, to: user.email, react: EmailDomainStatusChanged({ domain: domain.slug, oldStatus: domain.status, newStatus: updatedDomain.status, email: user.email, workspace: { slug: workspace.slug, name: workspace.name, }, }), })), ); if (resendResponse.error) { console.error( `Failed to send email notification for domain ${domain.slug}:`, resendResponse.error, ); } } ================================================ FILE: apps/web/app/(ee)/api/cron/export/commissions/fetch-commissions-batch.ts ================================================ import { getCommissions } from "@/lib/api/commissions/get-commissions"; import { getCommissionsQuerySchema } from "@/lib/zod/schemas/commissions"; import * as z from "zod/v4"; type CommissionFilters = Omit< z.infer, "page" | "pageSize" > & { programId: string; }; export async function* fetchCommissionsBatch( filters: CommissionFilters, pageSize: number = 1000, ) { let page = 1; let hasMore = true; while (hasMore) { const commissions = await getCommissions({ ...filters, page, pageSize, }); if (commissions.length > 0) { yield { commissions }; page++; hasMore = commissions.length === pageSize; } else { hasMore = false; } } } ================================================ FILE: apps/web/app/(ee)/api/cron/export/commissions/route.ts ================================================ import { convertToCSV } from "@/lib/analytics/utils/convert-to-csv"; import { formatCommissionsForExport } from "@/lib/api/commissions/format-commissions-for-export"; import { createDownloadableExport } from "@/lib/api/create-downloadable-export"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { generateExportFilename } from "@/lib/api/utils/generate-export-filename"; import { generateRandomString } from "@/lib/api/utils/generate-random-string"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { commissionsExportQuerySchema } from "@/lib/zod/schemas/commissions"; import { sendEmail } from "@dub/email"; import ExportReady from "@dub/email/templates/export-ready"; import { prisma } from "@dub/prisma"; import { log } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; import { fetchCommissionsBatch } from "./fetch-commissions-batch"; const payloadSchema = commissionsExportQuerySchema.extend({ programId: z.string(), userId: z.string(), }); // POST /api/cron/export/commissions - QStash worker for processing large commission exports export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); let { programId, columns, userId, ...filters } = payloadSchema.parse( JSON.parse(rawBody), ); const user = await prisma.user.findUnique({ where: { id: userId, }, select: { email: true, }, }); if (!user) { return logAndRespond(`User ${userId} not found. Skipping the export.`); } if (!user.email) { return logAndRespond(`User ${userId} has no email. Skipping the export.`); } const program = await prisma.program.findUnique({ where: { id: programId, }, select: { name: true, }, }); if (!program) { return logAndRespond( `Program ${programId} not found. Skipping the export.`, ); } // Fetch commissions in batches and build CSV const allCommissions: any[] = []; const commissionsFilters = { ...filters, programId, }; for await (const { commissions } of fetchCommissionsBatch( commissionsFilters, )) { allCommissions.push(...formatCommissionsForExport(commissions, columns)); } const csvData = convertToCSV(allCommissions); const { downloadUrl } = await createDownloadableExport({ fileKey: `exports/commissions/${generateRandomString(16)}.csv`, fileName: generateExportFilename("commissions"), body: csvData, contentType: "text/csv", }); await sendEmail({ to: user.email, subject: "Your commissions export is ready", react: ExportReady({ email: user.email, exportType: "commissions", downloadUrl, program: { name: program.name, }, }), }); return logAndRespond( `Export (${allCommissions.length} commissions) generated and email sent to user.`, ); } catch (error) { await log({ message: `Error exporting commissions: ${error.message}`, type: "cron", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/export/customers/route.ts ================================================ import { convertToCSV } from "@/lib/analytics/utils/convert-to-csv"; import { createDownloadableExport } from "@/lib/api/create-downloadable-export"; import { generateExportFilename } from "@/lib/api/utils/generate-export-filename"; import { generateRandomString } from "@/lib/api/utils/generate-random-string"; import { withCron } from "@/lib/cron/with-cron"; import { fetchCustomersBatch } from "@/lib/customers/api/fetch-customers-batch"; import { formatCustomersForExport } from "@/lib/customers/api/format-customers-export"; import { customersExportCronInputSchema } from "@/lib/zod/schemas/customers"; import { sendEmail } from "@dub/email"; import ExportReady from "@dub/email/templates/export-ready"; import { prisma } from "@dub/prisma"; import { logAndRespond } from "../../utils"; const MAX_CUSTOMERS_EXPORT_LIMIT = 100_000; export const dynamic = "force-dynamic"; // POST /api/cron/export/customers - QStash worker for processing large customer exports export const POST = withCron(async ({ rawBody }) => { const parsedFilters = customersExportCronInputSchema.parse( JSON.parse(rawBody), ); const { workspaceId, userId, columns } = parsedFilters; const user = await prisma.user.findUnique({ where: { id: userId, }, select: { email: true, }, }); if (!user) { return logAndRespond(`User ${userId} not found.`); } if (!user.email) { return logAndRespond(`User ${userId} has no email.`); } const workspace = await prisma.project.findUnique({ where: { id: workspaceId, }, select: { name: true, }, }); if (!workspace) { return logAndRespond(`Workspace ${workspaceId} not found.`); } const allRows: Record[] = []; for await (const { customers } of fetchCustomersBatch(parsedFilters)) { const formatted = formatCustomersForExport(customers, columns); const remaining = MAX_CUSTOMERS_EXPORT_LIMIT - allRows.length; if (remaining <= 0) { break; } allRows.push(...formatted.slice(0, remaining)); } const csvData = convertToCSV(allRows); const { downloadUrl } = await createDownloadableExport({ fileKey: `exports/customers/${generateRandomString(16)}.csv`, fileName: generateExportFilename("customers"), body: csvData, contentType: "text/csv", }); await sendEmail({ to: user.email, subject: "Your customers export is ready", react: ExportReady({ email: user.email, exportType: "customers", downloadUrl, workspace: { name: workspace.name, }, }), }); const capped = allRows.length >= MAX_CUSTOMERS_EXPORT_LIMIT ? ` (capped at ${MAX_CUSTOMERS_EXPORT_LIMIT})` : ""; return logAndRespond( `Export (${allRows.length} customers${capped}) generated and email sent to user.`, ); }); ================================================ FILE: apps/web/app/(ee)/api/cron/export/events/fetch-events-batch.ts ================================================ import { getEvents } from "@/lib/analytics/get-events"; import { EventsFilters } from "@/lib/analytics/types"; export async function* fetchEventsBatch( filters: Omit, pageSize: number = 1000, ) { let page = 1; let hasMore = true; while (hasMore) { const events = await getEvents({ ...filters, page, limit: pageSize, }); if (events.length > 0) { yield { events }; page++; hasMore = events.length === pageSize; } else { hasMore = false; } } } ================================================ FILE: apps/web/app/(ee)/api/cron/export/events/partner/route.ts ================================================ import { eventsExportColumnAccessors, eventsExportColumnNames, } from "@/lib/analytics/events-export-helpers"; import { getFirstFilterValue } from "@/lib/analytics/filter-helpers"; import { convertToCSV } from "@/lib/analytics/utils/convert-to-csv"; import { createDownloadableExport } from "@/lib/api/create-downloadable-export"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { obfuscateCustomerEmail } from "@/lib/api/partner-profile/obfuscate-customer-email"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { generateExportFilename } from "@/lib/api/utils/generate-export-filename"; import { generateRandomString } from "@/lib/api/utils/generate-random-string"; import { MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING } from "@/lib/constants/partner-profile"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { generateRandomName } from "@/lib/names"; import { partnerProfileEventsQuerySchema, PartnerProfileLinkSchema, } from "@/lib/zod/schemas/partner-profile"; import { sendEmail } from "@dub/email"; import ExportReady from "@dub/email/templates/export-ready"; import { prisma } from "@dub/prisma"; import { capitalize, log, parseFilterValue } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../../utils"; import { fetchEventsBatch } from "../fetch-events-batch"; const payloadSchema = partnerProfileEventsQuerySchema.extend({ columns: z .string() .transform((c) => c.split(",")) .pipe(z.string().array()), partnerId: z.string(), programId: z.string(), userId: z.string(), }); // POST /api/cron/export/events/partner - QStash worker for processing large partner event exports export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); const { columns, partnerId, programId, userId, ...parsedParams } = payloadSchema.parse(JSON.parse(rawBody)); const user = await prisma.user.findUnique({ where: { id: userId, }, select: { email: true, }, }); if (!user) { return logAndRespond(`User ${userId} not found. Skipping the export.`); } if (!user.email) { return logAndRespond(`User ${userId} has no email. Skipping the export.`); } const { program, links, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId, programId, include: { program: true, links: true, }, }); // If no links, return early with empty export if (links.length === 0) { return logAndRespond("No links found. Skipping the export."); } const { linkId, domain, key } = parsedParams; if (linkId) { // check to make sure all of the linkId.values are in the links if ( !linkId.values.every((value) => links.some((link) => link.id === value)) ) { return logAndRespond( "One or more links are not found. Skipping the export.", ); } } else if (domain && key) { const link = links.find( (link) => link.domain === getFirstFilterValue(domain) && link.key === key, ); if (!link) { return logAndRespond("Link not found. Skipping the export."); } parsedParams.linkId = { operator: "IS", sqlOperator: "IN", values: [link.id], }; } // Fetch events in batches and build CSV const allEvents: Record[] = []; const eventsFilters = { ...parsedParams, workspaceId: program.workspaceId, ...(parsedParams.linkId ? { linkId: parsedParams.linkId } : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING ? { partnerId } : { linkId: parseFilterValue(links.map((link) => link.id)) }), dataAvailableFrom: program.startedAt ?? program.createdAt, }; for await (const { events } of fetchEventsBatch(eventsFilters)) { // Apply partner profile data transformations const transformedEvents = events.map((event) => { // don't return ip address for partner profile // @ts-ignore – ip is deprecated but present in the data const { ip, click, customer, ...eventRest } = event; const { ip: _, ...clickRest } = click; return { ...eventRest, click: clickRest, link: event?.link ? PartnerProfileLinkSchema.parse(event.link) : null, ...(customer && { customer: z .object({ id: z.string(), email: z.string(), ...(customerDataSharingEnabledAt && { name: z.string() }), }) .parse({ ...customer, email: customer.email ? customerDataSharingEnabledAt ? customer.email : obfuscateCustomerEmail(customer.email) : customer.name || generateRandomName(), ...(customerDataSharingEnabledAt && { name: customer.name || generateRandomName(), }), }), }), }; }); const formattedEvents = transformedEvents.map((row) => Object.fromEntries( columns.map((c) => [ eventsExportColumnNames?.[c] ?? capitalize(c), eventsExportColumnAccessors[c]?.(row) ?? row?.[c], ]), ), ); allEvents.push(...formattedEvents); } const csvData = convertToCSV(allEvents); const { downloadUrl } = await createDownloadableExport({ fileKey: `exports/events/partner/${generateRandomString(16)}.csv`, fileName: generateExportFilename("events"), body: csvData, contentType: "text/csv", }); await sendEmail({ to: user.email, subject: "Your events export is ready", react: ExportReady({ email: user.email, exportType: "events", downloadUrl, program: { name: program.name, }, }), }); return logAndRespond( `Export (${allEvents.length} events) generated and email sent to user.`, ); } catch (error) { await log({ message: `Error exporting partner events: ${error.message}`, type: "cron", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/export/events/workspace/route.ts ================================================ import { eventsExportColumnAccessors, eventsExportColumnNames, } from "@/lib/analytics/events-export-helpers"; import { convertToCSV } from "@/lib/analytics/utils/convert-to-csv"; import { createDownloadableExport } from "@/lib/api/create-downloadable-export"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { generateExportFilename } from "@/lib/api/utils/generate-export-filename"; import { generateRandomString } from "@/lib/api/utils/generate-random-string"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { eventsQuerySchema } from "@/lib/zod/schemas/analytics"; import { sendEmail } from "@dub/email"; import ExportReady from "@dub/email/templates/export-ready"; import { prisma } from "@dub/prisma"; import { capitalize, log } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../../utils"; import { fetchEventsBatch } from "../fetch-events-batch"; const payloadSchema = eventsQuerySchema.extend({ columns: z .string() .transform((c) => c.split(",")) .pipe(z.string().array()), workspaceId: z.string(), userId: z.string(), }); // POST /api/cron/export/events/workspace - QStash worker for processing large event exports export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); const { columns, userId, ...filters } = payloadSchema.parse( JSON.parse(rawBody), ); const user = await prisma.user.findUnique({ where: { id: userId, }, select: { email: true, }, }); if (!user) { return logAndRespond(`User ${userId} not found. Skipping the export.`); } if (!user.email) { return logAndRespond(`User ${userId} has no email. Skipping the export.`); } const workspace = await prisma.project.findUnique({ where: { id: filters.workspaceId, }, select: { id: true, name: true, createdAt: true, }, }); if (!workspace) { return logAndRespond( `Workspace ${filters.workspaceId} not found. Skipping the export.`, ); } // Fetch events in batches and build CSV const allEvents: Record[] = []; for await (const { events } of fetchEventsBatch(filters)) { const formattedEvents = events.map((row) => Object.fromEntries( columns.map((c) => [ eventsExportColumnNames?.[c] ?? capitalize(c), eventsExportColumnAccessors[c]?.(row) ?? row?.[c], ]), ), ); allEvents.push(...formattedEvents); } const csvData = convertToCSV(allEvents); const { downloadUrl } = await createDownloadableExport({ fileKey: `exports/events/workspace/${generateRandomString(16)}.csv`, fileName: generateExportFilename("events"), body: csvData, contentType: "text/csv", }); await sendEmail({ to: user.email, subject: "Your events export is ready", react: ExportReady({ email: user.email, exportType: "events", downloadUrl, workspace: { name: workspace.name, }, }), }); return logAndRespond( `Export (${allEvents.length} events) generated and email sent to user.`, ); } catch (error) { await log({ message: `Error exporting events: ${error.message}`, type: "cron", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/export/links/fetch-links-batch.ts ================================================ import { getLinksForWorkspace, GetLinksForWorkspaceProps, } from "@/lib/api/links/get-links-for-workspace"; export async function* fetchLinksBatch( filters: Omit, pageSize: number = 1000, ) { let page = 1; let hasMore = true; while (hasMore) { const links = await getLinksForWorkspace({ ...filters, page, pageSize, }); if (links.length > 0) { yield { links }; page++; hasMore = links.length === pageSize; } else { hasMore = false; } } } ================================================ FILE: apps/web/app/(ee)/api/cron/export/links/route.ts ================================================ import { convertToCSV } from "@/lib/analytics/utils/convert-to-csv"; import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates"; import { createDownloadableExport } from "@/lib/api/create-downloadable-export"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { formatLinksForExport } from "@/lib/api/links/format-links-for-export"; import { validateLinksQueryFilters } from "@/lib/api/links/validate-links-query-filters"; import { generateExportFilename } from "@/lib/api/utils/generate-export-filename"; import { generateRandomString } from "@/lib/api/utils/generate-random-string"; import { MEGA_WORKSPACE_LINKS_LIMIT } from "@/lib/constants/misc"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { PlanProps } from "@/lib/types"; import { linksExportQuerySchema } from "@/lib/zod/schemas/links"; import { sendEmail } from "@dub/email"; import ExportReady from "@dub/email/templates/export-ready"; import { prisma } from "@dub/prisma"; import { log } from "@dub/utils"; import { endOfDay, startOfDay } from "date-fns"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; import { fetchLinksBatch } from "./fetch-links-batch"; const payloadSchema = linksExportQuerySchema.extend({ workspaceId: z.string(), userId: z.string(), }); // POST /api/cron/export/links - QStash worker for processing large link exports export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); const { columns, workspaceId, userId, ...filters } = payloadSchema.parse( JSON.parse(rawBody), ); const user = await prisma.user.findUnique({ where: { id: userId, }, select: { email: true, }, }); if (!user) { return logAndRespond(`User ${userId} not found. Skipping the export.`); } if (!user.email) { return logAndRespond(`User ${userId} has no email. Skipping the export.`); } const workspace = await prisma.project.findUnique({ where: { id: workspaceId, }, select: { id: true, name: true, plan: true, totalLinks: true, foldersUsage: true, users: { select: { role: true, defaultFolderId: true, }, }, }, }); if (!workspace) { return logAndRespond( `Workspace ${workspaceId} not found. Skipping the export.`, ); } const { folderIds } = await validateLinksQueryFilters({ ...filters, userId, workspace: { ...workspace, plan: workspace.plan as PlanProps, }, }); const { interval, start, end } = filters; const { startDate, endDate } = getStartEndDates({ interval, start: start ? startOfDay(new Date(start)) : undefined, end: end ? endOfDay(new Date(end)) : undefined, }); // Fetch links in batches and build CSV const allLinks: Record[] = []; const linksFilters = { ...filters, ...(interval !== "all" && { startDate, endDate, }), searchMode: (workspace.totalLinks > MEGA_WORKSPACE_LINKS_LIMIT ? "exact" : "fuzzy") as "exact" | "fuzzy", includeDashboard: false, includeUser: false, includeWebhooks: false, workspaceId, folderIds, }; for await (const { links } of fetchLinksBatch(linksFilters)) { allLinks.push(...formatLinksForExport(links, columns)); } const csvData = convertToCSV(allLinks); const { downloadUrl } = await createDownloadableExport({ fileKey: `exports/links/${generateRandomString(16)}.csv`, fileName: generateExportFilename("links"), body: csvData, contentType: "text/csv", }); await sendEmail({ to: user.email, subject: "Your links export is ready", react: ExportReady({ email: user.email, exportType: "links", downloadUrl, workspace: { name: workspace.name, }, }), }); return logAndRespond( `Export (${allLinks.length} links) generated and email sent to user.`, ); } catch (error) { await log({ message: `Error exporting links: ${error.message}`, type: "cron", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/export/partners/fetch-partners-batch.ts ================================================ import { getPartners } from "@/lib/api/partners/get-partners"; import { partnersExportQuerySchema } from "@/lib/zod/schemas/partners"; import * as z from "zod/v4"; type PartnerFilters = Omit< z.infer, "columns" > & { programId: string; }; export async function* fetchPartnersBatch( filters: PartnerFilters, pageSize: number = 1000, ) { let page = 1; let hasMore = true; while (hasMore) { const partners = await getPartners({ ...filters, page, pageSize, }); if (partners.length > 0) { yield { partners }; page++; hasMore = partners.length === pageSize; } else { hasMore = false; } } } ================================================ FILE: apps/web/app/(ee)/api/cron/export/partners/route.ts ================================================ import { convertToCSV } from "@/lib/analytics/utils/convert-to-csv"; import { createDownloadableExport } from "@/lib/api/create-downloadable-export"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { formatPartnersForExport } from "@/lib/api/partners/format-partners-for-export"; import { generateExportFilename } from "@/lib/api/utils/generate-export-filename"; import { generateRandomString } from "@/lib/api/utils/generate-random-string"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { partnersExportQuerySchema } from "@/lib/zod/schemas/partners"; import { sendEmail } from "@dub/email"; import ExportReady from "@dub/email/templates/export-ready"; import { prisma } from "@dub/prisma"; import { log } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; import { fetchPartnersBatch } from "./fetch-partners-batch"; const payloadSchema = partnersExportQuerySchema.extend({ programId: z.string(), userId: z.string(), }); // POST /api/cron/export/partners - QStash worker for processing large partner exports export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); let { programId, columns, userId, ...filters } = payloadSchema.parse( JSON.parse(rawBody), ); const user = await prisma.user.findUnique({ where: { id: userId, }, select: { email: true, }, }); if (!user) { return logAndRespond(`User ${userId} not found. Skipping the export.`); } if (!user.email) { return logAndRespond(`User ${userId} has no email. Skipping the export.`); } const program = await prisma.program.findUnique({ where: { id: programId, }, select: { name: true, }, }); if (!program) { return logAndRespond( `Program ${programId} not found. Skipping the export.`, ); } // Fetch partners in batches and build CSV const allPartners: any[] = []; const partnersFilters = { ...filters, programId, }; for await (const { partners } of fetchPartnersBatch(partnersFilters)) { allPartners.push(...formatPartnersForExport(partners, columns)); } const csvData = convertToCSV(allPartners); const { downloadUrl } = await createDownloadableExport({ fileKey: `exports/partners/${generateRandomString(16)}.csv`, fileName: generateExportFilename("partners"), body: csvData, contentType: "text/csv", }); await sendEmail({ to: user.email, subject: "Your partners export is ready", react: ExportReady({ email: user.email, exportType: "partners", downloadUrl, program: { name: program.name, }, }), }); return logAndRespond( `Export (${allPartners.length} partners) generated and email sent to user.`, ); } catch (error) { await log({ message: `Error exporting partners: ${error.message}`, type: "cron", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/folders/delete/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { queueFolderDeletion } from "@/lib/api/folders/queue-folder-deletion"; import { includeProgramEnrollment } from "@/lib/api/links/include-program-enrollment"; import { includeTags } from "@/lib/api/links/include-tags"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { recordLink } from "@/lib/tinybird"; import { prisma } from "@dub/prisma"; import * as z from "zod/v4"; export const dynamic = "force-dynamic"; const MAX_LINKS_PER_BATCH = 500; const schema = z.object({ folderId: z.string(), }); // POST /api/cron/folders/delete // Recursively remove the `folderId` association in all the links of a folder from Tinybird export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const { folderId } = schema.parse(JSON.parse(rawBody)); const linksToUpdate = await prisma.link.findMany({ where: { folderId, }, take: MAX_LINKS_PER_BATCH, orderBy: { createdAt: "desc", // TODO we need to add [folderId, createdAt] index on Link table }, include: { ...includeTags, ...includeProgramEnrollment, }, }); if (linksToUpdate.length === 0) { await prisma.folder.delete({ where: { id: folderId, }, }); return new Response("No more links to process. Deleting folder..."); } const recordLinkResponse = await recordLink( linksToUpdate.map((link) => ({ ...link, folderId: null, })), ); console.log("recordLinkResponse", recordLinkResponse); const updateLinksResponse = await prisma.link.updateMany({ where: { id: { in: linksToUpdate.map((link) => link.id), }, }, data: { folderId: null, }, }); console.log("updateLinksResponse", updateLinksResponse); // TODO: technically we can check if linksToUpdate.length < MAX_LINKS_PER_BATCH // because that means all the links have been updated and we can delete the folder await queueFolderDeletion({ folderId, delay: 2, }); return new Response( `Processed ${linksToUpdate.length} links in the folder.`, ); } catch (error) { return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/framer/backfill-leads-batch/route.ts ================================================ import { createId } from "@/lib/api/create-id"; import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { generateRandomName } from "@/lib/names"; import { recordLeadWithTimestamp, recordSaleWithTimestamp, tb, } from "@/lib/tinybird"; import { redis } from "@/lib/upstash"; import { clickEventSchemaTB } from "@/lib/zod/schemas/clicks"; import { parseDateSchema } from "@/lib/zod/schemas/utils"; import { prisma } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; import { linkConstructorSimple, nanoid } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; const schema = z.array( z.object({ via: z.string(), externalId: z.string(), eventName: z.string(), creationDate: parseDateSchema, }), ); // type coercion cause for some reason the return type of parseDateSchema is not Date type PayloadItem = { via: string; externalId: string; eventName: string; creationDate: Date; }; const FRAMER_WORKSPACE_ID = "clsvopiw0000ejy0grp821me0"; const CACHE_KEY = "framerMigratedExternalIdEventNames"; const DOMAIN = "framer.link"; const getFramerLeadEvents = tb.buildPipe({ pipe: "get_framer_lead_events", parameters: z.object({ linkIds: z .union([z.string(), z.array(z.string())]) .transform((v) => (Array.isArray(v) ? v : v.split(","))), customerIds: z .union([z.string(), z.array(z.string())]) .transform((v) => (Array.isArray(v) ? v : v.split(","))), }), data: z.any(), }); // POST /api/cron/framer/backfill-leads-batch export const POST = withWorkspace( async ({ req, workspace }) => { try { if (workspace.id !== FRAMER_WORKSPACE_ID) { throw new DubApiError({ code: "unauthorized", message: "Unauthorized", }); } const originalPayload = schema.parse( await parseRequestBody(req), ) as PayloadItem[]; const externalIdEventNames = originalPayload.map( (p) => `${p.externalId}:${p.eventName}`, ); const [existsResults, existingLinks, existingCustomers] = await Promise.all([ redis.smismember(CACHE_KEY, externalIdEventNames), prisma.link.findMany({ where: { shortLink: { in: originalPayload.map((p) => linkConstructorSimple({ domain: DOMAIN, key: p.via, }), ), }, }, select: { id: true, key: true, url: true, domain: true, programId: true, partnerId: true, }, }), prisma.customer.findMany({ where: { projectId: workspace.id, externalId: { in: originalPayload.map((p) => p.externalId), }, }, }), ]); const { data: existingLeadEventsForLinks } = await getFramerLeadEvents({ linkIds: existingLinks.map((l) => l.id), customerIds: existingCustomers.map((c) => c.id), }); let validEntries: PayloadItem[] = []; let invalidEntries: (PayloadItem & { error: string; clickId?: string; })[] = []; originalPayload.map((p, index) => { const existingLinkData = existingLinks.find((l) => l.key === p.via); const existingCustomerData = existingCustomers.find( (c) => c.externalId === p.externalId, ); if (!existingLinkData) { invalidEntries.push({ ...p, error: `Link for via tag ${p.via} not found.`, }); return; } if (existsResults[index]) { // get the lead event data for the existing customer const leadEventData = existingLeadEventsForLinks.find( (e) => e.link_id === existingLinkData.id && e.customer_id === existingCustomerData?.id && e.event_name === p.eventName, ); console.log({ leadEventData }); invalidEntries.push({ ...p, error: "Already backfilled.", clickId: leadEventData?.click_id, }); return; } if ( existingLinks.some( (l) => l.key === p.via && (!l.partnerId || !l.programId), ) ) { invalidEntries.push({ ...p, error: `Link for via tag ${p.via} has no partnerId or programId.`, }); return; } validEntries.push(p); }); const linkMap = new Map(existingLinks.map((l) => [l.key, l])); const customerData = validEntries.map((p) => { return { id: createId({ prefix: "cus_" }), name: generateRandomName(), externalId: p.externalId, projectId: workspace.id, projectConnectId: workspace.stripeConnectId, clickId: nanoid(16), linkId: linkMap.get(p.via)!.id, clickedAt: p.creationDate, createdAt: p.creationDate, }; }); await prisma.customer.createMany({ data: customerData, skipDuplicates: true, }); const finalCustomers = await prisma.customer.findMany({ where: { projectId: workspace.id, externalId: { in: customerData.map((c) => c.externalId), }, }, }); const customerMap = new Map( finalCustomers.map((c) => [ c.externalId, { id: c.id, clickId: c.clickId }, ]), ); if (validEntries.length === 0) { return NextResponse.json({ success: [], errors: invalidEntries, }); } const dataArray = validEntries.map((p) => { const link = linkMap.get(p.via)!; const clickData = { timestamp: p.creationDate.toISOString(), identity_hash: p.externalId, click_id: customerMap.get(p.externalId)!.clickId, workspace_id: workspace.id, link_id: link.id, domain: link.domain, key: link.key, url: link.url, ip: "", continent: "NA", country: "Unknown", region: "Unknown", city: "Unknown", latitude: "Unknown", longitude: "Unknown", vercel_region: "", device: "Desktop", device_vendor: "Unknown", device_model: "Unknown", browser: "Unknown", browser_version: "Unknown", engine: "Unknown", engine_version: "Unknown", os: "Unknown", os_version: "Unknown", cpu_architecture: "Unknown", ua: "Unknown", bot: 0, qr: 0, referer: "(direct)", referer_url: "(direct)", trigger: "link", }; const clickEvent = clickEventSchemaTB.parse(clickData); const leadEventData = { ...clickEvent, event_id: nanoid(16), event_name: p.eventName, customer_id: customerMap.get(p.externalId)!.id, timestamp: p.creationDate.toISOString(), }; const saleEventId = nanoid(16); const saleEventData = { ...clickEvent, event_id: saleEventId, event_name: "Invoice paid", amount: 0, customer_id: customerMap.get(p.externalId)!.id, payment_processor: "stripe", currency: "usd", timestamp: p.creationDate.toISOString(), }; const commissionData: Prisma.CommissionCreateManyInput = { id: createId({ prefix: "cm_" }), eventId: saleEventId, type: "sale", programId: link.programId!, partnerId: link.partnerId!, linkId: link.id, customerId: customerMap.get(p.externalId)!.id, amount: 0, quantity: 1, status: "paid", createdAt: p.creationDate, }; return { payload: p, linkData: link, clickData, leadEventData, saleEventData, commissionData, }; }); await Promise.all([ // Record clicks fetch( `${process.env.TINYBIRD_API_URL}/v0/events?name=dub_click_events&wait=true`, { method: "POST", headers: { Authorization: `Bearer ${process.env.TINYBIRD_API_KEY}`, "Content-Type": "application/x-ndjson", }, body: dataArray.map((d) => JSON.stringify(d.clickData)).join("\n"), }, ), // Record leads recordLeadWithTimestamp(dataArray.map((d) => d.leadEventData)), // Record commissions prisma.commission.createMany({ data: dataArray.map((d) => d.commissionData), skipDuplicates: true, }), // Record sales recordSaleWithTimestamp(dataArray.map((d) => d.saleEventData)), // Cache the externalId:eventName pairs redis.sadd( CACHE_KEY, ...(dataArray.map( ({ payload: { externalId, eventName } }) => `${externalId}:${eventName}`, ) as [string]), ), ]); waitUntil( (async () => { // Update link stats const linkCount = validEntries.reduce( (acc, p) => { acc[p.via] = (acc[p.via] || 0) + 1; return acc; }, {} as Record, ); // Group the links by the number of times they appear in the payload const groupedLinks = Object.entries(linkCount).reduce( (acc, [key, value]) => { acc[value] = (acc[value] || []).concat(key); return acc; }, {} as Record, ); await Promise.all( Object.entries(groupedLinks).map(([count, linkKeys]) => prisma.link.updateMany({ where: { shortLink: { in: linkKeys.map((key) => linkConstructorSimple({ domain: DOMAIN, key, }), ), }, }, data: { clicks: { increment: parseInt(count), }, leads: { increment: parseInt(count), }, sales: { increment: parseInt(count), }, }, }), ), ); })(), ); return NextResponse.json({ success: dataArray.map((d) => ({ ...d.payload, clickId: d.clickData.click_id, linkId: d.linkData.id, partnerId: d.linkData.partnerId, programId: d.linkData.programId, commissionId: d.commissionData.id, })), errors: invalidEntries, }); } catch (error) { return handleAndReturnErrorResponse(error); } }, { requiredRoles: ["owner", "member"], }, ); ================================================ FILE: apps/web/app/(ee)/api/cron/fraud/summary/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { FRAUD_RULES_BY_TYPE } from "@/lib/api/fraud/constants"; import { getWorkspaceUsers } from "@/lib/api/get-workspace-users"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { verifyVercelSignature } from "@/lib/cron/verify-vercel"; import { queueBatchEmail } from "@/lib/email/queue-batch-email"; import type UnresolvedFraudEventsSummary from "@dub/email/templates/unresolved-fraud-events-summary"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { format, startOfDay } from "date-fns"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const PROGRAMS_BATCH_SIZE = 10; const schema = z.object({ startingAfter: z.string().optional(), }); // POST /api/cron/fraud/summary // This route sends a daily summary of unresolved fraud events to program owners // Runs daily at 4:00 PM UTC async function handler(req: Request) { try { let { startingAfter } = schema.parse({}); if (req.method === "GET") { await verifyVercelSignature(req); } else if (req.method === "POST") { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); ({ startingAfter } = schema.parse(JSON.parse(rawBody))); } // Get batch of programs with unresolved fraud events const programs = await prisma.program.findMany({ where: { fraudEventGroups: { some: { status: "pending", lastEventAt: { gte: startOfDay(new Date()), }, }, }, }, select: { id: true, name: true, slug: true, workspace: { select: { slug: true, }, }, }, take: PROGRAMS_BATCH_SIZE, ...(startingAfter && { skip: 1, cursor: { id: startingAfter, }, }), orderBy: { id: "asc", }, }); if (programs.length === 0) { return logAndRespond( "No more programs found to send fraud events summary.", ); } const batchDate = format(new Date(), "yyyy-MM-dd"); for (const program of programs) { try { const fraudGroups = await prisma.fraudEventGroup.findMany({ where: { programId: program.id, status: "pending", lastEventAt: { gte: startOfDay(new Date()), }, }, select: { id: true, type: true, eventCount: true, partner: { select: { id: true, name: true, image: true, }, }, }, orderBy: { lastEventAt: "desc", }, take: 6, }); if (fraudGroups.length === 0) { continue; } // Get workspace users to send the email to const { users } = await getWorkspaceUsers({ role: "owner", programId: program.id, notificationPreference: "fraudEventsSummary", }); if (users.length === 0) { continue; } const transformedFraudGroups = fraudGroups.map( ({ id, type, eventCount, partner }) => ({ id, name: FRAUD_RULES_BY_TYPE[type].name, count: eventCount, partner, }), ); await queueBatchEmail( users.map((user) => ({ to: user.email, subject: `Fraud events pending review for ${program.name}`, variant: "notifications", templateName: "UnresolvedFraudEventsSummary", templateProps: { email: user.email, workspace: program.workspace, program, fraudGroups: transformedFraudGroups, }, })), { idempotencyKey: `fraud-events-summary/${program.id}/${batchDate}`, }, ); } catch (error) { console.error( `Error collecting email payloads for program ${program.id}: ${error.message}`, ); continue; } } if (programs.length === PROGRAMS_BATCH_SIZE) { startingAfter = programs[programs.length - 1].id; await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/fraud/summary`, method: "POST", body: { startingAfter, }, }); return logAndRespond( `Scheduled next batch for fraud events summary (startingAfter: ${startingAfter})`, ); } return logAndRespond("Finished sending fraud events summary."); } catch (error) { return handleAndReturnErrorResponse(error); } } // GET/POST /api/cron/fraud/summary export { handler as GET, handler as POST }; ================================================ FILE: apps/web/app/(ee)/api/cron/fx-rates/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { redis } from "@/lib/upstash"; import { log } from "@dub/utils"; import { NextResponse } from "next/server"; export const dynamic = "force-dynamic"; // Cron to update the Foreign Exchange Rates in Redis // Runs once every day at 08:00 AM UTC (0 8 * * *) export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); const res = await fetch("https://api.currencyapi.com/v3/latest", { headers: { apikey: process.env.CURRENCY_API_KEY || "", }, }); const { data } = (await res.json()) as { data: Record; }; if (!data) { return NextResponse.json({ message: "Failed to fetch FX rates", }); } const transformedRates: Record = {}; for (const [ticker, details] of Object.entries(data)) { transformedRates[ticker] = details.value; } // // Store FX rates in Redis (with USD as the base currency) await redis.hset("fxRates:usd", transformedRates); return NextResponse.json(transformedRates); } catch (error) { await log({ message: `Error updating FX rates: ${error.message}`, type: "cron", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/groups/create-default-links/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { bulkCreateLinks } from "@/lib/api/links"; import { generatePartnerLink } from "@/lib/api/partners/generate-partner-link"; import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { WorkspaceProps } from "@/lib/types"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, constructURLFromUTMParams, isFulfilled, log, } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const PAGE_SIZE = 100; const MAX_BATCH = 10; const schema = z.object({ defaultLinkId: z.string(), userId: z.string(), cursor: z.string().optional(), }); /** * Cron job to create default partner links for all approved partners in a group. * * For each approved partner in the group, it creates a link based on * the group's default link configuration (domain, URL, etc.). * * It processes up to MAX_BATCH * PAGE_SIZE partners per execution * and schedules additional jobs if needed. */ // POST /api/cron/groups/create-default-links export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const { defaultLinkId, userId, cursor } = schema.parse(JSON.parse(rawBody)); // Find the default link const defaultLink = await prisma.partnerGroupDefaultLink.findUnique({ where: { id: defaultLinkId, }, include: { partnerGroup: { include: { utmTemplate: true, }, }, }, }); if (!defaultLink) { return logAndRespond( `Default link ${defaultLinkId} not found. Skipping...`, { logLevel: "error", }, ); } const group = defaultLink.partnerGroup; if (!group) { return logAndRespond( `Group ${defaultLink.groupId} not found. Skipping...`, { logLevel: "error", }, ); } console.info( `Creating default links for the partners (defaultLinkId=${defaultLink.id}, groupId=${group.id}).`, ); // Find the workspace & program const { workspace, ...program } = await prisma.program.findUniqueOrThrow({ where: { id: group.programId, }, include: { workspace: true, }, }); let hasMore = true; let currentCursor = cursor; let processedBatches = 0; while (processedBatches < MAX_BATCH) { // Find partners in the group const programEnrollments = await prisma.programEnrollment.findMany({ where: { ...(currentCursor && { id: { gt: currentCursor, }, }), groupId: group.id, status: "approved", }, include: { partner: true, }, take: PAGE_SIZE, orderBy: { id: "asc", }, }); if (programEnrollments.length === 0) { hasMore = false; break; } // Create a new defaultLink for each partner in the group const processedLinks = ( await Promise.allSettled( programEnrollments.map(({ partner, ...programEnrollment }) => generatePartnerLink({ workspace: { id: workspace.id, plan: workspace.plan as WorkspaceProps["plan"], }, program: { id: program.id, defaultFolderId: program.defaultFolderId, }, partner: { id: partner.id, name: partner.name, email: partner.email!, tenantId: programEnrollment.tenantId ?? undefined, }, link: { domain: defaultLink.domain, url: constructURLFromUTMParams( defaultLink.url, extractUtmParams(group.utmTemplate), ), ...extractUtmParams(group.utmTemplate, { excludeRef: true }), tenantId: programEnrollment.tenantId ?? undefined, partnerGroupDefaultLinkId: defaultLink.id, }, userId, }), ), ) ) .filter(isFulfilled) .map(({ value }) => value); const createdLinks = await bulkCreateLinks({ links: processedLinks, }); console.log( `Created ${createdLinks.length} default links for the partners in the group ${group.id}.`, ); // Update cursor to the last processed record currentCursor = programEnrollments[programEnrollments.length - 1].id; processedBatches++; } if (hasMore) { await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/create-default-links`, method: "POST", body: { defaultLinkId, userId, cursor: currentCursor, }, }); } return logAndRespond(`Finished creating default links for the partners.`); } catch (error) { await log({ message: `Error creating default links for the partners: ${error.message}.`, type: "errors", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/groups/remap-default-links/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { bulkCreateLinks } from "@/lib/api/links"; import { generatePartnerLink } from "@/lib/api/partners/generate-partner-link"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { WorkspaceProps } from "@/lib/types"; import { MAX_DEFAULT_LINKS_PER_GROUP } from "@/lib/zod/schemas/groups"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, isFulfilled, log, prettyPrint, } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; import { remapPartnerGroupDefaultLinks } from "./utils"; export const dynamic = "force-dynamic"; const schema = z.object({ programId: z.string(), groupId: z.string(), partnerIds: z.array(z.string()).min(1), userId: z.string().nullish(), isGroupDeleted: z.boolean().optional(), }); /** Cron job to remap default partner links for all partners in a group. The way it works: for all the partners that are just moved to the group, fetch their links that have partnerGroupDefaultLinkId set and do the following: 1. for default links with URLs matching the new group's default links (excluding query params), update the partnerGroupDefaultLinkId field to the new default link IDs (linksToUpdate) 2. for the ones that don't match, set partnerGroupDefaultLinkId to null (linksToRemoveMapping) 3. for the new group's default links that don't exist in the old group, create them (linksToCreate) This runs when: 1. partners are moved to a group 2. a group is deleted and partners need to be moved to the default group */ // POST /api/cron/groups/remap-default-links export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const { programId, groupId, partnerIds, userId, isGroupDeleted } = schema.parse(JSON.parse(rawBody)); const [program, partnerGroup, programEnrollments] = await Promise.all([ prisma.program.findUniqueOrThrow({ where: { id: programId, }, include: { workspace: true, }, }), prisma.partnerGroup.findUniqueOrThrow({ where: { id: groupId, }, include: { utmTemplate: true, partnerGroupDefaultLinks: true, }, }), prisma.programEnrollment.findMany({ where: { partnerId: { in: partnerIds, }, programId, }, include: { partner: true, partnerGroup: true, links: { // if this was invoked from the DELETE /groups/[groupId] route, the partnerGroupDefaultLinkId will be null // due to Prisma cascade SetNull on delete – therefore we should take all links and remap them instead. ...(isGroupDeleted ? {} : { where: { partnerGroupDefaultLinkId: { not: null, }, }, }), orderBy: { createdAt: "asc", }, take: MAX_DEFAULT_LINKS_PER_GROUP, // there can only be up to MAX_DEFAULT_LINKS_PER_GROUP default links per group }, }, }), ]); console.log( `Updating ${programEnrollments.length} partners to be moved to group ${partnerGroup.name} (${partnerGroup.id}) for program ${program.name} (${program.id}).`, ); const remappedLinks = programEnrollments.map( ({ partnerId, links: partnerLinks }) => remapPartnerGroupDefaultLinks({ partnerId, partnerLinks, newGroupDefaultLinks: partnerGroup.partnerGroupDefaultLinks, }), ); const linksToCreate = remappedLinks.flatMap( ({ linksToCreate }) => linksToCreate, ); const linksToUpdate = remappedLinks.flatMap( ({ linksToUpdate }) => linksToUpdate, ); const linksToRemoveMapping = remappedLinks.flatMap( ({ linksToRemoveMapping }) => linksToRemoveMapping, ); console.log("linksToUpdate", linksToUpdate); console.log("linksToCreate", linksToCreate); console.log("linksToRemoveMapping", linksToRemoveMapping); // Create the links if (linksToCreate.length > 0) { const processedLinks = ( await Promise.allSettled( linksToCreate.map((link) => { const programEnrollment = programEnrollments.find( (p) => p.partner.id === link.partnerId, ); const partner = programEnrollment?.partner; return generatePartnerLink({ workspace: { id: program.workspace.id, plan: program.workspace.plan as WorkspaceProps["plan"], }, program: { id: program.id, defaultFolderId: program.defaultFolderId, }, partner: { id: partner?.id, name: partner?.name, email: partner?.email!, tenantId: programEnrollment?.tenantId ?? undefined, }, link: { domain: link.domain, url: link.url, tenantId: programEnrollment?.tenantId ?? undefined, partnerGroupDefaultLinkId: link.partnerGroupDefaultLinkId, }, userId: userId ?? undefined, }); }), ) ) .filter(isFulfilled) .map(({ value }) => value); const createdLinks = await bulkCreateLinks({ links: processedLinks, }); console.log( `Created ${createdLinks.length} links for ${programEnrollments.length} partners that were moved to the group ${partnerGroup.name} (${partnerGroup.id}).`, ); } // Update the links if (linksToUpdate.length > 0) { const groupedLinksToUpdate = linksToUpdate.reduce( (acc, link) => { acc[link.partnerGroupDefaultLinkId] = acc[link.partnerGroupDefaultLinkId] || []; acc[link.partnerGroupDefaultLinkId].push(link.id); return acc; }, {} as Record, ); for (const [partnerGroupDefaultLinkId, linkIds] of Object.entries( groupedLinksToUpdate, )) { const updatedLinks = await prisma.link.updateMany({ where: { id: { in: linkIds, }, }, data: { partnerGroupDefaultLinkId: partnerGroupDefaultLinkId, }, }); console.log( `Updated ${updatedLinks.count} links with partnerGroupDefaultLinkId: ${partnerGroupDefaultLinkId}`, ); } } if (linksToRemoveMapping.length > 0) { const updatedLinks = await prisma.link.updateMany({ where: { id: { in: linksToRemoveMapping, }, }, data: { partnerGroupDefaultLinkId: null, }, }); console.log( `Updated ${updatedLinks.count} links with partnerGroupDefaultLinkId: null`, ); } const syncUtmJob = await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/sync-utm`, body: { groupId, partnerIds, }, }); console.log( `Scheduled sync-utm job for group ${groupId}: ${prettyPrint(syncUtmJob)}`, ); const remapDiscountCodesJob = await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/remap-discount-codes`, body: { programId, partnerIds, groupId, isGroupDeleted, }, }); console.log( `Scheduled remap-discount-codes job for group ${groupId}: ${prettyPrint(remapDiscountCodesJob)}`, ); return logAndRespond(`Finished creating default links for the partners.`); } catch (error) { await log({ message: `Error creating default links for the partners: ${error.message}.`, type: "errors", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/groups/remap-default-links/utils.ts ================================================ import { Link, PartnerGroupDefaultLink } from "@dub/prisma/client"; import { normalizeUrl } from "@dub/utils"; // Add a new method that update the partner group default links when their group changes export function remapPartnerGroupDefaultLinks({ partnerId, partnerLinks, newGroupDefaultLinks, }: { partnerId: string; partnerLinks: Pick< Link, "id" | "url" | "partnerId" | "partnerGroupDefaultLinkId" >[]; newGroupDefaultLinks: Pick< PartnerGroupDefaultLink, "id" | "domain" | "url" >[]; }) { const linksToCreate: Array<{ domain: string; url: string; partnerId: string; partnerGroupDefaultLinkId: string; }> = []; const linksToUpdate: Array<{ id: string; partnerGroupDefaultLinkId: string; }> = []; const linksToRemoveMapping: string[] = []; // Create a map of normalized URLs to new group default links for quick lookup const newDefaultLinksByUrl = new Map< string, Pick >(); newGroupDefaultLinks.forEach((defaultLink) => { const normalizedUrl = normalizeUrl(defaultLink.url); newDefaultLinksByUrl.set(normalizedUrl, defaultLink); }); // Process existing partner links partnerLinks.forEach((link) => { const normalizedLinkUrl = normalizeUrl(link.url); const matchingNewDefault = newDefaultLinksByUrl.get(normalizedLinkUrl); if (matchingNewDefault) { // URL matches (excluding url params) - update the mapping linksToUpdate.push({ id: link.id, partnerGroupDefaultLinkId: matchingNewDefault.id, }); // Remove from the map so we don't create a duplicate newDefaultLinksByUrl.delete(normalizedLinkUrl); } else { // URL doesn't match - remove the mapping linksToRemoveMapping.push(link.id); } }); // Create new links for any remaining new default links that didn't match existing ones newDefaultLinksByUrl.forEach((defaultLink) => { linksToCreate.push({ domain: defaultLink.domain, url: defaultLink.url, partnerId, partnerGroupDefaultLinkId: defaultLink.id, }); }); return { linksToCreate, linksToUpdate, linksToRemoveMapping, }; } ================================================ FILE: apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts ================================================ import { createDiscountCode } from "@/lib/api/discounts/create-discount-code"; import { deleteDiscountCodes } from "@/lib/api/discounts/delete-discount-code"; import { isDiscountEquivalent } from "@/lib/api/discounts/is-discount-equivalent"; import { withCron } from "@/lib/cron/with-cron"; import { prisma } from "@dub/prisma"; import { DiscountCode } from "@dub/prisma/client"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const inputSchema = z.object({ programId: z.string(), groupId: z.string(), partnerIds: z.array(z.string()), isGroupDeleted: z.boolean().optional(), }); // POST /api/cron/groups/remap-discount-codes export const POST = withCron(async ({ rawBody }) => { const { programId, partnerIds, groupId, isGroupDeleted } = inputSchema.parse( JSON.parse(rawBody), ); if (partnerIds.length === 0) { return logAndRespond("No partner IDs provided."); } const programEnrollments = await prisma.programEnrollment.findMany({ where: { partnerId: { in: partnerIds, }, programId, }, include: { discountCodes: { include: { discount: true, }, }, }, }); const oldDiscount = programEnrollments[0]?.discountCodes[0]?.discount; if (programEnrollments.length === 0) { return logAndRespond("No program enrollments found."); } const group = await prisma.partnerGroup.findUnique({ where: { id: groupId, }, include: { discount: true, }, }); if (!group) { return logAndRespond("Group not found."); } const discountCodes = programEnrollments.flatMap( ({ discountCodes }) => discountCodes, ); // Find the discount codes to update and remove const discountCodesToUpdate: DiscountCode[] = []; const discountCodesToRemove: DiscountCode[] = []; for (const discountCode of discountCodes) { const keepDiscountCode = isDiscountEquivalent( group.discount, discountCode.discount, ); if (keepDiscountCode) { discountCodesToUpdate.push(discountCode); } else { discountCodesToRemove.push(discountCode); } } // Update the discount codes to use the new discount if they are equivalent if (discountCodesToUpdate.length > 0) { console.log( `Found ${discountCodesToUpdate.length} discount codes equivalent to the new group's discount. Updating them.`, ); await prisma.discountCode.updateMany({ where: { id: { in: discountCodesToUpdate.map(({ id }) => id), }, }, data: { discountId: group.discount?.id, }, }); } // Remove the previous discount codes if (discountCodesToRemove.length > 0) { console.log( `Found ${discountCodesToRemove.length} discount codes not equivalent to the new group's discount. Deleting them.`, ); await deleteDiscountCodes(discountCodesToRemove); } if (group.discount?.autoProvisionEnabledAt) { // Find the partner default links that don't have a discount code yet const links = await prisma.link.findMany({ where: { partnerId: { in: partnerIds, }, programId, partnerGroupDefaultLinkId: { not: null, }, discountCode: { is: null, }, }, select: { id: true, programEnrollment: { select: { partner: { select: { id: true, name: true, }, }, }, }, }, }); if (links.length > 0) { const workspace = await prisma.project.findUniqueOrThrow({ where: { defaultProgramId: programId, }, select: { stripeConnectId: true, }, }); // Create discount code for the partner default links if (workspace.stripeConnectId) { for (const link of links) { await createDiscountCode({ stripeConnectId: workspace.stripeConnectId, partner: link.programEnrollment!.partner, link, discount: group.discount, }); } } } } // if the group is deleted, need to check if there are any remaining discount codes, if not, delete the discount if (isGroupDeleted && oldDiscount) { const remainingDiscountCodes = await prisma.discountCode.count({ where: { discountId: oldDiscount.id, }, }); if (remainingDiscountCodes === 0) { await prisma.discount.deleteMany({ where: { id: oldDiscount.id, }, }); console.log( `Deleted discount ${oldDiscount.id} because it has no remaining discount codes.`, ); } } return logAndRespond("Finished remapping discount codes for the group."); }); ================================================ FILE: apps/web/app/(ee)/api/cron/groups/sync-utm/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { linkCache } from "@/lib/api/links/cache"; import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, constructURLFromUTMParams, log, } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const PAGE_SIZE = 50; const schema = z.object({ groupId: z.string(), partnerIds: z.array(z.string()).optional(), startAfterProgramEnrollmentId: z.string().optional(), }); /** Syncs the UTM parameter settings for a given group (whether there is a UTM template or not) This job is triggered when: 1. a UTM template is created for a group 2. a UTM template is updated 3. in groups/remap-default-links cron */ // POST /api/cron/groups/sync-utm export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const { groupId, partnerIds, startAfterProgramEnrollmentId } = schema.parse( JSON.parse(rawBody), ); // Find the UTM template const group = await prisma.partnerGroup.findUnique({ where: { id: groupId, }, include: { utmTemplate: true, }, }); if (!group) { return logAndRespond( `Group ${groupId} not found for groups/sync-utm cron. Skipping...`, { logLevel: "error", }, ); } const { utmTemplate } = group; // Find partners in the group const programEnrollments = await prisma.programEnrollment.findMany({ where: { groupId: group.id, ...(partnerIds && { partnerId: { in: partnerIds, }, }), ...(startAfterProgramEnrollmentId && { id: { gt: startAfterProgramEnrollmentId, }, }), }, take: PAGE_SIZE, orderBy: { id: "asc", }, include: { links: true, }, }); if (programEnrollments.length === 0) { return logAndRespond(`No program enrollments found. Skipping...`); } // extract links from program enrollments const linksToUpdate = programEnrollments.flatMap((enrollment) => enrollment.links.map((link) => link), ); // group links by the same url const groupedLinksToUpdate = linksToUpdate.reduce( (acc, link) => { acc[link.url] = acc[link.url] || []; acc[link.url].push(link.id); return acc; }, {} as Record, ); // Update the UTM for each partner links in the group for (const [url, linkIds] of Object.entries(groupedLinksToUpdate)) { const payload = { url: constructURLFromUTMParams(url, extractUtmParams(utmTemplate)), ...extractUtmParams(utmTemplate, { excludeRef: true }), }; const updatedLinks = await prisma.link.updateMany({ where: { id: { in: linkIds, }, }, data: payload, }); console.log( `Updated ${updatedLinks.count} links with URL: ${payload.url}`, ); } const redisRes = await linkCache.expireMany(linksToUpdate); console.log(`Updated Redis cache: ${JSON.stringify(redisRes, null, 2)}`); if (programEnrollments.length === PAGE_SIZE) { await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/sync-utm`, method: "POST", body: { groupId, partnerIds, startAfterProgramEnrollmentId: programEnrollments[programEnrollments.length - 1].id, }, }); } return logAndRespond( `Finished syncing UTM settings for ${programEnrollments.length} partners in the ${group.name} group (${group.id}).`, ); } catch (error) { await log({ message: `Error syncing UTM settings: ${error.message}.`, type: "errors", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/groups/update-default-links/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { linkCache } from "@/lib/api/links/cache"; import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, constructURLFromUTMParams, log, } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const PAGE_SIZE = 100; const MAX_BATCH = 10; const schema = z.object({ defaultLinkId: z.string(), cursor: z.string().optional(), }); /** * Cron job to update existing partner links when a group's default link configuration changes. * * For each link associated with a default link, it updates the domain and URL * to match the new default link configuration while preserving UTM parameters. * * It processes up to MAX_BATCH * PAGE_SIZE links per execution * and schedules additional jobs if needed. */ // POST /api/cron/groups/update-default-links export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const { defaultLinkId, cursor } = schema.parse(JSON.parse(rawBody)); // Find the default link const defaultLink = await prisma.partnerGroupDefaultLink.findUnique({ where: { id: defaultLinkId, }, include: { partnerGroup: { include: { utmTemplate: true, }, }, }, }); if (!defaultLink) { return logAndRespond( `Default link ${defaultLinkId} not found. Skipping...`, { logLevel: "error", }, ); } const group = defaultLink.partnerGroup; if (!group) { return logAndRespond( `Group ${defaultLink.groupId} not found. Skipping...`, { logLevel: "error", }, ); } console.info( `Updating default links for the partners (defaultLinkId=${defaultLink.id}, groupId=${group.id}).`, ); let hasMore = true; let currentCursor = cursor; let processedBatches = 0; while (processedBatches < MAX_BATCH) { const linksToUpdate = await prisma.link.findMany({ where: { ...(currentCursor && { id: { gt: currentCursor, }, }), partnerGroupDefaultLinkId: defaultLink.id, }, take: PAGE_SIZE, orderBy: { id: "asc", }, }); if (linksToUpdate.length === 0) { hasMore = false; break; } const updatedLinks = await prisma.link.updateMany({ where: { id: { in: linksToUpdate.map((link) => link.id), }, }, data: { url: constructURLFromUTMParams( defaultLink.url, extractUtmParams(group.utmTemplate), ), ...extractUtmParams(group.utmTemplate, { excludeRef: true }), }, }); console.log( `Updated ${updatedLinks.count} links with url=${defaultLink.url} (via defaultLinkId=${defaultLink.id})`, ); await linkCache.expireMany(linksToUpdate); // Update cursor to the last processed record currentCursor = linksToUpdate[linksToUpdate.length - 1].id; processedBatches++; } if (hasMore) { await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/update-default-links`, method: "POST", body: { defaultLinkId, cursor: currentCursor, }, }); } return logAndRespond(`Finished updating default links for the partners.`); } catch (error) { await log({ message: `Error updating default links for the partners: ${error.message}.`, type: "errors", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/import/bitly/fetch-utils.ts ================================================ /** * Utilities for fetching links from Bitly API */ import { sanitizeBitlyJson } from "./sanitize-json"; interface FetchBitlyLinksResult { links: any[]; nextSearchAfter: string | null; rateLimited: boolean; batchStats?: { batchCount: number; totalLinks: number; }; } /** * Fetch links from Bitly with batch support for high rate limit groups */ export const fetchBitlyLinks = async ({ bitlyGroup, bitlyApiKey, searchAfter = null, createdBefore = null, }: { bitlyGroup: string; bitlyApiKey: string; searchAfter: string | null; createdBefore: string | null; }): Promise => { // Use batch fetching for high rate limit group if (bitlyGroup === "Backg8weUUQ") { console.log("Using batch fetching for high rate limit group"); return fetchBitlyLinksBatch({ bitlyGroup, bitlyApiKey, searchAfter, createdBefore, }); } // Use standard fetching for regular groups return fetchBitlyLinksStandard({ bitlyGroup, bitlyApiKey, searchAfter, createdBefore, }); }; /** * Standard method to fetch links from Bitly (single request) */ const fetchBitlyLinksStandard = async ({ bitlyGroup, bitlyApiKey, searchAfter, createdBefore, }: { bitlyGroup: string; bitlyApiKey: string; searchAfter: string | null; createdBefore: string | null; }): Promise => { const response = await fetch( `https://api-ssl.bitly.com/v4/groups/${bitlyGroup}/bitlinks?${new URLSearchParams( { size: "100", ...(searchAfter && { search_after: searchAfter }), ...(createdBefore && { created_before: createdBefore }), }, )}`, { headers: { "Content-Type": "application/json", Authorization: `Bearer ${bitlyApiKey}`, }, }, ); if (!response.ok && response.status === 429) { return { links: [], nextSearchAfter: searchAfter, rateLimited: true, }; } // Get response as text first const responseText = await response.text(); const sanitizedResponseText = sanitizeBitlyJson(responseText); let data; try { // Sanitize the JSON and then parse it data = JSON.parse(sanitizedResponseText); } catch (error) { console.error("JSON parsing error:", error); console.error(`Failed to parse response: ${sanitizedResponseText}`); throw new Error("Failed to parse JSON response from Bitly API"); } if (!data.links || !data.pagination) { console.log("Unexpected response format:", data); return { links: [], nextSearchAfter: null, rateLimited: false, }; } const { links, pagination } = data; const nextSearchAfter = pagination.search_after; return { links, nextSearchAfter, rateLimited: false, }; }; /** * Batch method to fetch links from Bitly (multiple requests) * For use with high rate limit groups */ const fetchBitlyLinksBatch = async ({ bitlyGroup, bitlyApiKey, searchAfter, createdBefore, }: { bitlyGroup: string; bitlyApiKey: string; searchAfter: string | null; createdBefore: string | null; }): Promise => { // Array to collect all links from multiple requests let allLinks: any[] = []; let currentSearchAfter = searchAfter; let nextSearchAfter = null; const maxRequests = 10; // Number of consecutive requests to make // Make multiple requests to fetch up to 1000 links for (let i = 0; i < maxRequests; i++) { const response = await fetch( `https://api-ssl.bitly.com/v4/groups/${bitlyGroup}/bitlinks?${new URLSearchParams( { size: "100", ...(currentSearchAfter && { search_after: currentSearchAfter }), ...(createdBefore && { created_before: createdBefore }), }, )}`, { headers: { "Content-Type": "application/json", Authorization: `Bearer ${bitlyApiKey}`, }, }, ); // Check for rate limiting if (!response.ok && response.status === 429) { // If rate limited on the first request, return with rateLimited flag if (i === 0) { return { links: [], nextSearchAfter: searchAfter, rateLimited: true, }; } else { // If rate limited after collecting some links, process what we have so far console.log( `Rate limited after ${i} requests. Processing ${allLinks.length} links.`, ); break; } } // Get response as text first const responseText = await response.text(); const sanitizedResponseText = sanitizeBitlyJson(responseText); let data; try { // Sanitize the JSON and then parse it data = JSON.parse(sanitizedResponseText); } catch (error) { console.error("JSON parsing error:", error); console.error(`Failed to parse response: ${sanitizedResponseText}`); if (i === 0) { throw new Error("Failed to parse JSON response from Bitly API"); } break; // Process what we have so far } // If the response is not as expected, break the loop if (!data.links || !data.pagination) { console.log("Unexpected response format:", data); if (i === 0) { return { links: [], nextSearchAfter: null, rateLimited: false, }; } break; // Process what we have so far } const { links, pagination } = data; nextSearchAfter = pagination.search_after; // Add links to our collection allLinks = [...allLinks, ...links]; // Update search_after for next request currentSearchAfter = nextSearchAfter; // If there are no more links to fetch, break the loop if (!nextSearchAfter || links.length < 100) { break; } } console.log( `Batch fetched ${allLinks.length} links in ${Math.ceil(allLinks.length / 100)} requests`, ); return { links: allLinks, nextSearchAfter, rateLimited: false, batchStats: { batchCount: Math.ceil(allLinks.length / 100), totalLinks: allLinks.length, }, }; }; ================================================ FILE: apps/web/app/(ee)/api/cron/import/bitly/queue-import.ts ================================================ import { qstash } from "@/lib/cron"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; // Queue a Bitly import export const queueBitlyImport = async (payload: { workspaceId: string; userId: string; bitlyGroup: string; domains: string[]; folderId?: string; tagsToId?: Record; searchAfter?: string | null; count?: number; rateLimited?: boolean; delay?: number; }) => { const { tagsToId, delay, ...rest } = payload; return await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/bitly`, body: { ...rest, importTags: tagsToId ? true : false, }, ...(delay && { delay }), }); }; ================================================ FILE: apps/web/app/(ee)/api/cron/import/bitly/rate-limit.ts ================================================ import { queueBitlyImport } from "./queue-import"; /** * Check if we're rate limited and handle accordingly */ export const checkIfRateLimited = async (bitlyApiKey: unknown, body: any) => { const path = "/groups/{group_guid}/bitlinks"; const response = await fetch( `https://api-ssl.bitly.com/v4/user/platform_limits?path=${path}`, { headers: { Authorization: `Bearer ${bitlyApiKey}`, "Content-Type": "application/json", }, }, ); const data = (await response.json()) as { platform_limits: { endpoint: string; methods: { name: string; limit: number; count: number; }[]; }[]; }; const endpoint = data.platform_limits[0].methods.find( (method) => method.name === "GET", )!; const limit = endpoint.limit; const currentUsage = endpoint.count; console.log("checkIfRateLimited", endpoint); console.log("originalBody", body); const isRateLimited = currentUsage >= limit; if (isRateLimited) { await queueBitlyImport({ ...body, rateLimited: true, delay: 2 * 60, // try again after 2 minutes }); } return isRateLimited; }; ================================================ FILE: apps/web/app/(ee)/api/cron/import/bitly/route.ts ================================================ import { createId } from "@/lib/api/create-id"; import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { redis } from "@/lib/upstash"; import { randomBadgeColor } from "@/ui/links/tag-badge"; import { prisma } from "@dub/prisma"; import { log } from "@dub/utils"; import { NextResponse } from "next/server"; import { checkIfRateLimited } from "./rate-limit"; import { importLinksFromBitly } from "./utils"; export const dynamic = "force-dynamic"; export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const body = JSON.parse(rawBody); const { workspaceId, bitlyGroup, importTags, rateLimited = false } = body; try { const bitlyApiKey = await redis.get(`import:bitly:${workspaceId}`); if (rateLimited) { const isRateLimited = await checkIfRateLimited(bitlyApiKey, body); if (isRateLimited) { return NextResponse.json({ response: "rate_limited", }); } } let tagsToId: Record | null = null; if (importTags === true) { const tagsImported = await redis.get( `import:bitly:${workspaceId}:tags`, ); if (!tagsImported) { const tags = (await fetch( `https://api-ssl.bitly.com/v4/groups/${bitlyGroup}/tags`, { headers: { "Content-Type": "application/json", Authorization: `Bearer ${bitlyApiKey}`, }, }, ) .then((r) => r.json()) .then((r) => r.tags)) as string[]; await prisma.tag.createMany({ data: tags.map((tag) => ({ id: createId({ prefix: "tag_" }), name: tag, color: randomBadgeColor(), projectId: workspaceId, })), skipDuplicates: true, }); await redis.set(`import:bitly:${workspaceId}:tags`, "true"); } tagsToId = await prisma.tag .findMany({ where: { projectId: workspaceId, }, select: { id: true, name: true, }, }) .then((tags) => tags.reduce((acc, tag) => { acc[tag.name] = tag.id; return acc; }, {}), ); } await importLinksFromBitly({ ...body, tagsToId, bitlyApiKey, }); return NextResponse.json({ response: "success", }); } catch (error) { const workspace = await prisma.project.findUnique({ where: { id: workspaceId, }, select: { slug: true, }, }); throw new DubApiError({ code: "bad_request", message: `Workspace: ${workspace?.slug || workspaceId}. Error: ${error.message}`, }); } } catch (error) { await log({ message: `Error importing Bitly links: ${error.message}`, type: "cron", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/import/bitly/sanitize-json.ts ================================================ export function sanitizeBitlyJson(body: string): string { try { // if body is already valid JSON, return it JSON.parse(body); return body; } catch (err) { console.error("Error parsing JSON, starting sanitization..."); } // First, remove "title" field which can sometimes contain invalid values that break the JSON parsing body = body.replace( /"long_url":"([^"]+)".+?"archived"/g, '"long_url":"$1","archived"', ); // Handle problematic characters in URLs themselves body = body.replace(/"long_url":"(.*?)"/g, (_match, url) => { // Escape backslashes and quotes that might be in the URL const safeUrl = url .replace(/\\/g, "\\\\") .replace(/"/g, '\\"') // Additional problematic characters to escape .replace(/[\u0000-\u001F\u007F-\u009F]/g, ""); return `"long_url":"${safeUrl}"`; }); // Then handle control characters return body.replace(/[\u0000-\u001F\u007F-\u009F]/g, (char) => { // Convert to proper JSON escape sequence if it's a common one switch (char) { case "\n": return "\\n"; case "\r": return "\\r"; case "\t": return "\\t"; case "\b": return "\\b"; case "\f": return "\\f"; // Remove or replace other control characters default: return ""; } }); } ================================================ FILE: apps/web/app/(ee)/api/cron/import/bitly/utils.ts ================================================ import { bulkCreateLinks } from "@/lib/api/links"; import { redis } from "@/lib/upstash"; import { sendEmail } from "@dub/email"; import LinksImported from "@dub/email/templates/links-imported"; import { prisma } from "@dub/prisma"; import { getUrlFromStringIfValid, linkConstructorSimple } from "@dub/utils"; import { fetchBitlyLinks } from "./fetch-utils"; import { queueBitlyImport } from "./queue-import"; // Note: rate limit for /groups/{group_guid}/bitlinks is 1500 per hour or 150 per minute export const importLinksFromBitly = async ({ workspaceId, userId, bitlyGroup, domains, folderId, tagsToId, bitlyApiKey, searchAfter = null, createdBefore = null, count = 0, }: { workspaceId: string; userId: string; bitlyGroup: string; domains: string[]; folderId?: string; tagsToId?: Record; bitlyApiKey: string; searchAfter?: string | null; createdBefore?: string | null; count?: number; }) => { // Fetch links from Bitly (either standard or batch method based on bitlyGroup) const { links, nextSearchAfter, rateLimited, batchStats } = await fetchBitlyLinks({ bitlyGroup, bitlyApiKey, searchAfter, createdBefore, }); // If rate limited, queue for later if (rateLimited) { return await queueBitlyImport({ workspaceId, userId, bitlyGroup, domains, folderId, tagsToId, searchAfter, count, rateLimited: true, }); } // If no links were returned, exit early if (!links || links.length === 0) { console.log("No links returned from Bitly"); return count; } const invalidLinks: any[] = []; // convert links to format that can be imported into database const importedLinks = links.flatMap( ({ id, long_url: url, archived, created_at, custom_bitlinks, tags }) => { if (!id || !url) { return []; } const [domain, key] = id.split("/"); // if domain is not in workspace domains, skip (could be a bit.ly link or old short domain) if (!domains.includes(domain)) { invalidLinks.push({ id, url, }); return []; } const sanitizedUrl = getUrlFromStringIfValid(url); // skip if url is not valid if (!sanitizedUrl) { invalidLinks.push({ id, url, }); return []; } const createdAt = new Date(created_at).toISOString(); const tagIds = tagsToId ? tags.map((tag: string) => tagsToId[tag]) : []; const linkDetails = { projectId: workspaceId, userId, domain, key, url: sanitizedUrl, shortLink: linkConstructorSimple({ domain, key, }), archived, createdAt, tagIds, folderId, }; return [ linkDetails, // if link has custom bitlinks, add them to the list of links to import ...(custom_bitlinks ?.filter((customBitlink: string) => { try { const customDomain = new URL(customBitlink).hostname; // only import custom bitlinks that have the same domain as the domains // that were previously imported into the workspace from bitly return domains.includes(customDomain); } catch (e) { console.error( `Invalid custom bitlink, skipping: ${customBitlink}`, ); return false; } }) .map((customBitlink: string) => { try { // here we are getting the customDomain again just in case // the custom bitlink doesn't have the same domain as the // original bitlink, but it should const customDomain = new URL(customBitlink).hostname; const customKey = new URL(customBitlink).pathname.slice(1); // Create a copy with the new domain and key return { ...linkDetails, domain: customDomain, key: customKey, shortLink: linkConstructorSimple({ domain: customDomain, key: customKey, }), }; } catch (e) { console.error( `Error processing custom bitlink, skipping: ${customBitlink}`, ); return null; } }) .filter(Boolean) ?? []), ]; }, ); console.log(`Creating ${importedLinks.length} new links...`); // bulk create links await bulkCreateLinks({ links: importedLinks, skipRedisCache: true }); count += importedLinks.length; // Log batch stats if available console.log({ importedLinksLength: importedLinks.length, count, nextSearchAfter, ...(batchStats && { batchStats }), }); console.log(`Invalid links: ${invalidLinks.length}`); console.log(JSON.stringify(invalidLinks, null, 2)); const finalImportedLink = importedLinks[importedLinks.length - 1]; console.log( `Successfully imported ${importedLinks.length} new links! Final imported link: ${finalImportedLink?.shortLink} (${finalImportedLink ? new Date(finalImportedLink.createdAt).toISOString() : "none"})`, ); if (nextSearchAfter === "") { const workspace = await prisma.project.findUnique({ where: { id: workspaceId, }, select: { name: true, slug: true, users: { where: { role: "owner", }, select: { user: { select: { email: true, }, }, }, }, // only include links if less than 10,000 links have been imported ...(count < 10_000 && { links: { select: { domain: true, key: true, createdAt: true, }, where: { domain: { in: domains, }, }, take: 5, orderBy: { createdAt: "desc", }, }, }), }, }); const ownerEmail = workspace?.users[0].user.email ?? ""; const links = workspace?.links ?? []; await Promise.all([ // delete keys from redis redis.del(`import:bitly:${workspaceId}`), redis.del(`import:bitly:${workspaceId}:tags`), // delete tags that have no links prisma.tag.deleteMany({ where: { projectId: workspaceId, links: { none: {}, }, }, }), // send email to user sendEmail({ subject: `Your Bitly links have been imported!`, to: ownerEmail, react: LinksImported({ email: ownerEmail, provider: "Bitly", count, links, domains, workspaceName: workspace?.name ?? "", workspaceSlug: workspace?.slug ?? "", }), }), ]); return count; } else { return await queueBitlyImport({ workspaceId, userId, bitlyGroup, domains, folderId, tagsToId, searchAfter: nextSearchAfter, count, }); } }; ================================================ FILE: apps/web/app/(ee)/api/cron/import/csv/route.ts ================================================ import { createId } from "@/lib/api/create-id"; import { addDomainToVercel } from "@/lib/api/domains/add-domain-vercel"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { bulkCreateLinks, createLink, processLink } from "@/lib/api/links"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { ProcessedLinkProps } from "@/lib/types"; import { redis } from "@/lib/upstash"; import { linkMappingSchema } from "@/lib/zod/schemas/import-csv"; import { createLinkBodySchema } from "@/lib/zod/schemas/links"; import { randomBadgeColor } from "@/ui/links/tag-badge"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, DEFAULT_LINK_PROPS, DUB_DOMAINS_ARRAY, linkConstructorSimple, log, normalizeString, parseDateTime, } from "@dub/utils"; import { getUrlObjFromString } from "@dub/utils/src"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; import { sendCsvImportEmails } from "./utils"; export const dynamic = "force-dynamic"; const payloadSchema = z.object({ workspaceId: z.string(), userId: z.string(), id: z.string(), folderId: z.string().nullable(), mapping: linkMappingSchema, }); interface MapperResult { success: boolean; error?: string; data?: { domain: string; key: string; url: string; title?: string; description?: string; tags?: string[]; createdAt?: Date; }; } interface ErrorLink { domain: string; key: string; error: string; } // POST /api/cron/import/csv export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); const payload = payloadSchema.parse(JSON.parse(rawBody)); const { workspaceId, id } = payload; const redisKey = `import:csv:${workspaceId}:${id}`; const BATCH_SIZE = 100; const rows = await redis.lpop[]>( `${redisKey}:rows`, BATCH_SIZE, ); if (rows && rows.length > 0) { const mappedLinks: MapperResult[] = rows.map((row) => mapCsvRowToLink(row, payload.mapping), ); await processMappedLinks({ mappedLinks, payload, }); await redis.incrby(`${redisKey}:processed`, rows.length); if (rows.length === BATCH_SIZE) { const response = await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/csv`, body: payload, }); return NextResponse.json(response); } } // Finished processing all rows const errorLinks = await redis.lrange( `${redisKey}:failed`, 0, -1, ); const createdCount = parseInt( (await redis.get(`${redisKey}:created`)) || "0", ); const domains = await redis.smembers(`${redisKey}:domains`); await sendCsvImportEmails({ workspaceId, count: createdCount, domains, errorLinks, }); await Promise.allSettled([ redis.del(`${redisKey}:created`), redis.del(`${redisKey}:failed`), redis.del(`${redisKey}:domains`), redis.del(`${redisKey}:rows`), redis.del(`${redisKey}:processed`), ]); return NextResponse.json("OK"); } catch (error) { await log({ message: `Error importing CSV links: ${error.message}`, type: "cron", }); return handleAndReturnErrorResponse(error); } } // Map a CSV row to a link const mapCsvRowToLink = ( row: Record, mapping: z.infer, ): MapperResult => { try { // Helper function to get value from CSV row using case-insensitive matching const getValueByKey = (targetKey: string) => { const key = Object.keys(row).find( (k) => normalizeString(k) === normalizeString(targetKey), ); return key ? row[key].trim() : ""; }; const linkValue = getValueByKey(mapping.link); const urlValue = getValueByKey(mapping.url); if (!linkValue) { return { success: false, error: "Missing required field: link", }; } if (!urlValue) { return { success: false, error: "Missing required field: url", }; } const linkObj = getUrlObjFromString(linkValue); if (!linkObj) { return { success: false, error: `Invalid link format: ${linkValue}`, }; } const domain = linkObj.hostname; const key = linkObj.pathname.slice(1) || "_root"; let urlObj: URL; try { urlObj = new URL(urlValue); } catch { return { success: false, error: `Invalid URL format: ${urlValue}`, }; } const link: MapperResult["data"] = { domain, key, url: urlObj.toString(), }; if (mapping.title) { const title = getValueByKey(mapping.title); if (title) { link.title = title; } } if (mapping.description) { const description = getValueByKey(mapping.description); if (description) { link.description = description; } } if (mapping.createdAt) { const createdAt = getValueByKey(mapping.createdAt); if (createdAt) { const date = parseDateTime(createdAt); if (date) { link.createdAt = date; } } } if (mapping.tags) { const tags = getValueByKey(mapping.tags); if (tags) { link.tags = tags .split(",") .map((tag) => tag.trim()) .filter(Boolean) .map((tag) => normalizeString(tag)); } } return { success: true, data: link, }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : "Unknown error occurred", }; } }; // Process the mapped links and create the tag/domain/link in the database const processMappedLinks = async ({ mappedLinks, payload, }: { mappedLinks: MapperResult[]; payload: z.infer; }) => { const { workspaceId, userId, folderId } = payload; const redisKey = `import:csv:${workspaceId}:${payload.id}`; if (mappedLinks.length === 0) { console.log("No links to process."); return; } const successfulMappings = mappedLinks.filter( ( result, ): result is { success: true; data: NonNullable } => result.success && !!result.data, ); // Process the tags let selectedTags = successfulMappings .map((result) => result.data.tags || []) .flat() .filter((tag): tag is string => Boolean(tag)); selectedTags = [...new Set(selectedTags)]; const tags = await prisma.tag.findMany({ where: { projectId: workspaceId, }, select: { id: true, name: true, }, }); const tagsNotInWorkspace = selectedTags.filter( (tag) => !tags.some((t) => t.name.toLowerCase() === tag.toLowerCase()), ); if (tagsNotInWorkspace.length > 0) { console.log(`Creating ${tagsNotInWorkspace.length} new tags.`); await prisma.tag.createMany({ data: tagsNotInWorkspace.map((name) => ({ id: createId({ prefix: "tag_" }), projectId: workspaceId, name, color: randomBadgeColor(), })), skipDuplicates: true, }); } // Process the domains let selectedDomains = successfulMappings .map((result) => result.data.domain) .filter((domain): domain is string => Boolean(domain)); selectedDomains = [...new Set(selectedDomains)]; const domains = await prisma.domain.findMany({ where: { projectId: workspaceId, }, }); const domainsNotInWorkspace = selectedDomains.filter( (domain) => !domains.some((d) => d.slug === domain) && !DUB_DOMAINS_ARRAY.includes(domain), ); if (domainsNotInWorkspace.length > 0) { console.log(`Creating ${domainsNotInWorkspace.length} new domains.`); await Promise.allSettled([ prisma.domain.createMany({ data: domainsNotInWorkspace.map((slug) => ({ id: createId({ prefix: "dom_" }), projectId: workspaceId, slug, primary: false, })), skipDuplicates: true, }), domainsNotInWorkspace.map((domain) => addDomainToVercel(domain)), domainsNotInWorkspace.map((domain) => createLink({ ...DEFAULT_LINK_PROPS, projectId: workspaceId, userId, domain, key: "_root", url: "", tags: undefined, }), ), ]); } if (selectedDomains.length > 0) { await redis.sadd(`${redisKey}:domains`, ...(selectedDomains as [string])); } // Process the links let linksToCreate = successfulMappings.map((result) => result.data); const existingLinks = await prisma.link.findMany({ where: { projectId: workspaceId, shortLink: { in: linksToCreate.map((link) => linkConstructorSimple(link)), }, }, select: { shortLink: true, }, }); console.log(`Skipping ${existingLinks.length} existing links.`); linksToCreate = linksToCreate.filter( (link) => !existingLinks.some((l) => l.shortLink === linkConstructorSimple(link)), ); const workspace = await prisma.project.findUniqueOrThrow({ where: { id: workspaceId, }, select: { id: true, plan: true, users: { where: { userId, }, }, }, }); const processedLinks = await Promise.all( linksToCreate.map(({ tags, ...link }) => processLink({ payload: { ...createLinkBodySchema.parse({ ...link, tagNames: tags || [], folderId, }), }, workspace, userId, bulk: true, }), ), ); const validLinks = processedLinks .filter(({ error }) => error == null) .map(({ link }) => link); const errorLinks = processedLinks .filter(({ error }) => error != null) .map(({ link: { domain, key }, error }) => ({ domain, key, error, })); if (validLinks.length > 0) { console.log(`Creating ${validLinks.length} new links.`); await bulkCreateLinks({ links: validLinks as ProcessedLinkProps[], skipRedisCache: true, }); await redis.incrby(`${redisKey}:created`, validLinks.length); } if (errorLinks.length > 0) { console.log(`${errorLinks.length} failed to create.`); await redis.rpush(`${redisKey}:failed`, ...errorLinks); } }; ================================================ FILE: apps/web/app/(ee)/api/cron/import/csv/utils.ts ================================================ import { sendEmail } from "@dub/email"; import LinksImportErrors from "@dub/email/templates/links-import-errors"; import LinksImported from "@dub/email/templates/links-imported"; import { prisma } from "@dub/prisma"; export async function sendCsvImportEmails({ workspaceId, count, domains, errorLinks, }: { workspaceId: string; count: number; domains: string[]; errorLinks: { domain: string; key: string; error: string; }[]; }) { domains = Array.isArray(domains) && domains.length > 0 ? domains : []; errorLinks = Array.isArray(errorLinks) && errorLinks.length > 0 ? errorLinks : []; const workspace = await prisma.project.findUnique({ where: { id: workspaceId, }, select: { name: true, slug: true, users: { where: { role: "owner", }, select: { user: { select: { email: true, }, }, }, }, links: { select: { domain: true, key: true, createdAt: true, }, where: { domain: { in: domains, }, }, take: 5, orderBy: { createdAt: "desc", }, }, }, }); const ownerEmail = workspace?.users[0].user.email ?? ""; if (count > 0) { sendEmail({ subject: `Your CSV links have been imported!`, to: ownerEmail, react: LinksImported({ email: ownerEmail, provider: "CSV", count, links: workspace?.links ?? [], domains, workspaceName: workspace?.name ?? "", workspaceSlug: workspace?.slug ?? "", }), }); } if (errorLinks.length > 0) { sendEmail({ subject: `Some CSV links failed to import`, to: ownerEmail, react: LinksImportErrors({ email: ownerEmail, provider: "CSV", errorLinks, workspaceName: workspace?.name ?? "", workspaceSlug: workspace?.slug ?? "", }), }); } } ================================================ FILE: apps/web/app/(ee)/api/cron/import/firstpromoter/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { importCampaigns } from "@/lib/firstpromoter/import-campaigns"; import { importCommissions } from "@/lib/firstpromoter/import-commissions"; import { importCustomers } from "@/lib/firstpromoter/import-customers"; import { importPartners } from "@/lib/firstpromoter/import-partners"; import { firstPromoterImportPayloadSchema } from "@/lib/firstpromoter/schemas"; import { updateStripeCustomers } from "@/lib/firstpromoter/update-stripe-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-campaigns": await importCampaigns(payload); break; case "import-partners": await importPartners(payload); break; case "import-customers": await importCustomers(payload); break; case "import-commissions": await importCommissions(payload); break; case "update-stripe-customers": await updateStripeCustomers(payload); break; default: throw new Error(`Unknown action: ${payload.action}`); } return NextResponse.json("OK"); } catch (error) { return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/import/partnerstack/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { importCommissions } from "@/lib/partnerstack/import-commissions"; import { importCustomers } from "@/lib/partnerstack/import-customers"; import { importGroups } from "@/lib/partnerstack/import-groups"; import { importLinks } from "@/lib/partnerstack/import-links"; import { importPartners } from "@/lib/partnerstack/import-partners"; import { partnerStackImportPayloadSchema } from "@/lib/partnerstack/schemas"; import { updateStripeCustomers } from "@/lib/partnerstack/update-stripe-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 = partnerStackImportPayloadSchema.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; case "update-stripe-customers": await updateStripeCustomers(payload); break; default: throw new Error(`Unknown action: ${payload.action}`); } return NextResponse.json("OK"); } catch (error) { return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/import/rebrandly/route.ts ================================================ import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { redis } from "@/lib/upstash"; import { prisma } from "@dub/prisma"; import { log } from "@dub/utils"; import { NextResponse } from "next/server"; import { importLinksFromRebrandly, importTagsFromRebrandly } from "./utils"; export const dynamic = "force-dynamic"; export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const body = JSON.parse(rawBody); const { workspaceId, importTags, createdAfter } = body; try { const rebrandlyApiKey = await redis.get( `import:rebrandly:${workspaceId}`, ); if (!rebrandlyApiKey) { throw new DubApiError({ code: "bad_request", message: "Rebrandly API key not found", }); } let tagsToId: Record | null = null; if (importTags === true) { const tagsImported = await redis.get( `import:rebrandly:${workspaceId}:tags`, ); if (!tagsImported) { await importTagsFromRebrandly({ workspaceId, rebrandlyApiKey, }); await redis.set(`import:rebrandly:${workspaceId}:tags`, "true"); } tagsToId = await prisma.tag .findMany({ where: { projectId: workspaceId, }, select: { id: true, name: true, }, }) .then((tags) => tags.reduce((acc, tag) => { acc[tag.name] = tag.id; return acc; }, {}), ); } await importLinksFromRebrandly({ ...body, tagsToId, rebrandlyApiKey, createdAfter, }); return NextResponse.json({ response: "success", }); } catch (error) { const workspace = await prisma.project.findUnique({ where: { id: workspaceId, }, select: { slug: true, }, }); throw new DubApiError({ code: "bad_request", message: `Workspace: ${workspace?.slug || workspaceId}$. Error: ${error.message}`, }); } } catch (error) { await log({ message: `Error importing Rebrandly links: ${error.message}`, type: "cron", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/import/rebrandly/utils.ts ================================================ import { createId } from "@/lib/api/create-id"; import { bulkCreateLinks } from "@/lib/api/links"; import { qstash } from "@/lib/cron"; import { redis } from "@/lib/upstash"; import { randomBadgeColor } from "@/ui/links/tag-badge"; import { sendEmail } from "@dub/email"; import LinksImported from "@dub/email/templates/links-imported"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, linkConstructorSimple } from "@dub/utils"; export const importTagsFromRebrandly = async ({ workspaceId, rebrandlyApiKey, lastTagId = null, }: { workspaceId: string; rebrandlyApiKey: string; lastTagId?: string | null; }) => { const tags = (await fetch( `https://api.rebrandly.com/v1/tags?orderBy=name&orderDir=desc&limit=25${ lastTagId ? `&last=${lastTagId}` : "" }`, { headers: { "Content-Type": "application/json", apikey: rebrandlyApiKey as string, }, }, ).then((r) => r.json())) as { id: string; name: string; color: string; }[]; // if no tags left, meaning import is complete if (tags.length === 0) { return; } const newLastTagId = tags[tags.length - 1].id; // import tags into database await prisma.tag.createMany({ data: tags.map((tag) => ({ id: createId({ prefix: "tag_" }), name: tag.name, color: randomBadgeColor(), projectId: workspaceId, })), skipDuplicates: true, }); // wait 500 ms before making another request await new Promise((resolve) => setTimeout(resolve, 500)); return await importTagsFromRebrandly({ workspaceId, rebrandlyApiKey, lastTagId: newLastTagId, }); }; export const importLinksFromRebrandly = async ({ workspaceId, userId, domainId, domain, folderId, tagsToId, rebrandlyApiKey, createdAfter, lastLinkId = null, count = 0, }: { workspaceId: string; userId: string; domainId: number; domain: string; folderId?: string; tagsToId?: Record; rebrandlyApiKey: string; createdAfter?: string; lastLinkId?: string | null; count?: number; }) => { const links = await fetch( `https://api.rebrandly.com/v1/links?${new URLSearchParams({ domain: domainId.toString(), orderBy: "createdAt", orderDir: "desc", limit: "25", ...(lastLinkId && { last: lastLinkId }), ...(createdAfter && { dateFrom: createdAfter }), // ...(createdBefore && { dateTo: createdBefore }), // TODO add this in the future })}`, { headers: { "Content-Type": "application/json", apikey: rebrandlyApiKey as string, }, }, ).then((res) => res.json()); // if no more links, meaning import is complete if (links.length === 0) { const workspace = await prisma.project.findUnique({ where: { id: workspaceId, }, select: { name: true, slug: true, users: { where: { role: "owner", }, select: { user: { select: { email: true, }, }, }, }, links: { select: { domain: true, key: true, createdAt: true, }, where: { domain, }, take: 5, orderBy: { createdAt: "desc", }, }, }, }); const ownerEmail = workspace?.users[0].user.email ?? ""; const links = workspace?.links ?? []; await Promise.all([ // delete keys from redis redis.del(`import:rebrandly:${workspaceId}`), redis.del(`import:rebrandly:${workspaceId}:tags`), // delete tags that have no links prisma.tag.deleteMany({ where: { projectId: workspaceId, links: { none: {}, }, }, }), // send email to user sendEmail({ subject: `Your Rebrandly links have been imported!`, to: ownerEmail, react: LinksImported({ email: ownerEmail, provider: "Rebrandly", count, links, domains: [domain], workspaceName: workspace?.name ?? "", workspaceSlug: workspace?.slug ?? "", }), }), ]); return count; // if there are more links, import them } else { const newLastLinkId = links[links.length - 1].id; // convert links to format that can be imported into database const importedLinks = links .map( ({ title, slashtag: key, destination, tags, createdAt, updatedAt }) => { // if tagsToId is provided and tags array is not empty, get the tagIds const tagIds = tagsToId && tags.length > 0 ? tags.map( (tag: { id: string; name: string; color: string; active: boolean; clicks: number; }) => tagsToId[tag.name], ) : []; return { projectId: workspaceId, userId, domain, key, url: destination, shortLink: linkConstructorSimple({ domain, key, }), title, folderId, createdAt, updatedAt, tagIds, }; }, ) .filter(Boolean); // check if links are already in the database const alreadyCreatedLinks = await prisma.link.findMany({ where: { shortLink: { in: importedLinks.map((link) => link.shortLink), }, }, select: { shortLink: true, }, }); // filter out links that are already in the database const linksToCreate = importedLinks.filter( (link) => !alreadyCreatedLinks.some((l) => l.shortLink === link.shortLink), ); // bulk create links await bulkCreateLinks({ links: linksToCreate, skipRedisCache: true, }); count += importedLinks.length; console.log({ importedLinksLength: importedLinks.length, count, newLastLinkId, }); // wait 500 ms before making another request await new Promise((resolve) => setTimeout(resolve, 500)); return await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/rebrandly`, body: { workspaceId, userId, domainId, domain, folderId, importTags: tagsToId ? true : false, createdAfter, lastLinkId: newLastLinkId, count, }, }); } }; ================================================ FILE: apps/web/app/(ee)/api/cron/import/rewardful/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { importAffiliateCoupons } from "@/lib/rewardful/import-affiliate-coupons"; import { importCampaigns } from "@/lib/rewardful/import-campaigns"; import { importCommissions } from "@/lib/rewardful/import-commissions"; import { importCustomers } from "@/lib/rewardful/import-customers"; import { importPartners } from "@/lib/rewardful/import-partners"; import { rewardfulImportPayloadSchema } from "@/lib/rewardful/schemas"; 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 = rewardfulImportPayloadSchema.parse(JSON.parse(rawBody)); switch (payload.action) { case "import-campaigns": await importCampaigns(payload); break; case "import-partners": await importPartners(payload); break; case "import-affiliate-coupons": await importAffiliateCoupons(payload); break; case "import-customers": await importCustomers(payload); break; case "import-commissions": await importCommissions(payload); break; } return NextResponse.json("OK"); } catch (error) { return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/import/short/route.ts ================================================ import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { redis } from "@/lib/upstash"; import { prisma } from "@dub/prisma"; import { log } from "@dub/utils"; import { NextResponse } from "next/server"; import { importLinksFromShort } from "./utils"; export const dynamic = "force-dynamic"; export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const body = JSON.parse(rawBody); const { workspaceId, userId, domainId, domain, folderId, importTags, pageToken, count, } = body; try { const shortApiKey = (await redis.get( `import:short:${workspaceId}`, )) as string; await importLinksFromShort({ workspaceId, userId, domainId, domain, folderId, importTags, pageToken, count, shortApiKey, }); return NextResponse.json({ response: "success", }); } catch (error) { const workspace = await prisma.project.findUnique({ where: { id: workspaceId, }, select: { slug: true, }, }); throw new DubApiError({ code: "bad_request", message: `Workspace: ${workspace?.slug || workspaceId}$. Error: ${error.message}`, }); } } catch (error) { await log({ message: `Error importing Short.io links: ${error.message}`, type: "cron", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/import/short/utils.ts ================================================ import { createId } from "@/lib/api/create-id"; import { bulkCreateLinks } from "@/lib/api/links"; import { qstash } from "@/lib/cron"; import { redis } from "@/lib/upstash"; import { randomBadgeColor } from "@/ui/links/tag-badge"; import { sendEmail } from "@dub/email"; import LinksImported from "@dub/email/templates/links-imported"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, linkConstructorSimple } from "@dub/utils"; export const importLinksFromShort = async ({ workspaceId, userId, domainId, domain, folderId, importTags, pageToken = null, count = 0, shortApiKey, }: { workspaceId: string; userId: string; domainId: number; domain: string; folderId?: string; importTags?: boolean; pageToken?: string | null; count?: number; shortApiKey: string; }) => { const data = await fetch( `https://api.short.io/api/links?domain_id=${domainId}&limit=50${ pageToken ? `&pageToken=${pageToken}` : "" }`, { headers: { "Content-Type": "application/json", Authorization: shortApiKey, }, }, ).then((res) => res.json()); const { links, nextPageToken } = data; let tagsToCreate = new Set(); let allTags: { id: string; name: string }[] = []; // convert links to format that can be imported into database const importedLinks = links .map( ({ originalURL, path, title, iphoneURL, androidURL, archived, tags, createdAt, }) => { // skip the root domain if (path.length === 0) { return null; } if (tags) { tags.forEach((tag: string) => tagsToCreate.add(tag)); } return { projectId: workspaceId, userId, domain, key: path, url: originalURL, shortLink: linkConstructorSimple({ domain, key: path, }), title, ios: iphoneURL, android: androidURL, archived, tags, folderId, createdAt, }; }, ) .filter(Boolean); // check if links are already in the database const alreadyCreatedLinks = await prisma.link.findMany({ where: { shortLink: { in: importedLinks.map((link) => link.shortLink), }, }, select: { shortLink: true, }, }); // filter out links that are already in the database const linksToCreate = importedLinks.filter( (link) => !alreadyCreatedLinks.some((l) => l.shortLink === link.shortLink), ); // import tags into database if (importTags && tagsToCreate.size > 0) { const existingTags = await prisma.tag.findMany({ where: { projectId: workspaceId, }, select: { id: true, name: true, }, }); await prisma.tag.createMany({ data: Array.from(tagsToCreate) // filter out existing tags with the same name .filter((tag) => !existingTags.some((t) => t.name === tag)) .map((tag) => ({ id: createId({ prefix: "tag_" }), name: tag, color: randomBadgeColor(), projectId: workspaceId, })), skipDuplicates: true, }); allTags = await prisma.tag.findMany({ where: { projectId: workspaceId, }, select: { id: true, name: true, }, }); } // bulk create links await bulkCreateLinks({ links: linksToCreate.map(({ tags, ...rest }) => { return { ...rest, ...(importTags && Array.isArray(tags) && tags.length > 0 && { tagIds: tags .map( (tag: string) => allTags.find((t) => t.name === tag)?.id ?? null, ) .filter(Boolean), }), }; }), skipRedisCache: true, }); count += importedLinks.length; console.log({ importedLinksLength: importedLinks.length, count, nextPageToken, }); // wait 500 ms before making another request await new Promise((resolve) => setTimeout(resolve, 500)); if (!nextPageToken) { const workspace = await prisma.project.findUnique({ where: { id: workspaceId, }, select: { name: true, slug: true, users: { where: { role: "owner", }, select: { user: { select: { email: true, }, }, }, }, links: { select: { domain: true, key: true, createdAt: true, }, where: { domain, }, take: 5, orderBy: { createdAt: "desc", }, }, }, }); const ownerEmail = workspace?.users[0].user.email ?? ""; const links = workspace?.links ?? []; await Promise.all([ // delete key from redis redis.del(`import:short:${workspaceId}`), // send email to user sendEmail({ subject: `Your Short.io links have been imported!`, to: ownerEmail, react: LinksImported({ email: ownerEmail, provider: "Short.io", count, links, domains: [domain], workspaceName: workspace?.name ?? "", workspaceSlug: workspace?.slug ?? "", }), }), ]); return count; } else { // recursively call this function via qstash until nextPageToken is null return await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/short`, body: { workspaceId, userId, domainId, domain, folderId, pageToken: nextPageToken, count, }, }); } }; ================================================ FILE: apps/web/app/(ee)/api/cron/import/tolt/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { cleanupPartners } from "@/lib/tolt/cleanup-partners"; import { importCommissions } from "@/lib/tolt/import-commissions"; import { importCustomers } from "@/lib/tolt/import-customers"; import { importLinks } from "@/lib/tolt/import-links"; import { importPartners } from "@/lib/tolt/import-partners"; import { toltImportPayloadSchema } from "@/lib/tolt/schemas"; import { updateStripeCustomers } from "@/lib/tolt/update-stripe-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 = toltImportPayloadSchema.parse(JSON.parse(rawBody)); switch (payload.action) { 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; case "update-stripe-customers": await updateStripeCustomers(payload); break; case "cleanup-partners": await cleanupPartners(payload); break; } return NextResponse.json("OK"); } catch (error) { return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/invoices/retry-failed/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { createPaymentIntent } from "@/lib/stripe/create-payment-intent"; import { prisma } from "@dub/prisma"; import * as z from "zod/v4"; export const dynamic = "force-dynamic"; const schema = z.object({ invoiceId: z.string().min(1), }); // POST /api/cron/invoices/retry-failed export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); const { invoiceId } = schema.parse(JSON.parse(rawBody)); const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId, }, select: { id: true, type: true, status: true, total: true, failedAttempts: true, workspace: { select: { id: true, stripeId: true, }, }, }, }); if (!invoice) { console.log(`Invoice ${invoiceId} not found.`); return new Response(`Invoice ${invoiceId} not found.`); } if (invoice.status !== "failed") { console.log(`Invoice ${invoiceId} is not failed.`); return new Response(`Invoice ${invoiceId} is not failed.`); } if (invoice.failedAttempts >= 3) { console.log(`Invoice ${invoiceId} has reached max failed attempts of 3.`); return new Response( `Invoice ${invoiceId} has reached max failed attempts of 3.`, ); } if (invoice.type !== "domainRenewal") { console.log(`Only domain renewals can be retried at this time.`); return new Response(`Only domain renewals can be retried at this time.`); } if (!invoice.workspace.stripeId) { console.log(`Workspace ${invoice.workspace.id} has no stripeId.`); return new Response(`Workspace ${invoice.workspace.id} has no stripeId.`); } await createPaymentIntent({ stripeId: invoice.workspace.stripeId, amount: invoice.total, invoiceId: invoice.id, statementDescriptor: "Dub", description: `Domain renewal invoice (${invoice.id})`, idempotencyKey: `${invoice.id}-${invoice.failedAttempts}`, }); return new Response(`Retrying invoice charge ${invoice.id}...`); } catch (error) { return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/links/[linkId]/complete-tests/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { completeABTests } from "@/lib/api/links/complete-ab-tests"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { prisma } from "@dub/prisma"; // POST - /api/cron/links/[linkId]/complete-tests // Completes a link's AB tests if they're ready, scheduled by QStash export async function POST( req: Request, props: { params: Promise<{ linkId: string }>; }, ) { const params = await props.params; const { linkId } = params; try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const link = await prisma.link.findUnique({ where: { id: linkId, }, }); if (!link) { return new Response(`Link ${linkId} not found. Skipping...`); } // only complete tests if: // - there are test variants // - the tests completion time is in the past // - the tests completion time is within the last 15 minutes if ( link.testVariants && link.testCompletedAt && link.testCompletedAt < new Date() && Date.now() - link.testCompletedAt.getTime() < 15 * 60 * 1000 // Limit to a 15-minute window ) { await completeABTests(link); return new Response(`Tests completed for link ${linkId}.`); } return new Response(`No test completion necessary for link ${linkId}.`); } catch (error) { return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/links/delete/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { deleteLink } from "@/lib/api/links"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { prisma } from "@dub/prisma"; export const dynamic = "force-dynamic"; /* This route is used to delete demo links that are not claimed It is called by QStash 30 minutes after a demo link is created */ export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const { linkId } = JSON.parse(rawBody); const link = await prisma.link.findUnique({ where: { id: linkId, }, }); if (!link) { return new Response("Link not found. Skipping...", { status: 200 }); } if (link.userId) { return new Response("Link claimed. Skipping...", { status: 200 }); } await deleteLink(link.id); return new Response("Link deleted.", { status: 200 }); } catch (error) { return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/links/invalidate-for-discounts/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { linkCache } from "@/lib/api/links/cache"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { prisma } from "@dub/prisma"; import { chunk } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const schema = z.object({ groupId: z.string(), partnerIds: z .array(z.string()) .optional() .describe( "If provided, only invalidate the cache for the given partner ids.", ), }); // This route is used to invalidate the partnerlink cache when a discount is created/updated/deleted. // POST /api/cron/links/invalidate-for-discounts export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const { groupId, partnerIds } = schema.parse(JSON.parse(rawBody)); // Find the group const group = await prisma.partnerGroup.findUnique({ where: { id: groupId, }, }); if (!group) { return logAndRespond(`Group ${groupId} not found.`, { logLevel: "error", }); } // Find all the links of the partners in the group const programEnrollments = await prisma.programEnrollment.findMany({ where: { groupId, ...(partnerIds && { partnerId: { in: partnerIds, }, }), }, select: { links: { select: { domain: true, key: true, }, }, }, }); if (programEnrollments.length === 0) { return logAndRespond( `No program enrollments found for group ${groupId}.`, ); } const links = programEnrollments.flatMap((enrollment) => enrollment.links); if (links.length === 0) { return logAndRespond( `No links found for partners in the group ${groupId}.`, ); } const linkChunks = chunk(links, 100); // Expire the cache for the links for (const linkChunk of linkChunks) { const toExpire = linkChunk.map(({ domain, key }) => ({ domain, key })); await linkCache.expireMany(toExpire); } return logAndRespond(`Expired cache for ${links.length} links.`); } catch (error) { return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/links/invalidate-for-partners/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { linkCache } from "@/lib/api/links/cache"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { prisma } from "@dub/prisma"; import * as z from "zod/v4"; export const dynamic = "force-dynamic"; const schema = z.object({ partnerId: z.string(), }); // This route is used to invalidate the partnerlink cache when the partner info is updated. // POST /api/cron/links/invalidate-for-partners export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const { partnerId } = schema.parse(JSON.parse(rawBody)); const programs = await prisma.programEnrollment.findMany({ where: { partnerId, }, select: { programId: true, }, }); const links = await prisma.link.findMany({ where: { programId: { in: programs.map(({ programId }) => programId), }, partnerId, }, select: { domain: true, key: true, }, }); if (!links || links.length === 0) { return new Response("No links found."); } await linkCache.expireMany(links); return new Response(`Invalidated ${links.length} links.`); } catch (error) { return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/messages/notify-partner/route.ts ================================================ import { createId } from "@/lib/api/create-id"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { sendBatchEmail } from "@dub/email"; import NewMessageFromProgram from "@dub/email/templates/new-message-from-program"; import { prisma } from "@dub/prisma"; import { NotificationEmailType } from "@dub/prisma/client"; import { log } from "@dub/utils"; import { subDays } from "date-fns"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const schema = z.object({ programId: z.string(), partnerId: z.string(), lastMessageId: z.string(), }); // POST /api/cron/messages/notify-partner // Notify a partner about unread messages from a program export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); const { programId, partnerId, lastMessageId } = schema.parse( JSON.parse(rawBody), ); const [partner, program] = await Promise.all([ prisma.partner.findUniqueOrThrow({ where: { id: partnerId, }, include: { messages: { where: { programId, createdAt: { gt: subDays(new Date(), 3), // sent in the last 3 days }, senderPartnerId: null, // not sent by the partner readInApp: null, // unread messages only readInEmail: null, // unread messages only }, orderBy: { createdAt: "desc", }, include: { senderUser: true, }, }, users: { include: { user: true, }, where: { notificationPreferences: { newMessageFromProgram: true, }, }, }, }, }), prisma.program.findUniqueOrThrow({ where: { id: programId, }, }), ]); // unread messages are already sorted by latest message first const unreadMessages = partner.messages; if (unreadMessages.length === 0) return logAndRespond( `No unread messages found for partner ${partnerId} in program ${programId}. Skipping...`, ); // if the latest unread message is not the last message id, skip if (unreadMessages[0].id !== lastMessageId) return logAndRespond( `There is a more recent unread message than ${lastMessageId}. Skipping...`, ); const partnerUsersToNotify = partner.users .map(({ user }) => user) .filter(Boolean) as { email: string; id: string }[]; if (partnerUsersToNotify.length === 0) return logAndRespond( `No partner emails to notify for partner ${partnerId}. Skipping...`, ); const { data, error } = await sendBatchEmail( partnerUsersToNotify.map(({ email }) => ({ subject: `${program.name} sent ${unreadMessages.length === 1 ? "a message" : `${unreadMessages.length} messages`}`, variant: "notifications", to: email, replyTo: program.supportEmail || "noreply", react: NewMessageFromProgram({ program: { name: program.name, logo: program.logo, slug: program.slug, }, // can potentially replace this with `.toReversed()` once it's more widely supported messages: [...unreadMessages].reverse().map((message) => ({ text: message.text, createdAt: message.createdAt, user: message.senderUser.name ? { name: message.senderUser.name, image: message.senderUser.image, } : { name: program.name, image: program.logo, }, })), email, }), tags: [{ name: "type", value: "notification-email" }], })), ); if (error) throw new Error( `Error sending message emails to partner ${partnerId}: ${error.message}`, ); if (!data) throw new Error( `No data received from sending message emails to partner ${partnerId}`, ); await prisma.notificationEmail.createMany({ data: partnerUsersToNotify.map(({ id: userId }, idx) => ({ id: createId({ prefix: "em_" }), type: NotificationEmailType.Message, emailId: data.data[idx].id, messageId: lastMessageId, programId, partnerId, recipientUserId: userId, })), }); return logAndRespond( `Emails sent for messages from program ${programId} to partner ${partnerId}.`, ); } catch (error) { await log({ message: `Error notifying partner of new messages: ${error.message}`, type: "alerts", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/messages/notify-program/route.ts ================================================ import { createId } from "@/lib/api/create-id"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { sendBatchEmail } from "@dub/email"; import NewMessageFromPartner from "@dub/email/templates/new-message-from-partner"; import { prisma } from "@dub/prisma"; import { NotificationEmailType } from "@dub/prisma/client"; import { log } from "@dub/utils"; import { subDays } from "date-fns"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const schema = z.object({ programId: z.string(), partnerId: z.string(), lastMessageId: z.string(), }); // POST /api/cron/messages/notify-program // Notify a program about unread messages from a partner export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); const { programId, partnerId, lastMessageId } = schema.parse( JSON.parse(rawBody), ); const [program, partner] = await Promise.all([ prisma.program.findUniqueOrThrow({ where: { id: programId, }, include: { messages: { where: { partnerId, senderPartnerId: { not: null, // Sent by the partner }, createdAt: { gt: subDays(new Date(), 3), // Sent in the last 3 days }, readInApp: null, // Unread readInEmail: null, // Unread }, orderBy: { createdAt: "desc", }, include: { senderPartner: true, }, }, workspace: { include: { users: { include: { user: true, }, where: { notificationPreference: { newMessageFromPartner: true, }, }, }, }, }, }, }), prisma.partner.findUniqueOrThrow({ where: { id: partnerId, }, }), ]); const unreadMessages = program.messages; // unread messages are already sorted by latest message first if (unreadMessages.length === 0) return logAndRespond( `No unread messages found from partner ${partnerId} in program ${programId}. Skipping...`, ); // if the latest unread message is not the last message id, skip if (unreadMessages[0].id !== lastMessageId) return logAndRespond( `There is a more recent unread message than ${lastMessageId}. Skipping...`, ); const usersToNotify = program.workspace.users .map(({ user }) => user) .filter(Boolean) as { email: string; id: string }[]; if (usersToNotify.length === 0) return logAndRespond( `No program user emails to notify from partner ${partnerId}. Skipping...`, ); const { data, error } = await sendBatchEmail( usersToNotify.map(({ email }) => ({ subject: `${unreadMessages.length === 1 ? "New message from" : `${unreadMessages.length} new messages from`} ${partner.name}`, variant: "notifications", to: email, react: NewMessageFromPartner({ workspaceSlug: program.workspace.slug, partner: { id: partner.id, name: partner.name, image: partner.image, }, // can potentially replace this with `.toReversed()` once it's more widely supported messages: [...unreadMessages].reverse().map((message) => ({ text: message.text, createdAt: message.createdAt, })), email, }), tags: [{ name: "type", value: "notification-email" }], })), ); if (error) throw new Error( `Error sending message emails to program ${programId} users: ${error.message}`, ); if (!data) throw new Error( `No data received from sending message emails to program ${programId} users`, ); await prisma.notificationEmail.createMany({ data: usersToNotify.map(({ id: userId }, idx) => ({ id: createId({ prefix: "em_" }), type: NotificationEmailType.Message, emailId: data.data[idx].id, messageId: lastMessageId, programId, partnerId, recipientUserId: userId, })), }); return logAndRespond( `Emails sent for messages from partner ${partnerId} to program ${programId} users.`, ); } catch (error) { await log({ message: `Error notifying program users of new messages: ${error.message}`, type: "alerts", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/network/calculate-program-similarities/calculate-category-similarity.ts ================================================ import { prisma } from "@dub/prisma"; // Calculate category similarity using Jaccard similarity export async function calculateCategorySimilarity( program1Id: string, program2Id: string, ): Promise { const [categories1, categories2] = await Promise.all([ prisma.programCategory.findMany({ where: { programId: program1Id, }, select: { category: true, }, }), prisma.programCategory.findMany({ where: { programId: program2Id, }, select: { category: true, }, }), ]); const categories1Set = new Set(categories1.map(({ category }) => category)); const categories2Set = new Set(categories2.map(({ category }) => category)); const sharedCount = [...categories1Set].filter((c) => categories2Set.has(c), ).length; const totalUniqueCount = categories1Set.size + categories2Set.size - sharedCount; if (totalUniqueCount === 0) { return 0; } return sharedCount / totalUniqueCount; } ================================================ FILE: apps/web/app/(ee)/api/cron/network/calculate-program-similarities/calculate-partner-similarity.ts ================================================ import { prisma } from "@dub/prisma"; interface PartnerSimilarityResult { sharedPartnersCount: bigint; program1PartnersCount: bigint; program2PartnersCount: bigint; } // Calculate partner similarity using Jaccard similarity export async function calculatePartnerSimilarity( program1Id: string, program2Id: string, ): Promise { const [result] = await prisma.$queryRaw` SELECT COUNT(DISTINCT CASE WHEN e1.partnerId IS NOT NULL AND e2.partnerId IS NOT NULL THEN e1.partnerId END) AS sharedPartnersCount, (SELECT COUNT(*) FROM ProgramEnrollment WHERE programId = ${program1Id}) AS program1PartnersCount, (SELECT COUNT(*) FROM ProgramEnrollment WHERE programId = ${program2Id}) AS program2PartnersCount FROM ProgramEnrollment e1 JOIN ProgramEnrollment e2 ON e1.partnerId = e2.partnerId WHERE e1.programId = ${program1Id} AND e2.programId = ${program2Id} `; const { sharedPartnersCount, program1PartnersCount, program2PartnersCount } = result ?? { sharedPartnersCount: BigInt(0), program1PartnersCount: BigInt(0), program2PartnersCount: BigInt(0), }; const unionCount = Number(program1PartnersCount) + Number(program2PartnersCount) - Number(sharedPartnersCount); if (unionCount === 0) { return 0; } return Number(sharedPartnersCount) / unionCount; } ================================================ FILE: apps/web/app/(ee)/api/cron/network/calculate-program-similarities/calculate-performance-similarity.ts ================================================ import { prisma } from "@dub/prisma"; const METRIC_KEYS = [ "totalClicks", "totalLeads", "totalConversions", "totalSales", "totalSaleAmount", ] as const; // Calculate performance similarity using Cosine similarity export async function calculatePerformanceSimilarity( program1Id: string, program2Id: string, ): Promise { const [performance1, performance2] = await Promise.all([ prisma.programEnrollment.aggregate({ where: { programId: program1Id, }, _avg: { totalClicks: true, totalLeads: true, totalSales: true, totalConversions: true, totalSaleAmount: true, }, }), prisma.programEnrollment.aggregate({ where: { programId: program2Id, }, _avg: { totalClicks: true, totalLeads: true, totalSales: true, totalConversions: true, totalSaleAmount: true, }, }), ]); const program1Vector = METRIC_KEYS.map((key) => performance1._avg[key] ?? 0); const program2Vector = METRIC_KEYS.map((key) => performance2._avg[key] ?? 0); const dotProduct = program1Vector.reduce( (sum, val, i) => sum + val * program2Vector[i], 0, ); const magnitude1 = Math.sqrt( program1Vector.reduce((sum, val) => sum + val ** 2, 0), ); const magnitude2 = Math.sqrt( program2Vector.reduce((sum, val) => sum + val ** 2, 0), ); if (magnitude1 === 0 || magnitude2 === 0) { return 0; } return dotProduct / (magnitude1 * magnitude2); } ================================================ FILE: apps/web/app/(ee)/api/cron/network/calculate-program-similarities/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { PROGRAM_SIMILARITY_SCORE_THRESHOLD } from "@/lib/constants/program"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { prisma } from "@dub/prisma"; import { ProgramSimilarity } from "@dub/prisma/client"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; import { calculateCategorySimilarity } from "./calculate-category-similarity"; import { calculatePartnerSimilarity } from "./calculate-partner-similarity"; import { calculatePerformanceSimilarity } from "./calculate-performance-similarity"; const payloadSchema = z.object({ currentProgramId: z .string() .optional() .describe("Current program being compared."), comparisonBatchCursor: z .string() .optional() .describe("Cursor for programs to compare against."), }); const PROGRAMS_PER_BATCH = 10; // This route is used to calculate program similarities in the network // Runs once every 12 hours (0 */12 * * *) // POST /api/cron/network/calculate-program-similarities export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); const { currentProgramId, comparisonBatchCursor } = payloadSchema.parse( JSON.parse(rawBody), ); return await calculateProgramSimilarity({ currentProgramId, comparisonBatchCursor, }); } catch (err) { return handleAndReturnErrorResponse(err); } } async function calculateProgramSimilarity({ currentProgramId, comparisonBatchCursor, }: z.infer) { const currentProgram = await findNextProgram({ programId: currentProgramId, }); if (!currentProgram) { return logAndRespond("No current program found. Skipping..."); } const programs = await prisma.program.findMany({ where: { id: { gt: currentProgram.id, }, OR: [ { addedToMarketplaceAt: { not: null, }, }, { partnerNetworkEnabledAt: { not: null, }, }, ], }, ...(comparisonBatchCursor && { cursor: { id: comparisonBatchCursor, }, }), skip: comparisonBatchCursor ? 1 : 0, orderBy: { id: "asc", }, take: PROGRAMS_PER_BATCH, select: { id: true, name: true, }, }); console.log( `Found ${programs.length} programs to compare against ${currentProgram.name}`, ); if (programs.length > 0) { const results: Pick< ProgramSimilarity, | "programId" | "similarProgramId" | "similarityScore" | "categorySimilarityScore" | "partnerSimilarityScore" | "performanceSimilarityScore" >[] = []; for (const program of programs) { const program1 = currentProgram; const program2 = program; if (program1.id === program2.id) { continue; } const [ categorySimilarityScore, partnerSimilarityScore, performanceSimilarityScore, ] = await Promise.all([ calculateCategorySimilarity(program1.id, program2.id), calculatePartnerSimilarity(program1.id, program2.id), calculatePerformanceSimilarity(program1.id, program2.id), ]); const similarityScore = categorySimilarityScore * 0.5 + partnerSimilarityScore * 0.3 + performanceSimilarityScore * 0.2; console.log( `Calculated similarities between ${program1.name} <> ${program2.name}`, { categorySimilarityScore, partnerSimilarityScore, performanceSimilarityScore, similarityScore, }, ); if (similarityScore > PROGRAM_SIMILARITY_SCORE_THRESHOLD) { results.push({ programId: program1.id, similarProgramId: program2.id, similarityScore, categorySimilarityScore, partnerSimilarityScore, performanceSimilarityScore, }); results.push({ programId: program2.id, similarProgramId: program1.id, similarityScore, categorySimilarityScore, partnerSimilarityScore, performanceSimilarityScore, }); } } await prisma.$transaction(async (tx) => { const programIds = programs.map((program) => program.id); await tx.programSimilarity.deleteMany({ where: { programId: { in: programIds, }, }, }); await tx.programSimilarity.createMany({ data: results, skipDuplicates: true, }); }); } // If we have more programs to compare against the current program, continue with the next batch // Otherwise, move to the next current program and start comparing from the beginning if (programs.length === PROGRAMS_PER_BATCH) { currentProgramId = currentProgram.id; comparisonBatchCursor = programs[programs.length - 1].id; } else { const program = await findNextProgram({ afterProgramId: currentProgramId, }); if (!program) { return logAndRespond("No more programs to compare."); } currentProgramId = program.id; comparisonBatchCursor = undefined; } await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/network/calculate-program-similarities`, method: "POST", body: { currentProgramId, comparisonBatchCursor, }, }); return logAndRespond("Scheduled next batch calculation."); } async function findNextProgram({ programId, afterProgramId, }: { programId?: string; afterProgramId?: string; }) { // If a specific programId is provided, find that program if (programId) { return await prisma.program.findUnique({ where: { id: programId, }, select: { id: true, name: true, }, }); } // Otherwise, find the first/next program return await prisma.program.findFirst({ where: { ...(afterProgramId && { id: { gt: afterProgramId, }, }), OR: [ { addedToMarketplaceAt: { not: null, }, }, { partnerNetworkEnabledAt: { not: null, }, }, ], }, select: { id: true, name: true, }, orderBy: { id: "asc", }, }); } ================================================ FILE: apps/web/app/(ee)/api/cron/network/update-partner-discoverability/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { EXCLUDED_PROGRAM_IDS } from "@/lib/constants/partner-profile"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { prisma } from "@dub/prisma"; import { log, prettyPrint } from "@dub/utils"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; // This route is used to update the discoverability of partners in the network // Runs once every hour (0 * * * *) // POST /api/cron/network/update-partner-discoverability export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); const eligiblePartners = await prisma.partner.findMany({ where: { programs: { some: { programId: { notIn: EXCLUDED_PROGRAM_IDS, }, status: "approved", totalCommissions: { gte: 10_00, }, }, none: { status: "banned", }, }, }, }); const discoveredRes = await prisma.partner.updateMany({ where: { discoverableAt: null, id: { in: eligiblePartners.map((partner) => partner.id), }, }, data: { discoverableAt: new Date() }, }); console.log(`Updated ${discoveredRes.count} partners to be discoverable`); const notDiscoveredRes = await prisma.partner.updateMany({ where: { discoverableAt: { not: null, }, id: { notIn: eligiblePartners.map((partner) => partner.id), }, }, data: { discoverableAt: null }, }); console.log( `Updated ${notDiscoveredRes.count} partners to be not discoverable`, ); return logAndRespond( prettyPrint({ discoverable: discoveredRes.count, notDiscoverable: notDiscoveredRes.count, }), ); } catch (error) { await log({ message: `/api/cron/network/update-partner-discoverability failed - ${error.message}`, type: "errors", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/partner-platforms/route.ts ================================================ import { AccountNotFoundError, getSocialProfile, } from "@/lib/api/scrape-creators/get-social-profile"; import { qstash } from "@/lib/cron"; import { withCron } from "@/lib/cron/with-cron"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { subDays } from "date-fns"; import * as z from "zod/v4"; import { logAndRespond } from "../utils"; export const dynamic = "force-dynamic"; const BATCH_SIZE = 50; const schema = z.object({ startingAfter: z.string().optional(), }); /** * This route is used to update stats for verified Instagram, TikTok, and Twitter partners using the ScrapeCreators API * Runs once a day at 06:00 AM UTC (cron expression: 0 6 * * *) * POST /api/cron/partner-platforms */ export const POST = withCron(async ({ rawBody }) => { if (!process.env.SCRAPECREATORS_API_KEY) { throw new Error("SCRAPECREATORS_API_KEY is not defined"); } let { startingAfter } = schema.parse( rawBody ? JSON.parse(rawBody) : { startingAfter: undefined }, ); const verifiedProfiles = await prisma.partnerPlatform.findMany({ where: { type: { in: ["instagram", "tiktok", "twitter"], }, verifiedAt: { not: null, }, // only check platforms that haven't been checked in the last 7 days OR: [ { lastCheckedAt: { lt: subDays(new Date(), 7), }, }, { lastCheckedAt: null, }, ], // only check partners that are discoverable in the partner network partner: { discoverableAt: { not: null, }, }, }, take: BATCH_SIZE, ...(startingAfter && { cursor: { id: startingAfter, }, skip: 1, }), orderBy: { id: "asc", }, }); if (verifiedProfiles.length === 0) { return logAndRespond( "No more verified social profiles found. Finished updating social platform stats.", ); } await Promise.allSettled( verifiedProfiles.map(async (verifiedProfile) => { if (!verifiedProfile.identifier || !verifiedProfile.type) { return; } try { const socialProfile = await getSocialProfile({ platform: verifiedProfile.type, handle: verifiedProfile.identifier, }); const newStats = { subscribers: socialProfile.subscribers, posts: socialProfile.posts, avatarUrl: socialProfile.avatarUrl, }; await prisma.partnerPlatform.update({ where: { id: verifiedProfile.id, }, data: { ...newStats, lastCheckedAt: new Date(), }, }); console.log( `Updated ${verifiedProfile.type} stats for @${verifiedProfile.identifier}`, newStats, ); } catch (error) { // If account doesn't exist, unverify the platform if (error instanceof AccountNotFoundError) { await prisma.partnerPlatform.update({ where: { id: verifiedProfile.id, }, data: { verifiedAt: null, lastCheckedAt: new Date(), }, }); console.log( `Account @${verifiedProfile.identifier} on ${verifiedProfile.type} no longer exists. Unverified platform.`, ); return; } console.error( `Error updating ${verifiedProfile.type} stats for @${verifiedProfile.identifier}:`, error, ); } }), ); if (verifiedProfiles.length === BATCH_SIZE) { startingAfter = verifiedProfiles[verifiedProfiles.length - 1].id; await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partner-platforms`, method: "POST", body: { startingAfter, }, }); return logAndRespond( `Processed ${BATCH_SIZE} profiles. Scheduled next batch (startingAfter: ${startingAfter}).`, ); } return logAndRespond( "Finished updating social platform stats for all verified profiles.", ); }); ================================================ FILE: apps/web/app/(ee)/api/cron/partner-platforms/youtube/route.ts ================================================ import { withCron } from "@/lib/cron/with-cron"; import { prisma } from "@dub/prisma"; import { PlatformType } from "@dub/prisma/client"; import { chunk } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; import { youtubeChannelSchema } from "./youtube-channel-schema"; export const dynamic = "force-dynamic"; /** * This route is used to update stats for YouTube verified partners using the YouTube API * Runs once a day at 06:00 AM UTC (cron expression: 0 6 * * *) * POST /api/cron/partner-platforms/youtube */ export const POST = withCron(async () => { if (!process.env.YOUTUBE_API_KEY) { throw new Error("YOUTUBE_API_KEY is not defined"); } const youtubeChannels = await prisma.partnerPlatform.findMany({ where: { type: PlatformType.youtube, verifiedAt: { not: null, }, platformId: { not: null, }, }, }); if (youtubeChannels.length === 0) { return logAndRespond( "No YouTube platforms found. Skipping YouTube stats update.", ); } const channelChunks = chunk(youtubeChannels, 50); for (const channelChunk of channelChunks) { const channelIds = channelChunk.map((channel) => channel.platformId); if (channelIds.length === 0) { continue; } const response = await fetch( `https://www.googleapis.com/youtube/v3/channels?part=statistics,snippet&id=${channelIds.join(",")}`, { headers: { "X-Goog-Api-Key": process.env.YOUTUBE_API_KEY, }, }, ); if (!response.ok) { console.error("Failed to fetch YouTube data:", await response.text()); continue; } const data = await response.json().then((r) => r.items); const channels = z.array(youtubeChannelSchema).parse(data); const updateChunks = chunk(channels, 10); for (const updateChunk of updateChunks) { await Promise.all( updateChunk.map(async (channel) => { const partnerPlatform = channelChunk.find( (p) => p.platformId === channel.id, ); if (!partnerPlatform) { return; } const newStats = { subscribers: channel.statistics.subscriberCount, posts: channel.statistics.videoCount, views: channel.statistics.viewCount, avatarUrl: channel.snippet?.thumbnails?.default?.url, ...(channel.snippet?.customUrl && { identifier: channel.snippet.customUrl.replace("@", ""), }), }; await prisma.partnerPlatform.update({ where: { id: partnerPlatform.id, }, data: { ...newStats, lastCheckedAt: new Date(), }, }); console.log( `Updated YouTube stats for @${partnerPlatform.identifier}`, newStats, ); }), ); } } return logAndRespond( `YouTube stats updated for ${youtubeChannels.length} partners`, ); }); ================================================ FILE: apps/web/app/(ee)/api/cron/partner-platforms/youtube/youtube-channel-schema.ts ================================================ import * as z from "zod/v4"; export const youtubeChannelSchema = z.object({ id: z.string(), statistics: z.object({ videoCount: z.string().transform((val) => parseInt(val, 10)), subscriberCount: z.string().transform((val) => parseInt(val, 10)), viewCount: z.string().transform((val) => parseInt(val, 10)), }), snippet: z .object({ customUrl: z.string().nullish(), // YouTube handle (e.g. "channelname" for @channelname) thumbnails: z .object({ default: z .object({ url: z.string(), width: z.number().optional(), height: z.number().optional(), }) .optional(), }) .optional(), }) .optional(), }); ================================================ FILE: apps/web/app/(ee)/api/cron/partner-program-summary/process/route.ts ================================================ import { getAnalytics } from "@/lib/analytics/get-analytics"; import { qstash } from "@/lib/cron"; import { withCron } from "@/lib/cron/with-cron"; import { sendBatchEmail } from "@dub/email"; import PartnerProgramSummary from "@dub/email/templates/partner-program-summary"; import { prisma } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { endOfMonth, format, startOfMonth, subMonths } from "date-fns"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const PARTNER_BATCH_SIZE = 100; const queue = qstash.queue({ queueName: "send-partner-summary", }); const schema = z.object({ programId: z.string(), startingAfter: z.string().nullish(), batchNumber: z.number().nullish(), }); interface AnalyticsResponse { partnerId: string; clicks: number; leads: number; sales: number; saleAmount: number; } // This route processes partner program summary emails for a specific program. // Called by the main route after enqueuing jobs for each program. // POST /api/cron/partner-program-summary/process export const POST = withCron(async ({ rawBody }) => { const result = schema.parse(JSON.parse(rawBody)); let { programId, startingAfter, batchNumber } = result; const previousMonth = startOfMonth(subMonths(new Date(), 2)); const currentMonth = startOfMonth(subMonths(new Date(), 1)); const program = await prisma.program.findUnique({ where: { id: programId, }, select: { id: true, name: true, logo: true, slug: true, supportEmail: true, workspaceId: true, }, }); if (!program) { return logAndRespond(`Program ${programId} not found.`); } console.info(`Sending program summary for ${program.slug}`, { previousMonth, currentMonth, }); // Find the clicks, leads, sales analytics const [previousMonthAnalytics, currentMonthAnalytics] = await Promise.all([ // 2 months ago getAnalytics({ event: "composite", groupBy: "top_partners", workspaceId: program.workspaceId, programId: program.id, start: previousMonth, end: endOfMonth(previousMonth), }), // 1 month ago getAnalytics({ event: "composite", groupBy: "top_partners", workspaceId: program.workspaceId, programId: program.id, start: currentMonth, end: endOfMonth(currentMonth), }), ]); const programEnrollments = await prisma.programEnrollment.findMany({ where: { programId: program.id, status: "approved", partner: { users: { some: {}, }, }, links: { some: { leads: { gt: 0, }, }, }, }, select: { id: true, partner: { select: { id: true, email: true, createdAt: true, }, }, links: { select: { clicks: true, leads: true, sales: true, }, }, }, ...(startingAfter && { skip: 1, cursor: { id: startingAfter, }, }), orderBy: { id: "desc", }, take: PARTNER_BATCH_SIZE, }); console.info( `Found ${programEnrollments.length} active partners that have signed up for partners.dub.co and have links with at least 1 total lead.`, ); if (programEnrollments.length === 0) { return logAndRespond( `No more active partners found for program ${program.id}.`, ); } // Find the earnings const partners = programEnrollments.map(({ partner }) => partner); const commissionWhere: Prisma.CommissionWhereInput = { earnings: { gt: 0, }, programId: program.id, partnerId: { in: partners.map((partner) => partner.id), }, status: { in: ["pending", "processed", "paid"], }, }; const [previousMonthEarnings, currentMonthEarnings, lifetimeEarnings] = await Promise.all([ // Earnings 2 months ago (to compare with previous month) prisma.commission.groupBy({ by: ["partnerId"], where: { ...commissionWhere, createdAt: { gte: previousMonth, lte: endOfMonth(previousMonth), }, }, _sum: { earnings: true, }, }), // Earnings 1 month ago, prisma.commission.groupBy({ by: ["partnerId"], where: { ...commissionWhere, createdAt: { gte: currentMonth, lte: endOfMonth(currentMonth), }, }, _sum: { earnings: true, }, }), // All-time earnings prisma.commission.groupBy({ by: ["partnerId"], where: { ...commissionWhere, }, _sum: { earnings: true, }, }), ]); const previousEarningsMap = new Map( previousMonthEarnings.map((e) => [e.partnerId, e]), ); const currentEarningsMap = new Map( currentMonthEarnings.map((e) => [e.partnerId, e]), ); const lifetimeEarningsMap = new Map( lifetimeEarnings.map((e) => [e.partnerId, e]), ); const previousAnalyticsMap: Map = new Map( previousMonthAnalytics.map((a: AnalyticsResponse) => [a.partnerId, a]), ); const currentAnalyticsMap: Map = new Map( currentMonthAnalytics.map((a: AnalyticsResponse) => [a.partnerId, a]), ); const summary = partners.map((partner) => { // Get previous and current month analytics from Tinybird const _previousMonthAnalytics = previousAnalyticsMap.get(partner.id); const _currentMonthAnalytics = currentAnalyticsMap.get(partner.id); // Get lifetime analytics from MySQL const _lifetimeAnalytics = programEnrollments .find((enrollment) => enrollment.partner.id === partner.id) ?.links.reduce( (acc, link) => ({ clicks: acc.clicks + link.clicks, leads: acc.leads + link.leads, sales: acc.sales + link.sales, }), { clicks: 0, leads: 0, sales: 0 }, ); // Get earnings data from MySQL const _previousMonthEarnings = previousEarningsMap.get(partner.id); const _currentMonthEarnings = currentEarningsMap.get(partner.id); const _lifetimeEarnings = lifetimeEarningsMap.get(partner.id); return { partner, previousMonth: { clicks: _previousMonthAnalytics?.clicks ?? 0, leads: _previousMonthAnalytics?.leads ?? 0, sales: _previousMonthAnalytics?.sales ?? 0, earnings: _previousMonthEarnings?._sum.earnings ?? 0, }, currentMonth: { clicks: _currentMonthAnalytics?.clicks ?? 0, leads: _currentMonthAnalytics?.leads ?? 0, sales: _currentMonthAnalytics?.sales ?? 0, earnings: _currentMonthEarnings?._sum.earnings ?? 0, }, lifetime: { clicks: _lifetimeAnalytics?.clicks ?? 0, leads: _lifetimeAnalytics?.leads ?? 0, sales: _lifetimeAnalytics?.sales ?? 0, earnings: _lifetimeEarnings?._sum.earnings ?? 0, }, }; }); console.table( summary.map((s) => ({ partner: s.partner.email, program: program.name, currentClicks: s.currentMonth.clicks, currentLeads: s.currentMonth.leads, currentSales: s.currentMonth.sales, currentEarnings: s.currentMonth.earnings, lifetimeClicks: s.lifetime.clicks, lifetimeLeads: s.lifetime.leads, lifetimeSales: s.lifetime.sales, lifetimeEarnings: s.lifetime.earnings, })), ); const reportingMonth = format(currentMonth, "MMM yyyy"); batchNumber = batchNumber || 1; await sendBatchEmail( summary.map(({ partner, ...rest }) => ({ variant: "notifications", subject: `Your ${reportingMonth} performance report for ${program.name} program`, to: partner.email!, replyTo: program.supportEmail || "noreply", react: PartnerProgramSummary({ program, partner, ...rest, reportingPeriod: { month: reportingMonth, start: currentMonth.toISOString(), end: endOfMonth(currentMonth).toISOString(), }, }), })), { idempotencyKey: `partner-program-summary-${reportingMonth}-${program.id}-${batchNumber}`, }, ); // Schedule the next batch if there are more partners to process if (programEnrollments.length === PARTNER_BATCH_SIZE) { startingAfter = programEnrollments[programEnrollments.length - 1].id; batchNumber++; const response = await queue.enqueueJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partner-program-summary/process`, method: "POST", body: { ...result, startingAfter, batchNumber, }, }); return logAndRespond( `Enqueued partner program summary jobs for the next batch ${response.messageId}`, ); } return logAndRespond( `Finished processing all partners for program ${program.id}.`, ); }); ================================================ FILE: apps/web/app/(ee)/api/cron/partner-program-summary/route.ts ================================================ import { enqueueBatchJobs } from "@/lib/cron/enqueue-batch-jobs"; import { withCron } from "@/lib/cron/with-cron"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { format, startOfMonth, subMonths } from "date-fns"; import { logAndRespond } from "../utils"; export const dynamic = "force-dynamic"; const PROGRAM_BATCH_SIZE = 50; // This route handles the monthly partner program summary emails for partners. // Scheduled to run at 1 PM UTC on the 1st day of every month to send the previous month's summary. // GET /api/cron/partner-program-summary export const GET = withCron(async () => { const currentMonth = startOfMonth(subMonths(new Date(), 1)); const yearMonth = format(currentMonth, "yyyy-MM"); let page = 0; while (true) { const programs = await prisma.program.findMany({ select: { id: true, }, take: PROGRAM_BATCH_SIZE, skip: page * PROGRAM_BATCH_SIZE, orderBy: { id: "asc", }, }); if (programs.length === 0) { break; } await enqueueBatchJobs( programs.map((program) => ({ queueName: "send-partner-summary", url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partner-program-summary/process`, deduplicationId: `partner-program-summary-${yearMonth}-${program.id}`, body: { programId: program.id, }, })), ); page++; } return logAndRespond( `Enqueued partner program summary jobs for ${yearMonth}.`, ); }); ================================================ FILE: apps/web/app/(ee)/api/cron/partners/auto-approve/route.ts ================================================ import { getPartnerApplicationRisks } from "@/lib/api/fraud/get-partner-application-risks"; import { withCron } from "@/lib/cron/with-cron"; import { approvePartnerEnrollment } from "@/lib/partners/approve-partner-enrollment"; import { evaluateApplicationRequirements } from "@/lib/partners/evaluate-application-requirements"; import { getPlanCapabilities } from "@/lib/plan-capabilities"; import { prisma } from "@dub/prisma"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const schema = z.object({ programId: z.string(), partnerId: z.string(), }); // POST /api/cron/partners/auto-approve // This route is used to auto-approve a partner enrolled in a program export const POST = withCron(async ({ rawBody }) => { const { programId, partnerId } = schema.parse(JSON.parse(rawBody)); const programEnrollment = await prisma.programEnrollment.findUnique({ where: { partnerId_programId: { partnerId, programId, }, }, include: { partnerGroup: true, partner: { include: { platforms: true, }, }, }, }); if (!programEnrollment) { return logAndRespond( `Partner ${partnerId} not found in program ${programId}. Skipping auto-approval.`, ); } const group = programEnrollment.partnerGroup; if (!group) { return logAndRespond( `Group not found for partner ${partnerId} in program ${programId}. Skipping auto-approval.`, ); } if (!group.autoApprovePartnersEnabledAt) { return logAndRespond( `Group ${group.id} does not have auto-approval enabled. Skipping auto-approval.`, ); } if (programEnrollment.status !== "pending") { return logAndRespond( `${partnerId} is in ${programEnrollment.status} status. Skipping auto-approval.`, ); } // Check if the workspace plan has fraud event management capabilities // If enabled, we'll evaluate risk signals before auto-approving const program = await prisma.program.findUniqueOrThrow({ where: { id: programId, }, include: { workspace: { include: { users: { where: { role: "owner", }, take: 1, }, }, }, }, }); const { canManageFraudEvents } = getPlanCapabilities(program.workspace.plan); if (canManageFraudEvents) { const { riskSeverity } = await getPartnerApplicationRisks({ program, partner: programEnrollment.partner, }); if (riskSeverity === "high") { return logAndRespond( `Partner ${partnerId} has high risk. Skipping auto-approval.`, ); } } const result = evaluateApplicationRequirements({ applicationRequirements: program.applicationRequirements, context: { country: programEnrollment.partner.country, email: programEnrollment.partner.email, }, }); if (!result.valid) { switch (result.reason) { case "invalidRequirements": return logAndRespond( `Invalid applicationRequirements for program ${programId}. Skipping auto-approval.`, ); case "requirementsNotMet": return logAndRespond( `Partner ${partnerId} does not meet eligibility requirements. Skipping auto-approval.`, ); } } await approvePartnerEnrollment({ programId, partnerId, userId: program.workspace.users[0].userId, groupId: programEnrollment.groupId, }); return logAndRespond( `Successfully auto-approved partner ${partnerId} in program ${programId}.`, ); }); ================================================ FILE: apps/web/app/(ee)/api/cron/partners/auto-reject/route.ts ================================================ import { resolveFraudGroups } from "@/lib/api/fraud/resolve-fraud-groups"; import { withCron } from "@/lib/cron/with-cron"; import { evaluateApplicationRequirements } from "@/lib/partners/evaluate-application-requirements"; import { sendEmail } from "@dub/email"; import PartnerApplicationRejected from "@dub/email/templates/partner-application-rejected"; import { prisma } from "@dub/prisma"; import { ProgramEnrollmentStatus } from "@dub/prisma/client"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const inputSchema = z.object({ programId: z.string(), partnerId: z.string(), }); // POST /api/cron/partners/auto-reject // This route is used to auto-reject a partner enrollment (e.g. when eligibility requirements are not met) export const POST = withCron(async ({ rawBody }) => { const { programId, partnerId } = inputSchema.parse(JSON.parse(rawBody)); const programEnrollment = await prisma.programEnrollment.findUnique({ where: { partnerId_programId: { partnerId, programId, }, }, include: { partner: { select: { id: true, name: true, email: true, country: true, }, }, program: { select: { id: true, name: true, slug: true, supportEmail: true, applicationRequirements: true, }, }, }, }); if (!programEnrollment) { return logAndRespond( `Partner ${partnerId} not found in program ${programId}. Skipping auto-reject.`, ); } if (programEnrollment.status !== "pending") { return logAndRespond( `Partner ${partnerId} is in ${programEnrollment.status} status. Skipping auto-reject.`, ); } const result = evaluateApplicationRequirements({ applicationRequirements: programEnrollment.program.applicationRequirements, context: { country: programEnrollment.partner.country, email: programEnrollment.partner.email, }, }); if (result.reason !== "requirementsNotMet") { return logAndRespond( `Partner ${partnerId} now meets requirements for program ${programId} (reason: ${result.reason}). Skipping auto-reject.`, ); } const { count } = await prisma.programEnrollment.updateMany({ where: { id: programEnrollment.id, status: ProgramEnrollmentStatus.pending, }, data: { status: ProgramEnrollmentStatus.rejected, clickRewardId: null, leadRewardId: null, saleRewardId: null, discountId: null, }, }); if (count === 0) { return logAndRespond( `Partner ${partnerId} is no longer pending in program ${programId}. Skipping auto-reject.`, ); } await resolveFraudGroups({ where: { programId, partnerId, }, resolutionReason: "Resolved automatically because the partner application was automatically rejected.", }); const { partner, program } = programEnrollment; if (partner.email) { await sendEmail({ to: partner.email, subject: `Your application to ${program.name} was not approved`, variant: "notifications", replyTo: program.supportEmail || "noreply", react: PartnerApplicationRejected({ partner: { name: partner.name ?? "there", email: partner.email, }, program: { name: program.name, slug: program.slug, supportEmail: program.supportEmail ?? undefined, }, }), }); } return logAndRespond( `Successfully auto-rejected partner ${partnerId} in program ${programId}.`, ); }); ================================================ FILE: apps/web/app/(ee)/api/cron/partners/ban/cancel-commissions.ts ================================================ import { prisma } from "@dub/prisma"; // Mark the commissions as canceled export async function cancelCommissions({ programId, partnerId, }: { programId: string; partnerId: string; }) { let canceledCommissions = 0; let failedBatches = 0; const maxRetries = 3; while (true) { try { const commissions = await prisma.commission.findMany({ where: { programId, partnerId, // cancel all commissions that are pending // as well as processed commissions (added to a payout) but the payout was canceled OR: [ { status: "pending", }, { status: "processed", payout: { status: "canceled", }, }, ], }, select: { id: true, }, orderBy: { id: "asc", }, take: 500, }); if (commissions.length === 0) { break; } const { count } = await prisma.commission.updateMany({ where: { id: { in: commissions.map((c) => c.id), }, }, data: { status: "canceled", }, }); canceledCommissions += count; } catch (error) { failedBatches++; // If we've failed too many times, break to avoid infinite loop if (failedBatches >= maxRetries) { console.error( `Failed to cancel commissions after ${maxRetries} attempts. Stopping batch processing.`, ); break; } // Wait a bit before retrying the same batch await new Promise((resolve) => setTimeout(resolve, 1000)); } } if (failedBatches > 0) { console.warn( `Canceled ${canceledCommissions} commissions with ${failedBatches} failed batch(es).`, ); } else { console.info(`Canceled ${canceledCommissions} commissions.`); } } ================================================ FILE: apps/web/app/(ee)/api/cron/partners/ban/route.ts ================================================ import { deleteDiscountCodes } from "@/lib/api/discounts/delete-discount-code"; import { reportCrossProgramBanToNetwork } from "@/lib/api/fraud/report-cross-program-ban-to-network"; import { linkCache } from "@/lib/api/links/cache"; import { includeTags } from "@/lib/api/links/include-tags"; import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withCron } from "@/lib/cron/with-cron"; import { recordLink } from "@/lib/tinybird"; import { BAN_PARTNER_REASONS } from "@/lib/zod/schemas/partners"; import { sendEmail } from "@dub/email"; import PartnerBanned from "@dub/email/templates/partner-banned"; import { prisma } from "@dub/prisma"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; import { cancelCommissions } from "./cancel-commissions"; const schema = z.object({ programId: z.string(), partnerId: z.string(), }); // POST /api/cron/partners/ban - handle all side effects of banning a partner export const POST = withCron(async ({ rawBody }) => { const { programId, partnerId } = schema.parse(JSON.parse(rawBody)); console.info(`Banning partner ${partnerId} from program ${programId}...`); const { partner, links, ...programEnrollment } = await getProgramEnrollmentOrThrow({ partnerId, programId, include: { partner: true, links: { include: { ...includeTags, discountCode: true, }, }, }, }); if (programEnrollment.status !== "banned") { return logAndRespond( `Partner ${programEnrollment.partnerId} is not banned from program ${programEnrollment.programId}.`, ); } const commonWhere = { programId, partnerId, }; const [linksUpdated, bountySubmissions, discountCodes, payouts] = await prisma.$transaction([ // Disable links prisma.link.updateMany({ where: { ...commonWhere, }, data: { disabledAt: new Date(), expiresAt: new Date(), }, }), // Reject bounty submissions prisma.bountySubmission.updateMany({ where: { ...commonWhere, status: { not: "approved", }, }, data: { status: "rejected", rejectionReason: "other", rejectionNote: "Rejected automatically because the partner was banned.", }, }), // Remove discount codes prisma.discountCode.updateMany({ where: { ...commonWhere, }, data: { discountId: null, }, }), // Cancel payouts prisma.payout.updateMany({ where: { ...commonWhere, status: "pending", }, data: { status: "canceled", }, }), ]); console.info(`Disabled ${linksUpdated.count} links.`); console.info(`Rejected ${bountySubmissions.count} bounty submissions.`); console.info(`Removed ${discountCodes.count} discount codes.`); console.info(`Canceled ${payouts.count} payouts.`); // Mark the commissions as canceled await cancelCommissions({ programId, partnerId, }); await Promise.all([ // Sync total commissions syncTotalCommissions({ programId, partnerId, }), // Expire links from cache linkCache.expireMany(links), // Delete links from Tinybird links metadata recordLink(links, { deleted: true }), // Queue discount code deletions deleteDiscountCodes(links.map((link) => link.discountCode)), ]); await reportCrossProgramBanToNetwork({ partnerId, programId, bannedReason: programEnrollment.bannedReason, bannedAt: programEnrollment.bannedAt, }); // Send email if (partner.email) { const program = await prisma.program.findUniqueOrThrow({ where: { id: programId, }, select: { name: true, slug: true, supportEmail: true, }, }); try { await sendEmail({ to: partner.email, subject: `You've been banned from the ${program.name} Partner Program`, variant: "notifications", replyTo: program.supportEmail || "noreply", react: PartnerBanned({ partner: { name: partner.name, email: partner.email, }, program: { name: program.name, slug: program.slug, }, // A reason is always present because we validate the schema bannedReason: programEnrollment.bannedReason ? BAN_PARTNER_REASONS[programEnrollment.bannedReason!] : "", }), }); } catch {} } return logAndRespond( `Partner ${partnerId} banned from the program ${programId}.`, ); }); ================================================ FILE: apps/web/app/(ee)/api/cron/partners/deactivate/route.ts ================================================ import { deleteDiscountCodes } from "@/lib/api/discounts/delete-discount-code"; import { linkCache } from "@/lib/api/links/cache"; import { withCron } from "@/lib/cron/with-cron"; import { sendBatchEmail } from "@dub/email"; import PartnerDeactivated from "@dub/email/templates/partner-deactivated"; import { prisma } from "@dub/prisma"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; const inputSchema = z.object({ programId: z.string(), partnerIds: z.array(z.string()), programDeactivated: z.boolean().optional().default(false), }); // POST /api/cron/partners/deactivate - deactivate partners in a program export const POST = withCron(async ({ rawBody }) => { const { programId, partnerIds, programDeactivated } = inputSchema.parse( JSON.parse(rawBody), ); const programEnrollments = await prisma.programEnrollment.findMany({ where: { programId, partnerId: { in: partnerIds, }, }, include: { partner: { include: { _count: { select: { users: true, }, }, }, }, links: true, discountCodes: true, }, }); // Expire all links in cache const links = programEnrollments.flatMap(({ links }) => links); await linkCache.expireMany(links); console.log("[bulkDeactivatePartners] Expired links in cache."); // Queue discount code deletions const discountCodes = programEnrollments.flatMap(({ discountCodes }) => discountCodes.map((dc) => dc), ); await deleteDiscountCodes(discountCodes); console.log("[bulkDeactivatePartners] Queued discount code deletions."); // Find the program const program = await prisma.program.findUniqueOrThrow({ where: { id: programId, }, select: { name: true, slug: true, supportEmail: true, }, }); // Send notification emails const emailResponse = await sendBatchEmail( programEnrollments // only notify partners with user accounts (meaning they've signed up on partners.dub.co) .filter(({ partner }) => partner._count.users > 0) .map(({ partner }) => ({ variant: "notifications", subject: programDeactivated ? `The ${program.name} program has been deactivated` : `Your partnership with ${program.name} has been deactivated`, to: partner.email!, replyTo: program.supportEmail || "noreply", react: PartnerDeactivated({ partner: { name: partner.name, email: partner.email!, }, program: { name: program.name, slug: program.slug, }, programDeactivated, }), })), ); console.log("[bulkDeactivatePartners] Sent notification emails.", { response: emailResponse, }); return logAndRespond( `[bulkDeactivatePartners] Deactivated ${partnerIds.length} partners for program ${programId}.`, ); }); ================================================ FILE: apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { resolveFraudGroups } from "@/lib/api/fraud/resolve-fraud-groups"; import { linkCache } from "@/lib/api/links/cache"; import { includeProgramEnrollment } from "@/lib/api/links/include-program-enrollment"; import { includeTags } from "@/lib/api/links/include-tags"; import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { conn } from "@/lib/planetscale"; import { storage } from "@/lib/storage"; import { recordLink } from "@/lib/tinybird"; import { redis } from "@/lib/upstash"; import { sendBatchEmail } from "@dub/email"; import PartnerAccountMerged from "@dub/email/templates/partner-account-merged"; import { prisma } from "@dub/prisma"; import { log, prettyPrint, R2_URL } from "@dub/utils"; import * as z from "zod/v4"; export const dynamic = "force-dynamic"; const schema = z.object({ userId: z.string(), sourceEmail: z.string(), targetEmail: z.string(), }); const CACHE_KEY_PREFIX = "merge-partner-accounts"; // POST /api/cron/partners/merge-accounts // This route is used to merge a partner account into another account export async function POST(req: Request) { let userId: string | null = null; try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); const { userId: parsedUserId, sourceEmail, targetEmail, } = schema.parse(JSON.parse(rawBody)); userId = parsedUserId; console.log({ userId, sourceEmail, targetEmail, }); const partnerAccounts = await prisma.partner.findMany({ where: { email: { in: [sourceEmail, targetEmail], }, }, select: { id: true, email: true, image: true, payoutMethodHash: true, programs: { select: { programId: true, tenantId: true, }, }, users: { select: { userId: true, }, }, partnerRewinds: true, }, }); if (partnerAccounts.length === 0) { return new Response("Partner accounts not found."); } const sourceAccount = partnerAccounts.find( ({ email }) => email?.toLowerCase() === sourceEmail.toLowerCase(), ); const targetAccount = partnerAccounts.find( ({ email }) => email?.toLowerCase() === targetEmail.toLowerCase(), ); if (!sourceAccount) { return new Response( `Partner account with email ${sourceEmail} not found.`, ); } if (!targetAccount) { return new Response( `Partner account with email ${targetEmail} not found.`, ); } if (sourceAccount.id === targetAccount.id) { return new Response( `Source and target partner accounts must be different. Source account: ${sourceAccount.email} (${sourceAccount.id}), Target account: ${targetAccount.email} (${targetAccount.id})`, ); } const { id: sourcePartnerId, users: sourcePartnerUsers, programs: sourcePartnerEnrollments, } = sourceAccount; const { id: targetPartnerId, programs: targetPartnerEnrollments } = targetAccount; // Find new enrollments that are not in the target partner enrollments const newEnrollments = sourcePartnerEnrollments.filter( ({ programId }) => !targetPartnerEnrollments.some( ({ programId: targetProgramId }) => programId === targetProgramId, ), ); // Update program enrollments if (newEnrollments.length > 0) { await prisma.programEnrollment.updateMany({ where: { programId: { in: newEnrollments.map(({ programId }) => programId), }, partnerId: sourcePartnerId, }, data: { partnerId: targetPartnerId, }, }); } const programIdsToTransfer = sourcePartnerEnrollments.map( ({ programId }) => programId, ); const updateManyPayload = { where: { programId: { in: programIdsToTransfer, }, partnerId: sourcePartnerId, }, data: { partnerId: targetPartnerId, }, }; // update links, commissions, bounty submissions, and payouts if (programIdsToTransfer.length > 0) { const [ updatedLinksRes, updatedCustomersRes, updatedCommissionsRes, updatedPayoutsRes, ] = await Promise.all([ prisma.link.updateMany(updateManyPayload), prisma.customer.updateMany(updateManyPayload), prisma.commission.updateMany(updateManyPayload), prisma.payout.updateMany(updateManyPayload), ]); console.log( `Updated ${updatedLinksRes.count} links, ${updatedCustomersRes.count} customers, ${updatedCommissionsRes.count} commissions, and ${updatedPayoutsRes.count} payouts`, ); // update discount codes, notification emails, messages, and partner comments const [ updatedDiscountCodesRes, updatedNotificationEmailsRes, updatedMessagesRes, updatedPartnerCommentsRes, ] = await Promise.all([ prisma.discountCode.updateMany(updateManyPayload), prisma.notificationEmail.updateMany(updateManyPayload), prisma.message.updateMany(updateManyPayload), prisma.partnerComment.updateMany(updateManyPayload), ]); console.log( `Updated ${updatedDiscountCodesRes.count} discount codes, ${updatedNotificationEmailsRes.count} notification emails, ${updatedMessagesRes.count} messages, and ${updatedPartnerCommentsRes.count} partner comments`, ); const updatedLinks = await prisma.link.findMany({ where: { programId: { in: programIdsToTransfer, }, partnerId: targetPartnerId, }, include: { ...includeTags, ...includeProgramEnrollment, }, }); // only transfer bounty submissions if the target partner has no submissions for the same bounty const bountySubmissionStats = await prisma.bountySubmission.groupBy({ by: ["bountyId"], where: { partnerId: { in: [sourcePartnerId, targetPartnerId], }, }, _count: { partnerId: true, }, }); const bountiesToTransfer = bountySubmissionStats .filter(({ _count }) => _count.partnerId === 1) .map(({ bountyId }) => bountyId); if (bountiesToTransfer.length > 0) { const updatedBountySubmissions = await prisma.bountySubmission.updateMany({ where: { bountyId: { in: bountiesToTransfer }, partnerId: sourcePartnerId, }, data: { partnerId: targetPartnerId, }, }); console.log( `Transferred ${updatedBountySubmissions.count} bounty submissions`, ); } const res = await Promise.allSettled([ // update link metadata in Tinybird recordLink(updatedLinks), // expire link cache in Redis linkCache.expireMany(updatedLinks), // Sync total commissions for the target partner in each program ...programIdsToTransfer.map((programId) => syncTotalCommissions({ partnerId: targetPartnerId, programId, mode: "direct", }), ), ]); console.log(prettyPrint(res)); } const existingEnrollments = sourcePartnerEnrollments.filter( ({ programId }) => targetPartnerEnrollments.some( ({ programId: targetProgramId }) => programId === targetProgramId, ), ); if (existingEnrollments.length > 0) { for (const sourceEnrollment of existingEnrollments) { const targetEnrollment = targetPartnerEnrollments.find( ({ programId }) => programId === sourceEnrollment.programId, ); await prisma.$transaction(async (tx) => { // delete old source enrollment await tx.programEnrollment.delete({ where: { partnerId_programId: { partnerId: sourcePartnerId, programId: sourceEnrollment.programId, }, }, }); // update target enrollment with source enrollment's tenantId if target enrollment does not have a tenantId if (sourceEnrollment.tenantId && !targetEnrollment?.tenantId) { await tx.programEnrollment.update({ where: { partnerId_programId: { partnerId: targetPartnerId, programId: sourceEnrollment.programId, }, }, data: { tenantId: sourceEnrollment.tenantId, }, }); } }); console.log( `Deleted old source enrollment for program ${sourceEnrollment.programId}.${sourceEnrollment.tenantId ? ` Since there was a tenantId, we updated the target enrollment with the same tenantId: ${sourceEnrollment.tenantId}` : ""}`, ); } } // If source account has rewind, need to delete and recalculate for the target account if (sourceAccount.partnerRewinds.length > 0) { const deletedRewinds = await prisma.partnerRewind.deleteMany({ where: { partnerId: sourcePartnerId, }, }); console.log(`Deleted ${deletedRewinds.count} partner rewinds`); } // Remove the user if there are no workspaces left // TODO: we need to handle deleting multiple users when we allow partners to invite their team members in the future const sourcePartnerUser = sourcePartnerUsers[0]; if (sourcePartnerUser) { const workspaceCount = await prisma.projectUsers.count({ where: { userId: sourcePartnerUser.userId, }, }); if (workspaceCount === 0) { try { const deletedUser = await prisma.user.delete({ where: { id: sourcePartnerUser.userId, }, select: { id: true, email: true, image: true, }, }); console.log(`Deleted user ${deletedUser.email} (${deletedUser.id})`); if (deletedUser.image) { await storage.delete({ key: deletedUser.image.replace(`${R2_URL}/`, ""), }); } } catch (error) { console.error( `Error deleting user ${sourcePartnerUser.userId}: ${error.message}`, ); } } } try { // Finally, delete the partner account await conn.execute(`DELETE FROM Partner WHERE id = ?`, [sourcePartnerId]); console.log( `Deleted partner ${sourceAccount.email} (${sourceAccount.id})`, ); if (sourceAccount.image) { await storage.delete({ key: sourceAccount.image.replace(`${R2_URL}/`, ""), }); } } catch (error) { console.error( `Error deleting partner ${sourcePartnerId}: ${error.message}`, ); } // After merging, check if the fraud condition has been resolved. // If no other partners share the same payout method hash, we can // automatically resolve any pending fraud groups for this partner. if (targetAccount.payoutMethodHash) { const duplicatePartners = await prisma.partner.count({ where: { payoutMethodHash: targetAccount.payoutMethodHash, }, }); if (duplicatePartners <= 1) { await resolveFraudGroups({ where: { partnerId: targetPartnerId, type: "partnerDuplicatePayoutMethod", }, resolutionReason: "Automatically resolved because partners with duplicate payout methods were merged. No other partners share this payout method.", }); } } // Make sure the cache is cleared await redis.del(`${CACHE_KEY_PREFIX}:${userId}`); const resendBatchEmailRes = await sendBatchEmail( [ { variant: "notifications", to: sourceEmail, subject: "Your Dub partner accounts are now merged", react: PartnerAccountMerged({ email: sourceEmail, sourceEmail, targetEmail, }), }, { variant: "notifications", to: targetEmail, subject: "Your Dub partner accounts are now merged", react: PartnerAccountMerged({ email: targetEmail, sourceEmail, targetEmail, }), }, ], { idempotencyKey: `${CACHE_KEY_PREFIX}/${userId}`, }, ); console.log(prettyPrint(resendBatchEmailRes)); return new Response( `Partner account ${sourceEmail} merged into ${targetEmail}.`, ); } catch (error) { if (userId) { await redis.del(`${CACHE_KEY_PREFIX}:${userId}`); } await log({ message: `Error merging partner accounts: ${error.message}`, type: "alerts", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts ================================================ import { createId } from "@/lib/api/create-id"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { verifyVercelSignature } from "@/lib/cron/verify-vercel"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, chunk, log } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const BATCH_SIZE = 1000; const schema = z.object({ programId: z.string().optional().describe("Optional program ID to filter by"), }); // This cron job aggregates due commissions (pending commissions that are past the partner group's holding period) into payouts. // Runs once every hour (0 * * * *) + calls itself recursively to look through all pending commissions available. async function handler(req: Request) { try { let programId: string | undefined = undefined; if (req.method === "GET") { await verifyVercelSignature(req); } else if (req.method === "POST") { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); ({ programId } = schema.parse(JSON.parse(rawBody))); } const partnerGroupsByHoldingPeriod = await prisma.partnerGroup.groupBy({ by: ["holdingPeriodDays"], ...(programId ? { where: { programId } } : {}), _count: { id: true, }, orderBy: { _count: { id: "desc", }, }, }); console.log(JSON.stringify(partnerGroupsByHoldingPeriod, null, 2)); let holdingPeriodsWithMoreToProcess: number[] = []; for (const { holdingPeriodDays } of partnerGroupsByHoldingPeriod) { const partnerGroups = await prisma.partnerGroup.findMany({ where: { holdingPeriodDays, ...(programId ? { programId } : {}), }, select: { id: true, program: { select: { id: true, name: true, }, }, }, }); console.log( `Found ${partnerGroups.length} partner groups with holding period days: ${holdingPeriodDays}`, ); // Find all due commissions (limit by BATCH_SIZE) const dueCommissions = await prisma.commission.findMany({ where: { status: "pending", programEnrollment: { groupId: { in: partnerGroups.map((p) => p.id), }, }, // If holding period days is greater than 0: // we only process commissions that were created before the holding period // but custom commissions are always included ...(holdingPeriodDays > 0 ? { OR: [ { type: "custom", // includes manual commissions + clawbacks }, { createdAt: { lt: new Date( Date.now() - holdingPeriodDays * 24 * 60 * 60 * 1000, ), }, }, ], } : {}), }, select: { id: true, createdAt: true, earnings: true, partnerId: true, programId: true, }, orderBy: { createdAt: "asc", }, take: BATCH_SIZE, }); if (dueCommissions.length === 0) { console.log( `No more due commissions found for partner groups with holding period days: ${holdingPeriodDays}, skipping...`, ); continue; } if (dueCommissions.length === BATCH_SIZE) { holdingPeriodsWithMoreToProcess.push(holdingPeriodDays); } console.log( `Found ${dueCommissions.length} due commissions for partner groups with holding period days: ${holdingPeriodDays}`, ); const partnerProgramCommissions = dueCommissions.reduce< Record >((acc, commission) => { const key = `${commission.partnerId}:${commission.programId}`; if (!acc[key]) { acc[key] = []; } acc[key].push(commission); return acc; }, {}); const partnerProgramCommissionsArray = Object.entries( partnerProgramCommissions, ).map(([key, commissions]) => ({ partnerId: key.split(":")[0], programId: key.split(":")[1], commissions, })); const existingPendingPayouts = await prisma.payout.findMany({ where: { programId: { in: partnerProgramCommissionsArray.map((p) => p.programId), }, partnerId: { in: partnerProgramCommissionsArray.map((p) => p.partnerId), }, status: "pending", }, }); console.log( `Processing ${partnerProgramCommissionsArray.length} partners with due commissions for partner groups with holding period days: ${holdingPeriodDays}`, ); let totalProcessed = 0; const chunks = chunk(partnerProgramCommissionsArray, 50); for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]; await Promise.allSettled( chunk.map(async ({ partnerId, programId, commissions }) => { // sort the commissions by createdAt const sortedCommissions = commissions.sort( (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), ); // sum the earnings of the commissions const totalEarnings = sortedCommissions.reduce( (total, commission) => total + commission.earnings, 0, ); // earliest commission date const periodStart = sortedCommissions[0].createdAt; // last commission date const periodEnd = sortedCommissions[sortedCommissions.length - 1].createdAt; let payoutToUse = existingPendingPayouts.find( (p) => p.partnerId === partnerId && p.programId === programId, ); if (!payoutToUse) { const programName = partnerGroups.find( (p) => p.program.id === programId, )?.program.name; payoutToUse = await prisma.payout.create({ data: { id: createId({ prefix: "po_" }), programId, partnerId, periodStart, periodEnd, amount: totalEarnings, description: `Dub Partners payout${programName ? ` (${programName})` : ""}`, }, }); } // update the commissions to have the payoutId await prisma.commission.updateMany({ where: { id: { in: commissions.map((c) => c.id) }, }, data: { status: "processed", payoutId: payoutToUse.id, }, }); // if we're reusing a pending payout, we need to update the amount and periodEnd if (existingPendingPayouts.find((p) => p.id === payoutToUse.id)) { await prisma.payout.update({ where: { id: payoutToUse.id, }, data: { amount: { increment: totalEarnings, }, periodEnd, }, }); } totalProcessed++; }), ); console.log(`Processed chunk ${i + 1} of ${chunks.length}`); } const successRate = (totalProcessed / partnerProgramCommissionsArray.length) * 100; console.log( `Processed ${totalProcessed}/${partnerProgramCommissionsArray.length} partners with due commissions for partner groups with holding period days: ${holdingPeriodDays} (${successRate.toFixed(1)}% success rate)`, ); } if (holdingPeriodsWithMoreToProcess.length > 0) { console.log( `Several holding periods still have more due commissions: ${holdingPeriodsWithMoreToProcess.join(", ")}`, ); const qstashResponse = await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/aggregate-due-commissions`, body: programId ? { programId } : {}, // pass programId if defined, else pass an empty object }); if (qstashResponse.messageId) { console.log( `Message sent to Qstash with id ${qstashResponse.messageId}`, ); } else { // should never happen, but just in case await log({ message: `Error sending message to Qstash to schedule next batch of payouts: ${JSON.stringify(qstashResponse)}`, type: "errors", mention: true, }); } return logAndRespond( "Finished aggregating due commissions into payouts for current batch. Scheduling next batch...", ); } return logAndRespond( "Finished aggregating due commissions into payouts for all batches.", ); } catch (error) { await log({ message: `Error aggregating due commissions into payouts: ${error.message}`, type: "errors", mention: true, }); return handleAndReturnErrorResponse(error); } } // GET/POST /api/cron/payouts/aggregate-due-commissions export { handler as GET, handler as POST }; ================================================ FILE: apps/web/app/(ee)/api/cron/payouts/balance-available/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { BANK_ACCOUNT_STATUS_DESCRIPTIONS } from "@/lib/constants/payouts"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { getPartnerBankAccount } from "@/lib/partners/get-partner-bank-account"; import { stripe } from "@/lib/stripe"; import { sendEmail } from "@dub/email"; import PartnerPayoutWithdrawalFailed from "@dub/email/templates/partner-payout-withdrawal-failed"; import PartnerPayoutWithdrawalInitiated from "@dub/email/templates/partner-payout-withdrawal-initiated"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, currencyFormatter, formatDate, log, prettyPrint, } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const payloadSchema = z.object({ stripeAccount: z.string(), }); // POST /api/cron/payouts/balance-available export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const { stripeAccount } = payloadSchema.parse(JSON.parse(rawBody)); const partner = await prisma.partner.findUnique({ where: { stripeConnectId: stripeAccount, }, select: { id: true, email: true, }, }); if (!partner) { return logAndRespond( `Partner not found with Stripe connect account ${stripeAccount}. Skipping...`, { logLevel: "error", }, ); } // Get the partner's current balance const balance = await stripe.balance.retrieve({ stripeAccount, }); if (balance.available.length === 0) { // should never happen, but just in case return logAndRespond( `Partner ${partner.email} (${stripeAccount}) has no available balances. Skipping...`, ); } let { amount: availableBalance, currency } = balance.available[0]; // if available balance is 0, check if there's any pending balance if (availableBalance === 0) { const pendingBalance = balance.pending?.[0]?.amount ?? 0; // if there's a pending balance, schedule another check in 1 hour if (pendingBalance > 0) { const res = await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/balance-available`, delay: 60 * 60, // check again in 1 hour body: { stripeAccount, }, }); console.log( `Scheduled another check for partner ${partner.email} (${stripeAccount}) in 1 hour: ${res.messageId}`, ); return logAndRespond( `Pending balance found for partner ${partner.email} (${stripeAccount}): ${currencyFormatter(pendingBalance, { currency })}. Scheduling another check in 1 hour...`, ); } return logAndRespond( `Partner ${partner.email} (${stripeAccount})'s available balance is 0. Skipping...`, ); } const bankAccount = await getPartnerBankAccount(stripeAccount); const statusInfo = bankAccount ? BANK_ACCOUNT_STATUS_DESCRIPTIONS[bankAccount.status] : // edge case for cases where the partner doesn't have a bank account on file at all { title: "No bank account", description: "This partner does not have an active bank account.", variant: "invalid", }; if (statusInfo.variant === "invalid") { if (partner.email) { const sentEmail = await sendEmail({ variant: "notifications", subject: "[Action Required]: Update your bank account details to receive payouts", to: partner.email, react: PartnerPayoutWithdrawalFailed({ email: partner.email, bankAccount, payout: { amount: availableBalance, currency, failureReason: statusInfo.description, isAvailableBalance: true, }, }), }); console.log( `Sent email to partner ${partner.email} (${stripeAccount}): ${prettyPrint(sentEmail)}`, ); } return logAndRespond( `Partner ${partner.email} (${stripeAccount}) has an errored bank account. Skipping...`, ); } if (["huf", "twd"].includes(currency)) { // For HUF and TWD, Stripe requires payout amounts to be evenly divisible by 100 // We need to round down to the nearest 100 units availableBalance = Math.floor(availableBalance / 100) * 100; } const stripePayout = await stripe.payouts.create( { amount: availableBalance, currency, // example: "Dub Partners auto-withdrawal (Aug 1, 2025)" description: `Dub Partners auto-withdrawal (${formatDate(new Date(), { month: "short" })})`, method: "standard", }, { stripeAccount, }, ); console.log( `Stripe payout created for partner ${partner.email} (${stripeAccount}): ${stripePayout.id} (${currencyFormatter(stripePayout.amount, { currency: stripePayout.currency })})`, ); const transfers = await stripe.transfers.list({ destination: stripeAccount, limit: 100, }); // update all payouts for the partner that match the following criteria to have the stripePayoutId: // - in the "sent" status // - no stripe payout id (meaning it was not yet withdrawn to the connected bank account) // - have a stripe transfer id (meaning it was transferred to this connected account) // OR: payouts that are in the "failed" status + have a stripePayoutId (failed to send before) const updatedPayouts = await prisma.payout.updateMany({ where: { partnerId: partner.id, OR: [ { status: "sent", stripePayoutId: null, stripeTransferId: { in: transfers.data.map(({ id }) => id), }, }, { status: "failed", stripePayoutId: { not: null, }, }, ], }, data: { stripePayoutId: stripePayout.id, }, }); console.log( `Updated ${updatedPayouts.count} payouts for partner ${partner.email} (${stripeAccount}) to have the stripePayoutId: ${stripePayout.id}`, ); if (partner.email) { const sentEmail = await sendEmail({ variant: "notifications", subject: `Your ${currencyFormatter(stripePayout.amount, { currency: stripePayout.currency })} auto-withdrawal from Dub is on its way to your bank`, to: partner.email, react: PartnerPayoutWithdrawalInitiated({ email: partner.email, payout: { amount: stripePayout.amount, currency: stripePayout.currency, arrivalDate: stripePayout.arrival_date, }, }), headers: { "Idempotency-Key": `payout-initiated-${stripePayout.id}`, }, }); console.log( `Sent email to partner ${partner.email} (${stripeAccount}): ${JSON.stringify(sentEmail, null, 2)}`, ); } return logAndRespond( `Processed "balance.available" for partner ${partner.email} (${stripeAccount})`, ); } catch (error) { await log({ message: `Error handling "balance.available" ${error.message}.`, type: "errors", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-external-payouts.ts ================================================ import { queueBatchEmail } from "@/lib/email/queue-batch-email"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { payoutWebhookEventSchema } from "@/lib/zod/schemas/payouts"; import type PartnerPayoutConfirmed from "@dub/email/templates/partner-payout-confirmed"; import { prisma } from "@dub/prisma"; import { Invoice } from "@dub/prisma/client"; import { currencyFormatter, log } from "@dub/utils"; export async function queueExternalPayouts( invoice: Pick< Invoice, "id" | "paymentMethod" | "programId" | "workspaceId" | "payoutMode" >, ) { // All payouts are processed internally, hence no need to queue external payouts if (invoice.payoutMode === "internal") { console.log(`Invoice ${invoice.id} is paid internally. Skipping...`); return; } // should never happen, but just in case if (!invoice.programId) { console.log(`Invoice ${invoice.id} has no program ID. Skipping...`); return; } const program = await prisma.program.findUnique({ where: { id: invoice.programId, }, select: { id: true, name: true, slug: true, logo: true, supportEmail: true, }, }); // should never happen, but just in case if (!program) { console.log(`Program not found for invoice ${invoice.id}. Skipping...`); return; } const externalPayouts = await prisma.payout.findMany({ where: { invoiceId: invoice.id, status: "processing", mode: "external", }, include: { partner: { include: { programs: { where: { programId: program.id, }, select: { tenantId: true, status: true, }, }, }, }, }, }); if (externalPayouts.length === 0) { console.log("No external payouts found for invoice", invoice.id); return; } const webhooks = await prisma.webhook.findMany({ where: { projectId: invoice.workspaceId, disabledAt: null, triggers: { array_contains: ["payout.confirmed"], }, }, select: { id: true, url: true, secret: true, }, }); if (webhooks.length === 0) { await log({ message: `No payout.confirmed webhook found for workspace ${invoice.workspaceId} (program: ${program.slug}, invoice: ${invoice.id}). Skipping external payouts...`, type: "errors", mention: true, }); return; } for (const payout of externalPayouts) { try { const data = payoutWebhookEventSchema.parse({ ...payout, partner: { ...payout.partner, ...payout.partner.programs[0], }, }); await sendWorkspaceWebhook({ workspace: { id: invoice.workspaceId, webhookEnabled: true, }, webhooks, data, trigger: "payout.confirmed", }); } catch (error) { console.error(error.message); } } await queueBatchEmail( externalPayouts.map((payout) => ({ to: payout.partner.email!, subject: `Your ${currencyFormatter(payout.amount)} payout for ${program.name} is on the way`, variant: "notifications", replyTo: program.supportEmail || "noreply", templateName: "PartnerPayoutConfirmed", templateProps: { email: payout.partner.email!, program: { id: program.id, name: program.name, logo: program.logo, }, payout: { id: payout.id, amount: payout.amount, initiatedAt: payout.initiatedAt, startDate: payout.periodStart, endDate: payout.periodEnd, mode: "external", paymentMethod: invoice.paymentMethod ?? "ach", payoutMethod: payout.method, }, }, })), { idempotencyKey: `payout-confirmed-external/${invoice.id}`, }, ); } ================================================ FILE: apps/web/app/(ee)/api/cron/payouts/charge-succeeded/queue-stripe-payouts.ts ================================================ import { qstash } from "@/lib/cron"; import { prisma } from "@dub/prisma"; import { Invoice, PartnerPayoutMethod } from "@dub/prisma/client"; import { APP_DOMAIN_WITH_NGROK, chunk, log } from "@dub/utils"; import * as z from "zod/v4"; const stripeChargeMetadataSchema = z.object({ id: z.string(), // Stripe charge id }); const queue = qstash.queue({ queueName: "send-stripe-payout", }); export async function queueStripePayouts( invoice: Pick< Invoice, "id" | "paymentMethod" | "stripeChargeMetadata" | "payoutMode" >, skipStablecoinPayouts: boolean, ) { // All payouts are processed externally, hence no need to queue Stripe payouts if (invoice.payoutMode === "external") { return; } const { id: invoiceId, paymentMethod, stripeChargeMetadata } = invoice; // Find the id of the charge that was used to fund the transfer const parsedChargeMetadata = stripeChargeMetadataSchema.safeParse(stripeChargeMetadata); const chargeId = parsedChargeMetadata?.success ? parsedChargeMetadata?.data.id : undefined; // this should never happen since all completed invoices should have a charge id, but just in case if (!chargeId) { await log({ message: "No charge id found in stripeChargeMetadata for invoice " + invoiceId + ", continuing without source_transaction.", type: "errors", }); } const partnersInCurrentInvoice = await prisma.payout.groupBy({ by: ["partnerId"], where: { invoiceId, status: "processing", mode: "internal", method: { in: [ PartnerPayoutMethod.connect, ...(!skipStablecoinPayouts ? [PartnerPayoutMethod.stablecoin] : []), ], }, partner: { OR: [ { stripeConnectId: { not: null, }, }, { stripeRecipientId: { not: null, }, }, ], // here we're not checking for payoutsEnabledAt since we want visiblity // if a stripe.transfers.create fails due to restricted Stripe account }, }, }); const chunkedPartners = chunk(partnersInCurrentInvoice, 100); for (let i = 0; i < chunkedPartners.length; i++) { const partnersInChunk = chunkedPartners[i]; await Promise.allSettled( partnersInChunk.map(({ partnerId }) => { return queue.enqueueJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/send-stripe-payout`, deduplicationId: `${invoiceId}-${partnerId}`, method: "POST", body: { partnerId, invoiceId, // only pass chargeId if payment method is card // this is because we're passing chargeId as source_transaction for card payouts since card payouts can take a short time to settle fully // we omit chargeId/source_transaction for other payment methods (ACH, SEPA, etc.) since those settle via charge.succeeded webhook after ~4 days // x-slack-ref: https://dub.slack.com/archives/C074P7LMV9C/p1758776038825219?thread_ts=1758769780.982089&cid=C074P7LMV9C ...(paymentMethod === "card" && { chargeId }), }, }); }), ); console.log( `Enqueued Stripe payout for ${partnersInChunk.length} partners in chunk ${i + 1} of ${chunkedPartners.length}`, ); } } ================================================ FILE: apps/web/app/(ee)/api/cron/payouts/charge-succeeded/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { MIN_WITHDRAWAL_AMOUNT_CENTS } from "@/lib/constants/payouts"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { fundFinancialAccount } from "@/lib/stripe/fund-financial-account"; import { prisma } from "@dub/prisma"; import { log } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; import { queueExternalPayouts } from "./queue-external-payouts"; import { queueStripePayouts } from "./queue-stripe-payouts"; import { sendPaypalPayouts } from "./send-paypal-payouts"; import { scheduleDelayedStablecoinPayouts } from "./utils"; export const dynamic = "force-dynamic"; export const maxDuration = 600; // This function can run for a maximum of 10 minutes const payloadSchema = z.object({ invoiceId: z.string(), }); // POST /api/cron/payouts/charge-succeeded // This route is used to process the charge-succeeded event from Stripe. // We're intentionally offloading this to a cron job so we can return a 200 to Stripe immediately. export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const { invoiceId } = payloadSchema.parse(JSON.parse(rawBody)); const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId, }, include: { _count: { select: { payouts: { where: { status: "processing", }, }, }, }, }, }); if (!invoice) { return logAndRespond(`Invoice ${invoiceId} not found.`); } if (invoice._count.payouts === 0) { return logAndRespond( `No payouts found with status 'processing' for invoice ${invoiceId}, skipping...`, ); } // Set the method for each payout in the invoice to the corresponding partner's default payout method await prisma.$executeRaw` UPDATE Payout p INNER JOIN Partner pr ON p.partnerId = pr.id SET p.method = pr.defaultPayoutMethod WHERE p.invoiceId = ${invoice.id} AND pr.defaultPayoutMethod IS NOT NULL AND p.status = 'processing' `; // Fund the total stablecoin payout amount for this invoice const { _sum } = await prisma.payout.aggregate({ _sum: { amount: true }, where: { invoiceId: invoice.id, method: "stablecoin", // only transfer funds for stablecoin payouts >= minimum withdrawal amount // for payouts below the minimum withdrawal amount, we will just mark them as processed // and users can force withdraw them manually later (which triggers another fundFinancialAccount call) amount: { gte: MIN_WITHDRAWAL_AMOUNT_CENTS, }, }, }); let skipStablecoinPayouts = false; const stablecoinFundingAmount = _sum.amount ?? 0; // Send money to Financial Account to handle stablecoin payouts if (stablecoinFundingAmount > 0) { const { nextAction } = await scheduleDelayedStablecoinPayouts(invoice); if (nextAction === "executeNow") { try { await fundFinancialAccount({ amount: stablecoinFundingAmount, idempotencyKey: invoiceId, }); } catch (error) { await log({ message: `Failed to fund Dub's financial account for stablecoin payouts: ${error.message}`, type: "errors", }); skipStablecoinPayouts = true; } } if (nextAction === "skip") { skipStablecoinPayouts = true; } } await Promise.allSettled([ // Queue Stripe payouts queueStripePayouts(invoice, skipStablecoinPayouts), // Send PayPal payouts sendPaypalPayouts(invoice), // Queue external payouts queueExternalPayouts(invoice), ]); return logAndRespond( `Completed processing all payouts for invoice ${invoiceId}.`, ); } catch (error) { await log({ message: `Error sending payouts for invoice: ${error.message}`, type: "cron", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/payouts/charge-succeeded/send-paypal-payouts.ts ================================================ import { queueBatchEmail } from "@/lib/email/queue-batch-email"; import { createPayPalBatchPayout } from "@/lib/paypal/create-batch-payout"; import PartnerPayoutProcessed from "@dub/email/templates/partner-payout-processed"; import { prisma } from "@dub/prisma"; import { Invoice } from "@dub/prisma/client"; import { currencyFormatter } from "@dub/utils"; export async function sendPaypalPayouts(invoice: Pick) { const payouts = await prisma.payout.findMany({ where: { invoiceId: invoice.id, status: "processing", mode: "internal", method: "paypal", partner: { payoutsEnabledAt: { not: null, }, paypalEmail: { not: null, }, }, }, include: { partner: { select: { email: true, paypalEmail: true, }, }, program: { select: { name: true, logo: true, }, }, }, }); if (payouts.length === 0) { console.log("No payouts for sending via PayPal, skipping..."); return; } const batchPayout = await createPayPalBatchPayout({ payouts, invoiceId: invoice.id, }); console.log("PayPal batch payout created", batchPayout); // update the payouts to "sent" status const updatedPayouts = await prisma.payout.updateMany({ where: { id: { in: payouts.map((p) => p.id) }, }, data: { status: "sent", paidAt: new Date(), }, }); console.log(`Updated ${updatedPayouts.count} payouts to "sent" status`); await queueBatchEmail( payouts.map((payout) => ({ variant: "notifications", to: payout.partner.email!, subject: `You've received a ${currencyFormatter(payout.amount)} payout from ${payout.program.name}`, templateName: "PartnerPayoutProcessed", templateProps: { email: payout.partner.email!, program: payout.program, payout, }, })), ); } ================================================ FILE: apps/web/app/(ee)/api/cron/payouts/charge-succeeded/utils.ts ================================================ import { qstash } from "@/lib/cron"; import { stripe } from "@/lib/stripe"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import * as z from "zod/v4"; const stripeChargeMetadataSchema = z.object({ id: z.string(), }); interface StablecoinScheduleResult { nextAction: "skip" | "executeNow"; } // For stablecoin payouts, schedule a cron job at `available_on + 15 minutes` // because Stablecoin financial accounts do not support `source_transaction`, // so the payout must be triggered after funds become available. export async function scheduleDelayedStablecoinPayouts(invoice: { id: string; stripeChargeMetadata: unknown; }): Promise { const stripeChargeMetadata = stripeChargeMetadataSchema.parse( invoice.stripeChargeMetadata, ); const balanceTransactions = await stripe.balanceTransactions.list({ source: stripeChargeMetadata.id, }); const now = Date.now(); let scheduleTimeMs = 0; // Balance transaction is not available if (balanceTransactions.data.length === 0) { console.log( `No balance transaction found for charge ${stripeChargeMetadata.id}`, ); scheduleTimeMs = now + 1 * 60 * 60 * 1000; } // Balance transaction is available else { const balanceTransaction = balanceTransactions.data[0]; console.log( `Found balance transaction for charge invoice ${invoice.id}: ${balanceTransaction.id}`, { available_on: balanceTransaction.available_on, }, ); const availableOnMs = balanceTransaction.available_on * 1000; // Funds already available, execute immediately if (availableOnMs <= now) { return { nextAction: "executeNow", }; } scheduleTimeMs = availableOnMs + 15 * 60 * 1000; } // Schedule the QStash job const delaySeconds = Math.max( 0, Math.floor((scheduleTimeMs - Date.now()) / 1000), ); const qstashResponse = await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/charge-succeeded`, delay: delaySeconds, flowControl: { key: invoice.id, rate: 1, }, body: { invoiceId: invoice.id, }, }); if (qstashResponse.messageId) { const scheduledAt = new Date(scheduleTimeMs); console.log( `Scheduled delayed stablecoin payout for invoice ${invoice.id} at ${scheduledAt.toISOString()}.`, { qstashResponse, }, ); } else { throw new Error( `Failed to schedule delayed stablecoin payout for invoice ${invoice.id}`, ); } return { nextAction: "skip", }; } ================================================ FILE: apps/web/app/(ee)/api/cron/payouts/force-withdrawals/route.ts ================================================ import { forceWithdrawal } from "@/lib/actions/partners/force-withdrawal"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS } from "@/lib/constants/payouts"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { verifyVercelSignature } from "@/lib/cron/verify-vercel"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, log } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const BATCH_SIZE = 20; const schema = z.object({ startingAfter: z.string().optional(), }); // This route is used to force withdrawals for partners that haven't withdrew their earnings for than 90 days // Runs once a day at 5AM PST (0 12 * * *) + calls itself recursively to process all partners in batches async function handler(req: Request) { try { let rawBody: string | undefined; if (req.method === "GET") { await verifyVercelSignature(req); } else if (req.method === "POST") { rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); } let startingAfter: string | undefined; try { startingAfter = schema.parse( rawBody ? JSON.parse(rawBody) : {}, ).startingAfter; } catch { startingAfter = undefined; } // Get batch of partners with processed payouts (cursor-based pagination) const partnersToProcess = await prisma.partner.findMany({ where: { payoutsEnabledAt: { not: null, }, defaultPayoutMethod: { in: ["stablecoin", "connect"], }, payouts: { some: { status: "processed", amount: { gte: MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS, }, paidAt: { lte: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000), // 90 days ago }, }, }, }, take: BATCH_SIZE, orderBy: { id: "asc", }, ...(startingAfter && { skip: 1, cursor: { id: startingAfter, }, }), select: { id: true, defaultPayoutMethod: true, }, }); if (!partnersToProcess.length) { return logAndRespond( "No partners to process. Skipping force withdrawals...", ); } const hasMoreToProcess = partnersToProcess.length === BATCH_SIZE; console.log( `Found ${partnersToProcess.length} partners to process${hasMoreToProcess ? " (more to process)" : ""}`, ); await Promise.allSettled( partnersToProcess.map((partner) => forceWithdrawal(partner)), ); if (hasMoreToProcess) { console.log( "More partners need force withdrawals, scheduling next batch...", ); const nextStartingAfter = partnersToProcess[partnersToProcess.length - 1].id; const qstashResponse = await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/force-withdrawals`, method: "POST", body: { startingAfter: nextStartingAfter, }, }); if (qstashResponse.messageId) { console.log( `Message sent to Qstash with id ${qstashResponse.messageId}`, ); } else { await log({ message: `Error sending message to Qstash to schedule next batch of force withdrawals: ${JSON.stringify(qstashResponse)}`, type: "errors", mention: true, }); } return logAndRespond( `Finished force withdrawals for current batch. Scheduling next batch (startingAfter: ${nextStartingAfter})...`, ); } return logAndRespond("Finished force withdrawals for all batches."); } catch (error) { await log({ message: `Error force withdrawing: ${error.message}`, type: "errors", mention: true, }); return handleAndReturnErrorResponse(error); } } // GET/POST /api/cron/payouts/force-withdrawals export { handler as GET, handler as POST }; ================================================ FILE: apps/web/app/(ee)/api/cron/payouts/payout-failed/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { getPartnerBankAccount } from "@/lib/partners/get-partner-bank-account"; import { sendEmail } from "@dub/email"; import PartnerPayoutWithdrawalFailed from "@dub/email/templates/partner-payout-withdrawal-failed"; import { prisma } from "@dub/prisma"; import { log, pluralize, prettyPrint } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; const payloadSchema = z.object({ stripeAccount: z.string(), stripePayout: z.object({ id: z.string(), amount: z.number(), currency: z.string(), failureMessage: z.string().nullable(), }), }); // POST /api/cron/payouts/payout-failed export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const { stripeAccount, stripePayout } = payloadSchema.parse( JSON.parse(rawBody), ); const partner = await prisma.partner.findUnique({ where: { stripeConnectId: stripeAccount, }, select: { email: true, }, }); if (!partner) { return logAndRespond( `Partner not found with Stripe connect account ${stripeAccount}. Skipping...`, ); } const updatedPayouts = await prisma.payout.updateMany({ where: { stripePayoutId: stripePayout.id, }, data: { status: "failed", failureReason: stripePayout.failureMessage, }, }); if (partner.email) { const bankAccount = await getPartnerBankAccount(stripeAccount); const sentEmail = await sendEmail({ variant: "notifications", subject: "[Action Required]: Your recent auto-withdrawal from Dub failed", to: partner.email, react: PartnerPayoutWithdrawalFailed({ email: partner.email, bankAccount, payout: { amount: stripePayout.amount, currency: stripePayout.currency, failureReason: stripePayout.failureMessage, }, }), }); console.log( `Sent email to partner ${partner.email} (${stripeAccount}): ${prettyPrint(sentEmail)}`, ); } return logAndRespond( `Updated ${updatedPayouts.count} ${pluralize("payout", updatedPayouts.count)} for partner ${partner.email} (${stripeAccount}) to "failed" status.`, ); } catch (error) { await log({ message: `Error handling "payout.failed" ${error.message}.`, type: "errors", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/payouts/payout-paid/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { sendEmail } from "@dub/email"; import PartnerPayoutWithdrawalCompleted from "@dub/email/templates/partner-payout-withdrawal-completed"; import { prisma } from "@dub/prisma"; import { currencyFormatter, log, pluralize, prettyPrint } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; const payloadSchema = z.object({ stripeAccount: z.string(), stripePayout: z.object({ id: z.string(), traceId: z.string().nullable(), amount: z.number(), currency: z.string(), arrivalDate: z.number(), }), }); // POST /api/cron/payouts/payout-paid export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const { stripeAccount, stripePayout } = payloadSchema.parse( JSON.parse(rawBody), ); const partner = await prisma.partner.findUnique({ where: { stripeConnectId: stripeAccount, }, select: { email: true, }, }); if (!partner) { return logAndRespond( `Partner not found with Stripe connect account ${stripeAccount}. Skipping...`, ); } const updatedPayouts = await prisma.payout.updateMany({ where: { stripePayoutId: stripePayout.id, }, data: { status: "completed", stripePayoutTraceId: stripePayout.traceId, }, }); if (partner.email) { const sentEmail = await sendEmail({ variant: "notifications", subject: `Your ${currencyFormatter(stripePayout.amount, { currency: stripePayout.currency })} auto-withdrawal from Dub has been transferred to your bank`, to: partner.email, react: PartnerPayoutWithdrawalCompleted({ email: partner.email, payout: { amount: stripePayout.amount, currency: stripePayout.currency, arrivalDate: stripePayout.arrivalDate, traceId: stripePayout.traceId, }, }), }); console.log( `Sent email to partner ${partner.email} (${stripeAccount}): ${prettyPrint(sentEmail)}`, ); } return logAndRespond( `Updated ${updatedPayouts.count} ${pluralize("payout", updatedPayouts.count)} for partner ${partner.email} (${stripeAccount}) to "completed" status.`, ); } catch (error) { await log({ message: `Error handling "payout.paid" ${error.message}.`, type: "errors", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/payouts/process/process-payouts.ts ================================================ import { getPayoutEligibilityFilter } from "@/lib/api/payouts/payout-eligibility-filter"; import { FAST_ACH_FEE_CENTS, FOREX_MARKUP_RATE } from "@/lib/constants/payouts"; import { qstash } from "@/lib/cron"; import { calculatePayoutFeeWithWaiver } from "@/lib/partners/calculate-payout-fee-with-waiver"; import { CUTOFF_PERIOD, CUTOFF_PERIOD_TYPES, } from "@/lib/partners/cutoff-period"; import { stripe } from "@/lib/stripe"; import { createFxQuote } from "@/lib/stripe/create-fx-quote"; import { calculatePayoutFeeForMethod } from "@/lib/stripe/payment-methods"; import { sendEmail } from "@dub/email"; import ProgramPayoutThankYou from "@dub/email/templates/program-payout-thank-you"; import { prisma } from "@dub/prisma"; import { Invoice, Program, ProgramPayoutMode, Project, } from "@dub/prisma/client"; import { APP_DOMAIN_WITH_NGROK, currencyFormatter, log, nFormatter, pluralize, } from "@dub/utils"; const nonUsdPaymentMethodTypes = { sepa_debit: "eur", acss_debit: "cad", } as const; interface ProcessPayoutsProps { workspace: Pick< Project, | "id" | "slug" | "stripeId" | "plan" | "invoicePrefix" | "payoutsUsage" | "payoutsLimit" | "payoutFee" | "payoutFeeWaiverLimit" | "payoutFeeWaiverUsage" | "webhookEnabled" >; program: Pick< Program, "id" | "name" | "logo" | "url" | "minPayoutAmount" | "supportEmail" > & { payoutMode: ProgramPayoutMode; }; invoice: Pick; userId: string; paymentMethodId: string; cutoffPeriod?: CUTOFF_PERIOD_TYPES; selectedPayoutId?: string; excludedPayoutIds?: string[]; } export async function processPayouts({ workspace, program, invoice, userId, paymentMethodId, cutoffPeriod, selectedPayoutId, excludedPayoutIds, }: ProcessPayoutsProps) { const cutoffPeriodValue = CUTOFF_PERIOD.find( (c) => c.id === cutoffPeriod, )?.value; const res = await prisma.payout.updateMany({ where: { ...(selectedPayoutId ? { id: selectedPayoutId } : excludedPayoutIds && excludedPayoutIds.length > 0 ? { id: { notIn: excludedPayoutIds } } : {}), ...getPayoutEligibilityFilter({ program, workspace }), ...(cutoffPeriodValue && { periodEnd: { lte: cutoffPeriodValue, }, }), }, data: { invoiceId: invoice.id, status: "processing", userId, initiatedAt: new Date(), // if the program is in external mode, set the mode to external // otherwise set it to internal (we'll update specific payouts to "external" later if it's hybrid mode) mode: program.payoutMode === "external" ? "external" : "internal", }, }); if (res.count === 0) { console.log( `No payouts updated/found for invoice ${invoice.id}. Skipping...`, ); return; } console.log( `Updated ${res.count} payouts to invoice ${invoice.id} and "processing" status`, ); // if hybrid mode, we need to update payouts for partners with payoutsEnabledAt = null to external mode // here we don't need to filter if they have tenantId cause getPayoutEligibilityFilter above already takes care of that if (program.payoutMode === "hybrid") { await prisma.payout.updateMany({ where: { invoiceId: invoice.id, partner: { payoutsEnabledAt: null, }, }, data: { mode: "external", }, }); } const payoutsByMode = await prisma.payout.groupBy({ by: ["mode"], where: { invoiceId: invoice.id, }, _sum: { amount: true, }, }); const totalInternalPayoutAmount = payoutsByMode.find((p) => p.mode === "internal")?._sum.amount ?? 0; const totalExternalPayoutAmount = payoutsByMode.find((p) => p.mode === "external")?._sum.amount ?? 0; const totalPayoutAmount = totalInternalPayoutAmount + totalExternalPayoutAmount; const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId); const payoutFee = calculatePayoutFeeForMethod({ paymentMethod: paymentMethod.type, payoutFee: workspace.payoutFee, }); if (!payoutFee) { throw new Error("Failed to calculate payout fee."); } console.info( `Using payout fee of ${payoutFee} for payment method ${paymentMethod.type}`, ); const { fee: invoiceFee, feeFreeAmount, feeChargedAmount, feeWaiverRemaining, } = calculatePayoutFeeWithWaiver({ payoutAmount: totalPayoutAmount, payoutFee, payoutFeeWaiverLimit: workspace.payoutFeeWaiverLimit, payoutFeeWaiverUsage: workspace.payoutFeeWaiverUsage, fastAchFee: invoice.paymentMethod === "ach_fast" ? FAST_ACH_FEE_CENTS : 0, }); const invoiceTotal = totalPayoutAmount + invoiceFee; console.log({ totalInternalPayoutAmount, totalExternalPayoutAmount, totalPayoutAmount, invoiceFee, invoiceTotal, feeFreeAmount, feeChargedAmount, feeWaiverRemaining, }); await prisma.invoice.update({ where: { id: invoice.id, }, data: { amount: totalPayoutAmount, externalAmount: totalExternalPayoutAmount, fee: invoiceFee, total: invoiceTotal, }, }); let totalToCharge = invoiceTotal - totalExternalPayoutAmount; const currency = nonUsdPaymentMethodTypes[paymentMethod.type] || "usd"; // convert the amount to EUR/CAD if the payment method is sepa_debit or acss_debit if (Object.keys(nonUsdPaymentMethodTypes).includes(paymentMethod.type)) { const fxQuote = await createFxQuote({ fromCurrency: currency, toCurrency: "usd", }); const exchangeRate = fxQuote.rates[currency].exchange_rate; // if Stripe's FX rate is not available, throw an error if (!exchangeRate || exchangeRate <= 0) { throw new Error( `Failed to get exchange rate from Stripe for ${currency}.`, ); } const convertedTotal = Math.round( (totalToCharge / exchangeRate) * (1 + FOREX_MARKUP_RATE), ); console.log( `Currency conversion: ${totalToCharge} usd -> ${convertedTotal} ${currency} using exchange rate ${exchangeRate}.`, ); totalToCharge = convertedTotal; } await stripe.paymentIntents.create( { amount: totalToCharge, customer: workspace.stripeId!, payment_method_types: [paymentMethod.type], payment_method: paymentMethod.id, ...(paymentMethod.type === "us_bank_account" && { payment_method_options: { us_bank_account: { preferred_settlement_speed: invoice.paymentMethod === "ach_fast" ? "fastest" : "standard", }, }, }), currency, confirmation_method: "automatic", confirm: true, transfer_group: invoice.id, ...(paymentMethod.type === "card" ? { statement_descriptor_suffix: "Dub Partners" } : { statement_descriptor: "Dub Partners" }), description: `Dub Partners payout invoice (${invoice.id})`, }, { idempotencyKey: `process-payout-invoice/${invoice.id}`, }, ); const { users } = await prisma.project.update({ where: { id: workspace.id, }, data: { payoutsUsage: { increment: totalPayoutAmount, }, payoutFeeWaiverUsage: { increment: feeFreeAmount, }, }, include: { users: { where: { userId, }, select: { user: { select: { email: true, }, }, }, }, }, }); await log({ message: `<${program.url}|*${program.name}*> (\`${workspace.slug}\`) just sent a payout of *${currencyFormatter(totalPayoutAmount)}* :money_with_wings: \n\n Fees earned: *${currencyFormatter(invoiceFee)} (${payoutFee * 100}%)* :money_mouth_face:`, type: "payouts", }); const qstashResponse = await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/process/updates`, body: { invoiceId: invoice.id, }, }); if (qstashResponse.messageId) { console.log(`Message sent to Qstash with id ${qstashResponse.messageId}`); } else { console.error("Error sending message to Qstash", qstashResponse); } // should never happen, but just in case if (users.length === 0) { console.error( `No users found for workspace ${workspace.id}. Skipping email send...`, ); return; } const userWhoInitiatedPayout = users[0].user; if (userWhoInitiatedPayout.email) { const emailRes = await sendEmail({ to: userWhoInitiatedPayout.email, subject: `Thank you for your ${currencyFormatter(totalPayoutAmount)} payout to ${nFormatter(res.count, { full: true })} ${pluralize("partner", res.count)}`, react: ProgramPayoutThankYou({ email: userWhoInitiatedPayout.email, workspace, program: { name: program.name, }, payout: { amount: totalPayoutAmount, partnersCount: res.count, }, }), }); console.log( `Sent email to user ${userWhoInitiatedPayout.email}: ${JSON.stringify(emailRes, null, 2)}`, ); } } ================================================ FILE: apps/web/app/(ee)/api/cron/payouts/process/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { CUTOFF_PERIOD_ENUM } from "@/lib/partners/cutoff-period"; import { prisma } from "@dub/prisma"; import { log } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; import { processPayouts } from "./process-payouts"; import { splitPayouts } from "./split-payouts"; export const dynamic = "force-dynamic"; export const maxDuration = 600; // This function can run for a maximum of 10 minutes const processPayoutsCronSchema = z.object({ workspaceId: z.string(), userId: z.string(), invoiceId: z.string(), paymentMethodId: z.string(), cutoffPeriod: CUTOFF_PERIOD_ENUM, selectedPayoutId: z.string().optional(), excludedPayoutIds: z.array(z.string()).optional(), }); // POST /api/cron/payouts/process // This route is used to process payouts for a given invoice // we're intentionally offloading this to a cron job to avoid blocking the main thread export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const { workspaceId, userId, invoiceId, paymentMethodId, cutoffPeriod, selectedPayoutId, excludedPayoutIds, } = processPayoutsCronSchema.parse(JSON.parse(rawBody)); const workspace = await prisma.project.findUniqueOrThrow({ where: { id: workspaceId, }, include: { programs: true, invoices: { where: { id: invoiceId, }, }, }, }); // should never happen, but just in case if (workspace.programs.length === 0) { return logAndRespond( `Workspace ${workspaceId} has no programs. Skipping...`, ); } const program = workspace.programs[0]; // should never happen, but just in case if (workspace.invoices.length === 0) { return logAndRespond( `Invoice ${invoiceId} not found for workspace ${workspaceId}. Skipping...`, ); } const invoice = workspace.invoices[0]; // avoid race condition where Stripe's charge.failed webhook is processed before this cron job if (invoice.status === "failed") { return logAndRespond( `Invoice ${invoiceId} has already been marked as failed. Skipping...`, ); } if (cutoffPeriod) { await splitPayouts({ program, workspace, cutoffPeriod, selectedPayoutId, excludedPayoutIds, }); } await processPayouts({ program, workspace, invoice, userId, paymentMethodId, cutoffPeriod, selectedPayoutId, excludedPayoutIds, }); return logAndRespond(`Processed payouts for program ${program.name}.`); } catch (error) { await log({ message: `Error confirming payouts for program: ${error.message}`, type: "errors", mention: true, }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/payouts/process/split-payouts.ts ================================================ import { createId } from "@/lib/api/create-id"; import { getPayoutEligibilityFilter } from "@/lib/api/payouts/payout-eligibility-filter"; import { CUTOFF_PERIOD, CUTOFF_PERIOD_TYPES, } from "@/lib/partners/cutoff-period"; import { prisma } from "@dub/prisma"; import { Program, Project } from "@dub/prisma/client"; import { endOfMonth } from "date-fns"; export async function splitPayouts({ program, workspace, cutoffPeriod, selectedPayoutId, excludedPayoutIds, }: { program: Pick; workspace: Pick; cutoffPeriod: CUTOFF_PERIOD_TYPES; selectedPayoutId?: string; excludedPayoutIds?: string[]; }) { const payouts = await prisma.payout.findMany({ where: { ...(selectedPayoutId ? { id: selectedPayoutId } : excludedPayoutIds && excludedPayoutIds.length > 0 ? { id: { notIn: excludedPayoutIds } } : {}), ...getPayoutEligibilityFilter({ program, workspace }), }, include: { commissions: true, }, }); if (payouts.length === 0) { return; } const cutoffPeriodValue = CUTOFF_PERIOD.find( (c) => c.id === cutoffPeriod, )!.value; for (const payout of payouts) { const previousCommissions = payout.commissions .filter((commission) => { return commission.createdAt < cutoffPeriodValue; }) .sort((a, b) => { return a.createdAt.getTime() - b.createdAt.getTime(); }); const currentCommissions = payout.commissions .filter((commission) => { return commission.createdAt >= cutoffPeriodValue; }) .sort((a, b) => { return a.createdAt.getTime() - b.createdAt.getTime(); }); const previousCommissionsCount = previousCommissions.length; const currentCommissionsCount = currentCommissions.length; // If there are previous commissions, we need to split the payout into two // 1 - one for everything up until the end of the previous month // 2 - everything else in the current month will be left as pending (and excluded from the payout) if (previousCommissionsCount > 0) { await prisma.payout.update({ where: { id: payout.id, }, data: { periodEnd: endOfMonth( previousCommissions[previousCommissionsCount - 1].createdAt, ), amount: previousCommissions.reduce( (total, commission) => total + commission.earnings, 0, ), }, }); if (currentCommissionsCount > 0) { const currentMonthPayout = await prisma.payout.create({ data: { id: createId({ prefix: "po_" }), programId: program.id, partnerId: payout.partnerId, periodStart: currentCommissions[0].createdAt, periodEnd: currentCommissions[currentCommissions.length - 1].createdAt, amount: currentCommissions.reduce( (total, commission) => total + commission.earnings, 0, ), description: `Dub Partners payout (${program.name})`, }, }); await prisma.commission.updateMany({ where: { id: { in: currentCommissions.map((commission) => commission.id), }, }, data: { payoutId: currentMonthPayout.id, }, }); } } } } ================================================ FILE: apps/web/app/(ee)/api/cron/payouts/process/updates/route.ts ================================================ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { sendBatchEmail } from "@dub/email"; import PartnerPayoutConfirmed from "@dub/email/templates/partner-payout-confirmed"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, currencyFormatter, log } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../../utils"; export const dynamic = "force-dynamic"; const payloadSchema = z.object({ invoiceId: z.string(), startingAfter: z.string().optional(), }); const BATCH_SIZE = 100; // POST /api/cron/payouts/process/updates // Recursive cron job to handle side effects of the `cron/payouts/process` job (recordAuditLog, sendBatchEmails) export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); const { invoiceId, startingAfter } = payloadSchema.parse( JSON.parse(rawBody), ); const payouts = await prisma.payout.findMany({ where: { invoiceId, }, include: { program: true, partner: true, invoice: true, }, take: BATCH_SIZE, skip: startingAfter ? 1 : 0, ...(startingAfter && { cursor: { id: startingAfter, }, }), orderBy: { id: "asc", }, }); if (payouts.length === 0) { return logAndRespond( `No more payouts to process for invoice ${invoiceId}. Skipping...`, ); } const auditLogResponse = await recordAuditLog( payouts.map(({ program, partner, invoice, ...payout }) => { return { workspaceId: program.workspaceId, programId: program.id, action: "payout.confirmed", description: `Payout ${payout.id} confirmed`, actor: { id: payout.userId ?? "system", }, targets: [ { type: "payout", id: payout.id, metadata: payout, }, ], }; }), ); console.log(JSON.stringify({ auditLogResponse }, null, 2)); const invoice = payouts[0].invoice; const internalPayouts = payouts.filter( (payout) => payout.mode === "internal", ); if ( invoice && invoice.paymentMethod !== "card" && internalPayouts.length > 0 ) { const batchEmailResponse = await sendBatchEmail( internalPayouts.map((payout) => ({ to: payout.partner.email!, subject: `Your ${currencyFormatter(payout.amount)} payout for ${payout.program.name} is on the way`, variant: "notifications", replyTo: payout.program.supportEmail || "noreply", react: PartnerPayoutConfirmed({ email: payout.partner.email!, program: { id: payout.program.id, name: payout.program.name, logo: payout.program.logo, }, payout: { id: payout.id, amount: payout.amount, initiatedAt: payout.initiatedAt, startDate: payout.periodStart, endDate: payout.periodEnd, mode: payout.mode, paymentMethod: invoice.paymentMethod ?? "ach", payoutMethod: payout.partner.defaultPayoutMethod ?? null, }, }), })), ); console.log(JSON.stringify({ batchEmailResponse }, null, 2)); } if (payouts.length === BATCH_SIZE) { const nextStartingAfter = payouts[payouts.length - 1].id; await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/process/updates`, method: "POST", body: { invoiceId, startingAfter: nextStartingAfter, }, }); return logAndRespond( `Enqueued next batch for invoice ${invoiceId} (startingAfter: ${nextStartingAfter}).`, ); } return logAndRespond( `Finished processing updates for ${payouts.length} payouts for invoice ${invoiceId}`, ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); await log({ message: `Error sending Stripe payout: ${errorMessage}`, type: "errors", mention: true, }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/payouts/reminders/partners/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { MIN_PAYOUT_AMOUNT_FOR_REMINDERS } from "@/lib/constants/misc"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { verifyVercelSignature } from "@/lib/cron/verify-vercel"; import { queueBatchEmail } from "@/lib/email/queue-batch-email"; import ConnectPayoutReminder from "@dub/email/templates/connect-payout-reminder"; import { prisma } from "@dub/prisma"; import { ACME_PROGRAM_ID, APP_DOMAIN_WITH_NGROK, log } from "@dub/utils"; import { logAndRespond } from "../../../utils"; export const dynamic = "force-dynamic"; const BATCH_SIZE = 1000; // This route is used to send reminders to partners who have pending payouts // but haven't configured payouts yet. // Runs once a day at 7AM PST but only notifies partners every 3 days // + calls itself recursively to process all partners in batches async function handler(req: Request) { try { if (req.method === "GET") { await verifyVercelSignature(req); } else if (req.method === "POST") { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); } // Get unsent payouts grouped by partner and program, ordered by amount desc const unsentPayouts = await prisma.payout.groupBy({ by: ["partnerId", "programId"], where: { status: { in: ["pending", "processing", "processed", "failed"], }, programId: { not: ACME_PROGRAM_ID, }, partner: { payoutsEnabledAt: null, OR: [ { connectPayoutsLastRemindedAt: null }, { connectPayoutsLastRemindedAt: { lte: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), // Last notified was at least 3 days ago }, }, ], }, amount: { gte: MIN_PAYOUT_AMOUNT_FOR_REMINDERS, }, }, _sum: { amount: true, }, orderBy: { _sum: { amount: "desc", }, }, take: BATCH_SIZE, }); if (!unsentPayouts.length) { return logAndRespond("No action needed."); } const hasMoreToProcess = unsentPayouts.length === BATCH_SIZE; console.log( `Found ${unsentPayouts.length} partner-program combinations needing reminders${hasMoreToProcess ? " (more to process)" : ""}`, ); const [partnerData, programData] = await Promise.all([ prisma.partner.findMany({ where: { id: { in: unsentPayouts.map((payout) => payout.partnerId), }, OR: [ { users: { none: {}, }, }, { users: { some: { notificationPreferences: { connectPayoutReminder: true, }, }, }, }, ], }, }), prisma.program.findMany({ where: { id: { in: unsentPayouts.map((payout) => payout.programId), }, }, }), ]); const partnerProgramMap = new Map< string, { partner: { id: string; name: string; email: string; }; programs: { id: string; name: string; logo: string; amount: number; }[]; } >(); for (const payout of unsentPayouts) { const { partnerId, programId } = payout; const { amount } = payout._sum; const partner = partnerData.find((p) => p.id === partnerId); const program = programData.find((p) => p.id === programId); if (!partner?.email || !program) { continue; } if (!partnerProgramMap.has(partnerId)) { partnerProgramMap.set(partnerId, { partner: { id: partner.id, name: partner.name, email: partner.email, }, programs: [], }); } partnerProgramMap.get(partnerId)!.programs.push({ id: program.id, name: program.name, logo: program.logo!, amount: amount ?? 0, }); } const partnerPrograms = Array.from(partnerProgramMap.values()); const connectPayoutsLastRemindedAt = new Date(); console.log( `Processing ConnectPayoutReminder for ${partnerPrograms.length} partners`, ); await queueBatchEmail( partnerPrograms.map(({ partner, programs }) => ({ variant: "notifications", to: partner.email, subject: "Connect your payout details on Dub Partners", templateName: "ConnectPayoutReminder", templateProps: { email: partner.email, programs, }, })), ); console.log( `Queued ConnectPayoutReminder emails for ${partnerPrograms.length} partners`, ); await prisma.partner.updateMany({ where: { id: { in: partnerPrograms.map(({ partner }) => partner.id), }, }, data: { connectPayoutsLastRemindedAt, }, }); if (hasMoreToProcess) { console.log("More partners need reminders, scheduling next batch..."); const qstashResponse = await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/reminders/partners`, body: {}, }); if (qstashResponse.messageId) { console.log( `Message sent to Qstash with id ${qstashResponse.messageId}`, ); } else { // should never happen, but just in case await log({ message: `Error sending message to Qstash to schedule next batch of payout reminders: ${JSON.stringify(qstashResponse)}`, type: "errors", mention: true, }); } return logAndRespond( "Finished sending payout reminders for current batch. Scheduling next batch...", ); } return logAndRespond("Finished sending payout reminders for all batches."); } catch (error) { await log({ message: `Error sending payout reminders: ${error.message}`, type: "errors", mention: true, }); return handleAndReturnErrorResponse(error); } } // GET/POST /api/cron/payouts/reminders/partners export { handler as GET, handler as POST }; ================================================ FILE: apps/web/app/(ee)/api/cron/payouts/reminders/program-owners/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { INVOICE_MIN_PAYOUT_AMOUNT_CENTS } from "@/lib/constants/payouts"; import { verifyVercelSignature } from "@/lib/cron/verify-vercel"; import { sendBatchEmail } from "@dub/email"; import ProgramPayoutReminder from "@dub/email/templates/program-payout-reminder"; import { prisma } from "@dub/prisma"; import { chunk, pluralize } from "@dub/utils"; import { NextResponse } from "next/server"; export const dynamic = "force-dynamic"; // GET /api/cron/payouts/reminders/program-owners // This route is used to send reminders to program owners about pending payouts // Runs every weekday at 1:00 PM UTC between 25th of current month and 5th of next month // Cron expression: 0 13 25-31,1-5 * * (runs daily at 1:00 PM UTC on days 25-31 and 1-5, filtered for weekdays in code) export async function GET(req: Request) { try { await verifyVercelSignature(req); // Only run on weekdays (Monday = 1, Friday = 5) const today = new Date(); const dayOfWeek = today.getUTCDay(); // 0 = Sunday, 1 = Monday, ..., 6 = Saturday if (dayOfWeek === 0 || dayOfWeek === 6) { return NextResponse.json( "Skipping execution on weekend. Only runs on weekdays.", ); } const programsWithCustomMinPayouts = await prisma.program.findMany({ where: { minPayoutAmount: { gt: 0, }, }, }); const pendingPayouts = await prisma.payout.groupBy({ by: ["programId"], where: { status: "pending", amount: { gt: 0, }, programId: { notIn: programsWithCustomMinPayouts.map((p) => p.id), }, partner: { payoutsEnabledAt: { not: null, }, }, }, _sum: { amount: true, }, _count: { _all: true, }, }); for (const program of programsWithCustomMinPayouts) { console.log( `Manually calculating pending payout for program ${program.id} which has a custom min payout amount of ${program.minPayoutAmount}`, ); const pendingPayout = await prisma.payout.aggregate({ where: { programId: program.id, status: "pending", amount: { gte: program.minPayoutAmount, }, partner: { payoutsEnabledAt: { not: null, }, }, }, _sum: { amount: true, }, _count: { _all: true, }, }); // if there are no pending payouts, skip this program if (!pendingPayout._sum?.amount) { continue; } pendingPayouts.push({ programId: program.id, _sum: pendingPayout._sum, _count: pendingPayout._count, }); } if (!pendingPayouts.length) { return NextResponse.json("No pending payouts found. Skipping..."); } const recentPaidInvoices = await prisma.invoice.findMany({ where: { programId: { in: pendingPayouts.map((p) => p.programId), }, // take invoices from the last 2 weeks createdAt: { gte: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000), }, }, }); // only send notifications for programs that: // - have a total payout amount greater than or equal to $10 (INVOICE_MIN_PAYOUT_AMOUNT_CENTS) // - have not paid out any invoices in the last 2 weeks const payoutsToNotify = pendingPayouts.filter((p) => { const invoiceTotal = p._sum?.amount ?? 0; const recentPaidInvoicesForProgram = recentPaidInvoices.filter( (i) => i.programId === p.programId, ); return ( invoiceTotal >= INVOICE_MIN_PAYOUT_AMOUNT_CENTS || recentPaidInvoicesForProgram.length === 0 ); }); const programs = await prisma.program.findMany({ where: { id: { in: payoutsToNotify.map((p) => p.programId), }, }, include: { workspace: { select: { id: true, slug: true, users: { where: { role: "owner", }, select: { user: { select: { email: true, }, }, }, }, }, }, }, }); if (!programs.length) { return NextResponse.json("No programs found. Skipping..."); } const programsWithPendingPayoutsToNotify = await Promise.all( programs.map(async (program) => { const payoutDetails = payoutsToNotify.find( (p) => p.programId === program.id, ); const workspace = program.workspace; return workspace.users.map(({ user }) => ({ workspace: { slug: workspace.slug, }, user: { email: user.email, }, program: { name: program.name, }, payout: { amount: payoutDetails?._sum?.amount ?? 0, partnersCount: payoutDetails?._count?._all ?? 0, }, })); }), ).then((p) => p.flat()); console.table(programsWithPendingPayoutsToNotify); const programOwnerChunks = chunk(programsWithPendingPayoutsToNotify, 100); for (const programOwnerChunk of programOwnerChunks) { const res = await sendBatchEmail( programOwnerChunk.map(({ workspace, user, program, payout }) => ({ variant: "notifications", to: user.email!, subject: `${payout.partnersCount} ${pluralize( "partner", payout.partnersCount, )} awaiting your payout for ${program.name}`, react: ProgramPayoutReminder({ email: user.email!, workspace, program, payout, }), })), ); console.log(`Sent ${programOwnerChunk.length} emails`, res); } return NextResponse.json("OK"); } catch (error) { return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/payouts/send-stripe-payout/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { createStablecoinPayout } from "@/lib/partners/create-stablecoin-payout"; import { createStripeTransfer } from "@/lib/partners/create-stripe-transfer"; import { prisma } from "@dub/prisma"; import { log } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const payloadSchema = z.object({ partnerId: z.string(), invoiceId: z.string().optional(), chargeId: z.string().optional(), }); // POST /api/cron/payouts/send-stripe-payout export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); const { partnerId, invoiceId, chargeId } = payloadSchema.parse( JSON.parse(rawBody), ); const payout = await prisma.payout.findFirst({ where: { partnerId, invoiceId, status: "processing", mode: "internal", method: { in: ["connect", "stablecoin"], }, }, select: { method: true, }, }); if (!payout) { return logAndRespond( `No payout found for partner ${partnerId} and invoice ${invoiceId}`, ); } // Run the appropriate payout creation function based on the payout method if (payout.method === "connect") { await createStripeTransfer({ partnerId, invoiceId, chargeId, }); } else if (payout.method === "stablecoin") { await createStablecoinPayout({ partnerId, invoiceId, }); } return logAndRespond( `Processed send-stripe-payout job for partner ${partnerId} and invoice ${invoiceId}`, ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); await log({ message: `Error sending Stripe payout: ${errorMessage}`, type: "errors", mention: true, }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/pending-applications-summary/route.ts ================================================ import { qstash } from "@/lib/cron"; import { withCron } from "@/lib/cron/with-cron"; import { sendBatchEmail } from "@dub/email"; import { ResendBulkEmailOptions } from "@dub/email/resend/types"; import PendingApplicationsSummary from "@dub/email/templates/pending-applications-summary"; import { prisma } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; import { APP_DOMAIN_WITH_NGROK, chunk, nFormatter, pluralize, } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../utils"; export const dynamic = "force-dynamic"; const PROGRAMS_BATCH_SIZE = 50; const schema = z.object({ startingAfter: z.string().optional(), }); // GET/POST /api/cron/pending-applications-summary // This route sends a daily summary of pending partner applications to program owners // Runs daily at 9:00 AM UTC export const GET = withCron(async ({ rawBody }) => { let { startingAfter } = schema.parse( rawBody ? JSON.parse(rawBody) : { startingAfter: undefined }, ); // Get batch of programs with pending applications const programs = await prisma.program.findMany({ where: { partners: { some: { status: "pending", }, }, }, include: { workspace: { select: { id: true, slug: true, }, }, }, take: PROGRAMS_BATCH_SIZE, ...(startingAfter && { skip: 1, cursor: { id: startingAfter, }, }), orderBy: { id: "asc", }, }); if (!programs.length) { return logAndRespond( "No more programs with pending applications found. Skipping...", ); } const programIds = programs.map((p) => p.id); // Get top 3 pending enrollments per program using SQL window function // This efficiently gets only the top 3 from each program directly from the database const topEnrollments = await prisma.$queryRaw< Array<{ programId: string; partnerId: string; partnerName: string | null; partnerEmail: string | null; partnerImage: string | null; }> >(Prisma.sql` SELECT pe.programId, p.id as partnerId, p.name as partnerName, p.email as partnerEmail, p.image as partnerImage FROM ( SELECT id, programId, partnerId, ROW_NUMBER() OVER (PARTITION BY programId ORDER BY createdAt DESC) as rn FROM ProgramEnrollment WHERE programId IN (${Prisma.join(programIds)}) AND status = 'pending' ) ranked INNER JOIN ProgramEnrollment pe ON pe.id = ranked.id INNER JOIN Partner p ON p.id = pe.partnerId WHERE ranked.rn <= 3 ORDER BY pe.programId, pe.createdAt DESC `); // Group enrollments by programId const enrollmentsByProgramMap = new Map< string, Array<{ id: string; name: string | null; email: string | null; image: string | null; }> >(); for (const enrollment of topEnrollments) { const existing = enrollmentsByProgramMap.get(enrollment.programId) || []; enrollmentsByProgramMap.set(enrollment.programId, [ ...existing, { id: enrollment.partnerId, name: enrollment.partnerName, email: enrollment.partnerEmail, image: enrollment.partnerImage, }, ]); } // Get counts of pending enrollments per program const pendingCounts = await prisma.programEnrollment.groupBy({ by: ["programId"], where: { programId: { in: programIds, }, status: "pending", }, _count: true, }); // Create a map of programId -> count const pendingCountMap = new Map( pendingCounts.map((pc) => [pc.programId, pc._count]), ); const workspaceUsers = await prisma.projectUsers.findMany({ where: { project: { defaultProgramId: { in: programIds, }, }, notificationPreference: { pendingApplicationsSummary: true, }, user: { email: { not: null, }, }, }, select: { project: { select: { slug: true, defaultProgramId: true, }, }, user: { select: { email: true, }, }, }, }); // create a map of programId -> workspace users const programWorkspaceUsersMap = new Map< string, { users: { email: string; }[]; workspace: { slug: string; }; } >(); for (const workspaceUser of workspaceUsers) { const programId = workspaceUser.project.defaultProgramId!; // coerce since we filtered above const workspaceUserEmail = workspaceUser.user.email!; // coerce since we filtered above const existingData = programWorkspaceUsersMap.get(programId); if (existingData) { existingData.users.push({ email: workspaceUserEmail, }); } else { programWorkspaceUsersMap.set(programId, { users: [ { email: workspaceUserEmail, }, ], workspace: { slug: workspaceUser.project.slug, }, }); } } // Process each program const emailsToSend: ResendBulkEmailOptions = []; for (const program of programs) { const totalPendingApplications = pendingCountMap.get(program.id) || 0; if (totalPendingApplications === 0) { continue; } const pendingEnrollments = enrollmentsByProgramMap.get(program.id) || []; const { users, workspace } = programWorkspaceUsersMap.get(program.id) || {}; if (!users || !workspace) { continue; } // Create email for each owner for (const user of users) { emailsToSend.push({ variant: "notifications", to: user.email, subject: `You have ${nFormatter(totalPendingApplications, { full: true })} partner ${pluralize("application", totalPendingApplications)} pending review`, react: PendingApplicationsSummary({ email: user.email, partners: pendingEnrollments, totalCount: totalPendingApplications, date: new Date(), workspace: { slug: workspace.slug, }, }), }); } } if (!emailsToSend.length) { return logAndRespond( "No emails to send. All programs either have no pending applications or no owners with notification preference enabled.", ); } // Send email in batches const emailChunks = chunk(emailsToSend, 100); for (const emailChunk of emailChunks) { await sendBatchEmail(emailChunk); } // Schedule the next batch if there are more programs to process if (programs.length === PROGRAMS_BATCH_SIZE) { startingAfter = programs[programs.length - 1].id; const response = await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/pending-applications-summary`, method: "POST", body: { startingAfter, }, }); return logAndRespond( `Sent ${emailsToSend.length} emails and scheduled next batch (startingAfter: ${startingAfter}, messageId: ${response.messageId}).`, ); } return logAndRespond( `Successfully sent ${emailsToSend.length} pending applications summary email(s).`, ); }); export const POST = GET; ================================================ FILE: apps/web/app/(ee)/api/cron/program-application-reminder/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { sendEmail } from "@dub/email"; import ProgramApplicationReminder from "@dub/email/templates/program-application-reminder"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils/src/constants"; // POST - /api/cron/program-application-reminder // Sends an email if a program application hasn't received an associated partner export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const { applicationId } = JSON.parse(rawBody); const application = await prisma.programApplication.findFirst({ where: { id: applicationId, // Only send reminders for applications that were created less than 3 days ago createdAt: { gt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), }, }, include: { enrollment: true, program: { select: { id: true, name: true, slug: true, supportEmail: true, }, }, }, }); if (!application) { return new Response( `Application ${applicationId} not found. Skipping...`, ); } if (application.enrollment) { return new Response( `Partner with applicationId ${application.id} has already been enrolled in program ${application.program.name}. Skipping...`, ); } const programEnrollment = await prisma.programEnrollment.findFirst({ where: { programId: application.program.id, partner: { email: application.email, }, }, }); if (programEnrollment) { await prisma.programEnrollment.update({ where: { id: programEnrollment.id, }, data: { applicationId: application.id, }, }); return new Response( `Partner with email ${application.email} has already been enrolled in program ${application.program.name}. Updated applicationId to ${application.id} and skipping...`, ); } await sendEmail({ subject: `Complete your application for ${application.program.name}`, to: application.email, replyTo: application.program.supportEmail || "noreply", react: ProgramApplicationReminder({ email: application.email, program: { name: application.program.name, slug: application.program.slug, }, }), variant: "notifications", }); await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/program-application-reminder`, // repeat every 24 hours, but it'll be canceled if the application is more than 3 days old or is associated with a partner delay: 24 * 60 * 60, body: { applicationId: application.id, }, }); return new Response( `Email sent to ${application.email} for application ${applicationId}.`, ); } catch (error) { return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/programs/deactivate/route.ts ================================================ import { bulkDeactivatePartners } from "@/lib/api/partners/bulk-deactivate-partners"; import { CRON_BATCH_SIZE, qstash } from "@/lib/cron"; import { withCron } from "@/lib/cron/with-cron"; import { ACTIVE_ENROLLMENT_STATUSES } from "@/lib/zod/schemas/partners"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; const inputSchema = z.object({ programId: z.string(), }); // POST /api/cron/programs/deactivate - deactivate all partners in a program export const POST = withCron(async ({ rawBody }) => { const { programId } = inputSchema.parse(JSON.parse(rawBody)); console.info(`[deactivateProgram] Processing program ${programId}...`); const program = await prisma.program.findUnique({ where: { id: programId, }, select: { id: true, workspaceId: true, name: true, deactivatedAt: true, }, }); if (!program) { return logAndRespond(`Program ${programId} not found.`); } if (!program.deactivatedAt) { return logAndRespond( `Program ${programId} is not deactivated. Skipping...`, ); } const programEnrollments = await prisma.programEnrollment.findMany({ where: { programId, status: { in: ACTIVE_ENROLLMENT_STATUSES, }, }, select: { id: true, partnerId: true, }, take: CRON_BATCH_SIZE, }); if (programEnrollments.length === 0) { return logAndRespond( `[deactivateProgram] No more partners to deactivate for program ${programId}. Exiting...`, ); } const partnerIds = programEnrollments.map(({ partnerId }) => partnerId); await bulkDeactivatePartners({ workspaceId: program.workspaceId, programId, partnerIds, programDeactivated: true, }); // Self-queue the next batch if there are more partners to process if (programEnrollments.length === CRON_BATCH_SIZE) { const response = await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/programs/deactivate`, body: { programId, }, }); return logAndRespond( `[deactivateProgram] Processed ${partnerIds.length} partners. Queued next batch ${response.messageId}.`, ); } return logAndRespond( `[deactivateProgram] Finished deactivating all partners for program ${programId}.`, ); }); ================================================ FILE: apps/web/app/(ee)/api/cron/send-batch-email/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { EMAIL_TEMPLATES_MAP } from "@/lib/email/email-templates-map"; import { sendBatchEmail } from "@dub/email"; import { ResendEmailOptions } from "@dub/email/resend/types"; import { log } from "@dub/utils"; import { NextResponse } from "next/server"; import React from "react"; import * as z from "zod/v4"; export const dynamic = "force-dynamic"; const batchEmailPayloadSchema = z.array( z.object({ templateName: z.enum( Object.keys(EMAIL_TEMPLATES_MAP) as [string, ...string[]], ), templateProps: z.record(z.string(), z.any()), to: z.string(), from: z.string().optional(), subject: z.string(), bcc: z.union([z.string(), z.array(z.string())]).optional(), replyTo: z.string().optional(), variant: z.enum(["primary", "notifications", "marketing"]).optional(), headers: z.record(z.string(), z.string()).optional(), tags: z.array(z.object({ name: z.string(), value: z.string() })).optional(), scheduledAt: z.string().optional(), }), ); interface BatchError { email: string; templateName: string; error: string; } // POST /api/cron/send-batch-email export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); const payload = batchEmailPayloadSchema.parse(JSON.parse(rawBody)); const idempotencyKey = req.headers.get("Idempotency-Key") || undefined; console.log(`Processing batch of ${payload.length} email(s)`); // Process all emails in parallel and build Resend payload const results = await Promise.allSettled( payload.map(async (emailItem) => { const TemplateComponent = EMAIL_TEMPLATES_MAP[emailItem.templateName]; if (!TemplateComponent) { throw new Error( `Template "${emailItem.templateName}" not found in TEMPLATE_MAP`, ); } const react = React.createElement( TemplateComponent, emailItem.templateProps, ); return { emailItem, emailPayload: { react, from: emailItem.from, to: emailItem.to, subject: emailItem.subject, variant: emailItem.variant, ...(emailItem.bcc && { bcc: emailItem.bcc }), ...(emailItem.replyTo && { replyTo: emailItem.replyTo }), ...(emailItem.headers && { headers: emailItem.headers }), ...(emailItem.tags && { tags: emailItem.tags }), ...(emailItem.scheduledAt && { scheduledAt: emailItem.scheduledAt, }), }, }; }), ); // Separate successes and failures const emailsToSend: ResendEmailOptions[] = []; const errors: BatchError[] = []; for (let i = 0; i < results.length; i++) { const result = results[i]; const emailItem = payload[i]; if (result.status === "fulfilled") { emailsToSend.push(result.value.emailPayload); } else { const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason); console.error( `Failed to process email template ${emailItem.templateName} for ${emailItem.to}:`, errorMessage, ); errors.push({ email: emailItem.to, templateName: emailItem.templateName, error: errorMessage, }); await log({ message: `Failed to import/render email template "${emailItem.templateName}" for ${emailItem.to}: ${errorMessage}`, type: "errors", }); } } if (emailsToSend.length === 0) { console.error("No emails were successfully processed."); await log({ message: `Batch email processing failed: All ${payload.length} email(s) failed to process`, type: "errors", mention: true, }); return NextResponse.json( { success: false, processed: 0, failed: payload.length, errors, }, { status: 500 }, ); } console.log(`Sending ${emailsToSend.length} email(s) via Resend.`); const { data, error } = await sendBatchEmail(emailsToSend, { idempotencyKey, }); if (error) { console.error("Resend API error:", error); await log({ message: `Resend batch send failed: ${JSON.stringify(error)}`, type: "errors", mention: true, }); return NextResponse.json( { success: false, processed: 0, failed: emailsToSend.length, errors: [ ...errors, { error: "Resend API error", details: error, }, ], }, { status: 500 }, ); } if (data) { console.log(`Successfully sent ${emailsToSend.length} email(s).`, data); } return NextResponse.json({ success: true, sent: emailsToSend.length, failed: errors.length, ...(errors.length > 0 && { processingErrors: errors }), }); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); await log({ message: `Error processing batch email queue: ${errorMessage}`, type: "errors", mention: true, }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/shopify/order-paid/route.ts ================================================ import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { processOrder } from "@/lib/integrations/shopify/process-order"; import { redis } from "@/lib/upstash"; import * as z from "zod/v4"; export const dynamic = "force-dynamic"; const schema = z.object({ workspaceId: z.string(), checkoutToken: z.string(), }); // POST /api/cron/shopify/order-paid export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const { workspaceId, checkoutToken } = schema.parse(JSON.parse(rawBody)); // Find Shopify order const event = await redis.hget( `shopify:checkout:${checkoutToken}`, "order", ); if (!event) { return new Response( `[Shopify] Order with checkout token ${checkoutToken} not found. Skipping...`, ); } const clickId = await redis.hget( `shopify:checkout:${checkoutToken}`, "clickId", ); // clickId is empty, order is not from a Dub link if (clickId === "") { // set key to expire in 24 hours await redis.expire(`shopify:checkout:${checkoutToken}`, 60 * 60 * 24); return new Response( `[Shopify] Order is not from a Dub link. Skipping...`, ); } // clickId is found, process the order for the new customer else if (clickId) { await processOrder({ event, workspaceId, clickId, }); return new Response("[Shopify] Order event processed successfully."); } // Wait for the click event to come from Shopify pixel else { throw new DubApiError({ code: "bad_request", message: "[Shopify] Click event not found. Waiting for Shopify pixel event...", }); } } catch (error) { return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/streams/update-partner-stats/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyVercelSignature } from "@/lib/cron/verify-vercel"; import { conn } from "@/lib/planetscale"; import { PartnerActivityEvent, partnerActivityStream, } from "@/lib/upstash/redis-streams"; import { prisma } from "@dub/prisma"; import { ProgramEnrollment } from "@dub/prisma/client"; import { toCentsNumber } from "@dub/utils"; import { differenceInDays, format } from "date-fns"; import { NextResponse } from "next/server"; export const dynamic = "force-dynamic"; const BATCH_SIZE = 6000; type ProgramEnrollmentStats = Partial< Pick< ProgramEnrollment, | "totalClicks" | "totalLeads" | "totalConversions" | "totalSales" | "totalSaleAmount" | "totalCommissions" | "netRevenue" | "earningsPerClick" | "averageLifetimeValue" | "clickToLeadRate" | "clickToConversionRate" | "leadToConversionRate" | "returnOnAdSpend" | "lastConversionAt" | "daysSinceLastConversion" | "consistencyScore" > >; const processPartnerActivityStreamBatch = () => partnerActivityStream.processBatch( async (entries) => { if (!entries || Object.keys(entries).length === 0) { return { success: true, updates: [], processedEntryIds: [], }; } console.log(`Aggregating ${entries.length} partner activity events`); // Collect all unique program:partner combinations from all events const uniqueProgramPartners = new Set(); entries.forEach((entry) => { const { programId, partnerId } = entry.data; uniqueProgramPartners.add(`${programId}:${partnerId}`); }); const programPartnerPairs = Array.from(uniqueProgramPartners); if (programPartnerPairs.length === 0) { return { success: true, updates: [], processedEntryIds: entries.map((e) => e.id), }; } const programIds = [ ...new Set(programPartnerPairs.map((p) => p.split(":")[0])), ]; const partnerIds = [ ...new Set(programPartnerPairs.map((p) => p.split(":")[1])), ]; // Query both link and commission stats in parallel for all program:partner pairs const [partnerLinkStats, partnerCommissionStats] = await Promise.all([ prisma.link.groupBy({ by: ["programId", "partnerId"], where: { programId: { in: programIds }, partnerId: { in: partnerIds }, }, _sum: { clicks: true, leads: true, conversions: true, sales: true, saleAmount: true, }, _max: { lastConversionAt: true, }, }), prisma.commission.groupBy({ by: ["programId", "partnerId"], where: { earnings: { not: 0 }, programId: { in: programIds }, partnerId: { in: partnerIds }, status: { in: ["pending", "processed", "paid"] }, }, _sum: { earnings: true, }, }), ]); // Merge link and commission stats into a single object const programEnrollmentsToUpdate: Record = {}; // Initialize all program:partner pairs programPartnerPairs.forEach((pair) => { programEnrollmentsToUpdate[pair] = {}; }); // Add link stats partnerLinkStats.forEach((p) => { const key = `${p.programId}:${p.partnerId}`; programEnrollmentsToUpdate[key] = { ...programEnrollmentsToUpdate[key], totalClicks: p._sum.clicks ?? undefined, totalLeads: p._sum.leads ?? undefined, totalConversions: p._sum.conversions ?? undefined, totalSales: p._sum.sales ?? undefined, totalSaleAmount: p._sum.saleAmount ?? undefined, lastConversionAt: p._max.lastConversionAt ?? undefined, }; }); // Add commission stats partnerCommissionStats.forEach((c) => { const key = `${c.programId}:${c.partnerId}`; programEnrollmentsToUpdate[key] = { ...programEnrollmentsToUpdate[key], totalCommissions: BigInt(c._sum.earnings ?? 0), }; }); // Calculate derived metrics for each enrollment Object.keys(programEnrollmentsToUpdate).forEach((key) => { const enrollment = programEnrollmentsToUpdate[key]; const { totalClicks, totalLeads, totalConversions, totalSaleAmount, lastConversionAt, totalCommissions, } = enrollment; const totalSaleAmountNum = totalSaleAmount != null ? toCentsNumber(totalSaleAmount) : undefined; const totalCommissionsNum = totalCommissions != null ? toCentsNumber(totalCommissions) : undefined; // Calculate netRevenue if ( totalSaleAmountNum !== undefined && totalCommissionsNum !== undefined ) { enrollment.netRevenue = BigInt( Math.round(totalSaleAmountNum - totalCommissionsNum), ); } // Calculate earningsPerClick if (totalSaleAmountNum !== undefined && totalClicks) { enrollment.earningsPerClick = totalSaleAmountNum / totalClicks; } // Calculate average lifetime value (totalSaleAmount / totalConversions) if (totalConversions && totalSaleAmountNum !== undefined) { enrollment.averageLifetimeValue = totalSaleAmountNum / totalConversions; } // Calculate click to lead rate (totalLeads / totalClicks) if (totalLeads && totalClicks) { enrollment.clickToLeadRate = totalLeads / totalClicks; } // Calculate click to conversion rate (totalConversions / totalClicks) if (totalConversions && totalClicks) { enrollment.clickToConversionRate = totalConversions / totalClicks; } // Calculate lead to conversion rate (totalConversions / totalLeads) if (totalConversions && totalLeads) { enrollment.leadToConversionRate = totalConversions / totalLeads; } // Calculate return on ad spend (totalSaleAmount / totalCommissions) if (totalSaleAmountNum !== undefined && totalCommissionsNum) { enrollment.returnOnAdSpend = totalSaleAmountNum / totalCommissionsNum; } // Calculate days since last conversion if (lastConversionAt) { enrollment.daysSinceLastConversion = differenceInDays( new Date(), new Date(lastConversionAt), ); } // Calculate consistency score based on days since last conversion let consistencyScore = 50; if ( lastConversionAt && enrollment.daysSinceLastConversion !== null && enrollment.daysSinceLastConversion !== undefined ) { if (enrollment.daysSinceLastConversion <= 7) { consistencyScore = 100; } else if (enrollment.daysSinceLastConversion <= 30) { consistencyScore = 85; } else if (enrollment.daysSinceLastConversion <= 90) { consistencyScore = 70; } else if (enrollment.daysSinceLastConversion <= 180) { consistencyScore = 55; } else { consistencyScore = 40; } } enrollment.consistencyScore = consistencyScore; }); const programEnrollmentsToUpdateArray = Object.entries( programEnrollmentsToUpdate, ).map(([key, value]) => ({ programId: key.split(":")[0], partnerId: key.split(":")[1], ...value, })); console.table(programEnrollmentsToUpdateArray); if (programEnrollmentsToUpdateArray.length === 0) { console.log("No program enrollments to update"); return { success: true, updates: [], processedEntryIds: [] }; } console.log( `Processing ${programEnrollmentsToUpdateArray.length} program enrollments updates...`, ); // Process updates in parallel batches to avoid overwhelming the database const SUB_BATCH_SIZE = 50; const batches: (typeof programEnrollmentsToUpdateArray)[] = []; for ( let i = 0; i < programEnrollmentsToUpdateArray.length; i += SUB_BATCH_SIZE ) { batches.push( programEnrollmentsToUpdateArray.slice(i, i + SUB_BATCH_SIZE), ); } let totalProcessed = 0; const errors: { programId: string; partnerId: string; error: any }[] = []; const processedEntryIds: string[] = []; // Collect all entry IDs for tracking entries.forEach((entry) => { processedEntryIds.push(entry.id); }); for (const batch of batches) { await Promise.allSettled( batch.map(async (programEnrollment) => { const { programId, partnerId, ...stats } = programEnrollment; const finalStatsToUpdate = Object.entries(stats).filter( ([_, value]) => value !== undefined && (typeof value !== "number" || Number.isFinite(value)), ); try { // Update program enrollment stats if (finalStatsToUpdate.length > 0) { await conn.execute( `UPDATE ProgramEnrollment SET ${finalStatsToUpdate .map(([key, _]) => `${key} = ?`) .join(", ")} WHERE programId = ? AND partnerId = ?`, [ ...finalStatsToUpdate.map(([_, value]) => value instanceof Date ? format(value, "yyyy-MM-dd HH:mm:ss") : value, ), programId, partnerId, ], ); } totalProcessed++; } catch (error) { console.error( `Failed to update program enrollment ${programId}:${partnerId}:`, error, ); errors.push({ programId, partnerId, error, }); } }), ); } // Log results const successRate = (totalProcessed / programEnrollmentsToUpdateArray.length) * 100; console.log( `Processed ${totalProcessed}/${programEnrollmentsToUpdateArray.length} program enrollments updates (${successRate.toFixed(1)}% success rate)`, ); if (errors.length > 0) { console.error( `Encountered ${errors.length} errors while processing:`, errors.slice(0, 5), ); // Log first 5 errors } return { updates: programEnrollmentsToUpdateArray, errors, totalProcessed, processedEntryIds, }; }, { count: BATCH_SIZE, deleteAfterRead: true, }, ); // This route is used to process partner activity events from Redis streams // It runs every 5 minutes with a batch size of 6,000 to consume high-frequency partner activity updates // GET /api/cron/streams/update-partner-stats export async function GET(req: Request) { try { await verifyVercelSignature(req); console.log("Processing partner activity events from Redis stream..."); const { updates, errors, totalProcessed } = await processPartnerActivityStreamBatch(); if (!updates.length) { return NextResponse.json({ success: true, message: "No updates to process", processed: 0, }); } // Get stream info for monitoring const streamInfo = await partnerActivityStream.getStreamInfo(); const response = { success: true, processed: totalProcessed, errors: errors?.length || 0, streamInfo, message: `Successfully processed ${totalProcessed} partner activity updates`, }; console.log(response); return NextResponse.json(response); } catch (error) { console.error("Failed to process partner activity updates:", error); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/streams/update-workspace-clicks/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyVercelSignature } from "@/lib/cron/verify-vercel"; import { conn } from "@/lib/planetscale"; import { ClickEvent, RedisStreamEntry, workspaceUsageStream, } from "@/lib/upstash/redis-streams"; import { NextResponse } from "next/server"; export const dynamic = "force-dynamic"; const BATCH_SIZE = 10000; type WorkspaceAggregateUsage = { workspaceId: string; clicks: number; firstTimestamp: number; lastTimestamp: number; entryIds: string[]; }; const aggregateWorkspaceUsage = ( entries: RedisStreamEntry[], ): { updates: WorkspaceAggregateUsage[]; lastProcessedId: string | null } => { // Aggregate usage by workspaceId const aggregatedUsage = new Map(); let lastId: string | null = null; console.log(`Aggregating ${entries.length} workspace usage events`); // The entries are a batch of workspace usage events, each with workspaceId, timestamp, and linkId. // We want to aggregate by workspaceId, counting total events (clicks) and tracking first/last timestamps. for (const entry of entries) { const workspaceId = entry.data.workspaceId; if (!workspaceId) { continue; } const timestamp = Date.parse(entry.data.timestamp) / 1000; lastId = entry.id; if (aggregatedUsage.has(workspaceId)) { const existing = aggregatedUsage.get(workspaceId)!; existing.clicks += 1; existing.lastTimestamp = Math.max(existing.lastTimestamp, timestamp); existing.firstTimestamp = Math.min(existing.firstTimestamp, timestamp); existing.entryIds.push(entry.id); } else { aggregatedUsage.set(workspaceId, { workspaceId, clicks: 1, firstTimestamp: timestamp, lastTimestamp: timestamp, entryIds: [entry.id], }); } } return { updates: Array.from(aggregatedUsage.values()), lastProcessedId: lastId, }; }; const processWorkspaceUpdateStreamBatch = () => workspaceUsageStream.processBatch( async (entries) => { if (!entries || Object.keys(entries).length === 0) { return { success: true, updates: [], processedEntryIds: [], }; } const { updates, lastProcessedId } = aggregateWorkspaceUsage(entries); if (updates.length === 0) { console.log("No workspace usage updates to process"); return { success: true, updates: [], processedEntryIds: [] }; } console.log( `Processing ${updates.length} aggregated workspace usage updates...`, ); // Process updates in parallel batches to avoid overwhelming the database const SUB_BATCH_SIZE = 50; const batches: (typeof updates)[] = []; for (let i = 0; i < updates.length; i += SUB_BATCH_SIZE) { batches.push(updates.slice(i, i + SUB_BATCH_SIZE)); } let totalProcessed = 0; const errors: { workspaceId: string; error: any }[] = []; const processedEntryIds: string[] = []; for (const batch of batches) { try { // Execute all updates in the batch in parallel const batchPromises = batch.map(async (update) => { try { // Update the workspace usage and click counts await conn.execute( "UPDATE Project p SET p.usage = p.usage + ?, p.totalClicks = p.totalClicks + ? WHERE id = ?", [update.clicks, update.clicks, update.workspaceId], ); processedEntryIds.push(...update.entryIds); return { ...update, success: true, }; } catch (error) { console.error( `Failed to update workspace ${update.workspaceId}:`, error, ); return { success: false, error: { workspaceId: update.workspaceId, error }, }; } }); const batchResults = await Promise.allSettled(batchPromises); // Count successful updates and collect errors batchResults.forEach((result) => { if (result.status === "fulfilled" && result.value.success) { totalProcessed++; } else if ( result.status === "fulfilled" && !result.value.success && result.value.error ) { errors.push(result.value.error); } }); } catch (error) { console.error("Failed to process batch:", error); errors.push(error); } } // Log results const successRate = (totalProcessed / updates.length) * 100; console.log( `Processed ${totalProcessed}/${updates.length} workspace usage updates (${successRate.toFixed(1)}% success rate)`, ); if (errors.length > 0) { console.error( `Encountered ${errors.length} errors while processing:`, errors.slice(0, 5), ); // Log first 5 errors } return { updates, errors, totalProcessed, lastProcessedId, processedEntryIds, }; }, { count: BATCH_SIZE, deleteAfterRead: true, }, ); // This route is used to process aggregated workspace usage events from Redis streams // It runs every minute with a batch size of 10,000 to consume high-frequency usage updates export async function GET(req: Request) { try { await verifyVercelSignature(req); console.log("Processing workspace usage updates from Redis stream..."); const { updates, errors, totalProcessed, lastProcessedId } = await processWorkspaceUpdateStreamBatch(); if (!updates.length) { return NextResponse.json({ success: true, message: "No updates to process", processed: 0, }); } // Get stream info for monitoring const streamInfo = await workspaceUsageStream.getStreamInfo(); const response = { success: true, processed: totalProcessed, errors: errors?.length || 0, lastProcessedId, streamInfo, message: `Successfully processed ${totalProcessed} workspace usage updates`, }; console.log(response); return NextResponse.json(response); } catch (error) { console.error("Failed to process workspace usage updates:", error); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/trigger-withdrawal/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyVercelSignature } from "@/lib/cron/verify-vercel"; import { stripe } from "@/lib/stripe"; import { prisma } from "@dub/prisma"; import { currencyFormatter } from "@dub/utils"; import { logAndRespond } from "../utils"; export const dynamic = "force-dynamic"; // This route is used to trigger withdrawal from Stripe (since we're using manual payouts) // Runs twice a day at midnight and noon UTC (0 0 * * * and 0 12 * * *) export async function GET(req: Request) { try { await verifyVercelSignature(req); const [stripeBalanceData, payoutsToBeSentData] = await Promise.all([ stripe.balance.retrieve(), prisma.payout.aggregate({ where: { status: { in: ["processing", "processed"], }, }, _sum: { amount: true, }, }), ]); // available to withdraw (USD) const currentAvailableBalance = stripeBalanceData.available.find((b) => b.currency === "usd")?.amount ?? 0; // balance waiting to settle (USD) const currentPendingBalance = stripeBalanceData.pending.find((b) => b.currency === "usd")?.amount ?? 0; // x-slack-ref: https://dub.slack.com/archives/C074P7LMV9C/p1750185638973479 const currentNetBalance = currentPendingBalance < 0 ? currentAvailableBalance + currentPendingBalance : currentAvailableBalance; const payoutsToBeSent = payoutsToBeSentData._sum.amount ?? 0; const reservedBalance = 30_000_00; // keep at least $30,000 in the account const balanceToWithdraw = currentNetBalance - payoutsToBeSent - reservedBalance; console.log({ currentAvailableBalance: `${currencyFormatter(currentAvailableBalance)}`, currentPendingBalance: `${currencyFormatter(currentPendingBalance)}`, currentNetBalance: `${currencyFormatter(currentNetBalance)}`, payoutsToBeSent: `${currencyFormatter(payoutsToBeSent)}`, balanceToWithdraw: `${currencyFormatter(balanceToWithdraw)}`, }); if (balanceToWithdraw <= 0) { return logAndRespond( `Balance to withdraw (after deducting payouts to be sent and reserved balance) is less than $0, skipping...`, ); } const createdPayout = await stripe.payouts.create({ amount: balanceToWithdraw, currency: "usd", }); return logAndRespond( `Created payout: ${createdPayout.id} (${currencyFormatter(createdPayout.amount)})`, ); } catch (error) { return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/usage/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { verifyVercelSignature } from "@/lib/cron/verify-vercel"; import { log } from "@dub/utils"; import { NextResponse } from "next/server"; import { updateUsage } from "./utils"; /* This route is used to update the usage stats of each workspace. Runs once every day at noon UTC (0 12 * * *) */ export const dynamic = "force-dynamic"; async function handler(req: Request) { try { if (req.method === "GET") { await verifyVercelSignature(req); } else if (req.method === "POST") { await verifyQstashSignature({ req, rawBody: await req.text(), }); } await updateUsage(); return NextResponse.json({ response: "success", }); } catch (error) { await log({ message: `Error updating usage: ${error.message}`, type: "cron", }); return handleAndReturnErrorResponse(error); } } export { handler as GET, handler as POST }; ================================================ FILE: apps/web/app/(ee)/api/cron/usage/utils.ts ================================================ import { getAnalytics } from "@/lib/analytics/get-analytics"; import { qstash } from "@/lib/cron"; import { sendLimitEmail } from "@/lib/cron/send-limit-email"; import { WorkspaceProps } from "@/lib/types"; import { sendBatchEmail } from "@dub/email"; import ClicksSummary from "@dub/email/templates/clicks-summary"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, capitalize, getAdjustedBillingCycleStart, log, } from "@dub/utils"; const limit = 100; export const updateUsage = async () => { const workspaces = await prisma.project.findMany({ where: { // Check only workspaces that haven't been checked in the last 12 hours usageLastChecked: { lt: new Date(new Date().getTime() - 12 * 60 * 60 * 1000), }, }, include: { users: { select: { user: true, }, where: { user: { isMachine: false, }, notificationPreference: { linkUsageSummary: true, }, }, orderBy: { createdAt: "asc", }, take: 10, // Only send to the first 10 users }, sentEmails: true, }, orderBy: [ { usageLastChecked: "asc", }, { createdAt: "asc", }, ], take: limit, }); // if no workspaces left, meaning cron is complete if (workspaces.length === 0) { return; } // Reset billing cycles for workspaces that have // adjustedBillingCycleStart that matches today's date const billingReset = workspaces.filter( ({ billingCycleStart }) => getAdjustedBillingCycleStart(billingCycleStart as number) === new Date().getDate(), ); // Reset usage and alert emails for the billingReset workspaces // also send 30-day summary email await Promise.allSettled( billingReset.map(async (workspace) => { const { plan, usage, usageLimit } = workspace; /* We only reset clicks usage if it's not over usageLimit by: - 4x for free plan (4K clicks) - 2x for all other plans */ const resetUsage = plan === "free" ? usage <= usageLimit * 4 : usage <= usageLimit * 2; await prisma.project.update({ where: { id: workspace.id, }, data: { ...(resetUsage && { usage: 0, }), linksUsage: 0, payoutsUsage: 0, aiUsage: 0, sentEmails: { deleteMany: { type: { in: [ "firstUsageLimitEmail", "secondUsageLimitEmail", "firstLinksLimitEmail", "secondLinksLimitEmail", ], }, }, }, }, }); /* Only send the 30-day summary email if: - the workspace has at least 1 link click - the workspace was created more than 30 days ago */ if ( workspace.usage > 0 && workspace.createdAt.getTime() < new Date().getTime() - 30 * 24 * 60 * 60 * 1000 ) { const topLinks = await getAnalytics({ workspaceId: workspace.id, event: "clicks", groupBy: "top_links", interval: "30d", root: false, }); const topLinkIds = topLinks.slice(0, 100).map(({ link }) => link); const linksMetadata = await prisma.link.findMany({ where: { projectId: workspace.id, id: { in: topLinkIds, }, }, select: { id: true, shortLink: true, }, }); const topFiveLinks = topLinks .filter((d: { link: string; clicks: number }) => linksMetadata.find((l) => l.id === d.link), ) .slice(0, 5) .map((d: { link: string; clicks: number }) => ({ link: linksMetadata.find((l) => l.id === d.link)!, // coerce here since we're already filtering out links that don't exist clicks: d.clicks, })); const totalClicks = topLinks.reduce( (acc, curr) => acc + curr.clicks, 0, ); const emails = workspace.users.map( (user) => user.user.email, ) as string[]; await sendBatchEmail( emails.map((email) => ({ subject: `Your 30-day ${process.env.NEXT_PUBLIC_APP_NAME} summary for ${workspace.name}`, to: email, react: ClicksSummary({ email, workspaceName: workspace.name, workspaceSlug: workspace.slug, totalClicks, createdLinks: workspace.linksUsage, topLinks: topFiveLinks, }), variant: "notifications", })), ); } }), ); // Update usageLastChecked for workspaces await prisma.project.updateMany({ where: { id: { in: workspaces.map(({ id }) => id), }, }, data: { usageLastChecked: new Date(), }, }); // Get all workspaces that have exceeded usage const exceedingUsage = workspaces.filter( ({ usage, usageLimit }) => usage > usageLimit, ); // Send email to notify overages await Promise.allSettled( exceedingUsage.map(async (workspace) => { const { slug, plan, usage, usageLimit, users, sentEmails } = workspace; const emails = users.map((user) => user.user.email) as string[]; await log({ message: `*${slug}* is over their *${capitalize( plan, )} Plan* usage limit. Usage: ${usage}, Limit: ${usageLimit}, Email: ${emails.join( ", ", )}`, type: plan === "free" ? "cron" : "alerts", mention: plan !== "free", }); const sentFirstUsageLimitEmail = sentEmails.some( (email) => email.type === "firstUsageLimitEmail", ); if (!sentFirstUsageLimitEmail) { sendLimitEmail({ emails, workspace: workspace as unknown as WorkspaceProps, type: "firstUsageLimitEmail", }); } else { const sentSecondUsageLimitEmail = sentEmails.some( (email) => email.type === "secondUsageLimitEmail", ); if (!sentSecondUsageLimitEmail) { const daysSinceFirstEmail = Math.floor( (new Date().getTime() - new Date(sentEmails[0].createdAt).getTime()) / (1000 * 3600 * 24), ); if (daysSinceFirstEmail >= 3) { sendLimitEmail({ emails, workspace: workspace as unknown as WorkspaceProps, type: "secondUsageLimitEmail", }); } } } }), ); return await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/usage`, method: "POST", body: {}, }); }; ================================================ FILE: apps/web/app/(ee)/api/cron/utils.ts ================================================ export function logAndRespond( message: string, { status = 200, logLevel = "info", }: { status?: number; logLevel?: "error" | "warn" | "info"; } = {}, ) { console[logLevel](message); return new Response(message, { status }); } ================================================ FILE: apps/web/app/(ee)/api/cron/welcome-user/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { generateUnsubscribeToken } from "@/lib/email/unsubscribe-token"; import { getPlanCapabilities } from "@/lib/plan-capabilities"; import { sendEmail } from "@dub/email"; import WelcomeEmail from "@dub/email/templates/welcome-email"; import WelcomeEmailPartner from "@dub/email/templates/welcome-email-partner"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN, PARTNERS_DOMAIN } from "@dub/utils"; export const dynamic = "force-dynamic"; /* This route is used to send a welcome email to new users + subscribe them to the corresponding Resend audience It is called by QStash 45 minutes after a user is created. */ export async function POST(req: Request) { try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody }); const { userId } = JSON.parse(rawBody); const user = await prisma.user.findUnique({ where: { id: userId, }, select: { name: true, email: true, partners: true, projects: { select: { project: { select: { slug: true, name: true, logo: true, plan: true, programs: { select: { slug: true, name: true, logo: true, }, orderBy: { createdAt: "desc", }, take: 1, }, }, }, }, orderBy: { createdAt: "asc", }, take: 1, }, }, }); if (!user) { return new Response("User not found. Skipping...", { status: 200 }); } // this shouldn't happen but just in case if (!user.email) { return new Response("User email not found. Skipping...", { status: 200 }); } const isPartner = user.partners.length > 0; const unsubscribeUrl = `${isPartner ? PARTNERS_DOMAIN : APP_DOMAIN}/unsubscribe/${generateUnsubscribeToken(user.email)}`; await Promise.allSettled([ sendEmail({ to: user.email, replyTo: isPartner ? "noreply" : "steven.tey@dub.co", subject: `Welcome to Dub${isPartner ? " Partners" : ""}!`, react: isPartner ? WelcomeEmailPartner({ email: user.email, name: user.name, unsubscribeUrl, }) : WelcomeEmail({ email: user.email, workspace: user.projects?.[0]?.project, hasDubPartners: getPlanCapabilities( user.projects?.[0]?.project?.plan || "free", ).canManageProgram, program: user.projects?.[0]?.project?.programs?.[0], unsubscribeUrl, }), variant: "marketing", }), ]); return new Response("Welcome email sent and user subscribed.", { status: 200, }); } catch (error) { return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/workflows/[workflowId]/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { executeSendCampaignWorkflow } from "@/lib/api/workflows/execute-send-campaign-workflow"; import { parseWorkflowConfig } from "@/lib/api/workflows/parse-workflow-config"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { WORKFLOW_ACTION_TYPES } from "@/lib/zod/schemas/workflows"; import { prisma } from "@dub/prisma"; import { log } from "@dub/utils"; import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; // POST /api/cron/workflows/[workflowId] - Execute a scheduled workflow export async function POST( req: Request, { params }: { params: Promise<{ workflowId: string }> }, ) { const { workflowId } = await params; try { const rawBody = await req.text(); await verifyQstashSignature({ req, rawBody, }); const workflow = await prisma.workflow.findUnique({ where: { id: workflowId, }, }); if (!workflow) { return logAndRespond(`Workflow ${workflowId} not found. Skipping...`); } if (workflow.disabledAt) { return logAndRespond(`Workflow ${workflowId} is disabled. Skipping...`); } const workflowConfig = parseWorkflowConfig(workflow); if (workflowConfig.action.type === WORKFLOW_ACTION_TYPES.SendCampaign) { await executeSendCampaignWorkflow({ workflow, }); } return logAndRespond(`Finished executing workflow ${workflowId}.`); } catch (error) { await log({ message: "Workflows dispatch cron failed. Error: " + error.message, type: "errors", }); return handleAndReturnErrorResponse(error); } } ================================================ FILE: apps/web/app/(ee)/api/cron/workspaces/delete/delete-workspace-customers.ts ================================================ import { isStored, storage } from "@/lib/storage"; import { prisma } from "@dub/prisma"; import { R2_URL } from "@dub/utils"; import { DeleteWorkspacePayload, enqueueNextWorkspaceDeleteStep, } from "./utils"; const MAX_CUSTOMERS_PER_BATCH = 100; export async function deleteWorkspaceCustomers( payload: DeleteWorkspacePayload, ) { const { workspaceId, startingAfter } = payload; const customers = await prisma.customer.findMany({ where: { projectId: workspaceId, }, orderBy: { id: "asc", }, ...(startingAfter && { skip: 1, cursor: { id: startingAfter, }, }), take: MAX_CUSTOMERS_PER_BATCH, }); if (customers.length > 0) { // Delete customer avatars from storage await Promise.allSettled( customers.map(async (customer) => { if (customer.avatar && isStored(customer.avatar)) { await storage.delete({ key: customer.avatar.replace(`${R2_URL}/`, ""), }); } }), ); const deletedCustomers = await prisma.customer.deleteMany({ where: { id: { in: customers.map(({ id }) => id), }, }, }); console.log( `Deleted ${deletedCustomers.count} customers for workspace ${workspaceId}.`, ); } return await enqueueNextWorkspaceDeleteStep({ payload, currentStep: "delete-customers", nextStep: "delete-workspace", items: customers, maxBatchSize: MAX_CUSTOMERS_PER_BATCH, }); } ================================================ FILE: apps/web/app/(ee)/api/cron/workspaces/delete/delete-workspace-domains.ts ================================================ import { removeDomainFromVercel } from "@/lib/api/domains/remove-domain-vercel"; import { prisma } from "@dub/prisma"; import { DeleteWorkspacePayload, enqueueNextWorkspaceDeleteStep, } from "./utils"; const MAX_DOMAINS_PER_BATCH = 10; export async function deleteWorkspaceDomains(payload: DeleteWorkspacePayload) { const { workspaceId, startingAfter } = payload; const domains = await prisma.domain.findMany({ where: { projectId: workspaceId, }, orderBy: { id: "asc", }, ...(startingAfter && { skip: 1, cursor: { id: startingAfter, }, }), take: MAX_DOMAINS_PER_BATCH, }); // Delete registered domains const deletedRegisteredDomains = await prisma.registeredDomain.deleteMany({ where: { projectId: workspaceId, }, }); if (deletedRegisteredDomains.count > 0) { console.log( `Deleted ${deletedRegisteredDomains.count} registered domains for workspace ${workspaceId}.`, ); } // Delete other domains if (domains.length > 0) { const deletedDomains = await prisma.domain.deleteMany({ where: { id: { in: domains.map(({ id }) => id), }, }, }); console.log( `Deleted ${deletedDomains.count} domains for workspace ${workspaceId}.`, ); await Promise.allSettled( domains.map(({ slug }) => removeDomainFromVercel(slug)), ); } return await enqueueNextWorkspaceDeleteStep({ payload, currentStep: "delete-domains", nextStep: "delete-folders", items: domains, maxBatchSize: MAX_DOMAINS_PER_BATCH, }); } ================================================ FILE: apps/web/app/(ee)/api/cron/workspaces/delete/delete-workspace-folders.ts ================================================ import { prisma } from "@dub/prisma"; import { DeleteWorkspacePayload, enqueueNextWorkspaceDeleteStep, } from "./utils"; const MAX_FOLDERS_PER_BATCH = 100; export async function deleteWorkspaceFolders(payload: DeleteWorkspacePayload) { const { workspaceId, startingAfter } = payload; const folders = await prisma.folder.findMany({ where: { projectId: workspaceId, }, orderBy: { id: "asc", }, ...(startingAfter && { skip: 1, cursor: { id: startingAfter, }, }), take: MAX_FOLDERS_PER_BATCH, }); if (folders.length > 0) { const deletedFolders = await prisma.folder.deleteMany({ where: { id: { in: folders.map(({ id }) => id), }, }, }); console.log( `Deleted ${deletedFolders.count} folders for workspace ${workspaceId}.`, ); } return await enqueueNextWorkspaceDeleteStep({ payload, currentStep: "delete-folders", nextStep: "delete-customers", items: folders, maxBatchSize: MAX_FOLDERS_PER_BATCH, }); } ================================================ FILE: apps/web/app/(ee)/api/cron/workspaces/delete/delete-workspace-links.ts ================================================ import { bulkDeleteLinks } from "@/lib/api/links/bulk-delete-links"; import { prisma } from "@dub/prisma"; import { DeleteWorkspacePayload, enqueueNextWorkspaceDeleteStep, } from "./utils"; const MAX_LINKS_PER_BATCH = 100; export async function deleteWorkspaceLinks(payload: DeleteWorkspacePayload) { const { workspaceId, startingAfter } = payload; const links = await prisma.link.findMany({ where: { projectId: workspaceId, }, orderBy: { id: "asc", }, ...(startingAfter && { skip: 1, cursor: { id: startingAfter, }, }), take: MAX_LINKS_PER_BATCH, }); if (links.length > 0) { const deletedLinks = await prisma.link.deleteMany({ where: { id: { in: links.map(({ id }) => id), }, }, }); console.log( `Deleted ${deletedLinks.count} links for workspace ${workspaceId}.`, ); await bulkDeleteLinks(links); } return await enqueueNextWorkspaceDeleteStep({ payload, currentStep: "delete-links", nextStep: "delete-domains", items: links, maxBatchSize: MAX_LINKS_PER_BATCH, }); } ================================================ FILE: apps/web/app/(ee)/api/cron/workspaces/delete/delete-workspace.ts ================================================ import { prisma } from "@dub/prisma"; import { logAndRespond } from "../../utils"; import { DeleteWorkspacePayload } from "./utils"; export async function deleteWorkspace(payload: DeleteWorkspacePayload) { const { workspaceId } = payload; const workspace = await prisma.project.findUnique({ where: { id: workspaceId, }, select: { id: true, }, }); if (!workspace) { return logAndRespond(`Workspace ${workspaceId} not found. Skipping...`); } await prisma.project.delete({ where: { id: workspaceId, }, }); return logAndRespond(`Workspace ${workspaceId} deleted successfully.`); } ================================================ FILE: apps/web/app/(ee)/api/cron/workspaces/delete/route.ts ================================================ import { withCron } from "@/lib/cron/with-cron"; import { logAndRespond } from "../../utils"; import { deleteWorkspace } from "./delete-workspace"; import { deleteWorkspaceCustomers } from "./delete-workspace-customers"; import { deleteWorkspaceDomains } from "./delete-workspace-domains"; import { deleteWorkspaceFolders } from "./delete-workspace-folders"; import { deleteWorkspaceLinks } from "./delete-workspace-links"; import { deleteWorkspaceSchema } from "./utils"; export const dynamic = "force-dynamic"; // POST /api/cron/workspaces/delete export const POST = withCron(async ({ rawBody }) => { const payload = deleteWorkspaceSchema.parse(JSON.parse(rawBody)); switch (payload.step) { case "delete-links": return await deleteWorkspaceLinks(payload); case "delete-domains": return await deleteWorkspaceDomains(payload); case "delete-folders": return await deleteWorkspaceFolders(payload); case "delete-customers": return await deleteWorkspaceCustomers(payload); case "delete-workspace": return await deleteWorkspace(payload); default: return logAndRespond(`Unknown step ${payload.step}`); } }); ================================================ FILE: apps/web/app/(ee)/api/cron/workspaces/delete/utils.ts ================================================ import { qstash } from "@/lib/cron"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; export const deleteWorkspaceSchema = z.object({ workspaceId: z.string(), step: z .enum([ "delete-links", "delete-domains", "delete-folders", "delete-customers", "delete-workspace", ]) .optional() .default("delete-links"), startingAfter: z.string().optional(), }); export type DeleteWorkspacePayload = z.infer; export async function enqueueNextWorkspaceDeleteStep({ payload, currentStep, nextStep, items, maxBatchSize, }: { payload: DeleteWorkspacePayload; currentStep: DeleteWorkspacePayload["step"]; nextStep: DeleteWorkspacePayload["step"]; items: { id: string }[]; maxBatchSize: number; }) { const hasMore = items.length === maxBatchSize; const { messageId } = await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/workspaces/delete`, body: { ...payload, startingAfter: hasMore ? items[items.length - 1].id : undefined, step: hasMore ? currentStep : nextStep, }, }); return logAndRespond( hasMore ? `Enqueued next batch for step "${currentStep}" (messageId: ${messageId})` : `Completed step "${currentStep}", moving to "${nextStep}" (messageId: ${messageId})`, ); } ================================================ FILE: apps/web/app/(ee)/api/customers/[id]/activity/route.ts ================================================ import { getCustomerEvents } from "@/lib/analytics/get-customer-events"; import { getCustomerOrThrow } from "@/lib/api/customers/get-customer-or-throw"; import { decodeLinkIfCaseSensitive } from "@/lib/api/links/case-sensitivity"; import { withWorkspace } from "@/lib/auth"; import { customerActivityResponseSchema } from "@/lib/zod/schemas/customer-activity"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/customers/[id]/activity - get a customer's activity export const GET = withWorkspace(async ({ workspace, params }) => { const { id: customerId } = params; const customer = await getCustomerOrThrow({ workspaceId: workspace.id, id: customerId, }); const events = await getCustomerEvents({ customerId: customer.id, }); // get the first partner link that this customer interacted with const firstLinkId = events.length > 0 ? events[events.length - 1].link_id : customer.linkId; let link: { id: string; domain: string; key: string; shortLink: string; } | null = null; if (firstLinkId) { link = await prisma.link.findUniqueOrThrow({ where: { id: firstLinkId, }, select: { id: true, domain: true, key: true, shortLink: true, }, }); link = decodeLinkIfCaseSensitive(link); } return NextResponse.json( customerActivityResponseSchema.parse({ ...customer, events, link, }), ); }); ================================================ FILE: apps/web/app/(ee)/api/customers/[id]/route.ts ================================================ import { getCustomerOrThrow } from "@/lib/api/customers/get-customer-or-throw"; import { transformCustomer } from "@/lib/api/customers/transform-customer"; import { DubApiError } from "@/lib/api/errors"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { isStored, storage } from "@/lib/storage"; import { CustomerEnrichedSchema, CustomerSchema, getCustomersQuerySchema, updateCustomerBodySchema, } from "@/lib/zod/schemas/customers"; import { prisma } from "@dub/prisma"; import { nanoid, R2_URL } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // GET /api/customers/:id – Get a customer by ID export const GET = withWorkspace( async ({ workspace, params, searchParams }) => { const { id } = params; const { includeExpandedFields } = getCustomersQuerySchema.parse(searchParams); const customer = await getCustomerOrThrow( { id, workspaceId: workspace.id, }, { includeExpandedFields, }, ); const responseSchema = includeExpandedFields ? CustomerEnrichedSchema : CustomerSchema; return NextResponse.json(responseSchema.parse(transformCustomer(customer))); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], }, ); // PATCH /api/customers/:id – Update a customer by ID export const PATCH = withWorkspace( async ({ workspace, params, req, searchParams }) => { const { id } = params; const { includeExpandedFields } = getCustomersQuerySchema.parse(searchParams); const { name, email, avatar, externalId, stripeCustomerId } = updateCustomerBodySchema.parse(await parseRequestBody(req)); const customer = await getCustomerOrThrow( { id, workspaceId: workspace.id, }, { includeExpandedFields, }, ); const oldCustomerAvatar = customer.avatar; // we need to persist the customer avatar to R2 if: // 1. it's different from the old avatar // 2. it's not stored in R2 already const finalCustomerAvatar = avatar && avatar !== oldCustomerAvatar && !isStored(avatar) ? `${R2_URL}/customers/${customer.id}/avatar_${nanoid(7)}` : avatar; try { const updatedCustomer = await prisma.customer.update({ where: { id: customer.id, }, data: { name, email, avatar: finalCustomerAvatar, externalId, stripeCustomerId, }, }); if (avatar && !isStored(avatar) && finalCustomerAvatar) { waitUntil( storage .upload({ key: finalCustomerAvatar.replace(`${R2_URL}/`, ""), body: avatar, opts: { width: 128, height: 128, }, }) .then(() => { if (oldCustomerAvatar && isStored(oldCustomerAvatar)) { storage.delete({ key: oldCustomerAvatar.replace(`${R2_URL}/`, ""), }); } }) .catch(async (error) => { console.error("Error persisting customer avatar to R2", error); // if the avatar fails to upload to R2, set the avatar to null in the database await prisma.customer.update({ where: { id: customer.id }, data: { avatar: null }, }); }), ); } const responseSchema = includeExpandedFields ? CustomerEnrichedSchema : CustomerSchema; return NextResponse.json( responseSchema.parse( transformCustomer({ ...customer, ...updatedCustomer, }), ), ); } catch (error) { if (error.code === "P2002") { throw new DubApiError({ code: "conflict", message: "A customer with this external ID already exists.", }); } throw new DubApiError({ code: "unprocessable_entity", message: error.message, }); } }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], requiredRoles: ["owner", "member"], }, ); // DELETE /api/customers/:id – Delete a customer by ID export const DELETE = withWorkspace( async ({ workspace, params }) => { const { id } = params; const customer = await getCustomerOrThrow({ id, workspaceId: workspace.id, }); await prisma.customer.delete({ where: { id: customer.id, }, }); if (customer.avatar && isStored(customer.avatar)) { storage.delete({ key: customer.avatar.replace(`${R2_URL}/`, "") }); } return NextResponse.json({ id: customer.id, }); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], requiredRoles: ["owner", "member"], }, ); ================================================ FILE: apps/web/app/(ee)/api/customers/[id]/stripe-invoices/route.ts ================================================ import { getCustomerOrThrow } from "@/lib/api/customers/get-customer-or-throw"; import { getCustomerStripeInvoices } from "@/lib/api/customers/get-customer-stripe-invoices"; import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { NextResponse } from "next/server"; export const GET = withWorkspace(async ({ workspace, params }) => { const { id: customerId } = params; if (!workspace.stripeConnectId) { throw new DubApiError({ code: "bad_request", message: "Your workspace isn't connected to Stripe yet. Please install the Stripe integration under /settings/integrations/stripe to proceed.", }); } const customer = await getCustomerOrThrow({ workspaceId: workspace.id, id: customerId, }); if (!customer.stripeCustomerId) { throw new DubApiError({ code: "bad_request", message: "Customer doesn't have a Stripe customer ID. Please add a Stripe customer ID to the customer before proceeding.", }); } const stripeCustomerInvoices = await getCustomerStripeInvoices({ stripeCustomerId: customer.stripeCustomerId, stripeConnectId: workspace.stripeConnectId, programId: getDefaultProgramIdOrThrow(workspace), }); return NextResponse.json(stripeCustomerInvoices); }); ================================================ FILE: apps/web/app/(ee)/api/customers/count/route.ts ================================================ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { buildCustomerCountWhere } from "@/lib/customers/api/customer-count-where"; import { getCustomersCountQuerySchema } from "@/lib/zod/schemas/customers"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/customers/count export const GET = withWorkspace(async ({ workspace, searchParams }) => { const parsedFilters = getCustomersCountQuerySchema.parse(searchParams); let { programId, partnerId, groupBy } = parsedFilters; if (programId || partnerId) { programId = getDefaultProgramIdOrThrow(workspace); } const commonWhere = buildCustomerCountWhere({ ...parsedFilters, workspaceId: workspace.id, programId, }); // Get customer count by country if (groupBy === "country") { const data = await prisma.customer.groupBy({ by: ["country"], where: commonWhere, _count: true, orderBy: { _count: { country: "desc", }, }, }); return NextResponse.json(data); } // Get customer count by linkId if (groupBy === "linkId") { const data = await prisma.customer.groupBy({ by: ["linkId"], where: { ...commonWhere, linkId: { not: null } }, _count: true, orderBy: { _count: { linkId: "desc", }, }, take: 10000, }); const links = await prisma.link.findMany({ where: { id: { in: data.map(({ linkId }) => linkId!) }, }, select: { id: true, shortLink: true, url: true, }, }); const enrichedData = data .map((d) => { const link = links.find(({ id }) => id === d.linkId); if (!link) return null; return { ...d, shortLink: link?.shortLink, url: link?.url, }; }) .filter(Boolean); return NextResponse.json(enrichedData); } const count = await prisma.customer.count({ where: commonWhere, }); return NextResponse.json(count); }); ================================================ FILE: apps/web/app/(ee)/api/customers/export/route.ts ================================================ import { convertToCSV } from "@/lib/analytics/utils"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { qstash } from "@/lib/cron"; import { buildCustomerCountWhere } from "@/lib/customers/api/customer-count-where"; import { formatCustomersForExport } from "@/lib/customers/api/format-customers-export"; import { getCustomers } from "@/lib/customers/api/get-customers"; import { customersExportQuerySchema } from "@/lib/zod/schemas/customers"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { NextResponse } from "next/server"; const MAX_CUSTOMERS_TO_EXPORT = 1000; // GET /api/customers/export – export customers to CSV export const GET = withWorkspace( async ({ searchParams, workspace, session }) => { const filters = customersExportQuerySchema.parse(searchParams); let { programId, partnerId, columns } = filters; if (programId || partnerId) { programId = getDefaultProgramIdOrThrow(workspace); } const where = buildCustomerCountWhere({ ...filters, workspaceId: workspace.id, programId, }); const count = await prisma.customer.count({ where, }); if (count > MAX_CUSTOMERS_TO_EXPORT) { await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/export/customers`, body: { ...filters, workspaceId: workspace.id, programId, userId: session.user.id, columns: columns.join(","), }, }); return NextResponse.json({}, { status: 202 }); } const customers = await getCustomers({ ...filters, workspaceId: workspace.id, programId, page: 1, pageSize: MAX_CUSTOMERS_TO_EXPORT, includeExpandedFields: true, }); const rows = formatCustomersForExport(customers, columns); return new Response(convertToCSV(rows), { headers: { "Content-Type": "text/csv", "Content-Disposition": "attachment", }, }); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], }, ); ================================================ FILE: apps/web/app/(ee)/api/customers/route.ts ================================================ import { createId } from "@/lib/api/create-id"; import { transformCustomer } from "@/lib/api/customers/transform-customer"; import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { getCustomers } from "@/lib/customers/api/get-customers"; import { generateRandomName } from "@/lib/names"; import { isStored, storage } from "@/lib/storage"; import { createCustomerBodySchema, CustomerEnrichedSchema, CustomerSchema, getCustomersQuerySchemaExtended, } from "@/lib/zod/schemas/customers"; import { DiscountSchemaWithDeprecatedFields } from "@/lib/zod/schemas/discount"; import { prisma } from "@dub/prisma"; import { nanoid, R2_URL } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // GET /api/customers – Get all customers export const GET = withWorkspace( async ({ workspace, searchParams }) => { const filters = getCustomersQuerySchemaExtended.parse(searchParams); let { programId, partnerId, includeExpandedFields } = filters; if (programId || partnerId) { programId = getDefaultProgramIdOrThrow(workspace); } const customers = await getCustomers({ ...filters, workspaceId: workspace.id, programId, }); const responseSchema = includeExpandedFields ? CustomerEnrichedSchema.extend({ discount: DiscountSchemaWithDeprecatedFields, }) : CustomerSchema; const response = responseSchema .array() .parse(customers.map(transformCustomer)); return NextResponse.json(response); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], }, ); // POST /api/customers – Create a customer export const POST = withWorkspace( async ({ req, workspace }) => { const { email, name, avatar, externalId, stripeCustomerId, country } = createCustomerBodySchema.parse(await parseRequestBody(req)); const customerId = createId({ prefix: "cus_" }); const finalCustomerName = name || email || generateRandomName(); const finalCustomerAvatar = avatar && !isStored(avatar) ? `${R2_URL}/customers/${customerId}/avatar_${nanoid(7)}` : avatar; try { const customer = await prisma.customer.create({ data: { id: customerId, name: finalCustomerName, email, avatar: finalCustomerAvatar, externalId, stripeCustomerId, country, projectId: workspace.id, projectConnectId: workspace.stripeConnectId, }, }); if (avatar && !isStored(avatar) && finalCustomerAvatar) { waitUntil( storage .upload({ key: finalCustomerAvatar.replace(`${R2_URL}/`, ""), body: avatar, opts: { width: 128, height: 128, }, }) .catch(async (error) => { console.error("Error persisting customer avatar to R2", error); // if the avatar fails to upload to R2, set the avatar to null in the database await prisma.customer.update({ where: { id: customer.id, }, data: { avatar: null, }, }); }), ); } return NextResponse.json( CustomerSchema.parse(transformCustomer(customer)), { status: 201, }, ); } catch (error) { if (error.code === "P2002") { throw new DubApiError({ code: "conflict", message: "A customer with this external ID already exists.", }); } throw new DubApiError({ code: "unprocessable_entity", message: error.message, }); } }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], requiredRoles: ["owner", "member"], }, ); ================================================ FILE: apps/web/app/(ee)/api/customers/search-stripe/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { withWorkspace } from "@/lib/auth"; import { stripeAppClient } from "@/lib/stripe"; import { StripeCustomerSchema } from "@/lib/zod/schemas/customers"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; const stripe = stripeAppClient({ ...(process.env.VERCEL_ENV && { mode: "live" }), }); export const GET = withWorkspace(async ({ workspace, searchParams }) => { const { search } = z .object({ search: z.string(), }) .parse(searchParams); if (!workspace.stripeConnectId) { throw new DubApiError({ code: "bad_request", message: "Your workspace isn't connected to Stripe yet. Please install the Stripe integration under /settings/integrations/stripe to proceed.", }); } const { data } = await stripe.customers.search( { query: `email~"${search}"`, limit: 100, expand: ["data.subscriptions"], }, { stripeAccount: workspace.stripeConnectId, }, ); const existingCustomers = await prisma.customer.findMany({ where: { stripeCustomerId: { in: data.map((customer) => customer.id), }, projectId: workspace.id, }, select: { id: true, stripeCustomerId: true, }, }); const stripeCustomers = StripeCustomerSchema.array().parse( data.map((customer) => ({ id: customer.id, email: customer.email, name: customer.name, country: customer.address?.country ?? null, subscriptions: customer.subscriptions?.data.length ?? 0, dubCustomerId: existingCustomers.find((c) => c.stripeCustomerId === customer.id)?.id ?? null, })), ); return NextResponse.json(stripeCustomers); }); ================================================ FILE: apps/web/app/(ee)/api/discount-codes/[discountCodeId]/route.ts ================================================ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { deleteDiscountCodes } from "@/lib/api/discounts/delete-discount-code"; import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { prisma } from "@dub/prisma"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // DELETE /api/discount-codes/[discountCodeId] - soft delete a discount code export const DELETE = withWorkspace( async ({ workspace, params, session }) => { const { discountCodeId } = params; const programId = getDefaultProgramIdOrThrow(workspace); const discountCode = await prisma.discountCode.findUnique({ where: { id: discountCodeId, }, }); if (!discountCode || !discountCode.discountId) { throw new DubApiError({ message: `Discount code (${discountCodeId}) not found.`, code: "bad_request", }); } if (discountCode.programId !== programId) { throw new DubApiError({ message: `Discount code (${discountCodeId}) is not associated with the program.`, code: "bad_request", }); } await prisma.discountCode.update({ where: { id: discountCodeId, }, data: { discountId: null, }, }); waitUntil( Promise.allSettled([ recordAuditLog({ workspaceId: workspace.id, programId, action: "discount_code.deleted", description: `Discount code (${discountCode.code}) deleted`, actor: session.user, targets: [ { type: "discount_code", id: discountCode.id, metadata: discountCode, }, ], }), deleteDiscountCodes(discountCode), ]), ); return NextResponse.json({ id: discountCode.id }); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], requiredRoles: ["owner", "member"], }, ); ================================================ FILE: apps/web/app/(ee)/api/discount-codes/route.ts ================================================ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { createDiscountCode } from "@/lib/api/discounts/create-discount-code"; import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { createDiscountCodeSchema, DiscountCodeSchema, getDiscountCodesQuerySchema, } from "@/lib/zod/schemas/discount"; import { prisma } from "@dub/prisma"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // GET /api/discount-codes - get all discount codes for a partner export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { partnerId } = getDiscountCodesQuerySchema.parse(searchParams); const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId, programId, include: { discountCodes: true, }, }); const response = DiscountCodeSchema.array().parse( programEnrollment.discountCodes, ); return NextResponse.json(response); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], }, ); // POST /api/discount-codes - create a discount code export const POST = withWorkspace( async ({ workspace, req, session }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { partnerId, linkId, code } = createDiscountCodeSchema.parse( await parseRequestBody(req), ); if (!workspace.stripeConnectId) { throw new DubApiError({ code: "bad_request", message: "Your workspace isn't connected to Stripe yet. Please install the Stripe integration under /settings/integrations/stripe to proceed.", }); } const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId, programId, include: { links: true, discount: true, discountCodes: true, partner: { select: { id: true, name: true, }, }, }, }); const { links, discount } = programEnrollment; const link = links.find((link) => link.id === linkId); if (!link) { throw new DubApiError({ code: "bad_request", message: "Partner link not found.", }); } if (!discount) { throw new DubApiError({ code: "bad_request", message: "No discount is assigned to this partner group. Please add a discount before proceeding.", }); } // Check for duplicate by code if (code) { const duplicateByCode = await prisma.discountCode.findUnique({ where: { programId_code: { programId, code, }, }, }); if (duplicateByCode) { throw new DubApiError({ code: "bad_request", message: `A discount with the code ${code} already exists in the program. Please choose a different code.`, }); } } // A link can have only one discount code const duplicateByLink = programEnrollment.discountCodes.find( (discountCode) => discountCode.linkId === linkId, ); if (duplicateByLink) { throw new DubApiError({ code: "bad_request", message: `This link already has a discount code (${duplicateByLink.code}) assigned.`, }); } try { const discountCode = await createDiscountCode({ stripeConnectId: workspace.stripeConnectId, partner: programEnrollment.partner, link, discount, code, }); waitUntil( recordAuditLog({ workspaceId: workspace.id, programId, action: "discount_code.created", description: `Discount code (${discountCode.code}) created`, actor: session.user, targets: [ { type: "discount_code", id: discountCode.id, metadata: discountCode, }, ], }), ); return NextResponse.json(DiscountCodeSchema.parse(discountCode)); } catch (error) { throw new DubApiError({ code: "bad_request", message: error.code === "more_permissions_required_for_application" ? "STRIPE_APP_UPGRADE_REQUIRED: Your connected Stripe account doesn't have the permissions needed to create discount codes. Please upgrade your Stripe integration in settings or reach out to our support team for help." : error.message, }); } }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], requiredRoles: ["owner", "member"], }, ); ================================================ FILE: apps/web/app/(ee)/api/domains/register/route.ts ================================================ import { claimDotLinkDomain } from "@/lib/api/domains/claim-dot-link-domain"; import { DubApiError } from "@/lib/api/errors"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { DOMAIN_REGISTRATION_ELIGIBLE_WORKSPACES } from "@/lib/dynadot/constants"; import { registerDomainSchema } from "@/lib/zod/schemas/domains"; import { NextResponse } from "next/server"; // POST /api/domains/register - register a domain export const POST = withWorkspace( async ({ workspace, session, req }) => { const { domain } = registerDomainSchema.parse(await parseRequestBody(req)); if (!DOMAIN_REGISTRATION_ELIGIBLE_WORKSPACES.includes(workspace.id)) { throw new DubApiError({ code: "forbidden", message: "POST /domains/register is not available for your workspace. Contact support for more information.", }); } const response = await claimDotLinkDomain({ domain, workspace, userId: session.user.id, skipWorkspaceChecks: true, }); return NextResponse.json(response, { status: 201 }); }, { requiredPermissions: ["domains.write"], requiredPlan: ["enterprise"], }, ); ================================================ FILE: apps/web/app/(ee)/api/domains/status/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { withWorkspace } from "@/lib/auth"; import { DOMAIN_REGISTRATION_ELIGIBLE_WORKSPACES } from "@/lib/dynadot/constants"; import { searchDomainsAvailability } from "@/lib/dynadot/search-domains"; import { DomainStatusSchema, searchDomainSchema, } from "@/lib/zod/schemas/domains"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/domains/status - checks the availability status of one or more domains export const GET = withWorkspace( async ({ workspace, searchParams }) => { let { domains } = searchDomainSchema.parse(searchParams); if (domains.length === 0) { throw new DubApiError({ code: "bad_request", message: "You must provide at least one domain to check. We only support .link domains for now.", }); } if (!DOMAIN_REGISTRATION_ELIGIBLE_WORKSPACES.includes(workspace.id)) { throw new DubApiError({ code: "forbidden", message: "GET /domains/status is not available for your workspace. Contact support for more information.", }); } const domainsOnDub = await prisma.domain.findMany({ where: { slug: { in: domains, }, verified: true, }, select: { slug: true, }, }); let response: z.infer[] = []; // if all domains are already registered on Dub, return the status for all domains as false if (domainsOnDub.length > 0) { response = DomainStatusSchema.array().parse( domainsOnDub.map(({ slug: domain }) => ({ domain, available: false, price: null, premium: null, })), ); } domains = domains.filter( (domain) => !domainsOnDub.some((d) => d.slug === domain), ); if (domains.length > 0) { const domainsToSearch = domains.reduce( (acc, domain, index) => { acc[`domain${index}`] = domain; return acc; }, {} as Record, ); const searchResponse = await searchDomainsAvailability({ domains: domainsToSearch, }); response = response.concat(searchResponse); } return NextResponse.json(response); }, { requiredPermissions: ["domains.read"], requiredPlan: ["enterprise"], }, ); ================================================ FILE: apps/web/app/(ee)/api/e2e/bounties/[bountyId]/route.ts ================================================ import { withWorkspace } from "@/lib/auth"; import { prisma } from "@dub/prisma"; import { ACME_PROGRAM_ID } from "@dub/utils"; import { NextResponse } from "next/server"; import { assertE2EWorkspace } from "../../guard"; // special endpoint to delete a bounty and all associated submissions (only for e2e tests) export const DELETE = withWorkspace(async ({ params, workspace }) => { assertE2EWorkspace(workspace); const { bountyId } = params; await prisma.$transaction(async (tx) => { await tx.bountySubmission.deleteMany({ where: { bountyId, programId: ACME_PROGRAM_ID, }, }); const bounty = await tx.bounty.delete({ where: { id: bountyId, programId: ACME_PROGRAM_ID, }, }); if (bounty.workflowId) { await tx.workflow.delete({ where: { id: bounty.workflowId, programId: ACME_PROGRAM_ID, }, }); } }); return NextResponse.json({ id: bountyId }); }); ================================================ FILE: apps/web/app/(ee)/api/e2e/enrollments/route.ts ================================================ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; import { assertE2EWorkspace } from "../guard"; // PATCH /api/e2e/enrollments - Update enrollment (e.g., backdate createdAt) export const PATCH = withWorkspace( async ({ req, workspace }) => { assertE2EWorkspace(workspace); const programId = getDefaultProgramIdOrThrow(workspace); const body = await req.json(); const { partnerId, createdAt } = body; const enrollment = await prisma.programEnrollment.update({ where: { partnerId_programId: { partnerId, programId, }, }, data: { ...(createdAt && { createdAt: new Date(createdAt) }), }, select: { partnerId: true, programId: true, createdAt: true, }, }); return NextResponse.json(enrollment); }, { requiredPermissions: ["workspaces.write"], }, ); ================================================ FILE: apps/web/app/(ee)/api/e2e/guard.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { WorkspaceProps } from "@/lib/types"; import { ACME_WORKSPACE_ID } from "@dub/utils"; export function assertE2EWorkspace( workspace: Pick, ): void { if (workspace.id !== ACME_WORKSPACE_ID) { throw new DubApiError({ code: "forbidden", message: "E2E endpoints are restricted to the Acme test workspace.", }); } } ================================================ FILE: apps/web/app/(ee)/api/e2e/notification-emails/route.ts ================================================ import { createId } from "@/lib/api/create-id"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; import { assertE2EWorkspace } from "../guard"; // GET /api/e2e/notification-emails - Find notification emails export const GET = withWorkspace(async ({ workspace, searchParams }) => { assertE2EWorkspace(workspace); const { campaignId, partnerId } = searchParams; const emails = await prisma.notificationEmail.findMany({ where: { ...(campaignId && { campaignId }), ...(partnerId && { partnerId }), type: "Campaign", }, }); return NextResponse.json(emails); }); // POST /api/e2e/notification-emails - Create a notification email (for test setup) export const POST = withWorkspace( async ({ req, workspace }) => { assertE2EWorkspace(workspace); const programId = getDefaultProgramIdOrThrow(workspace); const body = await req.json(); const email = await prisma.notificationEmail.create({ data: { id: createId({ prefix: "em_" }), type: "Campaign", emailId: body.emailId || `e2e_${Date.now()}`, campaignId: body.campaignId, programId, partnerId: body.partnerId, recipientUserId: body.recipientUserId, }, }); return NextResponse.json(email); }, { requiredPermissions: ["workspaces.write"], }, ); // DELETE /api/e2e/notification-emails - Delete notification emails (cleanup) export const DELETE = withWorkspace( async ({ workspace, searchParams }) => { assertE2EWorkspace(workspace); const { campaignId } = searchParams; if (!campaignId) { return NextResponse.json( { error: "campaignId is required" }, { status: 400 }, ); } const result = await prisma.notificationEmail.deleteMany({ where: { campaignId, type: "Campaign", }, }); return NextResponse.json({ deleted: result.count }); }, { requiredPermissions: ["workspaces.write"], }, ); ================================================ FILE: apps/web/app/(ee)/api/e2e/trigger-workflow/[workflowId]/route.ts ================================================ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { executeSendCampaignWorkflow } from "@/lib/api/workflows/execute-send-campaign-workflow"; import { parseWorkflowConfig } from "@/lib/api/workflows/parse-workflow-config"; import { withWorkspace } from "@/lib/auth"; import { WORKFLOW_ACTION_TYPES } from "@/lib/zod/schemas/workflows"; import { prisma } from "@dub/prisma"; import { ACME_PROGRAM_ID } from "@dub/utils"; import { NextResponse } from "next/server"; import { assertE2EWorkspace } from "../../guard"; // POST /api/e2e/trigger-workflow/[workflowId] // Executes a workflow directly with API token auth (no QStash signature needed). export const POST = withWorkspace(async ({ workspace, params }) => { assertE2EWorkspace(workspace); const { workflowId } = params; try { const workflow = await prisma.workflow.findUnique({ where: { id: workflowId, programId: ACME_PROGRAM_ID }, }); if (!workflow) { return NextResponse.json({ message: `Workflow ${workflowId} not found. Skipping...`, }); } if (workflow.disabledAt) { return NextResponse.json({ message: `Workflow ${workflowId} is disabled. Skipping...`, }); } const workflowConfig = parseWorkflowConfig(workflow); if (workflowConfig.action.type === WORKFLOW_ACTION_TYPES.SendCampaign) { await executeSendCampaignWorkflow({ workflow }); } return NextResponse.json({ message: `Finished executing workflow ${workflowId}.`, }); } catch (error) { return handleAndReturnErrorResponse(error); } }); ================================================ FILE: apps/web/app/(ee)/api/e2e/workflows/[workflowId]/route.ts ================================================ import { withWorkspace } from "@/lib/auth"; import { prisma } from "@dub/prisma"; import { ACME_PROGRAM_ID } from "@dub/utils"; import { NextResponse } from "next/server"; import { assertE2EWorkspace } from "../../guard"; // PATCH /api/e2e/workflows/[workflowId] - Update workflow (e.g., disable) export const PATCH = withWorkspace( async ({ req, params, workspace }) => { assertE2EWorkspace(workspace); const { workflowId } = params; const body = await req.json(); const workflow = await prisma.workflow.update({ where: { id: workflowId, programId: ACME_PROGRAM_ID }, data: { disabledAt: body.disabledAt ? new Date(body.disabledAt) : null, }, select: { id: true, disabledAt: true, }, }); return NextResponse.json(workflow); }, { requiredPermissions: ["workspaces.write"], }, ); ================================================ FILE: apps/web/app/(ee)/api/e2e/workflows/route.ts ================================================ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; import { assertE2EWorkspace } from "../guard"; // GET /api/e2e/workflows - Find workflow by bountyId, campaignId, or groupId export const GET = withWorkspace(async ({ workspace, searchParams }) => { assertE2EWorkspace(workspace); const programId = getDefaultProgramIdOrThrow(workspace); const { bountyId, campaignId, groupId } = searchParams; const workflow = await prisma.workflow.findFirst({ where: { programId, ...(bountyId && { bounty: { id: bountyId } }), ...(campaignId && { campaign: { id: campaignId } }), ...(groupId && { partnerGroup: { id: groupId } }), }, select: { id: true, trigger: true, actions: true, triggerConditions: true, disabledAt: true, }, }); return NextResponse.json(workflow); }); ================================================ FILE: apps/web/app/(ee)/api/email-domains/[domain]/route.ts ================================================ import { CAMPAIGN_ACTIVE_STATUSES } from "@/lib/api/campaigns/constants"; import { getEmailDomainOrThrow } from "@/lib/api/domains/get-email-domain-or-throw"; import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { qstash } from "@/lib/cron"; import { EmailDomainSchema, updateEmailDomainBodySchema, } from "@/lib/zod/schemas/email-domains"; import { resend } from "@dub/email/resend"; import { prisma } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // PATCH /api/email-domains/[domain] - update an email domain export const PATCH = withWorkspace( async ({ workspace, params, req }) => { const { domain } = params; const programId = getDefaultProgramIdOrThrow(workspace); const { slug } = updateEmailDomainBodySchema.parse( await parseRequestBody(req), ); const emailDomain = await getEmailDomainOrThrow({ programId, domain, }); const domainChanged = slug && slug !== emailDomain.slug; // Prevent updating verified domains that have active campaigns if (domainChanged) { const activeCampaignsCount = await prisma.campaign.count({ where: { programId, status: { in: CAMPAIGN_ACTIVE_STATUSES, }, from: { endsWith: `@${emailDomain.slug}`, }, }, }); if (activeCampaignsCount > 0) { throw new DubApiError({ code: "bad_request", message: `There are active campaigns using this email domain. You can not update it until all campaigns are completed or paused.`, }); } } let resendDomainId: string | undefined; if (domainChanged) { if (!resend) { throw new DubApiError({ code: "internal_server_error", message: "Resend is not configured.", }); } if (emailDomain.resendDomainId) { await resend.domains.remove(emailDomain.resendDomainId); } const { data: resendDomain, error } = await resend.domains.create({ name: slug, }); if (error) { throw new DubApiError({ code: "unprocessable_entity", message: error.message, }); } resendDomainId = resendDomain.id; waitUntil( (async () => { // Moving the updates to Qstash because updating the domain immediately after creation can fail. const response = await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/email-domains/update`, method: "POST", delay: 1 * 60, // 1 minute delay body: { domainId: emailDomain.id, }, }); if (!response.messageId) { console.error( `Failed to queue email domain update for domain ${emailDomain.id}`, response, ); } else { console.log( `Queued email domain update for domain ${emailDomain.id}`, response, ); } })(), ); } try { const updatedEmailDomain = await prisma.emailDomain.update({ where: { id: emailDomain.id, }, data: { slug, ...(domainChanged && { resendDomainId, status: "pending", }), }, }); return NextResponse.json(EmailDomainSchema.parse(updatedEmailDomain)); } catch (error) { console.error(error); if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002" ) { throw new DubApiError({ code: "conflict", message: `This ${slug} domain has been registered already by another program.`, }); } throw new DubApiError({ code: "internal_server_error", message: error.message, }); } }, { requiredPlan: ["advanced", "enterprise"], requiredPermissions: ["domains.write"], }, ); // DELETE /api/email-domains/[domain] - delete an email domain export const DELETE = withWorkspace( async ({ workspace, params }) => { const { domain } = params; const programId = getDefaultProgramIdOrThrow(workspace); const emailDomain = await getEmailDomainOrThrow({ programId, domain, }); // Check if any active campaigns use this domain const activeCampaignsCount = await prisma.campaign.count({ where: { programId, status: { in: CAMPAIGN_ACTIVE_STATUSES, }, from: { endsWith: `@${emailDomain.slug}`, }, }, }); if (activeCampaignsCount > 0) { throw new DubApiError({ code: "bad_request", message: `There are active campaigns using this email domain. You can not delete it until all campaigns are completed or paused.`, }); } await prisma.emailDomain.delete({ where: { id: emailDomain.id, }, }); if (emailDomain.resendDomainId && resend) { await resend.domains.remove(emailDomain.resendDomainId); } return NextResponse.json({ id: emailDomain.id }); }, { requiredPlan: ["advanced", "enterprise"], requiredPermissions: ["domains.write"], }, ); ================================================ FILE: apps/web/app/(ee)/api/email-domains/[domain]/verify/route.ts ================================================ import { getEmailDomainOrThrow } from "@/lib/api/domains/get-email-domain-or-throw"; import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { resend } from "@dub/email/resend"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/email-domains/[domain]/verify - verify an email domain export const GET = withWorkspace( async ({ workspace, params }) => { const { domain } = params; const programId = getDefaultProgramIdOrThrow(workspace); const emailDomain = await getEmailDomainOrThrow({ programId, domain, }); if (!resend) { throw new DubApiError({ code: "internal_server_error", message: "Resend is not configured.", }); } if (!emailDomain.resendDomainId) { throw new DubApiError({ code: "not_found", message: "Resend domain ID is not found for this domain.", }); } const [verificationResponse, domainResponse] = await Promise.all([ resend.domains.verify(emailDomain.resendDomainId), resend.domains.get(emailDomain.resendDomainId), ]); if (verificationResponse.error || domainResponse.error) { throw new DubApiError({ code: "internal_server_error", message: verificationResponse.error?.message || domainResponse.error?.message || "Failed to verify email domain. Please try again later.", }); } if (emailDomain.status !== domainResponse.data.status) { await prisma.emailDomain.update({ where: { id: emailDomain.id, }, data: { status: domainResponse.data.status, lastChecked: new Date(), }, }); } return NextResponse.json(domainResponse.data); }, { requiredPlan: ["advanced", "enterprise"], requiredPermissions: ["domains.read"], }, ); ================================================ FILE: apps/web/app/(ee)/api/email-domains/route.ts ================================================ import { createId } from "@/lib/api/create-id"; import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { qstash } from "@/lib/cron"; import { createEmailDomainBodySchema, EmailDomainSchema, } from "@/lib/zod/schemas/email-domains"; import { resend } from "@dub/email/resend"; import { prisma } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/email-domains - get all email domains for a program export const GET = withWorkspace( async ({ workspace }) => { const programId = getDefaultProgramIdOrThrow(workspace); const emailDomains = await prisma.emailDomain.findMany({ where: { programId, }, }); return NextResponse.json(z.array(EmailDomainSchema).parse(emailDomains)); }, { requiredPlan: ["advanced", "enterprise"], requiredPermissions: ["domains.read"], }, ); // POST /api/email-domains - create an email domain export const POST = withWorkspace( async ({ workspace, req }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { slug } = createEmailDomainBodySchema.parse( await parseRequestBody(req), ); const existingEmailDomain = await prisma.emailDomain.findFirst({ where: { programId, }, }); if (existingEmailDomain) { throw new DubApiError({ code: "conflict", message: "An email domain has already been configured for this program. Each program can only have one email domain.", }); } if (!resend) { throw new DubApiError({ code: "internal_server_error", message: "Resend is not configured.", }); } const { data: resendDomain, error: resendError } = await resend.domains.create({ name: slug, }); if (resendError) { throw new DubApiError({ code: "unprocessable_entity", message: resendError.message, }); } try { const emailDomain = await prisma.emailDomain.create({ data: { id: createId({ prefix: "dom_" }), workspaceId: workspace.id, programId, slug, resendDomainId: resendDomain.id, }, }); waitUntil( (async () => { // Moving the updates to Qstash because updating the domain immediately after creation can fail. const response = await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/email-domains/update`, method: "POST", delay: 1 * 60, // 1 minute delay body: { domainId: emailDomain.id, }, }); if (!response.messageId) { console.error( `Failed to queue email domain update for domain ${emailDomain.id}`, response, ); } else { console.log( `Queued email domain update for domain ${emailDomain.id}`, response, ); } })(), ); return NextResponse.json(EmailDomainSchema.parse(emailDomain), { status: 201, }); } catch (error) { // Cleanup to avoid orphaned Resend domains waitUntil(resend.domains.remove(resendDomain.id)); if (error instanceof Prisma.PrismaClientKnownRequestError) { if (error.code === "P2002") { throw new DubApiError({ code: "conflict", message: `This ${slug} domain has been registered already by another program.`, }); } } throw new DubApiError({ code: "internal_server_error", message: error.message, }); } }, { requiredPlan: ["advanced", "enterprise"], requiredPermissions: ["domains.write"], }, ); ================================================ FILE: apps/web/app/(ee)/api/embed/referrals/analytics/route.ts ================================================ import { getAnalytics } from "@/lib/analytics/get-analytics"; import { withReferralsEmbedToken } from "@/lib/embed/referrals/auth"; import { parseFilterValue } from "@dub/utils"; import { NextResponse } from "next/server"; // GET /api/embed/referrals/analytics – get timeseries analytics for a partner export const GET = withReferralsEmbedToken(async ({ links, program }) => { if (links.length === 0) { return NextResponse.json([]); } const analytics = await getAnalytics({ event: "composite", groupBy: "timeseries", interval: "1y", linkId: parseFilterValue(links.map((link) => link.id)), dataAvailableFrom: program.startedAt ?? program.createdAt, }); return NextResponse.json(analytics); }); ================================================ FILE: apps/web/app/(ee)/api/embed/referrals/earnings/route.ts ================================================ import { obfuscateCustomerEmail } from "@/lib/api/partner-profile/obfuscate-customer-email"; import { REFERRALS_EMBED_EARNINGS_LIMIT } from "@/lib/constants/misc"; import { withReferralsEmbedToken } from "@/lib/embed/referrals/auth"; import { generateRandomName } from "@/lib/names"; import { PartnerEarningsSchema } from "@/lib/zod/schemas/partner-profile"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/embed/referrals/earnings – get commissions for a partner from an embed token export const GET = withReferralsEmbedToken( async ({ programEnrollment, searchParams }) => { const { page } = z .object({ page: z.coerce.number().optional().default(1) }) .parse(searchParams); const earnings = await prisma.commission.findMany({ where: { earnings: { gt: 0, }, programId: programEnrollment.programId, partnerId: programEnrollment.partnerId, }, include: { customer: { select: { id: true, email: true, name: true, }, }, link: { select: { id: true, shortLink: true, url: true, }, }, }, take: REFERRALS_EMBED_EARNINGS_LIMIT, skip: (page - 1) * REFERRALS_EMBED_EARNINGS_LIMIT, orderBy: { createdAt: "desc", }, }); return NextResponse.json( z.array(PartnerEarningsSchema).parse( earnings.map((e) => ({ ...e, customer: e.customer ? { ...e.customer, email: e.customer.email ? programEnrollment.customerDataSharingEnabledAt ? e.customer.email : obfuscateCustomerEmail(e.customer.email) : e.customer.name || generateRandomName(), } : null, })), ), ); }, ); ================================================ FILE: apps/web/app/(ee)/api/embed/referrals/leaderboard/route.ts ================================================ import { withReferralsEmbedToken } from "@/lib/embed/referrals/auth"; import { generateRandomName } from "@/lib/names"; import { LeaderboardPartnerSchema } from "@/lib/zod/schemas/partners"; import { prisma } from "@dub/prisma"; import { OG_AVATAR_URL } from "@dub/utils"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/embed/referrals/leaderboard – get leaderboard for a program export const GET = withReferralsEmbedToken(async ({ program }) => { const partners = await prisma.programEnrollment.findMany({ where: { programId: program.id, status: "approved", totalCommissions: { gt: 0, }, }, orderBy: { totalCommissions: "desc", }, take: 100, }); const response = partners.map((partner) => ({ id: partner.id, name: generateRandomName(partner.id), image: `${OG_AVATAR_URL}${partner.id}`, totalCommissions: Number(partner.totalCommissions), })); // if less than 20, append some dummy data if (response.length < 20) { response.push( ...Array.from({ length: 20 - response.length }).map((_, index) => { const randomName = generateRandomName(index.toString()); return { id: randomName, name: randomName, image: `${OG_AVATAR_URL}${randomName}`, totalCommissions: 0, }; }), ); } return NextResponse.json(z.array(LeaderboardPartnerSchema).parse(response)); }); ================================================ FILE: apps/web/app/(ee)/api/embed/referrals/links/[linkId]/route.ts ================================================ import { DubApiError, ErrorCodes } from "@/lib/api/errors"; import { processLink, updateLink } from "@/lib/api/links"; import { validatePartnerLinkUrl } from "@/lib/api/links/validate-partner-link-url"; import { parseRequestBody } from "@/lib/api/utils"; import { withReferralsEmbedToken } from "@/lib/embed/referrals/auth"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { linkEventSchema } from "@/lib/zod/schemas/links"; import { createPartnerLinkSchema, INACTIVE_ENROLLMENT_STATUSES, } from "@/lib/zod/schemas/partners"; import { ReferralsEmbedLinkSchema } from "@/lib/zod/schemas/referrals-embed"; import { prisma } from "@dub/prisma"; import { getPrettyUrl } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // PATCH /api/embed/referrals/links/[linkId] - update a link for a partner export const PATCH = withReferralsEmbedToken( async ({ req, params, programEnrollment, program, links, group }) => { const { url, key } = createPartnerLinkSchema .pick({ url: true, key: true }) .parse(await parseRequestBody(req)); if (INACTIVE_ENROLLMENT_STATUSES.includes(programEnrollment.status)) { throw new DubApiError({ code: "forbidden", message: `You are ${programEnrollment.status} from this program hence cannot create links.`, }); } const link = links.find((link) => link.id === params.linkId); if (!link) { throw new DubApiError({ code: "not_found", message: "Link not found.", }); } if (!program.domain || !program.url) { throw new DubApiError({ code: "bad_request", message: "This program needs a domain and URL set before creating a link.", }); } if (link.partnerGroupDefaultLinkId) { const linkUrlChanged = getPrettyUrl(link.url) !== getPrettyUrl(url); if (linkUrlChanged) { throw new DubApiError({ code: "forbidden", message: "You cannot update the destination URL of your default link.", }); } } validatePartnerLinkUrl({ group, url }); // if domain and key are the same, we don't need to check if the key exists const skipKeyChecks = link.key.toLowerCase() === key?.toLowerCase(); const { link: processedLink, error, code, } = await processLink({ // @ts-expect-error payload: { ...link, key: key || undefined, url: url || program.url, }, workspace: { id: program.workspaceId, plan: "business", users: [{ role: "owner" }], }, userId: link.userId!, skipKeyChecks, skipFolderChecks: true, // can't be changed by the partner skipProgramChecks: true, // can't be changed by the partner skipExternalIdChecks: true, // can't be changed by the partner }); if (error != null) { throw new DubApiError({ code: code as ErrorCodes, message: error, }); } const partnerLink = await updateLink({ oldLink: { domain: link.domain, key: link.key, image: link.image, }, updatedLink: processedLink, }); waitUntil( (async () => { const workspace = await prisma.project.findUnique({ where: { id: program.workspaceId, }, select: { id: true, webhookEnabled: true, }, }); if (workspace) { await sendWorkspaceWebhook({ trigger: "link.updated", workspace, data: linkEventSchema.parse(partnerLink), }); } })(), ); return NextResponse.json(ReferralsEmbedLinkSchema.parse(partnerLink)); }, ); ================================================ FILE: apps/web/app/(ee)/api/embed/referrals/links/route.ts ================================================ import { DubApiError, ErrorCodes } from "@/lib/api/errors"; import { createLink, processLink } from "@/lib/api/links"; import { validatePartnerLinkUrl } from "@/lib/api/links/validate-partner-link-url"; import { parseRequestBody } from "@/lib/api/utils"; import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; import { withReferralsEmbedToken } from "@/lib/embed/referrals/auth"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { linkEventSchema } from "@/lib/zod/schemas/links"; import { createPartnerLinkSchema } from "@/lib/zod/schemas/partners"; import { ReferralsEmbedLinkSchema } from "@/lib/zod/schemas/referrals-embed"; import { prisma } from "@dub/prisma"; import { getUTMParamsFromURL } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // GET /api/embed/referrals/links – get links for a partner export const GET = withReferralsEmbedToken(async ({ links }) => { const partnerLinks = ReferralsEmbedLinkSchema.array().parse(links); return NextResponse.json(partnerLinks); }); // POST /api/embed/referrals/links – create links for a partner export const POST = withReferralsEmbedToken( async ({ req, programEnrollment, program, links, group }) => { const { url, key } = createPartnerLinkSchema .pick({ url: true, key: true }) .parse(await parseRequestBody(req)); if (["banned", "deactivated"].includes(programEnrollment.status)) { throw new DubApiError({ code: "forbidden", message: `You are ${programEnrollment.status} from this program hence cannot create links.`, }); } if (!program.domain || !program.url) { throw new DubApiError({ code: "bad_request", message: "This program needs a domain and URL set before creating a link.", }); } if (links.length >= group.maxPartnerLinks) { throw new DubApiError({ code: "bad_request", message: `You have reached the limit of ${group.maxPartnerLinks} program links.`, }); } validatePartnerLinkUrl({ group, url }); const [workspaceOwner, partnerGroup] = await Promise.all([ prisma.projectUsers.findFirst({ where: { projectId: program.workspaceId, role: "owner", }, orderBy: { createdAt: "desc", }, include: { project: { select: { id: true, webhookEnabled: true, }, }, }, }), prisma.partnerGroup.findUnique({ where: { id: group.id, }, include: { partnerGroupDefaultLinks: true, utmTemplate: true, }, }), ]); // shouldn't happen but just in case if (!partnerGroup) { throw new DubApiError({ code: "not_found", message: "This partner is not part of a partner group.", }); } const linkUrl = url || partnerGroup.partnerGroupDefaultLinks[0].url; const { link, error, code } = await processLink({ payload: { key: key || undefined, url: linkUrl, ...(partnerGroup.utmTemplate ? { ...extractUtmParams(partnerGroup.utmTemplate), ...getUTMParamsFromURL(linkUrl), } : {}), domain: program.domain, programId: program.id, folderId: program.defaultFolderId, tenantId: programEnrollment.tenantId, partnerId: programEnrollment.partnerId, trackConversion: true, }, workspace: { id: program.workspaceId, plan: "business", users: [{ role: "owner" }], }, userId: workspaceOwner?.userId, skipFolderChecks: true, // can't be changed by the partner skipProgramChecks: true, // can't be changed by the partner skipExternalIdChecks: true, // can't be changed by the partner }); if (error != null) { throw new DubApiError({ code: code as ErrorCodes, message: error, }); } const partnerLink = await createLink(link); // this should always be present but just in case const workspace = workspaceOwner?.project; if (workspace) { waitUntil( sendWorkspaceWebhook({ trigger: "link.created", workspace, data: linkEventSchema.parse(partnerLink), }), ); } return NextResponse.json(ReferralsEmbedLinkSchema.parse(partnerLink), { status: 201, }); }, ); ================================================ FILE: apps/web/app/(ee)/api/embed/referrals/token/route.ts ================================================ import { withReferralsEmbedToken } from "@/lib/embed/referrals/auth"; import { NextResponse } from "next/server"; // GET /api/embed/referrals/token - get the referrals embed token export const GET = withReferralsEmbedToken(async ({ embedToken }) => { return NextResponse.json(embedToken); }); ================================================ FILE: apps/web/app/(ee)/api/events/export/route.ts ================================================ import { eventsExportColumnAccessors, eventsExportColumnNames, } from "@/lib/analytics/events-export-helpers"; import { getFirstFilterValue } from "@/lib/analytics/filter-helpers"; import { getAnalytics } from "@/lib/analytics/get-analytics"; import { getEvents } from "@/lib/analytics/get-events"; import { convertToCSV } from "@/lib/analytics/utils"; import { getLinkOrThrow } from "@/lib/api/links/get-link-or-throw"; import { throwIfClicksUsageExceeded } from "@/lib/api/links/usage-checks"; import { assertValidDateRangeForPlan } from "@/lib/api/utils/assert-valid-date-range-for-plan"; import { withWorkspace } from "@/lib/auth"; import { qstash } from "@/lib/cron"; import { verifyFolderAccess } from "@/lib/folder/permissions"; import { eventsQuerySchema } from "@/lib/zod/schemas/analytics"; import { APP_DOMAIN_WITH_NGROK, capitalize } from "@dub/utils"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; const MAX_EVENTS_TO_EXPORT = 1000; const exportQuerySchema = z .object({ columns: z .string() .transform((c) => c.split(",")) .pipe(z.string().array()), }) .passthrough(); // GET /api/events/export – export events to CSV (with async support if >1000 events) export const GET = withWorkspace( async ({ searchParams, workspace, session }) => { throwIfClicksUsageExceeded(workspace); const parsedParams = eventsQuerySchema.parse(searchParams); const { columns } = exportQuerySchema.parse(searchParams); let { event, interval, start, end, folderId, domain, key, linkId, externalId, } = parsedParams; let folderIdToVerify = getFirstFilterValue(folderId); if (!linkId && (externalId || (domain && key))) { const link = await getLinkOrThrow({ workspaceId: workspace.id, linkId, externalId, domain: getFirstFilterValue(domain), key, }); parsedParams.linkId = { operator: "IS", sqlOperator: "IN", values: [link.id], }; // since we're filtering for a specific link, exclude domain from filters parsedParams.domain = undefined; if (link.folderId && !folderIdToVerify) { folderIdToVerify = link.folderId; } } if (folderIdToVerify) { await verifyFolderAccess({ workspace, userId: session.user.id, folderId: folderIdToVerify, requiredPermission: "folders.read", }); } assertValidDateRangeForPlan({ plan: workspace.plan, dataAvailableFrom: workspace.createdAt, interval, start, end, }); // Count events using getAnalytics with groupBy: "count" const countResponse = await getAnalytics({ ...parsedParams, groupBy: "count", workspaceId: workspace.id, dataAvailableFrom: workspace.createdAt, }); // Extract the count based on event type // getAnalytics with groupBy: "count" returns an object like { clicks: 123 } or { leads: 45 } or { sales: 10, saleAmount: 5000 } const eventsCount = typeof countResponse === "object" && countResponse !== null ? (countResponse[event as keyof typeof countResponse] as number) ?? 0 : typeof countResponse === "number" ? countResponse : 0; // Process the export in the background if the number of events is greater than MAX_EVENTS_TO_EXPORT if (eventsCount > MAX_EVENTS_TO_EXPORT) { await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/export/events/workspace`, body: { ...searchParams, workspaceId: workspace.id, userId: session.user.id, }, }); return NextResponse.json({}, { status: 202 }); } const response = await getEvents({ ...parsedParams, event, workspaceId: workspace.id, limit: MAX_EVENTS_TO_EXPORT, }); const data = response.map((row) => Object.fromEntries( columns.map((c) => [ eventsExportColumnNames?.[c] ?? capitalize(c), eventsExportColumnAccessors[c]?.(row) ?? row?.[c], ]), ), ); const csvData = convertToCSV(data); return new Response(csvData, { headers: { "Content-Type": "application/csv", "Content-Disposition": `attachment; filename=${event}_export.csv`, }, }); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], requiredPermissions: ["analytics.read"], }, ); ================================================ FILE: apps/web/app/(ee)/api/events/route.ts ================================================ import { getFirstFilterValue } from "@/lib/analytics/filter-helpers"; import { getEvents } from "@/lib/analytics/get-events"; import { getLinkOrThrow } from "@/lib/api/links/get-link-or-throw"; import { throwIfClicksUsageExceeded } from "@/lib/api/links/usage-checks"; import { assertValidDateRangeForPlan } from "@/lib/api/utils/assert-valid-date-range-for-plan"; import { withWorkspace } from "@/lib/auth"; import { verifyFolderAccess } from "@/lib/folder/permissions"; import { eventsQuerySchema } from "@/lib/zod/schemas/analytics"; import { NextResponse } from "next/server"; // GET /api/events export const GET = withWorkspace( async ({ searchParams, workspace, session }) => { throwIfClicksUsageExceeded(workspace); const parsedParams = eventsQuerySchema.parse(searchParams); let { event, interval, start, end, folderId, domain, key, linkId, externalId, } = parsedParams; let folderIdToVerify = getFirstFilterValue(folderId); if (!linkId && (externalId || (domain && key))) { const link = await getLinkOrThrow({ workspaceId: workspace.id, linkId, externalId, domain: getFirstFilterValue(domain), key, }); parsedParams.linkId = { operator: "IS", sqlOperator: "IN", values: [link.id], }; // since we're filtering for a specific link, exclude domain from filters parsedParams.domain = undefined; if (link.folderId && !folderIdToVerify) { folderIdToVerify = link.folderId; } } if (folderIdToVerify) { await verifyFolderAccess({ workspace, userId: session.user.id, folderId: folderIdToVerify, requiredPermission: "folders.read", }); } assertValidDateRangeForPlan({ plan: workspace.plan, dataAvailableFrom: workspace.createdAt, interval, start, end, }); console.time("getEvents"); const response = await getEvents({ ...parsedParams, event, workspaceId: workspace.id, }); console.timeEnd("getEvents"); return NextResponse.json(response); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], requiredPermissions: ["analytics.read"], }, ); ================================================ FILE: apps/web/app/(ee)/api/fraud/events/count/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { fraudEventCountQuerySchema } from "@/lib/zod/schemas/fraud"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/fraud/events/count - Get the count of fraud events export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { groupId } = fraudEventCountQuerySchema.parse(searchParams); const fraudGroup = await prisma.fraudEventGroup.findUnique({ where: { id: groupId, }, select: { programId: true, partnerId: true, type: true, }, }); if (!fraudGroup) { throw new DubApiError({ code: "not_found", message: "Fraud event group not found.", }); } if (fraudGroup.programId !== programId) { throw new DubApiError({ code: "not_found", message: "Fraud event group not found in this program.", }); } const count = await prisma.fraudEvent.count({ where: { fraudEventGroupId: groupId, }, }); return NextResponse.json(count); }, { requiredPlan: ["advanced", "enterprise"], }, ); ================================================ FILE: apps/web/app/(ee)/api/fraud/events/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { fraudEventQuerySchema, fraudEventSchemas, } from "@/lib/zod/schemas/fraud"; import { prisma } from "@dub/prisma"; import { FraudRuleType, Prisma } from "@dub/prisma/client"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/fraud/events - Get the fraud events for a group export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { page = 1, pageSize, ...queryParams } = fraudEventQuerySchema.parse(searchParams); let where: Prisma.FraudEventWhereInput = {}; let eventGroupType: FraudRuleType | undefined; // Filter by group ID if ("groupId" in queryParams) { const { groupId } = queryParams; const fraudGroup = await prisma.fraudEventGroup.findUnique({ where: { id: groupId, }, select: { programId: true, partnerId: true, type: true, }, }); if (!fraudGroup) { throw new DubApiError({ code: "not_found", message: "Fraud event group not found.", }); } if (fraudGroup.programId !== programId) { throw new DubApiError({ code: "not_found", message: "Fraud event group not found in this program.", }); } where = { fraudEventGroupId: groupId, }; eventGroupType = fraudGroup.type; } // Filter by customer ID and type // Currently this is only used in E2E tests to fetch raw fraud events for a given customer + type if ("customerId" in queryParams && "type" in queryParams) { const { customerId, type } = queryParams; where = { customerId, fraudEventGroup: { programId, type, }, }; eventGroupType = type; } if (!eventGroupType) { throw new DubApiError({ code: "not_found", message: "Fraud event group type not found.", }); } const zodSchema = fraudEventSchemas[eventGroupType]; const fraudEvents = await prisma.fraudEvent.findMany({ where, include: { partner: true, customer: true, }, orderBy: { id: "desc", }, skip: (page - 1) * pageSize, take: pageSize, }); return NextResponse.json(z.array(zodSchema).parse(fraudEvents)); }, { requiredPlan: ["advanced", "enterprise"], }, ); ================================================ FILE: apps/web/app/(ee)/api/fraud/groups/count/route.ts ================================================ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { fraudGroupCountQuerySchema, fraudGroupCountSchema, } from "@/lib/zod/schemas/fraud"; import { prisma } from "@dub/prisma"; import { FraudRuleType, Prisma } from "@dub/prisma/client"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/fraud/groups/count - get the count of fraud event groups for a program export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { status, type, partnerId, groupId, groupBy } = fraudGroupCountQuerySchema.parse(searchParams); const commonWhere: Prisma.FraudEventGroupWhereInput = { programId, ...(status && { status }), ...(type && { type }), ...(partnerId && { partnerId }), ...(groupId && { id: groupId }), }; // Group by type if (groupBy === "type") { const fraudGroups = await prisma.fraudEventGroup.groupBy({ by: ["type"], where: { ...commonWhere, type: undefined, }, _count: true, orderBy: { _count: { type: "desc", }, }, }); Object.values(FraudRuleType).forEach((type) => { if (!fraudGroups.some((e) => e.type === type)) { fraudGroups.push({ _count: 0, type }); } }); return NextResponse.json( z.array(fraudGroupCountSchema).parse(fraudGroups), ); } // Group by partnerId if (groupBy === "partnerId") { const fraudGroups = await prisma.fraudEventGroup.groupBy({ by: ["partnerId"], where: { ...commonWhere, }, _count: true, orderBy: { _count: { partnerId: "desc", }, }, }); return NextResponse.json( z.array(fraudGroupCountSchema).parse(fraudGroups), ); } // Get the count of fraud event groups const count = await prisma.fraudEventGroup.count({ where: { ...commonWhere, }, }); return NextResponse.json(fraudGroupCountSchema.parse(count)); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], }, ); ================================================ FILE: apps/web/app/(ee)/api/fraud/groups/route.ts ================================================ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { fraudGroupQuerySchema, fraudGroupSchema, } from "@/lib/zod/schemas/fraud"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/fraud/groups - Get the fraud event groups for a program export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { status, type, partnerId, groupId, page = 1, pageSize, sortBy, sortOrder, } = fraudGroupQuerySchema.parse(searchParams); const fraudGroups = await prisma.fraudEventGroup.findMany({ where: { programId, ...(partnerId && { partnerId }), ...(status && { status }), ...(type && { type }), ...(groupId && { id: groupId }), }, include: { partner: { select: { id: true, name: true, email: true, image: true, }, }, programEnrollment: { select: { status: true, }, }, user: { select: { id: true, name: true, email: true, image: true, }, }, }, skip: (page - 1) * pageSize, take: pageSize, orderBy: { [sortBy]: sortOrder, }, }); // Transform data to merge programEnrollment.status into partner object const transformedGroups = fraudGroups.map((group) => ({ ...group, partner: { ...group.partner, status: group.programEnrollment.status, }, })); return NextResponse.json( z.array(fraudGroupSchema).parse(transformedGroups), ); }, { requiredPlan: ["advanced", "enterprise"], }, ); ================================================ FILE: apps/web/app/(ee)/api/fraud/rules/route.ts ================================================ import { createId } from "@/lib/api/create-id"; import { CONFIGURABLE_FRAUD_RULES } from "@/lib/api/fraud/constants"; import { resolveFraudGroups } from "@/lib/api/fraud/resolve-fraud-groups"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { updateFraudRuleSettingsSchema } from "@/lib/zod/schemas/fraud"; import { prisma } from "@dub/prisma"; import { FraudRuleType, Prisma } from "@dub/prisma/client"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; const defaultFraudRuleOverrides: Partial< Record > = { paidTrafficDetected: { enabled: true, config: { platforms: ["google"], google: { whitelistedCampaignIds: [], }, }, }, referralSourceBanned: { enabled: false, config: {}, }, }; // GET /api/fraud/rules export const GET = withWorkspace( async ({ workspace }) => { const programId = getDefaultProgramIdOrThrow(workspace); const fraudRules = await prisma.fraudRule.findMany({ where: { programId, }, select: { type: true, config: true, disabledAt: true, }, }); const mergedFraudRules = CONFIGURABLE_FRAUD_RULES.map(({ type }) => { const fraudRule = fraudRules.find((f) => f.type === type); // If the rule is not found, default it to the expected value if (!fraudRule) { const defaults = defaultFraudRuleOverrides[type]; return { type, enabled: defaults?.enabled ?? true, config: defaults?.config ?? {}, }; } return { type, enabled: fraudRule.disabledAt === null, config: fraudRule.config ?? {}, }; }); return NextResponse.json(mergedFraudRules); }, { requiredPlan: ["advanced", "enterprise"], }, ); // PATCH /api/fraud/rules - update fraud rules for a program export const PATCH = withWorkspace( async ({ workspace, req, session }) => { const programId = getDefaultProgramIdOrThrow(workspace); const parsed = updateFraudRuleSettingsSchema.parse( await parseRequestBody(req), ); const rulesToUpdate = CONFIGURABLE_FRAUD_RULES.map(({ type }) => ({ type: type as FraudRuleType, payload: parsed[type], })).filter((r) => r.payload); for (const { type, payload } of rulesToUpdate) { if (!payload) continue; const config = "config" in payload ? payload.config ?? Prisma.DbNull : Prisma.DbNull; await prisma.fraudRule.upsert({ where: { programId_type: { programId, type, }, }, create: { id: createId({ prefix: "fr_" }), programId, type, config, disabledAt: payload.enabled ? null : new Date(), }, update: { config, disabledAt: payload.enabled ? null : new Date(), }, }); } waitUntil( (async () => { const ruleTypesToResolve = rulesToUpdate .filter( (r) => r.payload?.enabled === false && r.payload?.resolvePendingEvents === true, ) .map((r) => r.type); if (ruleTypesToResolve.length > 0) { await resolveFraudGroups({ where: { programId, type: { in: ruleTypesToResolve, }, }, userId: session.user.id, resolutionReason: "Resolved automatically because the fraud rule was disabled.", }); } })(), ); return NextResponse.json({ success: true }); }, { requiredPlan: ["advanced", "enterprise"], requiredRoles: ["owner", "member"], }, ); ================================================ FILE: apps/web/app/(ee)/api/groups/[groupIdOrSlug]/default/route.ts ================================================ import { getGroupOrThrow } from "@/lib/api/groups/get-group-or-throw"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { DEFAULT_PARTNER_GROUP, GroupSchema } from "@/lib/zod/schemas/groups"; import { RESOURCE_COLORS } from "@/ui/colors"; import { prisma } from "@dub/prisma"; import { nanoid, randomValue } from "@dub/utils"; import slugify from "@sindresorhus/slugify"; import { waitUntil } from "@vercel/functions"; import { revalidatePath } from "next/cache"; import { NextResponse } from "next/server"; // POST /api/groups/[groupIdOrSlug]/default – set a group as default export const POST = withWorkspace( async ({ params, workspace }) => { const programId = getDefaultProgramIdOrThrow(workspace); const [group, currentDefaultGroup] = await Promise.all([ getGroupOrThrow({ programId, groupId: params.groupIdOrSlug, }), prisma.partnerGroup.findUniqueOrThrow({ where: { programId_slug: { programId, slug: DEFAULT_PARTNER_GROUP.slug, }, }, include: { program: { select: { slug: true, }, }, }, }), ]); // return the current default group if it's already the default group if (group.id === currentDefaultGroup.id) { return NextResponse.json(GroupSchema.parse(currentDefaultGroup)); } const updatedGroup = await prisma.$transaction(async (tx) => { const DEFAULT_GROUP_NAME_OLD = "Default Group (old)"; const isStandardDefaultGroupName = currentDefaultGroup.name.toLowerCase() === DEFAULT_PARTNER_GROUP.name.toLowerCase(); // set current default group's slug to a slugified version of its name // and assign a random color if it doesn't have one await tx.partnerGroup.update({ where: { programId_slug: { programId, slug: DEFAULT_PARTNER_GROUP.slug, }, }, data: { name: isStandardDefaultGroupName ? DEFAULT_GROUP_NAME_OLD : undefined, slug: `${ isStandardDefaultGroupName ? "old-default-group" : slugify(currentDefaultGroup.name) }-${nanoid(4)}`, color: currentDefaultGroup.color === DEFAULT_PARTNER_GROUP.color ? randomValue(RESOURCE_COLORS) : undefined, }, }); await tx.program.update({ where: { id: programId, }, data: { defaultGroupId: group.id, }, }); return await tx.partnerGroup.update({ where: { id: group.id, }, data: { name: group.name === DEFAULT_GROUP_NAME_OLD ? DEFAULT_PARTNER_GROUP.name : undefined, slug: DEFAULT_PARTNER_GROUP.slug, color: DEFAULT_PARTNER_GROUP.color, }, }); }); const programSlug = currentDefaultGroup.program.slug; // need to revalidate the program's cached public pages waitUntil( Promise.allSettled([ revalidatePath(`/partners.dub.co/${programSlug}`), revalidatePath(`/partners.dub.co/${programSlug}/apply`), revalidatePath(`/partners.dub.co/${programSlug}/apply/success`), ]), ); return NextResponse.json(GroupSchema.parse(updatedGroup)); }, { requiredPermissions: ["groups.write"], requiredPlan: [ "business", "business extra", "business max", "business plus", "advanced", "enterprise", ], }, ); ================================================ FILE: apps/web/app/(ee)/api/groups/[groupIdOrSlug]/default-links/[defaultLinkId]/route.ts ================================================ import { queueDomainUpdate } from "@/lib/api/domains/queue-domain-update"; import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; import { withWorkspace } from "@/lib/auth"; import { qstash } from "@/lib/cron"; import { createOrUpdateDefaultLinkSchema, PartnerGroupDefaultLinkSchema, } from "@/lib/zod/schemas/groups"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, constructURLFromUTMParams } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // PATCH /api/groups/[groupIdOrSlug]/default-links/[defaultLinkId] - update a default link for a group export const PATCH = withWorkspace( async ({ workspace, req, params }) => { const { groupIdOrSlug } = params; const programId = getDefaultProgramIdOrThrow(workspace); const { domain, url } = createOrUpdateDefaultLinkSchema.parse( await parseRequestBody(req), ); const group = await prisma.partnerGroup.findUniqueOrThrow({ where: { ...(groupIdOrSlug.startsWith("grp_") ? { id: groupIdOrSlug, } : { programId_slug: { programId, slug: groupIdOrSlug, }, }), programId, }, include: { utmTemplate: true, partnerGroupDefaultLinks: { where: { id: params.defaultLinkId, }, }, program: { select: { domain: true, }, }, }, }); if (group.partnerGroupDefaultLinks.length === 0) { throw new DubApiError({ code: "bad_request", message: `Default link ${params.defaultLinkId} not found for this group.`, }); } const defaultLink = group.partnerGroupDefaultLinks[0]; // Domain change detected, we should do the following // - Update the program's domain // - Update all default links across groups to use the new domain // - Update all partner links to use the new domain (via cron job) if (domain !== group.program.domain) { await prisma.$transaction([ prisma.program.update({ where: { id: programId, }, data: { domain, }, }), prisma.partnerGroupDefaultLink.updateMany({ where: { programId, }, data: { domain, }, }), ]); // Queue domain update for all partner links waitUntil( queueDomainUpdate({ newDomain: domain, oldDomain: defaultLink.domain, programId, }), ); } try { const updatedDefaultLink = await prisma.partnerGroupDefaultLink.update({ where: { id: defaultLink.id, }, data: { domain, url: group.utmTemplate ? constructURLFromUTMParams( url, extractUtmParams(group.utmTemplate), ) : url, }, }); if (updatedDefaultLink.url !== defaultLink.url) { waitUntil( qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/update-default-links`, body: { defaultLinkId: defaultLink.id, }, }), ); } return NextResponse.json( PartnerGroupDefaultLinkSchema.parse(updatedDefaultLink), ); } catch (error) { if (error.code === "P2002") { throw new DubApiError({ code: "conflict", message: "A default link with this URL already exists.", }); } throw new DubApiError({ code: "unprocessable_entity", message: error.message, }); } }, { requiredPermissions: ["groups.write"], requiredPlan: [ "business", "business extra", "business max", "business plus", "advanced", "enterprise", ], }, ); // DELETE /api/groups/[groupIdOrSlug]/default-links/[defaultLinkId] - delete a default link for a group export const DELETE = withWorkspace( async ({ workspace, params }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { groupIdOrSlug } = params; const group = await prisma.partnerGroup.findUniqueOrThrow({ where: { ...(groupIdOrSlug.startsWith("grp_") ? { id: groupIdOrSlug, } : { programId_slug: { programId, slug: groupIdOrSlug, }, }), programId, }, include: { partnerGroupDefaultLinks: { where: { id: params.defaultLinkId, }, }, }, }); if (group.partnerGroupDefaultLinks.length === 0) { throw new DubApiError({ code: "bad_request", message: `Default link ${params.defaultLinkId} not found for this group.`, }); } await prisma.partnerGroupDefaultLink.delete({ where: { id: group.partnerGroupDefaultLinks[0].id, }, }); return NextResponse.json({ id: group.partnerGroupDefaultLinks[0].id, }); }, { requiredPermissions: ["groups.write"], requiredPlan: [ "business", "business extra", "business max", "business plus", "advanced", "enterprise", ], }, ); ================================================ FILE: apps/web/app/(ee)/api/groups/[groupIdOrSlug]/default-links/route.ts ================================================ import { createId } from "@/lib/api/create-id"; import { DubApiError } from "@/lib/api/errors"; import { getGroupOrThrow } from "@/lib/api/groups/get-group-or-throw"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; import { withWorkspace } from "@/lib/auth"; import { qstash } from "@/lib/cron"; import { createOrUpdateDefaultLinkSchema, MAX_DEFAULT_LINKS_PER_GROUP, PartnerGroupDefaultLinkSchema, } from "@/lib/zod/schemas/groups"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, constructURLFromUTMParams } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/groups/[groupIdOrSlug]/default-links - get all default links for a group export const GET = withWorkspace( async ({ workspace, params }) => { const programId = getDefaultProgramIdOrThrow(workspace); const group = await getGroupOrThrow({ programId, groupId: params.groupIdOrSlug, }); const defaultLinks = await prisma.partnerGroupDefaultLink.findMany({ where: { groupId: group.id, }, orderBy: { createdAt: "desc", }, }); return NextResponse.json( z.array(PartnerGroupDefaultLinkSchema).parse(defaultLinks), ); }, { requiredPermissions: ["groups.read"], requiredPlan: [ "business", "business extra", "business max", "business plus", "advanced", "enterprise", ], }, ); // POST /api/groups/[groupIdOrSlug]/default-links - create a default link for a group export const POST = withWorkspace( async ({ workspace, req, params, session }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { url } = createOrUpdateDefaultLinkSchema.parse( await parseRequestBody(req), ); const group = await prisma.partnerGroup.findUniqueOrThrow({ where: { id: params.groupIdOrSlug, programId, }, include: { program: true, utmTemplate: true, }, }); // shouldn't happen but just in case if (!group.program.domain) { throw new DubApiError({ code: "bad_request", message: "This program needs a domain set before creating a default link.", }); } try { const defaultLink = await prisma.$transaction(async (tx) => { const count = await tx.partnerGroupDefaultLink.count({ where: { groupId: group.id, }, }); if (count >= MAX_DEFAULT_LINKS_PER_GROUP) { throw new DubApiError({ code: "bad_request", message: `You can't create more than ${MAX_DEFAULT_LINKS_PER_GROUP} default links for a group.`, }); } return await tx.partnerGroupDefaultLink.create({ data: { id: createId({ prefix: "pgdl_" }), programId: group.programId, groupId: group.id, domain: group.program.domain!, url: group.utmTemplate ? constructURLFromUTMParams( url, extractUtmParams(group.utmTemplate), ) : url, }, }); }); waitUntil( qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/create-default-links`, body: { defaultLinkId: defaultLink.id, userId: session.user.id, }, }), ); return NextResponse.json( PartnerGroupDefaultLinkSchema.parse(defaultLink), { status: 201, }, ); } catch (error) { if (error.code === "P2002") { throw new DubApiError({ code: "conflict", message: "A default link with this URL already exists.", }); } throw new DubApiError({ code: "unprocessable_entity", message: error.message, }); } }, { requiredPermissions: ["groups.write"], requiredPlan: [ "business", "business extra", "business max", "business plus", "advanced", "enterprise", ], }, ); ================================================ FILE: apps/web/app/(ee)/api/groups/[groupIdOrSlug]/partners/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { getGroupOrThrow } from "@/lib/api/groups/get-group-or-throw"; import { movePartnersToGroup } from "@/lib/api/groups/move-partners-to-group"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; const addPartnersToGroupSchema = z.object({ partnerIds: z.array(z.string()).min(1).max(100), // max move 100 partners at a time groupMoveDisabledAt: z.coerce.date().nullish(), }); // POST /api/groups/[groupIdOrSlug]/partners - add partners to group export const POST = withWorkspace( async ({ req, params, workspace, session }) => { const programId = getDefaultProgramIdOrThrow(workspace); const group = await getGroupOrThrow({ programId, groupId: params.groupIdOrSlug, includeExpandedFields: true, }); let { partnerIds, groupMoveDisabledAt } = addPartnersToGroupSchema.parse( await parseRequestBody(req), ); partnerIds = [...new Set(partnerIds)]; if (partnerIds.length === 0) { throw new DubApiError({ code: "bad_request", message: "At least one partner ID is required.", }); } const count = await movePartnersToGroup({ workspaceId: workspace.id, programId, partnerIds, userId: session.user.id, group, groupMoveDisabledAt, }); return NextResponse.json({ count, }); }, { requiredPermissions: ["groups.write"], requiredPlan: [ "business", "business extra", "business max", "business plus", "advanced", "enterprise", ], }, ); ================================================ FILE: apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts ================================================ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { DubApiError } from "@/lib/api/errors"; import { getGroupOrThrow } from "@/lib/api/groups/get-group-or-throw"; import { movePartnersToGroup } from "@/lib/api/groups/move-partners-to-group"; import { upsertGroupMoveRules } from "@/lib/api/groups/upsert-group-move-rules"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; import { withWorkspace } from "@/lib/auth"; import { qstash } from "@/lib/cron"; import { GroupWithProgramSchema } from "@/lib/zod/schemas/group-with-program"; import { DEFAULT_PARTNER_GROUP, GroupSchema, updateGroupSchema, } from "@/lib/zod/schemas/groups"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK, constructURLFromUTMParams } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // GET /api/groups/[groupIdOrSlug] - get information about a group export const GET = withWorkspace( async ({ params, workspace }) => { const programId = getDefaultProgramIdOrThrow(workspace); const group = await getGroupOrThrow({ programId, groupId: params.groupIdOrSlug, includeExpandedFields: true, includeBounties: true, }); return NextResponse.json(GroupWithProgramSchema.parse(group)); }, { requiredPermissions: ["groups.read"], requiredPlan: [ "business", "business extra", "business max", "business plus", "advanced", "enterprise", ], }, ); // PATCH /api/groups/[groupIdOrSlug] – update a group for a workspace export const PATCH = withWorkspace( async ({ req, params, workspace, session }) => { const programId = getDefaultProgramIdOrThrow(workspace); const group = await getGroupOrThrow({ programId, groupId: params.groupIdOrSlug, }); const { name, slug, color, maxPartnerLinks, additionalLinks, utmTemplateId, linkStructure, applicationFormData, landerData, holdingPeriodDays, autoApprovePartners, updateAutoApprovePartnersForAllGroups, updateHoldingPeriodDaysForAllGroups, moveRules, } = updateGroupSchema.parse(await parseRequestBody(req)); // Only check slug uniqueness if slug is being updated if (slug && slug.toLowerCase() !== group.slug.toLowerCase()) { if (group.slug === DEFAULT_PARTNER_GROUP.slug) { throw new DubApiError({ code: "bad_request", message: "You cannot change the slug of the default group.", }); } const existingGroup = await prisma.partnerGroup.findUnique({ where: { programId_slug: { programId, slug, }, }, }); if (existingGroup) { throw new DubApiError({ code: "bad_request", message: `Group with slug ${slug} already exists in your program.`, }); } } if (additionalLinks) { // check for duplicate link formats const linkFormatDomains = additionalLinks.reduce((acc, link) => { acc.add(link.domain); return acc; }, new Set()); if (linkFormatDomains.size !== additionalLinks.length) { throw new DubApiError({ code: "bad_request", message: "Duplicate link formats found. Please make sure all link formats have unique domains.", }); } } // Find the UTM template const utmTemplate = utmTemplateId ? await prisma.utmTemplate.findUniqueOrThrow({ where: { id: utmTemplateId, projectId: workspace.id, }, }) : null; const { workflowId } = await upsertGroupMoveRules({ workspace, group, moveRules, }); const [updatedGroup] = await Promise.all([ prisma.partnerGroup.update({ where: { id: group.id, }, data: { name, slug, color, additionalLinks, maxPartnerLinks, linkStructure, utmTemplateId, applicationFormData, landerData, workflowId, ...(holdingPeriodDays !== undefined && !updateHoldingPeriodDaysForAllGroups && { holdingPeriodDays, }), ...(autoApprovePartners !== undefined && !updateAutoApprovePartnersForAllGroups && { autoApprovePartnersEnabledAt: autoApprovePartners ? new Date() : null, }), }, include: { clickReward: true, leadReward: true, saleReward: true, discount: true, }, }), // Update auto-approve for all groups if selected ...(autoApprovePartners !== undefined && updateAutoApprovePartnersForAllGroups ? [ prisma.partnerGroup.updateMany({ where: { programId, }, data: { autoApprovePartnersEnabledAt: autoApprovePartners ? new Date() : null, }, }), ] : []), // Update holding period for all groups if selected ...(holdingPeriodDays !== undefined && updateHoldingPeriodDaysForAllGroups ? [ prisma.partnerGroup.updateMany({ where: { programId, }, data: { holdingPeriodDays, }, }), ] : []), ]); waitUntil( (async () => { const isTemplateAdded = group.utmTemplateId !== utmTemplateId; // If the UTM template is added, update the default links with the UTM parameters if (isTemplateAdded && utmTemplate) { const defaultLinks = await prisma.partnerGroupDefaultLink.findMany({ where: { groupId: group.id, }, }); if (defaultLinks.length > 0) { for (const defaultLink of defaultLinks) { await prisma.partnerGroupDefaultLink.update({ where: { id: defaultLink.id, }, data: { url: constructURLFromUTMParams( defaultLink.url, extractUtmParams(utmTemplate), ), }, }); } } } await Promise.allSettled([ recordAuditLog({ workspaceId: workspace.id, programId, action: "group.updated", description: `Group ${updatedGroup.name} (${group.id}) updated`, actor: session.user, targets: [ { type: "group", id: group.id, metadata: updatedGroup, }, ], }), group.utmTemplateId !== updatedGroup.utmTemplateId && qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/sync-utm`, body: { groupId: group.id, }, }), ]); })(), ); return NextResponse.json(GroupSchema.parse(updatedGroup)); }, { requiredPermissions: ["groups.write"], requiredPlan: [ "business", "business extra", "business max", "business plus", "advanced", "enterprise", ], }, ); // DELETE /api/groups/[groupIdOrSlug] – delete a group for a workspace export const DELETE = withWorkspace( async ({ params, workspace, session }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { groupIdOrSlug } = params; const [group, defaultGroup] = await Promise.all([ prisma.partnerGroup.findUniqueOrThrow({ where: { ...(groupIdOrSlug.startsWith("grp_") ? { id: groupIdOrSlug, } : { programId_slug: { programId, slug: groupIdOrSlug, }, }), }, }), prisma.partnerGroup.findUniqueOrThrow({ where: { programId_slug: { programId, slug: DEFAULT_PARTNER_GROUP.slug, }, }, }), ]); if (group.slug === DEFAULT_PARTNER_GROUP.slug) { throw new DubApiError({ code: "forbidden", message: "You cannot delete the default group of your program.", }); } while (true) { const programEnrollments = await prisma.programEnrollment.findMany({ where: { groupId: group.id, }, take: 100, }); if (programEnrollments.length === 0) { break; } const count = await movePartnersToGroup({ workspaceId: workspace.id, programId, partnerIds: programEnrollments.map(({ partnerId }) => partnerId), userId: session.user.id, group: defaultGroup, isGroupDeleted: true, }); console.log(`Moved ${count} partners to the default group`); } const deletedGroup = await prisma.$transaction(async (tx) => { // 1. Delete the group's rewards if (group.clickRewardId || group.leadRewardId || group.saleRewardId) { await tx.reward.deleteMany({ where: { id: { in: [ group.clickRewardId, group.leadRewardId, group.saleRewardId, ].filter(Boolean) as string[], }, }, }); } // Note: we can't delete this group's discount yet because it is needed // for `remap-discount-codes` that runs in movePartnersToGroup // but we will delete the Discount in `remap-discount-codes` once there are no remaining discount codes. // 2. Delete the group move workflow if (group.workflowId) { await tx.workflow.delete({ where: { id: group.workflowId, }, }); } // 3. Delete the group await tx.partnerGroup.delete({ where: { id: group.id, }, }); return true; }); if (deletedGroup) { waitUntil( recordAuditLog({ workspaceId: workspace.id, programId, action: "group.deleted", description: `Group ${group.name} (${group.id}) deleted`, actor: session.user, targets: [ { type: "group", id: group.id, metadata: group, }, ], }), ); } return NextResponse.json({ id: group.id }); }, { requiredPermissions: ["groups.write"], requiredPlan: [ "business", "business extra", "business max", "business plus", "advanced", "enterprise", ], }, ); ================================================ FILE: apps/web/app/(ee)/api/groups/count/route.ts ================================================ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { getGroupsCountQuerySchema } from "@/lib/zod/schemas/groups"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/groups/count - get the count of groups for a program export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { search } = getGroupsCountQuerySchema.parse(searchParams); const count = await prisma.partnerGroup.count({ where: { programId, ...(search && { OR: [ { name: { contains: search, }, }, { slug: { contains: search, }, }, ], }), }, }); return NextResponse.json(count); }, { requiredPermissions: ["groups.read"], requiredPlan: [ "business", "business extra", "business max", "business plus", "advanced", "enterprise", ], }, ); ================================================ FILE: apps/web/app/(ee)/api/groups/route.ts ================================================ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { createId } from "@/lib/api/create-id"; import { DubApiError } from "@/lib/api/errors"; import { getGroups } from "@/lib/api/groups/get-groups"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { exceededLimitError } from "@/lib/exceeded-limit-error"; import { createGroupSchema, DEFAULT_PARTNER_GROUP, getGroupsQuerySchema, GroupSchema, GroupSchemaExtended, } from "@/lib/zod/schemas/groups"; import { prisma } from "@dub/prisma"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/groups - get all groups for a program export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const parsedInput = getGroupsQuerySchema.parse(searchParams); console.time("getGroups"); const groups = await getGroups({ ...parsedInput, programId, }); console.timeEnd("getGroups"); return NextResponse.json(z.array(GroupSchemaExtended).parse(groups)); }, { requiredPermissions: ["groups.read"], requiredPlan: [ "business", "business extra", "business max", "business plus", "advanced", "enterprise", ], }, ); // POST /api/groups - create a group for a program export const POST = withWorkspace( async ({ workspace, req, session }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { name, slug, color } = createGroupSchema.parse( await parseRequestBody(req), ); const program = await prisma.program.findUniqueOrThrow({ where: { id: programId, }, include: { groups: { where: { slug: DEFAULT_PARTNER_GROUP.slug, }, include: { partnerGroupDefaultLinks: true, }, }, }, }); const group = await prisma.$transaction(async (tx) => { const [existingGroup, groupsCount] = await Promise.all([ tx.partnerGroup.findUnique({ where: { programId_slug: { programId, slug, }, }, }), tx.partnerGroup.count({ where: { programId, }, }), ]); if (existingGroup) { throw new DubApiError({ code: "conflict", message: `Group with slug ${slug} already exists in your program.`, }); } if (groupsCount >= workspace.groupsLimit) { throw new DubApiError({ code: "exceeded_limit", message: exceededLimitError({ plan: workspace.plan, limit: workspace.groupsLimit, type: "groups", }), }); } // copy over the default group's settings when creating a new group const { logo, wordmark, brandColor, additionalLinks, maxPartnerLinks, linkStructure, partnerGroupDefaultLinks, applicationFormData, landerData, holdingPeriodDays, autoApprovePartnersEnabledAt, } = program.groups[0]; return await tx.partnerGroup.create({ data: { id: createId({ prefix: "grp_" }), programId, name, slug, color, logo, wordmark, brandColor, holdingPeriodDays, autoApprovePartnersEnabledAt, ...(additionalLinks && { additionalLinks }), ...(maxPartnerLinks && { maxPartnerLinks }), ...(linkStructure && { linkStructure }), ...(applicationFormData && { applicationFormData }), ...(landerData && { landerData }), partnerGroupDefaultLinks: { createMany: { data: partnerGroupDefaultLinks.map((link) => ({ id: createId({ prefix: "pgdl_" }), programId, domain: link.domain, url: link.url, })), }, }, }, include: { clickReward: true, leadReward: true, saleReward: true, discount: true, }, }); }); waitUntil( recordAuditLog({ workspaceId: workspace.id, programId, action: "group.created", description: `Group ${group.name} (${group.id}) created`, actor: session.user, targets: [ { type: "group", id: group.id, metadata: group, }, ], }), ); return NextResponse.json(GroupSchema.parse(group), { status: 201, }); }, { requiredPermissions: ["groups.write"], requiredPlan: [ "business", "business extra", "business max", "business plus", "advanced", "enterprise", ], }, ); ================================================ FILE: apps/web/app/(ee)/api/groups/rules/route.ts ================================================ import { getGroupMoveRules } from "@/lib/api/groups/get-group-move-rules"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { groupRulesSchema } from "@/lib/zod/schemas/groups"; import { NextResponse } from "next/server"; // GET /api/groups/rules - get group move rules export const GET = withWorkspace( async ({ workspace }) => { const programId = getDefaultProgramIdOrThrow(workspace); const groups = await getGroupMoveRules(programId); return NextResponse.json(groupRulesSchema.parse(groups)); }, { requiredPermissions: ["groups.read"], requiredPlan: ["advanced", "enterprise"], }, ); ================================================ FILE: apps/web/app/(ee)/api/hubspot/callback/route.ts ================================================ import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; import { getSession } from "@/lib/auth"; import { HubSpotApi } from "@/lib/integrations/hubspot/api"; import { HUBSPOT_DUB_CONTACT_PROPERTIES } from "@/lib/integrations/hubspot/constants"; import { hubSpotOAuthProvider } from "@/lib/integrations/hubspot/oauth"; import { installIntegration } from "@/lib/integrations/install"; import { WorkspaceProps } from "@/lib/types"; import { prisma } from "@dub/prisma"; import { waitUntil } from "@vercel/functions"; import { redirect } from "next/navigation"; export const dynamic = "force-dynamic"; // GET /api/hubspot/callback - OAuth callback from HubSpot export const GET = async (req: Request) => { const { searchParams } = new URL(req.url); let workspace: Pick | null = null; // Local development redirect since the callback might be coming through ngrok if ( process.env.NODE_ENV === "development" && !req.headers.get("host")?.includes("localhost") ) { return redirect( `http://localhost:8888/api/hubspot/callback?${searchParams.toString()}`, ); } try { const session = await getSession(); if (!session?.user.id) { throw new DubApiError({ code: "unauthorized", message: "Unauthorized. Please login to continue.", }); } const { token, contextId: workspaceId } = await hubSpotOAuthProvider.exchangeCodeForToken(req); workspace = await prisma.project.findUniqueOrThrow({ where: { id: workspaceId, }, select: { id: true, slug: true, users: { where: { userId: session.user.id, }, select: { role: true, defaultFolderId: true, }, }, }, }); // Check if the user is a member of the workspace if (workspace.users.length === 0) { throw new DubApiError({ code: "bad_request", message: "You are not a member of this workspace. ", }); } // Check if the user is an owner of the workspace if (workspace.users[0].role !== "owner") { throw new DubApiError({ code: "bad_request", message: "Only workspace owners can install integrations. ", }); } const integration = await prisma.integration.findUniqueOrThrow({ where: { slug: "hubspot", }, select: { id: true, }, }); const credentials = { ...token, created_at: Date.now(), }; const installedIntegration = await installIntegration({ integrationId: integration.id, userId: session.user.id, workspaceId, credentials, }); if (installedIntegration) { const hubSpotApi = new HubSpotApi({ token: credentials.access_token, }); waitUntil( hubSpotApi.createPropertiesBatch({ objectType: "0-1", properties: HUBSPOT_DUB_CONTACT_PROPERTIES, }), ); } } catch (e: any) { return handleAndReturnErrorResponse(e); } redirect(`/${workspace.slug}/settings/integrations/hubspot`); }; ================================================ FILE: apps/web/app/(ee)/api/hubspot/webhook/route.ts ================================================ import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; import { withAxiom } from "@/lib/axiom/server"; import { hubSpotOAuthProvider } from "@/lib/integrations/hubspot/oauth"; import { hubSpotSettingsSchema, hubSpotWebhookSchema, } from "@/lib/integrations/hubspot/schema"; import { trackHubSpotLeadEvent } from "@/lib/integrations/hubspot/track-lead"; import { trackHubSpotSaleEvent } from "@/lib/integrations/hubspot/track-sale"; import { prisma } from "@dub/prisma"; import crypto from "crypto"; import { NextResponse } from "next/server"; const HUBSPOT_CLIENT_SECRET = process.env.HUBSPOT_CLIENT_SECRET || ""; // POST /api/hubspot/webhook – listen to webhook events from Hubspot export const POST = withAxiom(async (req) => { try { const rawPayload = await req.text(); const signature = req.headers.get("X-HubSpot-Signature"); // Verify webhook signature if (!signature) { throw new DubApiError({ code: "bad_request", message: "Missing X-HubSpot-Signature header.", }); } if (!HUBSPOT_CLIENT_SECRET) { throw new DubApiError({ code: "internal_server_error", message: "Missing HUBSPOT_CLIENT_SECRET environment variable.", }); } // Create expected hash: client_secret + request_body const sourceString = HUBSPOT_CLIENT_SECRET + rawPayload; const expectedHash = crypto .createHash("sha256") .update(sourceString) .digest("hex"); // Compare with provided signature if (signature !== expectedHash) { throw new DubApiError({ code: "unauthorized", message: "Invalid webhook signature.", }); } const payload = JSON.parse(rawPayload) as any[]; // HS send multiple events in the same request // so we need to process each event individually await Promise.allSettled(payload.map(processWebhookEvent)); return NextResponse.json({ message: "Webhook received." }); } catch (error) { return handleAndReturnErrorResponse(error); } }); // Process individual event async function processWebhookEvent(event: any) { const { objectTypeId, portalId, subscriptionType } = hubSpotWebhookSchema.parse(event); // Find the installation const installation = await prisma.installedIntegration.findFirst({ where: { integration: { slug: "hubspot", }, credentials: { path: "$.hub_id", equals: portalId, }, }, include: { project: true, }, }); if (!installation) { console.error( `[HubSpot] Installation is not found for portalId ${portalId}.`, ); return; } const { project: workspace } = installation; // Refresh the access token if needed const authToken = await hubSpotOAuthProvider.refreshTokenForInstallation(installation); if (!authToken) { console.error( `[HubSpot] Authentication token is not found or valid for portalId ${portalId}.`, ); return; } const settings = hubSpotSettingsSchema.parse(installation.settings ?? {}); console.log("[HubSpot] Event", event); console.log("[HubSpot] Integration settings", settings); // Contact events if (objectTypeId === "0-1") { const isContactCreated = subscriptionType === "object.creation"; const isLifecycleStageChanged = subscriptionType === "object.propertyChange" && settings.leadTriggerEvent === "lifecycleStageReached"; if (isContactCreated || isLifecycleStageChanged) { await trackHubSpotLeadEvent({ payload: event, workspace, authToken, settings, }); } } // Deal event if (objectTypeId === "0-3") { const isDealCreated = subscriptionType === "object.creation" && settings.leadTriggerEvent === "dealCreated"; const isDealUpdated = subscriptionType === "object.propertyChange"; // Track the final lead event if (isDealCreated) { await trackHubSpotLeadEvent({ payload: event, workspace, authToken, settings, }); } // Track the sale event when deal is closed won else if (isDealUpdated) { await trackHubSpotSaleEvent({ payload: event, workspace, authToken, settings, }); } } } ================================================ FILE: apps/web/app/(ee)/api/messages/count/route.ts ================================================ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { countMessagesQuerySchema } from "@/lib/zod/schemas/messages"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/messages/count - count messages for a program export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { unread } = countMessagesQuerySchema.parse(searchParams); const count = await prisma.message.count({ where: { programId, ...(unread !== undefined && { // Only count messages from the partner senderPartnerId: { not: null, }, readInApp: unread ? // Only count unread messages null : { // Only count read messages not: null, }, }), }, }); return NextResponse.json(count); }, { requiredPermissions: ["messages.read"], requiredPlan: ["advanced", "enterprise"], }, ); ================================================ FILE: apps/web/app/(ee)/api/messages/route.ts ================================================ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { PartnerMessagesSchema, getPartnerMessagesQuerySchema, } from "@/lib/zod/schemas/messages"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/messages - get messages grouped by partner export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { partnerId, sortBy, sortOrder, messagesLimit: messagesLimitArg, } = getPartnerMessagesQuerySchema.parse(searchParams); const messagesLimit = messagesLimitArg ?? (partnerId ? undefined : 10); const partners = await prisma.partner.findMany({ where: partnerId ? { id: partnerId, // Partner is either discoverable, enrolled in the program, or already has a message with the program OR: [ { discoverableAt: { not: null } }, { programs: { some: { programId } } }, { messages: { some: { programId } } }, ], } : { // Partner has messages with the program messages: { some: { programId, }, }, }, take: 1000, // TODO: add pagination later include: { messages: { where: { programId, }, include: { senderPartner: true, senderUser: true, }, orderBy: { [sortBy]: sortOrder, }, take: messagesLimit, }, }, }); return NextResponse.json( PartnerMessagesSchema.parse( partners // Sort by unread first, then by most recent message .sort((a, b) => { const aUnread = a.messages.some( (m) => m.senderPartnerId && !m.readInApp, ); const bUnread = b.messages.some( (m) => m.senderPartnerId && !m.readInApp, ); if (aUnread !== bUnread) { return aUnread ? -1 : 1; } return sortOrder === "desc" ? (b.messages?.[0]?.[sortBy]?.getTime() ?? 0) - (a.messages?.[0]?.[sortBy]?.getTime() ?? 0) : (a.messages?.[0]?.[sortBy]?.getTime() ?? 0) - (b.messages?.[0]?.[sortBy]?.getTime() ?? 0); }) // Map to {partner, messages} .map(({ messages, ...partner }) => ({ partner, messages, })), ), ); }, { requiredPermissions: ["messages.read"], requiredPlan: ["advanced", "enterprise"], }, ); ================================================ FILE: apps/web/app/(ee)/api/mock/rewardful/affiliates/route.ts ================================================ import { NextRequest, NextResponse } from "next/server"; export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const page = parseInt(searchParams.get("page") || "1"); const limit = parseInt(searchParams.get("limit") || "10"); const affiliates = [ { id: "d0ed8392-8880-4f39-8715-60230f9eceab", created_at: "2023-05-09T16:18:59.920Z", updated_at: "2023-05-09T16:25:42.614Z", first_name: "Adam", last_name: "Jones", email: "adam.jones@example.com", state: "active", visitors: 100, leads: 42, conversions: 18, links: [ { id: "eb844960-6c42-4a3b-8009-f588a42d8506", url: "http://www.example.com/?via=adam", token: "ref1", visitors: 100, leads: 42, conversions: 18, }, ], }, { id: "f7c91234-5678-4a3b-9012-34567890abcd", created_at: "2023-06-15T10:30:00.000Z", updated_at: "2023-06-15T10:35:00.000Z", first_name: "Sarah", last_name: "Smith", email: "sarah.smith@example.com", state: "active", visitors: 250, leads: 85, conversions: 30, links: [ { id: "cd123456-7890-4def-b123-456789abcdef", url: "http://www.example.com/?via=sarah", token: "ref2", visitors: 250, leads: 85, conversions: 30, }, ], }, { id: "a1b2c3d4-5678-4e5f-6g7h-8i9j0k1l2m3n", created_at: "2023-07-20T14:45:00.000Z", updated_at: "2023-07-20T14:50:00.000Z", first_name: "Michael", last_name: "Brown", email: "michael.brown@example.com", state: "active", visitors: 150, leads: 35, conversions: 12, links: [ { id: "ef123456-7890-4abc-def1-23456789abcd", url: "http://www.example.com/?via=michael", token: "ref3", visitors: 150, leads: 35, conversions: 12, }, ], }, { id: "b2c3d4e5-6f7g-8h9i-j0k1-l2m3n4o5p6q7", created_at: "2023-08-05T09:15:00.000Z", updated_at: "2023-08-05T09:20:00.000Z", first_name: "Emily", last_name: "Davis", email: "emily.davis@example.com", state: "active", visitors: 300, leads: 120, conversions: 45, links: [ { id: "gh123456-7890-4ijk-lmno-pqrstuvwxyz1", url: "http://www.example.com/?via=emily", token: "ref4", visitors: 300, leads: 120, conversions: 45, }, ], }, { id: "c3d4e5f6-7g8h-9i0j-k1l2-m3n4o5p6q7r8", created_at: "2023-09-10T11:20:00.000Z", updated_at: "2023-09-10T11:25:00.000Z", first_name: "David", last_name: "Wilson", email: "david.wilson@example.com", state: "active", visitors: 180, leads: 60, conversions: 25, links: [ { id: "ij123456-7890-4klm-nopq-rstuvwxyz123", url: "http://www.example.com/?via=david", token: "ref5", visitors: 180, leads: 60, conversions: 25, }, ], }, { id: "d4e5f6g7-8h9i-0j1k-l2m3-n4o5p6q7r8s9", created_at: "2023-10-15T13:40:00.000Z", updated_at: "2023-10-15T13:45:00.000Z", first_name: "Lisa", last_name: "Taylor", email: "lisa.taylor@example.com", state: "active", visitors: 220, leads: 75, conversions: 28, links: [ { id: "kl123456-7890-4mno-pqrs-tuvwxyz12345", url: "http://www.example.com/?via=lisa", token: "ref6", visitors: 220, leads: 75, conversions: 28, }, ], }, { id: "e5f6g7h8-9i0j-1k2l-m3n4-o5p6q7r8s9t0", created_at: "2023-11-20T15:55:00.000Z", updated_at: "2023-11-20T16:00:00.000Z", first_name: "James", last_name: "Anderson", email: "james.anderson@example.com", state: "active", visitors: 90, leads: 20, conversions: 8, links: [ { id: "mn123456-7890-4opq-rstu-vwxyz123456", url: "http://www.example.com/?via=james", token: "ref7", visitors: 90, leads: 20, conversions: 8, }, ], }, { id: "f6g7h8i9-0j1k-2l3m-n4o5-p6q7r8s9t0u1", created_at: "2023-12-25T08:10:00.000Z", updated_at: "2023-12-25T08:15:00.000Z", first_name: "Emma", last_name: "Martinez", email: "emma.martinez@example.com", state: "active", visitors: 280, leads: 95, conversions: 40, links: [ { id: "op123456-7890-4qrs-tuv-wxyz1234567", url: "http://www.example.com/?via=emma", token: "ref8", visitors: 280, leads: 95, conversions: 40, }, ], }, { id: "g7h8i9j0-1k2l-3m4n-o5p6-q7r8s9t0u1v2", created_at: "2024-01-05T12:30:00.000Z", updated_at: "2024-01-05T12:35:00.000Z", first_name: "Robert", last_name: "Garcia", email: "robert.garcia@example.com", state: "active", visitors: 160, leads: 55, conversions: 22, links: [ { id: "qr123456-7890-4stu-vwxy-z123456789", url: "http://www.example.com/?via=robert", token: "ref9", visitors: 160, leads: 55, conversions: 22, }, ], }, { id: "h8i9j0k1-2l3m-4n5o-p6q7-r8s9t0u1v2w3", created_at: "2024-02-10T16:50:00.000Z", updated_at: "2024-02-10T16:55:00.000Z", first_name: "Olivia", last_name: "Lee", email: "olivia.lee@example.com", state: "active", visitors: 200, leads: 70, conversions: 32, links: [ { id: "st123456-7890-4uvw-xyz1-234567890ab", url: "http://www.example.com/?via=olivia", token: "ref10", visitors: 200, leads: 70, conversions: 32, }, ], }, ]; const startIndex = (page - 1) * limit; const endIndex = startIndex + limit; const paginatedAffiliates = affiliates.slice(startIndex, endIndex); return NextResponse.json({ data: paginatedAffiliates, }); } ================================================ FILE: apps/web/app/(ee)/api/mock/rewardful/campaigns/[campaignId]/route.ts ================================================ import { NextResponse } from "next/server"; import { campaigns } from "../campaigns"; export async function GET( request: Request, props: { params: Promise<{ campaignId: string }> }, ) { const params = await props.params; const { campaignId } = params; const campaign = campaigns.find((c) => c.id === campaignId); return NextResponse.json(campaign); } ================================================ FILE: apps/web/app/(ee)/api/mock/rewardful/campaigns/campaigns.ts ================================================ export const campaigns = [ { id: "ceaef6d9-767e-49aa-a6ab-46c02aa79604", created_at: "2021-11-24T06:31:06.672Z", updated_at: "2022-02-22T23:17:55.119Z", name: "Campaign 1", url: "https://rewardful.com/", private: false, private_tokens: false, commission_amount_cents: null, commission_amount_currency: null, minimum_payout_cents: 0, max_commission_period_months: 12, max_commissions: null, days_before_referrals_expire: 30, days_until_commissions_are_due: 30, affiliate_dashboard_text: "", custom_reward_description: "", welcome_text: "", customers_visible_to_affiliates: false, sale_description_visible_to_affiliates: true, parameter_type: "query", stripe_coupon_id: "jo45MTj3", default: false, reward_type: "percent", commission_percent: 30.0, minimum_payout_currency: "USD", visitors: 150, leads: 39, conversions: 7, affiliates: 12, }, { id: "ceaef6d9-767e-49aa-a6ab-46c02aa79605", created_at: "2021-11-24T06:31:06.672Z", updated_at: "2022-02-22T23:17:55.119Z", name: "Campaign 2", url: "https://rewardful.com/", private: false, private_tokens: false, commission_amount_cents: 5000, // $50.00 commission_amount_currency: "USD", minimum_payout_cents: 0, max_commission_period_months: 24, max_commissions: null, days_before_referrals_expire: 30, days_until_commissions_are_due: 30, affiliate_dashboard_text: "", custom_reward_description: "", welcome_text: "", customers_visible_to_affiliates: false, sale_description_visible_to_affiliates: true, parameter_type: "query", stripe_coupon_id: "jo45MTj3", default: false, reward_type: "amount", commission_percent: null, minimum_payout_currency: "USD", visitors: 150, leads: 39, conversions: 7, affiliates: 12, }, ]; ================================================ FILE: apps/web/app/(ee)/api/mock/rewardful/campaigns/route.ts ================================================ import { NextResponse } from "next/server"; import { campaigns } from "./campaigns"; export async function GET() { return NextResponse.json({ data: campaigns, }); } ================================================ FILE: apps/web/app/(ee)/api/mock/rewardful/commissions/route.ts ================================================ import { NextRequest, NextResponse } from "next/server"; export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const page = parseInt(searchParams.get("page") || "1"); const limit = parseInt(searchParams.get("limit") || "10"); const commissions = Array.from({ length: 10 }, (_, i) => ({ id: `39e68c88-d84a-4510-b3b4-43c75016a0${i}0`, created_at: `2020-08-${19 + i}T16:28:31.164Z`, updated_at: `2020-08-${19 + i}T16:28:31.164Z`, amount: 3000 + i * 1000, currency: "USD", state: ["pending", "due", "paid", "voided"][i % 4], due_at: `2020-09-${18 + i}T16:28:25.000Z`, paid_at: i % 3 === 1 ? `2020-09-${20 + i}T16:28:25.000Z` : null, voided_at: i % 3 === 2 ? `2020-09-${21 + i}T16:28:25.000Z` : null, campaign: { id: "ceaef6d9-767e-49aa-a6ab-46c02aa79604", created_at: `2020-05-22T02:55:19.802Z`, updated_at: `2020-08-19T16:28:16.177Z`, name: `Campaign ${i + 1}`, }, sale: { id: `74e37d3b-03c5-4bfc-841c-a79d5799551${i}`, currency: "USD", charged_at: `2020-08-${19 + i}T16:28:25.000Z`, stripe_account_id: `acct_ABC${123 + i}`, stripe_charge_id: `ch_ABC${123 + i}`, invoiced_at: `2020-08-${19 + i}T16:28:25.000Z`, created_at: `2020-08-${19 + i}T16:28:31.102Z`, updated_at: `2020-08-${19 + i}T16:28:31.102Z`, charge_amount_cents: 10000 + i * 1000, refund_amount_cents: 0, tax_amount_cents: 0, sale_amount_cents: 10000 + i * 1000, referral: { id: `d154e622-278a-4103-b191-5cbebae4047${i}`, stripe_account_id: `acct_ABC${123 + i}`, stripe_customer_id: `cus_ABC${123 + i}`, conversion_state: "conversion", deactivated_at: null, expires_at: `2020-10-18T16:13:12.109Z`, created_at: `2020-08-19T16:13:12.109Z`, updated_at: `2020-08-19T16:28:31.166Z`, customer: { platform: "stripe", id: `cus_ABC${123 + i}`, name: [ "Freddie Mercury", "David Bowie", "Mick Jagger", "Robert Plant", "Roger Waters", "Paul McCartney", "John Lennon", "George Harrison", "Ringo Starr", "Brian May", ][i], email: `rockstar${i}@example.com`, }, visits: 2 + i, link: { id: `b759a9ed-ed63-499f-b621-0221f2712${i}86`, url: `http://www.demo.com:8080/?via=ref${i}`, token: `ref${i}`, visitors: 197 + i, leads: 196 + i, conversions: 156 + i, }, }, affiliate: { id: `07d8acc5-c689-4b4a-bbab-f88a71ffc0${i}2`, created_at: `2020-05-22T02:55:19.934Z`, updated_at: `2020-08-19T16:28:31.168Z`, first_name: [ "James", "Felix", "Eve", "M", "Q", "Bill", "Alec", "Pierce", "Timothy", "Daniel", ][i], last_name: [ "Bond", "Leiter", "Moneypenny", "Mansfield", "Boothroyd", "Tanner", "Trevelyan", "Brosnan", "Dalton", "Craig", ][i], email: `agent${i}@mi6.co.uk`, paypal_email: "", confirmed_at: `2020-07-09T03:53:06.760Z`, paypal_email_confirmed_at: `2020-07-03T17:49:23.489Z`, receive_new_commission_notifications: true, sign_in_count: 1 + i, unconfirmed_email: null, stripe_customer_id: null, stripe_account_id: null, visitors: 197 + i, leads: 196 + i, conversions: 156 + i, campaign: { id: `c3482343-8680-40c5-af9a-9efa119713b${i}`, created_at: `2020-05-22T02:55:19.802Z`, updated_at: `2020-08-19T16:28:16.177Z`, name: `Campaign ${i + 1}`, }, }, }, })); const startIndex = (page - 1) * limit; const endIndex = startIndex + limit; const slicedCommissions = commissions.slice(startIndex, endIndex); return NextResponse.json({ data: slicedCommissions.length > 0 ? slicedCommissions : [], }); } ================================================ FILE: apps/web/app/(ee)/api/mock/rewardful/referrals/route.ts ================================================ import { NextRequest, NextResponse } from "next/server"; export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const page = parseInt(searchParams.get("page") || "1"); const limit = parseInt(searchParams.get("limit") || "10"); const referrals = Array.from({ length: 10 }, (_, i) => ({ id: `e523da29-6157-4aac-b4b5-05b3b7b14fb${i}`, link: { id: `32a19d65-2b68-434d-a401-e72ca7f24d8${i}`, url: `http://www.example.com/?via=ref${i}`, leads: 3 + i, token: `ref${i + 1}`, visitors: 5 + i, conversions: 2 + i, }, visits: 30 + i * 5, customer: { id: `cus_ABC${123 + i}`, name: [ `John Doe`, `Jane Smith`, `Bob Johnson`, `Alice Brown`, `Charlie Wilson`, `Diana Prince`, `Bruce Wayne`, `Clark Kent`, `Peter Parker`, `Tony Stark`, ][i], email: `user${i}@example.com`, platform: "stripe", }, affiliate: { id: `dc939584-a94a-4bdf-b8f4-8d255aae72${i}c`, email: `affiliate${i}@example.com`, leads: 3 + i, campaign: { id: "ceaef6d9-767e-49aa-a6ab-46c02aa79604", name: `Campaign ${i + 1}`, created_at: `2020-04-${20 + i}T00:24:08.199Z`, updated_at: `2020-04-${20 + i}T00:24:08.199Z`, }, visitors: 5 + i, last_name: [ `Smith`, `Johnson`, `Williams`, `Brown`, `Jones`, `Garcia`, `Miller`, `Davis`, `Rodriguez`, `Martinez`, ][i], created_at: `2020-04-${20 + i}T00:24:08.334Z`, first_name: [ `James`, `Mary`, `Robert`, `Patricia`, `Michael`, `Linda`, `William`, `Elizabeth`, `David`, `Barbara`, ][i], updated_at: `2020-05-${i + 1}T19:39:03.028Z`, conversions: 2 + i, confirmed_at: `2020-04-${20 + i}T00:24:08.331Z`, paypal_email: null, sign_in_count: i, stripe_account_id: null, unconfirmed_email: null, stripe_customer_id: null, paypal_email_confirmed_at: null, receive_new_commission_notifications: true, }, created_at: `2025-01-${20 + i}T00:34:28.448Z`, became_lead_at: `2025-01-${20 + i}T00:36:28.448Z`, became_conversion_at: `2025-01-${20 + i}T00:38:28.448Z`, expires_at: `2020-06-${20 + i}T00:34:28.448Z`, updated_at: `2020-04-${20 + i}T00:38:28.448Z`, deactivated_at: null, conversion_state: "conversion", stripe_account_id: `acct_ABC${123 + i}`, stripe_customer_id: `cus_ABC${123 + i}`, })); const startIndex = (page - 1) * limit; const endIndex = startIndex + limit; const slicedReferrals = referrals.slice(startIndex, endIndex); return NextResponse.json({ data: slicedReferrals.length > 0 ? slicedReferrals : [], }); } ================================================ FILE: apps/web/app/(ee)/api/network/partners/count/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { getNetworkPartnersCountQuerySchema } from "@/lib/zod/schemas/partner-network"; import { prisma } from "@dub/prisma"; import { PlatformType, Prisma } from "@dub/prisma/client"; import { NextResponse } from "next/server"; // GET /api/network/partners/count - get the number of available partners in the network export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { partnerNetworkEnabledAt } = await prisma.program.findUniqueOrThrow({ select: { partnerNetworkEnabledAt: true, }, where: { id: programId, }, }); if (!partnerNetworkEnabledAt) { throw new DubApiError({ code: "forbidden", message: "Partner network is not enabled for this program.", }); } const { partnerIds, status, groupBy, country, starred, platform, subscribers, } = getNetworkPartnersCountQuerySchema.parse(searchParams); // Build platform filter - combine platform type and subscribers if both are set const platformFilter: Prisma.PartnerPlatformWhereInput | undefined = platform || subscribers ? { verifiedAt: { not: null }, ...(platform && { type: platform }), ...(subscribers === "<5000" && { subscribers: { lt: 5000 }, }), ...(subscribers === "5000-25000" && { subscribers: { gte: 5000, lt: 25000 }, }), ...(subscribers === "25000-100000" && { subscribers: { gte: 25000, lt: 100000 }, }), ...(subscribers === "100000+" && { subscribers: { gte: 100000 }, }), } : undefined; const commonWhere: Prisma.PartnerWhereInput = { discoverableAt: { not: null }, ...(partnerIds && { id: { in: partnerIds }, }), ...(country && { country, }), ...(platformFilter && { platforms: { some: platformFilter, }, }), }; const statusWheres = { discover: { programs: { none: { programId } }, // Allow partners with no DiscoveredPartner record OR not ignored OR: starred === true ? [ { discoveredByPrograms: { some: { programId, starredAt: { not: null } }, }, }, ] : starred === false ? [ { discoveredByPrograms: { none: { programId } } }, // No record yet { discoveredByPrograms: { some: { programId, starredAt: null, ignoredAt: null }, }, }, // Not starred and not ignored ] : [ { discoveredByPrograms: { none: { programId } } }, // No record yet { discoveredByPrograms: { some: { programId, ignoredAt: null }, }, }, // Has record but not ignored ], }, invited: { programs: { some: { programId, status: "invited" } }, discoveredByPrograms: { some: { programId, invitedAt: { not: null }, ignoredAt: null }, }, }, recruited: { programs: { some: { programId, status: "approved" } }, discoveredByPrograms: { some: { programId, invitedAt: { not: null } }, }, }, } as const; if (groupBy === "status") { const [discover, invited, recruited] = await Promise.all([ !status || status === "discover" ? prisma.partner.count({ where: { ...commonWhere, ...statusWheres.discover, }, }) : undefined, !status || status === "invited" ? prisma.partner.count({ where: { ...commonWhere, ...statusWheres.invited, }, }) : undefined, !status || status === "recruited" ? prisma.partner.count({ where: { ...commonWhere, ...statusWheres.recruited, }, }) : undefined, ]); return NextResponse.json({ discover, invited, recruited, }); } else if (groupBy === "country") { const countries = await prisma.partner.groupBy({ by: ["country"], _count: true, where: { ...commonWhere, ...statusWheres[status || "discover"] }, orderBy: { _count: { country: "desc", }, }, }); return NextResponse.json(countries); } else if (groupBy === "platform") { // Build platform filter for PartnerPlatform const platformPlatformFilter: Prisma.PartnerPlatformWhereInput = { verifiedAt: { not: null }, ...(subscribers === "<5000" && { subscribers: { lt: 5000 }, }), ...(subscribers === "5000-25000" && { subscribers: { gte: 5000, lt: 25000 }, }), ...(subscribers === "25000-100000" && { subscribers: { gte: 25000, lt: 100000 }, }), ...(subscribers === "100000+" && { subscribers: { gte: 100000 }, }), }; // Build partner where clause combining all filters const partnerWhere: Prisma.PartnerWhereInput = { ...commonWhere, ...statusWheres[status || "discover"], platforms: { some: platformPlatformFilter, }, }; // Get all partners matching the criteria with their platforms const partners = await prisma.partner.findMany({ where: partnerWhere, select: { id: true, platforms: { where: platformPlatformFilter, select: { type: true, }, }, }, }); // Group by platform type and count distinct partners const platformCountsMap = new Map>(); for (const partner of partners) { for (const platform of partner.platforms) { if (!platformCountsMap.has(platform.type)) { platformCountsMap.set(platform.type, new Set()); } platformCountsMap.get(platform.type)!.add(partner.id); } } const platformCounts = Array.from(platformCountsMap.entries()) .map(([type, partnerIds]) => ({ platform: type, _count: partnerIds.size, })) .sort((a, b) => b._count - a._count); return NextResponse.json(platformCounts); } else if (groupBy === "subscribers") { // Get counts by subscriber ranges (only verified platforms) const subscriberRanges = [ { label: "<5000", min: 0, max: 4999 }, { label: "5000-25000", min: 5000, max: 24999 }, { label: "25000-100000", min: 25000, max: 99999 }, { label: "100000+", min: 100000, max: null }, ]; const subscriberCounts = await Promise.all( subscriberRanges.map(async (range) => { const where: Prisma.PartnerWhereInput = { ...commonWhere, ...statusWheres[status || "discover"], platforms: { some: { verifiedAt: { not: null }, ...(range.max !== null ? { subscribers: { gte: range.min, lt: range.max + 1 }, } : { subscribers: { gte: range.min }, }), ...(platform && { type: platform }), }, }, }; const count = await prisma.partner.count({ where }); return { subscribers: range.label, _count: count, }; }), ); return NextResponse.json(subscriberCounts); } throw new Error("Invalid groupBy"); }, { requiredPlan: ["enterprise", "advanced"], }, ); ================================================ FILE: apps/web/app/(ee)/api/network/partners/invites-usage/route.ts ================================================ import { getNetworkInvitesUsage } from "@/lib/api/partners/get-network-invites-usage"; import { withWorkspace } from "@/lib/auth"; import { NextResponse } from "next/server"; // GET /api/network/partners/invites-usage - get the usage and limits for partner network invitations export const GET = withWorkspace( async ({ workspace }) => { const usage = await getNetworkInvitesUsage(workspace); return NextResponse.json({ usage, limit: workspace.networkInvitesLimit, remaining: Math.max(0, workspace.networkInvitesLimit - usage), }); }, { requiredPlan: ["enterprise", "advanced"], }, ); ================================================ FILE: apps/web/app/(ee)/api/network/partners/route.ts ================================================ import { getConversionScore } from "@/lib/actions/partners/get-conversion-score"; import { DubApiError } from "@/lib/api/errors"; import { calculatePartnerRanking } from "@/lib/api/network/calculate-partner-ranking"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { PROGRAM_SIMILARITY_SCORE_THRESHOLD } from "@/lib/constants/program"; import { NetworkPartnerSchema, getNetworkPartnersQuerySchema, } from "@/lib/zod/schemas/partner-network"; import { prisma } from "@dub/prisma"; import { PreferredEarningStructure, SalesChannel } from "@dub/prisma/client"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/network/partners - get all available partners in the network export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const program = await prisma.program.findUniqueOrThrow({ where: { id: programId, }, include: { similarPrograms: { where: { similarityScore: { gt: PROGRAM_SIMILARITY_SCORE_THRESHOLD, }, }, orderBy: { similarityScore: "desc", }, take: 10, }, }, }); if (!program.partnerNetworkEnabledAt) { throw new DubApiError({ code: "forbidden", message: "Partner network is not enabled for this program.", }); } const { partnerIds, status, page, pageSize, country, starred, platform, subscribers, } = getNetworkPartnersQuerySchema.parse(searchParams); const similarPrograms = program.similarPrograms.map((sp) => ({ programId: sp.similarProgramId, similarityScore: sp.similarityScore, })); console.time("calculatePartnerRanking"); const partners = await calculatePartnerRanking({ programId, partnerIds, status, country, page, pageSize, starred: starred ?? undefined, platform: platform ?? undefined, subscribers: subscribers ?? undefined, similarPrograms, }); console.timeEnd("calculatePartnerRanking"); return NextResponse.json( z.array(NetworkPartnerSchema).parse( partners.map((partner) => ({ ...partner, conversionScore: getConversionScore(partner.conversionRate || 0), starredAt: partner.starredAt ? new Date(partner.starredAt) : null, ignoredAt: partner.ignoredAt ? new Date(partner.ignoredAt) : null, invitedAt: partner.invitedAt ? new Date(partner.invitedAt) : null, categories: partner.categories ? partner.categories.split(",").map((c: string) => c.trim()) : [], preferredEarningStructures: partner.preferredEarningStructures ? partner.preferredEarningStructures .split(",") .map((e: string) => e.trim() as PreferredEarningStructure) : [], salesChannels: partner.salesChannels ? partner.salesChannels .split(",") .map((s: string) => s.trim() as SalesChannel) : [], })), ), ); }, { requiredPlan: ["enterprise", "advanced"], }, ); ================================================ FILE: apps/web/app/(ee)/api/network/programs/count/route.ts ================================================ import { withPartnerProfile } from "@/lib/auth/partner"; import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; import { getNetworkProgramsCountQuerySchema } from "@/lib/zod/schemas/program-network"; import { prisma } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; import { NextResponse } from "next/server"; const rewardTypeMap = { sale: Prisma.sql`pg.saleRewardId IS NOT NULL`, lead: Prisma.sql`pg.leadRewardId IS NOT NULL`, click: Prisma.sql`pg.clickRewardId IS NOT NULL`, discount: Prisma.sql`pg.discountId IS NOT NULL`, }; // GET /api/network/programs/count - get the number of available programs in the network export const GET = withPartnerProfile(async ({ partner, searchParams }) => { const { groupBy, category, rewardType, status, featured, search } = getNetworkProgramsCountQuerySchema.parse(searchParams); const searchSql = search ? Prisma.sql`CONCAT('%', ${search}, '%')` : null; const commonWhereSql = Prisma.sql` p.addedToMarketplaceAt IS NOT NULL AND EXISTS ( SELECT 1 FROM PartnerGroup pg WHERE pg.programId = p.id AND pg.slug = ${DEFAULT_PARTNER_GROUP.slug} AND pg.applicationFormPublishedAt IS NOT NULL ${ rewardType && groupBy !== "rewardType" ? Prisma.sql` AND ${Prisma.join( rewardType.map((type) => rewardTypeMap[type]), " AND ", )}` : Prisma.sql`` } ) ${ category && groupBy !== "category" ? Prisma.sql` AND EXISTS ( SELECT 1 FROM ProgramCategory pc WHERE pc.programId = p.id AND pc.category = ${category} )` : Prisma.sql`` } ${ status !== undefined && groupBy !== "status" ? Prisma.sql` AND ${status === null ? Prisma.sql`NOT` : Prisma.sql``} EXISTS ( SELECT 1 FROM ProgramEnrollment pe WHERE pe.programId = p.id AND pe.partnerId = ${partner.id} ${status === null ? Prisma.sql`` : Prisma.sql`AND pe.status = ${status}`} )` : Prisma.sql`` } ${featured !== undefined ? Prisma.sql`AND p.featuredOnMarketplaceAt IS ${featured ? Prisma.sql`NOT` : Prisma.sql``} NULL` : Prisma.sql``} ${searchSql ? Prisma.sql`AND (p.name LIKE ${searchSql} OR p.slug LIKE ${searchSql} OR p.domain LIKE ${searchSql})` : Prisma.sql``} `; if (groupBy === "category") { const categories = (await prisma.$queryRaw` SELECT pc.category, COUNT(p.id) AS _count FROM ProgramCategory pc JOIN Program p ON p.id = pc.programId WHERE ${commonWhereSql} GROUP BY pc.category ORDER BY _count DESC `) as { category: string; _count: bigint }[]; return NextResponse.json( categories.map(({ _count, ...rest }) => ({ ...rest, _count: Number(_count), })), ); } else if (groupBy === "rewardType") { const rewards = (await prisma.$queryRaw` SELECT COUNT(pg.clickRewardId) AS "click", COUNT(pg.leadRewardId) AS "lead", COUNT(pg.saleRewardId) AS "sale", COUNT(discountId) AS "discount" FROM PartnerGroup pg JOIN Program p ON p.id = pg.programId WHERE pg.slug = ${DEFAULT_PARTNER_GROUP.slug} AND ${commonWhereSql} `) as { click: bigint; lead: bigint; sale: bigint; discount: bigint }[]; return NextResponse.json( ["sale", "lead", "click", "discount"].map((k) => ({ type: k, _count: Number(rewards[0][k]), })), ); } else if (groupBy === "status") { const statuses = (await prisma.$queryRaw` SELECT pe.status, COUNT(p.id) AS _count FROM Program p LEFT JOIN ProgramEnrollment pe ON p.id = pe.programId AND pe.partnerId = ${partner.id} WHERE p.addedToMarketplaceAt IS NOT NULL AND ${commonWhereSql} GROUP BY pe.status ORDER BY _count DESC `) as { status: string | null; _count: bigint }[]; return NextResponse.json( statuses.map(({ _count, ...rest }) => ({ ...rest, _count: Number(_count), })), ); } const count = (await prisma.$queryRaw` SELECT COUNT(*) AS count FROM Program p WHERE ${commonWhereSql} `) as { count: bigint }[]; return NextResponse.json(Number(count[0].count)); }); ================================================ FILE: apps/web/app/(ee)/api/network/programs/route.ts ================================================ import { withPartnerProfile } from "@/lib/auth/partner"; import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; import { NetworkProgramSchema, getNetworkProgramsQuerySchema, } from "@/lib/zod/schemas/program-network"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/network/programs - get all available programs in the network export const GET = withPartnerProfile(async ({ partner, searchParams }) => { const { search, featured, category, rewardType, status, sortBy, sortOrder, page = 1, pageSize, } = getNetworkProgramsQuerySchema.parse(searchParams); const programs = await prisma.program.findMany({ where: { // Added to marketplace addedToMarketplaceAt: { not: null, }, ...(featured && { featuredOnMarketplaceAt: { not: null, }, }), ...(search && { OR: [ { name: { contains: search } }, { slug: { contains: search } }, { domain: { contains: search } }, { url: { contains: search } }, { description: { contains: search } }, ], }), ...(category && { categories: { some: { category, }, }, }), ...(rewardType && { groups: { some: { slug: DEFAULT_PARTNER_GROUP.slug, ...(rewardType.includes("sale") && { saleRewardId: { not: null }, }), ...(rewardType.includes("lead") && { leadRewardId: { not: null }, }), ...(rewardType.includes("click") && { clickRewardId: { not: null }, }), ...(rewardType.includes("discount") && { discountId: { not: null }, }), }, }, }), ...(status !== undefined && { partners: status === null ? { none: { partnerId: partner.id } } : { some: { partnerId: partner.id, status, }, }, }), }, include: { groups: { where: { slug: DEFAULT_PARTNER_GROUP.slug, }, include: { clickReward: true, leadReward: true, saleReward: true, discount: true, }, }, categories: true, invoices: true, }, orderBy: sortBy === "popularity" ? {} : { [sortBy === "recency" ? "addedToMarketplaceAt" : sortBy]: sortOrder, }, skip: (page - 1) * pageSize, take: pageSize, }); return NextResponse.json( z.array(NetworkProgramSchema).parse( programs .sort((a, b) => // if requesting featured programs, randomize the order featured ? Math.random() - 0.5 : // if sorting by popularity, sort by marketplaceRanking first, then total invoice paid out sortBy === "popularity" ? a.marketplaceRanking - b.marketplaceRanking || b.invoices.reduce((acc, invoice) => acc + invoice.amount, 0) - a.invoices.reduce((acc, invoice) => acc + invoice.amount, 0) : 0, ) .map((program) => ({ ...program, rewards: program.groups.length > 0 ? [ program.groups[0].clickReward, program.groups[0].leadReward, program.groups[0].saleReward, ].filter(Boolean) : [], discount: program.groups.length > 0 ? program.groups[0].discount : null, categories: program.categories.map(({ category }) => category), })), ), ); }); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/invites/accept/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { withSession } from "@/lib/auth"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // POST /api/partner-profile/invites/accept – accept a partner invite export const POST = withSession(async ({ session }) => { await prisma.$transaction(async (tx) => { const invite = await tx.partnerInvite.findFirst({ where: { email: session.user.email, }, include: { partner: true, }, }); if (!invite) { throw new DubApiError({ code: "not_found", message: "No invitation found for your email.", }); } if (invite.expires < new Date()) { throw new DubApiError({ code: "invite_expired", message: "The invitation has been expired.", }); } const partner = invite.partner; const existingPartnerMembership = await tx.partnerUser.count({ where: { userId: session.user.id, }, }); if (existingPartnerMembership > 0) { throw new DubApiError({ code: "conflict", message: "You're already associated with another partner profile. A user can only belong to one partner profile at a time.", }); } await tx.partnerUser.create({ data: { userId: session.user.id, role: invite.role, partnerId: partner.id, notificationPreferences: { create: {}, }, }, }); await tx.partnerInvite.delete({ where: { email_partnerId: { email: session.user.email, partnerId: partner.id, }, }, }); if (session.user["defaultPartnerId"] === null) { const currentUser = await tx.user.findUnique({ where: { id: session.user.id, }, select: { defaultPartnerId: true, }, }); // Only update if defaultPartnerId is still null in the database if (currentUser && currentUser.defaultPartnerId === null) { await tx.user.update({ where: { id: session.user.id, }, data: { defaultPartnerId: partner.id, }, }); } } }); return NextResponse.json({ message: "You are now a member of this partner profile.", }); }); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/invites/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { invitePartnerUser } from "@/lib/api/partners/invite-partner-user"; import { parseRequestBody } from "@/lib/api/utils"; import { withPartnerProfile } from "@/lib/auth/partner"; import { MAX_INVITES_PER_REQUEST, MAX_PARTNER_USERS, } from "@/lib/constants/partner-profile"; import { getPartnerUsersQuerySchema, invitePartnerUserSchema, partnerUserSchema, } from "@/lib/zod/schemas/partner-profile"; import { prisma } from "@dub/prisma"; import { PartnerRole } from "@dub/prisma/client"; import { isRejected, pluralize } from "@dub/utils"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/partner-profile/invites - get all invites for a partner profile export const GET = withPartnerProfile(async ({ partner, searchParams }) => { const { search, role } = getPartnerUsersQuerySchema.parse(searchParams); const invites = await prisma.partnerInvite.findMany({ where: { partnerId: partner.id, role, ...(search && { email: { contains: search }, }), }, }); const parsedInvites = invites.map((invite) => partnerUserSchema.parse({ ...invite, id: null, name: invite.email, }), ); return NextResponse.json(parsedInvites); }); // POST /api/partner-profile/invites - invite team members export const POST = withPartnerProfile( async ({ partner, req, session }) => { const invites = z .array(invitePartnerUserSchema) .parse(await parseRequestBody(req)); if (invites.length > MAX_INVITES_PER_REQUEST) { throw new DubApiError({ code: "bad_request", message: "You can only invite up to 5 members at a time.", }); } const emails = Array.from(new Set([...invites.map(({ email }) => email)])); const [ partnerInvitesCount, partnerUsersCount, existingPartnerUsers, existingPartnerInvites, ] = await Promise.all([ prisma.partnerInvite.count({ where: { partnerId: partner.id, }, }), prisma.partnerUser.count({ where: { partnerId: partner.id, }, }), prisma.partnerUser.findMany({ where: { user: { email: { in: emails, }, }, }, include: { user: true, }, }), prisma.partnerInvite.findMany({ where: { partnerId: partner.id, email: { in: emails, }, }, }), ]); // Check for users that already exist const existingPartnerUsersEmails = existingPartnerUsers.map( ({ user }) => user?.email, ); if (existingPartnerUsersEmails.length > 0) { throw new DubApiError({ code: "bad_request", message: `${pluralize("User", existingPartnerUsersEmails.length)} ${existingPartnerUsersEmails.join(", ")} already ${existingPartnerUsersEmails.length > 1 ? "have" : "has"} associated partner profiles.`, }); } // Check for pending invites const existingInviteEmails = existingPartnerInvites.map( ({ email }) => email, ); if (existingInviteEmails.length > 0) { throw new DubApiError({ code: "conflict", message: `${pluralize("User", existingInviteEmails.length)} ${existingInviteEmails.join(", ")} ${pluralize("has", existingInviteEmails.length, { plural: "have" })} already been invited to this partner profile.`, }); } if ( partnerInvitesCount + partnerUsersCount + invites.length > MAX_PARTNER_USERS ) { throw new DubApiError({ code: "exceeded_limit", message: `You can only have ${MAX_PARTNER_USERS} members in this partner profile.`, }); } const results = await Promise.allSettled( invites.map(({ email, role }) => invitePartnerUser({ email, role, partner, session, }), ), ); const rejectedResults = results.filter(isRejected); if (rejectedResults.length > 0) { throw new DubApiError({ code: "bad_request", message: "Some invitations could not be sent.", }); } return NextResponse.json({ message: "Invite(s) sent" }); }, { requiredPermission: "user_invites.create", }, ); const updateInviteRoleSchema = z.object({ email: z.email(), role: z.enum(PartnerRole), }); // PATCH /api/partner-profile/invites - update an invite's role export const PATCH = withPartnerProfile( async ({ req, partner }) => { const { email, role } = updateInviteRoleSchema.parse( await parseRequestBody(req), ); const invite = await prisma.partnerInvite.findUnique({ where: { email_partnerId: { email, partnerId: partner.id, }, }, }); if (!invite) { throw new DubApiError({ code: "not_found", message: "The invitation you're trying to update was not found.", }); } const response = await prisma.partnerInvite.update({ where: { email_partnerId: { email, partnerId: partner.id, }, }, data: { role, }, }); return NextResponse.json(response); }, { requiredPermission: "user_invites.update", }, ); const removeInviteSchema = z.object({ email: z.email(), }); // DELETE /api/partner-profile/invites?email={email} - remove an invite export const DELETE = withPartnerProfile( async ({ searchParams, partner }) => { const { email } = removeInviteSchema.parse(searchParams); await prisma.$transaction([ prisma.partnerInvite.delete({ where: { email_partnerId: { email, partnerId: partner.id, }, }, }), prisma.verificationToken.deleteMany({ where: { identifier: email, }, }), ]); return NextResponse.json({ email }); }, { requiredPermission: "user_invites.delete", }, ); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/messages/count/route.ts ================================================ import { withPartnerProfile } from "@/lib/auth/partner"; import { countMessagesQuerySchema } from "@/lib/zod/schemas/messages"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/partner-profile/messages/count - count messages for a partner export const GET = withPartnerProfile(async ({ partner, searchParams }) => { const { unread } = countMessagesQuerySchema.parse(searchParams); const count = await prisma.message.count({ where: { partnerId: partner.id, ...(unread !== undefined && { // Only count messages from the program senderPartnerId: null, readInApp: unread ? // Only count unread messages null : { // Only count read messages not: null, }, }), }, }); return NextResponse.json(count); }); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/messages/route.ts ================================================ import { withPartnerProfile } from "@/lib/auth/partner"; import { ProgramMessagesSchema, getProgramMessagesQuerySchema, } from "@/lib/zod/schemas/messages"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/partner-profile/messages - get messages grouped by program export const GET = withPartnerProfile(async ({ partner, searchParams }) => { const { programSlug, sortBy, sortOrder, messagesLimit: messagesLimitArg, } = getProgramMessagesQuerySchema.parse(searchParams); const messagesLimit = messagesLimitArg ?? (programSlug ? undefined : 10); const programs = await prisma.program.findMany({ where: { // Partner is not banned from the program partners: { none: { partnerId: partner.id, status: "banned", }, }, ...(programSlug ? { slug: programSlug, OR: [ // Partner is enrolled in the program // in this case, return messages regardless of messaging enabled status which is passed to the UI { partners: { some: { partnerId: partner.id, }, }, }, { // Partner has received a direct message from the program messages: { some: { partnerId: partner.id, senderPartnerId: null, // Sent by the program }, }, }, ], } : { OR: [ // Program has messaging enabled and partner has 1+ messages with the program { messagingEnabledAt: { not: null, }, messages: { some: { partnerId: partner.id, }, }, }, // Partner has received a direct message from the program { messages: { some: { partnerId: partner.id, senderPartnerId: null, // Sent by the program }, }, }, ], }), }, include: { messages: { where: { partnerId: partner.id, }, include: { senderPartner: true, senderUser: true, }, orderBy: { [sortBy]: sortOrder, }, take: messagesLimit, }, }, }); return NextResponse.json( ProgramMessagesSchema.parse( programs // Sort by unread first, then by most recent message .sort((a, b) => { const aUnread = a.messages.some( (m) => !m.senderPartnerId && !m.readInApp, ); const bUnread = b.messages.some( (m) => !m.senderPartnerId && !m.readInApp, ); if (aUnread !== bUnread) { return aUnread ? -1 : 1; } return sortOrder === "desc" ? (b.messages?.[0]?.[sortBy]?.getTime() ?? 0) - (a.messages?.[0]?.[sortBy]?.getTime() ?? 0) : (a.messages?.[0]?.[sortBy]?.getTime() ?? 0) - (b.messages?.[0]?.[sortBy]?.getTime() ?? 0); }) // Map to {program, messages} .map(({ messages, ...program }) => ({ program, messages, })), ), ); }); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/notification-preferences/route.ts ================================================ import { withPartnerProfile } from "@/lib/auth/partner"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/partner-profile/notification-preferences – get notification preferences for the current partner+user export const GET = withPartnerProfile(async ({ partner, session }) => { const response = await prisma.partnerNotificationPreferences.findFirstOrThrow( { where: { partnerUser: { partnerId: partner.id, userId: session.user.id, }, }, select: { commissionCreated: true, applicationApproved: true, newMessageFromProgram: true, marketingCampaign: true, connectPayoutReminder: true, }, }, ); return NextResponse.json(response); }); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/payouts/count/route.ts ================================================ import { withPartnerProfile } from "@/lib/auth/partner"; import { payoutsCountQuerySchema } from "@/lib/zod/schemas/payouts"; import { prisma } from "@dub/prisma"; import { PayoutStatus, Prisma } from "@dub/prisma/client"; import { NextResponse } from "next/server"; // GET /api/partner-profile/payouts/count – get payouts count for a partner export const GET = withPartnerProfile(async ({ partner, searchParams }) => { const { programId, groupBy, status } = payoutsCountQuerySchema.parse(searchParams); const where: Prisma.PayoutWhereInput = { partnerId: partner.id, ...(programId && { programId }), }; if (groupBy === "status") { const payouts = await prisma.payout.groupBy({ by: ["status"], where: where, _count: true, _sum: { amount: true, }, }); const counts = payouts.map((p) => ({ status: p.status, count: p._count, amount: p._sum.amount, })); Object.values(PayoutStatus).forEach((status) => { if (!counts.find((p) => p.status === status)) { counts.push({ status, count: 0, amount: 0, }); } }); return NextResponse.json(counts); } const count = await prisma.payout.count({ where: { ...where, status, }, }); return NextResponse.json(count); }); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/payouts/route.ts ================================================ import { getEffectivePayoutMode } from "@/lib/api/payouts/get-effective-payout-mode"; import { withPartnerProfile } from "@/lib/auth/partner"; import { partnerProfilePayoutsQuerySchema } from "@/lib/zod/schemas/partner-profile"; import { PartnerPayoutResponseSchema } from "@/lib/zod/schemas/payouts"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/partner-profile/payouts - get all payouts for a partner export const GET = withPartnerProfile(async ({ partner, searchParams }) => { const { programId, status, sortBy, sortOrder, page = 1, pageSize, } = partnerProfilePayoutsQuerySchema.parse(searchParams); const payouts = await prisma.payout.findMany({ where: { partnerId: partner.id, ...(programId && { programId }), ...(status && { status }), }, include: { program: true, }, skip: (page - 1) * pageSize, take: pageSize, orderBy: { [sortBy]: sortOrder, }, }); const transformedPayouts = payouts.map((payout) => { const mode = payout.mode ?? getEffectivePayoutMode({ payoutMode: payout.program.payoutMode, payoutsEnabledAt: partner.payoutsEnabledAt, }); return { ...payout, mode, traceId: payout.stripePayoutTraceId, }; }); return NextResponse.json( z.array(PartnerPayoutResponseSchema).parse(transformedPayouts), ); }); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/payouts/settings/route.ts ================================================ import { withPartnerProfile } from "@/lib/auth/partner"; import { getPartnerPayoutMethods } from "@/lib/payouts/get-partner-payout-methods"; import { NextResponse } from "next/server"; // GET /api/partner-profile/payouts/settings export const GET = withPartnerProfile(async ({ partner }) => { const payoutMethods = await getPartnerPayoutMethods(partner); return NextResponse.json(payoutMethods); }); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/events/route.ts ================================================ import { getPostbackOrThrow } from "@/lib/api/postbacks/get-postback-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { getPostbackEvents } from "@/lib/postback/api/get-postback-events"; import { NextResponse } from "next/server"; // GET /api/partner-profile/postbacks/[postbackId]/events export const GET = withPartnerProfile( async ({ partner, params }) => { const { postbackId } = params; await getPostbackOrThrow({ postbackId, partnerId: partner.id, }); const events = await getPostbackEvents({ postbackId, }); return NextResponse.json(events.data); }, { requiredPermission: "postbacks.read", featureFlag: "postbacks", }, ); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/rotate-secret/route.ts ================================================ import { createToken } from "@/lib/api/oauth/utils"; import { getPostbackOrThrow } from "@/lib/api/postbacks/get-postback-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { POSTBACK_SECRET_LENGTH, POSTBACK_SECRET_PREFIX, } from "@/lib/postback/constants"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // POST /api/partner-profile/postbacks/[postbackId]/rotate-secret export const POST = withPartnerProfile( async ({ partner, params }) => { const { postbackId } = params; await getPostbackOrThrow({ postbackId, partnerId: partner.id, }); const secret = createToken({ prefix: POSTBACK_SECRET_PREFIX, length: POSTBACK_SECRET_LENGTH, }); await prisma.postback.update({ where: { id: postbackId, }, data: { secret, }, }); return NextResponse.json({ secret }); }, { requiredPermission: "postbacks.write", featureFlag: "postbacks", }, ); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/route.ts ================================================ import { getPostbackOrThrow } from "@/lib/api/postbacks/get-postback-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withPartnerProfile } from "@/lib/auth/partner"; import { postbackSchema, updatePostbackInputSchema, } from "@/lib/postback/schemas"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/partner-profile/postbacks/[postbackId] export const GET = withPartnerProfile( async ({ partner, params }) => { const { postbackId } = params; const postback = await getPostbackOrThrow({ postbackId, partnerId: partner.id, }); return NextResponse.json(postbackSchema.parse(postback)); }, { requiredPermission: "postbacks.read", featureFlag: "postbacks", }, ); // PATCH /api/partner-profile/postbacks/[postbackId] export const PATCH = withPartnerProfile( async ({ partner, params, req }) => { const { postbackId } = params; let postback = await getPostbackOrThrow({ postbackId, partnerId: partner.id, }); const { name, url, triggers, disabled } = updatePostbackInputSchema.parse( await parseRequestBody(req), ); postback = await prisma.postback.update({ where: { id: postbackId, }, data: { ...(name !== undefined && { name }), ...(url !== undefined && { url }), ...(triggers !== undefined && { triggers }), ...(disabled !== undefined && { disabledAt: disabled ? new Date() : null, }), }, }); return NextResponse.json(postbackSchema.parse(postback)); }, { requiredPermission: "postbacks.write", featureFlag: "postbacks", }, ); // DELETE /api/partner-profile/postbacks/[postbackId] export const DELETE = withPartnerProfile( async ({ partner, params }) => { const { postbackId } = params; await getPostbackOrThrow({ postbackId, partnerId: partner.id, }); await prisma.postback.delete({ where: { id: postbackId, }, }); return NextResponse.json({ id: postbackId }); }, { requiredPermission: "postbacks.write", featureFlag: "postbacks", }, ); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/postbacks/[postbackId]/send-test/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { getPostbackOrThrow } from "@/lib/api/postbacks/get-postback-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withPartnerProfile } from "@/lib/auth/partner"; import { sendPartnerPostback } from "@/lib/postback/api/send-partner-postback"; import commissionCreated from "@/lib/postback/sample-events/commission-created.json"; import leadCreated from "@/lib/postback/sample-events/lead-created.json"; import saleCreated from "@/lib/postback/sample-events/sale-created.json"; import { sendTestPostbackInputSchema } from "@/lib/postback/schemas"; import { PostbackTrigger } from "@/lib/types"; import { NextResponse } from "next/server"; const samplePayloads: Record> = { "lead.created": leadCreated, "sale.created": saleCreated, "commission.created": commissionCreated, }; // POST /api/partner-profile/postbacks/[postbackId]/send-test export const POST = withPartnerProfile( async ({ partner, params, req }) => { const { postbackId } = params; const { event } = sendTestPostbackInputSchema.parse( await parseRequestBody(req), ); const postback = await getPostbackOrThrow({ postbackId, partnerId: partner.id, }); const triggers = postback.triggers as string[]; if (!triggers.includes(event)) { throw new DubApiError({ code: "bad_request", message: "The selected event is not configured for this postback.", }); } await sendPartnerPostback({ partnerId: partner.id, event, data: samplePayloads[event], skipEnrichment: true, }); return NextResponse.json({}); }, { requiredPermission: "postbacks.write", featureFlag: "postbacks", }, ); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/postbacks/route.ts ================================================ import { createId } from "@/lib/api/create-id"; import { DubApiError } from "@/lib/api/errors"; import { createToken } from "@/lib/api/oauth/utils"; import { parseRequestBody } from "@/lib/api/utils"; import { withPartnerProfile } from "@/lib/auth/partner"; import { identifyPostbackChannel } from "@/lib/postback/api/utils"; import { MAX_POSTBACKS, POSTBACK_SECRET_LENGTH, POSTBACK_SECRET_PREFIX, } from "@/lib/postback/constants"; import { createPostbackInputSchema, createPostbackOutputSchema, postbackSchema, } from "@/lib/postback/schemas"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/partner-profile/postbacks export const GET = withPartnerProfile( async ({ partner }) => { const postbacks = await prisma.postback.findMany({ where: { partnerId: partner.id, }, orderBy: { createdAt: "desc", }, }); return NextResponse.json(z.array(postbackSchema).parse(postbacks)); }, { requiredPermission: "postbacks.read", featureFlag: "postbacks", }, ); // POST /api/partner-profile/postbacks export const POST = withPartnerProfile( async ({ partner, req }) => { const { name, url, triggers } = createPostbackInputSchema.parse( await parseRequestBody(req), ); const postbackCount = await prisma.postback.count({ where: { partnerId: partner.id, }, }); if (postbackCount >= MAX_POSTBACKS) { throw new DubApiError({ code: "exceeded_limit", message: `Maximum number of postbacks (${MAX_POSTBACKS}) reached.`, }); } const secret = createToken({ prefix: POSTBACK_SECRET_PREFIX, length: POSTBACK_SECRET_LENGTH, }); const postback = await prisma.postback.create({ data: { id: createId({ prefix: "pb_" }), partnerId: partner.id, name, url, secret, triggers, receiver: identifyPostbackChannel(url), }, }); return NextResponse.json(createPostbackOutputSchema.parse(postback), { status: 201, }); }, { requiredPermission: "postbacks.write", featureFlag: "postbacks", }, ); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/[programId]/activity-logs/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { activityLogSchema, getActivityLogsQuerySchema, } from "@/lib/zod/schemas/activity-log"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; export const GET = withPartnerProfile( async ({ partner, params, searchParams }) => { const { resourceType, resourceId, action } = getActivityLogsQuerySchema.parse(searchParams); // Limit to referral for now if (resourceType !== "referral") { throw new DubApiError({ code: "bad_request", message: "Resource type must be referral.", }); } const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: {}, }); // Check if the resource is a referral and belongs to the program and partner if (resourceType === "referral") { const referral = await prisma.partnerReferral.findUnique({ where: { id: resourceId, programId: programEnrollment.programId, partnerId: partner.id, }, select: { id: true, }, }); if (!referral) { throw new DubApiError({ code: "not_found", message: "Referral not found.", }); } } const activityLogs = await prisma.activityLog.findMany({ where: { programId: programEnrollment.programId, resourceType, resourceId, action, }, orderBy: { createdAt: "desc", }, take: 100, }); return NextResponse.json(z.array(activityLogSchema).parse(activityLogs)); }, ); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/export/route.ts ================================================ import { VALID_ANALYTICS_ENDPOINTS } from "@/lib/analytics/constants"; import { getFirstFilterValue } from "@/lib/analytics/filter-helpers"; import { getAnalytics } from "@/lib/analytics/get-analytics"; import { convertToCSV } from "@/lib/analytics/utils"; import { DubApiError } from "@/lib/api/errors"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { LARGE_PROGRAM_IDS, LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS, MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING, } from "@/lib/constants/partner-profile"; import { partnerProfileAnalyticsQuerySchema } from "@/lib/zod/schemas/partner-profile"; import { parseFilterValue, toCentsNumber } from "@dub/utils"; import JSZip from "jszip"; // GET /api/partner-profile/programs/[programId]/analytics/export – get export data for partner profile analytics export const GET = withPartnerProfile( async ({ partner, params, searchParams }) => { const { program, links, totalCommissions } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, links: true, }, }); if ( LARGE_PROGRAM_IDS.includes(program.id) && toCentsNumber(totalCommissions) < LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS ) { throw new DubApiError({ code: "forbidden", message: "This feature is not available for your program.", }); } // Early return if partner has no links if (links.length === 0) { throw new DubApiError({ code: "not_found", message: "No links found", }); } const parsedParams = partnerProfileAnalyticsQuerySchema.parse(searchParams); const { linkId, domain, key } = parsedParams; if (linkId) { // check to make sure all of the linkId.values are in the links if ( !linkId.values.every((value) => links.some((link) => link.id === value)) ) { throw new DubApiError({ code: "not_found", message: "One or more links are not found", }); } if (linkId.sqlOperator === "NOT IN") { // if using NOT IN operator, we need to include all links except the ones in the linkId.values const finalIncludedLinkIds = links .filter((link) => !linkId.values.includes(link.id)) .map((link) => link.id); // early return if no links are left if (finalIncludedLinkIds.length === 0) { throw new DubApiError({ code: "not_found", message: "No links found", }); } parsedParams.linkId = { operator: "IS", sqlOperator: "IN", values: finalIncludedLinkIds, }; } } else if (domain && key) { const link = links.find( (link) => link.domain === getFirstFilterValue(domain) && link.key === key, ); if (!link) { throw new DubApiError({ code: "not_found", message: "Link not found", }); } parsedParams.linkId = { operator: "IS", sqlOperator: "IN", values: [link.id], }; } const zip = new JSZip(); await Promise.all( VALID_ANALYTICS_ENDPOINTS.map(async (endpoint) => { // no need to fetch top links data if there's a link specified // since this is just a single link if (endpoint === "top_links" && linkId) return; // skip clicks count if (endpoint === "count") return; const response = await getAnalytics({ ...parsedParams, workspaceId: program.workspaceId, ...(parsedParams.linkId ? { linkId: parsedParams.linkId } : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING ? { partnerId: partner.id } : { linkId: parseFilterValue(links.map((link) => link.id)) }), dataAvailableFrom: program.startedAt ?? program.createdAt, groupBy: endpoint, }); if (!response || response.length === 0) return; const csvData = convertToCSV(response); zip.file(`${endpoint}.csv`, csvData); }), ); const zipData = await zip.generateAsync({ type: "nodebuffer" }); return new Response(zipData as unknown as BodyInit, { headers: { "Content-Type": "application/zip", "Content-Disposition": "attachment; filename=analytics_export.zip", }, }); }, ); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/[programId]/analytics/route.ts ================================================ import { getFirstFilterValue } from "@/lib/analytics/filter-helpers"; import { getAnalytics } from "@/lib/analytics/get-analytics"; import { DubApiError } from "@/lib/api/errors"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { LARGE_PROGRAM_IDS, LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS, MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING, } from "@/lib/constants/partner-profile"; import { partnerProfileAnalyticsQuerySchema } from "@/lib/zod/schemas/partner-profile"; import { parseFilterValue, toCentsNumber } from "@dub/utils"; import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/[programId]/analytics – get analytics for a program enrollment link export const GET = withPartnerProfile( async ({ partner, params, searchParams }) => { const { program, links, totalCommissions } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, links: true, }, }); // early return if partner has no links if (links.length === 0) { return NextResponse.json([], { status: 200 }); } const parsedParams = partnerProfileAnalyticsQuerySchema.parse(searchParams); const { linkId, domain, key } = parsedParams; if (linkId) { // check to make sure all of the linkId.values are in the links if ( !linkId.values.every((value) => links.some((link) => link.id === value)) ) { throw new DubApiError({ code: "not_found", message: "One or more links are not found", }); } if (linkId.sqlOperator === "NOT IN") { // if using NOT IN operator, we need to include all links except the ones in the linkId.values const finalIncludedLinkIds = links .filter((link) => !linkId.values.includes(link.id)) .map((link) => link.id); // early return if no links are left if (finalIncludedLinkIds.length === 0) { return NextResponse.json([], { status: 200 }); } parsedParams.linkId = { operator: "IS", sqlOperator: "IN", values: finalIncludedLinkIds, }; } } else if (domain && key) { const link = links.find( (link) => link.domain === getFirstFilterValue(domain) && link.key === key, ); if (!link) { throw new DubApiError({ code: "not_found", message: "Link not found", }); } parsedParams.linkId = { operator: "IS", sqlOperator: "IN", values: [link.id], }; } const response = await getAnalytics({ ...(LARGE_PROGRAM_IDS.includes(program.id) && toCentsNumber(totalCommissions) < LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS ? { event: parsedParams.event, groupBy: "count", interval: "all" } : parsedParams), workspaceId: program.workspaceId, ...(parsedParams.linkId ? { linkId: parsedParams.linkId } : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING ? { partnerId: partner.id } : { linkId: parseFilterValue(links.map((link) => link.id)) }), dataAvailableFrom: program.startedAt ?? program.createdAt, }); return NextResponse.json(response); }, ); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { aggregatePartnerLinksStats } from "@/lib/partners/aggregate-partner-links-stats"; import { PartnerBountySchema } from "@/lib/zod/schemas/partner-profile"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/[programId]/bounties/[bountyId] – get a single bounty for an enrolled program export const GET = withPartnerProfile(async ({ partner, params }) => { const { programId, bountyId } = params; const { program, links, ...programEnrollment } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId, include: { program: true, links: true, }, }); const bounty = await prisma.bounty.findUnique({ where: { id: bountyId, programId: program.id, }, include: { workflow: { select: { triggerConditions: true, }, }, groups: true, submissions: { where: { partnerId: partner.id, }, include: { commission: { select: { id: true, earnings: true, status: true, createdAt: true, }, }, }, }, }, }); if (!bounty) { throw new DubApiError({ code: "not_found", message: "Bounty not found.", }); } if (bounty.startsAt > new Date()) { throw new DubApiError({ code: "not_found", message: "Bounty not found.", }); } const partnerGroupId = programEnrollment.groupId || program.defaultGroupId; const bountyGroupIds = bounty.groups.map((g) => g.groupId); const partnerCanSeeBounty = bountyGroupIds.length === 0 || (partnerGroupId && bountyGroupIds.includes(partnerGroupId)); if (!partnerCanSeeBounty) { throw new DubApiError({ code: "not_found", message: "Bounty not found.", }); } const { groups, ...bountyWithoutGroups } = bounty; return NextResponse.json( PartnerBountySchema.parse({ ...bountyWithoutGroups, performanceCondition: bounty.workflow?.triggerConditions?.[0] || null, partner: { ...aggregatePartnerLinksStats(links), totalCommissions: programEnrollment.totalCommissions, }, }), ); }); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { getSocialContent } from "@/lib/api/scrape-creators/get-social-content"; import { withPartnerProfile } from "@/lib/auth/partner"; import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; import { resolveBountyDetails } from "@/lib/bounty/utils"; import { ratelimit } from "@/lib/upstash"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; const searchParamsSchema = z.object({ url: z.url("Social media URL is required."), }); // GET /api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats export const GET = withPartnerProfile( async ({ partner, params, searchParams }) => { const { programId, bountyId } = params; const { url } = searchParamsSchema.parse(searchParams); const { success } = await ratelimit(10, "1 h").limit( `partner-profile:social-content-stats:${partner.id}`, ); if (!success) { throw new DubApiError({ code: "rate_limit_exceeded", message: "You've been rate limited. Please try again later.", }); } const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId, include: {}, }); const bounty = await getBountyOrThrow({ bountyId, programId: programEnrollment.programId, }); const bountyInfo = resolveBountyDetails(bounty); if (!bountyInfo?.socialMetrics) { throw new DubApiError({ code: "bad_request", message: "This bounty does not have social content requirements.", }); } const content = await getSocialContent({ platform: bountyInfo.socialMetrics.platform, url, }); return NextResponse.json(content); }, ); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts ================================================ import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { aggregatePartnerLinksStats } from "@/lib/partners/aggregate-partner-links-stats"; import { PartnerBountySchema } from "@/lib/zod/schemas/partner-profile"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/partner-profile/programs/[programId]/bounties – get available bounties for an enrolled program export const GET = withPartnerProfile( async ({ partner, params, searchParams }) => { const { program, totalCommissions, groupId, links } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, links: true, }, }); const now = new Date(); const partnerGroupId = groupId || program.defaultGroupId; const bounties = await prisma.bounty.findMany({ where: { programId: program.id, startsAt: { lte: now, }, // If bounty has no groups, it's available to all partners // If bounty has groups, only partners in those groups can see it AND: [ { OR: [ { groups: { none: {}, }, }, { groups: { some: { groupId: partnerGroupId, }, }, }, ], }, ], }, include: { workflow: { select: { triggerConditions: true, }, }, submissions: { where: { partnerId: partner.id, }, include: { commission: { select: { id: true, earnings: true, status: true, createdAt: true, }, }, }, }, }, }); return NextResponse.json( z.array(PartnerBountySchema).parse( bounties.map((bounty) => ({ ...bounty, submission: bounty.submissions?.[0] || null, performanceCondition: bounty.workflow?.triggerConditions?.[0] || null, partner: { ...aggregatePartnerLinksStats(links), totalCommissions, }, })), ), ); }, ); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/[customerId]/route.ts ================================================ import { getCustomerEvents } from "@/lib/analytics/get-customer-events"; import { transformCustomer } from "@/lib/api/customers/transform-customer"; import { DubApiError } from "@/lib/api/errors"; import { obfuscateCustomerEmail } from "@/lib/api/partner-profile/obfuscate-customer-email"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { LARGE_PROGRAM_IDS, LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS, } from "@/lib/constants/partner-profile"; import { generateRandomName } from "@/lib/names"; import { PartnerProfileCustomerSchema } from "@/lib/zod/schemas/partner-profile"; import { prisma } from "@dub/prisma"; import { CommissionType } from "@dub/prisma/client"; import { toCentsNumber } from "@dub/utils"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/partner-profile/programs/:programId/customers/:customerId – Get a customer by ID export const GET = withPartnerProfile(async ({ partner, params }) => { const { customerId, programId } = params; const { program, links, totalCommissions, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: programId, include: { program: true, links: true, }, }); if ( LARGE_PROGRAM_IDS.includes(program.id) && toCentsNumber(totalCommissions) < LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS ) { throw new DubApiError({ code: "forbidden", message: "This feature is not available for your program.", }); } const customer = await prisma.customer.findUnique({ where: { id: customerId, }, include: { // find the first sale commission for this customer and partner commissions: { where: { partnerId: partner.id, type: CommissionType.sale, }, take: 1, orderBy: { createdAt: "asc", }, }, }, }); if (!customer || customer?.projectId !== program.workspaceId) { throw new DubApiError({ code: "not_found", message: "Customer is not part of this program.", }); } const events = await getCustomerEvents({ customerId: customer.id, linkIds: links.map((link) => link.id), }); if (events.length === 0) { throw new DubApiError({ code: "not_found", message: "Customer is not attributed to any links by this partner.", }); } // get the first partner link that this customer interacted with const firstLinkId = events[events.length - 1].link_id; const link = links.find((link) => link.id === firstLinkId); const firstSaleAt = customer.commissions[0]?.createdAt ?? customer.firstSaleAt; return NextResponse.json( PartnerProfileCustomerSchema.extend({ ...(customerDataSharingEnabledAt && { name: z.string().nullish() }), }).parse({ ...transformCustomer({ ...customer, firstSaleAt, email: customer.email ? customerDataSharingEnabledAt ? customer.email : obfuscateCustomerEmail(customer.email) : customer.name || generateRandomName(), }), activity: { ...customer, events, link, }, }), ); }); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/count/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { LARGE_PROGRAM_IDS, LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS, } from "@/lib/constants/partner-profile"; import { getPartnerCustomersCountQuerySchema } from "@/lib/zod/schemas/partner-profile"; import { prisma, sanitizeFullTextSearch } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; import { toCentsNumber } from "@dub/utils"; import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/:programId/customers/count – Get customer counts grouped by a field export const GET = withPartnerProfile( async ({ partner, params, searchParams }) => { const { programId } = params; const { search, country, linkId, groupBy } = getPartnerCustomersCountQuerySchema.parse(searchParams); const { program, totalCommissions, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: programId, include: { program: true, }, }); if ( LARGE_PROGRAM_IDS.includes(program.id) && toCentsNumber(totalCommissions) < LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS ) { throw new DubApiError({ code: "forbidden", message: "This feature is not available for your program.", }); } const commonWhere: Prisma.CustomerWhereInput = { partnerId: partner.id, programId: program.id, projectId: program.workspaceId, // Only filter by country if not grouping by country ...(country && groupBy !== "country" && { country, }), // Only filter by linkId if not grouping by linkId ...(linkId && groupBy !== "linkId" && { linkId, }), // Only allow search if customer data sharing is enabled ...(search && customerDataSharingEnabledAt ? search.includes("@") ? { email: search } : { email: { search: sanitizeFullTextSearch(search) }, name: { search: sanitizeFullTextSearch(search) }, } : {}), }; // Get customer count by country if (groupBy === "country") { const data = await prisma.customer.groupBy({ by: ["country"], where: { ...commonWhere, country: { not: null } }, _count: true, orderBy: { _count: { country: "desc", }, }, }); return NextResponse.json(data); } // Get customer count by linkId if (groupBy === "linkId") { const data = await prisma.customer.groupBy({ by: ["linkId"], where: { ...commonWhere, linkId: { not: null } }, _count: true, orderBy: { _count: { linkId: "desc", }, }, take: 10000, }); const links = await prisma.link.findMany({ where: { id: { in: data.map(({ linkId }) => linkId!) }, }, select: { id: true, domain: true, key: true, shortLink: true, url: true, }, }); const enrichedData = data .map((d) => { const link = links.find(({ id }) => id === d.linkId); if (!link) return null; return { ...d, domain: link.domain, key: link.key, shortLink: link.shortLink, url: link.url, }; }) .filter(Boolean); return NextResponse.json(enrichedData); } // If no groupBy, return total count const count = await prisma.customer.count({ where: commonWhere, }); return NextResponse.json(count); }, ); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/[programId]/customers/route.ts ================================================ import { transformCustomer } from "@/lib/api/customers/transform-customer"; import { DubApiError } from "@/lib/api/errors"; import { obfuscateCustomerEmail } from "@/lib/api/partner-profile/obfuscate-customer-email"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { LARGE_PROGRAM_IDS, LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS, } from "@/lib/constants/partner-profile"; import { generateRandomName } from "@/lib/names"; import { PartnerProfileCustomerSchema, getPartnerCustomersQuerySchema, } from "@/lib/zod/schemas/partner-profile"; import { prisma, sanitizeFullTextSearch } from "@dub/prisma"; import { CommissionType } from "@dub/prisma/client"; import { toCentsNumber } from "@dub/utils"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/partner-profile/programs/:programId/customers – Get all customers for a partner program export const GET = withPartnerProfile( async ({ partner, params, searchParams }) => { const { programId } = params; const { search, country, linkId, sortBy, sortOrder, page = 1, pageSize, } = getPartnerCustomersQuerySchema.parse(searchParams); const { program, totalCommissions, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: programId, include: { program: true, }, }); if ( LARGE_PROGRAM_IDS.includes(program.id) && toCentsNumber(totalCommissions) < LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS ) { throw new DubApiError({ code: "forbidden", message: "This feature is not available for your program.", }); } // Get all customers with their first commission date in a single optimized query const customers = await prisma.customer.findMany({ where: { partnerId: partner.id, programId: program.id, projectId: program.workspaceId, ...(country && { country }), ...(linkId && { linkId }), // Only allow search if customer data sharing is enabled ...(search && customerDataSharingEnabledAt ? search.includes("@") ? { email: search } : { email: { search: sanitizeFullTextSearch(search) }, name: { search: sanitizeFullTextSearch(search) }, } : {}), }, include: { link: true, commissions: { where: { partnerId: partner.id, type: CommissionType.sale, }, take: 1, orderBy: { createdAt: "asc", }, }, }, orderBy: { [sortBy]: sortOrder, }, skip: (page - 1) * pageSize, take: pageSize, }); // Map customers with their data const customersWithData = customers.map((customer) => { const firstSaleAt = customer.commissions[0]?.createdAt ?? customer.firstSaleAt; return PartnerProfileCustomerSchema.extend({ ...(customerDataSharingEnabledAt && { name: z.string().nullish() }), }).parse({ ...transformCustomer({ ...customer, firstSaleAt, email: customer.email ? customerDataSharingEnabledAt ? customer.email : obfuscateCustomerEmail(customer.email) : customer.name || generateRandomName(), }), activity: { ...customer, events: [], link: customer.link, }, }); }); return NextResponse.json(customersWithData); }, ); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/count/route.ts ================================================ import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates"; import { obfuscateCustomerEmail } from "@/lib/api/partner-profile/obfuscate-customer-email"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { generateRandomName } from "@/lib/names"; import { getPartnerEarningsCountQuerySchema } from "@/lib/zod/schemas/partner-profile"; import { prisma } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/[programId]/earnings/count – get earnings count for a partner in a program enrollment export const GET = withPartnerProfile( async ({ partner, params, searchParams }) => { const { program, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, }, }); const { groupBy, status, linkId, customerId, payoutId, interval, start, end, timezone, } = getPartnerEarningsCountQuerySchema.parse(searchParams); const { startDate, endDate } = getStartEndDates({ interval, start, end, timezone, }); const where: Prisma.CommissionWhereInput = { earnings: { not: 0, }, programId: program.id, partnerId: partner.id, ...(payoutId && { payoutId }), createdAt: { gte: startDate, lte: endDate, }, }; if (groupBy) { let counts = await prisma.commission.groupBy({ by: [groupBy], where: { ...where, ...(status && groupBy !== "status" && { status }), ...(linkId && groupBy !== "linkId" && { linkId }), ...(customerId && groupBy !== "customerId" && { customerId }), }, _count: true, orderBy: { _count: { [groupBy]: "desc", }, }, }); if (groupBy === "linkId") { const links = await prisma.link.findMany({ where: { id: { in: counts .map(({ linkId }) => linkId) .filter((id): id is string => id !== null), }, }, }); counts = counts.map(({ linkId, _count }) => { const link = links.find((l) => l.id === linkId); return { id: linkId, domain: link?.domain, key: link?.key, url: link?.url, _count, }; }) as any[]; // TODO: find a better fix for types } else if (groupBy === "customerId") { const customers = await prisma.customer.findMany({ where: { id: { in: counts .map(({ customerId }) => customerId) .filter((id): id is string => id !== null), }, }, }); counts = counts.map(({ customerId, _count }) => { const customer = customers.find((c) => c.id === customerId); return { id: customerId, email: customer?.email ? customerDataSharingEnabledAt ? customer.email : obfuscateCustomerEmail(customer.email) : customer?.name || generateRandomName(), _count, }; }) as any[]; // TODO: find a better fix for types } return NextResponse.json(counts); } else { const count = await prisma.commission.count({ where: { ...where, ...(status && { status }), ...(linkId && { linkId }), ...(customerId && { customerId }), }, }); return NextResponse.json({ count }); } }, ); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/route.ts ================================================ import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates"; import { obfuscateCustomerEmail } from "@/lib/api/partner-profile/obfuscate-customer-email"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { generateRandomName } from "@/lib/names"; import { PartnerEarningsSchema, getPartnerEarningsQuerySchema, } from "@/lib/zod/schemas/partner-profile"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/partner-profile/programs/[programId]/earnings – get earnings for a partner in a program enrollment export const GET = withPartnerProfile( async ({ partner, params, searchParams }) => { const { program, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, }, }); const { page = 1, pageSize, type, status, sortBy, sortOrder, linkId, customerId, payoutId, interval, start, end, timezone, } = getPartnerEarningsQuerySchema.parse(searchParams); const { startDate, endDate } = getStartEndDates({ interval, start, end, timezone, }); const earnings = await prisma.commission.findMany({ where: { earnings: { not: 0, }, programId: program.id, partnerId: partner.id, status, type, linkId, customerId, payoutId, createdAt: { gte: startDate, lte: endDate, }, }, include: { customer: true, link: { select: { id: true, shortLink: true, url: true, }, }, }, skip: (page - 1) * pageSize, take: pageSize, orderBy: { [sortBy]: sortOrder }, }); const data = z.array(PartnerEarningsSchema).parse( earnings.map((e) => { // fallback to a random name if the customer doesn't have an email const customerEmail = e.customer?.email || e.customer?.name || generateRandomName(); return { ...e, customer: e.customer ? { ...e.customer, email: customerDataSharingEnabledAt ? customerEmail : obfuscateCustomerEmail(customerEmail), country: e.customer?.country, } : null, }; }), ); return NextResponse.json(data); }, ); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/[programId]/earnings/timeseries/route.ts ================================================ import { getPartnerEarningsTimeseries } from "@/lib/api/partner-profile/get-partner-earnings-timeseries"; import { withPartnerProfile } from "@/lib/auth/partner"; import { getPartnerEarningsTimeseriesSchema } from "@/lib/zod/schemas/partner-profile"; import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/[programId]/earnings/timeseries - get timeseries chart for a partner's earnings export const GET = withPartnerProfile( async ({ partner, params, searchParams }) => { const filters = getPartnerEarningsTimeseriesSchema.parse(searchParams); const timeseries = await getPartnerEarningsTimeseries({ partnerId: partner.id, programId: params.programId, filters, }); return NextResponse.json(timeseries); }, ); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/export/route.ts ================================================ import { eventsExportColumnAccessors, eventsExportColumnNames, } from "@/lib/analytics/events-export-helpers"; import { getFirstFilterValue } from "@/lib/analytics/filter-helpers"; import { getAnalytics } from "@/lib/analytics/get-analytics"; import { getEvents } from "@/lib/analytics/get-events"; import { convertToCSV } from "@/lib/analytics/utils"; import { DubApiError } from "@/lib/api/errors"; import { obfuscateCustomerEmail } from "@/lib/api/partner-profile/obfuscate-customer-email"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { LARGE_PROGRAM_IDS, LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS, MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING, } from "@/lib/constants/partner-profile"; import { qstash } from "@/lib/cron"; import { generateRandomName } from "@/lib/names"; import { PartnerProfileLinkSchema, partnerProfileEventsQuerySchema, } from "@/lib/zod/schemas/partner-profile"; import { APP_DOMAIN_WITH_NGROK, capitalize, parseFilterValue, toCentsNumber, } from "@dub/utils"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; const MAX_EVENTS_TO_EXPORT = 1000; // GET /api/partner-profile/programs/[programId]/events/export – get export data for partner profile events export const GET = withPartnerProfile( async ({ partner, params, searchParams, session }) => { const { program, links, totalCommissions, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, links: true, }, }); if ( LARGE_PROGRAM_IDS.includes(program.id) && toCentsNumber(totalCommissions) < LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS ) { throw new DubApiError({ code: "forbidden", message: "This feature is not available for your program.", }); } // early return if partner has no links if (links.length === 0) { throw new DubApiError({ code: "not_found", message: "No links found", }); } const parsedParams = partnerProfileEventsQuerySchema .extend({ columns: z .string() .optional() .transform((c) => (c ? c.split(",") : [])) .pipe(z.string().array()), }) .parse(searchParams); const { event, columns: columnsParam } = parsedParams; // Default columns based on event type if not provided const defaultColumns: Record = { clicks: ["timestamp", "link", "referer", "country", "device"], leads: ["timestamp", "event", "link", "customer", "referer"], sales: [ "timestamp", "saleAmount", "event", "customer", "referer", "link", ], }; const columns = columnsParam.length > 0 ? columnsParam : defaultColumns[event] || defaultColumns.clicks; const { linkId, domain, key } = parsedParams; if (linkId) { // check to make sure all of the linkId.values are in the links if ( !linkId.values.every((value) => links.some((link) => link.id === value)) ) { throw new DubApiError({ code: "not_found", message: "One or more links are not found", }); } if (linkId.sqlOperator === "NOT IN") { // if using NOT IN operator, we need to include all links except the ones in the linkId.values const finalIncludedLinkIds = links .filter((link) => !linkId.values.includes(link.id)) .map((link) => link.id); // early return if no links are left if (finalIncludedLinkIds.length === 0) { throw new DubApiError({ code: "not_found", message: "No links found", }); } parsedParams.linkId = { operator: "IS", sqlOperator: "IN", values: finalIncludedLinkIds, }; } } else if (domain && key) { const link = links.find( (link) => link.domain === getFirstFilterValue(domain) && link.key === key, ); if (!link) { throw new DubApiError({ code: "not_found", message: "Link not found", }); } parsedParams.linkId = { operator: "IS", sqlOperator: "IN", values: [link.id], }; } // Count events using getAnalytics with groupBy: "count" const countResponse = await getAnalytics({ ...parsedParams, event, groupBy: "count", workspaceId: program.workspaceId, ...(parsedParams.linkId ? { linkId: parsedParams.linkId } : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING ? { partnerId: partner.id } : { linkId: parseFilterValue(links.map((link) => link.id)) }), dataAvailableFrom: program.startedAt ?? program.createdAt, }); // Extract the count based on event type // getAnalytics with groupBy: "count" returns an object like { clicks: 123 } or { leads: 45 } or { sales: 10, saleAmount: 5000 } const eventsCount = typeof countResponse === "object" && countResponse !== null ? (countResponse[event as keyof typeof countResponse] as number) ?? 0 : typeof countResponse === "number" ? countResponse : 0; // Process the export in the background if the number of events is greater than MAX_EVENTS_TO_EXPORT if (eventsCount > MAX_EVENTS_TO_EXPORT) { await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/export/events/partner`, body: { ...searchParams, columns: columns.join(","), partnerId: partner.id, programId: params.programId, userId: session.user.id, dataAvailableFrom: ( program.startedAt ?? program.createdAt ).toISOString(), }, }); return NextResponse.json({}, { status: 202 }); } const events = await getEvents({ ...parsedParams, workspaceId: program.workspaceId, ...(parsedParams.linkId ? { linkId: parsedParams.linkId } : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING ? { partnerId: partner.id } : { linkId: parseFilterValue(links.map((link) => link.id)) }), limit: MAX_EVENTS_TO_EXPORT, }); // Apply partner profile data transformations similar to the main events route const transformedEvents = events.map((event) => { // don't return ip address for partner profile // @ts-ignore – ip is deprecated but present in the data const { ip, click, customer, ...eventRest } = event; const { ip: _, ...clickRest } = click; return { ...eventRest, click: clickRest, link: event?.link ? PartnerProfileLinkSchema.parse(event.link) : null, ...(customer && { customer: z .object({ id: z.string(), email: z.string(), ...(customerDataSharingEnabledAt && { name: z.string() }), }) .parse({ ...customer, email: customer.email ? customerDataSharingEnabledAt ? customer.email : obfuscateCustomerEmail(customer.email) : customer.name || generateRandomName(), ...(customerDataSharingEnabledAt && { name: customer.name || generateRandomName(), }), }), }), }; }); const data = transformedEvents.map((row) => Object.fromEntries( columns.map((c) => [ eventsExportColumnNames?.[c] ?? capitalize(c), eventsExportColumnAccessors[c]?.(row) ?? row?.[c], ]), ), ); const csvData = convertToCSV(data); return new Response(csvData, { headers: { "Content-Type": "application/csv", "Content-Disposition": `attachment; filename=${event}_export.csv`, }, }); }, ); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/[programId]/events/route.ts ================================================ import { getFirstFilterValue } from "@/lib/analytics/filter-helpers"; import { getEvents } from "@/lib/analytics/get-events"; import { DubApiError } from "@/lib/api/errors"; import { obfuscateCustomerEmail } from "@/lib/api/partner-profile/obfuscate-customer-email"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { LARGE_PROGRAM_IDS, LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS, MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING, } from "@/lib/constants/partner-profile"; import { generateRandomName } from "@/lib/names"; import { PartnerProfileLinkSchema, partnerProfileEventsQuerySchema, } from "@/lib/zod/schemas/partner-profile"; import { parseFilterValue, toCentsNumber } from "@dub/utils"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/partner-profile/programs/[programId]/events – get events for a program enrollment link export const GET = withPartnerProfile( async ({ partner, params, searchParams }) => { const { program, links, totalCommissions, customerDataSharingEnabledAt } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, links: true, }, }); if ( LARGE_PROGRAM_IDS.includes(program.id) && toCentsNumber(totalCommissions) < LARGE_PROGRAM_MIN_TOTAL_COMMISSIONS_CENTS ) { throw new DubApiError({ code: "forbidden", message: "This feature is not available for your program.", }); } // early return if partner has no links if (links.length === 0) { return NextResponse.json([], { status: 200 }); } const parsedParams = partnerProfileEventsQuerySchema.parse(searchParams); const { linkId, domain, key } = parsedParams; if (linkId) { // check to make sure all of the linkId.values are in the links if ( !linkId.values.every((value) => links.some((link) => link.id === value)) ) { throw new DubApiError({ code: "not_found", message: "One or more links are not found", }); } if (linkId.sqlOperator === "NOT IN") { // if using NOT IN operator, we need to include all links except the ones in the linkId.values const finalIncludedLinkIds = links .filter((link) => !linkId.values.includes(link.id)) .map((link) => link.id); // early return if no links are left if (finalIncludedLinkIds.length === 0) { return NextResponse.json([], { status: 200 }); } parsedParams.linkId = { operator: "IS", sqlOperator: "IN", values: finalIncludedLinkIds, }; } } else if (domain && key) { const link = links.find( (link) => link.domain === getFirstFilterValue(domain) && link.key === key, ); if (!link) { throw new DubApiError({ code: "not_found", message: "Link not found", }); } parsedParams.linkId = { operator: "IS", sqlOperator: "IN", values: [link.id], }; } const events = await getEvents({ ...parsedParams, workspaceId: program.workspaceId, ...(parsedParams.linkId ? { linkId: parsedParams.linkId } : links.length > MAX_PARTNER_LINKS_FOR_LOCAL_FILTERING ? { partnerId: partner.id } : { linkId: parseFilterValue(links.map((link) => link.id)) }), dataAvailableFrom: program.startedAt ?? program.createdAt, }); const response = events.map((event) => { // don't return ip address for partner profile // @ts-ignore – ip is deprecated but present in the data const { ip, click, customer, ...eventRest } = event; const { ip: _, ...clickRest } = click; return { ...eventRest, click: clickRest, link: event?.link ? PartnerProfileLinkSchema.parse(event.link) : null, ...(customer && { customer: z .object({ id: z.string(), email: z.string(), ...(customerDataSharingEnabledAt && { name: z.string() }), }) .parse({ ...customer, email: customer.email ? customerDataSharingEnabledAt ? customer.email : obfuscateCustomerEmail(customer.email) : customer.name || generateRandomName(), ...(customerDataSharingEnabledAt && { name: customer.name || generateRandomName(), }), }), }), }; }); return NextResponse.json(response); }, ); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/[programId]/groups/[groupIdOrSlug]/route.ts ================================================ import { getGroupOrThrow } from "@/lib/api/groups/get-group-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { PartnerProgramGroupSchema } from "@/lib/zod/schemas/groups"; import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/[programId]/groups/[groupIdOrSlug] - get information about a program's group export const GET = withPartnerProfile(async ({ params }) => { const { programId, groupIdOrSlug } = params; const group = await getGroupOrThrow({ programId, groupId: groupIdOrSlug, }); return NextResponse.json(PartnerProgramGroupSchema.parse(group)); }); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/[linkId]/route.ts ================================================ import { DubApiError, ErrorCodes } from "@/lib/api/errors"; import { deleteLink, processLink, updateLink } from "@/lib/api/links"; import { validatePartnerLinkUrl } from "@/lib/api/links/validate-partner-link-url"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; import { withPartnerProfile } from "@/lib/auth/partner"; import { NewLinkProps } from "@/lib/types"; import { PartnerProfileLinkSchema } from "@/lib/zod/schemas/partner-profile"; import { createPartnerLinkSchema } from "@/lib/zod/schemas/partners"; import { prisma } from "@dub/prisma"; import { getPrettyUrl, toCentsNumber } from "@dub/utils"; import { NextResponse } from "next/server"; // PATCH /api/partner-profile/[programId]/links/[linkId] - update a link for a partner export const PATCH = withPartnerProfile( async ({ partner, params, req, session }) => { const { url, key, comments } = createPartnerLinkSchema .pick({ url: true, key: true, comments: true }) .parse(await parseRequestBody(req)); const { programId, linkId } = params; const { program, links, status, partnerGroup: group, } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId, include: { program: true, links: true, partnerGroup: true, }, }); if (["banned", "deactivated"].includes(status)) { throw new DubApiError({ code: "forbidden", message: "You are banned from this program.", }); } if (!group) { throw new DubApiError({ code: "forbidden", message: "You're not part of any group yet. Please reach out to the program owner to be added.", }); } const link = links.find((link) => link.id === linkId); if (!link) { throw new DubApiError({ code: "not_found", message: "Link not found.", }); } if (!program.domain || !program.url) { throw new DubApiError({ code: "bad_request", message: "This program needs a domain and URL set before creating a link.", }); } const linkUrlChanged = getPrettyUrl(link.url) !== getPrettyUrl(url); if (linkUrlChanged) { if (link.partnerGroupDefaultLinkId) { throw new DubApiError({ code: "forbidden", message: "You cannot update the destination URL of your default link.", }); } else { validatePartnerLinkUrl({ group, url }); } } // check if the group has a UTM template const groupUtmTemplate = group.utmTemplateId ? await prisma.utmTemplate.findUnique({ where: { id: group.utmTemplateId, }, }) : null; // if domain and key are the same, we don't need to check if the key exists const skipKeyChecks = link.key.toLowerCase() === key?.toLowerCase(); const { link: processedLink, error, code, } = await processLink({ payload: { ...link, ...(groupUtmTemplate ? extractUtmParams(groupUtmTemplate) : {}), // coerce types expiresAt: link.expiresAt instanceof Date ? link.expiresAt.toISOString() : link.expiresAt, geo: link.geo as NewLinkProps["geo"], testVariants: link.testVariants as NewLinkProps["testVariants"], testCompletedAt: link.testCompletedAt instanceof Date ? link.testCompletedAt.toISOString() : link.testCompletedAt, testStartedAt: link.testStartedAt instanceof Date ? link.testStartedAt.toISOString() : link.testStartedAt, // merge in new props key: key || undefined, url: url || program.url, comments, }, workspace: { id: program.workspaceId, plan: "business", users: [{ role: "owner" }], }, userId: session.user.id, skipKeyChecks, skipFolderChecks: true, // can't be changed by the partner skipProgramChecks: true, // can't be changed by the partner skipExternalIdChecks: true, // can't be changed by the partner }); if (error != null) { throw new DubApiError({ code: code as ErrorCodes, message: error, }); } const partnerLink = await updateLink({ oldLink: { domain: link.domain, key: link.key, image: link.image, }, updatedLink: processedLink, }); return NextResponse.json(PartnerProfileLinkSchema.parse(partnerLink)); }, ); // DELETE /api/partner-profile/[programId]/links/[linkId] - delete a link for a partner export const DELETE = withPartnerProfile(async ({ partner, params }) => { const { programId, linkId } = params; const { links, status } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId, include: { links: true, }, }); if (["banned", "deactivated"].includes(status)) { throw new DubApiError({ code: "forbidden", message: "You are banned from this program.", }); } const link = links.find((link) => link.id === linkId); if (!link) { throw new DubApiError({ code: "not_found", message: "Link not found.", }); } // Check if this is a default link if (link.partnerGroupDefaultLinkId) { throw new DubApiError({ code: "forbidden", message: "You cannot delete your default link.", }); } // Check if link has any clicks, leads, or sales if (link.clicks > 0 || link.leads > 0 || toCentsNumber(link.saleAmount) > 0) { throw new DubApiError({ code: "bad_request", message: "You can only delete links with 0 clicks, 0 leads, and $0 in sales.", }); } // Delete the link await deleteLink(link.id); return NextResponse.json({ id: link.id }); }); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/[programId]/links/route.ts ================================================ import { DubApiError, ErrorCodes } from "@/lib/api/errors"; import { createLink, processLink } from "@/lib/api/links"; import { validatePartnerLinkUrl } from "@/lib/api/links/validate-partner-link-url"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; import { withPartnerProfile } from "@/lib/auth/partner"; import { PartnerProfileLinkSchema } from "@/lib/zod/schemas/partner-profile"; import { createPartnerLinkSchema, INACTIVE_ENROLLMENT_STATUSES, } from "@/lib/zod/schemas/partners"; import { prisma } from "@dub/prisma"; import { getUTMParamsFromURL } from "@dub/utils"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/partner-profile/programs/[programId]/links - get a partner's links in a program export const GET = withPartnerProfile(async ({ partner, params }) => { const { links, discountCodes } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { links: true, discountCodes: true, }, }); // Add discount code to the links const linksByDiscountCode = new Map( discountCodes?.map((discountCode) => [discountCode.linkId, discountCode]), ); const result = links.map((link) => { const discountCode = linksByDiscountCode.get(link.id); return { ...link, discountCode: discountCode?.code, }; }); return NextResponse.json(z.array(PartnerProfileLinkSchema).parse(result)); }); // POST /api/partner-profile/[programId]/links - create a link for a partner export const POST = withPartnerProfile( async ({ partner, params, req, session }) => { const { url, key, comments } = createPartnerLinkSchema .pick({ url: true, key: true, comments: true }) .parse(await parseRequestBody(req)); const { program, links, tenantId, status, partnerGroup: group, } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, links: true, partnerGroup: true, }, }); if (INACTIVE_ENROLLMENT_STATUSES.includes(status)) { throw new DubApiError({ code: "forbidden", message: `You cannot create links in this program because you have been ${status}`, }); } if (!program.domain || !program.url) { throw new DubApiError({ code: "bad_request", message: "This program needs a domain and URL set before creating a link.", }); } if (!group) { throw new DubApiError({ code: "forbidden", message: "You’re not part of any group yet. Please reach out to the program owner to be added.", }); } if (links.length >= group.maxPartnerLinks) { throw new DubApiError({ code: "bad_request", message: `You have reached this program's limit of ${group.maxPartnerLinks} partner links.`, }); } validatePartnerLinkUrl({ group, url }); // check if the group has a UTM template const groupUtmTemplate = group.utmTemplateId ? await prisma.utmTemplate.findUnique({ where: { id: group.utmTemplateId, }, }) : null; const linkUrl = url || program.url; const { link, error, code } = await processLink({ payload: { domain: program.domain, key: key || undefined, url: linkUrl, ...(groupUtmTemplate ? { ...extractUtmParams(groupUtmTemplate), ...getUTMParamsFromURL(linkUrl), } : {}), programId: program.id, tenantId, partnerId: partner.id, folderId: program.defaultFolderId, comments, trackConversion: true, }, workspace: { id: program.workspaceId, plan: "business", users: [{ role: "owner" }], }, userId: session.user.id, // TODO: Hm, this is the partner user, not the workspace user? skipFolderChecks: true, // can't be changed by the partner skipProgramChecks: true, // can't be changed by the partner skipExternalIdChecks: true, // can't be changed by the partner }); if (error != null) { throw new DubApiError({ code: code as ErrorCodes, message: error, }); } const partnerLink = await createLink(link); return NextResponse.json(PartnerProfileLinkSchema.parse(partnerLink), { status: 201, }); }, ); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/[programId]/referrals/count/route.ts ================================================ import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { getPartnerReferralsCountQuerySchema, partnerReferralsCountResponseSchema, } from "@/lib/zod/schemas/partner-profile"; import { prisma, sanitizeFullTextSearch } from "@dub/prisma"; import { Prisma, ReferralStatus } from "@dub/prisma/client"; import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/[programId]/referrals/count - get the count of referrals for the current partner in a program export const GET = withPartnerProfile( async ({ partner, params, searchParams }) => { const { programId } = params; const { status, search, groupBy } = getPartnerReferralsCountQuerySchema.parse(searchParams); const { program } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: programId, include: { program: true, }, }); const commonWhere: Prisma.PartnerReferralWhereInput = { programId: program.id, partnerId: partner.id, ...(status && groupBy !== "status" && { status }), ...(search ? search.includes("@") ? { email: search } : { email: { search: sanitizeFullTextSearch(search) }, name: { search: sanitizeFullTextSearch(search) }, } : {}), }; // Get referral count by status if (groupBy === "status") { const data = await prisma.partnerReferral.groupBy({ by: ["status"], where: commonWhere, _count: true, orderBy: { _count: { status: "desc", }, }, }); // Fill in missing statuses with zero counts Object.values(ReferralStatus).forEach((status) => { if (!data.some((d) => d.status === status)) { data.push({ _count: 0, status }); } }); return NextResponse.json(partnerReferralsCountResponseSchema.parse(data)); } // Get referral count const count = await prisma.partnerReferral.count({ where: commonWhere, }); return NextResponse.json(partnerReferralsCountResponseSchema.parse(count)); }, ); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/[programId]/referrals/route.ts ================================================ import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { getPartnerReferralsQuerySchema, partnerProfileReferralSchema, } from "@/lib/zod/schemas/partner-profile"; import { prisma, sanitizeFullTextSearch } from "@dub/prisma"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/partner-profile/programs/[programId]/referrals - get all referrals for the current partner in a program export const GET = withPartnerProfile( async ({ partner, params, searchParams }) => { const { programId } = params; const { status, search, page = 1, pageSize, } = getPartnerReferralsQuerySchema.parse(searchParams); const { program } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: programId, include: { program: true, }, }); const referrals = await prisma.partnerReferral.findMany({ where: { programId: program.id, partnerId: partner.id, ...(status && { status }), ...(search ? search.includes("@") ? { email: search } : { email: { search: sanitizeFullTextSearch(search) }, name: { search: sanitizeFullTextSearch(search) }, } : {}), }, skip: (page - 1) * pageSize, take: pageSize, orderBy: { createdAt: "desc", }, }); return NextResponse.json( z.array(partnerProfileReferralSchema).parse(referrals), ); }, ); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/[programId]/resources/route.ts ================================================ import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { programResourcesSchema } from "@/lib/zod/schemas/program-resources"; import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/[programId]/resources – get resources for an enrolled program export const GET = withPartnerProfile(async ({ partner, params }) => { const { program } = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, }, }); const resources = programResourcesSchema.parse( program?.resources ?? { logos: [], colors: [], files: [], }, ); return NextResponse.json(resources); }); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/[programId]/route.ts ================================================ import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; import { ProgramEnrollmentSchema } from "@/lib/zod/schemas/programs"; import { Reward } from "@dub/prisma/client"; import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/[programId] – get a partner's enrollment in a program export const GET = withPartnerProfile(async ({ partner, params }) => { const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId: params.programId, include: { program: true, partner: true, links: true, clickReward: true, leadReward: true, saleReward: true, discount: true, partnerGroup: true, }, }); const rewards = [ programEnrollment.clickReward, programEnrollment.leadReward, programEnrollment.saleReward, ].filter((r): r is Reward => r !== null); return NextResponse.json( ProgramEnrollmentSchema.parse({ ...programEnrollment, rewards, group: programEnrollment.partnerGroup, }), ); }); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/count/route.ts ================================================ import { withPartnerProfile } from "@/lib/auth/partner"; import { partnerProfileProgramsCountQuerySchema } from "@/lib/zod/schemas/partner-profile"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/partner-profile/programs/count - count program enrollments for a given partnerId export const GET = withPartnerProfile(async ({ partner, searchParams }) => { const { status } = partnerProfileProgramsCountQuerySchema.parse(searchParams); const count = await prisma.programEnrollment.count({ where: { partnerId: partner.id, ...(status && { status }), }, }); return NextResponse.json(count); }); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/programs/route.ts ================================================ import { withPartnerProfile } from "@/lib/auth/partner"; import { partnerProfileProgramsQuerySchema } from "@/lib/zod/schemas/partner-profile"; import { ProgramEnrollmentSchema } from "@/lib/zod/schemas/programs"; import { prisma } from "@dub/prisma"; import { Reward } from "@dub/prisma/client"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/partner-profile/programs - get all program enrollments for a given partnerId export const GET = withPartnerProfile(async ({ partner, searchParams }) => { const { includeRewardsDiscounts, status } = partnerProfileProgramsQuerySchema.parse(searchParams); const programEnrollments = await prisma.programEnrollment.findMany({ where: { partnerId: partner.id, ...(status && { status }), program: { deactivatedAt: null, }, }, include: { links: { take: 1, orderBy: { createdAt: "asc", }, }, program: { include: { workspace: { select: { plan: true, }, }, }, }, ...(includeRewardsDiscounts && { clickReward: true, leadReward: true, saleReward: true, discount: true, }), }, orderBy: [ { totalCommissions: "desc", }, { createdAt: "asc", }, ], }); const response = programEnrollments.map((enrollment) => { return { ...enrollment, rewards: includeRewardsDiscounts ? [ enrollment.clickReward, enrollment.leadReward, enrollment.saleReward, ].filter((r): r is Reward => r !== null) : [], }; }); return NextResponse.json(z.array(ProgramEnrollmentSchema).parse(response)); }); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/rewind/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { getPartnerRewind } from "@/lib/api/partners/get-partner-rewind"; import { withPartnerProfile } from "@/lib/auth/partner"; import { NextResponse } from "next/server"; // GET /api/partner-profile/rewind - get a partner rewind export const GET = withPartnerProfile(async ({ partner }) => { const partnerRewind = await getPartnerRewind({ partnerId: partner.id, }); if (!partnerRewind) throw new DubApiError({ code: "not_found", message: "Partner rewind not found", }); return NextResponse.json(partnerRewind); }); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/route.ts ================================================ import { withPartnerProfile } from "@/lib/auth/partner"; import { getPartnerFeatureFlags } from "@/lib/edge-config"; import { NextResponse } from "next/server"; // GET /api/partner-profile - get a partner profile export const GET = withPartnerProfile(async ({ partner, partnerUser }) => { const featureFlags = await getPartnerFeatureFlags(partner.id); return NextResponse.json({ ...partnerUser, ...partner, featureFlags, }); }); ================================================ FILE: apps/web/app/(ee)/api/partner-profile/users/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { parseRequestBody } from "@/lib/api/utils"; import { withPartnerProfile } from "@/lib/auth/partner"; import { throwIfNoPermission } from "@/lib/auth/partner-users/throw-if-no-permission"; import { getPartnerUsersQuerySchema, partnerUserSchema, } from "@/lib/zod/schemas/partner-profile"; import { prisma } from "@dub/prisma"; import { PartnerRole } from "@dub/prisma/client"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/partner-profile/users - list of users export const GET = withPartnerProfile(async ({ partner, searchParams }) => { const { search, role } = getPartnerUsersQuerySchema.parse(searchParams); const users = await prisma.partnerUser.findMany({ where: { partnerId: partner.id, role, ...(search && { OR: [ { user: { name: { contains: search, }, }, }, { user: { email: { contains: search, }, }, }, ], }), }, include: { user: true, }, }); const parsedUsers = users.map(({ user, ...rest }) => partnerUserSchema.parse({ ...rest, ...user, createdAt: rest.createdAt, // preserve the createdAt field from PartnerUser }), ); return NextResponse.json(parsedUsers); }); const updateRoleSchema = z.object({ userId: z.string(), role: z.enum(PartnerRole), }); // PATCH /api/partner-profile/users - update a user's role export const PATCH = withPartnerProfile( async ({ req, partner, session }) => { const { userId, role } = updateRoleSchema.parse( await parseRequestBody(req), ); if (userId === session.user.id) { throw new DubApiError({ code: "forbidden", message: "You cannot change your own role.", }); } // Wrap read and mutation in a transaction to prevent TOCTOU race conditions const response = await prisma.$transaction(async (tx) => { const [partnerUserFound, totalOwners] = await Promise.all([ tx.partnerUser.findUnique({ where: { userId_partnerId: { userId, partnerId: partner.id, }, }, }), tx.partnerUser.count({ where: { partnerId: partner.id, role: "owner", }, }), ]); if (!partnerUserFound) { throw new DubApiError({ code: "not_found", message: "The user you're trying to update was not found.", }); } if ( totalOwners === 1 && partnerUserFound.role === "owner" && role !== "owner" ) { throw new DubApiError({ code: "bad_request", message: "Cannot change the role of the last owner. Please assign another owner first.", }); } return tx.partnerUser.update({ where: { userId_partnerId: { userId, partnerId: partner.id, }, }, data: { role, }, }); }); return NextResponse.json(response); }, { requiredPermission: "users.update", }, ); const removeUserSchema = z.object({ userId: z.string(), }); // DELETE /api/partner-profile/users?userId={userId} - remove a user export const DELETE = withPartnerProfile( async ({ searchParams, partner, partnerUser }) => { const { userId } = removeUserSchema.parse(searchParams); // Wrap read and mutation in a transaction to prevent TOCTOU race conditions const response = await prisma.$transaction(async (tx) => { const [userToRemove, totalOwners] = await Promise.all([ tx.partnerUser.findUnique({ where: { userId_partnerId: { userId, partnerId: partner.id, }, }, }), tx.partnerUser.count({ where: { partnerId: partner.id, role: "owner", }, }), ]); if (!userToRemove) { throw new DubApiError({ code: "not_found", message: "The user you're trying to remove was not found in this partner profile.", }); } const isSelfRemoval = userToRemove.userId === partnerUser.userId; if (!isSelfRemoval) { throwIfNoPermission({ role: partnerUser.role, permission: "users.delete", }); } if (totalOwners === 1 && userToRemove.role === "owner") { throw new DubApiError({ code: "bad_request", message: "You can't remove the only owner from this partner profile. Please assign another owner before removing this one.", }); } return tx.partnerUser.delete({ where: { id: userToRemove.id, }, }); }); return NextResponse.json(response); }, ); ================================================ FILE: apps/web/app/(ee)/api/partners/[partnerId]/application-risks/route.ts ================================================ import { getPartnerApplicationRisks } from "@/lib/api/fraud/get-partner-application-risks"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withWorkspace } from "@/lib/auth"; import { NextResponse } from "next/server"; // GET /api/partners/:partnerId/application-risks - get application risks for a partner export const GET = withWorkspace( async ({ workspace, params }) => { const { partnerId } = params; const programId = getDefaultProgramIdOrThrow(workspace); const { partner } = await getProgramEnrollmentOrThrow({ partnerId, programId, include: { partner: { include: { platforms: true, }, }, }, }); const { risksDetected, riskSeverity } = await getPartnerApplicationRisks({ program: { id: programId }, partner, }); return NextResponse.json({ risksDetected, riskSeverity, }); }, { requiredPlan: ["advanced", "enterprise"], }, ); ================================================ FILE: apps/web/app/(ee)/api/partners/[partnerId]/comments/count/route.ts ================================================ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/partners/:id/comments/count – Get partner comments count export const GET = withWorkspace( async ({ workspace, params }) => { const { partnerId } = params; const programId = getDefaultProgramIdOrThrow(workspace); const count = await prisma.partnerComment.count({ where: { programId, partnerId, }, }); return NextResponse.json(count); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], }, ); ================================================ FILE: apps/web/app/(ee)/api/partners/[partnerId]/comments/route.ts ================================================ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { PartnerCommentSchema } from "@/lib/zod/schemas/programs"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/partners/:id/comments – Get partner comments export const GET = withWorkspace( async ({ workspace, params }) => { const { partnerId } = params; const programId = getDefaultProgramIdOrThrow(workspace); const comments = await prisma.partnerComment.findMany({ where: { programId, partnerId, }, include: { user: true, }, orderBy: { createdAt: "desc", }, }); return NextResponse.json(z.array(PartnerCommentSchema).parse(comments)); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], }, ); ================================================ FILE: apps/web/app/(ee)/api/partners/[partnerId]/cross-program-summary/route.ts ================================================ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withWorkspace } from "@/lib/auth"; import { ACTIVE_ENROLLMENT_STATUSES, partnerCrossProgramSummarySchema, } from "@/lib/zod/schemas/partners"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/partners/:partnerId/cross-program-summary - get cross-program summary for a partner export const GET = withWorkspace( async ({ workspace, params }) => { const { partnerId } = params; const programId = getDefaultProgramIdOrThrow(workspace); await getProgramEnrollmentOrThrow({ partnerId, programId, include: {}, }); const programEnrollments = await prisma.programEnrollment.groupBy({ by: ["status"], where: { partnerId, }, _count: true, }); // approved and archived statuses const activePrograms = programEnrollments .filter((enrollment) => ACTIVE_ENROLLMENT_STATUSES.includes(enrollment.status), ) .reduce((acc, enrollment) => acc + enrollment._count, 0); // banned statuses const bannedPrograms = programEnrollments.find((enrollment) => enrollment.status === "banned") ?._count ?? 0; return NextResponse.json( partnerCrossProgramSummarySchema.parse({ totalPrograms: activePrograms + bannedPrograms, activePrograms, bannedPrograms, }), ); }, { requiredPlan: ["advanced", "enterprise"], }, ); ================================================ FILE: apps/web/app/(ee)/api/partners/[partnerId]/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { getPartnerForProgram } from "@/lib/api/partner-profile/get-partner-for-program"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { EnrolledPartnerSchemaExtended } from "@/lib/zod/schemas/partners"; import { NextResponse } from "next/server"; // GET /api/partners/:partnerId – Get a partner by ID export const GET = withWorkspace( async ({ workspace, params }) => { const { partnerId } = params; const programId = getDefaultProgramIdOrThrow(workspace); const partner = await getPartnerForProgram({ programId, partnerId, }); if (!partner) throw new DubApiError({ code: "not_found", message: "Partner not found.", }); return NextResponse.json(EnrolledPartnerSchemaExtended.parse(partner)); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], }, ); ================================================ FILE: apps/web/app/(ee)/api/partners/analytics/route.ts ================================================ import { getAnalytics } from "@/lib/analytics/get-analytics"; import { getStartEndDates } from "@/lib/analytics/utils/get-start-end-dates"; import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { throwIfNoPartnerIdOrTenantId } from "@/lib/partners/throw-if-no-partnerid-tenantid"; import { sqlGranularityMap } from "@/lib/planetscale/granularity"; import { partnerAnalyticsQuerySchema, partnersTopLinksSchema, } from "@/lib/zod/schemas/partners"; import { prisma } from "@dub/prisma"; import { parseFilterValue } from "@dub/utils"; import { format } from "date-fns"; import { NextResponse } from "next/server"; // GET /api/partners/analytics – get analytics for a partner export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { groupBy, partnerId, tenantId, interval = "all", start, end, timezone, query, } = partnerAnalyticsQuerySchema.parse(searchParams); throwIfNoPartnerIdOrTenantId({ partnerId, tenantId }); const programEnrollment = await prisma.programEnrollment.findUnique({ where: partnerId ? { partnerId_programId: { partnerId, programId, }, } : { tenantId_programId: { tenantId: tenantId!, programId, }, }, include: { program: true, links: { orderBy: { clicks: "desc", }, }, }, }); if (!programEnrollment) { throw new DubApiError({ code: "not_found", message: `The partner with ${partnerId ? "partnerId" : "tenantId"} ${partnerId ?? tenantId} is not enrolled in your program.`, }); } if (programEnrollment.program.workspaceId !== workspace.id) { throw new DubApiError({ code: "not_found", message: "Program not found.", }); } const analytics = await getAnalytics({ event: "composite", groupBy, linkId: parseFilterValue(programEnrollment.links.map((link) => link.id)), interval, start, end, timezone, query, }); const { startDate, endDate, granularity } = getStartEndDates({ interval, start, end, timezone, }); // Group by count if (groupBy === "count") { const earnings = await prisma.commission.aggregate({ _sum: { earnings: true, }, where: { type: "sale", amount: { gt: 0, }, programId: programEnrollment.programId, partnerId: programEnrollment.partnerId, status: { in: ["pending", "processed", "paid"], }, createdAt: { gte: startDate, lt: endDate, }, }, }); return NextResponse.json({ ...analytics, earnings: earnings._sum.earnings || 0, }); } const { dateFormat } = sqlGranularityMap[granularity]; // Group by timeseries if (groupBy === "timeseries") { const earnings = await prisma.$queryRaw< { start: string; earnings: number }[] >` SELECT DATE_FORMAT(CONVERT_TZ(createdAt, '+00:00', ${timezone || "+00:00"}), ${dateFormat}) AS start, SUM(earnings) AS earnings FROM Commission WHERE earnings > 0 AND programId = ${programEnrollment.programId} AND partnerId = ${programEnrollment.partnerId} AND status in ('pending', 'processed', 'paid') AND type = 'sale' AND createdAt >= ${startDate} AND createdAt < ${endDate} GROUP BY start ORDER BY start ASC;`; const earningsLookup = Object.fromEntries( earnings.map((item) => [ format( new Date(item.start), granularity === "hour" ? "yyyy-MM-dd'T'HH:00" : "yyyy-MM-dd'T'00:00", ), { earnings: item.earnings, }, ]), ); const analyticsWithRevenue = analytics.map((item) => { const formattedDateTime = format( new Date(item.start), granularity === "hour" ? "yyyy-MM-dd'T'HH:00" : "yyyy-MM-dd'T'00:00", ); return { ...item, earnings: Number(earningsLookup[formattedDateTime]?.earnings ?? 0), }; }); return NextResponse.json(analyticsWithRevenue); } // Group by top_links const topLinkEarnings = await prisma.commission.groupBy({ by: ["linkId"], where: { type: "sale", amount: { gt: 0, }, programId: programEnrollment.programId, partnerId: programEnrollment.partnerId, status: { in: ["pending", "processed", "paid"], }, createdAt: { gte: startDate, lt: endDate, }, }, _sum: { earnings: true, }, }); const topLinksWithEarnings = programEnrollment.links.map((link) => { const analyticsData = analytics.find((a) => a.id === link.id); const earnings = topLinkEarnings.find((t) => t.linkId === link.id); return partnersTopLinksSchema.parse({ ...link, ...analyticsData, link: link.id, createdAt: link.createdAt.toISOString(), earnings: Number(earnings?._sum.earnings ?? 0), }); }); return NextResponse.json(topLinksWithEarnings); }, { requiredPlan: ["advanced", "enterprise"], }, ); ================================================ FILE: apps/web/app/(ee)/api/partners/ban/route.ts ================================================ import { banPartner } from "@/lib/actions/partners/ban-partner"; import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { throwIfNoPartnerIdOrTenantId } from "@/lib/partners/throw-if-no-partnerid-tenantid"; import { banPartnerApiSchema } from "@/lib/zod/schemas/partners"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // POST /api/partners/ban – Ban a partner via API export const POST = withWorkspace( async ({ workspace, req, session }) => { let { partnerId, tenantId, reason } = banPartnerApiSchema.parse( await parseRequestBody(req), ); throwIfNoPartnerIdOrTenantId({ partnerId, tenantId }); if (tenantId && !partnerId) { const programId = getDefaultProgramIdOrThrow(workspace); const programEnrollment = await prisma.programEnrollment.findUnique({ where: { tenantId_programId: { tenantId, programId, }, }, select: { partnerId: true, }, }); if (!programEnrollment) { throw new DubApiError({ code: "not_found", message: `Partner with tenantId ${tenantId} not found in program.`, }); } partnerId = programEnrollment.partnerId; } await banPartner({ workspace, partnerId: partnerId!, // coerce here because we're already throwing if no partnerId or tenantId reason, user: session.user, }); return NextResponse.json({ partnerId, }); }, { requiredPlan: ["advanced", "enterprise"], requiredRoles: ["owner", "member"], }, ); ================================================ FILE: apps/web/app/(ee)/api/partners/count/route.ts ================================================ import { getPartnersCount } from "@/lib/api/partners/get-partners-count"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { partnersCountQuerySchema } from "@/lib/zod/schemas/partners"; import { NextResponse } from "next/server"; // GET /api/partners/count - get the count of partners for a program export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const parsedParams = partnersCountQuerySchema.parse(searchParams); const count = await getPartnersCount({ ...parsedParams, programId, }); return NextResponse.json(count); }, { requiredPlan: [ "business", "business extra", "business max", "business plus", "advanced", "enterprise", ], }, ); ================================================ FILE: apps/web/app/(ee)/api/partners/deactivate/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { deactivatePartner } from "@/lib/api/partners/deactivate-partner"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { throwIfNoPartnerIdOrTenantId } from "@/lib/partners/throw-if-no-partnerid-tenantid"; import { deactivatePartnerApiSchema } from "@/lib/zod/schemas/partners"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // POST /api/partners/deactivate – Deactivate a partner via API export const POST = withWorkspace( async ({ workspace, req, session }) => { let { partnerId, tenantId } = deactivatePartnerApiSchema.parse( await parseRequestBody(req), ); throwIfNoPartnerIdOrTenantId({ partnerId, tenantId, }); const programId = getDefaultProgramIdOrThrow(workspace); if (tenantId && !partnerId) { const programEnrollment = await prisma.programEnrollment.findUnique({ where: { tenantId_programId: { tenantId, programId, }, }, select: { partnerId: true, }, }); if (!programEnrollment) { throw new DubApiError({ code: "not_found", message: `Partner with tenantId ${tenantId} not found in program.`, }); } partnerId = programEnrollment.partnerId; } await deactivatePartner({ workspaceId: workspace.id, programId, partnerId: partnerId!, // coerce here because we're already throwing if no partnerId or tenantId user: session.user, }); return NextResponse.json({ partnerId, }); }, { requiredPlan: ["advanced", "enterprise"], requiredRoles: ["owner", "member"], }, ); ================================================ FILE: apps/web/app/(ee)/api/partners/export/route.ts ================================================ import { convertToCSV } from "@/lib/analytics/utils/convert-to-csv"; import { formatPartnersForExport } from "@/lib/api/partners/format-partners-for-export"; import { getPartners } from "@/lib/api/partners/get-partners"; import { getPartnersCount } from "@/lib/api/partners/get-partners-count"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { qstash } from "@/lib/cron"; import { partnersExportQuerySchema } from "@/lib/zod/schemas/partners"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { NextResponse } from "next/server"; const MAX_PARTNERS_TO_EXPORT = 1000; // GET /api/partners/export – export partners to CSV export const GET = withWorkspace( async ({ searchParams, workspace, session }) => { const programId = getDefaultProgramIdOrThrow(workspace); const parsedParams = partnersExportQuerySchema.parse(searchParams); const { columns, ...filters } = parsedParams; const partnersCount = await getPartnersCount({ ...filters, groupBy: undefined, programId, }); // Process the export in the background if the number of partners is greater than MAX_PARTNERS_TO_EXPORT if (partnersCount > MAX_PARTNERS_TO_EXPORT) { await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/export/partners`, body: { ...parsedParams, columns: columns.join(","), programId, userId: session.user.id, }, }); return NextResponse.json({}, { status: 202 }); } const partners = await getPartners({ ...filters, page: 1, pageSize: MAX_PARTNERS_TO_EXPORT, programId, }); const formattedPartners = formatPartnersForExport(partners, columns); return new Response(convertToCSV(formattedPartners), { headers: { "Content-Type": "text/csv", "Content-Disposition": "attachment", }, }); }, { requiredPlan: [ "business", "business extra", "business max", "business plus", "advanced", "enterprise", ], }, ); ================================================ FILE: apps/web/app/(ee)/api/partners/links/route.ts ================================================ import { DubApiError, ErrorCodes } from "@/lib/api/errors"; import { createLink, processLink } from "@/lib/api/links"; import { validatePartnerLinkUrl } from "@/lib/api/links/validate-partner-link-url"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; import { withWorkspace } from "@/lib/auth"; import { throwIfNoPartnerIdOrTenantId } from "@/lib/partners/throw-if-no-partnerid-tenantid"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { linkEventSchema } from "@/lib/zod/schemas/links"; import { createPartnerLinkSchema, retrievePartnerLinksSchema, } from "@/lib/zod/schemas/partners"; import { ProgramPartnerLinkSchema } from "@/lib/zod/schemas/programs"; import { prisma } from "@dub/prisma"; import { getUTMParamsFromURL } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/partners/links - get the partner links export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { partnerId, tenantId } = retrievePartnerLinksSchema.parse(searchParams); throwIfNoPartnerIdOrTenantId({ partnerId, tenantId }); const programEnrollment = await prisma.programEnrollment.findUnique({ where: partnerId ? { partnerId_programId: { partnerId, programId, }, } : { tenantId_programId: { tenantId: tenantId as string, programId, }, }, select: { links: true, }, }); if (!programEnrollment) { throw new DubApiError({ code: "not_found", message: "Partner not found.", }); } const { links } = programEnrollment; return NextResponse.json(z.array(ProgramPartnerLinkSchema).parse(links)); }, { requiredPlan: ["advanced", "enterprise"], requiredRoles: ["owner", "member"], }, ); // POST /api/partners/links - create a link for a partner export const POST = withWorkspace( async ({ workspace, req, session }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { partnerId, tenantId, url, key, linkProps } = createPartnerLinkSchema.parse(await parseRequestBody(req)); const program = await getProgramOrThrow({ workspaceId: workspace.id, programId, }); if (!program.domain || !program.url) { throw new DubApiError({ code: "bad_request", message: "You need to set a domain and url for this program before creating a link.", }); } throwIfNoPartnerIdOrTenantId({ partnerId, tenantId }); const partner = await prisma.programEnrollment.findUnique({ where: partnerId ? { partnerId_programId: { partnerId, programId } } : { tenantId_programId: { tenantId: tenantId!, programId } }, include: { partnerGroup: { include: { partnerGroupDefaultLinks: true, utmTemplate: true, }, }, }, }); if (!partner) { throw new DubApiError({ code: "not_found", message: "Partner not found.", }); } const partnerGroup = partner.partnerGroup; // shouldn't happen but just in case if (!partnerGroup) { throw new DubApiError({ code: "not_found", message: "This partner is not part of a partner group.", }); } validatePartnerLinkUrl({ group: partnerGroup, url }); const linkUrl = url || partnerGroup.partnerGroupDefaultLinks[0].url; const { link, error, code } = await processLink({ payload: { ...linkProps, domain: program.domain, key: key || undefined, url: linkUrl, ...(partnerGroup.utmTemplate ? { ...extractUtmParams(partnerGroup.utmTemplate), ...getUTMParamsFromURL(linkUrl), } : {}), programId: program.id, tenantId: partner.tenantId, partnerId: partner.partnerId, folderId: program.defaultFolderId, trackConversion: true, }, workspace, userId: session.user.id, skipProgramChecks: true, // skip this cause we've already validated the program above }); if (error != null) { throw new DubApiError({ code: code as ErrorCodes, message: error, }); } const partnerLink = await createLink(link); waitUntil( sendWorkspaceWebhook({ trigger: "link.created", workspace, data: linkEventSchema.parse(partnerLink), }), ); return NextResponse.json(partnerLink, { status: 201 }); }, { requiredPlan: ["advanced", "enterprise"], requiredRoles: ["owner", "member"], }, ); ================================================ FILE: apps/web/app/(ee)/api/partners/links/upsert/route.ts ================================================ import { DubApiError, ErrorCodes } from "@/lib/api/errors"; import { createLink, processLink, transformLink, updateLink, } from "@/lib/api/links"; import { includeTags } from "@/lib/api/links/include-tags"; import { validatePartnerLinkUrl } from "@/lib/api/links/validate-partner-link-url"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; import { withWorkspace } from "@/lib/auth"; import { throwIfNoPartnerIdOrTenantId } from "@/lib/partners/throw-if-no-partnerid-tenantid"; import { NewLinkProps } from "@/lib/types"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { linkEventSchema } from "@/lib/zod/schemas/links"; import { upsertPartnerLinkSchema } from "@/lib/zod/schemas/partners"; import { prisma } from "@dub/prisma"; import { deepEqual, getUTMParamsFromURL } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // PUT /api/partners/links/upsert – update or create a partner link export const PUT = withWorkspace( async ({ req, headers, workspace, session }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { partnerId, tenantId, url, key, linkProps } = upsertPartnerLinkSchema.parse(await parseRequestBody(req)); throwIfNoPartnerIdOrTenantId({ partnerId, tenantId }); const program = await getProgramOrThrow({ workspaceId: workspace.id, programId, }); if (!program.domain || !program.url) { throw new DubApiError({ code: "bad_request", message: "You need to set a domain and url for this program before upserting a partner link.", }); } const partner = await prisma.programEnrollment.findUnique({ where: partnerId ? { partnerId_programId: { partnerId, programId } } : { tenantId_programId: { tenantId: tenantId!, programId } }, include: { partnerGroup: { include: { partnerGroupDefaultLinks: true, utmTemplate: true, }, }, }, }); if (!partner) { throw new DubApiError({ code: "not_found", message: "Partner not found.", }); } const partnerGroup = partner.partnerGroup; // shouldn't happen but just in case if (!partnerGroup) { throw new DubApiError({ code: "not_found", message: "This partner is not part of a partner group.", }); } validatePartnerLinkUrl({ group: partnerGroup, url, }); const link = await prisma.link.findFirst({ where: { programId, partnerId, projectId: workspace.id, url, }, include: includeTags, }); if (link) { // proceed with /api/links/[linkId] PATCH logic const updatedLink = { // original link ...link, // coerce types expiresAt: link.expiresAt instanceof Date ? link.expiresAt.toISOString() : link.expiresAt, geo: link.geo as NewLinkProps["geo"], testVariants: link.testVariants as NewLinkProps["testVariants"], testCompletedAt: link.testCompletedAt instanceof Date ? link.testCompletedAt.toISOString() : link.testCompletedAt, testStartedAt: link.testStartedAt instanceof Date ? link.testStartedAt.toISOString() : link.testStartedAt, // merge in new props ...linkProps, // set default fields domain: program.domain, ...(key && { key }), url, programId: program.id, tenantId: partner.tenantId, partnerId: partner.partnerId, folderId: program.defaultFolderId, trackConversion: true, }; // if link and updatedLink are identical, return the link if (deepEqual(link, updatedLink)) { return NextResponse.json(transformLink(link), { headers, }); } // if domain and key are the same, we don't need to check if the key exists const skipKeyChecks = link.domain === updatedLink.domain && link.key.toLowerCase() === updatedLink.key?.toLowerCase(); // if externalId is the same, we don't need to check if it exists const skipExternalIdChecks = link.externalId?.toLowerCase() === updatedLink.externalId?.toLowerCase(); const { link: processedLink, error, code, } = await processLink({ payload: { ...updatedLink, tags: undefined, }, workspace, skipKeyChecks, skipExternalIdChecks, userId: session.user.id, }); if (error) { throw new DubApiError({ code: code as ErrorCodes, message: error, }); } try { const response = await updateLink({ oldLink: { domain: link.domain, key: link.key, image: link.image, }, updatedLink: processedLink, }); waitUntil( sendWorkspaceWebhook({ trigger: "link.updated", workspace, data: linkEventSchema.parse(response), }), ); return NextResponse.json(response, { headers, }); } catch (error) { throw new DubApiError({ code: "unprocessable_entity", message: error.message, }); } } else { const linkUrl = url || partnerGroup.partnerGroupDefaultLinks[0].url; // proceed with /api/partners/links POST logic const { link, error, code } = await processLink({ payload: { ...linkProps, domain: program.domain, key: key || undefined, url: linkUrl, ...(partnerGroup.utmTemplate ? { ...extractUtmParams(partnerGroup.utmTemplate), ...getUTMParamsFromURL(linkUrl), } : {}), programId: program.id, tenantId: partner.tenantId, partnerId: partner.partnerId, folderId: program.defaultFolderId, trackConversion: true, }, workspace, userId: session.user.id, skipProgramChecks: true, // skip this cause we've already validated the program above }); if (error != null) { throw new DubApiError({ code: code as ErrorCodes, message: error, }); } const partnerLink = await createLink(link); waitUntil( sendWorkspaceWebhook({ trigger: "link.created", workspace, data: linkEventSchema.parse(partnerLink), }), ); return NextResponse.json(partnerLink, { headers, }); } }, { requiredPermissions: ["links.write"], requiredPlan: ["advanced", "enterprise"], }, ); ================================================ FILE: apps/web/app/(ee)/api/partners/platforms/callback/route.ts ================================================ import { PARTNER_PLATFORMS_PROVIDERS } from "@/lib/api/partner-profile/partner-platforms-providers"; import { getSocialProfile } from "@/lib/api/scrape-creators/get-social-profile"; import { getSession } from "@/lib/auth/utils"; import { redis } from "@/lib/upstash/redis"; import { prisma } from "@dub/prisma"; import { PartnerPlatform, PlatformType } from "@dub/prisma/client"; import { getSearchParams, PARTNERS_DOMAIN, PARTNERS_DOMAIN_WITH_NGROK, } from "@dub/utils"; import { cookies } from "next/headers"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; const requestSchema = z.object({ code: z.string(), state: z.string(), }); interface State { platform: PlatformType; partnerId: string; source: "onboarding" | "settings"; } // GET /api/partners/platforms/callback export async function GET(req: Request) { const { searchParams } = new URL(req.url); // Validate the request const parsedSearchParams = requestSchema.safeParse(getSearchParams(req.url)); if (!parsedSearchParams.success) { console.warn("Missing required search params in OAuth callback."); return NextResponse.redirect(PARTNERS_DOMAIN); } const { code, state } = parsedSearchParams.data; // Get current user const session = await getSession(); if (!session?.user?.id) { console.warn("Unauthorized: Login required."); return NextResponse.redirect(PARTNERS_DOMAIN); } // Find the state from Redis const stateFromRedis = await redis.get( `partnerSocialVerification:${state}`, ); if (!stateFromRedis) { console.warn("State is invalid or expired."); return NextResponse.redirect(PARTNERS_DOMAIN); } const { platform, partnerId, source } = stateFromRedis; if (session.user.defaultPartnerId !== partnerId) { console.warn("Unauthorized: User is not the default partner."); return NextResponse.redirect(PARTNERS_DOMAIN); } // Validate platform exists in providers const provider = PARTNER_PLATFORMS_PROVIDERS[platform]; if (!provider) { console.error(`Invalid platform: ${platform}`); return NextResponse.redirect(PARTNERS_DOMAIN); } // Redirect user based on source const redirectUrl = source === "onboarding" ? `${PARTNERS_DOMAIN}/onboarding/platforms` : `${PARTNERS_DOMAIN}/profile`; const { tokenUrl, clientId, clientSecret, verify, pkce, clientIdParam } = provider; const cookieStore = await cookies(); const codeVerifier = pkce ? cookieStore.get("online_presence_code_verifier")?.value : null; // Local development redirect since the verifier cookie won't be present on ngrok if (pkce && !codeVerifier && process.env.NODE_ENV === "development") { return NextResponse.redirect( `http://partners.localhost:8888/api/partners/platforms/callback?${searchParams.toString()}`, ); } // Remove the state from Redis await redis.del(`partnerSocialVerification:${state}`); // Get access token const urlParams = new URLSearchParams({ [clientIdParam ?? "client_id"]: clientId!, client_secret: clientSecret!, code, redirect_uri: `${PARTNERS_DOMAIN_WITH_NGROK}/api/partners/platforms/callback`, grant_type: "authorization_code", ...(codeVerifier && { code_verifier: codeVerifier }), }); const response = await fetch(tokenUrl, { headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`, }, method: "POST", body: urlParams.toString(), }); const tokenResponse = await response.json(); if (!response.ok) { console.warn("Failed to get access token in OAuth callback", tokenResponse); return NextResponse.redirect(redirectUrl); } if (!tokenResponse.access_token) { console.warn("No access token found in OAuth callback"); return NextResponse.redirect(redirectUrl); } const partnerPlatform = await prisma.partnerPlatform.findUnique({ where: { partnerId_type: { partnerId, type: platform, }, }, }); if (!partnerPlatform || !partnerPlatform.identifier) { console.error("No partner platform found in OAuth callback"); return NextResponse.redirect(redirectUrl); } const { verified, metadata } = await verify({ handle: partnerPlatform.identifier, accessToken: tokenResponse.access_token, }); if (!verified) { console.warn("Failed to verify social account in OAuth callback"); return NextResponse.redirect(redirectUrl); } let socialStats: Pick< PartnerPlatform, "subscribers" | "posts" | "views" | "avatarUrl" > = { subscribers: BigInt(0), posts: BigInt(0), views: BigInt(0), avatarUrl: null, }; if (["tiktok", "twitter"].includes(platform)) { try { const socialProfile = await getSocialProfile({ platform, handle: partnerPlatform.identifier, }); socialStats = { subscribers: socialProfile.subscribers, posts: socialProfile.posts, views: socialProfile.views, avatarUrl: socialProfile.avatarUrl, }; } catch (error) { console.error( `Failed to fetch social stats for ${platform} handle @${partnerPlatform.identifier}:`, error, ); } } await prisma.partnerPlatform.update({ where: { partnerId_type: { partnerId, type: platform, }, }, data: { verifiedAt: new Date(), ...(metadata && { metadata }), subscribers: socialStats.subscribers, posts: socialStats.posts, views: socialStats.views, avatarUrl: socialStats.avatarUrl, }, }); // Delete PKCE code verifier cookie after successful use if (pkce && codeVerifier) { cookieStore.delete("online_presence_code_verifier"); } return NextResponse.redirect(redirectUrl); } ================================================ FILE: apps/web/app/(ee)/api/partners/route.ts ================================================ import { createAndEnrollPartner } from "@/lib/api/partners/create-and-enroll-partner"; import { getPartners } from "@/lib/api/partners/get-partners"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { polyfillSocialMediaFields } from "@/lib/social-utils"; import { createPartnerSchema, EnrolledPartnerSchema, getPartnersQuerySchemaExtended, partnerPlatformSchema, } from "@/lib/zod/schemas/partners"; import { toCentsNumber } from "@dub/utils"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/partners - get all partners for a program export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { sortBy: sortByWithOldFields, includePartnerPlatforms, ...parsedParams } = getPartnersQuerySchemaExtended .extend({ // add old fields for backward compatibility sortBy: getPartnersQuerySchemaExtended.shape.sortBy.or( z.enum([ "clicks", "leads", "conversions", "sales", "saleAmount", "totalSales", ]), ), }) .parse(searchParams); // get the final sortBy field (replace old fields with new fields) const sortBy = { clicks: "totalClicks", leads: "totalLeads", conversions: "totalConversions", sales: "totalSaleAmount", saleAmount: "totalSaleAmount", totalSales: "totalSaleAmount", }[sortByWithOldFields] || sortByWithOldFields; console.time("getPartners"); const partners = await getPartners({ ...parsedParams, sortBy, programId, }); console.timeEnd("getPartners"); // polyfill deprecated fields for backward compatibility const baseSchema = EnrolledPartnerSchema.extend({ clicks: z.number().default(0), leads: z.number().default(0), conversions: z.number().default(0), sales: z.number().default(0), saleAmount: z.number().default(0), }); const responseSchema = includePartnerPlatforms ? baseSchema.extend({ platforms: z.array(partnerPlatformSchema), }) : baseSchema; return NextResponse.json( z.array(responseSchema).parse( partners.map((partner) => ({ ...partner, clicks: partner.totalClicks, leads: partner.totalLeads, conversions: partner.totalConversions, sales: partner.totalSales, saleAmount: toCentsNumber(partner.totalSaleAmount), ...polyfillSocialMediaFields(partner.platforms), })), ), ); }, { requiredPlan: [ "business", "business extra", "business max", "business plus", "advanced", "enterprise", ], }, ); // POST /api/partners - add a partner for a program export const POST = withWorkspace( async ({ workspace, req, session }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { linkProps: link, ...partner } = createPartnerSchema.parse( await parseRequestBody(req), ); const program = await getProgramOrThrow({ workspaceId: workspace.id, programId, }); const enrolledPartner = await createAndEnrollPartner({ workspace, program, partner, link, userId: session.user.id, }); return NextResponse.json(enrolledPartner, { status: 201, }); }, { requiredPlan: ["advanced", "enterprise"], requiredRoles: ["owner", "member"], }, ); ================================================ FILE: apps/web/app/(ee)/api/payouts/[payoutId]/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { getEffectivePayoutMode } from "@/lib/api/payouts/get-effective-payout-mode"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { withWorkspace } from "@/lib/auth"; import { PayoutResponseSchema } from "@/lib/zod/schemas/payouts"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/payouts/[payoutId] - get a single payout by ID export const GET = withWorkspace(async ({ workspace, params }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { payoutId } = params; const program = await getProgramOrThrow({ workspaceId: workspace.id, programId, }); const payout = await prisma.payout.findUnique({ where: { id: payoutId, programId, }, include: { programEnrollment: true, partner: true, user: true, }, }); if (!payout) { throw new DubApiError({ code: "not_found", message: `Payout ${payoutId} not found.`, }); } const { partner, programEnrollment, ...rest } = payout; const mode = rest.mode ?? getEffectivePayoutMode({ payoutMode: program.payoutMode, payoutsEnabledAt: partner.payoutsEnabledAt, }); return NextResponse.json( PayoutResponseSchema.parse({ ...rest, mode, traceId: rest.stripePayoutTraceId, partner: { ...partner, tenantId: programEnrollment.tenantId, }, }), ); }); ================================================ FILE: apps/web/app/(ee)/api/payouts/count/route.ts ================================================ import { getPayoutEligibilityFilter } from "@/lib/api/payouts/payout-eligibility-filter"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { withWorkspace } from "@/lib/auth"; import { payoutsCountQuerySchema } from "@/lib/zod/schemas/payouts"; import { prisma } from "@dub/prisma"; import { FraudEventStatus, PayoutStatus, Prisma } from "@dub/prisma/client"; import { NextResponse } from "next/server"; // GET /api/payouts/count export const GET = withWorkspace(async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const isHoldStatus = searchParams.status === "hold"; const { status: _status, ...restSearchParams } = searchParams; let { status, partnerId, groupBy, eligibility, invoiceId } = payoutsCountQuerySchema.parse( isHoldStatus ? restSearchParams : searchParams, ); if (isHoldStatus) { status = PayoutStatus.pending; } const program = await getProgramOrThrow({ workspaceId: workspace.id, programId, }); const where: Prisma.PayoutWhereInput = { programId, ...(partnerId && { partnerId }), ...(eligibility === "eligible" && { ...getPayoutEligibilityFilter({ program, workspace }), }), ...(invoiceId && { invoiceId }), ...(isHoldStatus && { programEnrollment: { fraudEventGroups: { some: { status: FraudEventStatus.pending, }, }, }, }), }; // Get payout count by status if (groupBy === "status") { const payouts = await prisma.payout.groupBy({ by: ["status"], where, _count: true, _sum: { amount: true, }, }); const counts = payouts.map((p) => ({ status: p.status, count: p._count, amount: p._sum.amount, })); Object.values(PayoutStatus).forEach((status) => { if (!counts.find((p) => p.status === status)) { counts.push({ status, count: 0, amount: 0, }); } }); return NextResponse.json(counts); } const count = await prisma.payout.count({ where: { ...where, status, }, }); return NextResponse.json(count); }); ================================================ FILE: apps/web/app/(ee)/api/payouts/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { getEffectivePayoutMode } from "@/lib/api/payouts/get-effective-payout-mode"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { withWorkspace } from "@/lib/auth"; import { PayoutResponseSchema, payoutsQuerySchema, } from "@/lib/zod/schemas/payouts"; import { prisma } from "@dub/prisma"; import { FraudEventStatus, PayoutStatus } from "@dub/prisma/client"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/payouts - get all payouts for a program export const GET = withWorkspace(async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const isHoldStatus = searchParams.status === "hold"; const { status: _status, ...restSearchParams } = searchParams; let { status, partnerId, tenantId, invoiceId, sortBy, sortOrder, page = 1, pageSize, } = payoutsQuerySchema.parse(isHoldStatus ? restSearchParams : searchParams); if (isHoldStatus) { status = PayoutStatus.pending; } if (tenantId && !partnerId) { const programEnrollment = await prisma.programEnrollment.findUnique({ where: { tenantId_programId: { tenantId, programId, }, }, select: { partnerId: true, }, }); if (!programEnrollment) { throw new DubApiError({ code: "not_found", message: `Partner with specified tenantId ${tenantId} not found.`, }); } partnerId = programEnrollment.partnerId; } const program = await getProgramOrThrow({ workspaceId: workspace.id, programId, }); const payouts = await prisma.payout.findMany({ where: { programId, ...(status && { status }), ...(partnerId && { partnerId }), ...(invoiceId && { invoiceId }), ...(isHoldStatus && { programEnrollment: { fraudEventGroups: { some: { status: FraudEventStatus.pending, }, }, }, }), }, include: { programEnrollment: true, partner: true, user: true, }, skip: (page - 1) * pageSize, take: pageSize, orderBy: { [sortBy]: sortOrder, }, }); const transformedPayouts = payouts.map( ({ partner, programEnrollment, ...payout }) => { const mode = payout.mode ?? getEffectivePayoutMode({ payoutMode: program.payoutMode, payoutsEnabledAt: partner.payoutsEnabledAt, }); return { ...payout, mode, traceId: payout.stripePayoutTraceId, partner: { ...partner, tenantId: programEnrollment.tenantId, }, }; }, ); return NextResponse.json( z.array(PayoutResponseSchema).parse(transformedPayouts), ); }); ================================================ FILE: apps/web/app/(ee)/api/paypal/callback/route.ts ================================================ import { getSession } from "@/lib/auth"; import { recomputePartnerPayoutState } from "@/lib/payouts/recompute-partner-payout-state"; import { paypalOAuthProvider } from "@/lib/paypal/oauth"; import { sendEmail } from "@dub/email"; import ConnectedPaypalAccount from "@dub/email/templates/connected-paypal-account"; import { prisma } from "@dub/prisma"; import { Prisma } from "@dub/prisma/client"; import { PARTNERS_DOMAIN } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { redirect } from "next/navigation"; // GET /api/paypal/callback - callback from PayPal export const GET = async (req: Request) => { const session = await getSession(); const { searchParams } = new URL(req.url); // Local development redirect since the callback might be coming through ngrok if ( process.env.NODE_ENV === "development" && !req.headers.get("host")?.includes("localhost") ) { return redirect( `${PARTNERS_DOMAIN}/api/paypal/callback?${searchParams.toString()}`, ); } if (!session?.user.id) { redirect(`${PARTNERS_DOMAIN}/login`); } let error: string | null = null; try { const { defaultPartnerId } = session.user; if (!defaultPartnerId) { throw new Error("partner_not_found"); } const { token, contextId } = await paypalOAuthProvider.exchangeCodeForToken(req); await prisma.user.findUniqueOrThrow({ where: { id: contextId, }, }); const paypalUser = await paypalOAuthProvider.getUserInfo( token.access_token, ); if (!paypalUser.email_verified) { throw new Error("paypal_email_not_verified"); } const { partner } = await prisma.partnerUser.findUniqueOrThrow({ where: { userId_partnerId: { userId: session.user.id, partnerId: defaultPartnerId, }, }, include: { partner: true, }, }); const { payoutsEnabledAt, defaultPayoutMethod } = await recomputePartnerPayoutState({ ...partner, paypalEmail: paypalUser.email, }); const updatedPartner = await prisma.partner.update({ where: { id: defaultPartnerId, }, data: { paypalEmail: paypalUser.email, payoutsEnabledAt, defaultPayoutMethod, }, }); // Send an email to the partner to inform them that their PayPal account has been connected if (updatedPartner.email && updatedPartner.paypalEmail) { waitUntil( sendEmail({ variant: "notifications", subject: "Successfully connected PayPal account", to: updatedPartner.email, react: ConnectedPaypalAccount({ email: updatedPartner.email, paypalEmail: updatedPartner.paypalEmail, }), }), ); } } catch (e) { console.error(e); if ( e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002" ) { error = "paypal_account_already_in_use"; } else { error = e.message; } } redirect( `/payouts?settings=true${error ? `&error=${encodeURIComponent(error)}` : ""}`, ); }; ================================================ FILE: apps/web/app/(ee)/api/paypal/webhook/payouts-item-failed.ts ================================================ import { sendEmail } from "@dub/email"; import PartnerPaypalPayoutFailed from "@dub/email/templates/partner-paypal-payout-failed"; import { prisma } from "@dub/prisma"; import { payoutsItemSchema } from "./utils"; const PAYPAL_TO_DUB_STATUS = { "PAYMENT.PAYOUTS-ITEM.BLOCKED": "failed", "PAYMENT.PAYOUTS-ITEM.DENIED": "failed", "PAYMENT.PAYOUTS-ITEM.FAILED": "failed", "PAYMENT.PAYOUTS-ITEM.REFUNDED": "failed", "PAYMENT.PAYOUTS-ITEM.RETURNED": "failed", }; export async function payoutsItemFailed(event: any) { const body = payoutsItemSchema.parse(event); let invoiceId = body.resource.sender_batch_id; const paypalEmail = body.resource.payout_item.receiver; const payoutItemId = body.resource.payout_item_id; const payoutId = body.resource.payout_item.sender_item_id; if (invoiceId.includes("-")) { invoiceId = invoiceId.split("-")[0]; } const payout = await prisma.payout.findUnique({ where: { id: payoutId, }, include: { partner: true, program: true, }, }); if (!payout) { console.log( `[PayPal] Payout not found for invoice ${invoiceId} and partner ${paypalEmail}`, ); return; } const payoutStatus: "failed" | undefined = PAYPAL_TO_DUB_STATUS[body.event_type]; const failureReason = body.resource.errors?.message; await prisma.payout.update({ where: { id: payout.id, }, data: { paypalTransferId: payoutItemId, status: payoutStatus, failureReason, }, }); if (payoutStatus !== "failed") { // we only send emails for failed payouts console.log( `Paypal payout status changed to ${body.event_type} for invoice ${invoiceId} and partner ${paypalEmail}. This is not a failure event, skipping email send...`, ); return; } if (!payout.partner.email) { console.log( `Paypal payout partner email not found for invoice ${invoiceId} and partner ${paypalEmail}. Skipping email send...`, ); return; } await sendEmail({ subject: `Your recent partner payout from ${payout.program.name} failed`, to: payout.partner.email, react: PartnerPaypalPayoutFailed({ email: payout.partner.email, program: { name: payout.program.name, }, payout: { amount: payout.amount, failureReason, }, partner: { paypalEmail: payout.partner.paypalEmail!, }, }), variant: "notifications", }); } ================================================ FILE: apps/web/app/(ee)/api/paypal/webhook/payouts-item-succeeded.ts ================================================ import { prisma } from "@dub/prisma"; import { payoutsItemSchema } from "./utils"; export async function payoutsItemSucceeded(event: any) { const body = payoutsItemSchema.parse(event); let invoiceId = body.resource.sender_batch_id; const paypalEmail = body.resource.payout_item.receiver; const payoutItemId = body.resource.payout_item_id; const payoutId = body.resource.payout_item.sender_item_id; if (invoiceId.includes("-")) { invoiceId = invoiceId.split("-")[0]; } const payout = await prisma.payout.findUnique({ where: { id: payoutId, }, include: { partner: true, program: true, }, }); if (!payout) { console.log( `[PayPal] Payout not found for invoice ${invoiceId} and partner ${paypalEmail}`, ); return; } if (payout.status === "completed") { console.log( `[PayPal] Payout already completed for invoice ${invoiceId} and partner ${paypalEmail}`, ); return; } await Promise.all([ prisma.payout.update({ where: { id: payout.id, }, data: { paypalTransferId: payoutItemId, status: "completed", paidAt: payout.paidAt ?? new Date(), // preserve the paidAt if it already exists }, }), prisma.commission.updateMany({ where: { payoutId: payout.id, }, data: { status: "paid", }, }), ]); } ================================================ FILE: apps/web/app/(ee)/api/paypal/webhook/route.ts ================================================ import { log } from "@dub/utils"; import { payoutsItemFailed } from "./payouts-item-failed"; import { payoutsItemSucceeded } from "./payouts-item-succeeded"; import { verifySignature } from "./verify-signature"; const relevantEvents = new Set([ // Individual payout item events "PAYMENT.PAYOUTS-ITEM.SUCCEEDED", "PAYMENT.PAYOUTS-ITEM.BLOCKED", "PAYMENT.PAYOUTS-ITEM.CANCELED", "PAYMENT.PAYOUTS-ITEM.DENIED", "PAYMENT.PAYOUTS-ITEM.FAILED", "PAYMENT.PAYOUTS-ITEM.HELD", "PAYMENT.PAYOUTS-ITEM.REFUNDED", "PAYMENT.PAYOUTS-ITEM.RETURNED", "PAYMENT.PAYOUTS-ITEM.UNCLAIMED", ]); // POST /api/paypal/webhook – Listen to Paypal webhook events export const POST = async (req: Request) => { const rawBody = await req.text(); const headers = req.headers; try { const isSignatureValid = await verifySignature({ event: rawBody, headers, }); if (!isSignatureValid) { throw new Error("Invalid signature"); } const body = JSON.parse(rawBody); if (!relevantEvents.has(body.event_type)) { console.info(`[Paypal] Unsupported event: ${body.event_type}`); return new Response("Unsupported event, skipping..."); } console.info(`[Paypal] Webhook received: ${body.event_type}`, body); switch (body.event_type) { case "PAYMENT.PAYOUTS-ITEM.SUCCEEDED": await payoutsItemSucceeded(body); break; case "PAYMENT.PAYOUTS-ITEM.BLOCKED": case "PAYMENT.PAYOUTS-ITEM.CANCELED": case "PAYMENT.PAYOUTS-ITEM.DENIED": case "PAYMENT.PAYOUTS-ITEM.FAILED": case "PAYMENT.PAYOUTS-ITEM.HELD": case "PAYMENT.PAYOUTS-ITEM.REFUNDED": case "PAYMENT.PAYOUTS-ITEM.RETURNED": case "PAYMENT.PAYOUTS-ITEM.UNCLAIMED": await payoutsItemFailed(body); break; } } catch (error) { console.error(`[Paypal] ${error.message}`); await log({ message: `Paypal webhook failed. Error: ${error.message}`, type: "errors", }); return new Response('Webhook error: "Webhook handler failed. View logs."', { status: 400, }); } return new Response("OK"); }; ================================================ FILE: apps/web/app/(ee)/api/paypal/webhook/utils.ts ================================================ import * as z from "zod/v4"; export const payoutsItemSchema = z.object({ event_type: z.string(), resource: z.object({ sender_batch_id: z.string(), // Dub invoice id payout_item_id: z.string(), payout_item_fee: z.object({ currency: z.string(), value: z.string(), }), payout_item: z.object({ receiver: z.string(), sender_item_id: z.string(), // Dub payout id }), errors: z .object({ name: z.string(), message: z.string(), }) .nullish(), }), }); ================================================ FILE: apps/web/app/(ee)/api/paypal/webhook/verify-signature.ts ================================================ import { paypalEnv } from "@/lib/paypal/env"; import { redis } from "@/lib/upstash/redis"; import { waitUntil } from "@vercel/functions"; import crc32 from "buffer-crc32"; import crypto from "crypto"; const CERT_CACHE_KEY_PREFIX = "paypal:cert:"; const CERT_CACHE_TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days async function downloadAndCache(url: string) { const urlHash = crypto.createHash("sha256").update(url).digest("hex"); const cacheKey = `${CERT_CACHE_KEY_PREFIX}${urlHash}`; const cachedCertPem = await redis.get(cacheKey); if (cachedCertPem) { console.info(`[PayPal] Using cached certificate.`); return cachedCertPem; } const response = await fetch(url); if (!response.ok) { throw new Error( `[PayPal] Failed to download certificate ${response.status}`, ); } const certPem = await response.text(); waitUntil( redis.set(cacheKey, certPem, { ex: CERT_CACHE_TTL_SECONDS, }), ); console.info(`[PayPal] Downloaded and cached certificate.`); return certPem; } export async function verifySignature({ event, headers, }: { event: string; // raw event data as string headers: Headers; }) { const transmissionId = headers.get("paypal-transmission-id"); const transmissionSig = headers.get("paypal-transmission-sig"); const timeStamp = headers.get("paypal-transmission-time"); const certUrl = headers.get("paypal-cert-url"); if (!transmissionId || !transmissionSig || !timeStamp || !certUrl) { console.error( "[PayPal] Missing required headers for signature verification", ); return false; } const certPem = await downloadAndCache(certUrl); if (!certPem) { console.error("[PayPal] Failed to download or cache PayPal certificate"); return false; } const crc = parseInt("0x" + crc32(event).toString("hex")); const message = `${transmissionId}|${timeStamp}|${paypalEnv.PAYPAL_WEBHOOK_ID}|${crc}`; const signatureBuffer = Buffer.from(transmissionSig, "base64"); const verifier = crypto.createVerify("SHA256"); verifier.update(message); return verifier.verify(certPem, new Uint8Array(signatureBuffer)); } ================================================ FILE: apps/web/app/(ee)/api/programs/[programId]/applications/[applicationId]/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; export const GET = withWorkspace(async ({ workspace, params }) => { const programId = getDefaultProgramIdOrThrow(workspace); const application = await prisma.programApplication.findUnique({ where: { id: params.applicationId, }, include: { partnerGroup: true, }, }); if (!application || application.programId !== programId) { throw new DubApiError({ code: "not_found", message: `Application ${params.applicationId} not found.`, }); } return NextResponse.json(application); }); ================================================ FILE: apps/web/app/(ee)/api/programs/[programId]/applications/export/route.ts ================================================ import { convertToCSV } from "@/lib/analytics/utils/convert-to-csv"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { exportApplicationColumns, exportApplicationsColumnsDefault, } from "@/lib/zod/schemas/partners"; import { prisma } from "@dub/prisma"; import * as z from "zod/v4"; const columnIdToLabel = exportApplicationColumns.reduce((acc, column) => { acc[column.id] = column.label; return acc; }, {}); const applicationsExportQuerySchema = z.object({ columns: z .string() .default(exportApplicationsColumnsDefault.join(",")) .transform((v) => v?.split(",")), }); // GET /api/programs/[programId]/applications/export – export applications to CSV export const GET = withWorkspace( async ({ searchParams, workspace }) => { const programId = getDefaultProgramIdOrThrow(workspace); let { columns } = applicationsExportQuerySchema.parse(searchParams); const programEnrollments = await prisma.programEnrollment.findMany({ where: { programId, status: "pending", }, include: { partner: true, application: true, }, }); const applications = programEnrollments.map( ({ partner, application, ...programEnrollment }) => { return { ...partner, createdAt: application?.createdAt || programEnrollment.createdAt, }; }, ); const columnOrderMap = exportApplicationColumns.reduce( (acc, column, index) => { acc[column.id] = index + 1; return acc; }, {}, ); columns = columns.sort( (a, b) => (columnOrderMap[a] || 999) - (columnOrderMap[b] || 999), ); const schemaFields = {}; columns.forEach((column) => { schemaFields[columnIdToLabel[column]] = z.string().optional().default(""); }); const formattedApplications = applications.map((application) => { const result = {}; columns.forEach((column) => { if (column === "createdAt") { result[columnIdToLabel[column]] = application[column] ? new Date(application[column]).toISOString() : ""; } else { result[columnIdToLabel[column]] = application[column] || ""; } }); return z.object(schemaFields).parse(result); }); return new Response(convertToCSV(formattedApplications), { headers: { "Content-Type": "text/csv", "Content-Disposition": "attachment", }, }); }, { requiredPlan: [ "business", "business extra", "business max", "business plus", "advanced", "enterprise", ], }, ); ================================================ FILE: apps/web/app/(ee)/api/programs/[programId]/discounts/route.ts ================================================ 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 * as z from "zod/v4"; // TODO: Remove once we migrate fully to partner groups // 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), ); }); ================================================ FILE: apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/count/route.ts ================================================ import { getEligiblePayouts } from "@/lib/api/payouts/get-eligible-payouts"; import { getPayoutEligibilityFilter } from "@/lib/api/payouts/payout-eligibility-filter"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { withWorkspace } from "@/lib/auth"; import { CUTOFF_PERIOD } from "@/lib/partners/cutoff-period"; import { eligiblePayoutsCountQuerySchema } from "@/lib/zod/schemas/payouts"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; /* * GET /api/programs/[programId]/payouts/eligible/count - get count of eligible payouts */ export const GET = withWorkspace(async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { cutoffPeriod, selectedPayoutId, excludedPayoutIds } = eligiblePayoutsCountQuerySchema.parse(searchParams); const program = await getProgramOrThrow({ workspaceId: workspace.id, programId, }); const cutoffPeriodValue = CUTOFF_PERIOD.find( (c) => c.id === cutoffPeriod, )?.value; // Requires special re-computing and filtering of payouts, so we just have to fetch all of them if (cutoffPeriodValue) { const eligiblePayouts = await getEligiblePayouts({ program, workspace, cutoffPeriod, selectedPayoutId, excludedPayoutIds, pageSize: Infinity, page: 1, }); return NextResponse.json({ count: eligiblePayouts.length ?? 0, amount: eligiblePayouts.reduce((acc, payout) => acc + payout.amount, 0), }); } const data = await prisma.payout.aggregate({ where: { ...(selectedPayoutId ? { id: selectedPayoutId } : excludedPayoutIds && excludedPayoutIds.length > 0 ? { id: { notIn: excludedPayoutIds } } : {}), ...getPayoutEligibilityFilter({ program, workspace }), }, _count: true, _sum: { amount: true, }, }); return NextResponse.json({ count: data._count ?? 0, amount: data._sum?.amount ?? 0, }); }); ================================================ FILE: apps/web/app/(ee)/api/programs/[programId]/payouts/eligible/route.ts ================================================ import { getEligiblePayouts } from "@/lib/api/payouts/get-eligible-payouts"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { withWorkspace } from "@/lib/auth"; import { eligiblePayoutsQuerySchema } from "@/lib/zod/schemas/payouts"; import { NextResponse } from "next/server"; /* * GET /api/programs/[programId]/payouts/eligible - get list of eligible payouts * * We're splitting this from /payouts because it's a special case that needs * to be handled differently: * - only include eligible payouts * - no pagination or filtering (we retrieve all pending payouts by default) * - sort by amount in descending order * - option to set a cutoff period to include commissions up to that date */ export const GET = withWorkspace(async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const query = eligiblePayoutsQuerySchema.parse(searchParams); const program = await getProgramOrThrow({ workspaceId: workspace.id, programId, }); const eligiblePayouts = await getEligiblePayouts({ program, workspace, ...query, }); return NextResponse.json(eligiblePayouts); }); ================================================ FILE: apps/web/app/(ee)/api/programs/[programId]/referrals/count/route.ts ================================================ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { getPartnerReferralsCountQuerySchema, partnerReferralsCountResponseSchema, } from "@/lib/zod/schemas/referrals"; import { prisma, sanitizeFullTextSearch } from "@dub/prisma"; import { Prisma, ReferralStatus } from "@dub/prisma/client"; import { NextResponse } from "next/server"; // GET /api/programs/[programId]/referrals/count - get the count of partner referrals for a program export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { partnerId, status, search, groupBy } = getPartnerReferralsCountQuerySchema.parse(searchParams); const commonWhere: Prisma.PartnerReferralWhereInput = { programId, ...(partnerId && groupBy !== "partnerId" && { partnerId }), ...(groupBy === "status" ? {} : status ? { status } : { status: { notIn: [ReferralStatus.unqualified, ReferralStatus.closedLost], }, }), ...(search ? search.includes("@") ? { email: search } : { email: { search: sanitizeFullTextSearch(search) }, name: { search: sanitizeFullTextSearch(search) }, } : {}), }; // Get referral count by status if (groupBy === "status") { const data = await prisma.partnerReferral.groupBy({ by: ["status"], where: commonWhere, _count: true, orderBy: { _count: { status: "desc", }, }, }); // Fill in missing statuses with zero counts Object.values(ReferralStatus).forEach((status) => { if (!data.some((d) => d.status === status)) { data.push({ _count: 0, status }); } }); return NextResponse.json(partnerReferralsCountResponseSchema.parse(data)); } // Get referral count by partnerId if (groupBy === "partnerId") { const data = await prisma.partnerReferral.groupBy({ by: ["partnerId"], where: commonWhere, _count: true, orderBy: { _count: { partnerId: "desc", }, }, take: 10000, }); return NextResponse.json(partnerReferralsCountResponseSchema.parse(data)); } // Get referral count const count = await prisma.partnerReferral.count({ where: commonWhere, }); return NextResponse.json(partnerReferralsCountResponseSchema.parse(count)); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], }, ); ================================================ FILE: apps/web/app/(ee)/api/programs/[programId]/referrals/route.ts ================================================ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { getPartnerReferralsQuerySchema, referralSchema, } from "@/lib/zod/schemas/referrals"; import { prisma, sanitizeFullTextSearch } from "@dub/prisma"; import { ReferralStatus } from "@dub/prisma/client"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/programs/[programId]/referrals - get all partner referrals for a program export const GET = withWorkspace( async ({ workspace, searchParams }) => { const programId = getDefaultProgramIdOrThrow(workspace); const { partnerId, status, search, page = 1, pageSize, } = getPartnerReferralsQuerySchema.parse(searchParams); const partnerReferrals = await prisma.partnerReferral.findMany({ where: { programId, ...(partnerId && { partnerId }), ...(status ? { status } : { status: { notIn: [ReferralStatus.unqualified, ReferralStatus.closedLost], }, }), ...(search ? search.includes("@") ? { email: search } : { email: { search: sanitizeFullTextSearch(search) }, name: { search: sanitizeFullTextSearch(search) }, } : {}), }, include: { partner: { select: { id: true, name: true, email: true, image: true, }, }, }, skip: (page - 1) * pageSize, take: pageSize, orderBy: { createdAt: "desc", }, }); return NextResponse.json(z.array(referralSchema).parse(partnerReferrals)); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], }, ); ================================================ FILE: apps/web/app/(ee)/api/programs/[programId]/resources/route.ts ================================================ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { withWorkspace } from "@/lib/auth"; import { programResourcesSchema } from "@/lib/zod/schemas/program-resources"; import { prisma } from "@dub/prisma"; import { NextResponse } from "next/server"; // GET /api/programs/[programId]/resources - get resources for a program export const GET = withWorkspace(async ({ workspace }) => { const programId = getDefaultProgramIdOrThrow(workspace); const program = await prisma.program.findUnique({ where: { id: programId, workspaceId: workspace.id, }, select: { resources: true, }, }); const resources = programResourcesSchema.parse( program?.resources ?? { logos: [], colors: [], files: [], }, ); return NextResponse.json(resources); }); ================================================ FILE: apps/web/app/(ee)/api/programs/[programId]/route.ts ================================================ import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw"; import { withWorkspace } from "@/lib/auth"; import { ProgramSchemaWithInviteEmailData } from "@/lib/zod/schemas/programs"; import { NextResponse } from "next/server"; // GET /api/programs/[programId] - get a program by id export const GET = withWorkspace(async ({ workspace, params }) => { const program = await getProgramOrThrow({ workspaceId: workspace.id, programId: params.programId, include: { categories: true, }, }); return NextResponse.json(ProgramSchemaWithInviteEmailData.parse(program)); }); ================================================ FILE: apps/web/app/(ee)/api/programs/rewardful/campaigns/route.ts ================================================ import { withWorkspace } from "@/lib/auth"; import { RewardfulApi } from "@/lib/rewardful/api"; import { rewardfulImporter } from "@/lib/rewardful/importer"; import { NextResponse } from "next/server"; // GET /api/programs/rewardful/campaigns - list rewardful campaigns export const GET = withWorkspace(async ({ workspace }) => { const { token } = await rewardfulImporter.getCredentials(workspace.id); const rewardfulApi = new RewardfulApi({ token }); return NextResponse.json(await rewardfulApi.listCampaigns()); }); ================================================ FILE: apps/web/app/(ee)/api/rewards/[rewardId]/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; 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"; export const GET = withWorkspace(async ({ workspace, params }) => { const programId = getDefaultProgramIdOrThrow(workspace); const reward = await prisma.reward.findUnique({ where: { id: params.rewardId, programId, }, }); if (!reward) { throw new DubApiError({ code: "not_found", message: "Reward not found.", }); } return NextResponse.json(RewardSchema.parse(reward)); }); ================================================ FILE: apps/web/app/(ee)/api/rewards/route.ts ================================================ 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 * as z from "zod/v4"; // GET /api/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: [ { event: "desc", }, { createdAt: "desc", }, ], }); return NextResponse.json(z.array(RewardSchema).parse(rewards)); }); ================================================ FILE: apps/web/app/(ee)/api/scim/v2.0/[...directory]/route.ts ================================================ import { inviteUser } from "@/lib/api/users"; import { jackson } from "@/lib/jackson"; import { WorkspaceProps } from "@/lib/types"; import type { DirectorySyncEvent, DirectorySyncRequest, } from "@boxyhq/saml-jackson"; import { prisma } from "@dub/prisma"; import { getSearchParams } from "@dub/utils"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; const handler = async ( req: Request, { params: initialParams }: { params: Promise> }, ) => { const params = (await initialParams) || {}; const headersList = await headers(); const authHeader = headersList.get("Authorization"); const apiSecret = authHeader ? authHeader.split(" ")[1] : null; const query = getSearchParams(req.url); const [directoryId, path, resourceId] = params.directory; let body; try { body = await req.json(); } catch (error) { body = {}; } const { directorySyncController } = await jackson(); // Handle the SCIM API requests const request: DirectorySyncRequest = { method: req.method, body, directoryId, resourceId, resourceType: path === "Users" ? "users" : "groups", apiSecret, query: { count: query.count ? parseInt(query.count as string) : undefined, startIndex: query.startIndex ? parseInt(query.startIndex as string) : undefined, filter: query.filter as string, }, }; const { status, data } = await directorySyncController.requests.handle( request, handleEvents, ); return NextResponse.json(data, { status }); }; export { handler as DELETE, handler as GET, handler as POST, handler as PUT }; // Handle the SCIM events const handleEvents = async (event: DirectorySyncEvent) => { const { event: action, tenant: workspaceId, data } = event; const workspace = (await prisma.project.findUnique({ where: { id: workspaceId, }, })) as unknown as WorkspaceProps; if (!workspace || workspace.plan !== "enterprise" || !("email" in data)) { return; } const [userInWorkspace, userInvited] = await Promise.all([ prisma.user.findFirst({ where: { email: data.email, projects: { some: { projectId: workspaceId, }, }, }, }), await prisma.projectInvite.findUnique({ where: { email_projectId: { email: data.email, projectId: workspaceId, }, }, }), ]); // User has been activated for the first time if (action === "user.created" && !userInWorkspace && !userInvited) { await inviteUser({ email: data.email, workspace, }); } // User has been activated if ( action === "user.updated" && // @ts-ignore – data.active can be a string (from Azure AD) (data.active === true || data.active === "True") ) { if (!userInWorkspace && !userInvited) { await inviteUser({ email: data.email, workspace, }); } } // User has been deactivated or deleted if ( (action === "user.updated" && // @ts-ignore – data.active can be a string (from Azure AD) (data.active === false || data.active === "False")) || action === "user.deleted" ) { if (userInWorkspace) { await prisma.projectUsers.delete({ where: { userId_projectId: { userId: userInWorkspace.id, projectId: workspaceId, }, }, }); } if (userInvited) { await prisma.projectInvite.delete({ where: { email_projectId: { email: data.email, projectId: workspaceId, }, }, }); } } return; }; ================================================ FILE: apps/web/app/(ee)/api/shopify/integration/callback/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { installIntegration } from "@/lib/integrations/install"; import { prisma } from "@dub/prisma"; import { SHOPIFY_INTEGRATION_ID } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // PATCH /api/shopify/integration/callback – update a shopify store id export const PATCH = withWorkspace( async ({ req, workspace, session }) => { const body = await parseRequestBody(req); const { shopifyStoreId } = z .object({ shopifyStoreId: z.string().nullable(), }) .parse(body); try { const response = await prisma.project.update({ where: { id: workspace.id, }, data: { shopifyStoreId, }, select: { shopifyStoreId: true, }, }); waitUntil( (async () => { const installation = await prisma.installedIntegration.findUnique({ where: { userId_integrationId_projectId: { userId: session.user.id, projectId: workspace.id, integrationId: SHOPIFY_INTEGRATION_ID, }, }, select: { id: true, }, }); // Install the integration if it doesn't exist if (!installation) { await installIntegration({ userId: session.user.id, workspaceId: workspace.id, integrationId: SHOPIFY_INTEGRATION_ID, credentials: { shopifyStoreId, }, }); } // Uninstall the integration if the shopify store id is null if (installation && shopifyStoreId === null) { await prisma.installedIntegration.delete({ where: { id: installation.id, }, }); } })(), ); return NextResponse.json(response); } catch (error) { if (error.code === "P2002") { throw new DubApiError({ code: "conflict", message: `The shopify store "${shopifyStoreId}" is already in use.`, }); } throw new DubApiError({ code: "internal_server_error", message: error.message, }); } }, { requiredRoles: ["owner", "member"], requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], }, ); ================================================ FILE: apps/web/app/(ee)/api/shopify/integration/webhook/app-uninstalled.ts ================================================ import { prisma } from "@dub/prisma"; export async function appUninstalled({ shopDomain }: { shopDomain: string }) { await prisma.project.update({ where: { shopifyStoreId: shopDomain, }, data: { shopifyStoreId: null, }, }); return "[Shopify] App Uninstalled received."; } ================================================ FILE: apps/web/app/(ee)/api/shopify/integration/webhook/customers-data-request.ts ================================================ import { createPlainThread } from "@/lib/plain/create-plain-thread"; import { prisma } from "@dub/prisma"; import { waitUntil } from "@vercel/functions"; import * as z from "zod/v4"; const schema = z.object({ shop_domain: z.string(), orders_requested: z.array(z.number()), customer: z.object({ id: z.number(), }), }); export async function customersDataRequest({ event, workspaceId, }: { event: any; workspaceId: string; }) { const { customer: { id: customerExternalId }, shop_domain: shopDomain, orders_requested: ordersRequested, } = schema.parse(event); const [{ user }, customer] = await Promise.all([ prisma.projectUsers.findFirstOrThrow({ where: { projectId: workspaceId, role: "owner", }, select: { user: { select: { id: true, name: true, email: true, }, }, }, }), prisma.customer.findUnique({ where: { projectId_externalId: { projectId: workspaceId, externalId: customerExternalId.toString(), }, }, }), ]); const rows = [ { text: "Shop domain", value: shopDomain, }, { text: "Customer ID", value: customerExternalId.toString(), }, { text: "Customer name", value: customer?.name ?? "N/A", }, { text: "Customer email", value: customer?.email ?? "N/A", }, ...(ordersRequested.length > 0 ? [{ text: "Orders requested", value: ordersRequested.join(", ") }] : []), ]; waitUntil( createPlainThread({ user: { id: user.id, name: user.name ?? "", email: user.email ?? "", }, title: `Shopify - Customer data request received for ${shopDomain}`, components: rows.map((row) => ({ componentRow: { rowMainContent: [{ componentText: { text: row.text } }], rowAsideContent: [{ componentText: { text: row.value } }], }, })), }), ); return "[Shopify] Customer Data Request received."; } ================================================ FILE: apps/web/app/(ee)/api/shopify/integration/webhook/customers-redact.ts ================================================ import { generateRandomName } from "@/lib/names"; import { createPlainThread } from "@/lib/plain/create-plain-thread"; import { prisma } from "@dub/prisma"; import { waitUntil } from "@vercel/functions"; import * as z from "zod/v4"; const schema = z.object({ shop_domain: z.string(), orders_to_redact: z.array(z.number()), customer: z.object({ id: z.number(), }), }); export async function customersRedact({ event, workspaceId, }: { event: any; workspaceId: string; }) { const { customer, shop_domain: shopDomain, orders_to_redact: ordersToRedact, } = schema.parse(event); const customerExternalId = customer.id.toString(); // Redact the customer's data try { await prisma.customer.update({ where: { projectId_externalId: { projectId: workspaceId, externalId: customerExternalId, }, }, data: { name: generateRandomName(), email: null, }, }); } catch (error) { return `[Shopify] Failed to redact customer data. Reason: ${error.message}`; } const { user } = await prisma.projectUsers.findFirstOrThrow({ where: { projectId: workspaceId, role: "owner", }, select: { user: { select: { id: true, name: true, email: true, }, }, }, }); const rows = [ { text: "Shop domain", value: shopDomain }, { text: "Customer ID", value: customerExternalId }, ...(ordersToRedact.length > 0 ? [{ text: "Orders to redact", value: ordersToRedact.join(", ") }] : []), ]; waitUntil( createPlainThread({ user: { id: user.id, name: user.name ?? "", email: user.email ?? "", }, title: `Shopify - Customer Redacted request received for ${shopDomain}`, components: rows.map((row) => ({ componentRow: { rowMainContent: [{ componentText: { text: row.text } }], rowAsideContent: [{ componentText: { text: row.value } }], }, })), }), ); return "[Shopify] Customer Redacted request received."; } ================================================ FILE: apps/web/app/(ee)/api/shopify/integration/webhook/orders-paid.ts ================================================ import { qstash } from "@/lib/cron"; import { processOrder } from "@/lib/integrations/shopify/process-order"; import { orderSchema } from "@/lib/integrations/shopify/schema"; import { redis } from "@/lib/upstash"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; export async function ordersPaid({ event, workspaceId, }: { event: any; workspaceId: string; }) { const { customer: orderCustomer, checkout_token: checkoutToken } = orderSchema.parse(event); if (orderCustomer) { const { id: externalId } = orderCustomer; const customer = await prisma.customer.findUnique({ where: { projectId_externalId: { projectId: workspaceId, externalId: externalId.toString(), }, }, }); // customer is found, process the order right away if (customer) { await processOrder({ event, workspaceId, customerId: customer.id, }); return "[Shopify] Order event processed successfully."; } } // Check the cache to see the pixel event for this checkout token exist before publishing the event to the queue const clickId = await redis.hget( `shopify:checkout:${checkoutToken}`, "clickId", ); // clickId is empty, order is not from a Dub link if (clickId === "") { await redis.del(`shopify:checkout:${checkoutToken}`); return "[Shopify] Order is not from a Dub link. Skipping..."; } // clickId is found, process the order for the new customer else if (clickId) { await processOrder({ event, workspaceId, clickId, }); return "[Shopify] Order event processed successfully."; } // clickId is not found, we need to wait for the pixel event to come in so that we can decide if the order is from a Dub link or not else { await redis.hset(`shopify:checkout:${checkoutToken}`, { order: event, }); await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/shopify/order-paid`, body: { checkoutToken, workspaceId, }, retries: 5, delay: 3, }); return "[Shopify] clickId not found, waiting for pixel event to arrive..."; } } ================================================ FILE: apps/web/app/(ee)/api/shopify/integration/webhook/route.ts ================================================ import { prisma } from "@dub/prisma"; import { log } from "@dub/utils"; import crypto from "crypto"; import { appUninstalled } from "./app-uninstalled"; import { customersDataRequest } from "./customers-data-request"; import { customersRedact } from "./customers-redact"; import { ordersPaid } from "./orders-paid"; import { shopRedact } from "./shop-redact"; const relevantTopics = new Set([ "orders/paid", // Mandatory compliance webhooks "app/uninstalled", "customers/data_request", "customers/redact", "shop/redact", ]); // POST /api/shopify/integration/webhook – Listen to Shopify webhook events export const POST = async (req: Request) => { const data = await req.text(); const headers = req.headers; const topic = headers.get("x-shopify-topic") || ""; const signature = headers.get("x-shopify-hmac-sha256") || ""; // Verify signature const generatedSignature = crypto .createHmac("sha256", `${process.env.SHOPIFY_WEBHOOK_SECRET}`) .update(data, "utf8") .digest("base64"); if (generatedSignature !== signature) { return new Response(`[Shopify] Invalid webhook signature. Skipping...`, { status: 401, }); } // Check if topic is relevant if (!relevantTopics.has(topic)) { return new Response(`[Shopify] Unsupported topic: ${topic}. Skipping...`); } const event = JSON.parse(data); const shopDomain = headers.get("x-shopify-shop-domain") || ""; // Find workspace const workspace = await prisma.project.findUnique({ where: { shopifyStoreId: shopDomain, }, select: { id: true, }, }); if (!workspace) { return new Response( `[Shopify] Workspace not found for shop: ${shopDomain}. Skipping...`, ); } let response = "OK"; try { switch (topic) { case "orders/paid": response = await ordersPaid({ event, workspaceId: workspace.id, }); break; case "customers/data_request": response = await customersDataRequest({ event, workspaceId: workspace.id, }); break; case "customers/redact": response = await customersRedact({ event, workspaceId: workspace.id, }); break; case "shop/redact": response = await shopRedact({ event, workspaceId: workspace.id, }); break; case "app/uninstalled": response = await appUninstalled({ shopDomain, }); break; } } catch (error) { await log({ message: `Shopify webhook failed. Error: ${error.message}`, type: "errors", }); return new Response(`[Shopify] Webhook handler failed. View logs`); } return new Response(response); }; ================================================ FILE: apps/web/app/(ee)/api/shopify/integration/webhook/shop-redact.ts ================================================ import { createPlainThread } from "@/lib/plain/create-plain-thread"; import { prisma } from "@dub/prisma"; import { waitUntil } from "@vercel/functions"; import * as z from "zod/v4"; const schema = z.object({ shop_domain: z.string(), }); export async function shopRedact({ event, workspaceId, }: { event: any; workspaceId: string; }) { const { shop_domain: shopDomain } = schema.parse(event); const { user } = await prisma.projectUsers.findFirstOrThrow({ where: { projectId: workspaceId, role: "owner", }, select: { user: { select: { id: true, name: true, email: true, }, }, }, }); const rows = [{ text: "Shop domain", value: shopDomain }]; waitUntil( createPlainThread({ user: { id: user.id, name: user.name ?? "", email: user.email ?? "", }, title: `Shopify - Shop Redacted request received for ${shopDomain}`, components: rows.map((row) => ({ componentRow: { rowMainContent: [{ componentText: { text: row.text } }], rowAsideContent: [{ componentText: { text: row.value } }], }, })), }), ); return "[Shopify] Shop Redacted request received."; } ================================================ FILE: apps/web/app/(ee)/api/shopify/pixel/route.ts ================================================ import { COMMON_CORS_HEADERS } from "@/lib/api/cors"; import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; import { parseRequestBody } from "@/lib/api/utils"; import { getClickEvent } from "@/lib/tinybird"; import { ratelimit, redis } from "@/lib/upstash"; import { LOCALHOST_IP } from "@dub/utils"; import { ipAddress, waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; export const runtime = "edge"; // POST /api/shopify/pixel – Handle the Shopify Pixel events export const POST = async (req: Request) => { try { let { clickId, checkoutToken } = await parseRequestBody(req); if (!checkoutToken) { throw new DubApiError({ code: "bad_request", message: "checkoutToken is required.", }); } // Rate limit the request const ip = process.env.VERCEL === "1" ? ipAddress(req) : LOCALHOST_IP; const { success } = await ratelimit().limit(`shopify-track-pixel:${ip}`); if (!success) { throw new DubApiError({ code: "rate_limit_exceeded", message: "Don't DDoS me pls 🥺", }); } // Validate the clickId if provided if (clickId) { const clickEvent = await getClickEvent({ clickId }); if (!clickEvent) { clickId = null; } } waitUntil( redis.hset(`shopify:checkout:${checkoutToken}`, { clickId: clickId || "", }), ); return NextResponse.json("OK", { headers: COMMON_CORS_HEADERS, }); } catch (error) { return handleAndReturnErrorResponse(error, COMMON_CORS_HEADERS); } }; export const OPTIONS = () => { return new Response(null, { status: 204, headers: COMMON_CORS_HEADERS, }); }; ================================================ FILE: apps/web/app/(ee)/api/singular/webhook/route.ts ================================================ import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; import { normalizeWorkspaceId } from "@/lib/api/workspaces/workspace-id"; import { withAxiom } from "@/lib/axiom/server"; import { trackSingularLeadEvent } from "@/lib/integrations/singular/track-lead"; import { trackSingularSaleEvent } from "@/lib/integrations/singular/track-sale"; import { prisma } from "@dub/prisma"; import { getSearchParams } from "@dub/utils"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; const singularToDubEvent = { activated: "lead", sng_complete_registration: "lead", sng_subscribe: "sale", sng_ecommerce_purchase: "sale", __iap__: "sale", // In-app purchase "Copy GAID": "lead", // Singular Device Assist "copy IDFA": "lead", // Singular Device Assist }; const supportedEvents = Object.keys(singularToDubEvent); const authSchema = z.object({ dub_token: z .string() .min(1, "dub_token is required") .describe("Global token to identify Singular events."), dub_workspace_id: z .string() .min(1, "dub_workspace_id is required") .describe( "The Singular advertiser's workspace ID on Dub (see https://d.to/id).", ) .transform((v) => normalizeWorkspaceId(v)), }); const singularWebhookToken = process.env.SINGULAR_WEBHOOK_TOKEN; // GET /api/singular/webhook – listen to Postback events from Singular export const GET = withAxiom(async (req) => { try { if (!singularWebhookToken) { throw new DubApiError({ code: "bad_request", message: "SINGULAR_WEBHOOK_TOKEN is not set in the environment variables.", }); } const queryParams = getSearchParams(req.url); const { dub_token: token, dub_workspace_id: workspaceId } = authSchema.parse(queryParams); if (token !== singularWebhookToken) { throw new DubApiError({ code: "unauthorized", message: "Invalid Singular webhook token. Skipping event processing.", }); } const { event_name: eventName } = queryParams; if (!eventName) { throw new DubApiError({ code: "bad_request", message: "event_name is required.", }); } if (!supportedEvents.includes(eventName)) { console.error( `Event ${eventName} is not supported by Singular <> Dub integration.`, ); return NextResponse.json("OK"); } const workspace = await prisma.project.findUnique({ where: { id: workspaceId, }, select: { id: true, stripeConnectId: true, webhookEnabled: true, }, }); if (!workspace) { throw new DubApiError({ code: "not_found", message: `Workspace ${workspaceId} not found.`, }); } const dubEvent = singularToDubEvent[eventName]; delete queryParams.dub_token; delete queryParams.dub_workspace_id; if (dubEvent === "lead") { await trackSingularLeadEvent({ queryParams, workspace, }); } else if (dubEvent === "sale") { await trackSingularSaleEvent({ queryParams, workspace, }); } return NextResponse.json("OK"); } catch (error) { return handleAndReturnErrorResponse(error); } }); export const HEAD = () => { return new Response("OK"); }; ================================================ FILE: apps/web/app/(ee)/api/stripe/connect/v2/webhook/outbound-payment-failed.ts ================================================ import { getStripeOutboundPayment } from "@/lib/stripe/get-stripe-outbound-payment"; import { OUTBOUND_PAYMENT_FAILURE_REASONS } from "@/lib/stripe/stripe-v2-schemas"; import { prisma } from "@dub/prisma"; import { pluralize } from "@dub/utils"; import Stripe from "stripe"; export async function outboundPaymentFailed(event: Stripe.ThinEvent) { const { related_object: relatedObject } = event; if (!relatedObject) { return "No related object found in event, skipping..."; } const { id: outboundPaymentId } = relatedObject; const outboundPayment = await getStripeOutboundPayment(outboundPaymentId); const rawFailureReason = outboundPayment.status_details?.failed?.reason; const failureReason = rawFailureReason ? OUTBOUND_PAYMENT_FAILURE_REASONS[rawFailureReason] : undefined; const updatedPayouts = await prisma.payout.updateMany({ where: { stripePayoutId: outboundPaymentId, }, data: { status: "failed", failureReason, }, }); // TODO: // Send email notification return `Updated ${updatedPayouts.count} ${pluralize("payout", updatedPayouts.count)} to failed status.`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/connect/v2/webhook/outbound-payment-posted.ts ================================================ import { getStripeOutboundPayment } from "@/lib/stripe/get-stripe-outbound-payment"; import { prisma } from "@dub/prisma"; import { pluralize } from "@dub/utils"; import Stripe from "stripe"; export async function outboundPaymentPosted(event: Stripe.ThinEvent) { const { related_object: relatedObject } = event; if (!relatedObject) { return "No related object found in event, skipping..."; } const { id: outboundPaymentId } = relatedObject; const outboundPayment = await getStripeOutboundPayment(outboundPaymentId); const stripePayoutTraceId = outboundPayment.trace_id?.value; const updatedPayouts = await prisma.payout.updateMany({ where: { stripePayoutId: outboundPaymentId, }, data: { status: "completed", paidAt: new Date(), failureReason: null, ...(stripePayoutTraceId && { stripePayoutTraceId: stripePayoutTraceId, }), }, }); // TODO: // Send email notification return `Updated ${updatedPayouts.count} ${pluralize("payout", updatedPayouts.count)} to completed status.`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/connect/v2/webhook/outbound-payment-returned.ts ================================================ import { getStripeOutboundPayment } from "@/lib/stripe/get-stripe-outbound-payment"; import { OUTBOUND_PAYMENT_RETURNED_REASONS } from "@/lib/stripe/stripe-v2-schemas"; import { prisma } from "@dub/prisma"; import { pluralize } from "@dub/utils"; import Stripe from "stripe"; export async function outboundPaymentReturned(event: Stripe.ThinEvent) { const { related_object: relatedObject } = event; if (!relatedObject) { return "No related object found in event, skipping..."; } const { id: outboundPaymentId } = relatedObject; const outboundPayment = await getStripeOutboundPayment(outboundPaymentId); const rawReturnedReason = outboundPayment.status_details?.returned?.reason; const failureReason = rawReturnedReason ? OUTBOUND_PAYMENT_RETURNED_REASONS[rawReturnedReason] : undefined; const updatedPayouts = await prisma.payout.updateMany({ where: { stripePayoutId: outboundPaymentId, }, data: { status: "failed", failureReason, }, }); // TODO: // Send email notification return `Updated ${updatedPayouts.count} ${pluralize("payout", updatedPayouts.count)} to failed status.`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/connect/v2/webhook/recipient-account-closed.ts ================================================ import { recomputePartnerPayoutState } from "@/lib/payouts/recompute-partner-payout-state"; import { prisma } from "@dub/prisma"; import type Stripe from "stripe"; export async function recipientAccountClosed(event: Stripe.ThinEvent) { const { related_object: relatedObject } = event; if (!relatedObject) { return "No related object found in event, skipping..."; } const { id: stripeRecipientId } = relatedObject; const partner = await prisma.partner.findUnique({ where: { stripeRecipientId, }, select: { id: true, email: true, stripeConnectId: true, stripeRecipientId: true, paypalEmail: true, payoutsEnabledAt: true, defaultPayoutMethod: true, }, }); if (!partner) { return `Partner with stripeRecipientId ${stripeRecipientId} not found, skipping...`; } const { payoutsEnabledAt, defaultPayoutMethod } = await recomputePartnerPayoutState({ ...partner, stripeRecipientId: null, }); await prisma.partner.update({ where: { id: partner.id, }, data: { stripeRecipientId: null, payoutsEnabledAt, defaultPayoutMethod, }, }); return `Recipient account ${stripeRecipientId} closed, removed stripeRecipientId for partner ${partner.email} (${partner.id})`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/connect/v2/webhook/recipient-configuration-updated.ts ================================================ import { detectDuplicatePayoutMethodFraud } from "@/lib/api/fraud/detect-duplicate-payout-method-fraud"; import { recomputePartnerPayoutState } from "@/lib/payouts/recompute-partner-payout-state"; import { sendEmail } from "@dub/email"; import ConnectedPayoutMethod from "@dub/email/templates/connected-payout-method"; import { prisma } from "@dub/prisma"; import Stripe from "stripe"; export async function recipientConfigurationUpdated(event: Stripe.ThinEvent) { const { related_object: relatedObject } = event; if (!relatedObject) { return "No related object found in event, skipping..."; } const { id: stripeRecipientId } = relatedObject; const partner = await prisma.partner.findUnique({ where: { stripeRecipientId, }, select: { id: true, email: true, stripeConnectId: true, stripeRecipientId: true, paypalEmail: true, payoutsEnabledAt: true, defaultPayoutMethod: true, cryptoWalletAddress: true, }, }); if (!partner) { return `Partner with stripeRecipientId ${stripeRecipientId} not found, skipping...`; } const { payoutsEnabledAt, defaultPayoutMethod, cryptoWalletAddress, cryptoWalletNetwork, maskedCryptoWalletAddress, } = await recomputePartnerPayoutState(partner); await prisma.partner.update({ where: { id: partner.id, }, data: { payoutsEnabledAt, defaultPayoutMethod, cryptoWalletAddress, }, }); if ( partner.email && cryptoWalletAddress && cryptoWalletAddress !== partner.cryptoWalletAddress ) { await sendEmail({ variant: "notifications", subject: "Successfully connected payout method", to: partner.email, react: ConnectedPayoutMethod({ email: partner.email, payoutMethod: { type: "stablecoin", wallet_address: maskedCryptoWalletAddress, wallet_network: cryptoWalletNetwork, }, }), }); } if (cryptoWalletAddress) { await detectDuplicatePayoutMethodFraud({ cryptoWalletAddress: cryptoWalletAddress, }); } return `Updated partner ${partner.email} (${stripeRecipientId}) with payoutsEnabledAt ${payoutsEnabledAt ? "set" : "cleared"}, defaultPayoutMethod ${defaultPayoutMethod ?? "cleared"}`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/connect/v2/webhook/route.ts ================================================ import { stripe } from "@/lib/stripe"; import { log } from "@dub/utils"; import { logAndRespond } from "app/(ee)/api/cron/utils"; import Stripe from "stripe"; import { outboundPaymentFailed } from "./outbound-payment-failed"; import { outboundPaymentPosted } from "./outbound-payment-posted"; import { outboundPaymentReturned } from "./outbound-payment-returned"; import { recipientAccountClosed } from "./recipient-account-closed"; import { recipientConfigurationUpdated } from "./recipient-configuration-updated"; const relevantEvents = new Set([ "v2.core.account.closed", "v2.core.account[configuration.recipient].updated", "v2.core.account[configuration.recipient].capability_status_updated", "v2.money_management.outbound_payment.posted", "v2.money_management.outbound_payment.returned", "v2.money_management.outbound_payment.failed", ]); const webhookSecret = process.env.STRIPE_CONNECT_V2_WEBHOOK_SECRET; // POST /api/stripe/connect/v2/webhook – Stripe Connect Account v2 webhooks export const POST = async (req: Request) => { const body = await req.text(); const signature = req.headers.get("Stripe-Signature"); if (!signature) { return logAndRespond("Missing Stripe-Signature header."); } if (!webhookSecret) { return logAndRespond( "STRIPE_CONNECT_V2_WEBHOOK_SECRET environment variable is not set.", { status: 500, }, ); } let event: Stripe.ThinEvent; try { event = stripe.parseThinEvent(body, signature, webhookSecret); } catch (error) { const message = error instanceof Error ? error.message : String(error); return logAndRespond(`[Webhook error]: ${message}`, { status: 400, }); } if (!relevantEvents.has(event.type)) { return logAndRespond(`Unsupported event ${event.type}, skipping...`); } let response = "OK"; try { switch (event.type) { case "v2.core.account.closed": response = await recipientAccountClosed(event); break; case "v2.core.account[configuration.recipient].updated": case "v2.core.account[configuration.recipient].capability_status_updated": response = await recipientConfigurationUpdated(event); break; case "v2.money_management.outbound_payment.posted": response = await outboundPaymentPosted(event); break; case "v2.money_management.outbound_payment.returned": response = await outboundPaymentReturned(event); break; case "v2.money_management.outbound_payment.failed": response = await outboundPaymentFailed(event); break; } } catch (error) { const message = error instanceof Error ? error.message : String(error); await log({ message: `/api/stripe/connect/v2/webhook webhook failed (${event.type}). Error: ${message}`, type: "errors", }); return logAndRespond(`[Webhook error]: ${message}`, { status: 400, }); } return logAndRespond(`[${event.type}]: ${response}`); }; ================================================ FILE: apps/web/app/(ee)/api/stripe/connect/webhook/account-application-deauthorized.ts ================================================ import { recomputePartnerPayoutState } from "@/lib/payouts/recompute-partner-payout-state"; import { prisma } from "@dub/prisma"; import type Stripe from "stripe"; export async function accountApplicationDeauthorized(event: Stripe.Event) { const stripeAccount = event.account; if (!stripeAccount) { return "No stripeConnectId found in event. Skipping..."; } const partner = await prisma.partner.findUnique({ where: { stripeConnectId: stripeAccount, }, select: { id: true, email: true, stripeConnectId: true, stripeRecipientId: true, paypalEmail: true, payoutsEnabledAt: true, defaultPayoutMethod: true, }, }); if (!partner) { return `Partner with stripeConnectId ${stripeAccount} not found, skipping...`; } const { payoutsEnabledAt, defaultPayoutMethod } = await recomputePartnerPayoutState({ ...partner, stripeConnectId: null, }); await prisma.partner.update({ where: { id: partner.id, }, data: { stripeConnectId: null, payoutsEnabledAt, defaultPayoutMethod, }, }); return `Connected account ${stripeAccount} deauthorized, removed stripeConnectId for partner ${partner.email} (${partner.id})`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/connect/webhook/account-updated.ts ================================================ import { detectDuplicatePayoutMethodFraud } from "@/lib/api/fraud/detect-duplicate-payout-method-fraud"; import { qstash } from "@/lib/cron"; import { getPartnerBankAccount } from "@/lib/partners/get-partner-bank-account"; import { recomputePartnerPayoutState } from "@/lib/payouts/recompute-partner-payout-state"; import { sendEmail } from "@dub/email"; import ConnectedPayoutMethod from "@dub/email/templates/connected-payout-method"; import DuplicatePayoutMethod from "@dub/email/templates/duplicate-payout-method"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import Stripe from "stripe"; const stripePayoutQueue = qstash.queue({ queueName: "send-stripe-payout", }); const balanceAvailableQueue = qstash.queue({ queueName: "handle-balance-available", }); export async function accountUpdated(event: Stripe.Event) { const account = event.data.object as Stripe.Account; const { country } = account; const partner = await prisma.partner.findUnique({ where: { stripeConnectId: account.id, }, select: { id: true, email: true, stripeConnectId: true, stripeRecipientId: true, paypalEmail: true, payoutsEnabledAt: true, defaultPayoutMethod: true, payoutMethodHash: true, }, }); if (!partner) { return `Partner with stripeConnectId ${account.id} not found, skipping...`; } const { payoutsEnabledAt, defaultPayoutMethod } = await recomputePartnerPayoutState(partner); const payoutStateChanged = partner.payoutsEnabledAt !== payoutsEnabledAt || partner.defaultPayoutMethod !== defaultPayoutMethod; if (!payoutStateChanged) { return `No change in payout state for partner ${partner.email} (${partner.stripeConnectId}), skipping...`; } await prisma.partner.update({ where: { id: partner.id, }, data: { payoutsEnabledAt, defaultPayoutMethod, }, }); if (partner.payoutsEnabledAt && !payoutsEnabledAt) { return `Payouts disabled, updated partner ${partner.email} (${partner.stripeConnectId}) with payoutsEnabledAt null`; } const bankAccount = await getPartnerBankAccount(partner.stripeConnectId!); if (!bankAccount) { // TODO: account for cases where partner connects a debit card instead return `No bank account found for partner ${partner.email} (${partner.stripeConnectId}), skipping...`; } const { payoutMethodHash } = await prisma.partner.update({ where: { stripeConnectId: account.id, }, data: { country, payoutsEnabledAt: partner.payoutsEnabledAt ? undefined // Don't update if already set : new Date(), payoutMethodHash: bankAccount.fingerprint, }, }); if (payoutMethodHash) { const [duplicatePartnersCount, _] = await Promise.all([ prisma.partner.count({ where: { payoutMethodHash, id: { not: partner.id, }, }, }), detectDuplicatePayoutMethodFraud({ payoutMethodHash, }), ]); // Send confirmation email only if this is the first time connecting a bank account if ( duplicatePartnersCount === 0 && partner.email && !partner.payoutsEnabledAt ) { await sendEmail({ variant: "notifications", subject: "Successfully connected payout method", to: partner.email, react: ConnectedPayoutMethod({ email: partner.email, payoutMethod: bankAccount, }), }); } // Notify the partner about duplicate payout method if (duplicatePartnersCount > 0 && partner.email) { await sendEmail({ variant: "notifications", subject: "Duplicate payout method detected", to: partner.email, react: DuplicatePayoutMethod({ email: partner.email, payoutMethod: bankAccount, }), }); } } // Retry payouts that got stuck when the account was restricted // (e.g: previously processed payouts OR payouts that were sent but paused due to verification requirements). // Once payouts are re-enabled, queue them for processing. const [previouslyProcessedPayouts, payoutsToWithdraw] = await Promise.all([ prisma.payout.count({ where: { partnerId: partner.id, status: "processed", stripeTransferId: null, }, }), prisma.payout.count({ where: { partnerId: partner.id, status: "sent", mode: "internal", }, }), ]); console.log({ previouslyProcessedPayouts, payoutsToWithdraw }); await Promise.allSettled([ ...(previouslyProcessedPayouts > 0 ? [ stripePayoutQueue .enqueueJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/send-stripe-payout`, method: "POST", deduplicationId: `${event.id}-send-stripe-payout`, body: { partnerId: partner.id, }, }) .then((res) => { console.log( `Enqueued send-stripe-payout queue for partner ${partner.id}: ${res.messageId}`, ); }), ] : []), ...(payoutsToWithdraw > 0 ? [ balanceAvailableQueue .enqueueJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/balance-available`, deduplicationId: `${event.id}-balance-available`, method: "POST", body: { stripeAccount: partner.stripeConnectId, }, }) .then((res) => { console.log( `Enqueued balance-available queue for partner ${partner.stripeConnectId}: ${res.messageId}`, ); }), ] : []), ]); return `Updated partner ${partner.email} (${partner.stripeConnectId}) with country ${country}, payoutsEnabledAt set, payoutMethodHash ${bankAccount.fingerprint}`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/connect/webhook/balance-available.ts ================================================ import { qstash } from "@/lib/cron"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import Stripe from "stripe"; const queue = qstash.queue({ queueName: "handle-balance-available", }); export async function balanceAvailable(event: Stripe.Event) { const stripeAccount = event.account; if (!stripeAccount) { return "No stripeConnectId found in event. Skipping..."; } const response = await queue.enqueueJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/balance-available`, deduplicationId: event.id, method: "POST", body: { stripeAccount, }, }); return `Enqueued handle-balance-available queue for partner ${stripeAccount}: ${response.messageId}`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/connect/webhook/payout-failed.ts ================================================ import { qstash } from "@/lib/cron"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import Stripe from "stripe"; const queue = qstash.queue({ queueName: "handle-payout-failed", }); export async function payoutFailed(event: Stripe.Event) { const stripeAccount = event.account; if (!stripeAccount) { return "No stripeConnectId found in event. Skipping..."; } const stripePayout = event.data.object as Stripe.Payout; const response = await queue.enqueueJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/payout-failed`, deduplicationId: event.id, method: "POST", body: { stripeAccount, stripePayout: { id: stripePayout.id, amount: stripePayout.amount, currency: stripePayout.currency, failureMessage: stripePayout.failure_message, }, }, }); return `Enqueued payout failed for partner ${stripeAccount}: ${response.messageId}`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/connect/webhook/payout-paid.ts ================================================ import { qstash } from "@/lib/cron"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import Stripe from "stripe"; const queue = qstash.queue({ queueName: "handle-payout-paid", }); export async function payoutPaid(event: Stripe.Event) { const stripeAccount = event.account; if (!stripeAccount) { return "No stripeConnectId found in event. Skipping..."; } const stripePayout = event.data.object as Stripe.Payout; const stripePayoutTraceId = stripePayout.trace_id?.value ?? null; const response = await queue.enqueueJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/payout-paid`, deduplicationId: event.id, method: "POST", body: { stripeAccount, stripePayout: { id: stripePayout.id, traceId: stripePayoutTraceId, amount: stripePayout.amount, currency: stripePayout.currency, arrivalDate: stripePayout.arrival_date, }, }, }); return `Enqueued payout paid for partner ${stripeAccount}: ${response.messageId}`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/connect/webhook/route.ts ================================================ import { stripe } from "@/lib/stripe"; import { log } from "@dub/utils"; import { logAndRespond } from "app/(ee)/api/cron/utils"; import Stripe from "stripe"; import { accountApplicationDeauthorized } from "./account-application-deauthorized"; import { accountUpdated } from "./account-updated"; import { balanceAvailable } from "./balance-available"; import { payoutFailed } from "./payout-failed"; import { payoutPaid } from "./payout-paid"; const relevantEvents = new Set([ "account.application.deauthorized", "account.external_account.updated", "account.updated", "balance.available", "payout.paid", "payout.failed", ]); // POST /api/stripe/connect/webhook – listen to Stripe Connect webhooks (for connected accounts) export const POST = async (req: Request) => { const buf = await req.text(); const sig = req.headers.get("Stripe-Signature"); const webhookSecret = process.env.STRIPE_CONNECT_WEBHOOK_SECRET; let event: Stripe.Event; try { if (!sig || !webhookSecret) return; event = stripe.webhooks.constructEvent(buf, sig, webhookSecret); } catch (err: any) { console.log(`❌ Error message: ${err.message}`); return new Response(`Webhook Error: ${err.message}`, { status: 400, }); } // Ignore unsupported events if (!relevantEvents.has(event.type)) { return new Response("Unsupported event, skipping...", { status: 200, }); } let response = "OK"; try { switch (event.type) { case "account.application.deauthorized": response = await accountApplicationDeauthorized(event); break; case "account.updated": response = await accountUpdated(event); break; case "account.external_account.updated": case "balance.available": response = await balanceAvailable(event); break; case "payout.paid": response = await payoutPaid(event); break; case "payout.failed": response = await payoutFailed(event); break; } } catch (error) { await log({ message: `Stripe Connect webhook failed (${event.type}). Error: ${error.message}`, type: "errors", }); return new Response(`Webhook error: ${error.message}`, { status: 400, }); } return logAndRespond(`[${event.type}]: ${response}`); }; ================================================ FILE: apps/web/app/(ee)/api/stripe/integration/callback/route.ts ================================================ import { getSession } from "@/lib/auth"; import { installIntegration } from "@/lib/integrations/install"; import { redis } from "@/lib/upstash"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN, getSearchParams, STRIPE_INTEGRATION_ID } from "@dub/utils"; import { redirect } from "next/navigation"; import { NextRequest } from "next/server"; import * as z from "zod/v4"; const schema = z.object({ state: z.string(), stripe_user_id: z.string().optional(), error: z.string().optional(), error_description: z.string().optional(), }); export const GET = async (req: NextRequest) => { const session = await getSession(); if (!session?.user.id) { return new Response("Unauthorized", { status: 401 }); } const parsed = schema.safeParse(getSearchParams(req.url)); if (!parsed.success) { console.error("[Stripe OAuth callback] Error", parsed.error); return new Response("Invalid request", { status: 400 }); } const { state, stripe_user_id: stripeAccountId, error, error_description, } = parsed.data; // Find workspace that initiated the Stripe app install const workspaceId = await redis.get(`stripe:install:state:${state}`); if (!workspaceId) { redirect(APP_DOMAIN); } // Delete the state key from Redis await redis.del(`stripe:install:state:${state}`); if (error) { const workspace = await prisma.project.findUnique({ where: { id: workspaceId, }, }); if (!workspace) { redirect(APP_DOMAIN); } redirect( `${APP_DOMAIN}/${workspace.slug}/settings/integrations/stripe?stripeConnectError=${error_description}`, ); } else if (stripeAccountId) { // Update the workspace with the Stripe Connect ID const workspace = await prisma.project.update({ where: { id: workspaceId, }, data: { stripeConnectId: stripeAccountId, }, }); await installIntegration({ integrationId: STRIPE_INTEGRATION_ID, userId: session.user.id, workspaceId: workspace.id, }); redirect(`${APP_DOMAIN}/${workspace.slug}/settings/integrations/stripe`); } return new Response("Invalid request", { status: 400 }); }; ================================================ FILE: apps/web/app/(ee)/api/stripe/integration/route.ts ================================================ import { DubApiError } from "@/lib/api/errors"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { installIntegration } from "@/lib/integrations/install"; import { prisma } from "@dub/prisma"; import { STRIPE_INTEGRATION_ID } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; const CORS_HEADERS = new Headers({ "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "PATCH, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", }); // PATCH /api/stripe/integration - update a workspace with a stripe connect account id export const PATCH = withWorkspace( async ({ req, workspace, session, token }) => { const body = await parseRequestBody(req); const { stripeAccountId } = z .object({ stripeAccountId: z.string().nullable(), }) .parse(body); if (!token?.installationId) { throw new DubApiError({ code: "forbidden", message: "You are not authorized to update the stripe integration.", }); } const installation = await prisma.installedIntegration.findUnique({ where: { id: token.installationId, }, select: { integrationId: true, }, }); if (!installation || installation.integrationId !== STRIPE_INTEGRATION_ID) { throw new DubApiError({ code: "forbidden", message: "You are not authorized to update the stripe integration.", }); } try { const response = await prisma.project.update({ where: { id: workspace.id, }, data: { stripeConnectId: stripeAccountId, }, select: { stripeConnectId: true, }, }); waitUntil( (async () => { const installation = await prisma.installedIntegration.findUnique({ where: { userId_integrationId_projectId: { userId: session.user.id, projectId: workspace.id, integrationId: STRIPE_INTEGRATION_ID, }, }, select: { id: true, }, }); // Install the integration if it doesn't exist if (!installation) { await installIntegration({ userId: session.user.id, workspaceId: workspace.id, integrationId: STRIPE_INTEGRATION_ID, credentials: { stripeConnectId: stripeAccountId, }, }); } // Uninstall the integration if the stripe account id is null if (installation && stripeAccountId === null) { await prisma.installedIntegration.delete({ where: { id: installation.id, }, }); } })(), ); return NextResponse.json(response, { headers: CORS_HEADERS, }); } catch (error) { if (error.code === "P2002") { throw new DubApiError({ code: "conflict", message: `The stripe connect account "${stripeAccountId}" is already in use.`, }); } throw new DubApiError({ code: "internal_server_error", message: error.message, }); } }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], requiredRoles: ["owner", "member"], }, ); export const OPTIONS = () => { return new Response(null, { status: 204, headers: CORS_HEADERS, }); }; ================================================ FILE: apps/web/app/(ee)/api/stripe/integration/webhook/account-application-deauthorized.ts ================================================ import { StripeMode } from "@/lib/types"; import { prisma } from "@dub/prisma"; import { STRIPE_INTEGRATION_ID } from "@dub/utils"; import type Stripe from "stripe"; // Handle event "account.application.deauthorized" export async function accountApplicationDeauthorized( event: Stripe.Event, mode: StripeMode, ) { const stripeAccountId = event.account; if (mode === "test") { return `Stripe Connect account ${stripeAccountId} deauthorized in test mode. Skipping...`; } const workspace = await prisma.project.findUnique({ where: { stripeConnectId: stripeAccountId, }, select: { id: true, }, }); if (!workspace) { return `Stripe Connect account ${stripeAccountId} deauthorized.`; } await prisma.project.update({ where: { stripeConnectId: stripeAccountId, }, data: { stripeConnectId: null, }, select: { id: true, }, }); await prisma.installedIntegration.deleteMany({ where: { projectId: workspace.id, integrationId: STRIPE_INTEGRATION_ID, }, }); return `Stripe Connect account ${stripeAccountId} deauthorized for workspace ${workspace.id}`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/integration/webhook/charge-refunded.ts ================================================ import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions"; import { stripeAppClient } from "@/lib/stripe"; import { StripeMode } from "@/lib/types"; import { prisma } from "@dub/prisma"; import type Stripe from "stripe"; // Handle event "charge.refunded" export async function chargeRefunded(event: Stripe.Event, mode: StripeMode) { const charge = event.data.object as Stripe.Charge; const stripeAccountId = event.account as string; const stripe = stripeAppClient({ mode, }); // Charge doesn't have invoice property, so we need to get the invoice from the payment intent const invoicePayments = await stripe.invoicePayments.list( { payment: { payment_intent: charge.payment_intent as string, type: "payment_intent", }, }, { stripeAccount: stripeAccountId, }, ); const invoicePayment = invoicePayments.data.length > 0 ? invoicePayments.data[0] : null; if (!invoicePayment || !invoicePayment.invoice) { return `Charge ${charge.id} has no invoice, skipping...`; } const workspace = await prisma.project.findUnique({ where: { stripeConnectId: stripeAccountId, }, select: { id: true, programs: true, }, }); if (!workspace) { return `Workspace not found for stripe account ${stripeAccountId}`; } if (!workspace.programs.length) { return `Workspace ${workspace.id} for stripe account ${stripeAccountId} has no programs, skipping...`; } const commission = await prisma.commission.findUnique({ where: { invoiceId_programId: { invoiceId: invoicePayment.invoice as string, programId: workspace.programs[0].id, }, }, select: { id: true, status: true, payoutId: true, earnings: true, partnerId: true, programId: true, }, }); if (!commission) { return `Commission not found for invoice ${invoicePayment.invoice}`; } if (commission.status === "paid") { return `Commission ${commission.id} is already paid, skipping...`; } // if the commission is processed and has a payout, we need to update the payout total if (commission.status === "processed" && commission.payoutId) { const payout = await prisma.payout.findUnique({ where: { id: commission.payoutId, }, }); if (payout) { await prisma.payout.update({ where: { id: payout.id, }, data: { amount: payout.amount - commission.earnings, }, }); } } // update the commission status to refunded await prisma.commission.update({ where: { id: commission.id, }, data: { status: "refunded", payoutId: null, }, }); // sync total commissions for the partner in the program await syncTotalCommissions({ partnerId: commission.partnerId, programId: commission.programId, }); return `Commission ${commission.id} updated to status "refunded"`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts ================================================ import { convertCurrency } from "@/lib/analytics/convert-currency"; import { isFirstConversion } from "@/lib/analytics/is-first-conversion"; import { createId } from "@/lib/api/create-id"; import { detectAndRecordFraudEvent } from "@/lib/api/fraud/detect-record-fraud-event"; import { includeTags } from "@/lib/api/links/include-tags"; import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; import { generateRandomName } from "@/lib/names"; import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; import { sendPartnerPostback } from "@/lib/postback/api/send-partner-postback"; import { getClickEvent, getLeadEvent, recordLead, recordSale, } from "@/lib/tinybird"; import { recordFakeClick } from "@/lib/tinybird/record-fake-click"; import { ClickEventTB, LeadEventTB, StripeMode } from "@/lib/types"; import { redis } from "@/lib/upstash"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { transformLeadEventData, transformSaleEventData, } from "@/lib/webhook/transform"; import { prisma } from "@dub/prisma"; import { Customer, Project } from "@dub/prisma/client"; import { COUNTRIES_TO_CONTINENTS, nanoid, pick } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import type Stripe from "stripe"; import { getConnectedCustomer } from "./utils/get-connected-customer"; import { getPromotionCode } from "./utils/get-promotion-code"; import { getSubscriptionProductId } from "./utils/get-subscription-product-id"; import { updateCustomerWithStripeCustomerId } from "./utils/update-customer-with-stripe-customer-id"; // Handle event "checkout.session.completed" export async function checkoutSessionCompleted( event: Stripe.Event, mode: StripeMode, ) { let charge = event.data.object as Stripe.Checkout.Session; let dubCustomerExternalId = charge.metadata?.dubCustomerExternalId || charge.metadata?.dubCustomerId; const clientReferenceId = charge.client_reference_id; const stripeAccountId = event.account as string; const stripeCustomerId = charge.customer as string; const stripeCustomerName = charge.customer_details?.name; const stripeCustomerEmail = charge.customer_details?.email; const invoiceId = charge.invoice as string; const promotionCodeId = charge.discounts?.[0]?.promotion_code as | string | null | undefined; let customer: Customer | null = null; let existingCustomer: Customer | null = null; let clickEvent: ClickEventTB | null = null; let leadEvent: LeadEventTB | undefined; let linkId: string | undefined; const workspace = await prisma.project.findUnique({ where: { stripeConnectId: stripeAccountId, }, select: { id: true, stripeConnectId: true, defaultProgramId: true, webhookEnabled: true, }, }); if (!workspace) { return `Workspace with stripeConnectId ${stripeAccountId} not found, skipping...`; } /* for stripe checkout links: - if client_reference_id is a dub_id, we find the click event - the click event will be used to create a lead event + customer - the lead event will then be passed to the remaining logic to record a sale */ if (clientReferenceId?.startsWith("dub_id_")) { const dubClickId = clientReferenceId.split("dub_id_")[1]; clickEvent = await getClickEvent({ clickId: dubClickId }); if (!clickEvent) { return `Click event with dub_id ${dubClickId} not found, skipping...`; } existingCustomer = await prisma.customer.findFirst({ where: { projectId: workspace.id, // check for existing customer with the same externalId (via clickId or email) OR: [ { externalId: clickEvent.click_id, }, ...(stripeCustomerEmail ? [ { externalId: stripeCustomerEmail, }, ] : []), ], }, }); const payload = { name: stripeCustomerName, email: stripeCustomerEmail, // stripeCustomerId can potentially be null, so we use email as fallback externalId: stripeCustomerId || stripeCustomerEmail, projectId: workspace.id, projectConnectId: stripeAccountId, stripeCustomerId, clickId: clickEvent.click_id, linkId: clickEvent.link_id, country: clickEvent.country, clickedAt: new Date(clickEvent.timestamp + "Z"), }; if (existingCustomer) { customer = await prisma.customer.update({ where: { id: existingCustomer.id, }, data: payload, }); } else { customer = await prisma.customer.create({ data: { id: createId({ prefix: "cus_" }), ...payload, }, }); } // remove timestamp from clickEvent const { timestamp, ...rest } = clickEvent; leadEvent = { ...rest, workspace_id: clickEvent.workspace_id || customer.projectId, // in case for some reason the click event doesn't have workspace_id event_id: nanoid(16), event_name: "Sign up", customer_id: customer.id, metadata: "", }; if (!existingCustomer) { await recordLead(leadEvent); waitUntil(incrementLinkLeads(clickEvent.link_id)); } linkId = clickEvent.link_id; } else if (stripeCustomerId) { /* for regular stripe checkout setup (provided stripeCustomerId is present): - if dubCustomerExternalId is provided: - we try to update the customer with the stripe customerId (for future events) if the customer is not found, we check if a promotion code was used in the checkout: - if yes, follow the promotion code logic below - if no, we skip the event - else: - we first try to see if the customer with the Stripe ID already exists in Dub - if it does, great, we can use the customer found on Dub - if it doesn't, we try to find the customer on the connected account - if present: - we update the customer with the stripe customerId - we then find the lead event using the customer's unique ID on Dub - the lead event will then be passed to the remaining logic to record a sale - if not present: - we check if a promotion code was used in the checkout - if a promotion code is present, we try to attribute via the promotion code: - confirm the promotion code exists in Stripe - find the associated discount code and link in Dub - record a fake click event for attribution - create a new customer and lead event - proceed with sale recording - if no promotion code or attribution fails, we skip the event */ if (dubCustomerExternalId) { customer = await updateCustomerWithStripeCustomerId({ stripeAccountId, dubCustomerExternalId, stripeCustomerId, }); if (!customer) { if (promotionCodeId) { const promoCodeResponse = await attributeViaPromoCode({ promotionCodeId, stripeAccountId, workspace, mode, charge, }); if (promoCodeResponse) { ({ linkId, customer, clickEvent, leadEvent } = promoCodeResponse); } else { return `Failed to attribute via promotion code ${promotionCodeId}, skipping...`; } } else { return `dubCustomerExternalId was provided but customer with dubCustomerExternalId ${dubCustomerExternalId} not found on Dub, skipping...`; } } } else { // find customer by stripeCustomerId or email existingCustomer = await prisma.customer.findFirst({ where: { OR: [ { stripeCustomerId, }, ...(stripeCustomerEmail ? [ { projectId: workspace.id, email: stripeCustomerEmail, }, ] : []), ], }, }); if (existingCustomer) { dubCustomerExternalId = existingCustomer.externalId ?? stripeCustomerId; customer = existingCustomer; } else { const connectedCustomer = await getConnectedCustomer({ stripeCustomerId, stripeAccountId, mode, }); const connectedCustomerDubCustomerExternalId = connectedCustomer?.metadata.dubCustomerExternalId || connectedCustomer?.metadata.dubCustomerId; if (connectedCustomerDubCustomerExternalId) { dubCustomerExternalId = connectedCustomerDubCustomerExternalId; customer = await updateCustomerWithStripeCustomerId({ stripeAccountId, dubCustomerExternalId, stripeCustomerId, }); if (!customer) { return `dubCustomerExternalId was found on the connected customer ${stripeCustomerId} but customer with dubCustomerExternalId ${dubCustomerExternalId} not found on Dub, skipping...`; } } else if (promotionCodeId) { const promoCodeResponse = await attributeViaPromoCode({ promotionCodeId, stripeAccountId, workspace, mode, charge, }); if (promoCodeResponse) { ({ linkId, customer, clickEvent, leadEvent } = promoCodeResponse); } else { return `Failed to attribute via promotion code ${promotionCodeId}, skipping...`; } } else { return `dubCustomerExternalId not found in Stripe checkout session metadata (nor is it available on the connected customer ${stripeCustomerId}), client_reference_id is not a dub_id, and promotion code is not provided, skipping...`; } } } // if leadEvent is not defined yet, we need to pull it from Tinybird if (!leadEvent) { const leadEventData = await getLeadEvent({ customerId: customer.id }); if (!leadEventData) { return `No lead event found for customer ${customer.id}, skipping...`; } leadEvent = { ...leadEventData, workspace_id: leadEventData.workspace_id || customer.projectId, // in case for some reason the lead event doesn't have workspace_id }; linkId = leadEvent.link_id; } } else { return "No stripeCustomerId or dubCustomerExternalId found in Stripe checkout session metadata, skipping..."; } let chargeAmountTotal = (charge.amount_total ?? 0) - (charge.total_details?.amount_tax ?? 0); // should never be below 0, but just in case if (chargeAmountTotal <= 0) { return `Checkout session completed for Stripe customer ${stripeCustomerId} but amount is 0, skipping...`; } if (charge.mode === "setup") { return `Checkout session completed for Stripe customer ${stripeCustomerId} but mode is "setup", skipping...`; } if (charge.payment_status !== "paid") { return `Checkout session completed for Stripe customer ${stripeCustomerId} but payment_status is not "paid", skipping...`; } if (invoiceId) { // Skip if invoice id is already processed const ok = await redis.set( `trackSale:stripe:invoiceId:${invoiceId}`, // here we assume that Stripe's invoice ID is unique across all customers { timestamp: new Date().toISOString(), dubCustomerExternalId, stripeCustomerId, stripeAccountId, invoiceId, customerId: customer.id, workspaceId: customer.projectId, amount: chargeAmountTotal, currency: charge.currency, }, { ex: 60 * 60 * 24 * 7, nx: true, }, ); if (!ok) { console.info( "[Stripe Webhook] Skipping already processed invoice.", invoiceId, ); return `Invoice with ID ${invoiceId} already processed, skipping...`; } } if (charge.currency && charge.currency !== "usd" && chargeAmountTotal) { // support for Stripe Adaptive Pricing: https://docs.stripe.com/payments/checkout/adaptive-pricing if (charge.currency_conversion) { charge.currency = charge.currency_conversion.source_currency; chargeAmountTotal = charge.currency_conversion.amount_total; // if Stripe Adaptive Pricing is not enabled, we convert the amount to USD based on the current FX rate // TODO: allow custom "defaultCurrency" on workspace table in the future } else { const { currency: convertedCurrency, amount: convertedAmount } = await convertCurrency({ currency: charge.currency, amount: chargeAmountTotal, }); charge.currency = convertedCurrency; chargeAmountTotal = convertedAmount; } } const saleData = { ...leadEvent, workspace_id: leadEvent.workspace_id || customer.projectId, // in case for some reason the lead event doesn't have workspace_id event_id: nanoid(16), // if the charge is a one-time payment, we set the event name to "Purchase" event_name: charge.mode === "payment" ? "Purchase" : "Subscription creation", payment_processor: "stripe", amount: chargeAmountTotal, currency: charge.currency!, invoice_id: invoiceId || "", metadata: JSON.stringify({ charge, }), }; const link = await prisma.link.findUnique({ where: { id: linkId, }, }); const firstConversionFlag = isFirstConversion({ customer, linkId, }); const [_sale, linkUpdated] = await Promise.all([ recordSale(saleData), // update link stats link && prisma.link.update({ where: { id: link.id, }, data: { ...(firstConversionFlag && { conversions: { increment: 1, }, lastConversionAt: new Date(), }), sales: { increment: 1, }, saleAmount: { increment: chargeAmountTotal, }, }, include: includeTags, }), // update workspace usage prisma.project.update({ where: { id: customer.projectId, }, data: { usage: { increment: 1, }, }, }), // update customer stats + program/partner associations prisma.customer.update({ where: { id: customer.id, }, data: { ...(link?.programId && { programId: link.programId, }), ...(link?.partnerId && { partnerId: link.partnerId, }), sales: { increment: 1, }, saleAmount: { increment: chargeAmountTotal, }, firstSaleAt: customer.firstSaleAt ? undefined : new Date(), subscriptionCanceledAt: null, }, }), ]); // for program links let createdCommission: | Awaited> | undefined = undefined; if (link && link.programId && link.partnerId) { const productId = await getSubscriptionProductId({ stripeSubscriptionId: charge.subscription as string, stripeAccountId, mode, }); createdCommission = await createPartnerCommission({ event: "sale", programId: link.programId, partnerId: link.partnerId, linkId: link.id, eventId: saleData.event_id, customerId: customer.id, amount: saleData.amount, quantity: 1, invoiceId, currency: saleData.currency, context: { customer: { country: customer.country, signupDate: customer.createdAt, }, sale: { productId, amount: saleData.amount, }, }, }); const { webhookPartner, programEnrollment } = createdCommission; waitUntil( Promise.allSettled([ executeWorkflows({ trigger: "partnerMetricsUpdated", reason: "sale", identity: { workspaceId: workspace.id, programId: link.programId, partnerId: link.partnerId, }, metrics: { current: { saleAmount: saleData.amount, conversions: firstConversionFlag ? 1 : 0, }, }, }), syncPartnerLinksStats({ partnerId: link.partnerId, programId: link.programId, eventType: "sale", }), webhookPartner && detectAndRecordFraudEvent({ program: { id: link.programId }, partner: pick(webhookPartner, ["id", "email", "name"]), programEnrollment: pick(programEnrollment, ["status"]), customer: { ...pick(customer, ["id", "email", "name"]), isFirstConversion: firstConversionFlag, }, link: pick(link, ["id"]), click: pick(saleData, ["url", "referer"]), event: { id: saleData.event_id }, }), ]), ); } waitUntil( Promise.allSettled([ sendWorkspaceWebhook({ trigger: "sale.created", workspace, data: transformSaleEventData({ ...saleData, clickedAt: customer.clickedAt || customer.createdAt, link: linkUpdated, customer, partner: createdCommission?.webhookPartner, metadata: null, }), }), ...(link?.partnerId ? [ sendPartnerPostback({ partnerId: link.partnerId, event: "sale.created", data: { ...saleData, clickedAt: customer.clickedAt || customer.createdAt, link: linkUpdated, customer, }, }), ] : []), ]), ); return `Checkout session completed for customer with external ID ${dubCustomerExternalId} and invoice ID ${invoiceId}`; } async function attributeViaPromoCode({ promotionCodeId, stripeAccountId, workspace, mode, charge, }: { promotionCodeId: string; stripeAccountId: string; workspace: Pick< Project, "id" | "defaultProgramId" | "stripeConnectId" | "webhookEnabled" >; mode: StripeMode; charge: Stripe.Checkout.Session; }) { // Find the promotion code for the promotion code id const promotionCode = await getPromotionCode({ promotionCodeId, stripeAccountId, mode, }); if (!promotionCode) { console.log( `Promotion code ${promotionCodeId} not found in connected account ${stripeAccountId}, skipping...`, ); return null; } if (!workspace.defaultProgramId) { console.log( `Workspace with stripeConnectId ${stripeAccountId} has no default program, skipping...`, ); return null; } const discountCode = await prisma.discountCode.findUnique({ where: { programId_code: { programId: workspace.defaultProgramId, code: promotionCode.code, }, }, select: { link: true, }, }); if (!discountCode) { console.log( `Couldn't find link associated with promotion code ${promotionCode.code}, skipping...`, ); return null; } const link = discountCode.link; const linkId = link.id; // Record a fake click for this event const customerDetails = charge.customer_details; const customerAddress = customerDetails?.address; const clickEvent = await recordFakeClick({ link, customer: { continent: customerAddress?.country ? COUNTRIES_TO_CONTINENTS[customerAddress.country] : "Unknown", country: customerAddress?.country ?? "Unknown", region: customerAddress?.state ?? "Unknown", }, }); const customer = await prisma.customer.create({ data: { id: createId({ prefix: "cus_" }), name: customerDetails?.name || customerDetails?.email || generateRandomName(), email: customerDetails?.email, externalId: clickEvent.click_id, stripeCustomerId: charge.customer as string, linkId: clickEvent.link_id, clickId: clickEvent.click_id, clickedAt: new Date(clickEvent.timestamp + "Z"), country: customerAddress?.country, projectId: workspace.id, projectConnectId: workspace.stripeConnectId, }, }); // Prepare the payload for the lead event const { timestamp, ...rest } = clickEvent; const leadEvent = { ...rest, workspace_id: clickEvent.workspace_id || customer.projectId, // in case for some reason the click event doesn't have workspace_id event_id: nanoid(16), event_name: "Checkout with discount code", customer_id: customer.id, metadata: "", }; await recordLead(leadEvent); // record lead side effects (link stats, partner commissions, workflows, workspace webhook) waitUntil( (async () => { const linkUpdated = await incrementLinkLeads(link.id); let createdCommission: | Awaited> | undefined = undefined; if (link.programId && link.partnerId) { createdCommission = await createPartnerCommission({ event: "lead", programId: link.programId, partnerId: link.partnerId, linkId: link.id, eventId: leadEvent.event_id, customerId: customer.id, quantity: 1, context: { customer: { country: customer.country, }, }, }); await Promise.allSettled([ executeWorkflows({ trigger: "partnerMetricsUpdated", reason: "lead", identity: { workspaceId: workspace.id, programId: link.programId, partnerId: link.partnerId, }, metrics: { current: { leads: 1, }, }, }), syncPartnerLinksStats({ partnerId: link.partnerId, programId: link.programId, eventType: "lead", }), ]); } await Promise.allSettled([ sendWorkspaceWebhook({ trigger: "lead.created", workspace, data: transformLeadEventData({ ...leadEvent, eventName: "Checkout session completed", link: linkUpdated, customer, partner: createdCommission?.webhookPartner, metadata: null, }), }), ...(link.partnerId ? [ sendPartnerPostback({ partnerId: link.partnerId, event: "lead.created", data: { ...leadEvent, eventName: "Checkout session completed", link: linkUpdated, customer, }, }), ] : []), ]); })(), ); return { linkId, customer, clickEvent, leadEvent, }; } async function incrementLinkLeads(linkId: string) { return prisma.link.update({ where: { id: linkId, }, data: { leads: { increment: 1, }, lastLeadAt: new Date(), }, include: includeTags, }); } ================================================ FILE: apps/web/app/(ee)/api/stripe/integration/webhook/coupon-deleted.ts ================================================ import { getWorkspaceUsers } from "@/lib/api/get-workspace-users"; import { qstash } from "@/lib/cron"; import { sendBatchEmail } from "@dub/email"; import { VARIANT_TO_FROM_MAP } from "@dub/email/resend/constants"; import DiscountDeleted from "@dub/email/templates/discount-deleted"; import { prisma } from "@dub/prisma"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import type Stripe from "stripe"; // Handle event "coupon.deleted" export async function couponDeleted(event: Stripe.Event) { const coupon = event.data.object as Stripe.Coupon; const stripeAccountId = event.account as string; const workspace = await prisma.project.findUnique({ where: { stripeConnectId: stripeAccountId, }, select: { id: true, slug: true, defaultProgramId: true, stripeConnectId: true, }, }); if (!workspace) { return `Workspace not found for Stripe account ${stripeAccountId}.`; } if (!workspace.defaultProgramId) { return `Workspace ${workspace.id} for stripe account ${stripeAccountId} has no programs.`; } const discounts = await prisma.discount.findMany({ where: { programId: workspace.defaultProgramId, OR: [{ couponId: coupon.id }, { couponTestId: coupon.id }], }, include: { partnerGroup: true, }, }); if (!discounts.length) { return `Discount not found for Stripe coupon ${coupon.id}.`; } const discountIds = discounts.map((d) => d.id); await prisma.$transaction(async (tx) => { if (discountIds.length > 0) { await tx.partnerGroup.updateMany({ where: { discountId: { in: discountIds, }, }, data: { discountId: null, }, }); await tx.programEnrollment.updateMany({ where: { discountId: { in: discountIds, }, }, data: { discountId: null, }, }); await tx.discountCode.deleteMany({ where: { discountId: { in: discountIds, }, }, }); await tx.discount.deleteMany({ where: { id: { in: discountIds, }, }, }); } }); waitUntil( (async () => { const { users } = await getWorkspaceUsers({ workspaceId: workspace.id, role: "owner", }); const groupIds = discounts .map((d) => d.partnerGroup?.id) .filter(Boolean) as string[]; await Promise.allSettled([ ...groupIds.map((groupId) => qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/links/invalidate-for-discounts`, body: { groupId, }, }), ), sendBatchEmail( users.map((user) => ({ from: VARIANT_TO_FROM_MAP.notifications, to: user.email, subject: `${process.env.NEXT_PUBLIC_APP_NAME}: Discount has been deleted`, react: DiscountDeleted({ email: user.email, coupon: { id: coupon.id, }, }), })), ), ]); })(), ); return `Stripe coupon ${coupon.id} deleted.`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/integration/webhook/customer-created.ts ================================================ import { prisma } from "@dub/prisma"; import type Stripe from "stripe"; import { createNewCustomer } from "./utils/create-new-customer"; // Handle event "customer.created" export async function customerCreated(event: Stripe.Event) { const stripeCustomer = event.data.object as Stripe.Customer; const stripeAccountId = event.account as string; const dubCustomerExternalId = stripeCustomer.metadata?.dubCustomerExternalId || stripeCustomer.metadata?.dubCustomerId; if (!dubCustomerExternalId) { return "External ID not found in Stripe customer metadata, skipping..."; } const workspace = await prisma.project.findUnique({ where: { stripeConnectId: stripeAccountId, }, select: { id: true, }, }); if (!workspace) { return "Workspace not found, skipping..."; } // Check the customer is not already created const customer = await prisma.customer.findFirst({ where: { OR: [ { projectId: workspace.id, externalId: dubCustomerExternalId, }, { stripeCustomerId: stripeCustomer.id, }, ], }, }); if (customer) { // if customer exists (created via /track/lead) // update it with the Stripe customer ID (for future reference by invoice.paid) try { await prisma.customer.update({ where: { id: customer.id, }, data: { externalId: dubCustomerExternalId, stripeCustomerId: stripeCustomer.id, projectConnectId: stripeAccountId, }, }); return `Dub customer with ID ${customer.id} updated with Stripe customer ID ${stripeCustomer.id}`; } catch (error) { console.error(error); return `Error updating Dub customer with ID ${customer.id}: ${error}`; } } // otherwise create a new customer return await createNewCustomer(event); } ================================================ FILE: apps/web/app/(ee)/api/stripe/integration/webhook/customer-subscription-created.ts ================================================ import { trackLead } from "@/lib/api/conversions/track-lead"; import { stripeIntegrationSettingsSchema } from "@/lib/integrations/stripe/schema"; import { StripeMode } from "@/lib/types"; import { prisma } from "@dub/prisma"; import { Customer } from "@dub/prisma/client"; import { pick, STRIPE_INTEGRATION_ID } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import type Stripe from "stripe"; import { getConnectedCustomer } from "./utils/get-connected-customer"; // Handle event "customer.subscription.created" // only used for recording free trial creations export async function customerSubscriptionCreated( event: Stripe.Event, mode: StripeMode, ) { const createdSubscription = event.data.object as Stripe.Subscription; if (createdSubscription.status !== "trialing") { return "Subscription is not in trialing status, skipping..."; } const stripeAccountId = event.account as string; const stripeCustomerId = createdSubscription.customer as string; const workspace = await prisma.project.findUnique({ where: { stripeConnectId: stripeAccountId, }, select: { id: true, slug: true, stripeConnectId: true, webhookEnabled: true, installedIntegrations: { where: { integrationId: STRIPE_INTEGRATION_ID, }, }, }, }); if (!workspace) { return `Workspace with stripeConnectId ${stripeAccountId} not found, skipping...`; } if (!workspace.installedIntegrations.length) { return `Workspace ${workspace.slug} has no Stripe integration installed, skipping...`; } const stripeIntegrationSettings = stripeIntegrationSettingsSchema.parse( workspace.installedIntegrations[0].settings ?? {}, ); if (!stripeIntegrationSettings?.freeTrials?.enabled) { return `Stripe free trial tracking is not enabled for workspace ${workspace.slug}, skipping...`; } let customer: Customer | null = null; // find customer by stripeCustomerId or email customer = await prisma.customer.findUnique({ where: { stripeCustomerId, }, }); if (!customer) { const stripeCustomer = await getConnectedCustomer({ stripeCustomerId, stripeAccountId, mode, }); if (stripeCustomer?.email) { customer = await prisma.customer.findFirst({ where: { projectId: workspace.id, email: stripeCustomer.email, }, }); if (!customer) { // this should never happen, but just in case return `Customer ${stripeCustomer.id} with email ${stripeCustomer.email} has not been tracked yet, skipping...`; } // update the customer with the Stripe customer ID (for future reference by invoice.paid) waitUntil( prisma.customer.update({ where: { id: customer.id, }, data: { stripeCustomerId, }, }), ); } else { // this should never happen either, but just in case return `Customer with stripeCustomerId ${stripeCustomerId} ${stripeCustomer ? "does not have an email on Stripe" : "does not exist"}, skipping...`; } } if (!customer.clickId) { return `Customer ${customer.id} has no clickId, skipping...`; } if (!customer.externalId) { return `Customer ${customer.id} has no externalId, skipping...`; } // if trackQuantity is enabled, use the quantity from the main subscription item // (e.g. for a 3-seat free trial, the event quantity will be 3) const eventQuantity = stripeIntegrationSettings.freeTrials.trackQuantity ? createdSubscription.items.data[0].quantity : 1; await trackLead({ clickId: customer.clickId, eventName: "Started Trial", customerExternalId: customer.externalId, customerName: customer.name, customerEmail: customer.email, eventQuantity, rawBody: {}, workspace: pick(workspace, ["id", "stripeConnectId", "webhookEnabled"]), source: "trial", }); return `Customer subscription created for customer ${customer.id} with stripeCustomerId ${stripeCustomerId} and workspace ${workspace.slug}`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/integration/webhook/customer-subscription-deleted.ts ================================================ import { prisma } from "@dub/prisma"; import type Stripe from "stripe"; // Handle event "customer.subscription.deleted" export async function customerSubscriptionDeleted(event: Stripe.Event) { const deletedSubscription = event.data.object as Stripe.Subscription; const customer = await prisma.customer.findUnique({ where: { stripeCustomerId: deletedSubscription.customer.toString(), }, }); if (!customer) { return "Customer not found, skipping subscription cancellation..."; } const updatedCustomer = await prisma.customer.update({ where: { id: customer.id }, data: { subscriptionCanceledAt: new Date() }, }); return `Subscription cancelled, updating customer ${updatedCustomer.id} with subscriptionCanceledAt: ${updatedCustomer.subscriptionCanceledAt}`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/integration/webhook/customer-updated.ts ================================================ import { prisma } from "@dub/prisma"; import type Stripe from "stripe"; import { createNewCustomer } from "./utils/create-new-customer"; // Handle event "customer.updated" export async function customerUpdated(event: Stripe.Event) { const stripeCustomer = event.data.object as Stripe.Customer; const stripeAccountId = event.account as string; const dubCustomerExternalId = stripeCustomer.metadata?.dubCustomerExternalId || stripeCustomer.metadata?.dubCustomerId; if (!dubCustomerExternalId) { return "External ID not found in Stripe customer metadata, skipping..."; } const workspace = await prisma.project.findUnique({ where: { stripeConnectId: stripeAccountId, }, select: { id: true, }, }); if (!workspace) { return "Workspace not found, skipping..."; } const customer = await prisma.customer.findFirst({ where: { OR: [ { projectId: workspace.id, externalId: dubCustomerExternalId, }, { stripeCustomerId: stripeCustomer.id, }, ], }, }); if (customer) { try { await prisma.customer.update({ where: { id: customer.id, }, data: { name: stripeCustomer.name, email: stripeCustomer.email, externalId: dubCustomerExternalId, stripeCustomerId: stripeCustomer.id, projectConnectId: stripeAccountId, }, }); return `Dub customer with ID ${customer.id} updated.`; } catch (error) { console.error(error); return `Error updating Dub customer with ID ${customer.id}: ${error}`; } } // otherwise create a new customer return await createNewCustomer(event); } ================================================ FILE: apps/web/app/(ee)/api/stripe/integration/webhook/invoice-paid.ts ================================================ import { convertCurrency } from "@/lib/analytics/convert-currency"; import { isFirstConversion } from "@/lib/analytics/is-first-conversion"; import { detectAndRecordFraudEvent } from "@/lib/api/fraud/detect-record-fraud-event"; import { includeTags } from "@/lib/api/links/include-tags"; import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; import { createPartnerCommission } from "@/lib/partners/create-partner-commission"; import { sendPartnerPostback } from "@/lib/postback/api/send-partner-postback"; import { getLeadEvent, recordSale } from "@/lib/tinybird"; import { StripeMode } from "@/lib/types"; import { redis } from "@/lib/upstash"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { transformSaleEventData } from "@/lib/webhook/transform"; import { prisma } from "@dub/prisma"; import { nanoid, pick } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import type Stripe from "stripe"; import { getConnectedCustomer } from "./utils/get-connected-customer"; // Handle event "invoice.paid" export async function invoicePaid(event: Stripe.Event, mode: StripeMode) { const invoice = event.data.object as Stripe.Invoice; const stripeAccountId = event.account as string; const stripeCustomerId = invoice.customer as string; const invoiceId = invoice.id; // Find customer using stripeCustomerId let customer = await prisma.customer.findUnique({ where: { stripeCustomerId, }, }); // if customer is not found, we check if the connected customer has a dubCustomerExternalId if (!customer) { const connectedCustomer = await getConnectedCustomer({ stripeCustomerId, stripeAccountId, mode, }); const dubCustomerExternalId = connectedCustomer?.metadata.dubCustomerExternalId || connectedCustomer?.metadata.dubCustomerId; if (dubCustomerExternalId) { try { // Update customer with stripeCustomerId if exists – for future events customer = await prisma.customer.update({ where: { projectConnectId_externalId: { projectConnectId: stripeAccountId, externalId: dubCustomerExternalId, }, }, data: { stripeCustomerId, }, }); } catch (error) { console.log(error); return `Customer with dubCustomerExternalId ${dubCustomerExternalId} not found, skipping...`; } } } // if customer is still not found, we skip the event if (!customer) { return `Customer with stripeCustomerId ${stripeCustomerId} not found on Dub (nor does the connected customer ${stripeCustomerId} have a valid dubCustomerExternalId), skipping...`; } // Sale amount excluding tax: use total_excluding_tax only when invoice was paid in full // (amount_paid === total); otherwise use amount_paid (e.g. credits applied, upsells, etc.). let invoiceSaleAmount = invoice.amount_paid === invoice.total && invoice.total_excluding_tax != null ? invoice.total_excluding_tax : invoice.amount_paid; // Skip if invoice id is already processed const ok = await redis.set( `trackSale:stripe:invoiceId:${invoiceId}`, // here we assume that Stripe's invoice ID is unique across all customers { timestamp: new Date().toISOString(), dubCustomerExternalId: customer.externalId, stripeCustomerId, stripeAccountId, invoiceId, customerId: customer.id, workspaceId: customer.projectId, amount: invoiceSaleAmount, currency: invoice.currency, }, { ex: 60 * 60 * 24 * 7, nx: true, }, ); if (!ok) { console.info( "[Stripe Webhook] Skipping already processed invoice.", invoiceId, ); return `Invoice with ID ${invoiceId} already processed, skipping...`; } // Stripe can sometimes return a negative amount for some reason, so we skip if it's below 0 if (invoiceSaleAmount <= 0) { return `Invoice with ID ${invoiceId} has an amount of 0, skipping...`; } // if currency is not USD, convert it to USD based on the current FX rate // TODO: allow custom "defaultCurrency" on workspace table in the future if (invoice.currency && invoice.currency !== "usd") { const { currency: convertedCurrency, amount: convertedAmount } = await convertCurrency({ currency: invoice.currency, amount: invoiceSaleAmount, }); invoice.currency = convertedCurrency; invoiceSaleAmount = convertedAmount; } // Find lead const leadEvent = await getLeadEvent({ customerId: customer.id }); if (!leadEvent) { return `Lead event with customer ID ${customer.id} not found, skipping...`; } const eventId = nanoid(16); // if the invoice has no subscription, it's a one-time payment const isOneTimePayment = invoice.lines.data.some( (line) => line.parent?.subscription_item_details === null, ); const saleData = { ...leadEvent, workspace_id: leadEvent.workspace_id || customer.projectId, // in case for some reason the lead event doesn't have workspace_id event_id: eventId, event_name: isOneTimePayment ? "Purchase" : "Invoice paid", payment_processor: "stripe", amount: invoiceSaleAmount, currency: invoice.currency, invoice_id: invoiceId, metadata: JSON.stringify({ invoice, }), }; const linkId = leadEvent.link_id; const link = await prisma.link.findUnique({ where: { id: linkId, }, }); if (!link) { return `Link with ID ${linkId} not found, skipping...`; } const firstConversionFlag = isFirstConversion({ customer, linkId, }); const [_sale, linkUpdated, workspace] = await Promise.all([ recordSale(saleData), // update link stats prisma.link.update({ where: { id: linkId, }, data: { ...(firstConversionFlag && { conversions: { increment: 1, }, lastConversionAt: new Date(), }), sales: { increment: 1, }, saleAmount: { increment: invoiceSaleAmount, }, }, include: includeTags, }), // update workspace sales usage prisma.project.update({ where: { id: customer.projectId, }, data: { usage: { increment: 1, }, }, }), // update customer sales count prisma.customer.update({ where: { id: customer.id, }, data: { sales: { increment: 1, }, saleAmount: { increment: invoiceSaleAmount, }, firstSaleAt: customer.firstSaleAt ? undefined : new Date(), }, }), ]); // for program links let createdCommission: | Awaited> | undefined = undefined; if (link.programId && link.partnerId) { createdCommission = await createPartnerCommission({ event: "sale", programId: link.programId, partnerId: link.partnerId, linkId: link.id, eventId, customerId: customer.id, amount: saleData.amount, quantity: 1, invoiceId, currency: saleData.currency, context: { customer: { country: customer.country, signupDate: customer.createdAt, }, sale: { productId: invoice.lines.data[0]?.pricing?.price_details?.product, amount: saleData.amount, }, }, }); const { webhookPartner, programEnrollment } = createdCommission; waitUntil( Promise.allSettled([ executeWorkflows({ trigger: "partnerMetricsUpdated", reason: "sale", identity: { workspaceId: workspace.id, programId: link.programId, partnerId: link.partnerId, }, metrics: { current: { saleAmount: saleData.amount, conversions: firstConversionFlag ? 1 : 0, }, }, }), syncPartnerLinksStats({ partnerId: link.partnerId, programId: link.programId, eventType: "sale", }), webhookPartner && detectAndRecordFraudEvent({ program: { id: link.programId }, partner: pick(webhookPartner, ["id", "email", "name"]), programEnrollment: pick(programEnrollment, ["status"]), customer: { ...pick(customer, ["id", "email", "name"]), isFirstConversion: firstConversionFlag, }, link: pick(link, ["id"]), click: pick(saleData, ["url", "referer"]), event: { id: saleData.event_id }, }), ]), ); } waitUntil( Promise.allSettled([ sendWorkspaceWebhook({ trigger: "sale.created", workspace, data: transformSaleEventData({ ...saleData, clickedAt: customer.clickedAt || customer.createdAt, link: linkUpdated, customer, partner: createdCommission?.webhookPartner, metadata: null, }), }), ...(link?.partnerId ? [ sendPartnerPostback({ partnerId: link.partnerId, event: "sale.created", data: { ...saleData, clickedAt: customer.clickedAt || customer.createdAt, link: linkUpdated, customer, }, }), ] : []), ]), ); return `Sale recorded for customer ID ${customer.id} and invoice ID ${invoiceId}`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/integration/webhook/promotion-code-updated.ts ================================================ import { prisma } from "@dub/prisma"; import type Stripe from "stripe"; // Handle event "promotion_code.updated" export async function promotionCodeUpdated(event: Stripe.Event) { const promotionCode = event.data.object as Stripe.PromotionCode; const stripeAccountId = event.account as string; const workspace = await prisma.project.findUnique({ where: { stripeConnectId: stripeAccountId, }, select: { id: true, slug: true, defaultProgramId: true, stripeConnectId: true, }, }); if (!workspace) { return `Workspace not found for Stripe account ${stripeAccountId}.`; } if (!workspace.defaultProgramId) { return `Workspace ${workspace.id} for stripe account ${stripeAccountId} has no programs.`; } if (promotionCode.active) { return `Promotion code ${promotionCode.id} is active.`; } // If the promotion code is not active, we need to remove them from Dub const discountCode = await prisma.discountCode.findUnique({ where: { programId_code: { programId: workspace.defaultProgramId, code: promotionCode.code, }, }, }); if (!discountCode) { return `Discount code not found for Stripe promotion code ${promotionCode.id}.`; } await prisma.discountCode.delete({ where: { id: discountCode.id, }, }); return `Discount code ${discountCode.id} deleted from the program ${workspace.defaultProgramId}.`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/integration/webhook/route.ts ================================================ import { withAxiom } from "@/lib/axiom/server"; import { stripe } from "@/lib/stripe"; import { StripeMode } from "@/lib/types"; import { logAndRespond } from "app/(ee)/api/cron/utils"; import Stripe from "stripe"; import { accountApplicationDeauthorized } from "./account-application-deauthorized"; import { chargeRefunded } from "./charge-refunded"; import { checkoutSessionCompleted } from "./checkout-session-completed"; import { couponDeleted } from "./coupon-deleted"; import { customerCreated } from "./customer-created"; import { customerSubscriptionCreated } from "./customer-subscription-created"; import { customerSubscriptionDeleted } from "./customer-subscription-deleted"; import { customerUpdated } from "./customer-updated"; import { invoicePaid } from "./invoice-paid"; import { promotionCodeUpdated } from "./promotion-code-updated"; const relevantEvents = new Set([ "account.application.deauthorized", "charge.refunded", "checkout.session.completed", "coupon.deleted", "customer.created", "customer.updated", "customer.subscription.created", "customer.subscription.deleted", "invoice.paid", "promotion_code.updated", ]); // POST /api/stripe/integration/webhook – listen to Stripe webhooks (for Stripe Integration) export const POST = withAxiom(async (req: Request) => { const pathname = new URL(req.url).pathname; const buf = await req.text(); const sig = req.headers.get("Stripe-Signature"); // @see https://github.com/dubinc/dub/blob/main/apps/web/app/(ee)/api/stripe/integration/webhook/test/route.ts let webhookSecret: string | undefined; let mode: StripeMode; if (pathname.endsWith("/test")) { webhookSecret = process.env.STRIPE_APP_WEBHOOK_SECRET_TEST; mode = "test"; } else if (pathname.endsWith("/sandbox")) { webhookSecret = process.env.STRIPE_APP_WEBHOOK_SECRET_SANDBOX; mode = "sandbox"; } else { webhookSecret = process.env.STRIPE_APP_WEBHOOK_SECRET; mode = "live"; } if (!sig || !webhookSecret) { return new Response("Invalid request", { status: 400, }); } let event: Stripe.Event; try { event = stripe.webhooks.constructEvent(buf, sig, webhookSecret); } catch (err: any) { console.log(`❌ Error message: ${err.message}`); return new Response(`Webhook Error: ${err.message}`, { status: 400, }); } // Ignore unsupported events if (!relevantEvents.has(event.type)) { return new Response("Unsupported event, skipping...", { status: 200, }); } // When an app is installed in both live & test mode, // test mode events are sent to both the test mode and live mode endpoints, // and live mode events are sent to the live mode endpoint. // See: https://docs.stripe.com/stripe-apps/build-backend#event-behavior-depends-on-install-mode if (!event.livemode && mode === "live") { return logAndRespond( `Received a test webhook event (${event.type}) on our live webhook receiver endpoint, skipping...`, ); } let response = "OK"; switch (event.type) { case "account.application.deauthorized": response = await accountApplicationDeauthorized(event, mode); break; case "charge.refunded": response = await chargeRefunded(event, mode); break; case "checkout.session.completed": response = await checkoutSessionCompleted(event, mode); break; case "coupon.deleted": response = await couponDeleted(event); break; case "customer.created": response = await customerCreated(event); break; case "customer.updated": response = await customerUpdated(event); break; case "customer.subscription.created": response = await customerSubscriptionCreated(event, mode); break; case "customer.subscription.deleted": response = await customerSubscriptionDeleted(event); break; case "invoice.paid": response = await invoicePaid(event, mode); break; case "promotion_code.updated": response = await promotionCodeUpdated(event); break; } return logAndRespond(`[${event.type}]: ${response}`); }); ================================================ FILE: apps/web/app/(ee)/api/stripe/integration/webhook/sandbox/route.ts ================================================ /* POST /api/stripe/integration/webhook/sandbox – listen to Stripe test mode connect webhooks (for Stripe Integration) We need a separate route for test webhooks because of how Stripe webhooks behave: - Live mode only: When a connected account is connected only in live mode to your platform, the live Events and test Events are sent to your live Connect webhook endpoint. - Test mode only: When a connected account is connected only in test mode to your platform, the test Events are sent to your test Connect webhook endpoint. Live Events are never sent. - Live mode and test mode: When a connected account is connected in live and in test mode to your platform, the live Events are sent to your live Connect webhook endpoint and the test Events are sent to both the live and the test Connect webhook endpoints. @see https://support.stripe.com/questions/connect-account-webhook-configurations */ export { POST } from "../route"; ================================================ FILE: apps/web/app/(ee)/api/stripe/integration/webhook/test/route.ts ================================================ /* POST /api/stripe/integration/webhook/test – listen to Stripe test mode connect webhooks (for Stripe Integration) We need a separate route for test webhooks because of how Stripe webhooks behave: - Live mode only: When a connected account is connected only in live mode to your platform, the live Events and test Events are sent to your live Connect webhook endpoint. - Test mode only: When a connected account is connected only in test mode to your platform, the test Events are sent to your test Connect webhook endpoint. Live Events are never sent. - Live mode and test mode: When a connected account is connected in live and in test mode to your platform, the live Events are sent to your live Connect webhook endpoint and the test Events are sent to both the live and the test Connect webhook endpoints. @see https://support.stripe.com/questions/connect-account-webhook-configurations */ export { POST } from "../route"; ================================================ FILE: apps/web/app/(ee)/api/stripe/integration/webhook/utils/create-new-customer.ts ================================================ import { createId } from "@/lib/api/create-id"; import { includeTags } from "@/lib/api/links/include-tags"; import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; import { generateRandomName } from "@/lib/names"; import { sendPartnerPostback } from "@/lib/postback/api/send-partner-postback"; import { getClickEvent, recordLead } from "@/lib/tinybird"; import { redis } from "@/lib/upstash"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { transformLeadEventData } from "@/lib/webhook/transform"; import { prisma } from "@dub/prisma"; import { nanoid } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import type Stripe from "stripe"; export async function createNewCustomer(event: Stripe.Event) { const stripeCustomer = event.data.object as Stripe.Customer; const stripeAccountId = event.account as string; const dubCustomerExternalId = stripeCustomer.metadata?.dubCustomerExternalId || stripeCustomer.metadata?.dubCustomerId; const clickId = stripeCustomer.metadata?.dubClickId; // The client app should always send dubClickId (dub_id) via metadata if (!clickId) { return "Click ID not found in Stripe customer metadata, skipping..."; } // Find click const clickData = await getClickEvent({ clickId }); if (!clickData) { return `Click event with ID ${clickId} not found, skipping...`; } // Find link const linkId = clickData.link_id; const link = await prisma.link.findUnique({ where: { id: linkId, }, }); if (!link || !link.projectId) { return `Link with ID ${linkId} not found or does not have a project, skipping...`; } // Create a customer const customer = await prisma.customer.create({ data: { id: createId({ prefix: "cus_" }), name: stripeCustomer.name || generateRandomName(), email: stripeCustomer.email, stripeCustomerId: stripeCustomer.id, projectConnectId: stripeAccountId, externalId: dubCustomerExternalId, projectId: link.projectId, programId: link.programId, partnerId: link.partnerId, linkId, clickId, clickedAt: new Date(clickData.timestamp + "Z"), country: clickData.country, }, }); const eventName = "New customer"; const leadData = { ...clickData, workspace_id: clickData.workspace_id || customer.projectId, // in case for some reason the click event doesn't have workspace_id event_id: nanoid(16), event_name: eventName, customer_id: customer.id, }; const [_lead, _leadCached, linkUpdated, workspace] = await Promise.all([ // record lead event in Tinybird recordLead(leadData), // cache lead event in Redis because the ingested event is not available immediately on Tinybird redis.set(`leadCache:${customer.id}`, leadData, { ex: 60 * 5, }), // update link leads count + lastLeadAt date prisma.link.update({ where: { id: linkId, }, data: { leads: { increment: 1, }, lastLeadAt: new Date(), }, include: includeTags, }), // update workspace usage prisma.project.update({ where: { id: customer.projectId, }, data: { usage: { increment: 1, }, }, }), ]); if (link.programId && link.partnerId) { waitUntil( Promise.allSettled([ executeWorkflows({ trigger: "partnerMetricsUpdated", reason: "lead", identity: { workspaceId: workspace.id, programId: link.programId, partnerId: link.partnerId, }, metrics: { current: { leads: 1, }, }, }), syncPartnerLinksStats({ partnerId: link.partnerId, programId: link.programId, eventType: "lead", }), ]), ); } // send workspace webhook waitUntil( Promise.allSettled([ sendWorkspaceWebhook({ trigger: "lead.created", workspace, data: transformLeadEventData({ ...clickData, eventName, link: linkUpdated, customer, metadata: null, }), }), ...(link.partnerId ? [ sendPartnerPostback({ partnerId: link.partnerId, event: "lead.created", data: { ...clickData, eventName, link: linkUpdated, customer, }, }), ] : []), ]), ); return `New Dub customer created: ${customer.id}. Lead event recorded: ${leadData.event_id}`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-connected-customer.ts ================================================ import { stripeAppClient } from "@/lib/stripe"; import { StripeMode } from "@/lib/types"; export async function getConnectedCustomer({ stripeCustomerId, stripeAccountId, mode, }: { stripeCustomerId?: string | null; stripeAccountId?: string | null; mode: StripeMode; }) { // if stripeCustomerId or stripeAccountId is not provided, return null if (!stripeCustomerId || !stripeAccountId) { return null; } const connectedCustomer = await stripeAppClient({ mode, }).customers.retrieve(stripeCustomerId, { stripeAccount: stripeAccountId, }); if (connectedCustomer.deleted) { return null; } return connectedCustomer; } ================================================ FILE: apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-promotion-code.ts ================================================ import { stripeAppClient } from "@/lib/stripe"; import { StripeMode } from "@/lib/types"; export async function getPromotionCode({ promotionCodeId, stripeAccountId, mode, }: { promotionCodeId?: string | null; stripeAccountId?: string | null; mode: StripeMode; }) { if (!stripeAccountId || !promotionCodeId) { return null; } try { return await stripeAppClient({ mode }).promotionCodes.retrieve( promotionCodeId, { stripeAccount: stripeAccountId, }, ); } catch (error) { console.log("Failed to get promotion code:", error); return null; } } ================================================ FILE: apps/web/app/(ee)/api/stripe/integration/webhook/utils/get-subscription-product-id.ts ================================================ import { stripeAppClient } from "@/lib/stripe"; import { StripeMode } from "@/lib/types"; export async function getSubscriptionProductId({ stripeSubscriptionId, stripeAccountId, mode, }: { stripeSubscriptionId?: string | null; stripeAccountId?: string | null; mode: StripeMode; }) { if (!stripeAccountId || !stripeSubscriptionId) { return null; } try { const subscription = await stripeAppClient({ mode, }).subscriptions.retrieve(stripeSubscriptionId, { stripeAccount: stripeAccountId, }); return subscription.items.data[0].price.product as string; } catch (error) { console.log("Failed to get subscription price ID:", error); return null; } } ================================================ FILE: apps/web/app/(ee)/api/stripe/integration/webhook/utils/update-customer-with-stripe-customer-id.ts ================================================ import { prisma } from "@dub/prisma"; export async function updateCustomerWithStripeCustomerId({ stripeAccountId, dubCustomerExternalId, stripeCustomerId, }: { stripeAccountId?: string | null; dubCustomerExternalId: string; stripeCustomerId?: string | null; }) { // if stripeCustomerId or stripeAccountId is not provided, return null // (same logic as in getConnectedCustomer) if (!stripeCustomerId || !stripeAccountId) { return null; } try { // Update customer with stripeCustomerId if exists – for future events return await prisma.customer.update({ where: { projectConnectId_externalId: { projectConnectId: stripeAccountId, externalId: dubCustomerExternalId, }, }, data: { stripeCustomerId, }, }); } catch (error) { // Skip if customer not found (not an error, just a case where the customer doesn't exist on Dub yet) console.log("Failed to update customer with StripeCustomerId:", error); return null; } } ================================================ FILE: apps/web/app/(ee)/api/stripe/webhook/charge-failed.ts ================================================ import { prisma } from "@dub/prisma"; import Stripe from "stripe"; import { processDomainRenewalFailure } from "./utils/process-domain-renewal-failure"; import { processPayoutInvoiceFailure } from "./utils/process-payout-invoice-failure"; export async function chargeFailed(event: Stripe.Event) { const charge = event.data.object as Stripe.Charge; const { transfer_group: invoiceId, failure_message: failedReason } = charge; if (!invoiceId) { return "No transfer group found, skipping..."; } let invoice = await prisma.invoice.findUnique({ where: { id: invoiceId, }, }); if (!invoice) { return `Invoice with transfer group ${invoiceId} not found.`; } invoice = await prisma.invoice.update({ where: { id: invoiceId, }, data: { status: "failed", failedReason, failedAttempts: { increment: 1, }, }, }); if (invoice.type === "partnerPayout") { await processPayoutInvoiceFailure({ invoice, charge }); return `Processed partner payout failure for invoice ${invoice.id}.`; } else if (invoice.type === "domainRenewal") { await processDomainRenewalFailure({ invoice }); return `Processed domain renewal failure for invoice ${invoice.id}.`; } return `Unsupported invoice type (${invoice.type}), skipping...`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/webhook/charge-refunded.ts ================================================ import { setRenewOption } from "@/lib/dynadot/set-renew-option"; import { prisma } from "@dub/prisma"; import { Invoice } from "@dub/prisma/client"; import Stripe from "stripe"; export async function chargeRefunded(event: Stripe.Event) { const charge = event.data.object as Stripe.Charge; const { transfer_group: invoiceId } = charge; if (!invoiceId) { return "No transfer group found, skipping..."; } const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId, }, }); if (!invoice) { return `Invoice with transfer group ${invoiceId} not found.`; } if (invoice.type !== "domainRenewal") { return `Invoice ${invoice.id} is not a 'domainRenewal' type, skipping...`; } await processDomainRenewalInvoice({ invoice }); return `Disabled auto-renew for domains on invoice ${invoice.id}.`; } async function processDomainRenewalInvoice({ invoice }: { invoice: Invoice }) { const domains = invoice.registeredDomains as string[]; await Promise.allSettled( domains.map((domain) => setRenewOption({ domain, autoRenew: false, }), ), ); } ================================================ FILE: apps/web/app/(ee)/api/stripe/webhook/charge-succeeded.ts ================================================ import { qstash } from "@/lib/cron"; import { setRenewOption } from "@/lib/dynadot/set-renew-option"; import { sendBatchEmail } from "@dub/email"; import DomainRenewed from "@dub/email/templates/domain-renewed"; import { prisma } from "@dub/prisma"; import { Invoice } from "@dub/prisma/client"; import { APP_DOMAIN_WITH_NGROK, pluralize } from "@dub/utils"; import { addDays } from "date-fns"; import Stripe from "stripe"; export async function chargeSucceeded(event: Stripe.Event) { const charge = event.data.object as Stripe.Charge; const { transfer_group: invoiceId } = charge; if (!invoiceId) { // check if the customer's workspace has paymentFailedAt, if so, reset it to null const stripeId = charge.customer as string; if (stripeId) { const workspace = await prisma.project.findUnique({ where: { stripeId, }, }); if (workspace?.paymentFailedAt) { console.log("Workspace has paymentFailedAt, resetting it to null..."); await prisma.project.update({ where: { id: workspace.id, }, data: { paymentFailedAt: null, }, }); } } return "No transfer_group (invoiceId) found, skipping invoice update flow..."; } let invoice = await prisma.invoice.findUnique({ where: { id: invoiceId, }, }); if (!invoice) { return `Invoice with transfer group ${invoiceId} not found.`; } if (invoice.status === "completed") { return `Invoice ${invoice.id} already completed, skipping...`; } invoice = await prisma.invoice.update({ where: { id: invoice.id, }, data: { receiptUrl: charge.receipt_url, status: "completed", paidAt: new Date(), stripeChargeMetadata: JSON.parse(JSON.stringify(charge)), }, }); if (invoice.type === "partnerPayout") { return await processPayoutInvoice({ invoice }); } else if (invoice.type === "domainRenewal") { return await processDomainRenewalInvoice({ invoice }); } return `Unsupported invoice type (${invoice.type}), skipping...`; } async function processPayoutInvoice({ invoice }: { invoice: Invoice }) { const payoutsToProcess = await prisma.payout.count({ where: { invoiceId: invoice.id, status: { not: "completed", }, }, }); if (payoutsToProcess === 0) { return `No payouts to process found for invoice ${invoice.id}, skipping...`; } const qstashResponse = await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/charge-succeeded`, flowControl: { key: invoice.id, rate: 1, }, body: { invoiceId: invoice.id, }, }); if (qstashResponse.messageId) { return `Message sent to Qstash with id ${qstashResponse.messageId}`; } else { return `Error sending message to Qstash: ${JSON.stringify(qstashResponse)}`; } } async function processDomainRenewalInvoice({ invoice }: { invoice: Invoice }) { const domains = await prisma.registeredDomain.findMany({ where: { slug: { in: invoice.registeredDomains as string[], }, }, orderBy: { expiresAt: "asc", }, }); if (domains.length === 0) { return `No domains found for invoice ${invoice.id}, skipping...`; } const newExpiresAt = addDays(domains[0].expiresAt, 365); await prisma.registeredDomain.updateMany({ where: { id: { in: domains.map(({ id }) => id), }, }, data: { expiresAt: newExpiresAt, autoRenewalDisabledAt: null, }, }); await Promise.allSettled( domains.map((domain) => setRenewOption({ domain: domain.slug, autoRenew: true, }), ), ); const workspace = await prisma.project.findUniqueOrThrow({ where: { id: invoice.workspaceId, }, include: { users: { where: { role: "owner", }, select: { user: true, }, }, }, }); const workspaceOwners = workspace.users.filter(({ user }) => user.email); if (workspaceOwners.length === 0) { return "No users found to send domain renewal success email."; } await sendBatchEmail( workspaceOwners.map(({ user }) => ({ variant: "notifications", to: user.email!, subject: `Your ${pluralize("domain", domains.length)} have been renewed`, react: DomainRenewed({ email: user.email!, workspace: { slug: workspace.slug, }, domains: domains.map(({ slug }) => ({ slug })), expiresAt: newExpiresAt, }), })), ); return `Domain renewal success email sent to ${workspaceOwners.length} users.`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/webhook/checkout-session-completed.ts ================================================ import { createProgram } from "@/lib/actions/partners/create-program"; import { claimDotLinkDomain } from "@/lib/api/domains/claim-dot-link-domain"; import { onboardingStepCache } from "@/lib/api/workspaces/onboarding-step-cache"; import { tokenCache } from "@/lib/auth/token-cache"; import { stripe } from "@/lib/stripe"; import { WorkspaceProps } from "@/lib/types"; import { redis } from "@/lib/upstash"; import { sendBatchEmail } from "@dub/email"; import UpgradeEmail from "@dub/email/templates/upgrade-email"; import { prisma } from "@dub/prisma"; import { Program, User } from "@dub/prisma/client"; import { getPlanAndTierFromPriceId, log, prettyPrint } from "@dub/utils"; import Stripe from "stripe"; export async function checkoutSessionCompleted(event: Stripe.Event) { const checkoutSession = event.data.object as Stripe.Checkout.Session; if ( checkoutSession.mode === "setup" || checkoutSession.payment_status !== "paid" ) { return "Session is setup mode or not paid, skipping..."; } if ( checkoutSession.client_reference_id === null || checkoutSession.customer === null ) { await log({ message: "Missing items in Stripe webhook callback", type: "errors", }); return "Missing client_reference_id or customer in checkout session."; } const subscription = await stripe.subscriptions.retrieve( checkoutSession.subscription as string, ); const priceId = subscription.items.data[0].price.id; const { plan, planTier } = getPlanAndTierFromPriceId({ priceId }); if (!plan) { return `Invalid price ID in checkout.session.completed event: ${priceId}`; } const stripeId = checkoutSession.customer.toString(); const workspaceId = checkoutSession.client_reference_id; const planName = plan.name.toLowerCase(); // when the workspace subscribes to a plan, set their stripe customer ID // in the database for easy identification in future webhook events // also update the billingCycleStart to today's date const workspace = await prisma.project.update({ where: { id: workspaceId, }, data: { stripeId, billingCycleStart: new Date().getDate(), plan: planName, planTier: planTier, usageLimit: plan.limits.clicks, linksLimit: plan.limits.links, payoutsLimit: plan.limits.payouts, domainsLimit: plan.limits.domains, aiLimit: plan.limits.ai, tagsLimit: plan.limits.tags, foldersLimit: plan.limits.folders, groupsLimit: plan.limits.groups, networkInvitesLimit: plan.limits.networkInvites, usersLimit: plan.limits.users, paymentFailedAt: null, }, select: { plan: true, defaultProgramId: true, users: { select: { user: { select: { id: true, name: true, email: true, }, }, }, where: { user: { isMachine: false, }, }, }, restrictedTokens: { select: { hashedKey: true, }, }, }, }); const users = workspace.users.map(({ user }) => ({ id: user.id, name: user.name, email: user.email, })); await Promise.allSettled([ completeOnboarding({ users, workspaceId }), sendBatchEmail( users.map((user) => ({ to: user.email as string, replyTo: "steven.tey@dub.co", subject: `Thank you for upgrading to Dub ${plan.name}!`, react: UpgradeEmail({ name: user.name, email: user.email as string, plan: plan.name, planTier: planTier, }), variant: "marketing", })), ), // enable dub.link premium default domain for the workspace prisma.defaultDomains.update({ where: { projectId: workspaceId, }, data: { dublink: true, }, }), // expire tokens cache tokenCache.expireMany({ hashedKeys: workspace.restrictedTokens.map(({ hashedKey }) => hashedKey), }), ]); return `Checkout completed for workspace ${workspaceId}, upgraded to ${plan.name}.`; } async function completeOnboarding({ users, workspaceId, }: { users: Pick[]; workspaceId: string; }) { const workspace = (await prisma.project.findUnique({ where: { id: workspaceId, }, include: { users: true, programs: true, }, })) as unknown as (WorkspaceProps & { programs: Program[] }) | null; if (!workspace) { console.error("Failed to complete onboarding for workspace", workspaceId); return; } await Promise.allSettled([ // Complete onboarding for workspace users onboardingStepCache.mset({ userIds: users.map(({ id }) => id), step: "completed", }), (async () => { // Register saved domain const data = await redis.get<{ domain: string; userId: string }>( `onboarding-domain:${workspaceId}`, ); if (data && data.domain && data.userId) { const { domain, userId } = data; try { await claimDotLinkDomain({ domain, userId, workspace, }); await redis.del(`onboarding-domain:${workspaceId}`); } catch (e) { console.error( "Failed to register saved domain from onboarding", { domain, userId, workspace }, e, ); } } // Create program if ( users.length > 0 && workspace.programs.length === 0 && workspace.store?.programOnboarding ) { try { await createProgram({ workspace, user: users[0], }); } catch (e) { console.error( "Failed to create program from onboarding", prettyPrint({ workspace, user: users[0] }), e, ); } } })(), ]); } ================================================ FILE: apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts ================================================ import { deleteWorkspaceFolders } from "@/lib/api/folders/delete-workspace-folders"; import { linkCache } from "@/lib/api/links/cache"; import { includeProgramEnrollment } from "@/lib/api/links/include-program-enrollment"; import { includeTags } from "@/lib/api/links/include-tags"; import { deactivateProgram } from "@/lib/api/programs/deactivate-program"; import { tokenCache } from "@/lib/auth/token-cache"; import { isBlacklistedEmail } from "@/lib/edge-config/is-blacklisted-email"; import { stripe } from "@/lib/stripe"; import { recordLink } from "@/lib/tinybird"; import { webhookCache } from "@/lib/webhook/cache"; import { prisma } from "@dub/prisma"; import { capitalize, FREE_PLAN, log } from "@dub/utils"; import Stripe from "stripe"; import { sendCancellationFeedback } from "./utils/send-cancellation-feedback"; import { updateWorkspacePlan } from "./utils/update-workspace-plan"; export async function customerSubscriptionDeleted(event: Stripe.Event) { const subscriptionDeleted = event.data.object as Stripe.Subscription; const stripeId = subscriptionDeleted.customer.toString(); // If a workspace deletes their subscription, reset their usage limit in the database to 1000. // Also remove the root domain link for all their domains from MySQL, Redis, and Tinybird const workspace = await prisma.project.findUnique({ where: { stripeId, }, select: { id: true, slug: true, plan: true, planTier: true, foldersUsage: true, paymentFailedAt: true, payoutsLimit: true, defaultProgramId: true, links: { where: { key: "_root", }, include: { ...includeTags, ...includeProgramEnrollment, }, }, users: { select: { user: { select: { name: true, email: true, }, }, }, where: { role: "owner", user: { isMachine: false, }, }, }, restrictedTokens: { select: { hashedKey: true, }, }, }, }); if (!workspace) { return `Workspace with Stripe ID ${stripeId} not found in customer.subscription.deleted callback.`; } // Check if the customer has another active subscription const { data: activeSubscriptions } = await stripe.subscriptions.list({ customer: stripeId, status: "active", }); if (activeSubscriptions.length > 0) { const activeSubscription = activeSubscriptions[0]; const priceId = activeSubscription.items.data[0].price.id; await updateWorkspacePlan({ workspace, priceId, }); return `Workspace ${workspace.slug} has another active subscription; updated plan.`; } const workspaceLinks = workspace.links; const workspaceUsers = workspace.users.map(({ user }) => user); const isBlacklistedCancellation = await isBlacklistedEmail( workspaceUsers.filter(({ email }) => email).map(({ email }) => email!), ); await Promise.allSettled([ prisma.project.update({ where: { stripeId, }, data: { plan: "free", usageLimit: FREE_PLAN.limits.clicks!, linksLimit: FREE_PLAN.limits.links!, payoutsLimit: FREE_PLAN.limits.payouts!, domainsLimit: FREE_PLAN.limits.domains!, aiLimit: FREE_PLAN.limits.ai!, tagsLimit: FREE_PLAN.limits.tags!, foldersLimit: FREE_PLAN.limits.folders!, groupsLimit: FREE_PLAN.limits.groups!, networkInvitesLimit: FREE_PLAN.limits.networkInvites!, usersLimit: FREE_PLAN.limits.users!, paymentFailedAt: null, }, }), // disable dub.link premium default domain for the workspace prisma.defaultDomains.update({ where: { projectId: workspace.id, }, data: { dublink: false, }, }), // remove logo from all domains for the workspace prisma.domain.updateMany({ where: { projectId: workspace.id, }, data: { logo: null, }, }), // remove root domain link for all domains from MySQL prisma.link.updateMany({ where: { id: { in: workspaceLinks.map(({ id }) => id), }, }, data: { url: "", }, }), // expire root domain link cache from Redis linkCache.expireMany(workspaceLinks), // record root domain link for all domains from Tinybird recordLink( workspaceLinks.map((link) => ({ ...link, url: "", })), ), // Log the deletion log({ message: ":cry: Workspace *`" + workspace.slug + "`* deleted their *`" + capitalize(workspace.plan) + "`* subscription" + (isBlacklistedCancellation ? " (blacklisted / banned)" : ""), type: "cron", mention: true, }), // Don't send feedback if the user was blacklisted / banned !isBlacklistedCancellation && sendCancellationFeedback({ owners: workspaceUsers, }), // Disable the webhooks prisma.webhook.updateMany({ where: { projectId: workspace.id, }, data: { disabledAt: new Date(), }, }), prisma.project.update({ where: { id: workspace.id, }, data: { webhookEnabled: false, }, }), // expire tokens cache tokenCache.expireMany({ hashedKeys: workspace.restrictedTokens.map(({ hashedKey }) => hashedKey), }), ]); // Update the webhooks cache const webhooks = await prisma.webhook.findMany({ where: { projectId: workspace.id, }, select: { id: true, url: true, secret: true, triggers: true, disabledAt: true, }, }); await webhookCache.mset(webhooks); await deleteWorkspaceFolders({ workspaceId: workspace.id, defaultProgramId: workspace.defaultProgramId, }); // Deactivate the program if the workspace had partner access if (workspace.defaultProgramId) { await deactivateProgram(workspace.defaultProgramId); } return `Workspace ${workspace.slug} subscription deleted; downgraded to free.`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/webhook/customer-subscription-updated.ts ================================================ import { prisma } from "@dub/prisma"; import { getPlanAndTierFromPriceId } from "@dub/utils"; import Stripe from "stripe"; import { sendCancellationFeedback } from "./utils/send-cancellation-feedback"; import { updateWorkspacePlan } from "./utils/update-workspace-plan"; export async function customerSubscriptionUpdated(event: Stripe.Event) { const subscriptionUpdated = event.data.object as Stripe.Subscription; const priceId = subscriptionUpdated.items.data[0].price.id; const { plan } = getPlanAndTierFromPriceId({ priceId }); if (!plan) { return `Invalid price ID in customer.subscription.updated event: ${priceId}`; } const stripeId = subscriptionUpdated.customer.toString(); const workspace = await prisma.project.findUnique({ where: { stripeId, }, select: { id: true, plan: true, planTier: true, paymentFailedAt: true, payoutsLimit: true, foldersUsage: true, defaultProgramId: true, users: { select: { user: { select: { email: true, name: true, }, }, }, where: { role: "owner", user: { isMachine: false, }, }, }, restrictedTokens: { select: { hashedKey: true, }, }, }, }); if (!workspace) { return `Workspace with Stripe ID ${stripeId} not found in customer.subscription.updated callback.`; } await updateWorkspacePlan({ workspace, priceId, }); const subscriptionCanceled = subscriptionUpdated.status === "active" && subscriptionUpdated.cancel_at_period_end; if (subscriptionCanceled) { const owners = workspace.users.map(({ user }) => user); const cancelReason = subscriptionUpdated.cancellation_details?.feedback; await sendCancellationFeedback({ owners, reason: cancelReason, }); return `Updated workspace ${workspace.id} plan; cancellation at period end requested.`; } return `Updated workspace ${workspace.id} plan to ${plan.name}.`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/webhook/invoice-payment-failed.tsx ================================================ import { sendEmail } from "@dub/email"; import FailedPayment from "@dub/email/templates/failed-payment"; import { prisma } from "@dub/prisma"; import Stripe from "stripe"; export async function invoicePaymentFailed(event: Stripe.Event) { const { customer: stripeId, attempt_count: attemptCount, amount_due: amountDue, } = event.data.object as Stripe.Invoice; if (!stripeId) { return "No customer found in invoice.payment_failed event."; } const workspace = await prisma.project.findUnique({ where: { stripeId: stripeId.toString(), }, select: { id: true, name: true, slug: true, plan: true, defaultProgramId: true, users: { select: { user: { select: { name: true, email: true, }, }, }, where: { user: { isMachine: false, }, }, }, }, }); if (!workspace) { return `Workspace with Stripe ID ${stripeId} not found in invoice.payment_failed event.`; } await Promise.allSettled([ prisma.project.update({ where: { id: workspace.id, }, data: { paymentFailedAt: new Date(), }, }), ...workspace.users.map(({ user }) => sendEmail({ to: user.email as string, subject: `${ attemptCount == 2 ? "2nd notice: " : attemptCount == 3 ? "3rd notice: " : "" }Your payment for Dub.co failed`, react: ( ), variant: "notifications", }), ), ]); return `Recorded payment failure and sent ${workspace.users.length} notice(s) for workspace ${workspace.slug}.`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/webhook/payment-intent-requires-action.ts ================================================ import { prisma } from "@dub/prisma"; import Stripe from "stripe"; import { processDomainRenewalFailure } from "./utils/process-domain-renewal-failure"; import { processPayoutInvoiceFailure } from "./utils/process-payout-invoice-failure"; export async function paymentIntentRequiresAction(event: Stripe.Event) { const { transfer_group: invoiceId, latest_charge: charge } = event.data .object as Stripe.PaymentIntent; if (!invoiceId) { return "No transfer group found, skipping..."; } let invoice = await prisma.invoice.findUnique({ where: { id: invoiceId, }, }); if (!invoice) { return `Invoice with transfer group ${invoiceId} not found.`; } invoice = await prisma.invoice.update({ where: { id: invoiceId, }, data: { status: "failed", failedReason: "Your payment requires additional authentication to complete.", failedAttempts: { increment: 1, }, }, }); if (invoice.type === "partnerPayout") { await processPayoutInvoiceFailure({ invoice }); return `Processed partner payout failure for invoice ${invoice.id}.`; } else if (invoice.type === "domainRenewal") { await processDomainRenewalFailure({ invoice }); return `Processed domain renewal failure for invoice ${invoice.id}.`; } return `Unsupported invoice type (${invoice.type}), skipping...`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/webhook/route.ts ================================================ import { stripe } from "@/lib/stripe"; import { log } from "@dub/utils"; import Stripe from "stripe"; import { logAndRespond } from "../../cron/utils"; import { chargeFailed } from "./charge-failed"; import { chargeRefunded } from "./charge-refunded"; import { chargeSucceeded } from "./charge-succeeded"; import { checkoutSessionCompleted } from "./checkout-session-completed"; import { customerSubscriptionDeleted } from "./customer-subscription-deleted"; import { customerSubscriptionUpdated } from "./customer-subscription-updated"; import { invoicePaymentFailed } from "./invoice-payment-failed"; import { paymentIntentRequiresAction } from "./payment-intent-requires-action"; import { transferReversed } from "./transfer-reversed"; const relevantEvents = new Set([ "charge.succeeded", "charge.failed", "charge.refunded", "checkout.session.completed", "customer.subscription.updated", "customer.subscription.deleted", "invoice.payment_failed", "payment_intent.requires_action", "transfer.reversed", ]); // POST /api/stripe/webhook – listen to Stripe webhooks export const POST = async (req: Request) => { const buf = await req.text(); const sig = req.headers.get("Stripe-Signature") as string; const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; let event: Stripe.Event; try { if (!sig || !webhookSecret) return; event = stripe.webhooks.constructEvent(buf, sig, webhookSecret); } catch (err: any) { console.log(`❌ Error message: ${err.message}`); return new Response(`Webhook Error: ${err.message}`, { status: 400, }); } // Ignore unsupported events if (!relevantEvents.has(event.type)) { return new Response("Unsupported event, skipping...", { status: 200, }); } let response = "OK"; try { switch (event.type) { case "charge.succeeded": response = await chargeSucceeded(event); break; case "charge.failed": response = await chargeFailed(event); break; case "charge.refunded": response = await chargeRefunded(event); break; case "checkout.session.completed": response = await checkoutSessionCompleted(event); break; case "customer.subscription.updated": response = await customerSubscriptionUpdated(event); break; case "customer.subscription.deleted": response = await customerSubscriptionDeleted(event); break; case "invoice.payment_failed": response = await invoicePaymentFailed(event); break; case "payment_intent.requires_action": response = await paymentIntentRequiresAction(event); break; case "transfer.reversed": response = await transferReversed(event); break; } } catch (error) { await log({ message: `Stripe webhook failed (${event.type}). Error: ${error.message}`, type: "errors", }); return new Response(`Webhook error: ${error.message}`, { status: 400, }); } return logAndRespond(`[${event.type}]: ${response}`); }; ================================================ FILE: apps/web/app/(ee)/api/stripe/webhook/transfer-reversed.ts ================================================ import { prisma } from "@dub/prisma"; import { pluralize } from "@dub/utils"; import Stripe from "stripe"; export async function transferReversed(event: Stripe.Event) { const stripeTransfer = event.data.object as Stripe.Transfer; // when transfer is reversed on Stripe, we update any sent payouts with matching stripeTransferId to: // - set the status to processed (so it can be resent to the partner later) // - reset the stripeTransferId + stripePayoutId, stripePayoutTraceId, failureReason (if any) const updatedPayouts = await prisma.payout.updateMany({ where: { stripeTransferId: stripeTransfer.id, status: { in: ["sent", "failed"], }, }, data: { status: "processed", stripeTransferId: null, stripePayoutId: null, stripePayoutTraceId: null, failureReason: null, }, }); return `Updated ${updatedPayouts.count} ${pluralize( "payout", updatedPayouts.count, )} to "processed" status for Stripe transfer ${stripeTransfer.id}.`; } ================================================ FILE: apps/web/app/(ee)/api/stripe/webhook/utils/process-domain-renewal-failure.ts ================================================ import { qstash } from "@/lib/cron"; import { setRenewOption } from "@/lib/dynadot/set-renew-option"; import { sendBatchEmail } from "@dub/email"; import DomainExpired from "@dub/email/templates/domain-expired"; import DomainRenewalFailed from "@dub/email/templates/domain-renewal-failed"; import { prisma } from "@dub/prisma"; import { Invoice } from "@dub/prisma/client"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; export async function processDomainRenewalFailure({ invoice, }: { invoice: Invoice; }) { const domains = await prisma.registeredDomain.findMany({ where: { slug: { in: invoice.registeredDomains as string[], }, }, select: { slug: true, expiresAt: true, }, }); const workspace = await prisma.project.findUniqueOrThrow({ where: { id: invoice.workspaceId, }, include: { users: { where: { role: "owner", }, select: { user: true, }, }, }, }); const workspaceOwners = workspace.users.filter(({ user }) => user.email); // Domain renewal failed 3 times: // 1. Turn off auto-renew for the domains on Dynadot // 2. Disable auto-renew for the domains on Dub // 3. Send email to the workspace users if (invoice.failedAttempts >= 3) { await Promise.allSettled( domains.map((domain) => setRenewOption({ domain: domain.slug, autoRenew: false, }), ), ); const updateDomains = await prisma.registeredDomain.updateMany({ where: { slug: { in: domains.map(({ slug }) => slug), }, }, data: { autoRenewalDisabledAt: new Date(), }, }); console.log( `Updated autoRenewalDisabledAt for ${updateDomains.count} domains.`, ); if (workspaceOwners.length > 0) { await sendBatchEmail( workspaceOwners.map(({ user }) => ({ variant: "notifications", to: user.email!, subject: "Domain expired", react: DomainExpired({ email: user.email!, workspace: { name: workspace.name, slug: workspace.slug, }, domains, }), })), ); } } // We'll retry the invoice 3 times, if it fails 3 times, we'll turn off auto-renew for the domains if (invoice.failedAttempts < 3) { await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/invoices/retry-failed`, delay: 3 * 24 * 60 * 60, // 3 days in seconds deduplicationId: `${invoice.id}-attempt-${invoice.failedAttempts + 1}`, body: { invoiceId: invoice.id, }, }); if (workspaceOwners.length > 0) { await sendBatchEmail( workspaceOwners.map(({ user }) => ({ variant: "notifications", to: user.email!, subject: "Domain renewal failed", react: DomainRenewalFailed({ email: user.email!, workspace: { slug: workspace.slug, }, domains, }), })), ); } } } ================================================ FILE: apps/web/app/(ee)/api/stripe/webhook/utils/process-payout-invoice-failure.ts ================================================ import { DIRECT_DEBIT_PAYMENT_METHOD_TYPES, PAYOUT_FAILURE_FEE_CENTS, } from "@/lib/constants/payouts"; import { createPaymentIntent } from "@/lib/stripe/create-payment-intent"; import { sendBatchEmail } from "@dub/email"; import PartnerPayoutFailed from "@dub/email/templates/partner-payout-failed"; import { prisma } from "@dub/prisma"; import { Invoice } from "@dub/prisma/client"; import { log } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import Stripe from "stripe"; export async function processPayoutInvoiceFailure({ invoice, charge, }: { invoice: Invoice; charge?: Stripe.Charge; }) { await log({ message: `Partner payout failed for invoice ${invoice.id}.`, type: "errors", mention: true, }); // reset the payouts to their initial state const { count } = await prisma.payout.updateMany({ where: { invoiceId: invoice.id, }, data: { status: "pending", userId: null, invoiceId: null, initiatedAt: null, paidAt: null, mode: null, }, }); console.log( `Reset ${count} payouts to their initial state for invoice ${invoice.id}`, ); const workspace = await prisma.project.update({ where: { id: invoice.workspaceId, }, // Reduce the payoutsUsage by the invoice amount since the charge failed data: { payoutsUsage: { decrement: invoice.amount, }, }, include: { users: { select: { user: { select: { email: true, }, }, }, }, programs: { select: { name: true, }, }, }, }); if (!workspace.stripeId) { console.log("Workspace does not have a Stripe ID, skipping..."); return; } const paymentMethod = charge && charge.payment_method_details && DIRECT_DEBIT_PAYMENT_METHOD_TYPES.includes( charge.payment_method_details.type as Stripe.PaymentMethod.Type, ) ? "direct_debit" : "card"; let chargedFailureFee = false; let cardLast4: string | undefined; // Charge failure fee for direct debit payment failures (excluding blocked charges) if (paymentMethod === "direct_debit") { const isBlocked = charge?.outcome?.type === "blocked"; if (!isBlocked) { const { paymentIntent, paymentMethod } = await createPaymentIntent({ stripeId: workspace.stripeId, amount: PAYOUT_FAILURE_FEE_CENTS, description: `Dub Partners payout failure fee for invoice ${invoice.id}`, statementDescriptor: "Dub Partners", }); if (paymentIntent) { chargedFailureFee = true; console.log( `Charged a failure fee of $${PAYOUT_FAILURE_FEE_CENTS / 100} to ${workspace.slug}.`, ); } if (paymentMethod?.card) { cardLast4 = paymentMethod.card.last4; } } else { console.log( `Skipped charging failure fee for blocked direct debit charge on invoice ${invoice.id}.`, ); } } waitUntil( (async () => { // Send email to the workspace users about the failed payout const emailData = workspace.users .filter((user) => user.user.email) .map((user) => ({ email: user.user.email!, workspace: { slug: workspace.slug, }, program: { name: workspace.programs[0].name, }, payout: { amount: invoice.total, method: paymentMethod as "card" | "direct_debit", failedReason: invoice.failedReason, ...(chargedFailureFee && { failureFee: PAYOUT_FAILURE_FEE_CENTS, cardLast4, }), }, })); if (emailData.length === 0) { console.log("No users found to send email, skipping..."); return; } await sendBatchEmail( emailData.map((data) => ({ variant: "notifications", subject: "Partner payout failed", to: data.email, react: PartnerPayoutFailed(data), })), ); })(), ); } ================================================ FILE: apps/web/app/(ee)/api/stripe/webhook/utils/send-cancellation-feedback.ts ================================================ import { sendEmail } from "@dub/email"; import Stripe from "stripe"; const cancellationReasonMap = { customer_service: "you had a bad experience with our customer service", low_quality: "the product didn't meet your expectations", missing_features: "you were expecting more features", switched_service: "you switched to a different service", too_complex: "the product was too complex", too_expensive: "the product was too expensive", unused: "you didn't use the product", }; export async function sendCancellationFeedback({ owners, reason, }: { owners: { name: string | null; email: string | null; }[]; reason?: Stripe.Subscription.CancellationDetails.Feedback | null; }) { const reasonText = reason ? cancellationReasonMap[reason] : ""; return await Promise.all( owners.map( (owner) => owner.email && sendEmail({ to: owner.email, from: "Steven Tey ", replyTo: "steven.tey@dub.co", subject: "Feedback for Dub.co?", text: `Hey ${owner.name ? owner.name.split(" ")[0] : "there"}!\n\nSaw you canceled your Dub subscription${reasonText ? ` and mentioned that ${reasonText}` : ""} – do you mind sharing if there's anything we could've done better on our side?\n\nWe're always looking to improve our product offering so any feedback would be greatly appreciated!\n\nThank you so much in advance!\n\nBest,\nSteven Tey\nFounder, Dub.co`, headers: { "Idempotency-Key": `cancellation-feedback-${owner.email}`, }, }), ), ); } ================================================ FILE: apps/web/app/(ee)/api/stripe/webhook/utils/update-workspace-plan.ts ================================================ import { deleteWorkspaceFolders } from "@/lib/api/folders/delete-workspace-folders"; import { deactivateProgram } from "@/lib/api/programs/deactivate-program"; import { tokenCache } from "@/lib/auth/token-cache"; import { syncUserPlanToPlain } from "@/lib/plain/sync-user-plan"; import { getPlanCapabilities } from "@/lib/plan-capabilities"; import { wouldLosePartnerAccess } from "@/lib/plans/has-partner-access"; import { WorkspaceProps } from "@/lib/types"; import { webhookCache } from "@/lib/webhook/cache"; import { prisma } from "@dub/prisma"; import { getPlanAndTierFromPriceId } from "@dub/utils"; import { NEW_BUSINESS_PRICE_IDS } from "@dub/utils/src"; import { waitUntil } from "@vercel/functions"; export async function updateWorkspacePlan({ workspace, priceId, }: { workspace: Pick< WorkspaceProps, | "id" | "planTier" | "paymentFailedAt" | "payoutsLimit" | "foldersUsage" | "defaultProgramId" > & { plan: string; restrictedTokens: { hashedKey: string; }[]; }; priceId: string; }) { const { plan: newPlan, planTier: newPlanTier } = getPlanAndTierFromPriceId({ priceId, }); if (!newPlan) return; const newPlanName = newPlan.name.toLowerCase(); const shouldDisableWebhooks = newPlanName === "free" || newPlanName === "pro"; const { canManageProgram, canMessagePartners } = getPlanCapabilities(newPlanName); // If a workspace upgrades/downgrades their subscription // or if the payouts limit increases and the updated price ID is a new business price ID // update their usage limit in the database if ( workspace.plan !== newPlanName || workspace.planTier !== newPlanTier || (workspace.payoutsLimit < newPlan.limits.payouts && NEW_BUSINESS_PRICE_IDS.includes(priceId)) ) { const [updatedWorkspace] = await Promise.allSettled([ prisma.project.update({ where: { id: workspace.id, }, data: { plan: newPlanName, planTier: newPlanTier, usageLimit: newPlan.limits.clicks, linksLimit: newPlan.limits.links, payoutsLimit: newPlan.limits.payouts, domainsLimit: newPlan.limits.domains, aiLimit: newPlan.limits.ai, tagsLimit: newPlan.limits.tags, foldersLimit: newPlan.limits.folders, groupsLimit: newPlan.limits.groups, networkInvitesLimit: newPlan.limits.networkInvites, usersLimit: newPlan.limits.users, paymentFailedAt: null, }, include: { users: { where: { role: "owner", }, select: { user: { select: { id: true, name: true, email: true, }, }, }, orderBy: { createdAt: "asc", }, take: 1, }, }, }), // expire tokens cache tokenCache.expireMany({ hashedKeys: workspace.restrictedTokens.map( ({ hashedKey }) => hashedKey, ), }), // if workspace has a program, need to update deactivatedAt and messagingEnabledAt columns based on the plan capabilities ...(workspace.defaultProgramId ? [ prisma.program.update({ where: { id: workspace.defaultProgramId, }, data: { deactivatedAt: canManageProgram ? null : undefined, messagingEnabledAt: canMessagePartners ? new Date() : null, }, }), ] : []), ]); // Disable the webhooks if the new plan does not support webhooks if (shouldDisableWebhooks) { await Promise.all([ prisma.project.update({ where: { id: workspace.id, }, data: { webhookEnabled: false, }, }), prisma.webhook.updateMany({ where: { projectId: workspace.id, }, data: { disabledAt: new Date(), }, }), ]); // Update the webhooks cache const webhooks = await prisma.webhook.findMany({ where: { projectId: workspace.id, }, select: { id: true, url: true, secret: true, triggers: true, disabledAt: true, }, }); await webhookCache.mset(webhooks); } // Delete the folders if the new plan is free // For downgrade from Business → Pro, it should be fine since we're accounting that to make sure all folders get write access. if (newPlanName === "free") { await deleteWorkspaceFolders({ workspaceId: workspace.id, defaultProgramId: workspace.defaultProgramId, }); } // Deactivate the program if the workspace loses partner access (Business/Enterprise -> Pro/Free) if ( wouldLosePartnerAccess({ currentPlan: workspace.plan, newPlan: newPlanName, }) ) { if (workspace.defaultProgramId) { await deactivateProgram(workspace.defaultProgramId); } } if ( updatedWorkspace.status === "fulfilled" && updatedWorkspace.value.users.length ) { const workspaceOwner = updatedWorkspace.value.users[0].user; waitUntil(syncUserPlanToPlain(workspaceOwner)); } } else if (workspace.paymentFailedAt) { await prisma.project.update({ where: { id: workspace.id, }, data: { paymentFailedAt: null, }, }); } } ================================================ FILE: apps/web/app/(ee)/api/track/click/route.ts ================================================ import { allowedHostnamesCache } from "@/lib/analytics/allowed-hostnames-cache"; import { getHostnameFromRequest, verifyAnalyticsAllowedHostnames, } from "@/lib/analytics/verify-analytics-allowed-hostnames"; import { COMMON_CORS_HEADERS } from "@/lib/api/cors"; import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; import { linkCache } from "@/lib/api/links/cache"; import { recordClickCache } from "@/lib/api/links/record-click-cache"; import { parseRequestBody } from "@/lib/api/utils"; import { withAxiom } from "@/lib/axiom/server"; import { getIdentityHash } from "@/lib/middleware/utils/get-identity-hash"; import { getWorkspaceViaEdge } from "@/lib/planetscale"; import { getLinkWithPartner } from "@/lib/planetscale/get-link-with-partner"; import { recordClick } from "@/lib/tinybird"; import { RedisLinkProps } from "@/lib/types"; import { formatRedisLink, redis, redisGlobalWithTimeout } from "@/lib/upstash"; import { DiscountSchema } from "@/lib/zod/schemas/discount"; import { PartnerSchema } from "@/lib/zod/schemas/partners"; import { getDomainWithoutWWW, isValidUrl, nanoid } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; const trackClickSchema = z.object({ domain: z.preprocess( (val) => getDomainWithoutWWW(val as string), z.string({ error: "domain is required." }), ), key: z.string({ error: "key is required." }), url: z.string().nullish(), referrer: z.string().nullish(), }); const trackClickResponseSchema = z.object({ clickId: z.string(), partner: PartnerSchema.pick({ id: true, name: true, image: true, }) .extend({ groupId: z.string().nullish(), tenantId: z.string().nullish(), }) .nullish(), discount: DiscountSchema.pick({ id: true, amount: true, type: true, maxDuration: true, couponId: true, couponTestId: true, }).nullish(), }); // POST /api/track/click – Track a click event for a link export const POST = withAxiom(async (req) => { try { const { domain, key, url, referrer } = trackClickSchema.parse( await parseRequestBody(req), ); const identityHash = await getIdentityHash(req); let [redisGlobalResults, cachedAllowedHostnames] = await Promise.all([ redisGlobalWithTimeout .mget< [string | null, RedisLinkProps | null] >([recordClickCache._createKey({ domain, key, identityHash }), linkCache._createKey({ domain, key })]) .catch(() => [null, null] as [string | null, RedisLinkProps | null]), redis.get(allowedHostnamesCache._createKey({ domain })), ]); let [cachedClickId, cachedLink] = redisGlobalResults; // assign a new clickId if there's no cached clickId // else, reuse the cached clickId const clickId = cachedClickId ?? nanoid(16); if (!cachedLink) { const link = await getLinkWithPartner({ domain, key, }); if (!link) { throw new DubApiError({ code: "not_found", message: `Link not found for domain: ${domain} and key: ${key}.`, }); } cachedLink = formatRedisLink(link as any); waitUntil(linkCache.set(link as any)); } if (!cachedLink.projectId) { throw new DubApiError({ code: "not_found", message: "Link does not belong to a workspace.", }); } const finalUrl = url ? isValidUrl(url) ? url : cachedLink.url : cachedLink.url; // if there's no cached clickId, track the click event if (!cachedClickId) { if (!cachedAllowedHostnames) { const workspace = await getWorkspaceViaEdge({ workspaceId: cachedLink.projectId, includeDomains: true, }); cachedAllowedHostnames = (workspace?.allowedHostnames ?? []) as string[]; waitUntil( allowedHostnamesCache.mset({ allowedHostnames: JSON.stringify(cachedAllowedHostnames), domains: workspace?.domains.map(({ slug }) => slug) ?? [], }), ); } const allowRequest = verifyAnalyticsAllowedHostnames({ allowedHostnames: cachedAllowedHostnames, req, }); if (!allowRequest) { throw new DubApiError({ code: "forbidden", message: `Request origin '${getHostnameFromRequest(req)}' is not included in the allowed hostnames for this workspace. Update your allowed hostnames here: https://app.dub.co/settings/tracking`, }); } await recordClick({ req, clickId, workspaceId: cachedLink.projectId, linkId: cachedLink.id, domain, key, url: finalUrl, programId: cachedLink.programId, partnerId: cachedLink.partnerId, skipRatelimit: true, ...(referrer && { referrer }), shouldCacheClickId: true, }); } const isPartnerLink = Boolean(cachedLink.programId && cachedLink.partnerId); const { partner = null, discount = null } = cachedLink; const response = trackClickResponseSchema.parse({ clickId, ...(isPartnerLink && { partner, discount: discount ? { ...discount, // Support backwards compatibility with old cache format // We could potentially remove after 24 hours couponId: discount?.couponId ?? null, couponTestId: discount?.couponTestId ?? null, } : null, }), }); return NextResponse.json(response, { headers: COMMON_CORS_HEADERS }); } catch (error) { return handleAndReturnErrorResponse(error, COMMON_CORS_HEADERS); } }); export const OPTIONS = () => { return new Response(null, { status: 204, headers: COMMON_CORS_HEADERS, }); }; ================================================ FILE: apps/web/app/(ee)/api/track/lead/client/route.ts ================================================ import { getHostnameFromRequest, verifyAnalyticsAllowedHostnames, } from "@/lib/analytics/verify-analytics-allowed-hostnames"; import { trackLead } from "@/lib/api/conversions/track-lead"; import { COMMON_CORS_HEADERS } from "@/lib/api/cors"; import { DubApiError } from "@/lib/api/errors"; import { parseRequestBody } from "@/lib/api/utils"; import { withPublishableKey } from "@/lib/auth/publishable-key"; import { trackLeadRequestSchema } from "@/lib/zod/schemas/leads"; import { NextResponse } from "next/server"; // POST /api/track/lead/client – Track a lead conversion event on the client side export const POST = withPublishableKey( async ({ req, workspace }) => { const body = await parseRequestBody(req); const allowRequest = verifyAnalyticsAllowedHostnames({ allowedHostnames: (workspace?.allowedHostnames ?? []) as string[], req, }); if (!allowRequest) { throw new DubApiError({ code: "forbidden", message: `Request origin '${getHostnameFromRequest(req)}' is not included in the allowed hostnames for this workspace. Update your allowed hostnames here: https://app.dub.co/settings/tracking`, }); } const { clickId, eventName, eventQuantity, customerExternalId, customerName, customerEmail, customerAvatar, mode, metadata, } = trackLeadRequestSchema.parse(body); const response = await trackLead({ clickId, eventName, eventQuantity, customerExternalId, customerName, customerEmail, customerAvatar, mode, metadata, rawBody: body, workspace, }); return NextResponse.json(response, { headers: COMMON_CORS_HEADERS }); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], }, ); export const OPTIONS = () => { return new Response(null, { status: 204, headers: COMMON_CORS_HEADERS, }); }; ================================================ FILE: apps/web/app/(ee)/api/track/lead/route.ts ================================================ import { trackLead } from "@/lib/api/conversions/track-lead"; import { DubApiError } from "@/lib/api/errors"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { trackLeadRequestSchema } from "@/lib/zod/schemas/leads"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // POST /api/track/lead – Track a lead conversion event export const POST = withWorkspace( async ({ req, workspace }) => { const body = await parseRequestBody(req); const { clickId, eventName, eventQuantity, customerExternalId: newExternalId, externalId: oldExternalId, // deprecated (but we'll support it for backwards compatibility) customerId: oldCustomerId, // deprecated (but we'll support it for backwards compatibility) customerName, customerEmail, customerAvatar, mode, metadata, } = trackLeadRequestSchema .extend({ // we if clickId is undefined/nullish, we'll coerce into an empty string clickId: z.string().trim().nullish(), // add backwards compatibility customerExternalId: z.string().trim().nullish(), externalId: z.string().trim().nullish(), customerId: z.string().trim().nullish(), }) .parse(body); const customerExternalId = newExternalId || oldExternalId || oldCustomerId; if (!customerExternalId) { throw new DubApiError({ code: "bad_request", message: "customerExternalId is required", }); } const response = await trackLead({ clickId: clickId ?? "", eventName, eventQuantity, customerExternalId, customerName, customerEmail, customerAvatar, mode, metadata, rawBody: body, workspace, }); return NextResponse.json(response); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], requiredRoles: ["owner", "member"], }, ); ================================================ FILE: apps/web/app/(ee)/api/track/open/route.ts ================================================ import { COMMON_CORS_HEADERS } from "@/lib/api/cors"; import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; import { linkCache } from "@/lib/api/links/cache"; import { recordClickCache } from "@/lib/api/links/record-click-cache"; import { parseRequestBody } from "@/lib/api/utils"; import { withAxiom } from "@/lib/axiom/server"; import { DeepLinkClickData } from "@/lib/middleware/utils/cache-deeplink-click-data"; import { getIdentityHash } from "@/lib/middleware/utils/get-identity-hash"; import { getLinkViaEdge } from "@/lib/planetscale"; import { recordClick } from "@/lib/tinybird"; import { RedisLinkProps } from "@/lib/types"; import { formatRedisLink, redis, redisGlobalWithTimeout } from "@/lib/upstash"; import { trackOpenRequestSchema, trackOpenResponseSchema, } from "@/lib/zod/schemas/opens"; import { LOCALHOST_IP, nanoid } from "@dub/utils"; import { ipAddress, waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // POST /api/track/open – Track an open event for deep link export const POST = withAxiom(async (req) => { try { const { deepLink: deepLinkUrl, dubDomain } = trackOpenRequestSchema.parse( await parseRequestBody(req), ); const ip = process.env.VERCEL === "1" ? ipAddress(req) : LOCALHOST_IP; const identityHash = await getIdentityHash(req); if (!deepLinkUrl) { // Probabilistic IP-based tracking if (ip) { // if ip address is present, check if there's a cached click console.log(`Checking cache for ${ip}:${dubDomain}:*`); // Get all iOS click cache keys for this IP address const [_, cacheKeysForDomain] = await redis.scan(0, { match: `deepLinkClickCache:${ip}:${dubDomain}:*`, count: 10, }); if (cacheKeysForDomain.length > 0) { const cachedData = await redis.get( cacheKeysForDomain[0], ); if (cachedData) { return NextResponse.json( trackOpenResponseSchema.parse(cachedData), { headers: COMMON_CORS_HEADERS, }, ); } } } return NextResponse.json( trackOpenResponseSchema.parse({ clickId: null, link: null, }), { headers: COMMON_CORS_HEADERS }, ); } const deepLink = new URL(deepLinkUrl); const domain = deepLink.hostname.replace(/^www\./, "").toLowerCase(); const key = deepLink.pathname.slice(1) || "_root"; // Remove leading slash, default to _root if empty let [cachedClickId, cachedLink] = await Promise.all([ redisGlobalWithTimeout .get(recordClickCache._createKey({ domain, key, identityHash })) .catch(() => null), redisGlobalWithTimeout .get(linkCache._createKey({ domain, key })) .catch(() => null), ]); // assign a new clickId if there's no cached clickId // else, reuse the cached clickId const clickId = cachedClickId ?? nanoid(16); if (!cachedLink) { const link = await getLinkViaEdge({ domain, key, }); if (!link) { throw new DubApiError({ code: "not_found", message: `Deep link not found: ${deepLink}`, }); } cachedLink = formatRedisLink(link as any); waitUntil(linkCache.set(link as any)); } if (!cachedLink.projectId) { throw new DubApiError({ code: "not_found", message: "Deep link does not belong to a workspace.", }); } const linkData = { id: cachedLink.id, domain, key, url: cachedLink.url, }; // if there's no cached clickId, track the click event if (!cachedClickId) { const clickData = await recordClick({ req, clickId, workspaceId: cachedLink.projectId, linkId: cachedLink.id, domain, key, url: cachedLink.url, programId: cachedLink.programId, partnerId: cachedLink.partnerId, skipRatelimit: true, shouldCacheClickId: true, trigger: "deeplink", }); // return early with clickId = null if no click data was recorded (bot detected) if (!clickData) { return NextResponse.json( trackOpenResponseSchema.parse({ clickId: null, link: linkData, }), { headers: COMMON_CORS_HEADERS }, ); } } const response = trackOpenResponseSchema.parse({ clickId, link: linkData, }); return NextResponse.json(response, { headers: COMMON_CORS_HEADERS }); } catch (error) { return handleAndReturnErrorResponse(error, COMMON_CORS_HEADERS); } }); export const OPTIONS = () => { return new Response(null, { status: 204, headers: COMMON_CORS_HEADERS, }); }; ================================================ FILE: apps/web/app/(ee)/api/track/sale/client/route.ts ================================================ import { getHostnameFromRequest, verifyAnalyticsAllowedHostnames, } from "@/lib/analytics/verify-analytics-allowed-hostnames"; import { trackSale } from "@/lib/api/conversions/track-sale"; import { COMMON_CORS_HEADERS } from "@/lib/api/cors"; import { DubApiError } from "@/lib/api/errors"; import { parseRequestBody } from "@/lib/api/utils"; import { withPublishableKey } from "@/lib/auth/publishable-key"; import { trackSaleRequestSchema } from "@/lib/zod/schemas/sales"; import { NextResponse } from "next/server"; // POST /api/track/sale/client – Track a sale conversion event on the client side export const POST = withPublishableKey( async ({ req, workspace }) => { const body = await parseRequestBody(req); const allowRequest = verifyAnalyticsAllowedHostnames({ allowedHostnames: (workspace?.allowedHostnames ?? []) as string[], req, }); if (!allowRequest) { throw new DubApiError({ code: "forbidden", message: `Request origin '${getHostnameFromRequest(req)}' is not included in the allowed hostnames for this workspace. Update your allowed hostnames here: https://app.dub.co/settings/tracking`, }); } const { customerExternalId, customerName, customerEmail, customerAvatar, clickId, amount, currency, eventName, paymentProcessor, invoiceId, leadEventName, metadata, } = trackSaleRequestSchema.parse(body); if (!customerExternalId) { throw new DubApiError({ code: "bad_request", message: "customerExternalId is required", }); } const response = await trackSale({ customerExternalId, customerName, customerEmail, customerAvatar, clickId, amount, currency, eventName, paymentProcessor, invoiceId, leadEventName, metadata, workspace, rawBody: body, }); return NextResponse.json(response, { headers: COMMON_CORS_HEADERS }); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], }, ); export const OPTIONS = () => { return new Response(null, { status: 204, headers: COMMON_CORS_HEADERS, }); }; ================================================ FILE: apps/web/app/(ee)/api/track/sale/route.ts ================================================ import { trackSale } from "@/lib/api/conversions/track-sale"; import { DubApiError } from "@/lib/api/errors"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { trackSaleRequestSchema } from "@/lib/zod/schemas/sales"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; // POST /api/track/sale – Track a sale conversion event export const POST = withWorkspace( async ({ req, workspace }) => { const body = await parseRequestBody(req); let { customerExternalId: newExternalId, externalId: oldExternalId, // deprecated customerId: oldCustomerId, // deprecated customerName, customerEmail, customerAvatar, clickId, amount, currency, eventName, paymentProcessor, invoiceId, leadEventName, metadata, } = trackSaleRequestSchema .extend({ // add backwards compatibility customerExternalId: z.string().nullish(), externalId: z.string().nullish(), customerId: z.string().nullish(), }) .parse(body); const customerExternalId = newExternalId || oldExternalId || oldCustomerId; if (!customerExternalId) { throw new DubApiError({ code: "bad_request", message: "customerExternalId is required", }); } const response = await trackSale({ customerExternalId, customerName, customerEmail, customerAvatar, clickId, amount, currency, eventName, paymentProcessor, invoiceId, leadEventName, metadata, workspace, rawBody: body, }); return NextResponse.json(response); }, { requiredPlan: [ "business", "business plus", "business extra", "business max", "advanced", "enterprise", ], requiredRoles: ["owner", "member"], }, ); ================================================ FILE: apps/web/app/(ee)/api/track/visit/route.ts ================================================ import { verifyAnalyticsAllowedHostnames } from "@/lib/analytics/verify-analytics-allowed-hostnames"; import { COMMON_CORS_HEADERS } from "@/lib/api/cors"; import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; import { linkCache } from "@/lib/api/links/cache"; import { recordClickCache } from "@/lib/api/links/record-click-cache"; import { parseRequestBody } from "@/lib/api/utils"; import { withAxiom } from "@/lib/axiom/server"; import { getIdentityHash } from "@/lib/middleware/utils/get-identity-hash"; import { getLinkViaEdge, getWorkspaceViaEdge } from "@/lib/planetscale"; import { recordClick } from "@/lib/tinybird"; import { RedisLinkProps } from "@/lib/types"; import { formatRedisLink, redisGlobalWithTimeout } from "@/lib/upstash"; import { isValidUrl, nanoid } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // POST /api/track/visit – Track a visit event from the client-side export const POST = withAxiom(async (req) => { try { const { domain, url, referrer } = await parseRequestBody(req); if (!domain || !url) { throw new DubApiError({ code: "bad_request", message: "Missing domain or url", }); } const urlObj = new URL(url); let key = urlObj.pathname.slice(1); if (key === "") { key = "_root"; } const identityHash = await getIdentityHash(req); let [clickId, cachedLink] = await Promise.all([ redisGlobalWithTimeout .get(recordClickCache._createKey({ domain, key, identityHash })) .catch(() => null), redisGlobalWithTimeout .get(linkCache._createKey({ domain, key })) .catch(() => null), ]); // if the clickId is already cached in Redis, return it if (clickId) { return NextResponse.json({ clickId }, { headers: COMMON_CORS_HEADERS }); } // Otherwise, track the visit event clickId = nanoid(16); if (!cachedLink) { const link = await getLinkViaEdge({ domain, key, }); if (!link) { throw new DubApiError({ code: "not_found", message: `Link not found for domain: ${domain} and key: ${key}.`, }); } cachedLink = formatRedisLink(link as any); waitUntil(linkCache.set(link as any)); } const finalUrl = isValidUrl(url) ? url : cachedLink.url; waitUntil( (async () => { const workspace = await getWorkspaceViaEdge({ workspaceId: cachedLink.projectId!, }); const allowedHostnames = workspace?.allowedHostnames as string[]; if ( verifyAnalyticsAllowedHostnames({ allowedHostnames, req, }) ) { await recordClick({ req, clickId, workspaceId: cachedLink.projectId, linkId: cachedLink.id, domain, key, url: finalUrl, skipRatelimit: true, ...(referrer && { referrer }), trigger: "pageview", shouldCacheClickId: true, }); } })(), ); return NextResponse.json( { clickId, }, { headers: COMMON_CORS_HEADERS, }, ); } catch (error) { return handleAndReturnErrorResponse(error, COMMON_CORS_HEADERS); } }); export const OPTIONS = () => { return new Response(null, { status: 204, headers: COMMON_CORS_HEADERS, }); }; ================================================ FILE: apps/web/app/(ee)/api/workflows/partner-approved/route.ts ================================================ import { createDiscountCode } from "@/lib/api/discounts/create-discount-code"; import { createPartnerDefaultLinks } from "@/lib/api/partners/create-partner-default-links"; import { getGroupRewardsAndBounties } from "@/lib/api/partners/get-group-rewards-and-bounties"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { executeWorkflows } from "@/lib/api/workflows/execute-workflows"; import { triggerDraftBountySubmissionCreation } from "@/lib/bounty/api/trigger-draft-bounty-submissions"; import { createWorkflowLogger } from "@/lib/cron/qstash-workflow-logger"; import { polyfillSocialMediaFields } from "@/lib/social-utils"; import { PlanProps } from "@/lib/types"; import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { EnrolledPartnerSchema } from "@/lib/zod/schemas/partners"; import { sendBatchEmail } from "@dub/email"; import PartnerApplicationApproved from "@dub/email/templates/partner-application-approved"; import { prisma } from "@dub/prisma"; import { serve } from "@upstash/workflow/nextjs"; import * as z from "zod/v4"; const payloadSchema = z.object({ programId: z.string(), partnerId: z.string(), userId: z.string(), }); type Payload = z.infer; /** * Partner Approved Workflow * * This workflow is triggered when a partner's application to join a program is approved. * It performs the following steps in sequence: * * 1. **Create Default Links**: Creates partner-specific default links based on the group's * configuration. * * 2. **Create Discount Codes**: If the group's discount has auto-provisioning enabled, * creates a discount code for the partner. * * 3. **Send Email Notification**: Sends an approval email to all partner users who have * opted in to receive application approval notifications. * * 4. **Send Webhook**: Notifies the workspace via webhook that a new partner has been * enrolled in the program. * * 5. **Trigger Draft Bounty Submission Creation**: Triggers the creation of * draft bounty submissions for the partner if they are eligible for performance bounties. * * 6. **Execute Dub Workflows**: Executes Dub workflows using the “partnerEnrolled” trigger. */ // POST /api/workflows/partner-approved export const { POST } = serve( async (context) => { const input = context.requestPayload; const { programId, partnerId, userId } = input; const logger = createWorkflowLogger({ workflowId: "partner-approved", workflowRunId: context.workflowRunId, }); const { program, partner, links, ...programEnrollment } = await getProgramEnrollmentOrThrow({ programId, partnerId, include: { program: true, partner: true, links: true, }, }); const { groupId } = programEnrollment; // Step 1: Create partner default links await context.run("create-default-links", async () => { logger.info({ message: "Started executing workflow step 'create-default-links'.", data: input, }); if (!groupId) { logger.error({ message: `The partner ${partnerId} is not associated with any group.`, }); return; } let { partnerGroupDefaultLinks, utmTemplate } = await prisma.partnerGroup.findUniqueOrThrow({ where: { id: groupId, }, include: { partnerGroupDefaultLinks: true, utmTemplate: true, }, }); if (partnerGroupDefaultLinks.length === 0) { logger.error({ message: `Group ${groupId} does not have any default links.`, }); return; } // Skip existing default links for (const link of links) { if (link.partnerGroupDefaultLinkId) { partnerGroupDefaultLinks = partnerGroupDefaultLinks.filter( (defaultLink) => defaultLink.id !== link.partnerGroupDefaultLinkId, ); } } // Find the workspace const workspace = await prisma.project.findUniqueOrThrow({ where: { id: program.workspaceId, }, select: { id: true, plan: true, }, }); const partnerLinks = await createPartnerDefaultLinks({ workspace: { id: workspace.id, plan: workspace.plan as PlanProps, }, program: { id: program.id, defaultFolderId: program.defaultFolderId, }, partner: { id: partner.id, name: partner.name, email: partner.email!, tenantId: programEnrollment.tenantId ?? undefined, }, group: { defaultLinks: partnerGroupDefaultLinks, utmTemplate: utmTemplate, }, userId, }); logger.info({ message: `Created ${partnerLinks.length} partner default links.`, data: partnerLinks.map(({ id, url, shortLink }) => ({ id, url, shortLink, })), }); return; }); // Step 2: Auto-provision discount code if enabled await context.run("create-discount-codes", async () => { if (!groupId) { return; } const group = await prisma.partnerGroup.findUnique({ where: { id: groupId, }, include: { discount: true, }, }); if (!group?.discount?.autoProvisionEnabledAt) { return; } const workspace = await prisma.project.findUniqueOrThrow({ where: { id: program.workspaceId, }, select: { stripeConnectId: true, }, }); if (!workspace.stripeConnectId) { return; } const partnerLinks = await prisma.link.findMany({ where: { programId, partnerId, partnerGroupDefaultLinkId: { not: null, }, discountCode: { is: null, }, }, select: { id: true, }, }); if (partnerLinks.length === 0) { return; } for (const link of partnerLinks) { try { await createDiscountCode({ stripeConnectId: workspace.stripeConnectId, partner, link, discount: group.discount, }); } catch (error) { console.error( `Failed to create discount code for link ${link.id}:`, error, ); } } }); // Step 3: Send email to partner application approved await context.run("send-email", async () => { logger.info({ message: "Started executing workflow step 'send-email'.", data: input, }); if (!groupId) { logger.error({ message: `The partner ${partnerId} is not associated with any group.`, }); return; } // Find the partner users to send email notification const partnerUsers = await prisma.partnerUser.findMany({ where: { partnerId, notificationPreferences: { applicationApproved: true, }, user: { email: { not: null, }, }, }, select: { user: { select: { id: true, email: true, }, }, }, }); if (partnerUsers.length === 0) { logger.info({ message: `No partner users found for partner ${partnerId} to send email notification.`, }); return; } logger.info({ message: `Sending email notification to ${partnerUsers.length} partner users.`, data: partnerUsers, }); const rewardsAndBounties = await getGroupRewardsAndBounties({ programId, groupId: programEnrollment.groupId || program.defaultGroupId, }); // Resend batch email const { data, error } = await sendBatchEmail( partnerUsers.map(({ user }) => ({ variant: "notifications", to: user.email!, subject: `Your application to join ${program.name} partner program has been approved!`, replyTo: program.supportEmail || "noreply", react: PartnerApplicationApproved({ program: { name: program.name, logo: program.logo, slug: program.slug, }, partner: { name: partner.name, email: user.email!, payoutsEnabled: Boolean(partner.payoutsEnabledAt), }, ...rewardsAndBounties, }), })), { idempotencyKey: `application-approved/${programEnrollment.id}`, }, ); if (data) { logger.info({ message: `Sent emails to ${partnerUsers.length} partner users.`, data: data, }); } if (error) { throw new Error(error.message); } }); // Step 4: Send webhook to workspace await context.run("send-webhook", async () => { logger.info({ message: "Started executing workflow step 'send-webhook'.", data: input, }); const partnerPlatforms = await prisma.partnerPlatform.findMany({ where: { partnerId, }, }); const enrolledPartner = EnrolledPartnerSchema.parse({ ...programEnrollment, ...partner, ...polyfillSocialMediaFields(partnerPlatforms), id: partner.id, status: programEnrollment.status, links, }); const workspace = await prisma.project.findUniqueOrThrow({ where: { id: program.workspaceId, }, select: { id: true, webhookEnabled: true, }, }); await sendWorkspaceWebhook({ workspace, trigger: "partner.enrolled", data: enrolledPartner, }); logger.info({ message: `Sent "partner.enrolled" webhook to workspace ${workspace.id}.`, }); }); // Step 5: Trigger draft bounty submission creation await context.run("trigger-draft-bounty-submission-creation", async () => { logger.info({ message: "Started executing workflow step 'trigger-draft-bounty-submission-creation'.", data: input, }); await triggerDraftBountySubmissionCreation({ programId, partnerIds: [partnerId], }); logger.info({ message: `Triggered draft bounty submission creation for partner ${partnerId} in program ${programId}.`, }); }); // Step 6: Execute Dub workflows using the “partnerEnrolled” trigger. await context.run("execute-workflows", async () => { logger.info({ message: "Started executing workflow step 'execute-workflows' for the trigger 'partnerEnrolled'.", data: input, }); await executeWorkflows({ trigger: "partnerEnrolled", identity: { workspaceId: program.workspaceId, programId, partnerId, }, }); }); }, { initialPayloadParser: (requestPayload) => { return payloadSchema.parse(JSON.parse(requestPayload)); }, }, ); ================================================ FILE: apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/layout.tsx ================================================ import WorkspaceAuth from "app/app.dub.co/(dashboard)/[slug]/auth"; import { ReactNode } from "react"; export default function NewProgramWorkspaceLayout({ children, }: { children: ReactNode; }) { return {children}; } ================================================ FILE: apps/web/app/(ee)/app.dub.co/(new-program)/[slug]/program/new/form.tsx ================================================ "use client"; import { onboardProgramAction } from "@/lib/actions/partners/onboard-program"; import useWorkspace from "@/lib/swr/use-workspace"; import { ProgramData } from "@/lib/types"; import { ProgramLinkConfiguration } from "@/ui/partners/program-link-configuration"; import { Button, FileUpload, Input, useMediaQuery } from "@dub/ui"; import { Plus } from "lucide-react"; import { useAction } from "next-safe-action/hooks"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { Controller, useFormContext } from "react-hook-form"; import { toast } from "sonner"; export function Form() { const router = useRouter(); const { isMobile } = useMediaQuery(); const [isUploading, setIsUploading] = useState(false); const [hasSubmitted, setHasSubmitted] = useState(false); const { id: workspaceId, slug: workspaceSlug, mutate } = useWorkspace(); const { register, handleSubmit, watch, control, setValue, formState: { isSubmitting }, } = useFormContext(); const [name, url, domain, logo] = watch(["name", "url", "domain", "logo"]); const { executeAsync, isPending } = useAction(onboardProgramAction, { onSuccess: () => { router.push(`/${workspaceSlug}/program/new/rewards`); mutate(); }, onError: ({ error }) => { toast.error(error.serverError); setHasSubmitted(false); }, }); const onSubmit = async (data: ProgramData) => { if (!workspaceId) return; setHasSubmitted(true); await executeAsync({ ...data, workspaceId, step: "get-started", }); }; // Handle logo upload const handleUpload = async (file: File) => { setIsUploading(true); try { const response = await fetch( `/api/workspaces/${workspaceId}/upload-url`, { method: "POST", body: JSON.stringify({ folder: "program-logos", }), }, ); if (!response.ok) { throw new Error("Failed to get signed URL for upload."); } const { signedUrl, destinationUrl } = await response.json(); const uploadResponse = await fetch(signedUrl, { method: "PUT", body: file, headers: { "Content-Type": file.type, "Content-Length": file.size.toString(), }, }); if (!uploadResponse.ok) { throw new Error("Failed to upload to signed URL"); } setValue("logo", destinationUrl, { shouldDirty: true }); toast.success(`${file.name} uploaded!`); } catch (e) { toast.error("Failed to upload logo"); } finally { setIsUploading(false); } }; const buttonDisabled = isSubmitting || isPending || !name || !url || !domain || !logo; return (

The name of your company

A square logo that will be used in various parts of your program

( handleUpload(file)} content={null} maxFileSizeMB={2} /> )} />

Referral link

Set the custom domain and destination URL for your referral links

setValue("domain", domain, { shouldDirty: true }) } onUrlChange={(url) => setValue("url", url, { shouldDirty: true })} />

Create partner program

Cancel
); } ================================================ FILE: apps/web/app/(ee)/app.dub.co/(new-program)/layout.tsx ================================================ "use client"; import { ProgramOnboardingFormWrapper } from "@/ui/partners/program-onboarding-form-wrapper"; import { ProgramOnboardingHeader } from "./header"; import { SidebarProvider } from "./sidebar-context"; import { ProgramOnboardingSteps } from "./steps"; export default function Layout({ children }: { children: React.ReactNode }) { return (
{children}
); } ================================================ FILE: apps/web/app/(ee)/app.dub.co/(new-program)/sidebar-context.tsx ================================================ "use client"; import useWorkspace from "@/lib/swr/use-workspace"; import LayoutLoader from "@/ui/layout/layout-loader"; import { redirect } from "next/navigation"; import { createContext, ReactNode, useContext, useState } from "react"; interface SidebarContextType { isOpen: boolean; setIsOpen: (isOpen: boolean) => void; } const SidebarContext = createContext(undefined); export function SidebarProvider({ children }: { children: ReactNode }) { const [isOpen, setIsOpen] = useState(false); const { slug: workspaceSlug, defaultProgramId, loading: workspaceLoading, error: workspaceError, } = useWorkspace(); if (workspaceError && workspaceError.status === 404) { redirect("/account/settings"); } else if (workspaceLoading) { return ; } if (defaultProgramId) { redirect(`/${workspaceSlug}/program`); } return ( {children} ); } export function useSidebar() { const context = useContext(SidebarContext); if (context === undefined) { throw new Error("useSidebar must be used within a SidebarProvider"); } return context; } ================================================ FILE: apps/web/app/(ee)/app.dub.co/(new-program)/steps.tsx ================================================ "use client"; import { useWorkspaceStore } from "@/lib/swr/use-workspace-store"; import { ProgramData } from "@/lib/types"; import { PROGRAM_ONBOARDING_STEPS } from "@/lib/zod/schemas/program-onboarding"; import { useMediaQuery } from "@dub/ui"; import { cn } from "@dub/utils"; import { Check, Lock, X } from "lucide-react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; import { useEffect } from "react"; import { useSidebar } from "./sidebar-context"; export function ProgramOnboardingSteps() { const pathname = usePathname(); const { isMobile } = useMediaQuery(); const { isOpen, setIsOpen } = useSidebar(); const { slug } = useParams<{ slug: string }>(); const [programOnboarding] = useWorkspaceStore("programOnboarding"); useEffect(() => { document.body.style.overflow = isOpen && isMobile ? "hidden" : "auto"; }, [isOpen, isMobile]); const currentPath = pathname.replace(`/${slug}`, ""); const currentStep = PROGRAM_ONBOARDING_STEPS.find( (s) => s.href === currentPath, ); const lastCompletedStep = programOnboarding?.lastCompletedStep ?? "get-started"; const lastCompletedStepObj = PROGRAM_ONBOARDING_STEPS.find( (s) => s.step === lastCompletedStep, ); return ( <>
{ if (e.target === e.currentTarget) { e.stopPropagation(); setIsOpen(false); } }} >

Program Setup

); } ================================================ FILE: apps/web/app/(ee)/app.dub.co/embed/referrals/activity.tsx ================================================ import { CursorRays } from "@/ui/layout/sidebar/icons/cursor-rays"; import { InfoTooltip, MiniAreaChart } from "@dub/ui"; import { cn, currencyFormatter, fetcher, nFormatter } from "@dub/utils"; import { AnalyticsTimeseries } from "dub/models/components"; import { SVGProps, useId } from "react"; import useSWR from "swr"; import { useEmbedToken } from "../../embed/use-embed-token"; export function ReferralsEmbedActivity({ clicks, leads, sales, saleAmount, color, }: { clicks: number; leads: number; sales: number; saleAmount: number; color?: string | null; }) { const token = useEmbedToken(); const isEmpty = clicks === 0 && leads === 0 && sales === 0; const { data: analytics } = useSWR( !isEmpty && "/api/embed/referrals/analytics", (url) => fetcher(url, { headers: { Authorization: `Bearer ${token}`, }, }), { keepPreviousData: true, dedupingInterval: 60000, }, ); return (
{isEmpty ? ( ) : (
{[ { label: "Clicks", value: clicks, description: "Total number of unique clicks your link has received", }, { label: "Leads", value: leads, description: "Total number of signups that came from your link", }, { label: "Sales", value: sales, subValue: saleAmount, description: "Total number of leads that converted to a paid account", }, ].map(({ label, value, subValue, description }) => (
{label} {nFormatter(value, { full: true })}{" "} {subValue || subValue === 0 ? ( ({currencyFormatter(subValue)}) ) : null}
({ date: new Date(a.start), value: a[label.toLowerCase()], })) ?? [] } color={color ?? undefined} />
))}
)}
); } function EmptyState() { return (

No activity yet

After your first click, your stats will show

); } function EmptyStateBackground({ className, ...rest }: SVGProps) { const id = useId(); return ( ); } ================================================ FILE: apps/web/app/(ee)/app.dub.co/embed/referrals/add-edit-link.tsx ================================================ import { mutateSuffix } from "@/lib/swr/mutate"; import { PartnerGroupAdditionalLink, PartnerGroupProps } from "@/lib/types"; import { Lock } from "@/ui/shared/icons"; import { Program } from "@dub/prisma/client"; import { Button, Combobox, InfoTooltip, TAB_ITEM_ANIMATION_SETTINGS, useCopyToClipboard, useMediaQuery, } from "@dub/ui"; import { cn, getApexDomain, getPathnameFromUrl, linkConstructor, punycode, } from "@dub/utils"; import { motion } from "motion/react"; import { useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { useDebounce } from "use-debounce"; import { useEmbedToken } from "../use-embed-token"; import { ReferralsEmbedLink } from "./types"; interface Props { program: Pick; group: Pick; link?: ReferralsEmbedLink | null; onCancel: () => void; } interface FormData { pathname: string; key: string; } export function ReferralsEmbedCreateUpdateLink({ program, group, link, onCancel, }: Props) { const token = useEmbedToken(); const { isMobile } = useMediaQuery(); const [, copyToClipboard] = useCopyToClipboard(); const [lockKey, setLockKey] = useState(Boolean(link)); const [isSubmitting, setIsSubmitting] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [isExactMode, setIsExactMode] = useState(false); const shortLinkDomain = program.domain || ""; const additionalLinks: PartnerGroupAdditionalLink[] = group.additionalLinks ?? []; const destinationDomains = useMemo( () => additionalLinks.map((link) => link.domain), [additionalLinks], ); const [destinationDomain, setDestinationDomain] = useState( link ? getApexDomain(link.url) : destinationDomains?.[0] ?? null, ); useEffect(() => { const additionalLink = additionalLinks.find( (link) => link.domain === destinationDomain, ); setIsExactMode(additionalLink?.validationMode === "exact"); }, [destinationDomain, additionalLinks]); const { watch, setValue, register, handleSubmit, formState: { isDirty }, } = useForm({ defaultValues: link ? { pathname: getPathnameFromUrl(link.url), key: link.key, } : undefined, }); const [key, pathname] = watch(["key", "pathname"]); const onSubmit = async (data: FormData) => { setIsSubmitting(true); setErrorMessage(null); try { const endpoint = !link ? "/api/embed/referrals/links" : `/api/embed/referrals/links/${link.id}`; const response = await fetch(endpoint, { method: !link ? "POST" : "PATCH", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ ...data, url: linkConstructor({ domain: destinationDomain, key: getPathnameFromUrl(pathname), }), }), }); const result = await response.json(); if (!response.ok) { setErrorMessage(result.error.message); return; } if (!link) { copyToClipboard(result.shortLink); } await mutateSuffix("api/embed/referrals/links"); onCancel(); } catch (error) { setErrorMessage("Something went wrong. Please try again."); } finally { setIsSubmitting(false); } }; const saveDisabled = useMemo( () => Boolean(isSubmitting || (!link ? !key : !isDirty)), [isSubmitting, key, isDirty, link], ); // If there is only one destination domain and we are in exact mode, hide the destination URL input const hideDestinationUrl = useMemo( () => link?.partnerGroupDefaultLinkId || (destinationDomains.length === 1 && isExactMode), [destinationDomains.length, isExactMode, link?.partnerGroupDefaultLinkId], ); return (
{!link ? "New link" : "Edit link"}
{lockKey && ( )}
{shortLinkDomain}
{errorMessage && (
{errorMessage}
)}
{!hideDestinationUrl && (
) => { if (isExactMode) return; e.preventDefault(); // if pasting in a URL, extract the pathname const text = e.clipboardData.getData("text/plain"); try { const url = new URL(text); e.currentTarget.value = url.pathname.slice(1); } catch (err) { e.currentTarget.value = text; } setValue("pathname", e.currentTarget.value, { shouldDirty: true, }); }} className={cn( "z-0 block w-full rounded-r-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:z-[1] focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm", { "cursor-not-allowed border bg-neutral-100 text-neutral-500": isExactMode, }, )} {...register("pathname", { required: false })} />
)}
); } function DestinationDomainCombobox({ selectedDomain, setSelectedDomain, destinationDomains, disabled = false, }: { selectedDomain?: string; setSelectedDomain: (domain: string) => void; destinationDomains: string[]; disabled?: boolean; }) { const [search, setSearch] = useState(""); const [debouncedSearch] = useDebounce(search, 500); const [isOpen, setIsOpen] = useState(false); const options = useMemo(() => { const allDomains = selectedDomain ? [ selectedDomain, ...destinationDomains.filter((d) => d !== selectedDomain), ] : destinationDomains; if (!debouncedSearch) { return allDomains.map((domain) => ({ value: domain, label: punycode(domain), })); } return allDomains .filter((domain) => punycode(domain).toLowerCase().includes(debouncedSearch.toLowerCase()), ) .map((domain) => ({ value: domain, label: punycode(domain), })); }, [selectedDomain, destinationDomains, debouncedSearch]); return ( { if (!option) return; setSelectedDomain(option.value); }} options={options} caret={true} placeholder="Select domain..." searchPlaceholder="Search domains..." buttonProps={{ className: cn( "w-32 sm:w-40 h-full rounded-r-none border-r-transparent justify-start px-2.5", "data-[state=open]:ring-1 data-[state=open]:ring-neutral-500 data-[state=open]:border-neutral-500", "focus:ring-1 focus:ring-neutral-500 focus:border-neutral-500 transition-none", { "cursor-not-allowed bg-neutral-100 text-neutral-500": disabled, }, ), disabled, }} optionClassName="sm:max-w-[225px]" shouldFilter={false} open={disabled ? false : isOpen} onOpenChange={disabled ? undefined : setIsOpen} onSearchChange={disabled ? undefined : setSearch} /> ); } ================================================ FILE: apps/web/app/(ee)/app.dub.co/embed/referrals/dynamic-height-messenger.tsx ================================================ "use client"; import { useEffect } from "react"; export function DynamicHeightMessenger() { useEffect(() => { document.body.style.overflow = "hidden"; const update = () => { const height = document.body.scrollHeight; parent.postMessage( { originator: "Dub", event: "PAGE_HEIGHT", data: { height }, }, "*", ); }; update(); const resizeObserver = new ResizeObserver(update); resizeObserver.observe(document.body); return () => { resizeObserver.disconnect(); }; }, []); return false; } ================================================ FILE: apps/web/app/(ee)/app.dub.co/embed/referrals/earnings-summary.tsx ================================================ import { Button, InfoTooltip } from "@dub/ui"; import { currencyFormatter } from "@dub/utils"; export function ReferralsEmbedEarningsSummary({ earnings, programSlug, partnerEmail, }: { earnings: { upcoming: number; paid: number }; programSlug: string; partnerEmail: string | null; }) { return (

Earnings

{[ { label: "Upcoming", value: earnings.upcoming, }, { label: "Paid", value: earnings.paid, }, ].map(({ label, value }) => (
{label} {currencyFormatter(value)}
))}
); } ================================================ FILE: apps/web/app/(ee)/app.dub.co/embed/referrals/earnings.tsx ================================================ import { REFERRALS_EMBED_EARNINGS_LIMIT } from "@/lib/constants/misc"; import { PartnerEarningsResponse } from "@/lib/types"; import { CommissionStatusBadges } from "@/ui/partners/commission-status-badges"; import { Gift, StatusBadge, TAB_ITEM_ANIMATION_SETTINGS, Table, usePagination, useTable, } from "@dub/ui"; import { currencyFormatter, fetcher, formatDate, formatDateTime, } from "@dub/utils"; import { motion } from "motion/react"; import useSWR from "swr"; import { useEmbedToken } from "../../embed/use-embed-token"; export function ReferralsEmbedEarnings({ salesCount }: { salesCount: number }) { const token = useEmbedToken(); const { pagination, setPagination } = usePagination( REFERRALS_EMBED_EARNINGS_LIMIT, ); const { data: earnings, isLoading } = useSWR( `/api/embed/referrals/earnings?page=${pagination.pageIndex}`, (url) => fetcher(url, { headers: { Authorization: `Bearer ${token}`, }, }), { keepPreviousData: true, }, ); const { table, ...tableProps } = useTable({ data: earnings || [], loading: isLoading, columns: [ { id: "customer", header: "Customer", cell: ({ row }) => { return row.original.customer ? row.original.customer.email : "-"; }, }, { id: "createdAt", header: "Date", cell: ({ row }) => (

{formatDate(row.original.createdAt, { month: "short" })}

), }, { id: "amount", header: "Amount", cell: ({ row }) => { return currencyFormatter(row.original.amount); }, }, { id: "earnings", header: "Earnings", accessorKey: "earnings", cell: ({ row }) => { return currencyFormatter(row.original.earnings); }, }, { header: "Status", cell: ({ row }) => { const badge = CommissionStatusBadges[row.original.status]; return ( {badge.label} ); }, }, ], pagination, onPaginationChange: setPagination, rowCount: salesCount, emptyState: (

No earnings yet. When you refer a friend and they make a purchase, they'll show up here.

), thClassName: "border-l-0", tdClassName: "border-l-0", resourceName: (plural) => `sale${plural ? "s" : ""}`, }); return (
); } ================================================ FILE: apps/web/app/(ee)/app.dub.co/embed/referrals/faq.tsx ================================================ import { constructRewardAmount } from "@/lib/api/sales/construct-reward-amount"; import { RewardProps } from "@/lib/types"; import { programEmbedSchema } from "@/lib/zod/schemas/program-embed"; import { BlockMarkdown } from "@/ui/partners/lander/blocks/block-markdown"; import { Program } from "@dub/prisma/client"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, TAB_ITEM_ANIMATION_SETTINGS, } from "@dub/ui"; import { motion } from "motion/react"; export function ReferralsEmbedFAQ({ program, reward, }: { program: Program; reward: RewardProps | null; }) { const rewardDescription = reward ? `For each new customer you refer, you'll earn a ${constructRewardAmount(reward)} commission on their subscription${ reward.maxDuration === null ? " for their lifetime" : reward.maxDuration && reward.maxDuration > 1 ? ` for up to ${reward.maxDuration} months` : "" }. There are no limits to how much you can earn.` : ""; const programEmbedData = programEmbedSchema.parse(program.embedData); const items = programEmbedData?.faq || [ { title: `What is the ${program.name} Referral Program?`, content: `The ${program.name} Referral Program is a way for you to earn money by referring new customers to ${program.name}. ${rewardDescription}`, }, { title: "What counts as a successful conversion?", content: `New customers that sign up for a paid plan within 90 days of using your referral link will be counted as a successful conversion. Attributions are done on a last-click basis, so your link must be the last link clicked before the customer signs up for an account on ${program.name}.`, }, { title: "How should I promote the program?", content: `You should promote the program by sharing your unique referral link with your audience. When you post or distribute content about ${program.name}, your message must make it obvious that you have a financially compensated relationship with ${program.name}. We need all promotions to be FTC compliant. A helpful guide can be found [here](https://www.ftc.gov/business-guidance/resources/disclosures-101-social-media-influencers).`, }, { title: "Can I refer myself?", content: "Self-referrals are not allowed. The goal of the program is to reward you for referring other people. This is not a way for you to get a discount on your own account.", }, ]; return ( {items.map((item, idx) => (

{item.title}

{item.content}
))}
); } ================================================ FILE: apps/web/app/(ee)/app.dub.co/embed/referrals/leaderboard.tsx ================================================ import { LeaderboardPartnerSchema } from "@/lib/zod/schemas/partners"; import { AnimatedEmptyState } from "@/ui/shared/animated-empty-state"; import { Crown, TAB_ITEM_ANIMATION_SETTINGS, Table, Tooltip, Users, useTable, } from "@dub/ui"; import { currencyFormatter, fetcher } from "@dub/utils"; import { cn } from "@dub/utils/src/functions"; import { motion } from "motion/react"; import useSWR from "swr"; import * as z from "zod/v4"; import { useEmbedToken } from "../../embed/use-embed-token"; export function ReferralsEmbedLeaderboard() { const token = useEmbedToken(); const { data: partners, isLoading } = useSWR< z.infer[] >( "/api/embed/referrals/leaderboard", (url) => fetcher(url, { headers: { Authorization: `Bearer ${token}`, }, }), { keepPreviousData: true, }, ); const { table, ...tableProps } = useTable({ data: partners || [], loading: isLoading, columns: [ { id: "rank", header: "Rank", size: 40, minSize: 40, cell: ({ row }) => { return (
{row.index + 1} {row.index <= 2 && ( )}
); }, }, { id: "name", header: "Partner", cell: ({ row }) => { return (
{row.original.name} {row.original.name}
); }, }, { id: "totalCommissions", header: "Earnings", cell: ({ row }) => { return currencyFormatter(row.original.totalCommissions); }, }, ], emptyState: ( ( <>
)} className="border-none md:min-h-fit" /> ), thClassName: "border-l-0", tdClassName: "border-l-0", resourceName: (plural) => `partner${plural ? "s" : ""}`, }); return (
); } ================================================ FILE: apps/web/app/(ee)/app.dub.co/embed/referrals/links-list.tsx ================================================ import { constructPartnerLink } from "@/lib/partners/construct-partner-link"; import { PartnerGroupProps } from "@/lib/types"; import { AnimatedEmptyState } from "@/ui/shared/animated-empty-state"; import { Program } from "@dub/prisma/client"; import { Button, CopyButton, TAB_ITEM_ANIMATION_SETTINGS, Table, Users, useTable, } from "@dub/ui"; import { ArrowTurnRight2, Pen2, Plus2 } from "@dub/ui/icons"; import { currencyFormatter, fetcher, getApexDomain, getPrettyUrl, nFormatter, } from "@dub/utils"; import { motion } from "motion/react"; import { useEffect, useState } from "react"; import useSWR from "swr"; import { useEmbedToken } from "../use-embed-token"; import { ReferralsEmbedLink } from "./types"; interface Props { program: Pick; links: ReferralsEmbedLink[]; group: Pick< PartnerGroupProps, "id" | "additionalLinks" | "maxPartnerLinks" | "linkStructure" >; onCreateLink: () => void; onEditLink: (link: ReferralsEmbedLink) => void; } export function ReferralsEmbedLinksList({ program, links, group, onCreateLink, onEditLink, }: Props) { const token = useEmbedToken(); const [partnerLinks, setPartnerLinks] = useState(links); const { data: refreshedLinks, isLoading } = useSWR( "/api/embed/referrals/links", (url) => fetcher(url, { headers: { Authorization: `Bearer ${token}`, }, }), { keepPreviousData: true, }, ); useEffect(() => { if (refreshedLinks) { setPartnerLinks(refreshedLinks); } }, [refreshedLinks]); const hasLinksLimitReached = partnerLinks.length >= group.maxPartnerLinks; const hasAdditionalLinks = group.additionalLinks?.length > 0; const canCreateNewLink = !hasLinksLimitReached && hasAdditionalLinks; const { table, ...tableProps } = useTable({ data: partnerLinks, columns: [ { id: "link", header: "Link", minSize: 200, cell: ({ row }) => { const partnerLink = constructPartnerLink({ group, link: row.original, }); const destinationUrl = row.original.url; return ( ); }, }, { id: "clicks", header: "Clicks", minSize: 80, maxSize: 100, cell: ({ row }) => nFormatter(row.original.clicks), }, { id: "leads", header: "Leads", minSize: 80, maxSize: 100, cell: ({ row }) => nFormatter(row.original.leads), }, { id: "sales", header: "Sales", minSize: 80, maxSize: 100, cell: ({ row }) => currencyFormatter(row.original.saleAmount), }, { id: "actions", header: () => (
{links.length > 0 && (
)} ); } ================================================ FILE: apps/web/app/(ee)/app.dub.co/embed/referrals/links.tsx ================================================ import { PartnerGroupProps } from "@/lib/types"; import { Program } from "@dub/prisma/client"; import { useState } from "react"; import { ReferralsEmbedCreateUpdateLink } from "./add-edit-link"; import { ReferralsEmbedLinksList } from "./links-list"; import { ReferralsEmbedLink } from "./types"; interface Props { links: ReferralsEmbedLink[]; program: Pick; group: Pick< PartnerGroupProps, "id" | "additionalLinks" | "maxPartnerLinks" | "linkStructure" >; } export function ReferralsEmbedLinks({ links, program, group }: Props) { const [createLink, setCreateLink] = useState(false); const [link, setLink] = useState(null); return (
{createLink ? ( { setCreateLink(false); setLink(null); }} /> ) : ( setCreateLink(true)} onEditLink={(link) => { setLink(link); setCreateLink(true); }} /> )}
); } ================================================ FILE: apps/web/app/(ee)/app.dub.co/embed/referrals/page-client.tsx ================================================ "use client"; import { constructPartnerLink } from "@/lib/partners/construct-partner-link"; import { QueryLinkStructureHelpText } from "@/lib/partners/query-link-structure-help-text"; import { DiscountProps, PartnerGroupProps, RewardProps } from "@/lib/types"; import { programEmbedSchema } from "@/lib/zod/schemas/program-embed"; import { programResourcesSchema } from "@/lib/zod/schemas/program-resources"; import { HeroBackground } from "@/ui/partners/hero-background"; import { ProgramRewardList } from "@/ui/partners/program-reward-list"; import { ProgramRewardTerms } from "@/ui/partners/program-reward-terms"; import { ThreeDots } from "@/ui/shared/icons"; import { Partner, Program } from "@dub/prisma/client"; import { Button, Check, Combobox, Copy, Directions, Popover, TabSelect, useCopyToClipboard, useLocalStorage, Wordmark, } from "@dub/ui"; import { ArrowTurnRight2 } from "@dub/ui/icons"; import { cn, getApexDomain, getPrettyUrl } from "@dub/utils"; import { ChevronDown } from "lucide-react"; import { AnimatePresence } from "motion/react"; import { useEffect, useMemo, useState } from "react"; import { ReferralsEmbedActivity } from "./activity"; import { ReferralsEmbedEarnings } from "./earnings"; import { ReferralsEmbedEarningsSummary } from "./earnings-summary"; import { ReferralsEmbedFAQ } from "./faq"; import { ReferralsEmbedLeaderboard } from "./leaderboard"; import { ReferralsEmbedLinks } from "./links"; import { ReferralsEmbedQuickstart } from "./quickstart"; import { ReferralsEmbedResources } from "./resources"; import { ThemeOptions } from "./theme-options"; import { ReferralsReferralsEmbedToken } from "./token"; import { ReferralsEmbedLink } from "./types"; export function ReferralsEmbedPageClient({ program, partner, links, rewards, discount, earnings, stats, group, themeOptions, dynamicHeight, }: { program: Program; partner: Pick; links: ReferralsEmbedLink[]; rewards: RewardProps[]; discount?: DiscountProps | null; earnings: { upcoming: number; paid: number; }; stats: { clicks: number; leads: number; sales: number; saleAmount: number; }; group: Pick< PartnerGroupProps, | "id" | "logo" | "wordmark" | "brandColor" | "additionalLinks" | "maxPartnerLinks" | "linkStructure" | "holdingPeriodDays" >; themeOptions: ThemeOptions; dynamicHeight: boolean; }) { const resources = programResourcesSchema.parse( program.resources ?? { logos: [], colors: [], files: [] }, ); const programEmbedData = programEmbedSchema.parse(program.embedData); const hasResources = resources && ["logos", "colors", "files"].some( (resource) => resources?.[resource]?.length, ); const [showQuickstart, setShowQuickstart] = useLocalStorage( "referral-embed-show-quickstart", true, ); const tabs = useMemo( () => [ ...(showQuickstart ? ["Quickstart"] : []), "Earnings", ...(group.additionalLinks.length > 0 ? ["Links"] : []), ...(programEmbedData?.leaderboard?.mode === "disabled" ? [] : ["Leaderboard"]), "FAQ", ...(hasResources ? ["Resources"] : []), ], [showQuickstart, group.additionalLinks, programEmbedData, hasResources], ); const [selectedTab, setSelectedTab] = useState(tabs[0]); useEffect(() => { if (!tabs.includes(selectedTab)) setSelectedTab(tabs[0]); }, [tabs, selectedTab]); return (
Rewards {program.termsUrl && ( View terms ↗ )}
{!programEmbedData?.hidePoweredByBadge && ( )}
({ id: tab, label: tab, }))} selected={selectedTab} onSelect={(option) => { setSelectedTab(option); }} className="scrollbar-hide min-w-0 grow overflow-x-auto" />
{ setShowQuickstart(show); if (show) setSelectedTab("Quickstart"); }} />
{selectedTab === "Quickstart" ? ( ) : selectedTab === "Earnings" ? ( ) : selectedTab === "Links" ? ( ) : selectedTab === "Leaderboard" && programEmbedData?.leaderboard?.mode !== "disabled" ? ( ) : selectedTab === "FAQ" ? ( ) : selectedTab === "Resources" ? ( ) : null}
); } function ReferralLinkDisplay({ links, group, onSelectTab, }: { links: ReferralsEmbedLink[]; group: Pick< PartnerGroupProps, | "id" | "logo" | "wordmark" | "brandColor" | "additionalLinks" | "maxPartnerLinks" | "linkStructure" | "holdingPeriodDays" >; onSelectTab: (tab: string) => void; }) { const [copied, copyToClipboard] = useCopyToClipboard(); const [selectedLinkId, setSelectedLinkId] = useState( links[0]?.id ?? null, ); const selectedLink = useMemo( () => links.find((l) => l.id === selectedLinkId) ?? links[0], [links, selectedLinkId], ); const partnerLink = selectedLink ? constructPartnerLink({ group, link: selectedLink }) : undefined; const options = useMemo( () => links.map((link) => ({ value: link.id, label: getPrettyUrl(constructPartnerLink({ group, link })), meta: { destination: link.url ? getApexDomain(link.url) : null, }, })), [links, group], ); const selectedOption = selectedLink && partnerLink ? { value: selectedLink.id, label: getPrettyUrl(partnerLink), meta: { destination: selectedLink.url ? getApexDomain(selectedLink.url) : null, }, } : null; let actionButton: React.ReactNode = null; if (partnerLink) { actionButton = (
} text={copied ? "Copied link" : "Copy link"} className="xs:w-fit" onClick={() => copyToClipboard(partnerLink)} /> ); } else if (links.length === 0) { actionButton = ( } /> {actionButton} )} {partnerLink && group.linkStructure === "query" && ( )} ); } function Menu({ showQuickstart, setShowQuickstart, }: { showQuickstart: boolean; setShowQuickstart: (value: boolean) => void; }) { const [openPopover, setOpenPopover] = useState(false); return (
{requiresUpgrade && ( <>
{upgradeOverlay}
)}
); } ================================================ FILE: apps/web/ui/analytics/events/events-tabs.tsx ================================================ import { editQueryString } from "@/lib/analytics/utils"; import { MiniAreaChart, useMediaQuery, useRouterStuff } from "@dub/ui"; import { capitalize, cn, fetcher } from "@dub/utils"; import NumberFlow from "@number-flow/react"; import { useCallback, useContext, useEffect } from "react"; import useSWRImmutable from "swr/immutable"; import { AnalyticsContext } from "../analytics-provider"; type TimeseriesData = { start: Date; clicks: number; leads: number; sales: number; saleAmount: number; }[]; export default function EventsTabs() { const { searchParams, queryParams } = useRouterStuff(); const { isMobile } = useMediaQuery(); const tab = searchParams.get("event") || "clicks"; const { baseApiPath, queryString, requiresUpgrade, totalEvents, totalEventsLoading, fetchCompositeStats, } = useContext(AnalyticsContext); const { data: timeseriesData, isLoading: isLoadingTimeseries } = useSWRImmutable( `${baseApiPath}?${editQueryString(queryString, { groupBy: "timeseries", event: fetchCompositeStats ? "composite" : "clicks", })}`, fetcher, { shouldRetryOnError: !requiresUpgrade, keepPreviousData: true, }, ); const onEventTabClick = useCallback( (event: string) => { const sortOptions = event === "sales" ? ["timestamp", "saleAmount"] : ["date"]; const currentSort = searchParams.get("sort"); queryParams({ set: { event }, del: [ // Reset pagination "page", // Reset sort if not possible ...(currentSort && !sortOptions.includes(currentSort) ? ["sort"] : []), ], }); }, [queryParams, searchParams.get("sort")], ); useEffect(() => { const sortBy = searchParams.get("sort"); if (tab !== "sales" && sortBy !== "timestamp") queryParams({ del: "sort" }); }, [tab, searchParams.get("sort")]); return (
{["clicks", "leads", "sales"].map((event) => ( ))}
); } ================================================ FILE: apps/web/ui/analytics/events/example-data.ts ================================================ const common = { ip: "0.0.0.0", referer: "(direct)", qr: 0, device: "Desktop", browser: "Chrome", os: "Mac OS", }; const dubLink = { id: "1", domain: "dub.link", key: "uxUrVCz", shortLink: "https://dub.co/uxUrVCz", url: "https://dub.co/", }; const githubLink = { id: "3", domain: "git.new", key: "9XyzIho", shortLink: "https://git.new/9XyzIho", url: "https://github.com/dubinc/dub", }; const steven = { name: "Steven Tey", email: "steven@dub.co", avatar: "https://avatar.vercel.sh/s.png?text=S", }; const tim = { name: "Tim Wilson", email: "tim@dub.co", avatar: "https://avatar.vercel.sh/t.png?text=T", }; const kiran = { name: "Kiran Kuriya", email: "kiran@dub.co", avatar: "https://avatar.vercel.sh/k.png?text=K", }; export const EXAMPLE_EVENTS_DATA = { clicks: [ { event: "click", timestamp: new Date().toISOString(), click: { id: "1", country: "US", city: "San Francisco", region: "US-CA", continent: "NA", ...common, }, link: dubLink, }, { event: "click", timestamp: new Date(Date.now() - 3 * 60 * 1000).toISOString(), click: { id: "2", country: "US", city: "New York", region: "US-NY", continent: "NA", ...common, }, link: dubLink, }, { event: "click", timestamp: new Date(Date.now() - 7 * 60 * 1000).toISOString(), click: { id: "3", country: "US", city: "Pittsburgh", region: "US-PA", continent: "NA", ...common, }, link: githubLink, }, ], leads: [ { event: "lead", timestamp: new Date().toISOString(), eventId: "YbL8RwLTRRCxQz5H", eventName: "Sign up", click: { id: "1", country: "US", city: "San Francisco", region: "US-CA", continent: "NA", ...common, }, link: dubLink, customer: steven, }, { event: "lead", timestamp: new Date(Date.now() - 3 * 60 * 1000).toISOString(), eventId: "YbL8RwLTRRCxQz5H", eventName: "Sign up", click: { id: "1", country: "IN", city: "Kerala", region: "IN-KL", continent: "AS", ...common, }, link: dubLink, customer: kiran, }, { event: "lead", timestamp: new Date(Date.now() - 7 * 60 * 1000).toISOString(), eventId: "YbL8RwLTRRCxQz5H", eventName: "Sign up", click: { id: "3", country: "US", city: "Pittsburgh", region: "US-PA", continent: "NA", ...common, }, link: githubLink, customer: tim, }, ], sales: [ { event: "sale", timestamp: new Date().toISOString(), eventId: "Nffk2cwShKu5lQ7E", eventName: "Purchase", sale: { amount: 49_90, paymentProcessor: "stripe", invoiceId: "123456", }, click: { id: "1", country: "US", city: "San Francisco", region: "US-CA", continent: "NA", ...common, }, link: dubLink, customer: steven, }, { event: "sale", timestamp: new Date(Date.now() - 3 * 60 * 1000).toISOString(), eventId: "Nffk2cwShKu5lQ7E", eventName: "Purchase", sale: { amount: 79_90, paymentProcessor: "stripe", invoiceId: "123456", }, click: { id: "2", country: "US", city: "Pittsburgh", region: "US-PA", continent: "NA", ...common, }, link: dubLink, customer: tim, }, { event: "sale", timestamp: new Date(Date.now() - 7 * 60 * 1000).toISOString(), eventId: "Nffk2cwShKu5lQ7E", eventName: "Purchase", sale: { amount: 99_90, paymentProcessor: "stripe", invoiceId: "123456", }, click: { id: "3", country: "IN", city: "Kerala", region: "IN-KL", continent: "AS", ...common, }, link: dubLink, customer: kiran, }, ], }; ================================================ FILE: apps/web/ui/analytics/events/index.tsx ================================================ "use client"; import useWorkspace from "@/lib/swr/use-workspace"; import EmptyState from "@/ui/shared/empty-state"; import { Menu3 } from "@dub/ui/icons"; import { cn } from "@dub/utils"; import { useContext } from "react"; import AnalyticsProvider, { AnalyticsContext } from "../analytics-provider"; import { AnalyticsToggle } from "../toggle"; import EventsTable from "./events-table"; import EventsTabs from "./events-tabs"; export default function AnalyticsEvents({ staticDomain, staticUrl, adminPage, }: { staticDomain?: string; staticUrl?: string; adminPage?: boolean; }) { return (
{({ dashboardProps }) => (
)}
); } function EventsTableContainer() { const { selectedTab } = useContext(AnalyticsContext); const { plan, slug } = useWorkspace(); const requiresUpgrade = plan === "free" || plan === "pro"; return ( } /> ); } ================================================ FILE: apps/web/ui/analytics/events/metadata-viewer.tsx ================================================ import { Button, Tooltip, useCopyToClipboard } from "@dub/ui"; import { cn, pluralize, truncate } from "@dub/utils"; import { Check, Copy } from "lucide-react"; import { Fragment } from "react"; // Display the event metadata export function MetadataViewer({ metadata, previewItems = 2, }: { metadata: Record; previewItems?: number; }) { const [copied, copyToClipboard] = useCopyToClipboard(); const displayEntries = Object.entries(metadata) .map(([key, value]) => { if (typeof value === "object" && value !== null) { // Only show nested properties if the parent object has exactly one property if (Object.keys(metadata).length === 1) { const nestedEntries = Object.entries(value).map( ([nestedKey, nestedValue]) => { const displayValue = typeof nestedValue === "object" && nestedValue !== null ? truncate(JSON.stringify(nestedValue), 20) : truncate(String(nestedValue), 20); return `${key}.${nestedKey}: ${displayValue}`; }, ); // else show the parent object properties return nestedEntries; } return [`${key}: ${truncate(JSON.stringify(value), 20)}`]; } return [`${key}: ${truncate(String(value), 20)}`]; }) .flat(); const hasMoreItems = displayEntries.length > previewItems; const visibleEntries = hasMoreItems ? displayEntries.slice(0, previewItems) : displayEntries; return (
{visibleEntries.map((entry, i) => ( {entry} ))}
                  {JSON.stringify(metadata, null, 2)}
                
} className="h-9" text={copied ? "Copied metadata" : "Copy metadata"} onClick={() => copyToClipboard(JSON.stringify(metadata, null, 2))} />
} align="start" >
); } ================================================ FILE: apps/web/ui/analytics/events/row-menu-button.tsx ================================================ import { Button, Icon, Popover, useCopyToClipboard } from "@dub/ui"; import { Copy, Dots } from "@dub/ui/icons"; import { cn } from "@dub/utils"; import { Row } from "@tanstack/react-table"; import { Command } from "cmdk"; import { useState } from "react"; import { toast } from "sonner"; import { EventDatum } from "./events-table"; export function RowMenuButton({ row }: { row: Row }) { const [isOpen, setIsOpen] = useState(false); const [, copyToClipboard] = useCopyToClipboard(); return ( {"eventId" in row.original && ( { if (!("eventId" in row.original)) return; const eventId = row.original.eventId as string; toast.promise(copyToClipboard(eventId), { success: "Copied to clipboard", }); setIsOpen(false); }} /> )} { const clickId = row.original.click_id as string; toast.promise(copyToClipboard(clickId), { success: "Copied to clipboard", }); setIsOpen(false); }} /> } align="end" >
)}
); } function UpgradeTooltip({ rangeLabel, plan, }: { rangeLabel: string; plan?: string; }) { const { slug } = useWorkspace(); const isAllTime = rangeLabel === "All Time"; return ( ); } ================================================ FILE: apps/web/ui/analytics/top-links.tsx ================================================ import { AnalyticsGroupByOptions } from "@/lib/analytics/types"; import { useWorkspacePreferences } from "@/lib/swr/use-workspace-preferences"; import { LinkLogo, useRouterStuff } from "@dub/ui"; import { Globe, Hyperlink } from "@dub/ui/icons"; import { getApexDomain } from "@dub/utils"; import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { FolderIcon } from "../folders/folder-icon"; import TagBadge from "../links/tag-badge"; import { AnalyticsCard } from "./analytics-card"; import { AnalyticsLoadingSpinner } from "./analytics-loading-spinner"; import { AnalyticsContext } from "./analytics-provider"; import { BarList } from "./bar-list"; import { useAnalyticsFilterOption } from "./utils"; type TabId = "links" | "urls"; type LinksSubtab = "links" | "folders" | "tags"; type UrlsSubtab = "base_urls" | "full_urls"; type Subtab = LinksSubtab | UrlsSubtab; const TAB_CONFIG: Record< TabId, { subtabs: Subtab[]; defaultSubtab: Subtab; getSubtabLabel: (subtab: Subtab) => string; getGroupBy: (subtab: Subtab) => { groupBy: AnalyticsGroupByOptions; }; } > = { links: { subtabs: ["links", "folders", "tags"], defaultSubtab: "links", getSubtabLabel: (subtab) => { if (subtab === "links") return "Links"; if (subtab === "folders") return "Folders"; return "Tags"; }, getGroupBy: (subtab) => { if (subtab === "links") return { groupBy: "top_links" }; if (subtab === "folders") return { groupBy: "top_folders" }; return { groupBy: "top_link_tags" }; }, }, urls: { subtabs: ["base_urls", "full_urls"], defaultSubtab: "base_urls", getSubtabLabel: (subtab) => subtab === "full_urls" ? "Full URLs" : "Base URLs", getGroupBy: (subtab) => ({ groupBy: subtab === "full_urls" ? "top_urls" : "top_base_urls", }), }, }; export function TopLinks() { const { queryParams, searchParams } = useRouterStuff(); const { selectedTab, saleUnit, adminPage, partnerPage, dashboardProps } = useContext(AnalyticsContext); const dataKey = selectedTab === "sales" ? saleUnit : "count"; const [tab, setTab] = useState("links"); const [subtab, setSubtab] = useState(TAB_CONFIG[tab].defaultSubtab); const [selectedItems, setSelectedItems] = useState([]); // Reset subtab when tab changes to ensure it's valid for the new tab const handleTabChange = (newTab: TabId) => { setTab(newTab); setSubtab(TAB_CONFIG[newTab].defaultSubtab); }; useEffect(() => { setSelectedItems([]); }, [tab, subtab]); const filterParamKey = useMemo(() => { if (subtab === "links") return "linkId"; if (subtab === "base_urls") return "url"; if (subtab === "folders") return "folderId"; if (subtab === "tags") return "tagId"; return null; }, [subtab]); const onToggleFilter = useCallback((val: string) => { setSelectedItems((prev) => prev.includes(val) ? prev.filter((v) => v !== val) : [...prev, val], ); }, []); const onApplyFilterValues = useCallback( (values: string[]) => { if (!filterParamKey) return; if (values.length === 0) { queryParams({ del: filterParamKey }); } else { queryParams({ set: { [filterParamKey]: values.join(",") } }); } setSelectedItems([]); }, [filterParamKey, queryParams], ); const isFilterActive = useMemo( () => (filterParamKey ? searchParams.has(filterParamKey) : false), [filterParamKey, searchParams], ); const activeFilterValues = useMemo( () => filterParamKey ? searchParams.get(filterParamKey)?.split(",") ?? [] : [], [filterParamKey, searchParams], ); const onClearFilter = useCallback(() => { setSelectedItems([]); if (isFilterActive && filterParamKey) queryParams({ del: filterParamKey }); }, [filterParamKey, queryParams, isFilterActive]); const groupByParams = useMemo( () => TAB_CONFIG[tab].getGroupBy(subtab), [tab, subtab], ); const { data } = useAnalyticsFilterOption(groupByParams); const { data: allData } = useAnalyticsFilterOption(groupByParams, { omitGroupByFilterKey: true, }); const [persisted] = useWorkspacePreferences("linksDisplay"); const getItemTitle = useCallback( (d: Record) => { if (tab === "urls") { return d.url || "Unknown"; } // For links tab with different subtabs if (subtab === "folders") { return d.folder?.name || "Unknown"; } if (subtab === "tags") { return d.tag?.name || "Unknown"; } // For links subtab const displayProperties = persisted?.displayProperties; if (displayProperties?.includes("title") && d.title) { return d.title; } return d.shortLink || "Unknown"; }, [persisted, tab, subtab], ); const mapItem = useCallback( (d: Record) => { const isLinksTab = tab === "links"; const isUrlsTab = tab === "urls"; const isFoldersSubtab = isLinksTab && subtab === "folders"; const isTagsSubtab = isLinksTab && subtab === "tags"; const isLinksSubtab = isLinksTab && subtab === "links"; let icon; if (isFoldersSubtab) { icon = d.folder ? ( ) : null; } else if (isTagsSubtab) { icon = d.tag ? ( ) : null; } else { icon = ( ); } let filterValue: string | undefined; if (isLinksSubtab) filterValue = d.id; else if (isUrlsTab && subtab === "base_urls") filterValue = d.url; else if (isFoldersSubtab) filterValue = d.folderId; else if (isTagsSubtab) filterValue = d.tagId; return { icon, title: getItemTitle(d), filterValue, value: d[dataKey] || 0, ...(isLinksSubtab && { linkData: d }), }; }, [tab, subtab, dataKey, getItemTitle], ); const subTabProps = useMemo(() => { if (adminPage || partnerPage || dashboardProps) return {}; const config = TAB_CONFIG[tab]; return { subTabs: config.subtabs.map((s) => ({ id: s, label: config.getSubtabLabel(s), })), selectedSubTabId: subtab, onSelectSubTab: setSubtab, }; }, [tab, subtab, adminPage, partnerPage]); return ( {({ limit, setShowModal }) => data ? ( data.length > 0 ? ( b.value - a.value) || []} allData={allData?.map(mapItem).sort((a, b) => b.value - a.value)} unit={selectedTab} maxValue={Math.max(...data.map((d) => d[dataKey] ?? 0))} barBackground="bg-orange-100" hoverBackground="hover:bg-gradient-to-r hover:from-orange-50 hover:to-transparent hover:border-orange-500" filterSelectedBackground="bg-orange-500" filterSelectedHoverBackground="hover:bg-orange-600" filterHoverClass="bg-white border border-orange-200" setShowModal={setShowModal} selectedFilterValues={selectedItems} activeFilterValues={activeFilterValues} onToggleFilter={onToggleFilter} onClearFilter={filterParamKey ? onClearFilter : undefined} onClearSelection={() => setSelectedItems([])} onApplyFilterValues={ filterParamKey ? onApplyFilterValues : undefined } {...(limit && { limit })} /> ) : (

No data available

) ) : (
) }
); } ================================================ FILE: apps/web/ui/analytics/trigger-display.tsx ================================================ import { CursorRays, MarketingTarget, Page2, QRCode } from "@dub/ui"; export const TRIGGER_DISPLAY = { qr: { title: "QR scan", icon: QRCode, }, link: { title: "Link click", icon: CursorRays, }, pageview: { title: "Page View", icon: Page2, }, deeplink: { title: "Deep Link", icon: MarketingTarget, }, }; ================================================ FILE: apps/web/ui/analytics/use-analytics-connected-status.ts ================================================ import { useWorkspaceStore } from "@/lib/swr/use-workspace-store"; export function useAnalyticsConnectedStatus() { const [connectionSetupComplete] = useWorkspaceStore( "analyticsSettingsConnectionSetupComplete", ); const [leadTrackingSetupComplete] = useWorkspaceStore( "analyticsSettingsLeadTrackingSetupComplete", ); const [saleTrackingSetupComplete] = useWorkspaceStore( "analyticsSettingsSaleTrackingSetupComplete", ); const all = [ connectionSetupComplete, leadTrackingSetupComplete, saleTrackingSetupComplete, ]; return { isConnected: all.some(Boolean), isFullyConnected: all.every(Boolean), }; } ================================================ FILE: apps/web/ui/analytics/use-analytics-filters.tsx ================================================ import { generateFilters } from "@/lib/ai/generate-filters"; import { VALID_ANALYTICS_FILTERS } from "@/lib/analytics/constants"; import useCustomer from "@/lib/swr/use-customer"; import usePartner from "@/lib/swr/use-partner"; import usePartnerCustomer from "@/lib/swr/use-partner-customer"; import { CustomerAvatar } from "@/ui/customers/customer-avatar"; import { PartnerAvatar } from "@/ui/partners/partner-avatar"; import { readStreamableValue } from "@ai-sdk/rsc"; import { BlurImage, Filter, LinkLogo, Sliders, useRouterStuff, UTM_PARAMETERS, } from "@dub/ui"; import { Calendar6, Cube, CursorRays, FlagWavy, Folder, Globe2, Hyperlink, LinkBroken, LocationPin, Magic, MapPosition, MobilePhone, OfficeBuilding, QRCode, Receipt2, ReferredVia, Tag, User, UserPlus, Users, Users6, Window, } from "@dub/ui/icons"; import { capitalize, CONTINENTS, COUNTRIES, currencyFormatter, getApexDomain, GOOGLE_FAVICON_URL, linkConstructor, nFormatter, parseFilterValue, REGIONS, type FilterOperator, type ParsedFilter, } from "@dub/utils"; import { useParams } from "next/navigation"; import { ComponentProps, ContextType, useCallback, useContext, useMemo, useState, } from "react"; import { FolderIcon } from "../folders/folder-icon"; import { LinkIcon } from "../links/link-icon"; import TagBadge from "../links/tag-badge"; import { GroupColorCircle } from "../partners/groups/group-color-circle"; import { AnalyticsContext, AnalyticsDashboardProps, } from "./analytics-provider"; import { ContinentIcon } from "./continent-icon"; import { DeviceIcon } from "./device-icon"; import { ReferrerIcon } from "./referrer-icon"; import { TRIGGER_DISPLAY } from "./trigger-display"; import { useAnalyticsFilterOption } from "./utils"; export function useAnalyticsFilters({ partnerPage, dashboardProps, context, programPage = false, }: { partnerPage?: boolean; dashboardProps?: AnalyticsDashboardProps; context?: Pick< ContextType, | "baseApiPath" | "queryString" | "selectedTab" | "saleUnit" | "requiresUpgrade" >; programPage?: boolean; } = {}) { const { selectedTab, saleUnit } = context ?? useContext(AnalyticsContext); const { slug } = useParams(); const { queryParams, searchParamsObj } = useRouterStuff(); const selectedCustomerId = searchParamsObj.customerId; const { data: selectedCustomerWorkspace } = useCustomer({ customerId: selectedCustomerId, }); const { data: selectedCustomerPartner } = usePartnerCustomer({ customerId: selectedCustomerId, }); const selectedCustomer = selectedCustomerPartner || selectedCustomerWorkspace; const selectedPartnerId = searchParamsObj.partnerId; const { partner: selectedPartner } = usePartner({ partnerId: selectedPartnerId, }); const [requestedFilters, setRequestedFilters] = useState([]); const parseFilterParam = useCallback( (value: string): ParsedFilter | undefined => { return parseFilterValue(value); }, [], ); const activeFilters = useMemo(() => { const { domain, key, root, ...params } = searchParamsObj; // Handle special cases first const filters: Array<{ key: string; operator: FilterOperator; values: any[]; }> = [ // Legacy: show one link chip when domain+key are present (no linkId) ...(domain && key && !params.linkId ? [ { key: "linkId", operator: "IS" as FilterOperator, values: [linkConstructor({ domain, key, pretty: true })], }, ] : []), // Handle customerId special case ...(selectedCustomer ? [ { key: "customerId", operator: "IS" as FilterOperator, values: [ selectedCustomer.email || selectedCustomer["name"] || selectedCustomer["externalId"], ], }, ] : []), ]; // Handle all filters dynamically (including domain, tagId, folderId, root) VALID_ANALYTICS_FILTERS.forEach((filter) => { // Skip special cases we handled above if (["key", "customerId"].includes(filter)) return; // Also skip date range filters and qr if (["interval", "start", "end", "qr"].includes(filter)) return; // Skip domain if we're showing a specific link (domain + key) without linkId if (filter === "domain" && domain && key && !params.linkId) return; const value = params[filter] || (filter === "domain" ? domain : filter === "root" ? root : undefined); if (value) { const parsed = parseFilterParam(value); if (parsed) { filters.push({ key: filter, operator: parsed.operator, values: parsed.values, }); } } }); return filters; }, [ searchParamsObj, partnerPage, selectedCustomerId, selectedCustomer, parseFilterParam, ]); const isRequested = useCallback( (key: string) => requestedFilters.includes(key) || activeFilters.some((af) => af.key === key), [requestedFilters, activeFilters], ); const { data: links } = useAnalyticsFilterOption("top_links", { disabled: !isRequested("linkId"), omitGroupByFilterKey: true, context, }); const { data: folders } = useAnalyticsFilterOption("top_folders", { disabled: !isRequested("folderId"), omitGroupByFilterKey: true, context, }); const { data: linkTags } = useAnalyticsFilterOption("top_link_tags", { disabled: !isRequested("tagId"), omitGroupByFilterKey: true, context, }); const { data: domains } = useAnalyticsFilterOption("top_domains", { disabled: !isRequested("domain"), omitGroupByFilterKey: true, context, }); const { data: partners } = useAnalyticsFilterOption("top_partners", { disabled: !isRequested("partnerId"), omitGroupByFilterKey: true, context, }); const { data: groups } = useAnalyticsFilterOption("top_groups", { disabled: !isRequested("groupId"), omitGroupByFilterKey: true, context, }); const { data: countries } = useAnalyticsFilterOption("countries", { disabled: !isRequested("country"), omitGroupByFilterKey: true, context, }); const { data: regions } = useAnalyticsFilterOption("regions", { disabled: !isRequested("region"), omitGroupByFilterKey: true, context, }); const { data: cities } = useAnalyticsFilterOption("cities", { disabled: !isRequested("city"), omitGroupByFilterKey: true, context, }); const { data: continents } = useAnalyticsFilterOption("continents", { disabled: !isRequested("continent"), omitGroupByFilterKey: true, context, }); const { data: devices } = useAnalyticsFilterOption("devices", { disabled: !isRequested("device"), omitGroupByFilterKey: true, context, }); const { data: browsers } = useAnalyticsFilterOption("browsers", { disabled: !isRequested("browser"), omitGroupByFilterKey: true, context, }); const { data: os } = useAnalyticsFilterOption("os", { disabled: !isRequested("os"), omitGroupByFilterKey: true, context, }); const { data: triggers } = useAnalyticsFilterOption("triggers", { disabled: !isRequested("trigger"), omitGroupByFilterKey: true, context, }); const { data: referers } = useAnalyticsFilterOption("referers", { disabled: !isRequested("referer"), omitGroupByFilterKey: true, context, }); const { data: refererUrls } = useAnalyticsFilterOption("referer_urls", { disabled: !isRequested("refererUrl"), omitGroupByFilterKey: true, context, }); const { data: baseUrls } = useAnalyticsFilterOption("top_base_urls", { disabled: !isRequested("url"), omitGroupByFilterKey: true, context, }); const { data: utmSources } = useAnalyticsFilterOption("utm_sources", { disabled: !isRequested("utm_source"), omitGroupByFilterKey: true, context, }); const { data: utmMediums } = useAnalyticsFilterOption("utm_mediums", { disabled: !isRequested("utm_medium"), omitGroupByFilterKey: true, context, }); const { data: utmCampaigns } = useAnalyticsFilterOption("utm_campaigns", { disabled: !isRequested("utm_campaign"), omitGroupByFilterKey: true, context, }); const { data: utmTerms } = useAnalyticsFilterOption("utm_terms", { disabled: !isRequested("utm_term"), omitGroupByFilterKey: true, context, }); const { data: utmContents } = useAnalyticsFilterOption("utm_contents", { disabled: !isRequested("utm_content"), omitGroupByFilterKey: true, context, }); const utmData = { utm_source: utmSources, utm_medium: utmMediums, utm_campaign: utmCampaigns, utm_term: utmTerms, utm_content: utmContents, }; const getFilterOptionTotal = useCallback( ({ count, saleAmount }: { count?: number; saleAmount?: number }) => { return selectedTab === "sales" && saleUnit === "saleAmount" && saleAmount ? currencyFormatter(saleAmount) : nFormatter(count, { full: true }); }, [selectedTab, saleUnit], ); // Some suggestions will only appear if previously requested (see isRequested above) const aiFilterSuggestions = useMemo( () => [ { value: "Mobile users, US only", icon: MobilePhone, }, { value: "Tokyo, Chrome users", icon: OfficeBuilding, }, { value: "Safari, Singapore, last month", icon: FlagWavy, }, { value: "QR scans last quarter", icon: QRCode, }, ], [dashboardProps, partnerPage], ); const [streaming, setStreaming] = useState(false); const LinkFilterItem = { key: "linkId", icon: Hyperlink, label: "Link", getOptionIcon: (_value, props) => { const data = props.option?.data; const url = data?.url; return ; }, options: links?.map(({ id, domain, key, url, ...rest }) => ({ value: id, label: linkConstructor({ domain, key, pretty: true }), right: getFilterOptionTotal(rest), data: { url, domain, key }, })) ?? null, }; const DomainFilterItem = { key: "domain", icon: Globe2, label: "Domain", getOptionIcon: (value) => ( ), options: domains?.map(({ domain, ...rest }) => ({ value: domain, label: domain, right: getFilterOptionTotal(rest), })) ?? null, }; const SaleTypeFilterItem = { key: "saleType", icon: Receipt2, label: "Sale type", separatorAfter: true, hideMultipleIcons: true, singleSelect: true, options: [ { value: "new", label: "New", icon: UserPlus, }, { value: "recurring", label: "Recurring", icon: Calendar6, }, ], }; const filters: ComponentProps["filters"] = useMemo( () => [ { key: "ai", icon: Magic, label: "Ask AI", singleSelect: true, separatorAfter: true, options: aiFilterSuggestions?.map(({ icon, value }) => ({ value, label: value, icon, })) ?? null, }, ...(dashboardProps ? dashboardProps.key ? [] : [DomainFilterItem, LinkFilterItem] : programPage ? [ { key: "groupId", icon: Users6, label: "Group", getOptionIcon: (_value, props) => { const group = props.option?.data?.group; return group ? : null; }, options: groups?.map(({ group, ...rest }) => ({ value: group.id, icon: , label: group.name, data: { group }, right: getFilterOptionTotal(rest), })) ?? null, }, { key: "partnerId", icon: Users, label: "Partner", options: partners?.map(({ partner, ...rest }) => { return { value: partner.id, label: partner.name, icon: ( ), right: getFilterOptionTotal(rest), }; }) ?? null, }, SaleTypeFilterItem, ] : partnerPage ? [LinkFilterItem, SaleTypeFilterItem] : [ { key: "folderId", icon: Folder, label: "Folder", getOptionIcon: (_value, props) => { const folder = props.option?.data?.folder; return folder ? ( ) : null; }, options: folders?.map(({ folder, ...rest }) => ({ value: folder.id, icon: ( ), label: folder.name, data: { folder }, right: getFilterOptionTotal(rest), })) ?? null, }, { key: "tagId", icon: Tag, label: "Tag", getOptionIcon: (_value, props) => { const tagColor = props.option?.data?.color; return tagColor ? ( ) : null; }, options: linkTags?.map(({ tag: { id, name, color }, ...rest }) => ({ value: id, icon: ( ), label: name, data: { color }, right: getFilterOptionTotal(rest), })) ?? null, }, DomainFilterItem, LinkFilterItem, { key: "root", icon: Sliders, label: "Link type", hideMultipleIcons: true, singleSelect: true, options: [ { value: "true", icon: Globe2, label: "Root domain link", }, { value: "false", icon: Hyperlink, label: "Regular short link", }, ], }, SaleTypeFilterItem, ]), { key: "country", icon: FlagWavy, label: "Country", labelPlural: "countries", getOptionIcon: (value) => { if (typeof value !== "string") return null; return ( {value} ); }, options: countries?.map(({ country, ...rest }) => ({ value: country, label: COUNTRIES[country], right: getFilterOptionTotal(rest), })) ?? null, }, { key: "city", icon: OfficeBuilding, label: "City", labelPlural: "cities", options: cities?.map(({ city, country, ...rest }) => ({ value: city, label: city, icon: ( {country} ), right: getFilterOptionTotal(rest), })) ?? null, }, { key: "region", icon: LocationPin, label: "Region", options: regions?.map(({ region, country, ...rest }) => ({ value: region, label: REGIONS[region] || region.split("-")[1], icon: ( {country} ), right: getFilterOptionTotal(rest), })) ?? null, }, { key: "continent", icon: MapPosition, label: "Continent", getOptionIcon: (value) => { if (typeof value !== "string") return null; return ( ); }, getOptionLabel: (value) => { if (typeof value !== "string") return String(value); return CONTINENTS[value] || value; }, options: continents?.map(({ continent, ...rest }) => ({ value: continent, label: CONTINENTS[continent], right: getFilterOptionTotal(rest), })) ?? null, }, { key: "device", icon: MobilePhone, label: "Device", hideMultipleIcons: true, getOptionIcon: (value) => { if (typeof value !== "string") return null; return ( ); }, options: devices?.map(({ device, ...rest }) => ({ value: device, label: device, right: getFilterOptionTotal(rest), })) ?? null, }, { key: "browser", icon: Window, label: "Browser", getOptionIcon: (value) => { if (typeof value !== "string") return null; return ( ); }, options: browsers?.map(({ browser, ...rest }) => ({ value: browser, label: browser, right: getFilterOptionTotal(rest), })) ?? null, }, { key: "os", icon: Cube, label: "OS", labelPlural: "OS", hideMultipleIcons: true, getOptionIcon: (value) => { if (typeof value !== "string") return null; return ; }, options: os?.map(({ os, ...rest }) => ({ value: os, label: os, right: getFilterOptionTotal(rest), })) ?? null, }, ...(programPage ? [] : [ { key: "trigger", icon: CursorRays, label: "Trigger", hideMultipleIcons: true, options: triggers?.map(({ trigger, ...rest }) => { const { title, icon } = TRIGGER_DISPLAY[trigger]; return { value: trigger, label: title, icon, right: getFilterOptionTotal(rest), }; }) ?? null, separatorAfter: true, }, ]), { key: "referer", icon: ReferredVia, label: "Referrer", getOptionIcon: (value, _props) => { if (typeof value !== "string") return null; return ; }, options: referers?.map(({ referer, ...rest }) => ({ value: referer, label: referer, right: getFilterOptionTotal(rest), })) ?? null, }, ...(programPage ? [] : [ { key: "refererUrl", icon: ReferredVia, label: "Referrer URL", getOptionIcon: (value, props) => { if (typeof value !== "string") return null; return ; }, options: refererUrls?.map(({ refererUrl, ...rest }) => ({ value: refererUrl, label: refererUrl, right: getFilterOptionTotal(rest), })) ?? null, }, { key: "url", icon: LinkBroken, label: "Destination URL", getOptionIcon: (_, props) => ( ), options: baseUrls?.map(({ url, ...rest }) => ({ value: url, label: url.replace(/^https?:\/\//, "").replace(/\/$/, ""), right: getFilterOptionTotal(rest), })) ?? null, }, ...UTM_PARAMETERS.filter(({ key }) => key !== "ref").map( ({ key, label, icon: Icon }) => ({ key, icon: Icon, label: `UTM ${label}`, getOptionIcon: (value) => { if (typeof value !== "string") return null; return ; }, options: utmData[key]?.map((dt) => ({ value: dt[key], label: dt[key], right: nFormatter(dt.count, { full: true }), })) ?? null, }), ), ]), // additional fields that are hidden in filter dropdown { key: "customerId", icon: User, label: "Customer", singleSelect: true, hideMultipleIcons: true, hideInFilterDropdown: true, getOptionIcon: () => { return selectedCustomer ? ( ) : null; }, getOptionPermalink: () => { return programPage ? `/${slug}/program/customers/${selectedCustomerId}` : slug ? `/${slug}/customers/${selectedCustomerId}` : null; }, options: [], }, { key: "partnerId", icon: Users6, label: "Partner", hideInFilterDropdown: true, getOptionIcon: () => { return selectedPartner ? ( ) : null; }, getOptionLabel: () => { return selectedPartner?.name ?? selectedPartnerId; }, options: [], }, ], [ dashboardProps, partnerPage, domains, links, linkTags, folders, groups, selectedCustomerId, countries, cities, devices, browsers, os, referers, refererUrls, baseUrls, utmData, searchParamsObj.tagId, searchParamsObj.domain, ], ); const onSelect = useCallback( async (key, value) => { if (Array.isArray(value)) { if (value.length === 0) { queryParams({ del: key, scroll: false, }); } else { const currentParam = searchParamsObj[key]; const isNegated = currentParam?.startsWith("-") ?? false; const newParam = isNegated ? `-${value.join(",")}` : value.join(","); queryParams({ set: { [key]: newParam }, del: "page", scroll: false, }); } return; } if (key === "ai") { setStreaming(true); const prompt = value.replace("Ask AI ", ""); const { object } = await generateFilters(prompt); for await (const partialObject of readStreamableValue(object)) { if (partialObject) { queryParams({ set: Object.fromEntries( Object.entries(partialObject).map(([key, value]) => [ key, // Convert Dates to ISO strings value instanceof Date ? value.toISOString() : String(value), ]), ), }); } } setStreaming(false); } else { const currentParam = searchParamsObj[key]; const filterDef = filters.find((f) => f.key === key); const isSingleSelect = filterDef?.singleSelect; if (!currentParam || isSingleSelect) { queryParams({ set: { [key]: value }, del: "page", scroll: false, }); } else { const parsed = parseFilterParam(currentParam); if (parsed && !parsed.values.includes(value)) { const newValues = [...parsed.values, value]; const newParam = parsed.operator.includes("NOT") ? `-${newValues.join(",")}` : newValues.join(","); queryParams({ set: { [key]: newParam }, del: "page", scroll: false, }); } } } }, [queryParams, activeFilters, searchParamsObj, parseFilterParam, filters], ); const onRemove = useCallback( (key, value) => { const currentParam = searchParamsObj[key]; if (!currentParam) return; const parsed = parseFilterParam(currentParam); if (!parsed) { queryParams({ del: key, scroll: false }); return; } const newValues = parsed.values.filter((v) => v !== value); if (newValues.length === 0) { queryParams({ del: key, scroll: false }); } else { const newParam = parsed.operator.includes("NOT") ? `-${newValues.join(",")}` : newValues.join(","); queryParams({ set: { [key]: newParam }, scroll: false, }); } }, [queryParams, searchParamsObj, parseFilterParam], ); const onRemoveAll = useCallback( () => queryParams({ // Reset all filters except for date range del: VALID_ANALYTICS_FILTERS.concat(["page"]).filter( (f) => !["interval", "start", "end"].includes(f), ), scroll: false, }), [queryParams], ); const onOpenFilter = useCallback((key) => { setRequestedFilters((rf) => (rf.includes(key) ? rf : [...rf, key])); }, []); const onToggleOperator = useCallback( (key) => { const currentParam = searchParamsObj[key]; if (!currentParam) return; const isNegated = currentParam.startsWith("-"); const cleanValue = isNegated ? currentParam.slice(1) : currentParam; const newParam = isNegated ? cleanValue : `-${cleanValue}`; queryParams({ set: { [key]: newParam }, del: "page", scroll: false, }); }, [searchParamsObj, queryParams], ); const onRemoveFilter = useCallback( (key) => queryParams({ del: key, scroll: false }), [queryParams], ); const activeFiltersWithStreaming = useMemo(() => { return [ ...activeFilters, ...(streaming && !activeFilters.length ? Array.from({ length: 2 }, (_, i) => i).map((i) => ({ key: "loader", values: [String(i)], operator: "IS" as const, })) : []), ]; }, [activeFilters, streaming]); return { filters, activeFilters, onSelect, onRemove, onRemoveFilter, onRemoveAll, onOpenFilter, onToggleOperator, streaming, activeFiltersWithStreaming, }; } ================================================ FILE: apps/web/ui/analytics/use-analytics-query.tsx ================================================ import { DUB_LINKS_ANALYTICS_INTERVAL, EVENT_TYPES, VALID_ANALYTICS_FILTERS, } from "@/lib/analytics/constants"; import { EventType } from "@/lib/analytics/types"; import useWorkspace from "@/lib/swr/use-workspace"; import { endOfDay, startOfDay, subDays } from "date-fns"; import { useSearchParams } from "next/navigation"; import { useMemo } from "react"; export function useAnalyticsQuery({ defaultEvent = "clicks", domain: domainParam, defaultKey, defaultFolderId, defaultInterval = DUB_LINKS_ANALYTICS_INTERVAL, }: { defaultEvent?: EventType; domain?: string; defaultKey?: string; defaultFolderId?: string; defaultInterval?: string; } = {}) { const searchParams = useSearchParams(); const { id: workspaceId } = useWorkspace(); const domain = domainParam ?? searchParams?.get("domain"); // key can be a query param (stats pages in app) or passed as a staticKey (shared analytics dashboards) const key = searchParams?.get("key") || defaultKey; const folderId = searchParams?.get("folderId") ?? defaultFolderId ?? undefined; const tagId = searchParams?.get("tagId") ?? undefined; const customerId = searchParams?.get("customerId") ?? undefined; // Default to last 24 hours const { start, end } = useMemo(() => { const hasRange = searchParams?.has("start") && searchParams?.has("end"); return { start: hasRange ? startOfDay( new Date(searchParams?.get("start") || subDays(new Date(), 1)), ) : undefined, end: hasRange ? endOfDay(new Date(searchParams?.get("end") || new Date())) : undefined, }; }, [searchParams?.get("start"), searchParams?.get("end")]); // Only set interval if start and end are not provided const interval = start || end ? undefined : searchParams?.get("interval") ?? defaultInterval; const root = searchParams.get("root"); const selectedTab: EventType = useMemo(() => { const event = searchParams.get("event"); return EVENT_TYPES.find((t) => t === event) ?? defaultEvent; }, [searchParams.get("event"), defaultEvent]); const queryString = useMemo(() => { const availableFilterParams = VALID_ANALYTICS_FILTERS.reduce( (acc, filter) => ({ ...acc, ...(searchParams?.get(filter) && { [filter]: searchParams.get(filter), }), }), {}, ); return new URLSearchParams({ ...availableFilterParams, event: selectedTab, ...(workspaceId && { workspaceId }), ...(domain && { domain }), ...(key && { key }), ...(start && end && { start: start.toISOString(), end: end.toISOString() }), ...(interval && { interval }), ...(folderId && { folderId }), ...(tagId && { tagId }), ...(customerId && { customerId }), ...(root && { root: root.toString() }), timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, }).toString(); }, [ searchParams, workspaceId, domain, key, start, end, interval, folderId, tagId, root, selectedTab, customerId, ]); return { queryString, domain, key, start, end, interval, folderId, tagId, root, selectedTab, customerId, }; } ================================================ FILE: apps/web/ui/analytics/utils.ts ================================================ import { SINGULAR_ANALYTICS_ENDPOINTS } from "@/lib/analytics/constants"; import { AnalyticsGroupByOptions } from "@/lib/analytics/types"; import { editQueryString } from "@/lib/analytics/utils"; import { fetcher } from "@dub/utils"; import { ContextType, useContext } from "react"; import useSWR from "swr"; import { AnalyticsContext } from "./analytics-provider"; type AnalyticsFilterResult = { data: | ({ count?: number; saleAmount?: number } & Record)[] | null; loading: boolean; }; /** * Fetches event counts grouped by the specified filter * * @param groupByOrParams Either a groupBy option or a query parameter object including groupBy * @param options Additional options */ export function useAnalyticsFilterOption( groupByOrParams: | AnalyticsGroupByOptions | ({ groupBy: AnalyticsGroupByOptions } & Record), options?: { disabled?: boolean; omitGroupByFilterKey?: boolean; // for Filter.Select and Filter.List, we need to show all options by default, so we need to omit the groupBy filter key context?: Pick< ContextType, "baseApiPath" | "queryString" | "selectedTab" | "requiresUpgrade" >; }, ): AnalyticsFilterResult { const { baseApiPath, queryString, selectedTab, requiresUpgrade } = options?.context ?? useContext(AnalyticsContext); const groupBy = typeof groupByOrParams === "string" ? groupByOrParams : groupByOrParams?.groupBy; // Extract additional params (like root) from the params object const additionalParams = typeof groupByOrParams === "object" && groupByOrParams !== null ? Object.fromEntries( Object.entries(groupByOrParams).filter(([key]) => key !== "groupBy"), ) : {}; const { data, isLoading } = useSWR[]>( !options?.disabled && `${baseApiPath}?${editQueryString( queryString, { ...(groupBy && { groupBy }), ...additionalParams, }, // if theres no groupBy or we're not omitting the groupBy filter, skip // else, we need to remove the filter for that groupBy param (() => { if (!groupBy || !options?.omitGroupByFilterKey) return undefined; return SINGULAR_ANALYTICS_ENDPOINTS[groupBy] ? SINGULAR_ANALYTICS_ENDPOINTS[groupBy] : undefined; })(), )}`, fetcher, { shouldRetryOnError: !requiresUpgrade, }, ); return { data: data?.map((d) => ({ ...d, count: d[selectedTab] as number | undefined, saleAmount: d.saleAmount as number | undefined, })) ?? null, loading: !data || isLoading, }; } ================================================ FILE: apps/web/ui/auth/auth-alternative-banner.tsx ================================================ import { DotsPattern } from "@dub/ui"; import Link from "next/link"; export function AuthAlternativeBanner({ text, cta, href, }: { text: string; cta: string; href: string; }) { return (

{text}

{cta}
); } ================================================ FILE: apps/web/ui/auth/auth-methods-separator.tsx ================================================ export function AuthMethodsSeparator() { return (
or
); } ================================================ FILE: apps/web/ui/auth/forgot-password-form.tsx ================================================ "use client"; import { requestPasswordResetAction } from "@/lib/actions/request-password-reset"; import { Button, Input, useMediaQuery } from "@dub/ui"; import { useAction } from "next-safe-action/hooks"; import { useRouter, useSearchParams } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; export const ForgotPasswordForm = () => { const router = useRouter(); const { isMobile } = useMediaQuery(); const searchParams = useSearchParams(); const [email, setEmail] = useState(searchParams.get("email") || ""); const { executeAsync, isPending } = useAction(requestPasswordResetAction, { onSuccess() { toast.success( "You will receive an email with instructions to reset your password.", ); router.push("/login"); }, onError({ error }) { toast.error(error.serverError); }, }); return (
{ e.preventDefault(); executeAsync({ email }); }} >
); }; ================================================ FILE: apps/web/ui/auth/login/email-sign-in.tsx ================================================ import { checkAccountExistsAction } from "@/lib/actions/check-account-exists"; import { Button, Input, useMediaQuery } from "@dub/ui"; import { cn } from "@dub/utils"; import { signIn } from "next-auth/react"; import { useAction } from "next-safe-action/hooks"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { useContext, useState } from "react"; import { toast } from "sonner"; import { errorCodes, LoginFormContext } from "./login-form"; export const EmailSignIn = ({ next }: { next?: string }) => { const router = useRouter(); const searchParams = useSearchParams(); const finalNext = next ?? searchParams?.get("next"); const { isMobile } = useMediaQuery(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const { showPasswordField, setShowPasswordField, setClickedMethod, authMethod, setAuthMethod, clickedMethod, setLastUsedAuthMethod, setShowSSOOption, } = useContext(LoginFormContext); const { executeAsync, isPending } = useAction(checkAccountExistsAction, { onError: ({ error }) => { toast.error(error.serverError); }, }); return ( <>
{ e.preventDefault(); // Check if the user can enter a password, and if so display the field if (!showPasswordField) { const result = await executeAsync({ email }); if (!result?.data) { return; } const { accountExists, hasPassword, requireSAML } = result.data; if (requireSAML) { setClickedMethod(undefined); toast.error( "Your organization requires authentication through your company's identity provider.", ); return; } if (accountExists && hasPassword) { setShowPasswordField(true); return; } if (!accountExists) { setClickedMethod(undefined); toast.error("No account found with that email address."); return; } } setClickedMethod("email"); const result = await executeAsync({ email }); if (!result?.data) { return; } const { accountExists, hasPassword } = result.data; if (!accountExists) { setClickedMethod(undefined); toast.error("No account found with that email address."); return; } const provider = password && hasPassword ? "credentials" : "email"; const response = await signIn(provider, { email, redirect: false, callbackUrl: finalNext || "/workspaces", ...(password && { password }), }); if (!response) { return; } if (!response.ok && response.error) { if (errorCodes[response.error]) { toast.error(errorCodes[response.error]); } else { toast.error(response.error); } setClickedMethod(undefined); return; } setLastUsedAuthMethod("email"); if (provider === "email") { toast.success("Email sent - check your inbox!"); setEmail(""); setClickedMethod(undefined); return; } if (provider === "credentials") { router.push(response?.url || finalNext || "/workspaces"); } }} className="flex flex-col gap-y-6" > {authMethod === "email" && ( )} {showPasswordField && ( )}
) : ( authProviders .filter( (provider) => provider.method !== authMethod && methods.includes(provider.method), ) .map((provider) => (
)) )}
); } ================================================ FILE: apps/web/ui/auth/login/sso-sign-in.tsx ================================================ "use client"; import { Button, InfoTooltip, useMediaQuery } from "@dub/ui"; import { Lock } from "lucide-react"; import { signIn } from "next-auth/react"; import { useContext } from "react"; import { toast } from "sonner"; import { LoginFormContext } from "./login-form"; export const SSOSignIn = () => { const { isMobile } = useMediaQuery(); const { setClickedMethod, clickedMethod, authMethod, setLastUsedAuthMethod, setShowSSOOption, showSSOOption, } = useContext(LoginFormContext); return (
{ e.preventDefault(); setClickedMethod("saml"); fetch("/api/auth/saml/verify", { method: "POST", body: JSON.stringify({ slug: e.currentTarget.slug.value }), }).then(async (res) => { const { data, error } = await res.json(); if (error) { toast.error(error); setClickedMethod(undefined); return; } setLastUsedAuthMethod("saml"); await signIn("saml", undefined, { tenant: data.workspaceId, product: "Dub", }); }); }} className="flex flex-col space-y-3" > {showSSOOption && (
{authMethod !== "saml" && (
)}

Workspace Slug

)}

)} {state === "success" && (

Code sent successfully.

)} {state === "error" && (

Failed to send code.

)}
); }; const Delay = ({ seconds }: { seconds: number }) => { return ( {seconds}s ); }; ================================================ FILE: apps/web/ui/auth/register/signup-email.tsx ================================================ "use client"; import { sendOtpAction } from "@/lib/actions/send-otp"; import { signUpSchema } from "@/lib/zod/schemas/auth"; import { PasswordRequirements } from "@/ui/shared/password-requirements"; import { Button, Input, useMediaQuery } from "@dub/ui"; import { useAction } from "next-safe-action/hooks"; import { FormEvent, useCallback, useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { toast } from "sonner"; import * as z from "zod/v4"; import { useRegisterContext } from "./context"; type SignUpProps = z.infer; export const SignUpEmail = () => { const { isMobile } = useMediaQuery(); const { setStep, setEmail, setPassword, email, lockEmail } = useRegisterContext(); const [showPassword, setShowPassword] = useState(false); const form = useForm({ defaultValues: { email, }, }); const { register, handleSubmit, formState: { errors }, getValues, } = form; const { executeAsync, isPending } = useAction(sendOtpAction, { onSuccess: () => { setEmail(getValues("email")); setPassword(getValues("password")); setStep("verify"); }, onError: ({ error }) => { toast.error( error.serverError || error.validationErrors?.email?.[0] || error.validationErrors?.password?.[0], ); }, }); const onSubmit = useCallback( (e: FormEvent) => { const { email, password } = getValues(); if (email && !password && !showPassword) { e.preventDefault(); e.stopPropagation(); setShowPassword(true); return; } handleSubmit(async (data) => await executeAsync(data))(e); }, [getValues, showPassword, handleSubmit, executeAsync], ); return (
{showPassword && ( )}
); }; ================================================ FILE: apps/web/ui/auth/register/signup-form.tsx ================================================ "use client"; import { AnimatedSizeContainer } from "@dub/ui"; import { AuthMethodsSeparator } from "../auth-methods-separator"; import { SignUpEmail } from "./signup-email"; import { SignUpOAuth } from "./signup-oauth"; export const SignUpForm = ({ methods = ["email", "google", "github"], }: { methods?: ("email" | "google" | "github")[]; }) => { return (
{methods.includes("email") && } {methods.length && }
); }; ================================================ FILE: apps/web/ui/auth/register/signup-oauth.tsx ================================================ "use client"; import { getValidInternalRedirectPath } from "@/lib/middleware/utils/is-valid-internal-redirect"; import { Button, Github, Google } from "@dub/ui"; import { signIn } from "next-auth/react"; import { useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; export const SignUpOAuth = ({ methods, }: { methods: ("email" | "google" | "github")[]; }) => { const searchParams = useSearchParams(); const next = getValidInternalRedirectPath({ redirectPath: searchParams.get("next"), currentUrl: window.location.href, }); const [clickedGoogle, setClickedGoogle] = useState(false); const [clickedGithub, setClickedGithub] = useState(false); useEffect(() => { // when leave page, reset state return () => { setClickedGoogle(false); setClickedGithub(false); }; }, []); return ( <> {methods.includes("google") && ( } openPopover={openPopover} setOpenPopover={setOpenPopover} align="end" > )} {props && lockDomain ? (
{domain}
) : (
{ setDomainStatus("idle"); debouncedValidateDomain(e.target.value); }, })} className="block w-full rounded-md border-0 text-neutral-900 placeholder-neutral-400 focus:outline-none focus:ring-0 sm:text-sm" placeholder="go.acme.com" autoFocus={!isMobile} />

{domainStatus !== "idle" ? ( <> {currentStatusProps.prefix || "The domain"}{" "} {currentStatusProps.useStrong ? ( {domain} ) : ( {domain} )}{" "} {currentStatusProps.suffix} ) : ( currentStatusProps.message )}

{currentStatusProps.icon && ( )}
)} {enableDomainConfig && ( <>
{DOMAIN_OPTIONS.map( ({ id, title, description, icon: Icon, proFeature }) => { const showOption = showOptionStates[id] || !!watch(id); return (
{id === "logo" ? (
{!isMobile && ( )} ( field.onChange(src)} maxFileSizeMB={2} targetResolution={{ width: 160, height: 160, }} customPreview={ } /> )} />
) : ( )}
); }, )}
{showAdvancedOptions && ADVANCED_OPTIONS.map( ({ id, title, description, icon: Icon, proFeature }) => { return (

{title}

{proFeature && plan === "free" && (

Pro

)}

{description}

{ setShowOptionStates((prev) => ({ ...prev, [id]: checked, })); if (checked) { // hacky frontend workaround since we don't have a way // to customize the actual appearance of the deepview page yet if (id === "deepviewData") { setValue("deepviewData", "{}", { shouldDirty: true, }); } } else { setValue(id, null, { shouldDirty: true, }); } }} disabled={isSubmitting} />
{showOptionStates[id] && id !== "deepviewData" && (