diff --git a/.agents/skills/jobs-define-job/SKILL.md b/.agents/skills/jobs-define-job/SKILL.md new file mode 100644 index 00000000000..36388828d89 --- /dev/null +++ b/.agents/skills/jobs-define-job/SKILL.md @@ -0,0 +1,130 @@ +--- +name: jobs-define-job +description: Add or migrate background jobs with defineJob, register them in jobLoaders, and dispatch via job.dispatch/dispatchBatch instead of qstash.publishJSON to a cron URL. Use when adding a QStash worker, converting a POST /api/cron route into a job, or when the user mentions defineJob, job handlers, /api/jobs/process, or background jobs. +--- + +# Background jobs: use defineJob + +Payload-driven QStash work goes through `defineJob`. Do **not** add a new HTTP route under `/api/jobs` — every job is executed by the existing [`apps/web/app/api/jobs/process/[jobName]/route.ts`](apps/web/app/api/jobs/process/[jobName]/route.ts). + +Keep using `withCron` for Vercel GET schedules and for cron scanners that have no job envelope. See the `cron-use-with-cron` skill for those. + +## File layout + +``` +apps/web/lib/jobs/ +├── index.ts # defineJob — do not edit unless changing the framework +├── registry.ts # jobLoaders — register every new job here +├── send-jobs.ts # envelope + QStash request builder +└── handlers/ + └── {name}-job.ts # one file per job +``` + +## 1. Create the handler + +Add `apps/web/lib/jobs/handlers/{name}-job.ts`. The `name` must be kebab-case and end in `-job` (enforced by `jobNameSchema`: `/^[a-z][a-z0-9]*(-[a-z0-9]+)*-job$/`). + +```ts +import * as z from "zod/v4"; +import { defineJob } from "../index"; + +const inputSchema = z.object({ + programId: z.string(), + partnerId: z.string(), +}); + +export const unbanPartnerJob = defineJob({ + name: "unban-partner-job", + schema: inputSchema, + defaults: { + retries: 3, // optional; QStash retries on 5xx + // queue: "unban-partner", // optional; named QStash queue + // flowControl: { key: "unban-partner", parallelism: 20 }, + }, + async handle(input) { + // skip (permanent / not found) → return (process route returns 2xx, QStash does not retry) + // transient failure → throw (process route returns 500, QStash retries) + }, +}); +``` + +Export the const as camelCase `{name}Job` matching the kebab `name`. + +Reference handlers: + +- Simple skip/work: `unban-partner-job.ts`, `create-tremendous-campaign-job.ts` +- Self-pagination: `folder-deleted-job.ts`, `domain-deleted-job.ts`, `partner-search-sync-job.ts` +- `defaults.flowControl`: `partner-search-sync-job.ts` + +## 2. Register it + +Add a **static** `import()` in [`apps/web/lib/jobs/registry.ts`](apps/web/lib/jobs/registry.ts) `jobLoaders`. The object key must equal `defineJob({ name })`. Webpack code-splits each handler. + +```ts +"unban-partner-job": () => + import("./handlers/unban-partner-job").then((m) => m.unbanPartnerJob), +``` + +Do not register by editing the process route. `loadJob` throws if `job.name !==` the registry key. + +## 3. Dispatch from call sites + +Import the handler (not `qstash`) and call `dispatch` / `dispatchBatch`: + +```ts +import { unbanPartnerJob } from "@/lib/jobs/handlers/unban-partner-job"; + +await unbanPartnerJob.dispatch( + { workspaceId, programId, partnerId }, + { label: partnerId }, +); + +await folderDeletedJob.dispatchBatch( + folderIds.map((folderId) => ({ folderId })), + ({ folderId }) => ({ label: folderId }), +); +``` + +Per-dispatch options merge over `defaults`: `delay`, `notBefore`, `deduplicationId`, `retries`, `queue`, `flowControl`, `label`. + +Failed QStash publish is persisted to the jobs outbox automatically — do not catch-and-swallow unless even the outbox persist failing must not fail the source mutation (see `queue-partner-search-sync.ts`). + +Self-pagination: call `theJob.dispatch(nextPayload, { delay: 1 })` from `handle` (see `folder-deleted-job`, `partner-search-sync-job`). + +Do not call `job.execute` from app code. That is only for the process route (and cron drain shims below). + +## 4. Convert an existing cron worker + +1. Move the `withCron` body into `handle`. Replace `logAndRespond("skip…")` with `console.info` / `console.error` + `return`. +2. Register + switch every `qstash.publishJSON` / `enqueueJSON` / `enqueueBatchJobs` targeting that cron URL to `job.dispatch` / `dispatchBatch`. +3. Keep a thin POST shim at the old `/api/cron/...` URL that parses the **old** body (not the job envelope) and calls `job.execute(payload)`. That drains in-flight QStash messages. Do not add a shim for brand-new jobs. +4. Delete cron-only helpers that moved with the handler. + +## Handle semantics + +| Outcome | What to do | HTTP from process route | QStash | +| --- | --- | --- | --- | +| Work done | `return` | 200 | stop | +| Skip (not found, already done, env not configured) | `console.*` + `return` | 200 | stop | +| Bad payload | throw `ZodError` (schema.parse) | 200 | stop (non-retryable) | +| Transient failure | `throw` | 500 | retry | + +Unknown job names and invalid envelopes also return 2xx so QStash does not retry forever. + +## Do not use defineJob for + +- Vercel GET crons in `apps/web/vercel.json` +- Scanners that only fan out work (the worker they enqueue can be a job) +- Importers that republish continuation state to the same URL +- `/api/cron/queue/retry` (job replay infrastructure) +- Path-param identity (`/api/cron/links/[linkId]/…`) unless the id moves into the payload +- Outbound webhook forwarding and postbacks + +## Do not + +- Add a new `/api/jobs/...` route or a new `/api/cron/...` POST worker for payload-driven work. +- Call `qstash.publishJSON` / `enqueueJSON` / `enqueueBatchJobs` with `/api/jobs/process/...` — use `dispatch`. +- Register jobs with a dynamic `import()` that webpack cannot statically analyze, or a key that differs from `defineJob({ name })`. +- Name a job without the `-job` suffix. +- Use `job.execute` at dispatch call sites. +- Run `pnpm build` after adding a job. diff --git a/.agents/skills/playwright-api-tests/SKILL.md b/.agents/skills/playwright-api-tests/SKILL.md index 63f6005cc5e..d21668ff3c7 100644 --- a/.agents/skills/playwright-api-tests/SKILL.md +++ b/.agents/skills/playwright-api-tests/SKILL.md @@ -54,10 +54,6 @@ import { expect } from "@playwright/test"; import { randomName } from "../../utils"; import { test, type ApiClient } from "../fixtures"; -test.describe.configure({ - mode: "parallel", -}); - async function createThing( api: ApiClient, overrides: Record = {}, @@ -98,7 +94,7 @@ test("POST /things", async ({ api }) => { | Rule | Detail | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | Import `test` from `../fixtures` | Provides `api`, `workspace`, and `program` | -| `test.describe.configure({ mode: "parallel" })` | At top of every API spec file | +| Serial only when tests share state | API project is `fullyParallel: true`. Do not add `mode: "parallel"`. Use `test.describe.configure({ mode: "serial" })` only when tests in a file/describe share state (e.g. domains, seeded pagination) | | Cleanup in `finally` | Create → assert → always delete created rows | | Unique names/ids | Use `randomName` / `randomCustomer` / `randomPartnerEmail` from `../../utils` — never fixed colliding names | | Assert status + body | Prefer `toStrictEqual` / `toEqual` on full shapes; use `expect.any(String)` for ids/timestamps | diff --git a/.gitignore b/.gitignore index 898bef49661..64759ecd647 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,5 @@ packages/stripe-app/.build/* playwright-report/ **/playwright/.auth/ test-results/ -blob-report/ \ No newline at end of file +blob-report/ +.pnpm-store diff --git a/apps/web/.env.example b/apps/web/.env.example index b43adfd90b6..c8b378c39e3 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -5,8 +5,12 @@ # Generate secrets with: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" NEXTAUTH_SECRET= NEXTAUTH_URL=http://localhost:8888 # (only needed for localhost) -# Secret for Vercel cron jobs (https://vercel.com/docs/cron-jobs/manage-cron-jobs#securing-cron-jobs) + +# Secret for Vercel cron jobs + sync-embeddings CRON_SECRET= +# Shared with the LoopWork demo. Mints geo-accurate clicks via POST /api/demo/click +# and backdated commissions via POST /api/demo/commission +DEMO_CLICK_SECRET= # Encryption key (AES-256-GCM) for encrypting sensitive data in the database ENCRYPTION_KEY= # Email unsubscribe token secret (optional, falls back to NEXTAUTH_SECRET) @@ -23,6 +27,10 @@ PLANETSCALE_DATABASE_URL="http://root:unused@localhost:3900/planetscale" UPSTASH_REDIS_REST_URL= UPSTASH_REDIS_REST_TOKEN= +# Full-text partner search. Unset falls back to the database search path. +TURBOPUFFER_API_KEY= +PARTNER_SEARCH_READ_ENABLED= + # Upstash QStash – required for queues and background jobs # Get your QStash Token here: https://upstash.com/docs/qstash/overall/getstarted QSTASH_URL="https://qstash-us-east-1.upstash.io" @@ -195,4 +203,9 @@ E2E_PARTNER_PASSWORD= # Veriff (Identity Verification) VERIFF_API_KEY= -VERIFF_SHARED_SECRET= \ No newline at end of file +VERIFF_SHARED_SECRET= + +# Domain Connect (auto-configure DNS for custom domains) +# Generate key: openssl genrsa -out private.pem 2048 +# Get public key: openssl rsa -in private.pem -pubout -outform DER | base64 | tr -d '\n' +DOMAIN_CONNECT_PRIVATE_KEY= diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/components/impersonate-user.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/components/impersonate-user.tsx index bfcd7c17689..44a74b3f194 100644 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/components/impersonate-user.tsx +++ b/apps/web/app/(ee)/admin.dub.co/(dashboard)/components/impersonate-user.tsx @@ -18,7 +18,7 @@ export function ImpersonateUser() { await fetch("/api/admin/impersonate", { method: "POST", body: JSON.stringify({ - email: formData.get("email"), + query: formData.get("query"), }), }).then(async (res) => { if (res.ok) { @@ -89,9 +89,9 @@ const Form = () => { return (
{ pending && "bg-neutral-100", )} onPaste={(e: React.ClipboardEvent) => { - // 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; + let text = e.clipboardData.getData("text/plain").trim(); + if (text.toLowerCase().startsWith("mailto:")) { + text = text.slice(7); } + e.currentTarget.value = text; }} - placeholder="panic@thedis.co" + placeholder="panic@thedis.co, acme, or acme.com" aria-invalid="true" /> {pending && ( diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/components/impersonate-workspace.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/components/impersonate-workspace.tsx deleted file mode 100644 index 4eaeec08c96..00000000000 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/components/impersonate-workspace.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"use client"; - -import { 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 ImpersonateWorkspace() { - const [data, setData] = useState(null); - - return ( -
-
{ - await fetch("/api/admin/impersonate", { - method: "POST", - body: JSON.stringify({ - slug: formData.get("slug"), - }), - }).then(async (res) => { - if (res.ok) { - setData(await res.json()); - } else { - const error = await res.text(); - toast.error(error); - } - }); - }} - > - -
- {data && } -
- ); -} - -const Form = () => { - const { pending } = useFormStatus(); - - return ( -
- - app.dub.co - - - {pending && ( - - )} -
- ); -}; diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/components/user-info.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/components/user-info.tsx index 9bb9830b8cc..929a337065d 100644 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/components/user-info.tsx +++ b/apps/web/app/(ee)/admin.dub.co/(dashboard)/components/user-info.tsx @@ -1,7 +1,25 @@ "use client"; + import { PartnerStatusBadges } from "@/ui/partners/partner-status-badges"; -import { Badge, Copy, StatusBadge, Tick, useCopyToClipboard } from "@dub/ui"; -import { capitalize, currencyFormatter, nFormatter } from "@dub/utils"; +import { + Badge, + Copy, + CopyButton, + StatusBadge, + Tick, + useCopyToClipboard, +} from "@dub/ui"; +import { ArrowUpRight2 } from "@dub/ui/icons"; +import { + APP_DOMAIN, + capitalize, + currencyFormatter, + formatDate, + getPrettyUrl, + isSafeLinkHref, + isWorkspaceBillingTrialActive, + nFormatter, +} from "@dub/utils"; import { toast } from "sonner"; export interface UserInfoProps { @@ -11,10 +29,16 @@ export interface UserInfoProps { name: string; slug: string; plan: string; - clicks: number; + planPeriod: string | null; + trialEndsAt: string | Date | null; + events: number; links: number; - totalClicks: number; - totalLinks: number; + statsInterval: "30d" | "all"; + program: { + url: string; + partners: number; + commissions: number; + } | null; }[]; programs: { id: string; @@ -33,24 +57,20 @@ export interface UserInfoProps { }; } -const workspaceItems = [ - { id: "clicks", label: "Clicks" }, - { id: "links", label: "Links" }, - { id: "totalClicks", label: "Total Clicks" }, - { id: "totalLinks", label: "Total Links" }, -]; - -const programItems = [ - { id: "totalClicks", label: "Total Clicks" }, - { id: "totalLeads", label: "Total Leads" }, - { id: "totalConversions", label: "Total Conversions" }, - { id: "totalSaleAmount", label: "Total Sales", isCurrency: true }, - { id: "totalCommissions", label: "Total Commissions", isCurrency: true }, -]; +const programHighlightItems = [ + { id: "totalSaleAmount", label: "Revenue" }, + { id: "totalCommissions", label: "Commissions" }, +] as const; + +const programDetailItems = [ + { id: "totalClicks", label: "Clicks" }, + { id: "totalLeads", label: "Leads" }, + { id: "totalConversions", label: "Conversions" }, +] as const; export default function UserInfo({ data }: { data: UserInfoProps }) { return ( -
+
{data.workspaces.length > 0 && ( -
-

+
+

Workspaces

-
+
{data.workspaces.map((workspace) => ( -
-
-

{workspace.name}

- {workspace.slug} -
-
- ID - {workspace.id} -
-
- Plan - - {capitalize(workspace.plan)} - -
- {workspaceItems.map((item) => ( -
- - {item.label} - - - {nFormatter(workspace[item.id], { full: true })} - -
- ))} -
+ ))}
-
- )} - - {data.workspaces.length > 0 && data.programs.length > 0 && ( -
+
)} {data.programs.length > 0 && ( -
-

- Programs +
+

+ Partner programs

-
+
{data.programs.map((program) => ( -
-
-

{program.name}

- {program.slug} -
-
- ID - {program.id} -
-
- Status - - {PartnerStatusBadges[program.status].label} - -
- {programItems.map((item) => ( -
- - {item.label} - - - {item.isCurrency - ? currencyFormatter(program[item.id]) - : nFormatter(program[item.id], { full: true })} - -
- ))} -
+ ))}
+
+ )} +

+ ); +} + +function WorkspaceCard({ + workspace, +}: { + workspace: UserInfoProps["workspaces"][number]; +}) { + const trialActive = isWorkspaceBillingTrialActive(workspace.trialEndsAt); + const planLabel = workspace.planPeriod + ? `${capitalize(workspace.plan)} (${workspace.planPeriod})` + : capitalize(workspace.plan); + const statsLabel = + workspace.statsInterval === "30d" ? "Last 30 days" : "All-time"; + const programUrl = workspace.program + ? getPrettyUrl(workspace.program.url) + : null; + + return ( +
+
+
+ + {workspace.name} + + +

+ {workspace.slug} +

+
+
+ {planLabel} + {trialActive && workspace.trialEndsAt && ( + + Trial ends {formatDate(workspace.trialEndsAt, { month: "short" })} + + )} +
+
+ +
+ + +
+ + + + {workspace.program && ( +
+ + +
)}
); } +function ProgramCard({ + program, +}: { + program: UserInfoProps["programs"][number]; +}) { + const status = PartnerStatusBadges[program.status]; + + return ( +
+
+
+

+ {program.name} +

+

+ {program.slug} +

+
+ {status && ( + {status.label} + )} +
+ +
+ {programHighlightItems.map((item) => ( + + ))} +
+ +
+ + {programDetailItems.map((item) => ( + + ))} +
+
+ ); +} + +function StatTile({ + label, + value, + hint, +}: { + label: string; + value: string; + hint?: string; +}) { + return ( +
+

+ {label} +

+

+ {value} +

+ {hint &&

{hint}

} +
+ ); +} + +function MetaRow({ + label, + value, + copyValue, + href, + mono, +}: { + label: string; + value: string; + copyValue?: string; + href?: string; + mono?: boolean; +}) { + return ( +
+ + {label} + + + {href ? ( + + {value} + + ) : ( + + {value} + + )} + {copyValue && } + +
+ ); +} + const LoginLinkCopyButton = ({ text, url }: { text: string; url: string }) => { const [copied, copyToClipboard] = useCopyToClipboard(); return ( -
-
+
+
{text}
); diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/page.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/page.tsx index a407e8d4aff..60593dac261 100644 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/page.tsx +++ b/apps/web/app/(ee)/admin.dub.co/(dashboard)/page.tsx @@ -2,7 +2,6 @@ import { BanLink } from "./components/ban-link"; import { DeletePartnerAccount } from "./components/delete-partner-account"; import { DisableRestoreWorkspace } from "./components/disable-restore-workspace"; import { ImpersonateUser } from "./components/impersonate-user"; -import { ImpersonateWorkspace } from "./components/impersonate-workspace"; import { ResetLoginAttempts } from "./components/reset-login-attempts"; import { SlackSupportInvite } from "./components/slack-support-invite"; @@ -10,19 +9,13 @@ export default function AdminPage() { return (
-

Impersonate User/Partner

+

Impersonate User

- Get a login link for a user email (or partner email) + Get a login link by user/partner email, workspace slug, or domain. + Workspace and domain lookups impersonate the main owner.

-
-

Impersonate Workspace

-

- Get a login link for the owner of a workspace -

- -

Ban Link

Ban a dub.sh link

diff --git a/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/fraud/page.tsx b/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/fraud/page.tsx index aa41bc0ee46..28c0bd781ca 100644 --- a/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/fraud/page.tsx +++ b/apps/web/app/(ee)/admin.dub.co/(dashboard)/partners/fraud/page.tsx @@ -3,6 +3,7 @@ import { adminFraudAlertSchema } from "@/lib/zod/schemas/admin"; import { PartnerAvatar } from "@/ui/partners/partner-avatar"; import { + Button, Filter, StatusBadge, Table, @@ -12,9 +13,16 @@ import { useTable, } from "@dub/ui"; import { CircleDotted, GridIcon } from "@dub/ui/icons"; -import { fetcher, formatDate, formatDateTime, OG_AVATAR_URL } from "@dub/utils"; +import { + fetcher, + formatDate, + formatDateTime, + OG_AVATAR_URL, + pluralize, +} from "@dub/utils"; import { FraudAlertStatus } from "@prisma/client"; import { Suspense, useCallback, useMemo, useState } from "react"; +import { toast } from "sonner"; import useSWR from "swr"; import * as z from "zod/v4"; import { ReviewFraudAlertSheet } from "./review-fraud-alert-sheet"; @@ -45,6 +53,7 @@ function FraudAlertsPageClient() { const [selectedAlert, setSelectedAlert] = useState( null, ); + const [isBulkConfirming, setIsBulkConfirming] = useState(false); const { data: { fraudAlerts, total } = {}, @@ -57,6 +66,75 @@ function FraudAlertsPageClient() { keepPreviousData: true, }); + const handleBulkConfirm = async ( + alerts: AdminFraudAlert[], + resetSelection: () => void, + ) => { + // Confirming an alert confirms all pending alerts for that partner, so + // dedupe by partner before looping the existing PATCH endpoint. + const pendingAlerts = [ + ...new Map( + alerts + .filter((alert) => alert.status === "pending") + .map((alert) => [alert.partner.id, alert]), + ).values(), + ]; + + if (pendingAlerts.length === 0) { + toast.error("No pending fraud alerts selected."); + return; + } + + if ( + !window.confirm( + `Are you sure you want to confirm ${pendingAlerts.length} fraud ${pluralize("alert", pendingAlerts.length)}?`, + ) + ) { + return; + } + + setIsBulkConfirming(true); + + try { + let succeeded = 0; + let failed = 0; + + for (const alert of pendingAlerts) { + try { + const response = await fetch(`/api/admin/fraud-alerts/${alert.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: "confirmed" }), + }); + + // 409 means the partner's pending alerts were already confirmed + if (response.ok || response.status === 409) { + succeeded++; + } else { + failed++; + } + } catch { + failed++; + } + } + + if (failed > 0) { + toast.error( + `Confirmed ${succeeded} ${pluralize("alert", succeeded)}, ${failed} failed.`, + ); + } else { + toast.success( + `Confirmed ${succeeded} fraud ${pluralize("alert", succeeded)}.`, + ); + } + + resetSelection(); + await mutate(); + } finally { + setIsBulkConfirming(false); + } + }; + // Extract unique programs from fraud alerts for filter options const programs = useMemo(() => { if (!fraudAlerts) return []; @@ -241,6 +319,37 @@ function FraudAlertsPageClient() { resourceName: (plural) => `fraud alert${plural ? "s" : ""}`, rowCount: total ?? 0, loading: isLoading, + thClassName: (id) => (id === "partner" ? "pl-4" : ""), + tdClassName: (id) => (id === "partner" ? "pl-4" : ""), + getRowId: (row) => row.id, + selectionControls: (tableInstance) => { + const selectedAlerts = tableInstance + .getSelectedRowModel() + .rows.map((row) => row.original); + const pendingCount = selectedAlerts.filter( + (alert) => alert.status === "pending", + ).length; + + return ( +
)) : [...Array(3)].map((_, idx) => ( -
setShowIndustryInterestsModal(true)} className={cn( - "border-border-subtle h-11 w-32 rounded-full border border-dashed bg-white", + "relative flex h-11 w-32 items-center justify-center rounded-full bg-white", + !disabled && + "transition-colors hover:bg-neutral-50/60", + disabled && "cursor-not-allowed", )} - /> + > + + + ))}
{rewardPeriodEndDate && isBefore(rewardPeriodEndDate, new Date()) && ( -
- -

- The earning period for this customer has ended as of{" "} - {formatDate(rewardPeriodEndDate)}. No future conversions - will be rewarded. -

-
+ + The earning period for this customer has ended as of{" "} + {formatDate(rewardPeriodEndDate)}. No future conversions + will be rewarded. + )}
diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/earnings/earnings-composite-chart.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/earnings/earnings-composite-chart.tsx index c9efeb85837..e160eec4595 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/earnings/earnings-composite-chart.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/earnings/earnings-composite-chart.tsx @@ -52,7 +52,7 @@ export function EarningsCompositeChart() { start, end, interval = DUB_PARTNERS_ANALYTICS_INTERVAL, - groupBy = "linkId", + groupBy = "type", } = searchParamsObj as { start?: string; end?: string; @@ -150,20 +150,20 @@ export function EarningsCompositeChart() { { label: (
- - Link + + Type
), - value: "linkId", + value: "type", }, { label: (
- - Type + + Link
), - value: "type", + value: "linkId", }, ]} selected={groupBy} diff --git a/apps/web/app/api/analytics/dashboard/route.ts b/apps/web/app/api/analytics/dashboard/route.ts index 5205fca9686..760dbfb8dbd 100644 --- a/apps/web/app/api/analytics/dashboard/route.ts +++ b/apps/web/app/api/analytics/dashboard/route.ts @@ -8,6 +8,7 @@ import { redis } from "@/lib/upstash"; import { parseAnalyticsQuery } from "@/lib/zod/schemas/analytics"; import { DUB_DEMO_LINKS, DUB_WORKSPACE_ID, getSearchParams } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; +import { cookies } from "next/headers"; import { NextResponse } from "next/server"; export const dynamic = "force-dynamic"; @@ -48,7 +49,12 @@ export const GET = async (req: Request) => { }, select: { id: true, - dashboard: true, + dashboard: { + select: { + id: true, + password: true, + }, + }, projectId: true, project: { select: { @@ -80,6 +86,8 @@ export const GET = async (req: Request) => { }); } + await assertDashboardPassword(folder.dashboard); + workspace = folder.project; if ("links" in folder && folder.links?.length) link = folder.links[0]; @@ -102,7 +110,12 @@ export const GET = async (req: Request) => { }, select: { id: true, - dashboard: true, + dashboard: { + select: { + id: true, + password: true, + }, + }, projectId: true, project: { select: { @@ -123,6 +136,8 @@ export const GET = async (req: Request) => { }); } + await assertDashboardPassword(link.dashboard); + workspace = link.project; } } @@ -185,3 +200,23 @@ export const GET = async (req: Request) => { return handleAndReturnErrorResponse(error); } }; + +async function assertDashboardPassword(dashboard: { + id: string; + password: string | null; +}) { + if (!dashboard.password) { + return; + } + + const cookiePassword = (await cookies()).get( + `dub_password_${dashboard.id}`, + )?.value; + + if (cookiePassword !== dashboard.password) { + throw new DubApiError({ + code: "unauthorized", + message: "This dashboard is password protected", + }); + } +} diff --git a/apps/web/app/api/domains/[domain]/domain-connect/apply/route.ts b/apps/web/app/api/domains/[domain]/domain-connect/apply/route.ts new file mode 100644 index 00000000000..02c40d598cc --- /dev/null +++ b/apps/web/app/api/domains/[domain]/domain-connect/apply/route.ts @@ -0,0 +1,144 @@ +import { getConfigResponse } from "@/lib/api/domains/get-config-response"; +import { getDomainOrThrow } from "@/lib/api/domains/get-domain-or-throw"; +import { getDomainResponse } from "@/lib/api/domains/get-domain-response"; +import { DubApiError } from "@/lib/api/errors"; +import { assertEnv } from "@/lib/assert-env"; +import { withWorkspace } from "@/lib/auth"; +import { isAllowedSyncUXOrigin } from "@/lib/domain-connect/allowed-origins"; +import { + DEFAULT_DC_SERVICE_APEX, + DEFAULT_DC_SERVICE_SUBDOMAIN, + DOMAIN_CONNECT_KEY_HOST, +} from "@/lib/domain-connect/constants"; +import { discoverDomainConnect } from "@/lib/domain-connect/discover"; +import { buildSignedApplyUrl } from "@/lib/domain-connect/sign-apply-url"; +import { APP_DOMAIN, getApexDomain, getSubdomain } from "@dub/utils"; +import { NextResponse } from "next/server"; +import * as z from "zod/v4"; + +const bodySchema = z.object({ + returnTo: z.string().max(512).optional(), +}); + +// POST /api/domains/[domain]/domain-connect/apply +export const POST = withWorkspace( + async ({ req, workspace, params }) => { + const privateKeyPem = assertEnv("DOMAIN_CONNECT_PRIVATE_KEY") + .trim() + .replace(/\\n/g, "\n"); + + const { slug: domain } = await getDomainOrThrow({ + workspace, + domain: params.domain, + dubDomainChecks: true, + }); + + const body = bodySchema.parse(await req.json().catch(() => ({}))); + + const [domainJson, configJson] = await Promise.all([ + getDomainResponse(domain), + getConfigResponse(domain), + ]); + + if (domainJson?.error?.code === "not_found" || domainJson?.error) { + throw new DubApiError({ + code: "bad_request", + message: "Domain is not available for configuration.", + }); + } + + if (configJson?.conflicts?.length) { + throw new DubApiError({ + code: "bad_request", + message: "Remove conflicting DNS records first, then retry.", + }); + } + + if (domainJson.verified && !configJson.misconfigured) { + throw new DubApiError({ + code: "bad_request", + message: "This domain is already configured correctly.", + }); + } + + const apex = getApexDomain(`https://${domain}`); + const discovery = await discoverDomainConnect(apex); + if (!discovery) { + throw new DubApiError({ + code: "bad_request", + message: + "Auto configure is only available for Vercel or Cloudflare DNS zones.", + }); + } + + if (!isAllowedSyncUXOrigin(discovery.urlSyncUX)) { + throw new DubApiError({ + code: "bad_request", + message: "Invalid Domain Connect provider URL.", + }); + } + + const subdomain = getSubdomain(domain.toLowerCase(), apex); + const isApex = !subdomain; + const serviceId = isApex + ? DEFAULT_DC_SERVICE_APEX + : DEFAULT_DC_SERVICE_SUBDOMAIN; + + const returnPath = + body.returnTo && + body.returnTo.startsWith(`/${workspace.slug}/`) && + !body.returnTo.includes("://") + ? body.returnTo + : `/${workspace.slug}/settings/domains`; + + const redirectUrl = new URL(returnPath, APP_DOMAIN); + redirectUrl.searchParams.set("domain_connect", "callback"); + const redirectUri = redirectUrl.toString(); + + const queryParams: Record = { + domain: apex, + groupId: "subdomain", + redirect_uri: redirectUri, + }; + + if (isApex) { + queryParams.groupId = "apex"; + } else { + queryParams.groupId = "subdomain"; + queryParams.host = (subdomain ?? "www").toLowerCase(); + } + + const txtVerification = domainJson.verification?.find( + (x: { type: string }) => x.type === "TXT", + ); + if (txtVerification) { + const txtHostFqdn: string = txtVerification.domain?.toLowerCase() ?? ""; + const apexSuffix = `.${apex}`; + const txtHost = txtHostFqdn.endsWith(apexSuffix) + ? txtHostFqdn.slice(0, -apexSuffix.length) + : txtHostFqdn === apex + ? "@" + : txtHostFqdn; + const txtValue = txtVerification.value?.trim(); + + if (txtHost && txtValue) { + queryParams.groupId = queryParams.groupId + ",verification"; + queryParams.txtHost = txtHost; + queryParams.txtValue = txtValue; + } + } + + const applyUrl = buildSignedApplyUrl({ + urlSyncUX: discovery.urlSyncUX, + serviceId, + privateKeyPem, + keyHost: DOMAIN_CONNECT_KEY_HOST, + queryParams, + }); + + return NextResponse.json({ applyUrl }); + }, + { + requiredPermissions: ["domains.write"], + }, +); diff --git a/apps/web/app/api/domains/[domain]/forward-instructions/route.ts b/apps/web/app/api/domains/[domain]/forward-instructions/route.ts new file mode 100644 index 00000000000..23bc4acbf8d --- /dev/null +++ b/apps/web/app/api/domains/[domain]/forward-instructions/route.ts @@ -0,0 +1,116 @@ +import { getDomainOrThrow } from "@/lib/api/domains/get-domain-or-throw"; +import { getDomainResponse } from "@/lib/api/domains/get-domain-response"; +import { DubApiError } from "@/lib/api/errors"; +import { withWorkspace } from "@/lib/auth"; +import { + DUB_CUSTOM_DOMAIN_A_RECORD, + DUB_CUSTOM_DOMAIN_CNAME, +} from "@/lib/domain-connect/constants"; +import { assertEmailSent } from "@/lib/email/assert-email-sent"; +import { assertRateLimit } from "@/lib/upstash/assert-rate-limit"; +import { RATELIMIT_POLICIES } from "@/lib/upstash/ratelimit-policies"; +import { sendEmail } from "@dub/email"; +import DomainDnsInstructions from "@dub/email/templates/domain-dns-instructions"; +import { getApexDomain, getSubdomain } from "@dub/utils"; +import { NextResponse } from "next/server"; +import * as z from "zod/v4"; + +const bodySchema = z.object({ + email: z.email(), + recordType: z.enum(["A", "CNAME"]), +}); + +type DnsRecord = { + type: string; + name: string; + value: string; +}; + +// POST /api/domains/[domain]/forward-instructions +export const POST = withWorkspace( + async ({ req, workspace, params, session }) => { + const { slug: domain } = await getDomainOrThrow({ + workspace, + domain: params.domain, + dubDomainChecks: true, + }); + + const { email, recordType } = bodySchema.parse(await req.json()); + + await assertRateLimit({ + policy: RATELIMIT_POLICIES.forwardDnsInstructions, + identifier: [workspace.id, session.user.id], + }); + + await assertRateLimit({ + policy: RATELIMIT_POLICIES.forwardDnsInstructionsTarget, + identifier: email.toLowerCase(), + }); + + const records: DnsRecord[] = []; + + const domainJson = await getDomainResponse(domain); + + if (domainJson?.error) { + throw new DubApiError({ + code: "bad_request", + message: "Could not retrieve DNS records for this domain.", + }); + } + + const apex = getApexDomain(`https://${domain}`); + const subdomain = getSubdomain( + domainJson.name?.toLowerCase() ?? domain, + domainJson.apexName?.toLowerCase() ?? apex, + ); + + if (recordType === "A") { + records.push({ + type: "A", + name: subdomain ?? "@", + value: DUB_CUSTOM_DOMAIN_A_RECORD, + }); + } else { + records.push({ + type: "CNAME", + name: subdomain ?? "www", + value: DUB_CUSTOM_DOMAIN_CNAME, + }); + } + + const txtVerification = domainJson.verification?.find( + (x: { type: string }) => x.type === "TXT", + ); + if (txtVerification) { + const txtHostFqdn: string = txtVerification.domain?.toLowerCase() ?? ""; + const apexSuffix = `.${apex}`; + const txtHost = txtHostFqdn.endsWith(apexSuffix) + ? txtHostFqdn.slice(0, -apexSuffix.length) + : txtHostFqdn === apex + ? "@" + : txtHostFqdn; + const txtValue = txtVerification.value?.trim() ?? ""; + if ((txtHost || txtHost === "@") && txtValue) { + records.push({ type: "TXT", name: txtHost, value: txtValue }); + } + } + + const result = await sendEmail({ + subject: `DNS instructions for ${domain}`, + to: email, + react: DomainDnsInstructions({ + email, + domain, + records, + senderEmail: session.user.email, + }), + }); + + assertEmailSent(result); + + return NextResponse.json({ ok: true }); + }, + { + requiredPermissions: ["domains.write"], + }, +); diff --git a/apps/web/app/api/domains/[domain]/verify/route.ts b/apps/web/app/api/domains/[domain]/verify/route.ts index d48cb79bbdc..88d150f8fe6 100644 --- a/apps/web/app/api/domains/[domain]/verify/route.ts +++ b/apps/web/app/api/domains/[domain]/verify/route.ts @@ -1,10 +1,13 @@ import { getConfigResponse } from "@/lib/api/domains/get-config-response"; import { getDomainOrThrow } from "@/lib/api/domains/get-domain-or-throw"; import { getDomainResponse } from "@/lib/api/domains/get-domain-response"; -import { verifyDomain } from "@/lib/api/domains/verify-domain"; +import { verifyDomainWithRetry } from "@/lib/api/domains/verify-domain"; import { withWorkspace } from "@/lib/auth"; +import { discoverDomainConnectIfEligible } from "@/lib/domain-connect/discover"; +import type { DomainConnectDiscovery } from "@/lib/domain-connect/types"; import { prisma } from "@/lib/prisma"; import { DomainVerificationStatusProps } from "@/lib/types"; +import { getApexDomain } from "@dub/utils"; import { NextResponse } from "next/server"; export const maxDuration = 30; @@ -19,6 +22,7 @@ export const GET = withWorkspace( }); let status: DomainVerificationStatusProps = "Valid Configuration"; + const apex = getApexDomain(`https://${domain}`); const [domainJson, configJson] = await Promise.all([ getDomainResponse(domain), @@ -31,12 +35,14 @@ export const GET = withWorkspace( return NextResponse.json({ status, response: { configJson, domainJson }, + domainConnect: null, }); } else if (domainJson.error) { status = "Unknown Error"; return NextResponse.json({ status, response: { configJson, domainJson }, + domainConnect: null, }); } @@ -48,6 +54,7 @@ export const GET = withWorkspace( return NextResponse.json({ status, response: { configJson, domainJson }, + domainConnect: null, }); } @@ -56,22 +63,53 @@ export const GET = withWorkspace( */ if (!domainJson.verified) { status = "Pending Verification"; - const verificationJson = await verifyDomain(domain); + const verificationJson = await verifyDomainWithRetry(domain); - if (verificationJson && verificationJson.verified) { - /** - * Domain was just verified - */ - status = "Valid Configuration"; + if (verificationJson?.verified) { + // Re-check config after Vercel ownership verification succeeds + const freshConfig = await getConfigResponse(domain); + if (freshConfig?.conflicts?.length) { + status = "Conflicting DNS Records"; + } else if (freshConfig?.misconfigured) { + status = "Invalid Configuration"; + await prisma.domain.update({ + where: { slug: domain }, + data: { verified: false, lastChecked: new Date() }, + }); + } else { + status = "Valid Configuration"; + await prisma.domain.update({ + where: { slug: domain }, + data: { verified: true, lastChecked: new Date() }, + }); + } + + const domainConnect: DomainConnectDiscovery | null = + await discoverDomainConnectIfEligible(apex, status); + + return NextResponse.json({ + status, + response: { + configJson: freshConfig, + domainJson: { ...domainJson, verified: true }, + verificationJson, + }, + domainConnect, + }); } + const domainConnect: DomainConnectDiscovery | null = + await discoverDomainConnectIfEligible(apex, status); + return NextResponse.json({ status, response: { configJson, domainJson, verificationJson }, + domainConnect, }); } let prismaResponse: any = null; + let domainConnect: DomainConnectDiscovery | null = null; if (!configJson.misconfigured) { prismaResponse = await prisma.domain.update({ where: { @@ -84,20 +122,19 @@ export const GET = withWorkspace( }); } else { status = "Invalid Configuration"; - prismaResponse = await prisma.domain.update({ - where: { - slug: domain, - }, - data: { - verified: false, - lastChecked: new Date(), - }, - }); + [prismaResponse, domainConnect] = await Promise.all([ + prisma.domain.update({ + where: { slug: domain }, + data: { verified: false, lastChecked: new Date() }, + }), + discoverDomainConnectIfEligible(apex, "Invalid Configuration"), + ]); } return NextResponse.json({ status, response: { configJson, domainJson, prismaResponse }, + domainConnect, }); }, { diff --git a/apps/web/app/api/dub/webhook/route.ts b/apps/web/app/api/dub/webhook/route.ts index 630365f3fc9..7dc78bf3be5 100644 --- a/apps/web/app/api/dub/webhook/route.ts +++ b/apps/web/app/api/dub/webhook/route.ts @@ -1,10 +1,12 @@ +import { withAxiom } from "@/lib/axiom/server"; import { webhookPayloadSchema } from "@/lib/webhook/schemas"; +import { timingSafeCompare } from "@/lib/webhook/timing-safe-compare"; import crypto from "crypto"; import { leadCreated } from "./lead-created"; import { saleCreated } from "./sale-created"; // POST /api/dub/webhook - receive webhooks for Dub -export const POST = async (req: Request) => { +export const POST = withAxiom(async (req: Request) => { const body = await req.json(); const { event, data } = webhookPayloadSchema.parse(body); @@ -19,7 +21,7 @@ export const POST = async (req: Request) => { .update(JSON.stringify(body)) .digest("hex"); - if (webhookSignature !== computedSignature) { + if (!timingSafeCompare(webhookSignature, computedSignature)) { return new Response("Invalid signature", { status: 400 }); } @@ -35,4 +37,4 @@ export const POST = async (req: Request) => { } return new Response(response); -}; +}); diff --git a/apps/web/app/api/jobs/process/[jobName]/route.ts b/apps/web/app/api/jobs/process/[jobName]/route.ts index 57c50019834..3b0e2f46c29 100644 --- a/apps/web/app/api/jobs/process/[jobName]/route.ts +++ b/apps/web/app/api/jobs/process/[jobName]/route.ts @@ -1,7 +1,7 @@ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { logger, withAxiomBodyLog } from "@/lib/axiom/server"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; -import { jobEnvelopeSchema } from "@/lib/jobs"; +import { jobEnvelopeSchema } from "@/lib/jobs/send-jobs"; import { loadJob } from "@/lib/jobs/registry"; import * as z from "zod/v4"; diff --git a/apps/web/app/api/links/[linkId]/route.ts b/apps/web/app/api/links/[linkId]/route.ts index b30cb32ff3f..7ea389b30de 100644 --- a/apps/web/app/api/links/[linkId]/route.ts +++ b/apps/web/app/api/links/[linkId]/route.ts @@ -175,6 +175,8 @@ export const PATCH = withWorkspace( domain: link.domain, key: link.key, image: link.image, + programId: link.programId, + partnerId: link.partnerId, }, updatedLink: processedLink, }); diff --git a/apps/web/app/api/links/bulk/route.ts b/apps/web/app/api/links/bulk/route.ts index a5c736069fb..60a917d0a08 100644 --- a/apps/web/app/api/links/bulk/route.ts +++ b/apps/web/app/api/links/bulk/route.ts @@ -11,6 +11,7 @@ import { includeProgramEnrollment } from "@/lib/api/links/include-program-enroll import { includeTags } from "@/lib/api/links/include-tags"; import { throwIfLinksUsageExceeded } from "@/lib/api/links/usage-checks"; import { checkIfLinksHaveFolders } from "@/lib/api/links/utils/check-if-links-have-folders"; +import { checkIfLinksHaveProgramPartners } from "@/lib/api/links/utils/check-if-links-have-program-partners"; import { isRootDomainLinkKey } from "@/lib/api/links/utils/is-root-domain-link-key"; import { combineTagIds } from "@/lib/api/tags/combine-tag-ids"; import { parseRequestBody } from "@/lib/api/utils"; @@ -222,6 +223,55 @@ export const POST = withWorkspace( }); } + if (checkIfLinksHaveProgramPartners(validLinks)) { + const partnerIds = [ + ...new Set( + validLinks.map((link) => link.partnerId).filter(Boolean) as string[], + ), + ]; + + const enrollments = + workspace.defaultProgramId && partnerIds.length > 0 + ? await prisma.programEnrollment.findMany({ + where: { + programId: workspace.defaultProgramId, + partnerId: { in: partnerIds }, + }, + select: { + partnerId: true, + }, + }) + : []; + + const enrolledPartnerIds = new Set( + enrollments.map(({ partnerId }) => partnerId), + ); + + validLinks = validLinks.filter((link) => { + if (link.programId && link.programId !== workspace.defaultProgramId) { + errorLinks.push({ + error: `Invalid programId detected: ${link.programId}`, + code: "unprocessable_entity", + link, + }); + + return false; + } + + if (link.partnerId && !enrolledPartnerIds.has(link.partnerId)) { + errorLinks.push({ + error: `Invalid partnerId detected: ${link.partnerId}`, + code: "unprocessable_entity", + link, + }); + + return false; + } + + return true; + }); + } + if (checkIfLinksHaveWebhooks(validLinks)) { if (workspace.plan === "free" || workspace.plan === "pro") { throw new DubApiError({ @@ -425,6 +475,38 @@ export const PATCH = withWorkspace( }); } + if (data.programId || data.partnerId) { + if (data.programId && data.programId !== workspace.defaultProgramId) { + throw new DubApiError({ + code: "unprocessable_entity", + message: `Invalid programId detected: ${data.programId}`, + }); + } + + if (data.partnerId) { + const enrollment = workspace.defaultProgramId + ? await prisma.programEnrollment.findUnique({ + where: { + partnerId_programId: { + partnerId: data.partnerId, + programId: workspace.defaultProgramId, + }, + }, + select: { + partnerId: true, + }, + }) + : null; + + if (!enrollment) { + throw new DubApiError({ + code: "unprocessable_entity", + message: `Invalid partnerId detected: ${data.partnerId}`, + }); + } + } + } + const processedLinks = await Promise.all( links.map(async (link) => processLink({ diff --git a/apps/web/app/api/links/sync/route.ts b/apps/web/app/api/links/sync/route.ts index 8232d15bc5a..a61c0273779 100644 --- a/apps/web/app/api/links/sync/route.ts +++ b/apps/web/app/api/links/sync/route.ts @@ -27,6 +27,7 @@ export const POST = withWorkspace( domain: link.domain, key: link.key, }, + projectId: null, userId: null, }, }); diff --git a/apps/web/app/api/links/upsert/route.ts b/apps/web/app/api/links/upsert/route.ts index febaa1a2dcd..22f0a9b4064 100644 --- a/apps/web/app/api/links/upsert/route.ts +++ b/apps/web/app/api/links/upsert/route.ts @@ -149,6 +149,8 @@ export const PUT = withWorkspace( domain: link.domain, key: link.key, image: link.image, + programId: link.programId, + partnerId: link.partnerId, }, updatedLink: processedLink, }); diff --git a/apps/web/app/api/oauth/token/exchange-code-for-token.ts b/apps/web/app/api/oauth/token/exchange-code-for-token.ts index 97d38373f84..ea3d92aca75 100644 --- a/apps/web/app/api/oauth/token/exchange-code-for-token.ts +++ b/apps/web/app/api/oauth/token/exchange-code-for-token.ts @@ -5,6 +5,7 @@ import { hashToken } from "@/lib/auth"; import { installIntegration } from "@/lib/integrations/install"; import { generateRandomName } from "@/lib/names"; import { prisma } from "@/lib/prisma"; +import { timingSafeCompare } from "@/lib/webhook/timing-safe-compare"; import { authCodeExchangeSchema } from "@/lib/zod/schemas/oauth"; import { waitUntil } from "@vercel/functions"; import { NextRequest } from "next/server"; @@ -100,7 +101,8 @@ export const exchangeAuthCodeForToken = async ( }); } - if (app.hashedClientSecret !== (await hashToken(clientSecret))) { + const hashedClientSecret = await hashToken(clientSecret); + if (!timingSafeCompare(hashedClientSecret, app.hashedClientSecret)) { throw new DubApiError({ code: "unauthorized", message: "Invalid client_secret", diff --git a/apps/web/app/api/oauth/token/refresh-access-token.ts b/apps/web/app/api/oauth/token/refresh-access-token.ts index 9a98ce21cad..c19b029a9b8 100644 --- a/apps/web/app/api/oauth/token/refresh-access-token.ts +++ b/apps/web/app/api/oauth/token/refresh-access-token.ts @@ -4,6 +4,7 @@ import { createToken } from "@/lib/api/oauth/utils"; import { hashToken } from "@/lib/auth"; import { generateRandomName } from "@/lib/names"; import { prisma } from "@/lib/prisma"; +import { timingSafeCompare } from "@/lib/webhook/timing-safe-compare"; import { refreshTokenSchema } from "@/lib/zod/schemas/oauth"; import { NextRequest } from "next/server"; import * as z from "zod/v4"; @@ -68,7 +69,8 @@ export const refreshAccessToken = async ( }); } - if (oAuthApp.hashedClientSecret !== (await hashToken(clientSecret))) { + const hashedClientSecret = await hashToken(clientSecret); + if (!timingSafeCompare(hashedClientSecret, oAuthApp.hashedClientSecret)) { throw new DubApiError({ code: "unauthorized", message: "Invalid client_secret", diff --git a/apps/web/app/api/og/program/categories/route.tsx b/apps/web/app/api/og/program/categories/route.tsx new file mode 100644 index 00000000000..4567056fc3c --- /dev/null +++ b/apps/web/app/api/og/program/categories/route.tsx @@ -0,0 +1,142 @@ +import { PROGRAM_CATEGORIES } from "@/lib/network/program-categories"; +import { DUB_WORDMARK } from "@dub/utils"; +import { ImageResponse } from "next/og"; +import { NextRequest } from "next/server"; +import { loadGoogleFont } from "../../load-google-font"; + +const DARK_CELLS = [ + [2, 3], + [5, 3], + [56, 7], + [53, 1], +]; + +// GET /api/og/program/categories?categorySlug=ai +export async function GET(req: NextRequest) { + const categorySlug = req.nextUrl.searchParams.get("categorySlug"); + + if (!categorySlug) { + return new Response("Missing 'categorySlug' parameter", { + status: 400, + }); + } + + const category = PROGRAM_CATEGORIES.find( + ({ id }) => id.toLowerCase() === categorySlug.toLowerCase(), + ); + + if (!category) { + return new Response("Category not found", { + status: 404, + }); + } + + const interSemibold = await loadGoogleFont("Inter:wght@600"); + + return new ImageResponse( + ( +
+ {/* @ts-ignore */} + + + + + + + + + + + + + + {DARK_CELLS.map(([x, y]) => ( + + ))} + + + + + +
+
+ +
+
+ Program Marketplace +
+
+
+ {`${category.label} Affiliate Programs`} +
+
+
+ {category.listPageDescription} +
+
+
+
+ ), + { + width: 1200, + height: 630, + fonts: interSemibold + ? [ + { + name: "Inter", + data: interSemibold, + style: "normal", + weight: 600, + }, + ] + : [], + }, + ); +} diff --git a/apps/web/app/api/resend/webhook/route.ts b/apps/web/app/api/resend/webhook/route.ts index ef479c51372..db8ffb5b864 100644 --- a/apps/web/app/api/resend/webhook/route.ts +++ b/apps/web/app/api/resend/webhook/route.ts @@ -1,3 +1,4 @@ +import { withAxiom } from "@/lib/axiom/server"; import { NextResponse } from "next/server"; import { Webhook } from "svix"; import { emailBounced } from "./email-bounced"; @@ -7,7 +8,7 @@ import { emailOpened } from "./email-opened"; const webhookSecret = process.env.RESEND_WEBHOOK_SECRET!; // POST /api/resend/webhook – listen to Resend webhooks -export const POST = async (req: Request) => { +export const POST = withAxiom(async (req: Request) => { const rawBody = await req.text(); const webhook = new Webhook(webhookSecret); @@ -33,4 +34,4 @@ export const POST = async (req: Request) => { } return NextResponse.json({ message: "Webhook processed." }); -}; +}); diff --git a/apps/web/app/api/route.ts b/apps/web/app/api/route.ts index 50e1ba7a48d..b3149410a18 100644 --- a/apps/web/app/api/route.ts +++ b/apps/web/app/api/route.ts @@ -1,8 +1,14 @@ import { document } from "@/lib/openapi"; import { NextResponse } from "next/server"; -export const runtime = "edge"; +export const dynamic = "force-static"; export function GET() { - return NextResponse.json(document); + return NextResponse.json(document, { + headers: { + // cache indefinitely till next deployment + "Vercel-CDN-Cache-Control": "s-maxage=31536000", + "Cache-Control": "public, max-age=31536000", + }, + }); } diff --git a/apps/web/app/api/utm/[id]/route.ts b/apps/web/app/api/utm/[id]/route.ts index 466faf0dd8d..ae1574f4014 100644 --- a/apps/web/app/api/utm/[id]/route.ts +++ b/apps/web/app/api/utm/[id]/route.ts @@ -1,14 +1,10 @@ import { DubApiError } from "@/lib/api/errors"; import { extractUtmParams } from "@/lib/api/utm/extract-utm-params"; import { withWorkspace } from "@/lib/auth"; -import { qstash } from "@/lib/cron"; +import { syncGroupUtmJob } from "@/lib/jobs/handlers/sync-group-utm-job"; import { prisma } from "@/lib/prisma"; import { updateUTMTemplateBodySchema } from "@/lib/zod/schemas/utm"; -import { - APP_DOMAIN_WITH_NGROK, - constructURLFromUTMParams, - deepEqual, -} from "@dub/utils"; +import { constructURLFromUTMParams, deepEqual } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; @@ -96,15 +92,9 @@ export const PATCH = withWorkspace( } } - const res = await qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/sync-utm`, - body: { - groupId: partnerGroup.id, - }, + await syncGroupUtmJob.dispatch({ + groupId: partnerGroup.id, }); - console.log( - `Scheduled sync-utm job for template ${template.id}: ${JSON.stringify(res, null, 2)}`, - ); })(), ); } diff --git a/apps/web/app/api/veriff/webhook/route.ts b/apps/web/app/api/veriff/webhook/route.ts index 0dd3c215261..54f71df5e31 100644 --- a/apps/web/app/api/veriff/webhook/route.ts +++ b/apps/web/app/api/veriff/webhook/route.ts @@ -1,10 +1,12 @@ +import { withAxiom } from "@/lib/axiom/server"; +import { timingSafeCompare } from "@/lib/webhook/timing-safe-compare"; import { logAndRespond } from "app/(ee)/api/cron/utils"; import crypto from "crypto"; import { handleDecisionEvent } from "./handle-decision-event"; import { handleSessionEvent } from "./handle-session-event"; // POST /api/veriff/webhook -export const POST = async (req: Request) => { +export const POST = withAxiom(async (req: Request) => { const rawBody = await req.text(); const signature = req.headers.get("x-hmac-signature"); @@ -17,17 +19,7 @@ export const POST = async (req: Request) => { const expectedApiKey = process.env.VERIFF_API_KEY; - if (!expectedApiKey || !authClient) { - return logAndRespond("Invalid auth client.", { status: 401 }); - } - - const authClientBuffer = Uint8Array.from(Buffer.from(authClient)); - const expectedApiKeyBuffer = Uint8Array.from(Buffer.from(expectedApiKey)); - - if ( - authClientBuffer.length !== expectedApiKeyBuffer.length || - !crypto.timingSafeEqual(authClientBuffer, expectedApiKeyBuffer) - ) { + if (!expectedApiKey || !timingSafeCompare(authClient, expectedApiKey)) { return logAndRespond("Invalid auth client.", { status: 401 }); } @@ -42,16 +34,7 @@ export const POST = async (req: Request) => { .update(rawBody) .digest("hex"); - const computedSignatureBuffer = Uint8Array.from( - Buffer.from(computedSignature), - ); - const signatureBuffer = Uint8Array.from(Buffer.from(signature)); - - const isSignatureValid = - computedSignatureBuffer.length === signatureBuffer.length && - crypto.timingSafeEqual(computedSignatureBuffer, signatureBuffer); - - if (!isSignatureValid) { + if (!timingSafeCompare(signature, computedSignature)) { return logAndRespond("Invalid signature.", { status: 400 }); } @@ -62,4 +45,4 @@ export const POST = async (req: Request) => { } else { return await handleSessionEvent(body); } -}; +}); diff --git a/apps/web/app/api/workspaces/[idOrSlug]/billing/payment-methods/route.ts b/apps/web/app/api/workspaces/[idOrSlug]/billing/payment-methods/route.ts index 2de6675303e..1c2bd21f491 100644 --- a/apps/web/app/api/workspaces/[idOrSlug]/billing/payment-methods/route.ts +++ b/apps/web/app/api/workspaces/[idOrSlug]/billing/payment-methods/route.ts @@ -5,6 +5,7 @@ import { DIRECT_DEBIT_PAYMENT_METHOD_TYPES, DIRECT_DEBIT_PAYMENT_TYPES_INFO, PAYMENT_METHOD_TYPES, + SEPA_ENABLED_WORKSPACE_IDS, } from "@/lib/constants/payouts"; import { stripe } from "@/lib/stripe"; import { @@ -134,7 +135,11 @@ export const POST = withWorkspace( return NextResponse.json({ url }); } - if (method === "sepa_debit" && workspace.plan !== "enterprise") { + if ( + method === "sepa_debit" && + workspace.plan !== "enterprise" && + !SEPA_ENABLED_WORKSPACE_IDS.has(workspace.id) + ) { throw new DubApiError({ code: "forbidden", message: "SEPA Debit is only available on the Enterprise plan.", diff --git a/apps/web/app/api/workspaces/route.ts b/apps/web/app/api/workspaces/route.ts index 4b5800a4677..6e143aa8997 100644 --- a/apps/web/app/api/workspaces/route.ts +++ b/apps/web/app/api/workspaces/route.ts @@ -1,3 +1,4 @@ +import { isCI } from "@/lib/api/environment"; import { DubApiError } from "@/lib/api/errors"; import { generateRandomString } from "@/lib/api/utils/generate-random-string"; import { createWorkspaceId } from "@/lib/api/workspaces/create-workspace-id"; @@ -89,7 +90,8 @@ export const POST = withSession(async ({ req, session }) => { }, }); - if (freeWorkspaces >= FREE_WORKSPACES_LIMIT) { + // apply free workspaces limit (if not in CI) + if (freeWorkspaces >= FREE_WORKSPACES_LIMIT && !isCI) { throw new DubApiError({ code: "exceeded_limit", message: `You can only create up to ${FREE_WORKSPACES_LIMIT} free workspaces. Additional workspaces require a paid plan.`, diff --git a/apps/web/app/app.dub.co/(auth)/oauth/authorize/page.tsx b/apps/web/app/app.dub.co/(auth)/oauth/authorize/page.tsx index f80f079e725..b892c74e588 100644 --- a/apps/web/app/app.dub.co/(auth)/oauth/authorize/page.tsx +++ b/apps/web/app/app.dub.co/(auth)/oauth/authorize/page.tsx @@ -1,9 +1,10 @@ import { validateAuthorizeRequest } from "@/lib/api/oauth/actions"; import { getSession } from "@/lib/auth"; import { authorizeRequestSchema } from "@/lib/zod/schemas/oauth"; +import { Callout } from "@/ui/shared/callout"; import EmptyState from "@/ui/shared/empty-state"; import { BlurImage, Logo } from "@dub/ui"; -import { CircleWarning, CubeSettings } from "@dub/ui/icons"; +import { CubeSettings } from "@dub/ui/icons"; import { constructMetadata } from "@dub/utils"; import { ArrowLeftRight } from "lucide-react"; import { redirect } from "next/navigation"; @@ -81,9 +82,12 @@ export default async function Authorize(props: { {!integration.verified && ( -
- -
+ +

Dub hasn't verified this integration

@@ -92,7 +96,7 @@ export default async function Authorize(props: { workspace.

-
+ )}
diff --git a/apps/web/app/app.dub.co/(auth-marketing)/layout.tsx b/apps/web/app/app.dub.co/(auth-marketing)/layout.tsx index 6e509c8240b..047d821f4e7 100644 --- a/apps/web/app/app.dub.co/(auth-marketing)/layout.tsx +++ b/apps/web/app/app.dub.co/(auth-marketing)/layout.tsx @@ -1,4 +1,5 @@ import Toolbar from "@/ui/layout/toolbar/toolbar"; +import { Analytics as DubAnalytics } from "@dub/analytics/react"; import { Grid, Wordmark } from "@dub/ui"; import { cn } from "@dub/utils"; import { ReactNode } from "react"; @@ -11,8 +12,19 @@ export default function AuthMarketingLayout({ }) { return ( <> + -
{/* Left: Main auth content */}
@@ -53,7 +65,7 @@ export default function AuthMarketingLayout({ ))}
-
+
(); - - const { slug } = useWorkspace(); - const { data: customer, isLoading } = useCustomer({ - customerId, - query: { includeExpandedFields: true }, - }); - - if (!customer && !isLoading) redirect(`/${slug}/customers`); - - return !customer || (customer.partner && customer.programId) ? ( -
-

- Partner earnings -

-
- -
- {customer?.partner ? ( - <> - - - {customer.partner.name} - - - ) : ( - <> -
-
- - )} -
- - - -
- -
-
-
- ) : null; -} - -const PartnerEarningsTable = memo(({ customerId }: { customerId: string }) => { - const { id: workspaceId, slug } = useWorkspace(); - - const { data: commissions, isLoading: isComissionsLoading } = useSWR< - CommissionResponse[] - >( - `/api/commissions?${new URLSearchParams({ - customerId, - workspaceId: workspaceId!, - pageSize: CUSTOMER_PAGE_EVENTS_LIMIT.toString(), - })}`, - fetcher, - ); - - const { data: totalCommissions, isLoading: isTotalCommissionsLoading } = - useSWR<{ all: { count: number } }>( - // Only fetch total earnings count if the earnings data is equal to the limit - commissions?.length === CUSTOMER_PAGE_EVENTS_LIMIT && - `/api/commissions/count?${new URLSearchParams({ - customerId, - workspaceId: workspaceId!, - })}`, - fetcher, - ); - - return ( - - ); -}); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/links/customers/[customerId]/earnings/page.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/links/customers/[customerId]/earnings/page.tsx index effdf5c3a79..6c9a612a2dd 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/links/customers/[customerId]/earnings/page.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/links/customers/[customerId]/earnings/page.tsx @@ -1,5 +1,100 @@ -import { CustomerEarningsPageClient } from "./page-client"; +"use client"; + +import { CUSTOMER_PAGE_EVENTS_LIMIT } from "@/lib/constants/misc"; +import useCustomer from "@/lib/swr/use-customer"; +import useWorkspace from "@/lib/swr/use-workspace"; +import { CommissionResponse, CustomerEnriched } from "@/lib/types"; +import { CustomerPartnerEarningsTable } from "@/ui/customers/customer-partner-earnings-table"; +import { PartnerAvatar } from "@/ui/partners/partner-avatar"; +import { ArrowUpRight } from "@dub/ui"; +import { fetcher } from "@dub/utils"; +import Link from "next/link"; +import { redirect, useParams } from "next/navigation"; +import { memo } from "react"; +import useSWR from "swr"; export default function CustomerEarningsPage() { - return ; + const { customerId } = useParams<{ customerId: string }>(); + + const { slug } = useWorkspace(); + const { data: customer, isLoading } = useCustomer({ + customerId, + query: { includeExpandedFields: true }, + }); + + if (!customer && !isLoading) redirect(`/${slug}/customers`); + + return !customer || (customer.partner && customer.programId) ? ( +
+

+ Partner earnings +

+
+ +
+ {customer?.partner ? ( + <> + + + {customer.partner.name} + + + ) : ( + <> +
+
+ + )} +
+ + + +
+ +
+
+
+ ) : null; } + +const PartnerEarningsTable = memo(({ customerId }: { customerId: string }) => { + const { id: workspaceId, slug } = useWorkspace(); + + const { data: commissions, isLoading: isComissionsLoading } = useSWR< + CommissionResponse[] + >( + `/api/commissions?${new URLSearchParams({ + customerId, + workspaceId: workspaceId!, + pageSize: CUSTOMER_PAGE_EVENTS_LIMIT.toString(), + })}`, + fetcher, + ); + + const { data: totalCommissions, isLoading: isTotalCommissionsLoading } = + useSWR<{ all: { count: number } }>( + // Only fetch total earnings count if the earnings data is equal to the limit + commissions?.length === CUSTOMER_PAGE_EVENTS_LIMIT && + `/api/commissions/count?${new URLSearchParams({ + customerId, + workspaceId: workspaceId!, + })}`, + fetcher, + ); + + return ( + + ); +}); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx index 86843091acb..0efe865149e 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx @@ -601,7 +601,7 @@ function BountySubmissionDetailsSheetContent({
- + {RejectBountySubmissionModal} {ConfirmApproveBountySubmissionModal}
); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx index 1e73ff202a0..10c6ef18564 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx @@ -256,7 +256,6 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) {
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx index d547ffaa380..113e2f751e6 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx @@ -23,6 +23,7 @@ import { import { cn, formatDate } from "@dub/utils"; import { BountyStartMode } from "@prisma/client"; import { addDays, addMonths, addWeeks } from "date-fns"; +import { useParams } from "next/navigation"; import { ReactNode, useContext, useEffect, useState } from "react"; type PresetOption = { value: T; label: string }; @@ -388,14 +389,11 @@ function BountyDatePicker({ interface BountyDurationProps { value: BountyTimingInput; onChange: (value: BountyTimingInput) => void; - isEditing?: boolean; } -export function BountyDuration({ - value, - onChange, - isEditing = false, -}: BountyDurationProps) { +export function BountyDuration({ value, onChange }: BountyDurationProps) { + const { bountyId } = useParams(); + const isEditing = !!bountyId; const initialPresets = parsePresetsFromValue(value, isEditing); const [startPreset, setStartPreset] = useState( diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-logic.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-logic.tsx index 7c16729f27f..b4daf32fece 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-logic.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-logic.tsx @@ -10,9 +10,11 @@ import { InlineBadgePopover, InlineBadgePopoverMenu, } from "@/ui/shared/inline-badge-popover"; +import { DynamicTooltipWrapper } from "@dub/ui"; import { Trophy } from "@dub/ui/icons"; import { cn, currencyFormatter, nFormatter } from "@dub/utils"; import { BountyStartMode } from "@prisma/client"; +import { useParams } from "next/navigation"; import { Controller } from "react-hook-form"; import { BountyAmountInput } from "./bounty-amount-input"; import { useBountyFormContext } from "./bounty-form-context"; @@ -23,6 +25,9 @@ const PERFORMANCE_SCOPE_DESCRIPTIONS = { } as const; export function BountyLogic({ className }: { className?: string }) { + const { bountyId } = useParams(); + const isEditing = !!bountyId; + const { control, watch } = useBountyFormContext(); const [attribute, value, startMode] = watch([ @@ -45,31 +50,45 @@ export function BountyLogic({ className }: { className?: string }) { control={control} name="performanceScope" render={({ field }) => ( - - - + + + + + + )} /> field.onChange(editor.getJSON())} variables={[...EMAIL_TEMPLATE_VARIABLES]} + variableInfo={EMAIL_TEMPLATE_VARIABLE_INFO} editable={!isLocked} uploadImage={async (file) => { try { diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx index e4e7ca78030..bfa2982fe31 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/send-email-preview-modal.tsx @@ -3,7 +3,7 @@ import { useApiMutation } from "@/lib/swr/use-api-mutation"; import useUser from "@/lib/swr/use-user"; import { Button, Modal, useEnterSubmit, useMediaQuery } from "@dub/ui"; -import { Dispatch, SetStateAction, useState } from "react"; +import { Dispatch, SetStateAction, useCallback, useState } from "react"; import { useWatch } from "react-hook-form"; import { toast } from "sonner"; import { useCampaignFormContext } from "./campaign-form-context"; @@ -135,15 +135,20 @@ export function useSendEmailPreviewModal({ const [showSendEmailPreviewModal, setShowSendEmailPreviewModal] = useState(false); - return { - showSendEmailPreviewModal, - setShowSendEmailPreviewModal, - SendEmailPreviewModal: () => ( + const SendEmailPreviewModalCallback = useCallback( + () => ( ), + [showSendEmailPreviewModal, campaignId], + ); + + return { + showSendEmailPreviewModal, + setShowSendEmailPreviewModal, + SendEmailPreviewModal: SendEmailPreviewModalCallback, }; } diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/transactional-campaign-logic.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/transactional-campaign-logic.tsx index 93b48192dd0..7004dda4912 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/transactional-campaign-logic.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/[campaignId]/transactional-campaign-logic.tsx @@ -9,17 +9,16 @@ import { type SendCampaignAttributeKey, } from "@/lib/api/workflows/send-campaign/schema"; import { satisfiesExclusiveAttributeRules } from "@/lib/api/workflows/utils"; -import { handleMoneyInputChange, handleMoneyKeyDown } from "@/lib/form-utils"; import { DurationPopoverContent } from "@/ui/shared/duration-popover-content"; import { InlineBadgePopover, - InlineBadgePopoverContext, + InlineBadgePopoverAmountInput, InlineBadgePopoverMenu, } from "@/ui/shared/inline-badge-popover"; import { Button } from "@dub/ui"; import { Xmark } from "@dub/ui/icons"; -import { cn, currencyFormatter, pluralize } from "@dub/utils"; -import { useContext, useEffect, useMemo, useRef } from "react"; +import { currencyFormatter, pluralize } from "@dub/utils"; +import { ChangeEvent, useEffect, useMemo, useRef } from "react"; import { Controller, useFieldArray } from "react-hook-form"; import { useCampaignFormContext } from "./campaign-form-context"; @@ -152,8 +151,11 @@ function ConditionRow({ setValue( `triggerConditions.${index}.value`, attribute === "partnerJoined" ? 0 : (null as any), + { shouldDirty: true }, ); - setValue(`triggerConditions.${index}.operator`, "gte"); + setValue(`triggerConditions.${index}.operator`, "gte", { + shouldDirty: true, + }); } prevAttributeRef.current = attribute; @@ -162,7 +164,7 @@ function ConditionRow({ // Ensure partnerJoined always has value 0 useEffect(() => { if (attribute === "partnerJoined" && value !== 0) { - setValue(`triggerConditions.${index}.value`, 0); + setValue(`triggerConditions.${index}.value`, 0, { shouldDirty: true }); } }, [attribute, value, index, setValue]); @@ -235,7 +237,7 @@ function ConditionRow({ {config.inputType === "dropdown" ? ( ) : ( - + )} )} @@ -299,83 +301,57 @@ function DropdownValueInput({ function ValueInput({ index, config, - value, }: { index: number; config: { inputType?: string }; - value: number | null | undefined; }) { - const { watch, setValue } = useCampaignFormContext(); - const { setIsOpen } = useContext(InlineBadgePopoverContext); - - const storedValue = watch(`triggerConditions.${index}.value`); - + const { control } = useCampaignFormContext(); const isCurrency = config.inputType === "currency"; - const displayValue = - isCurrency && storedValue ? storedValue / 100 : storedValue; - - const hasValue = value !== null && value !== undefined; - return ( - -
- {isCurrency && ( - - $ - - )} - { - const nextValue = e.target.value; - if (nextValue === "") { - setValue(`triggerConditions.${index}.value`, null as any); - } else { - const numValue = +nextValue; - setValue( - `triggerConditions.${index}.value`, - isCurrency ? Math.round(numValue * 100) : numValue, - ); - } + { + const storedValue = field.value; + const displayValue = + isCurrency && storedValue ? storedValue / 100 : storedValue; + const hasValue = storedValue !== null && storedValue !== undefined; - if (isCurrency) { - handleMoneyInputChange(e); - } - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - setIsOpen(false); - return; + return ( + + ) => { + const nextValue = e.target.value; - if (isCurrency) { - handleMoneyKeyDown(e); - } - }} - /> - {isCurrency && ( - - USD - - )} -
-
+ if (nextValue === "") { + field.onChange(null as unknown as number); + return; + } + + const numValue = +nextValue; + field.onChange( + isCurrency ? Math.round(numValue * 100) : numValue, + ); + }} + onBlur={field.onBlur} + /> + + ); + }} + /> ); } diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/delete-campaign-modal.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/delete-campaign-modal.tsx index 9b31a015984..b8bb4c59ef6 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/delete-campaign-modal.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/campaigns/delete-campaign-modal.tsx @@ -142,7 +142,7 @@ export function useDeleteCampaignModal( campaign={campaign} /> ); - }, [showDeleteCampaignModal, setShowDeleteCampaignModal, campaign]); + }, [showDeleteCampaignModal, setShowDeleteCampaignModal]); return useMemo( () => ({ diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx index 2e1b8e80107..89355406456 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx @@ -1,14 +1,13 @@ -import { createClawbackAction } from "@/lib/actions/partners/create-clawback"; import { mutatePrefix } from "@/lib/swr/mutate"; +import { useApiMutation } from "@/lib/swr/use-api-mutation"; import useWorkspace from "@/lib/swr/use-workspace"; import { CLAWBACK_REASONS, - createClawbackSchema, + createCommissionResponseSchema, } from "@/lib/zod/schemas/commissions"; import { PartnerSelector } from "@/ui/partners/partner-selector"; import { X } from "@/ui/shared/icons"; import { Button, Sheet } from "@dub/ui"; -import { useAction } from "next-safe-action/hooks"; import { useParams } from "next/navigation"; import { useState } from "react"; import { Controller, useForm } from "react-hook-form"; @@ -21,7 +20,11 @@ interface CreateClawbackSheetProps { nested?: boolean; } -type FormData = z.infer; +type FormData = { + partnerId?: string; + amount?: number; + reason?: (typeof CLAWBACK_REASONS)[number]["value"]; +}; function CreateClawbackSheetContent( props: Omit, @@ -37,47 +40,42 @@ function CreateClawbackSheetContent( reset, watch, getValues, - formState: { errors, isSubmitting, isSubmitSuccessful }, + formState: { errors, isSubmitting }, } = useForm({ defaultValues: { partnerId: params.partnerId, - description: "", + reason: undefined, }, }); - const [partnerId, amount, description] = watch([ - "partnerId", - "amount", - "description", - ]); + const [partnerId, amount, reason] = watch(["partnerId", "amount", "reason"]); - const { executeAsync, isPending } = useAction(createClawbackAction, { - onSuccess: () => { - toast.success("A clawback has been created for the partner!"); - setIsOpen(false); - mutatePrefix(`/api/commissions?workspaceId=${workspaceId}`); - const currentValues = getValues(); - reset(currentValues); - }, - onError({ error }) { - toast.error(error.serverError || "Failed to create clawback."); - }, - }); + const { makeRequest, isSubmitting: isCreating } = + useApiMutation>(); const onSubmit = async (data: FormData) => { if (!workspaceId || !defaultProgramId) { return; } - await executeAsync({ - ...data, - amount: data.amount * 100, - workspaceId, + await makeRequest("/api/commissions", { + method: "POST", + body: { + type: "custom", + partnerId: data.partnerId, + amount: data.amount ? -Math.round(data.amount * 100) : 0, + description: data.reason, + }, + onSuccess: async ({ message }) => { + toast.success(message); + setIsOpen(false); + await mutatePrefix("/api/commissions"); + const currentValues = getValues(); + reset(currentValues); + }, }); }; - const disableSubmitButton = !partnerId || !amount || !description; - return (
@@ -110,7 +108,7 @@ function CreateClawbackSheetContent( rules={{ required: true }} render={({ field }) => ( )} @@ -171,21 +169,21 @@ function CreateClawbackSheetContent(
( )} /> - {errors.description && ( + {errors.reason && ( - {errors.description.message} + {errors.reason.message} )}
@@ -216,15 +214,15 @@ function CreateClawbackSheetContent( onClick={() => setIsOpen(false)} text="Cancel" className="w-fit" - disabled={isPending || isSubmitting || isSubmitSuccessful} + disabled={isCreating || isSubmitting} />
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx index b7ad59f1f4b..cb9250d3a22 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx @@ -17,6 +17,7 @@ import { ProgramSheetAccordionItem, ProgramSheetAccordionTrigger, } from "@/ui/partners/program-sheet-accordion"; +import { Callout } from "@/ui/shared/callout"; import { X } from "@/ui/shared/icons"; import { MaxCharactersCounter } from "@/ui/shared/max-characters-counter"; import { @@ -668,26 +669,26 @@ function CreateCommissionSheetContent({
) : noStripeCustomerId ? ( - + ) : stripeInvoicesError ? ( -
+ Failed to load invoices. Try again. -
+ ) : stripeInvoices.length === 0 ? (

diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/add-edit-group-default-link-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/add-edit-group-default-link-sheet.tsx index 3fecf4ebfae..44c1ee3401f 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/add-edit-group-default-link-sheet.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/add-edit-group-default-link-sheet.tsx @@ -13,7 +13,7 @@ import { RewardIconSquare } from "@/ui/partners/rewards/reward-icon-square"; import { X } from "@/ui/shared/icons"; import { Button, Input, Sheet } from "@dub/ui"; import { Eye, Hyperlink } from "@dub/ui/icons"; -import { normalizeUrl } from "@dub/utils"; +import { normalizeUrl, safeDecodeURIComponent } from "@dub/utils"; import { Dispatch, PropsWithChildren, @@ -47,7 +47,7 @@ function DefaultPartnerLinkSheetContent({ const { handleSubmit, watch, setValue, formState } = useForm({ defaultValues: { domain: link?.domain || program?.domain || "", - url: link?.url || "", + url: link?.url ? safeDecodeURIComponent(link.url) : "", }, }); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/group-link-settings.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/group-link-settings.tsx index e7b10629a5e..6af691513e6 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/group-link-settings.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/group-link-settings.tsx @@ -1,6 +1,7 @@ "use client"; import { getLinkStructureOptions } from "@/lib/partners/get-link-structure-options"; +import { PARTNER_MACROS } from "@/lib/partners/macros"; import { mutatePrefix } from "@/lib/swr/mutate"; import { useApiMutation } from "@/lib/swr/use-api-mutation"; import useGroup from "@/lib/swr/use-group"; @@ -260,19 +261,30 @@ function GroupLinkSettingsForm({ group }: { group: GroupProps }) { heading="UTM parameters" description="Configure [UTM tracking parameters](https://dub.co/help/article/partner-link-settings#utm-parameters) for all links in this group" > - { - setValue(key, value, { shouldDirty: true }); - }} - /> +

+ { + setValue(key, value, { shouldDirty: true }); + }} + suggestions={PARTNER_MACROS.map((m) => ({ + value: m.macro, + description: m.description, + }))} + /> +

+ Dynamic values:{" "} + {"{{PARTNER_NAME}}"},{" "} + {"{{PARTNER_LINK_KEY}}"} +

+
)} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/partner-link-preview.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/partner-link-preview.tsx index 73bc2440232..68f80779a0d 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/partner-link-preview.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/links/partner-link-preview.tsx @@ -1,7 +1,7 @@ import { getLinkStructureOptions } from "@/lib/partners/get-link-structure-options"; import { LinkLogo } from "@dub/ui"; import { ArrowTurnRight2 } from "@dub/ui/icons"; -import { cn, getApexDomain, getPrettyUrl } from "@dub/utils"; +import { cn, getApexDomain, getPrettyUrl, safeDecodeURIComponent } from "@dub/utils"; import { PartnerLinkStructure } from "@prisma/client"; import { useMemo } from "react"; @@ -64,7 +64,9 @@ export function PartnerLinkPreview({ {url ? ( <> - {getPrettyUrl(url)} + + {getPrettyUrl(safeDecodeURIComponent(url))} + ) : (
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-move-rules.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-move-rules.tsx index 027523f5931..53ac0d9778b 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-move-rules.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-move-rules.tsx @@ -112,8 +112,13 @@ export function GroupMoveRules() { [metricRuleIndexes], ); + const hasIncompleteMetricRule = metricRuleIndexes.some( + ({ rule }) => !rule.attribute, + ); + const canAddMetricRule = - usedMetricAttributes.length < GROUP_MOVE_METRIC_ATTRIBUTE_KEYS.length; + !hasIncompleteMetricRule && + metricRuleIndexes.length < GROUP_MOVE_METRIC_ATTRIBUTE_KEYS.length; // Source group is only an additional condition after a complete metric rule const canAddPartnerGroupCondition = @@ -220,7 +225,9 @@ export function GroupMoveRules() { disabled={!canAddMetricRule && ruleFields.length > 0} disabledTooltip={ !canAddMetricRule && ruleFields.length > 0 - ? "All rules are in use. Delete existing rules." + ? hasIncompleteMetricRule + ? "Select an activity before adding another rule" + : "All available rules have been added" : undefined } /> diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-menu-popover.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-menu-popover.tsx index ca526a1b76c..3d124ffc53b 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-menu-popover.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-menu-popover.tsx @@ -4,6 +4,7 @@ import { PROGRAM_IMPORT_SOURCES } from "@/lib/constants/program"; import useWorkspace from "@/lib/swr/use-workspace"; import { useExportPartnersModal } from "@/ui/modals/export-partners-modal"; import { useImportFirstPromoterModal } from "@/ui/modals/import-firstpromoter-modal"; +import { useImportLemonSqueezyModal } from "@/ui/modals/import-lemonsqueezy-modal"; import { useImportPartnerStackModal } from "@/ui/modals/import-partnerstack-modal"; import { useImportRewardfulModal } from "@/ui/modals/import-rewardful-modal"; import { useImportTapfiliateModal } from "@/ui/modals/import-tapfiliate-modal"; @@ -23,6 +24,7 @@ export function PartnersMenuPopover() { const { ImportPartnerStackModal } = useImportPartnerStackModal(); const { ImportFirstPromoterModal } = useImportFirstPromoterModal(); const { ImportTapfiliateModal } = useImportTapfiliateModal(); + const { ImportLemonSqueezyModal } = useImportLemonSqueezyModal(); const { ExportPartnersModal, setShowExportPartnersModal } = useExportPartnersModal(); @@ -34,6 +36,7 @@ export function PartnersMenuPopover() { + ({ @@ -160,6 +173,7 @@ export function PartnersTable() { data: partners, error, isLoading, + isValidating, } = useSWR( `/api/partners${getQueryString({ workspaceId, @@ -537,7 +551,12 @@ export function PartnersTable() { return (
- + {partners?.length !== 0 ? ( ) : ( @@ -564,15 +583,18 @@ function PartnersFilters({ sortBy, sortOrder, status, + searchLoading, }: { sortBy: string; sortOrder: "asc" | "desc"; - status: ProgramEnrollmentStatus; + status: ProgramEnrollmentStatus | undefined; + searchLoading: boolean; }) { const { queryParams, searchParams } = useRouterStuff(); const { partnersCount: inviteCount } = usePartnersCount({ status: ProgramEnrollmentStatus.invited, + ignoreParams: true, }); const { @@ -583,7 +605,13 @@ function PartnersFilters({ onRemoveFilter, onRemoveAll, onToggleOperator, - } = usePartnerFilters({ sortBy, sortOrder, status }); + setSelectedFilter, + setSearch, + } = usePartnerFilters({ + sortBy, + sortOrder, + ...(status && { status }), + }); const showPendingInvitesButton = inviteCount > 0 && @@ -600,6 +628,8 @@ function PartnersFilters({ onSelect={onSelect} onRemove={onRemove} onRemoveFilter={onRemoveFilter} + onSearchChange={setSearch} + onSelectedFilterChange={setSelectedFilter} /> {showPendingInvitesButton ? ( -
-
{processing && }
-
+ { + e.preventDefault(); + e.stopPropagation(); + void handleAdd(); + }} + > +
+ setHostname(e.target.value)} + autoComplete="off" + autoFocus={!isMobile} + placeholder="example.com or *.example.com" + className={cn( + "block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm", + )} + /> +
+ +
-
- - ); -}; - -interface AddHostnameModalProps { - showModal: boolean; - setShowModal: (showModal: boolean) => void; -} - -const AddHostnameModal = ({ - showModal, - setShowModal, -}: AddHostnameModalProps) => { - const close = () => setShowModal(false); - return ( - -
-

Add hostname

- -
- -
- -
+
); }; -export function useAddHostnameModal() { +export function useAddHostnameModal({ + existingHostnames, + onAdd, +}: { + existingHostnames: string[]; + onAdd: (hostname: string) => void | Promise; +}) { const [showAddHostnameModal, setShowAddHostnameModal] = useState(false); - const AddHostnameModalCallback = useCallback(() => { - return ( + return { + setShowAddHostnameModal, + addHostnameModal: ( - ); - }, [showAddHostnameModal, setShowAddHostnameModal]); - - return useMemo( - () => ({ - setShowAddHostnameModal, - AddHostnameModal: AddHostnameModalCallback, - }), - [setShowAddHostnameModal, AddHostnameModalCallback], - ); + ), + }; } diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/add-sitemap-modal.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/add-sitemap-modal.tsx new file mode 100644 index 00000000000..16af622c7d2 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/add-sitemap-modal.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { X } from "@/ui/shared/icons"; +import { Button, Modal, useMediaQuery } from "@dub/ui"; +import { cn } from "@dub/utils"; +import { useCallback, useMemo, useState } from "react"; +import { toast } from "sonner"; + +const normalizeSitemapUrl = (value: string) => { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + const withProtocol = + trimmed.startsWith("http://") || trimmed.startsWith("https://") + ? trimmed + : `https://${trimmed}`; + + try { + return new URL(withProtocol).toString(); + } catch { + return null; + } +}; + +const AddSitemapForm = ({ + existingUrls, + onAdd, + onCancel, +}: { + existingUrls: string[]; + onAdd: (url: string) => void; + onCancel?: () => void; +}) => { + const [url, setUrl] = useState(""); + const { isMobile } = useMediaQuery(); + const normalizedUrl = normalizeSitemapUrl(url); + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + + if (!normalizedUrl) { + toast.error("Enter a valid sitemap URL."); + return; + } + + if (existingUrls.includes(normalizedUrl)) { + toast.error("Sitemap already exists."); + return; + } + + onAdd(normalizedUrl); + setUrl(""); + }} + > +
+ setUrl(e.target.value)} + autoComplete="off" + autoFocus={!isMobile} + placeholder="https://acme.com/sitemap.xml" + className={cn( + "block w-full rounded-md border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm", + )} + /> +
+ +
+
+ + ); +}; + +export function useAddSitemapModal({ + existingUrls, + onAdd, +}: { + existingUrls: string[]; + onAdd: (url: string) => void; +}) { + const [showAddSitemapModal, setShowAddSitemapModal] = useState(false); + + const AddSitemapModalCallback = useCallback(() => { + return ( + +
+

Add sitemap

+ +
+ +
+ setShowAddSitemapModal(false)} + onAdd={(sitemapUrl) => { + onAdd(sitemapUrl); + setShowAddSitemapModal(false); + }} + /> +
+
+ ); + }, [showAddSitemapModal, existingUrls, onAdd]); + + return useMemo( + () => ({ + setShowAddSitemapModal, + AddSitemapModal: AddSitemapModalCallback, + }), + [AddSitemapModalCallback], + ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/base-script-section.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/base-script-section.tsx deleted file mode 100644 index ffd510d66b1..00000000000 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/base-script-section.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client"; - -import { LockSmall, Switch } from "@dub/ui"; -import { useId } from "react"; -import { HostnameSection } from "./hostname-section"; - -export function BaseScriptSection() { - const id = useId(); - - return ( -
-
-
-
- -

- For basic cookie-management and{" "} - - client-side click tracking - - . -

-
-
- - - -
- } - /> -
- - -
- ); -} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/complete-step-button.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/complete-step-button.tsx deleted file mode 100644 index 927418e9bcc..00000000000 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/complete-step-button.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { Button } from "@dub/ui"; - -export const CompleteStepButton = ({ - onClick, - loading, -}: { - onClick: () => void; - loading?: boolean; -}) => { - return ( - + + + + ); + })} + + + ); +} + +function StepIcon({ step }: { step: TrackingSetupStep }) { + if (!step.icon) { + return ; + } + + const Icon = step.icon; + + if (step.iconProps?.fullSize) { + return ( + + ); + } + + return ; +} + +const GENERAL_GUIDES = [ + { title: "Dub docs", href: "https://dub.co/docs" }, + { + title: "Conversion tracking guide", + href: "https://dub.co/docs/conversions/quickstart", + }, + { + title: "API reference", + href: "https://dub.co/docs/api-reference/introduction", + }, +] as const; + +export function DeveloperGuides() { + return ( +
+

+ Read developer guides +

+
+ {GENERAL_GUIDES.map((guide) => ( + + + {guide.title} ↗ + + ))} +
+
+ ); +} + +export function SetupInstructions({ + setup, + ready, +}: { + setup: TrackingSetup; + ready: boolean; +}) { + if (!ready) { + return ( +
+ +

+ Save your changes after selecting your stack, and adding your + hostnames to generate install instructions +

+
+ ); + } + + return ( +
+ + + {setup.steps.length > 0 && ( +
+ {setup.steps.map((step) => ( +
+
+ + + {step.label} + +
+
+ ))} +
+ )} +
+ ); +} + +function MainPromptCard({ prompt }: { prompt: string }) { + const [expanded, setExpanded] = useState(false); + const shouldReduceMotion = useReducedMotion(); + + return ( +
+
+
+ +

+ Main prompt +

+
+ +
+ +
+ + + +
+
+

+ {prompt} +

+
+ + {expanded ? ( +
+
+ ) : ( +
+
+ )} +
+
+
+
+ ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/site-visit-tracking-field.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/site-visit-tracking-field.tsx new file mode 100644 index 00000000000..117c1819ed1 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/site-visit-tracking-field.tsx @@ -0,0 +1,341 @@ +"use client"; + +import { MAX_TRACKED_SITEMAPS_PER_WORKSPACE } from "@/lib/zod/schemas/site-visit-tracking"; +import { DomainSelector } from "@/ui/domains/domain-selector"; +import { ThreeDots } from "@/ui/shared/icons"; +import { + AnimatedSizeContainer, + Button, + InfoTooltip, + LoadingSpinner, + Popover, + Sitemap, + Switch, +} from "@dub/ui"; +import { Trash } from "@dub/ui/icons"; +import { cn, formatDate } from "@dub/utils"; +import { RefreshCw } from "lucide-react"; +import { useReducedMotion } from "motion/react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { useAddSitemapModal } from "./add-sitemap-modal"; +import { EmptyTrackingCard } from "./empty-tracking-card"; + +export type TrackedSitemapDraft = { + url: string; + lastCrawledAt?: string; + lastUrlCount?: number; +}; + +export function SiteVisitTrackingField({ + enabled, + onEnabledChange, + siteDomainSlug, + onSiteDomainSlugChange, + sitemaps, + onSitemapsChange, + persistedSitemapUrls, + workspaceId, + onSitemapRefreshed, + disabled, + disabledTooltip, +}: { + enabled: boolean; + onEnabledChange: (enabled: boolean) => void; + siteDomainSlug: string; + onSiteDomainSlugChange: (slug: string) => void; + sitemaps: TrackedSitemapDraft[]; + onSitemapsChange: (sitemaps: TrackedSitemapDraft[]) => void; + persistedSitemapUrls: string[]; + workspaceId?: string; + onSitemapRefreshed?: () => void; + disabled?: boolean; + disabledTooltip?: string; +}) { + const shouldReduceMotion = useReducedMotion(); + const [shouldAnimateHeight, setShouldAnimateHeight] = useState(false); + const [refreshingSitemapUrl, setRefreshingSitemapUrl] = useState< + string | null + >(null); + + const addSitemap = (normalizedSitemapUrl: string) => { + if (disabled) { + return; + } + + if (!siteDomainSlug) { + toast.error("Choose a domain for sitemap imports before adding sources."); + return; + } + + if (sitemaps.some((sitemap) => sitemap.url === normalizedSitemapUrl)) { + toast.error("Sitemap already exists."); + return; + } + + if (sitemaps.length >= MAX_TRACKED_SITEMAPS_PER_WORKSPACE) { + toast.error( + `You can track up to ${MAX_TRACKED_SITEMAPS_PER_WORKSPACE} sitemaps per workspace.`, + ); + return; + } + + onSitemapsChange([ + ...sitemaps, + { + url: normalizedSitemapUrl, + }, + ]); + }; + + const { AddSitemapModal, setShowAddSitemapModal } = useAddSitemapModal({ + existingUrls: sitemaps.map((sitemap) => sitemap.url), + onAdd: addSitemap, + }); + + const refreshSitemap = async (sitemapUrl: string) => { + if (!workspaceId) { + toast.error("Workspace is still loading. Please try again."); + return; + } + + setRefreshingSitemapUrl(sitemapUrl); + + try { + const response = await fetch( + `/api/workspaces/${workspaceId}/sitemaps/import`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + sitemapUrl, + }), + }, + ); + + if (response.ok) { + toast.success("Sitemap refreshed."); + onSitemapRefreshed?.(); + } else { + const { error } = await response.json(); + toast.error(error?.message || "Failed to refresh sitemap."); + } + } catch { + toast.error("Network error, please try again."); + } finally { + setRefreshingSitemapUrl(null); + } + }; + + const addSitemapDisabledReason = disabledTooltip + ? disabledTooltip + : !siteDomainSlug + ? "Choose a domain for imports first" + : sitemaps.length >= MAX_TRACKED_SITEMAPS_PER_WORKSPACE + ? `Maximum ${MAX_TRACKED_SITEMAPS_PER_WORKSPACE} sitemaps per workspace` + : undefined; + + return ( + <> +
+ + + + {enabled && ( +
+
+ + +

+ This domain will be used for links we create when importing + pages from the sitemaps you add. +

+
+ +
+

+ Sitemaps + +

+ + {sitemaps.length === 0 ? ( + } + text="No site maps added" + action={ +
+
+ )} +
+
+ + + ); +} + +function SitemapRowMenu({ + canRefresh, + onRefresh, + onDelete, + loading, +}: { + canRefresh: boolean; + onRefresh: () => void; + onDelete: () => void; + loading: boolean; +}) { + const [openPopover, setOpenPopover] = useState(false); + + return ( + + {canRefresh && ( + <> +
+
+
+ + )} +
+
+
+ } + align="end" + openPopover={openPopover} + setOpenPopover={setOpenPopover} + > + + ); + })} + + ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/step.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/step.tsx deleted file mode 100644 index 50fbf87efa1..00000000000 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/step.tsx +++ /dev/null @@ -1,120 +0,0 @@ -"use client"; - -import { PropsWithChildren } from "react"; - -import { Button, Check2, ChevronRight } from "@dub/ui"; -import { cn, isClickOnInteractiveChild } from "@dub/utils"; -import { AnimatePresence, motion } from "motion/react"; -import { ReactNode } from "react"; - -export type Step = "connect" | "lead" | "sale"; - -export type BaseStepProps = { - expanded?: boolean; - toggleExpanded: () => void; -}; - -export type StepProps = BaseStepProps & { - id: Step; - step: number; - title: string; - subtitle?: string; - complete?: boolean; - children?: ReactNode; - contentClassName?: string; -}; - -const Step = ({ - step, - title, - subtitle, - complete, - expanded, - toggleExpanded, - children, - contentClassName, -}: PropsWithChildren) => { - return ( -
- - -
-
{ - if (!isClickOnInteractiveChild(e)) toggleExpanded(); - }} - > -
-
- {title} -
- {subtitle && ( -
- {subtitle} -
- )} -
- -
- - - - {expanded && ( - - {children} - - )} - - -
-
- ); -}; - -const StepNumber = ({ - number, - complete, -}: { - number: number; - complete?: boolean; -}) => { - return ( -
- {complete ? ( - - ) : ( - - {number} - - )} -
- ); -}; - -export default Step; diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/track-lead-guides-section.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/track-lead-guides-section.tsx deleted file mode 100644 index b25c5f8d1a6..00000000000 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/track-lead-guides-section.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client"; - -import useGuide from "@/lib/swr/use-guide"; -import { GuideActionButton } from "@/ui/guides/guide-action-button"; -import { GuideSelector } from "@/ui/guides/guide-selector"; -import { guides as allGuides } from "@/ui/guides/integrations"; -import { GuidesMarkdown } from "@/ui/guides/markdown"; -import { useSelectedGuide } from "./use-selected-guide"; - -export function TrackLeadsGuidesSection() { - const guides = allGuides.filter((guide) => guide.type === "track-lead"); - const { selectedGuide, setSelectedGuide } = useSelectedGuide({ guides }); - - const { loading, guideMarkdown } = useGuide(selectedGuide.key); - - let button: React.ReactNode; - let content: React.ReactNode; - - if (loading) { - content = ( -
-
-
-
-
-
- ); - button = ( -
- ); - } else if (guideMarkdown) { - content = {guideMarkdown}; - button = ( - - ); - } else { - content = ( -
- Failed to load guide -
- ); - } - - return ( -
-
- - - {button} -
- -
-
{content}
-
-
- ); -} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/track-sales-guides-section.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/track-sales-guides-section.tsx deleted file mode 100644 index a1dd8593cfa..00000000000 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/track-sales-guides-section.tsx +++ /dev/null @@ -1,70 +0,0 @@ -"use client"; - -import useGuide from "@/lib/swr/use-guide"; -import { GuideActionButton } from "@/ui/guides/guide-action-button"; -import { GuideSelector } from "@/ui/guides/guide-selector"; -import { InstallStripeIntegrationButton } from "@/ui/guides/install-stripe-integration-button"; -import { guides as allGuides } from "@/ui/guides/integrations"; -import { GuidesMarkdown } from "@/ui/guides/markdown"; -import { useSelectedGuide } from "./use-selected-guide"; - -export function TrackSalesGuidesSection() { - const guides = allGuides.filter((guide) => guide.type === "track-sale"); - const { selectedGuide, setSelectedGuide } = useSelectedGuide({ guides }); - - const { loading, guideMarkdown } = useGuide(selectedGuide.key); - - let button; - let content; - - if (loading) { - content = ( -
-
-
-
-
-
- ); - button = ( -
- ); - } else if (guideMarkdown) { - content = ( -
- {selectedGuide.key.startsWith("stripe") && ( - - )} - {guideMarkdown} -
- ); - button = ( - - ); - } else { - content = ( -
- Failed to load guide -
- ); - } - - return ( -
-
- - - {button} -
- -
-
{content}
-
-
- ); -} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/tracking-settings-row.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/tracking-settings-row.tsx new file mode 100644 index 00000000000..28e795ca752 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/tracking-settings-row.tsx @@ -0,0 +1,42 @@ +import { MarkdownDescription } from "@/ui/shared/markdown-description"; +import { cn } from "@dub/utils"; +import { PropsWithChildren, ReactNode } from "react"; + +export function TrackingSettingsRow({ + heading, + description, + leftExtra, + leftExtraAlign = "start", + align = "start", + children, +}: PropsWithChildren<{ + heading: string; + description: string; + leftExtra?: ReactNode; + leftExtraAlign?: "start" | "end"; + align?: "start" | "center"; +}>) { + return ( +
+
+

+ {heading} +

+ + {description} + + {leftExtra && ( +
+ {leftExtra} +
+ )} +
+
{children}
+
+ ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/use-dynamic-guide.ts b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/use-dynamic-guide.ts deleted file mode 100644 index 4ea15a17192..00000000000 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/use-dynamic-guide.ts +++ /dev/null @@ -1,167 +0,0 @@ -import useGuide from "@/lib/swr/use-guide"; -import useProgram from "@/lib/swr/use-program"; -import useWorkspace from "@/lib/swr/use-workspace"; -import { useWorkspaceStore } from "@/lib/swr/use-workspace-store"; -import { useMemo } from "react"; -import { SWRConfiguration } from "swr"; - -export function useDynamicGuide( - { guide }: { guide: string }, - swrOpts?: SWRConfiguration, -) { - const { guideMarkdown: guideMarkdownRaw, error } = useGuide(guide, swrOpts); - - const { publishableKey } = useWorkspace(); - const { program } = useProgram(); - - const [siteVisitTrackingEnabled] = useWorkspaceStore( - "analyticsSettingsSiteVisitTrackingEnabled", - ); - const [domainTrackingEnabled] = useWorkspaceStore( - "analyticsSettingsOutboundDomainTrackingEnabled", - ); - const [conversionTrackingEnabled] = useWorkspaceStore( - "analyticsSettingsConversionTrackingEnabled", - ); - - const guideMarkdown = useMemo(() => { - let result = guideMarkdownRaw; - - if (program?.domain) - result = result?.replaceAll(/yourcompany\.link/g, program.domain); - - const scriptComponents = [ - siteVisitTrackingEnabled ? "site-visit" : null, - domainTrackingEnabled ? "outbound-domains" : null, - conversionTrackingEnabled ? "conversion-tracking" : null, - ] - .filter(Boolean) - .join("."); - - if (scriptComponents.length) - result = result?.replaceAll( - /https\:\/\/www.dubcdn.com\/analytics\/script.js/g, - `https://www.dubcdn.com/analytics/script.${scriptComponents}.js`, - ); - - if (result) { - // Store original result for context checks - const originalResult = result; - - result = result - // for manual installations - add data-publishable-key after src attribute - .replaceAll( - /()/g, - (match, beforeSrc, srcAttr, afterSrc, closingTag) => { - if (match.includes("data-publishable-key")) return match; - - // Find src line in original to get indentation - const originalLines = originalResult.split("\n"); - const srcLine = - originalLines.find((line) => line.includes(srcAttr)) || ""; - const indent = srcLine.match(/^(\s*)/)?.[1] || " "; - - // Clean up other attributes - const otherAttrs = afterSrc.replace(/>$/, "").trim(); - - const domainsConfigParts = [ - ...(program ? [`"refer": "${program.domain}"`] : []), - ...(domainTrackingEnabled - ? [`"outbound": ["example.com", "example.sh"]`] - : []), - ].join(`, `); - const parts = [ - ...(publishableKey - ? [`data-publishable-key="${publishableKey}"`] - : []), - ...(domainsConfigParts - ? [`data-domains='{${domainsConfigParts}}'`] - : []), - ].join(`\n${indent}`); - - // Return: before src, src, publishable-key, other attrs, closing tag - return `${beforeSrc}${srcAttr}${parts ? `\n${indent}${parts}` : ""}${otherAttrs ? `\n${indent}${otherAttrs}` : ""}\n${closingTag}`; - }, - ) - // for React applications - add publishableKey prop after |\s+[^\n>]*\/?>|)$/gm, - (match, indent, tag, rest) => { - if (match.includes("publishableKey")) return match; - // Check context for multiline case - if (rest === "") { - const idx = originalResult.indexOf(match); - if (idx >= 0) { - const context = originalResult.substring(idx, idx + 300); - if (context.includes("publishableKey")) return match; - } - } - - const domainsConfigParts = [ - ...(program ? [`refer: "${program.domain}"`] : []), - ...(domainTrackingEnabled - ? [`outbound: ["example.com", "example.sh"]`] - : []), - ].join(`,\n${indent} `); - const parts = [ - ...(publishableKey ? [`publishableKey="${publishableKey}"`] : []), - ...(domainsConfigParts - ? [ - `domainsConfig={{\n${indent} ${domainsConfigParts}\n${indent} }}`, - ] - : []), - ].join(`\n ${indent}`); - - return `${indent}${tag}${parts ? `\n${indent} ${parts}` : ""}${rest}`; - }, - ) - // for GTM installations - add data-publishable-key after script.src - .replaceAll( - /^(\s+)(s\.src\s*=\s*"https:\/\/www\.dubcdn\.com\/analytics\/script[^"]+";)$/gm, - (match, indent, srcLine) => { - const idx = originalResult.indexOf(match); - if (idx >= 0) { - const context = originalResult.substring(idx, idx + 200); - if (context.includes("data-publishable-key")) return match; - } - - const domainsConfigParts = [ - ...(program ? [`"refer": "${program.domain}"`] : []), - ...(domainTrackingEnabled - ? [`"outbound": ["example.com", "example.sh"]`] - : []), - ].join(`, `); - const parts = [ - ...(publishableKey - ? [ - `s.setAttribute("data-publishable-key", "${publishableKey}");`, - ] - : []), - ...(domainsConfigParts - ? [ - `s.dataset.domains = JSON.stringify({${domainsConfigParts}});`, - ] - : []), - ].join(`\n${indent}`); - - return `${indent}${srcLine}${parts ? `\n${indent}${parts}` : ""}`; - }, - ); - } - - return result; - }, [ - guideMarkdownRaw, - program, - siteVisitTrackingEnabled, - domainTrackingEnabled, - conversionTrackingEnabled, - publishableKey, - ]); - - return { - guideMarkdown, - error, - loading: !guideMarkdown && !error, - }; -} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/use-selected-guide.ts b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/use-selected-guide.ts deleted file mode 100644 index ef541c36931..00000000000 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/use-selected-guide.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { IntegrationGuide } from "@/ui/guides/integrations"; -import { useRouterStuff } from "@dub/ui"; -import { useEffect, useState } from "react"; - -export function useSelectedGuide({ guides }: { guides: IntegrationGuide[] }) { - const { searchParams, queryParams } = useRouterStuff(); - const paramGuide = searchParams.get("guide"); - - if (guides.length === 0) - throw new Error("useSelectedGuide requires a non-empty guides array"); - - const [selectedGuide, setSelectedGuide] = useState( - guides[0], - ); - - useEffect(() => { - if (!paramGuide) return; - - const guide = guides.find((g) => g.key === paramGuide); - if (!guide) return; - - setSelectedGuide(guide); - }, [paramGuide, guides]); - - return { - selectedGuide, - setSelectedGuide: (guide: IntegrationGuide) => - queryParams({ set: { guide: guide.key } }), - }; -} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/verify-install.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/verify-install.tsx index e0311e32a7c..ba05739bcec 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/verify-install.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/verify-install.tsx @@ -1,151 +1,275 @@ +"use client"; + import { verifyWorkspaceSetup } from "@/lib/actions/verify-workspace-setup"; +import type { VerifyInstallationResult } from "@/lib/analytics/verify-installation"; +import { clientAccessCheck } from "@/lib/client-access-check"; import useWorkspace from "@/lib/swr/use-workspace"; -import { useWorkspaceStore } from "@/lib/swr/use-workspace-store"; -import { Button, Plug2 } from "@dub/ui"; -import { cn } from "@dub/utils"; +import { UserAvatar } from "@/ui/users/user-avatar"; +import { Button, Combobox, Globe } from "@dub/ui"; +import { cn, getPrettyUrl, OG_AVATAR_URL, timeAgo } from "@dub/utils"; import { useAction } from "next-safe-action/hooks"; -import Link from "next/link"; -import { useMemo } from "react"; +import { type ReactNode, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { CompleteStepButton } from "./complete-step-button"; -type VerifyStatus = "pending" | "success" | "error"; +const HOSTNAME_REQUIRED_MESSAGE = + "A hostname is required in order to verify installation."; -const VerifyInstallIcon = ({ status }: { status: VerifyStatus }) => { - return ( -
- -
- ); +const isVerifiableHostname = (hostname: string) => + !hostname.startsWith("*.") && + hostname !== "localhost" && + !hostname.startsWith("localhost:"); + +const VERIFY_DOCS_HREF = "https://dub.co/docs/sdks/client-side/introduction"; +const VERIFY_SUPPORT_HREF = "https://dub.co/support"; + +const ERROR_HEADLINE = { + not_installed: "Script is not installed.", + missing_attributes: "Script missing attributes.", + missing_refer_domain: "Script is missing the referral link domain.", + duplicate: "Duplicate script.", + malformed: "Malformed script.", + unreachable: "We couldn’t reach this hostname.", + unsupported: "Wildcard hostnames can’t be verified.", +}; + +const getErrorHeadline = ( + result: Extract, +) => { + if (result.error === "missing_refer_domain" && result.referDomain) { + return `Script is missing the referral link domain (${result.referDomain}).`; + } + + return ERROR_HEADLINE[result.error]; }; -type VerificationResponse = { - verifiedAt: Date; - verifiedBy: { name: string; avatarUrl?: string }; +type LastVerified = { + hostname: string; + verifiedAt: string; + user: { + id: string; + name: string | null; + image: string | null; + }; }; -const VerifyInstall = () => { - const { id: workspaceId } = useWorkspace(); +type HelperTone = "success" | "error" | "neutral"; + +export function VerifyInstall({ hostnames }: { hostnames: string[] }) { + const { id: workspaceId, role, store, mutate } = useWorkspace(); + const [selectedHostname, setSelectedHostname] = useState(null); + const [result, setResult] = useState(null); + const selectedHostnameRef = useRef(selectedHostname); + const pendingHostnameRef = useRef(null); + selectedHostnameRef.current = selectedHostname; + + const permissionsError = clientAccessCheck({ + action: "workspaces.write", + role, + customPermissionDescription: "verify tracking installation", + }).error; + const disabledTooltip = + typeof permissionsError === "string" + ? permissionsError + : !selectedHostname + ? HOSTNAME_REQUIRED_MESSAGE + : undefined; + + const lastVerified = store?.analyticsSettingsInstallationVerified as + | LastVerified + | undefined; - // const [verified, setVerified] = useState(false); + const hostnameOptions = useMemo( + () => + hostnames.filter(isVerifiableHostname).map((hostname) => ({ + value: hostname, + label: getPrettyUrl(hostname), + icon: , + })), + [hostnames], + ); + + const selectedOption = + hostnameOptions.find((option) => option.value === selectedHostname) ?? null; + + const persistedForHostname = + selectedHostname && lastVerified?.hostname === selectedHostname + ? lastVerified + : null; - const error: any | null = null; - const response: VerificationResponse | null = null; - // const response: VerificationResponse | null = { - // verifiedAt: new Date(), - // verifiedBy: { name: "Ian" }, - // }; + const resultForSelection = + result && result.hostname === selectedHostname ? result : null; + const showSuccess = resultForSelection?.status === "success"; + const showError = resultForSelection?.status === "error"; + const showLastVerified = !resultForSelection && Boolean(persistedForHostname); + const canReverify = showSuccess || showLastVerified; + + const helperTone: HelperTone | null = showSuccess + ? "success" + : showError + ? "error" + : showLastVerified + ? "neutral" + : null; const { executeAsync, isPending } = useAction(verifyWorkspaceSetup, { - async onSuccess(response) { - toast.success("Account created! Redirecting to dashboard..."); - - // if (response?.ok) { - // } else { - // toast.error( - // "Failed to sign in with credentials. Please try again or contact support.", - // ); - // } + onSuccess({ data }) { + if (!data) { + return; + } + + if (data.status === "success") { + void mutate(); + } + + if (data.hostname !== selectedHostnameRef.current) { + return; + } + + setResult(data); }, onError({ error }) { - toast.error(error.serverError); + const hostname = pendingHostnameRef.current; + + if (!hostname || hostname !== selectedHostnameRef.current) { + return; + } + + setResult({ + status: "error", + hostname, + error: "unreachable", + }); + toast.error(error.serverError || "Failed to verify installation."); }, }); - const status: VerifyStatus = useMemo(() => { - if (error) return "error"; - if (response) return "success"; - return "pending"; - }, [response, error]); + return ( +
+
+ { + setSelectedHostname(option?.value ?? null); + setResult(null); + }} + placeholder="Select hostname" + searchPlaceholder="Search hostnames..." + buttonProps={{ + className: cn("h-10 w-full", helperTone && "bg-bg-default"), + disabled: hostnameOptions.length === 0 || isPending, + disabledTooltip: + hostnameOptions.length === 0 + ? HOSTNAME_REQUIRED_MESSAGE + : isPending + ? "Verification in progress..." + : undefined, + }} + matchTriggerWidth + /> - const [complete, markComplete, { loading }] = useWorkspaceStore( - "analyticsSettingsConnectionSetupComplete", - ); + {showSuccess && ( + + Successfully connected and ready to use. + + )} - return ( -
-
{ - if (!workspaceId) { + {showError && resultForSelection?.status === "error" && ( + + {getErrorHeadline(resultForSelection)} After correcting, try + verifying again and if the issue still persists, check out our{" "} + + docs + {" "} + or{" "} + + contact support + + . + + )} + + {showLastVerified && persistedForHostname && ( + + Last verified by + + + {persistedForHostname.user.name} + + {timeAgo(new Date(persistedForHostname.verifiedAt), { + withAgo: true, + })} + + )} +
+ +
-
- + />
); -}; +} -export default VerifyInstall; +function HelperText({ + tone, + inline = false, + children, +}: { + tone: HelperTone; + inline?: boolean; + children: ReactNode; +}) { + return ( +

+ {children} +

+ ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/webhooks/create-webhook-button.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/webhooks/create-webhook-button.tsx index 86d923dfdb4..8d5ab89b2f0 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/webhooks/create-webhook-button.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/webhooks/create-webhook-button.tsx @@ -27,9 +27,10 @@ export default function CreateWebhookButton() { return (
+
+ + ); +}; + +function EventMappingsSection({ + name, + control, + title, + description, + conversionActionPlaceholder, + eventNamesPlaceholder, + eventNamesEmptyState, + conversionActionOptions, + eventNameRows, + eventCountKey, + isLoadingConversionActions, + isLoadingEventNames, +}: { + name: "leadMappings" | "saleMappings"; + control: Control; + title: string; + description: string; + conversionActionPlaceholder: string; + eventNamesPlaceholder: string; + eventNamesEmptyState: string; + conversionActionOptions: ComboboxOption[]; + eventNameRows: EventNameRow[] | undefined; + eventCountKey: "leads" | "sales"; + isLoadingConversionActions: boolean; + isLoadingEventNames: boolean; +}) { + const mappings = useWatch({ control, name }) ?? []; + const { fields, append, remove } = useFieldArray({ + control, + name, + }); + + const conversionActionLabelByValue = useMemo(() => { + return new Map( + conversionActionOptions.map((option) => [option.value, option.label]), + ); + }, [conversionActionOptions]); + + const canAddMapping = + mappings.length > 0 && + mappings.every((mapping) => mapping.conversionAction) && + mappings.length < MAX_MAPPINGS && + mappings.length < conversionActionOptions.length; + + return ( +
+

{title}

+

{description}

+ +
+
+
+ + Conversion action + + + Event names + +
+ {fields.length > 1 &&
} +
+ + {fields.map((field, index) => { + const mapping = mappings[index] ?? createEmptyMapping(); + const selectedConversionAction = + conversionActionOptions.find( + (option) => option.value === mapping.conversionAction, + ) ?? null; + const usedConversionActions = new Set( + mappings + .filter((_, mappingIndex) => mappingIndex !== index) + .map((item) => item.conversionAction) + .filter(Boolean), + ); + const usedEventNames = new Map(); + mappings.forEach((item, mappingIndex) => { + if (mappingIndex === index) { + return; + } + + const actionLabel = + conversionActionLabelByValue.get(item.conversionAction) ?? + "another conversion action"; + + for (const eventName of item.eventNames) { + usedEventNames.set(eventName, String(actionLabel)); + } + }); + + return ( +
+
( + render={({ field: conversionActionField }) => ( ({ + ...option, + disabledTooltip: usedConversionActions.has(option.value) + ? "Already used by another mapping" + : undefined, + }))} + selected={selectedConversionAction} setSelected={(option) => { if (option) { - field.onChange(option.value); + conversionActionField.onChange(option.value); } }} - placeholder={ - isLoadingOptions - ? "Loading conversion actions..." - : "Select sale conversion action" - } + placeholder={conversionActionPlaceholder} matchTriggerWidth - caret={ - - } - buttonProps={{ - className: - "h-9 w-full max-w-none justify-between gap-1.5 px-3 py-0 text-sm font-normal shadow-none", - }} + caret={comboboxCaret} + buttonProps={comboboxButtonProps} /> )} /> + + item.conversionAction) + .length > 1 + ? eventNamesPlaceholder + : eventNamesPlaceholder.replace("unmatched ", "") + } + emptyState={eventNamesEmptyState} + />
- - )} + {fields.length > 1 && ( + + )} +
+ ); + })} -
+
- +
); -}; +} + +function toFormMappings(mappings: EventMapping[]): EventMapping[] { + if (mappings.length === 0) { + return [createEmptyMapping()]; + } + + return mappings.map((mapping) => ({ + conversionAction: mapping.conversionAction, + eventNames: [...mapping.eventNames], + })); +} + +function sanitizeMappings(mappings: EventMapping[]) { + return mappings + .filter((mapping) => mapping.conversionAction) + .map((mapping) => ({ + conversionAction: mapping.conversionAction, + eventNames: [...new Set(mapping.eventNames)], + })); +} + +function buildEventNameOptions({ + rows, + selected, + usedByOthers, + countKey, +}: { + rows: EventNameRow[] | undefined; + selected: string[]; + usedByOthers: Map; + countKey: "leads" | "sales"; +}): EventNameOption[] { + const fromAnalytics = (rows ?? []) + .filter((row) => row.eventName) + .map((row) => ({ + value: row.eventName, + label: row.eventName, + meta: { count: row[countKey] }, + disabledTooltip: usedByOthers.has(row.eventName) + ? `Already mapped to ${usedByOthers.get(row.eventName)}` + : undefined, + })); + const fromAnalyticsSet = new Set(fromAnalytics.map((option) => option.value)); + const extras = selected + .filter((eventName) => !fromAnalyticsSet.has(eventName)) + .map((eventName) => ({ + value: eventName, + label: eventName, + })); + + return [...extras, ...fromAnalytics]; +} + +function FieldLabel({ + src, + alt, + children, +}: { + src: string; + alt: string; + children: string; +}) { + return ( +
+ + {children} +
+ ); +} + +function EventNamesField({ + name, + control, + options, + usedByOthers, + isLoading, + disabled, + placeholder, + emptyState, +}: { + name: + | `leadMappings.${number}.eventNames` + | `saleMappings.${number}.eventNames`; + control: Control; + options: EventNameOption[]; + usedByOthers: Map; + isLoading: boolean; + disabled: boolean; + placeholder: string; + emptyState: string; +}) { + return ( + ( + + options.find((option) => option.value === eventName) ?? { + value: eventName, + label: eventName, + }, + )} + setSelected={(selected) => { + if (disabled) { + return; + } + field.onChange(selected.map((option) => option.value)); + }} + onCreate={async (search) => { + if (disabled) { + return false; + } + + const eventName = search.trim(); + if (!eventName) { + return false; + } + + const usedBy = usedByOthers.get(eventName); + if (usedBy) { + toast.error(`Already mapped to ${usedBy}`); + return false; + } + + const current = field.value ?? []; + if (!current.includes(eventName)) { + field.onChange([...current, eventName]); + } + return true; + }} + createLabel={(search) => `Add "${search.trim()}"`} + placeholder={ + isLoading && !disabled ? "Loading event names..." : placeholder + } + searchPlaceholder="Search or add event names..." + emptyState={emptyState} + optionRight={(option) => + option.meta?.count != null ? ( + + {nFormatter(option.meta.count, { full: true })} + + ) : undefined + } + matchTriggerWidth + caret={comboboxCaret} + buttonProps={{ + ...comboboxButtonProps, + disabled, + }} + /> + )} + /> + ); +} diff --git a/apps/web/lib/integrations/google-ads/update-google-ads-settings.ts b/apps/web/lib/integrations/google-ads/update-google-ads-settings.ts index 417f090ccd0..20efcfac224 100644 --- a/apps/web/lib/integrations/google-ads/update-google-ads-settings.ts +++ b/apps/web/lib/integrations/google-ads/update-google-ads-settings.ts @@ -9,11 +9,20 @@ import { revalidatePath } from "next/cache"; import * as z from "zod/v4"; import { inferLoginCustomerId } from "./api"; import { googleAdsSettingsSchema } from "./schema"; +import { getGoogleAdsEventMappingsError } from "./utils"; const schema = googleAdsSettingsSchema.omit({ customers: true }).extend({ workspaceId: z.string(), }); +const uniqueMappingEventNames = ( + mappings: z.infer["leadMappings"], +) => + mappings.map((mapping) => ({ + ...mapping, + eventNames: [...new Set(mapping.eventNames)], + })); + export const updateGoogleAdsSettingsAction = authActionClient .inputSchema(schema) .action(async ({ parsedInput, ctx }) => { @@ -21,8 +30,9 @@ export const updateGoogleAdsSettingsAction = authActionClient const { customerId, customerName, - leadConversionAction, - saleConversionAction, + loginCustomerId: submittedLoginCustomerId, + leadMappings, + saleMappings, } = parsedInput; throwIfNoPermission({ @@ -67,13 +77,14 @@ export const updateGoogleAdsSettingsAction = authActionClient } const resolvedLoginCustomerId = customerId - ? inferLoginCustomerId({ + ? submittedLoginCustomerId?.replace(/-/g, "") || + inferLoginCustomerId({ customers: currentSettings.customers, selectedCustomerId: customerId, }) : null; - if (!customerId && (leadConversionAction || saleConversionAction)) { + if (!customerId && (leadMappings.length || saleMappings.length)) { throw new Error( "A Google Ads account is required to configure conversion actions.", ); @@ -84,20 +95,32 @@ export const updateGoogleAdsSettingsAction = authActionClient const expectedPrefix = `customers/${normalizedCustomerId}/conversionActions/`; if ( - leadConversionAction && - !leadConversionAction.startsWith(expectedPrefix) + leadMappings.some( + (mapping) => !mapping.conversionAction.startsWith(expectedPrefix), + ) ) { throw new Error("Invalid lead conversion action."); } if ( - saleConversionAction && - !saleConversionAction.startsWith(expectedPrefix) + saleMappings.some( + (mapping) => !mapping.conversionAction.startsWith(expectedPrefix), + ) ) { throw new Error("Invalid sale conversion action."); } } + const leadMappingsError = getGoogleAdsEventMappingsError(leadMappings); + if (leadMappingsError) { + throw new Error(`Lead events: ${leadMappingsError}`); + } + + const saleMappingsError = getGoogleAdsEventMappingsError(saleMappings); + if (saleMappingsError) { + throw new Error(`Sale events: ${saleMappingsError}`); + } + await prisma.installedIntegration.update({ where: { id: installedIntegration.id, @@ -108,8 +131,8 @@ export const updateGoogleAdsSettingsAction = authActionClient customerId, loginCustomerId: resolvedLoginCustomerId, customerName, - leadConversionAction, - saleConversionAction, + leadMappings: uniqueMappingEventNames(leadMappings), + saleMappings: uniqueMappingEventNames(saleMappings), }, }, }); diff --git a/apps/web/lib/integrations/google-ads/upload-conversion.ts b/apps/web/lib/integrations/google-ads/upload-conversion.ts index 696f952c785..e9cc5775abf 100644 --- a/apps/web/lib/integrations/google-ads/upload-conversion.ts +++ b/apps/web/lib/integrations/google-ads/upload-conversion.ts @@ -5,6 +5,7 @@ import { APP_DOMAIN_WITH_NGROK, getSearchParams, GOOGLE_ADS_INTEGRATION_ID, + isZeroDecimalCurrency, } from "@dub/utils"; import * as z from "zod/v4"; import { GoogleAdsApi, GoogleAdsClickId } from "./api"; @@ -14,6 +15,7 @@ import { googleAdsConversionUploadSchema, googleAdsSettingsSchema, } from "./schema"; +import { resolveGoogleAdsConversionMapping } from "./utils"; const extractGoogleAdsClickId = (url: string): GoogleAdsClickId | null => { try { @@ -54,6 +56,16 @@ export const queueGoogleAdsConversionUpload = async ( return; } + // Data Manager expects major currency units, so we need to divide by 100 + // if the currency is NOT a zero decimal currency (e.g. JPY doesn't have cents) + if ( + payload.conversionValue && + payload.currencyCode && + !isZeroDecimalCurrency(payload.currencyCode) + ) { + payload.conversionValue = payload.conversionValue / 100; + } + try { const response = await qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/google-ads/upload-conversion`, @@ -98,6 +110,7 @@ export const uploadGoogleAdsConversion = async ( click, conversionDateTime, eventId, + eventName, conversionValue, currencyCode, conversionCount, @@ -127,18 +140,31 @@ export const uploadGoogleAdsConversion = async ( installedIntegration.settings ?? {}, ); - const conversionAction = - eventType === "lead" - ? settings.leadConversionAction - : settings.saleConversionAction; + const mappings = + eventType === "lead" ? settings.leadMappings : settings.saleMappings; + const mapping = resolveGoogleAdsConversionMapping({ + mappings, + eventName, + }); - if (!settings.customerId || !conversionAction) { + if (!settings.customerId) { return { - message: `Missing ${!settings.customerId ? "customerId" : `${eventType}ConversionAction`}. Skipping...`, + message: `Missing customerId. Skipping...`, status: "skipped", }; } + if (!mapping) { + return { + message: mappings.length + ? `No ${eventType} conversion mapping matched event name "${eventName ?? ""}". Skipping...` + : `Missing ${eventType} conversion mapping. Skipping...`, + status: "skipped", + }; + } + + const conversionAction = mapping.conversionAction; + const googleClickId = extractGoogleAdsClickId(click.url); if (!googleClickId) { diff --git a/apps/web/lib/integrations/google-ads/utils.ts b/apps/web/lib/integrations/google-ads/utils.ts index 5c85b99cdd6..2ba34fe9488 100644 --- a/apps/web/lib/integrations/google-ads/utils.ts +++ b/apps/web/lib/integrations/google-ads/utils.ts @@ -3,3 +3,77 @@ import { GOOGLE_ADS_ALLOWED_WORKSPACE_IDS } from "./constants"; export const isGoogleAdsAllowedWorkspace = (workspaceId: string) => GOOGLE_ADS_ALLOWED_WORKSPACE_IDS.has(normalizeWorkspaceId(workspaceId)); + +type GoogleAdsEventMapping = { + conversionAction: string; + eventNames: string[]; +}; + +export const findDuplicateMappingEventNames = ( + mappings: Pick[], +) => { + const seen = new Set(); + const duplicates = new Set(); + + for (const mapping of mappings) { + for (const eventName of mapping.eventNames) { + if (seen.has(eventName)) { + duplicates.add(eventName); + } + seen.add(eventName); + } + } + + return [...duplicates]; +}; + +export const getGoogleAdsEventMappingsError = ( + mappings: GoogleAdsEventMapping[], +) => { + const duplicateEventNames = findDuplicateMappingEventNames(mappings); + if (duplicateEventNames.length > 0) { + return `Each event name can only be mapped to one conversion action: ${duplicateEventNames.join(", ")}`; + } + + const conversionActions = mappings + .map((mapping) => mapping.conversionAction) + .filter(Boolean); + if (new Set(conversionActions).size !== conversionActions.length) { + return "Each conversion action can only be used once."; + } + + const catchAllCount = mappings.filter( + (mapping) => mapping.conversionAction && mapping.eventNames.length === 0, + ).length; + if (catchAllCount > 1) { + return "Only one conversion action can receive unmatched events."; + } + + return null; +}; + +// Prefer a mapping whose eventNames includes the event, then a catch-all +// mapping with an empty eventNames list. Returns null when nothing matches. +export const resolveGoogleAdsConversionMapping = ({ + mappings, + eventName, +}: { + mappings: GoogleAdsEventMapping[]; + eventName?: string | null; +}): GoogleAdsEventMapping | null => { + if (!mappings.length) { + return null; + } + + if (eventName) { + const specific = mappings.find((mapping) => + mapping.eventNames.includes(eventName), + ); + + if (specific) { + return specific; + } + } + + return mappings.find((mapping) => mapping.eventNames.length === 0) ?? null; +}; diff --git a/apps/web/lib/integrations/hubspot/ui/settings.tsx b/apps/web/lib/integrations/hubspot/ui/settings.tsx index b03995a3547..34d42348ddc 100644 --- a/apps/web/lib/integrations/hubspot/ui/settings.tsx +++ b/apps/web/lib/integrations/hubspot/ui/settings.tsx @@ -66,7 +66,7 @@ export const HubSpotSettings = ({ } return ( -
+

diff --git a/apps/web/lib/integrations/segment/ui/set-write-key.tsx b/apps/web/lib/integrations/segment/ui/set-write-key.tsx index bfd22d07e60..c6c5f8ddd3e 100644 --- a/apps/web/lib/integrations/segment/ui/set-write-key.tsx +++ b/apps/web/lib/integrations/segment/ui/set-write-key.tsx @@ -55,7 +55,7 @@ export function SetWriteKey({ ); return ( - +

diff --git a/apps/web/lib/integrations/shopify/attribute-via-discount-code.ts b/apps/web/lib/integrations/shopify/attribute-via-discount-code.ts new file mode 100644 index 00000000000..2e177264708 --- /dev/null +++ b/apps/web/lib/integrations/shopify/attribute-via-discount-code.ts @@ -0,0 +1,184 @@ +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 { queueGoogleAdsConversionUpload } from "@/lib/integrations/google-ads/upload-conversion"; +import { generateRandomName } from "@/lib/names"; +import { queuePartnerCommissionCreation } from "@/lib/partners/queue-partner-commission-creation"; +import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; +import { prisma } from "@/lib/prisma"; +import { recordLead } from "@/lib/tinybird"; +import { recordFakeClick } from "@/lib/tinybird/record-fake-click"; +import { WorkspaceProps } from "@/lib/types"; +import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; +import { transformLeadEventData } from "@/lib/webhook/transform"; +import { COUNTRIES_TO_CONTINENTS, nanoid } from "@dub/utils"; +import { EventType, Link } from "@prisma/client"; +import { ShopifyOrder } from "./schema"; + +export async function attributeViaDiscountCode({ + order, + workspace, + link, +}: { + order: ShopifyOrder; + workspace: Pick; + link: Link; +}) { + const { customer: orderCustomer, billing_address: billingAddress } = order; + + const billingAddressCountry = billingAddress?.country_code?.toUpperCase(); + + // Record a fake click for this event + const clickEvent = await recordFakeClick({ + link, + customer: { + continent: billingAddressCountry + ? COUNTRIES_TO_CONTINENTS[billingAddressCountry] ?? "Unknown" + : "Unknown", + country: billingAddressCountry ?? "Unknown", + region: billingAddress?.province ?? "Unknown", + }, + }); + + const customerId = createId({ prefix: "cus_" }); + const clickId = clickEvent.click_id; + + // Create the customer before recording the fake click so a P2002 on + // projectId_externalId never leaves an orphaned Tinybird click behind. + const customer = await prisma.customer.create({ + data: { + id: customerId, + name: orderCustomer + ? `${orderCustomer.first_name} ${orderCustomer.last_name}`.trim() + : generateRandomName(), + email: orderCustomer?.email, + externalId: orderCustomer?.id?.toString() || customerId, + linkId: link.id, + clickId, + clickedAt: new Date(), + country: billingAddress?.country_code, + projectId: workspace.id, + programId: link.programId, + partnerId: link.partnerId, + }, + }); + + // 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); + + const linkUpdated = await prisma.link.update({ + where: { + id: link.id, + }, + data: { + leads: { + increment: 1, + }, + lastLeadAt: new Date(), + }, + include: includeTags, + }); + + let result: + | Awaited> + | undefined = undefined; + + if (link.programId && link.partnerId) { + result = await queuePartnerCommissionCreation({ + 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({ + event: "leadRecorded", + 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({ + ...clickEvent, + eventName: "Checkout with discount code", + link: linkUpdated, + customer, + partner: result?.webhookPartner, + metadata: null, + }), + }), + + queueGoogleAdsConversionUpload({ + workspaceId: workspace.id, + eventType: EventType.lead, + eventId: leadEvent.event_id, + eventName: leadEvent.event_name, + conversionDateTime: new Date().toISOString(), + conversionCount: 1, + click: { + id: clickEvent.click_id, + url: clickEvent.url, + }, + }), + + ...(link.partnerId + ? [ + sendPartnerPostback({ + partnerId: link.partnerId, + event: "lead.created", + data: { + ...leadEvent, + eventName: "Checkout with discount code", + link: linkUpdated, + customer, + }, + }), + ] + : []), + ]); + + return { + customer, + leadEvent, + }; +} diff --git a/apps/web/lib/integrations/shopify/checkout-cache.ts b/apps/web/lib/integrations/shopify/checkout-cache.ts new file mode 100644 index 00000000000..543228e2c40 --- /dev/null +++ b/apps/web/lib/integrations/shopify/checkout-cache.ts @@ -0,0 +1,128 @@ +import { processShopifyOrderJob } from "@/lib/jobs/handlers/process-shopify-order-job"; +import { redis } from "@/lib/upstash"; +import * as z from "zod/v4"; +import { shopifyOrderSchema } from "./schema"; + +const SHOPIFY_CHECKOUT_CACHE_TTL_SECONDS = 60 * 60; // 1 hours +const SHOPIFY_CHECKOUT_CACHE_KEY_PREFIX = "shopify:checkout:"; + +const shopifyCheckoutCacheSchema = z.object({ + clickId: z + .string() + .nullish() + .transform((value) => value ?? undefined), + workspaceId: z.string().optional(), + order: shopifyOrderSchema.optional(), + dispatched: z.boolean().optional(), +}); + +type ShopifyCheckoutCacheItem = z.infer; + +class ShopifyCheckoutCache { + async get(checkoutToken: string): Promise { + const cache = await redis.hgetall(this.createKey(checkoutToken)); + return this.parse(cache); + } + + async set({ + checkoutToken, + fields, + }: { + checkoutToken: string; + fields: Partial; + }): Promise { + const key = this.createKey(checkoutToken); + + const pipeline = redis.pipeline(); + pipeline.hset(key, fields); + pipeline.expire(key, SHOPIFY_CHECKOUT_CACHE_TTL_SECONDS); + pipeline.hgetall(key); + + const results = await pipeline.exec(); + return this.parse(results[2]); + } + + async delete(checkoutToken: string) { + return await redis.del(this.createKey(checkoutToken)); + } + + createKey(checkoutToken: string) { + return `${SHOPIFY_CHECKOUT_CACHE_KEY_PREFIX}${checkoutToken}`; + } + + parse(cache: unknown): ShopifyCheckoutCacheItem { + const parsed = shopifyCheckoutCacheSchema.safeParse(cache); + return parsed.success ? parsed.data : shopifyCheckoutCacheSchema.parse({}); + } +} + +export const shopifyCheckoutCache = new ShopifyCheckoutCache(); + +export async function tryDispatchShopifyOrderJob({ + checkoutToken, + checkout, +}: { + checkoutToken: string; + checkout: ShopifyCheckoutCacheItem; +}) { + const logContext = { + checkoutToken, + workspaceId: checkout.workspaceId, + clickId: checkout.clickId, + hasClickId: Boolean(checkout.clickId), + hasOrder: Boolean(checkout.order), + dispatched: Boolean(checkout.dispatched), + }; + + if (!checkout.order || !checkout.workspaceId || !checkout.clickId) { + console.info( + "Shopify order dispatch skipped: checkout incomplete", + logContext, + ); + return false; + } + + if (checkout.dispatched) { + console.info( + "Shopify order dispatch skipped: already dispatched", + logContext, + ); + return false; + } + + // Claim the checkout + const key = shopifyCheckoutCache.createKey(checkoutToken); + const claim = await redis.hsetnx(key, "dispatched", true); + const claimed = Boolean(claim); + + if (!claimed) { + console.info("Shopify order dispatch skipped: claim lost", logContext); + return false; + } + + try { + await processShopifyOrderJob.dispatch( + { + workspaceId: checkout.workspaceId, + clickId: checkout.clickId, + order: checkout.order, + }, + { + deduplicationId: `shopify-order-${checkoutToken}`, + }, + ); + } catch (error) { + console.error("Shopify order dispatch failed, releasing claim", { + ...logContext, + error, + }); + await redis.hdel(key, "dispatched"); + throw error; + } + + await shopifyCheckoutCache.delete(checkoutToken); + + console.info("Shopify order job dispatched", logContext); + + return true; +} diff --git a/apps/web/lib/integrations/shopify/create-lead.ts b/apps/web/lib/integrations/shopify/create-lead.ts index a361a40ae2f..e0ab241752d 100644 --- a/apps/web/lib/integrations/shopify/create-lead.ts +++ b/apps/web/lib/integrations/shopify/create-lead.ts @@ -1,5 +1,4 @@ import { createId } from "@/lib/api/create-id"; -import { DubApiError } from "@/lib/api/errors"; import { includeTags } from "@/lib/api/links/include-tags"; import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-stats"; import { generateRandomName } from "@/lib/names"; @@ -10,19 +9,22 @@ import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { transformLeadEventData } from "@/lib/webhook/transform"; import { leadEventSchemaTB } from "@/lib/zod/schemas/leads"; import { nanoid } from "@dub/utils"; +import { EventType } from "@prisma/client"; import { waitUntil } from "@vercel/functions"; -import { orderSchema } from "./schema"; +import { queueGoogleAdsConversionUpload } from "../google-ads/upload-conversion"; +import { ShopifyError } from "./error"; +import { ShopifyOrder } from "./schema"; export async function createShopifyLead({ + order, clickId, workspaceId, - event, }: { + order: ShopifyOrder; clickId: string; workspaceId: string; - event: any; }) { - const { customer: orderCustomer } = orderSchema.parse(event); + const { customer: orderCustomer } = order; const customerId = createId({ prefix: "cus_" }); /* @@ -40,10 +42,13 @@ export async function createShopifyLead({ const clickData = await getClickEvent({ clickId }); if (!clickData) { - throw new DubApiError({ - code: "not_found", - message: `Click event not found for clickId: ${clickId}`, - }); + throw new ShopifyError("Click event not found. Skipping the order..."); + } + + if (clickData.workspace_id !== workspaceId) { + throw new ShopifyError( + "Click event not found in the workspace. Skipping the order...", + ); } const { link_id: linkId, country, timestamp } = clickData; @@ -56,14 +61,18 @@ export async function createShopifyLead({ id: true, programId: true, partnerId: true, + disabledAt: true, }, }); if (!partnerLink) { - throw new DubApiError({ - code: "not_found", - message: `Link not found for linkId: ${linkId}`, - }); + throw new ShopifyError( + "Link not found in your workspace. Skipping the order...", + ); + } + + if (partnerLink.disabledAt) { + throw new ShopifyError("Link is disabled. Skipping the order..."); } // create customer @@ -83,13 +92,16 @@ export async function createShopifyLead({ }, }); - const eventName = "Account created"; + const leadEvent = { + id: nanoid(16), + name: "Account created", + }; const leadData = leadEventSchemaTB.parse({ ...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, + event_id: leadEvent.id, + event_name: leadEvent.name, customer_id: customer.id, }); @@ -131,13 +143,26 @@ export async function createShopifyLead({ workspace, data: transformLeadEventData({ ...clickData, - eventName, + eventName: leadEvent.name, link, customer, metadata: null, }), }), + queueGoogleAdsConversionUpload({ + workspaceId: workspace.id, + eventType: EventType.lead, + eventId: leadData.event_id, + eventName: leadData.event_name, + conversionDateTime: new Date().toISOString(), + conversionCount: 1, + click: { + id: clickData.click_id, + url: clickData.url, + }, + }), + ...(link.partnerId ? [ sendPartnerPostback({ @@ -145,7 +170,7 @@ export async function createShopifyLead({ event: "lead.created", data: { ...clickData, - eventName, + eventName: leadEvent.name, link, customer, }, @@ -174,5 +199,7 @@ export async function createShopifyLead({ ]), ); - return leadData; + return { + leadData, + }; } diff --git a/apps/web/lib/integrations/shopify/create-sale.ts b/apps/web/lib/integrations/shopify/create-sale.ts index d4b20f2b80c..f8c71d923f7 100644 --- a/apps/web/lib/integrations/shopify/create-sale.ts +++ b/apps/web/lib/integrations/shopify/create-sale.ts @@ -12,21 +12,21 @@ import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; import { transformSaleEventData } from "@/lib/webhook/transform"; import { nanoid } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; -import { orderSchema } from "./schema"; +import { shopifyCheckoutCache } from "./checkout-cache"; +import { ShopifyError } from "./error"; +import { ShopifyOrder } from "./schema"; export async function createShopifySale({ - event, + order, customerId, workspaceId, leadData, }: { - event: any; + order: ShopifyOrder; customerId: string; workspaceId: string; leadData: LeadEventTB; }) { - const order = orderSchema.parse(event); - const { checkout_token: checkoutToken, confirmation_number: invoiceId, @@ -48,14 +48,14 @@ export async function createShopifySale({ ); if (!ok) { - return new Response( - `[Shopify] Order has been processed already. Skipping...`, + throw new ShopifyError( + "Sale event for this order has been processed already. Skipping the order...", ); } const saleData = { ...leadData, - workspace_id: leadData.workspace_id || workspaceId, // in case for some reason the lead event doesn't have workspace_id + workspace_id: workspaceId, event_id: nanoid(16), event_name: "Purchase", payment_processor: "shopify", @@ -127,7 +127,7 @@ export async function createShopifySale({ firstSaleAt: existingCustomer.firstSaleAt ? undefined : new Date(), }, }), - redis.del(`shopify:checkout:${checkoutToken}`), + shopifyCheckoutCache.delete(checkoutToken), ]); // for program links @@ -222,4 +222,8 @@ export async function createShopifySale({ : []), ]), ); + + return { + saleData, + }; } diff --git a/apps/web/lib/integrations/shopify/error.ts b/apps/web/lib/integrations/shopify/error.ts new file mode 100644 index 00000000000..0531925c2e3 --- /dev/null +++ b/apps/web/lib/integrations/shopify/error.ts @@ -0,0 +1,10 @@ +export class ShopifyError extends Error { + readonly data: Record | null; + + constructor(message: string, data: Record | null = null) { + super(message); + this.name = "ShopifyError"; + this.data = data; + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/apps/web/lib/integrations/shopify/process-order.ts b/apps/web/lib/integrations/shopify/process-order.ts index 1a9f2866d61..d3059d17ef7 100644 --- a/apps/web/lib/integrations/shopify/process-order.ts +++ b/apps/web/lib/integrations/shopify/process-order.ts @@ -1,232 +1,141 @@ -import { createId } from "@/lib/api/create-id"; -import { handleAndReturnErrorResponse } from "@/lib/api/errors"; -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 { queuePartnerCommissionCreation } from "@/lib/partners/queue-partner-commission-creation"; -import { sendPartnerPostback } from "@/lib/postback/send-partner-postback"; import { prisma } from "@/lib/prisma"; -import { getLeadEvent, recordLead } from "@/lib/tinybird"; -import { recordFakeClick } from "@/lib/tinybird/record-fake-click"; +import { getLeadEvent } from "@/lib/tinybird"; import { WorkspaceProps } from "@/lib/types"; -import { sendWorkspaceWebhook } from "@/lib/webhook/publish"; -import { transformLeadEventData } from "@/lib/webhook/transform"; -import { COUNTRIES_TO_CONTINENTS, nanoid } from "@dub/utils"; -import { Link } from "@prisma/client"; -import { waitUntil } from "@vercel/functions"; +import { attributeViaDiscountCode } from "./attribute-via-discount-code"; import { createShopifyLead } from "./create-lead"; import { createShopifySale } from "./create-sale"; -import { orderSchema } from "./schema"; +import { ShopifyOrder } from "./schema"; // Process the order from Shopify webhook -export async function processOrder({ - event, - workspaceId, - customerId, +export async function processShopifyOrder({ + order, + workspace, clickId, }: { - event: unknown; - workspaceId: string; - customerId?: string; // ID of the customer in Dub - clickId?: string; // ID of the click event from Shopify pixel + order: ShopifyOrder; + workspace: Pick; + clickId: string | null; // ID of the click event from Shopify pixel }) { - try { - // for existing customer - if (customerId) { - const leadEvent = await getLeadEvent({ customerId }); + const { customer: orderCustomer, discount_codes: discountCodes } = order; - if (!leadEvent) { - return new Response( - `[Shopify] Lead event with customer ID ${customerId} not found, skipping...`, - ); - } + const sharedData = { + checkoutToken: order.checkout_token, + shopifyCustomerId: orderCustomer?.id, + }; - await createShopifySale({ - leadData: leadEvent, - event, - workspaceId, - customerId, - }); + // Check customer exists in the workspace + if (orderCustomer) { + const externalId = orderCustomer.id?.toString(); - return; - } + const customer = await prisma.customer.findUnique({ + where: { + projectId_externalId: { + projectId: workspace.id, + externalId, + }, + }, + }); - // for new customer - if (clickId) { - const leadData = await createShopifyLead({ - clickId, - workspaceId, - event, + // Existing customer found + if (customer) { + const leadData = await getLeadEvent({ + customerId: customer.id, }); - const { customer_id: customerId } = leadData; + if (!leadData) { + // Not a skip — we cannot tell "no lead" from "Tinybird unavailable". + // Throw so the job retries rather than creating a duplicate customer. + throw new Error( + `Lead event not found for customer ${customer.id}; refusing to re-attribute.`, + ); + } - await createShopifySale({ + const { saleData } = await createShopifySale({ leadData, - event, - workspaceId, - customerId, + order, + workspaceId: workspace.id, + customerId: customer.id, }); - return; + return { + message: "Sale has been tracked for this order.", + data: { + ...sharedData, + eventId: saleData.event_id, + customerId: customer.id, + attribution: "existing_lead", + }, + }; } - } catch (error) { - return handleAndReturnErrorResponse(error); } -} - -export async function attributeViaDiscountCode({ - event, - workspace, - link, -}: { - event: unknown; - workspace: Pick; - link: Link; -}) { - const { customer: orderCustomer, billing_address: billingAddress } = - orderSchema.parse(event); - - const billingAddressCountry = billingAddress?.country_code?.toUpperCase(); - // Record a fake click for this event - const clickEvent = await recordFakeClick({ - link, - customer: { - continent: billingAddressCountry - ? COUNTRIES_TO_CONTINENTS[billingAddressCountry] ?? "Unknown" - : "Unknown", - country: billingAddressCountry ?? "Unknown", - region: billingAddress?.province ?? "Unknown", - }, - }); - - const customerId = createId({ prefix: "cus_" }); - const customer = await prisma.customer.create({ - data: { - id: customerId, - name: orderCustomer - ? `${orderCustomer.first_name} ${orderCustomer.last_name}`.trim() - : generateRandomName(), - email: orderCustomer?.email, - externalId: orderCustomer?.id?.toString() || customerId, - linkId: clickEvent.link_id, - clickId: clickEvent.click_id, - clickedAt: new Date(clickEvent.timestamp + "Z"), - country: billingAddress?.country_code, - projectId: workspace.id, - programId: link.programId, - partnerId: link.partnerId, - }, - }); - - // 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); - - waitUntil( - (async () => { - const linkUpdated = await prisma.link.update({ - where: { - id: link.id, + // Check if the order has created using a program discount code + if (discountCodes && discountCodes.length > 0 && workspace.defaultProgramId) { + const programDiscountCodes = await prisma.discountCode.findMany({ + where: { + programId: workspace.defaultProgramId, + code: { + in: discountCodes.map(({ code }) => code), }, - data: { - leads: { - increment: 1, - }, - lastLeadAt: new Date(), - }, - include: includeTags, + }, + include: { + link: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + if (programDiscountCodes.length > 0) { + const { leadEvent: leadData } = await attributeViaDiscountCode({ + order, + workspace, + link: programDiscountCodes[0].link, }); - let result: - | Awaited> - | undefined = undefined; - - if (link.programId && link.partnerId) { - result = await queuePartnerCommissionCreation({ - 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({ - event: "leadRecorded", - 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({ - ...clickEvent, - eventName: "Checkout with discount code", - link: linkUpdated, - customer, - partner: result?.webhookPartner, - metadata: null, - }), - }), + const { saleData } = await createShopifySale({ + leadData, + order, + workspaceId: workspace.id, + customerId: leadData.customer_id, + }); - ...(link.partnerId - ? [ - sendPartnerPostback({ - partnerId: link.partnerId, - event: "lead.created", - data: { - ...leadEvent, - eventName: "Checkout with discount code", - link: linkUpdated, - customer, - }, - }), - ] - : []), - ]); - })(), - ); + return { + message: "Sale has been tracked for this order.", + data: { + ...sharedData, + eventId: saleData.event_id, + customerId: leadData.customer_id, + discountCode: programDiscountCodes[0].code, + attribution: "discount_code", + }, + }; + } + } - return { - customer, - leadEvent, - }; + // New customer + if (clickId) { + const { leadData } = await createShopifyLead({ + order, + clickId, + workspaceId: workspace.id, + }); + + const { saleData } = await createShopifySale({ + leadData, + order, + workspaceId: workspace.id, + customerId: leadData.customer_id, + }); + + return { + message: "Sale has been tracked for this order.", + data: { + ...sharedData, + eventId: saleData.event_id, + customerId: leadData.customer_id, + attribution: "click", + }, + }; + } } diff --git a/apps/web/lib/integrations/shopify/schema.ts b/apps/web/lib/integrations/shopify/schema.ts index cc7c985a5f4..62ad7bcb850 100644 --- a/apps/web/lib/integrations/shopify/schema.ts +++ b/apps/web/lib/integrations/shopify/schema.ts @@ -1,11 +1,11 @@ import * as z from "zod/v4"; -export const orderSchema = z.object({ +export const shopifyOrderSchema = z.object({ confirmation_number: z.string(), checkout_token: z.string(), customer: z .object({ - id: z.number(), + id: z.union([z.number(), z.string()]), email: z.string().nullish(), first_name: z.string().nullish(), last_name: z.string().nullish(), @@ -30,6 +30,14 @@ export const orderSchema = z.object({ country_code: z.string().nullish(), }) .nullish(), + note_attributes: z + .array( + z.object({ + name: z.string(), // dubClickId + value: z.string().nullish(), + }), + ) + .default([]), }); export const integrationCredentialsSchema = z.object({ @@ -39,3 +47,5 @@ export const integrationCredentialsSchema = z.object({ .describe("Encrypted access token for the Shopify store."), scope: z.string().nullish().describe("Scope of the Shopify store."), }); + +export type ShopifyOrder = z.infer; diff --git a/apps/web/lib/integrations/slack/transform.ts b/apps/web/lib/integrations/slack/transform.ts index f3288b5255e..202e45e2c17 100644 --- a/apps/web/lib/integrations/slack/transform.ts +++ b/apps/web/lib/integrations/slack/transform.ts @@ -10,9 +10,11 @@ import { BountyEventWebhookPayload, ClickEventWebhookPayload, CommissionEventWebhookPayload, + DiscountCodeEventWebhookPayload, LeadEventWebhookPayload, PartnerApplicationWebhookPayload, PartnerEventWebhookPayload, + PartnerMergedWebhookPayload, PayoutEventWebhookPayload, SaleEventWebhookPayload, } from "../../webhook/types"; @@ -535,6 +537,69 @@ const bountyTemplates = ({ }; }; +const partnerMergedTemplate = ({ + data, +}: { + data: PartnerMergedWebhookPayload; +}) => { + const { targetAlreadyEnrolled, sourcePartner, targetPartner } = data; + const hrefToPartnerPage = `${APP_DOMAIN}/program/partners/${targetPartner.id}`; + const outcomeLabel = targetAlreadyEnrolled + ? "Target was already enrolled" + : "Target was not enrolled"; + + return { + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `*Partner accounts merged* :twisted_rightwards_arrows:`, + }, + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: `*Source*\n\`${sourcePartner.id}\`${sourcePartner.email ? ` (${sourcePartner.email})` : ""}`, + }, + { + type: "mrkdwn", + text: `*Target*\n<${hrefToPartnerPage}|\`${targetPartner.id}\`>${targetPartner.email ? ` (${targetPartner.email})` : ""}`, + }, + ], + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: `*Outcome*\n${outcomeLabel}`, + }, + ...(sourcePartner.tenantId || targetPartner.tenantId + ? [ + { + type: "mrkdwn", + text: `*Tenant ID*\n${sourcePartner.tenantId ?? "—"} → ${targetPartner.tenantId ?? "—"}`, + }, + ] + : []), + ], + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `<${hrefToPartnerPage}|View on Dub>`, + }, + ], + }, + ], + }; +}; + const payoutConfirmedTemplate = ({ data, }: { @@ -596,6 +661,44 @@ const payoutConfirmedTemplate = ({ }; }; +const discountCodeTemplates = ({ + data, + event, +}: { + data: DiscountCodeEventWebhookPayload; + event: WebhookTrigger; +}) => { + const eventMessages = { + "discount_code.created": "*Discount code created* :ticket:", + "discount_code.deleted": "*Discount code deleted* :ticket:", + }; + + return { + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: eventMessages[event as keyof typeof eventMessages], + }, + }, + { + type: "section", + fields: [ + { + type: "mrkdwn", + text: `*Code*\n${data.code}`, + }, + { + type: "mrkdwn", + text: `*Partner ID*\n${data.partnerId}`, + }, + ], + }, + ], + }; +}; + const slackTemplates: Record = { "link.created": linkTemplates, "link.updated": linkTemplates, @@ -604,11 +707,14 @@ const slackTemplates: Record = { "lead.created": leadCreatedTemplate, "sale.created": saleCreatedTemplate, "partner.enrolled": partnerEnrolledTemplate, + "partner.merged": partnerMergedTemplate, "partner.application_submitted": partnerApplicationSubmittedTemplate, "commission.created": commissionCreatedTemplate, "bounty.created": bountyTemplates, "bounty.updated": bountyTemplates, "payout.confirmed": payoutConfirmedTemplate, + "discount_code.created": discountCodeTemplates, + "discount_code.deleted": discountCodeTemplates, }; export const formatEventForSlack = ( @@ -625,9 +731,13 @@ export const formatEventForSlack = ( event, ); const isBountyEvent = ["bounty.created", "bounty.updated"].includes(event); + const isDiscountCodeEvent = [ + "discount_code.created", + "discount_code.deleted", + ].includes(event); return template({ data, - ...((isLinkEvent || isBountyEvent) && { event }), + ...((isLinkEvent || isBountyEvent || isDiscountCodeEvent) && { event }), }); }; diff --git a/apps/web/lib/integrations/slack/ui/settings.tsx b/apps/web/lib/integrations/slack/ui/settings.tsx index b62f88cb8fa..b57b963440e 100644 --- a/apps/web/lib/integrations/slack/ui/settings.tsx +++ b/apps/web/lib/integrations/slack/ui/settings.tsx @@ -19,6 +19,7 @@ export const SlackSettings = (props: InstalledIntegrationInfoProps) => { "lead.created", "sale.created", "partner.enrolled", + "partner.merged", "commission.created", "bounty.created", "bounty.updated", diff --git a/apps/web/lib/integrations/slack/verify-request.ts b/apps/web/lib/integrations/slack/verify-request.ts index bbe2c342f87..bf51a60cf7d 100644 --- a/apps/web/lib/integrations/slack/verify-request.ts +++ b/apps/web/lib/integrations/slack/verify-request.ts @@ -1,3 +1,4 @@ +import { timingSafeCompare } from "@/lib/webhook/timing-safe-compare"; import { createHmac } from "crypto"; interface SlackRequestVerificationOptions { @@ -63,7 +64,7 @@ export const verifySlackSignature = async (req: Request, body: string) => { hmac.update(`${signatureVersion}:${requestTimestampSec}:${options.body}`); const expectedSignature = hmac.digest("hex"); - if (!signatureHash || signatureHash !== expectedSignature) { + if (!timingSafeCompare(signatureHash, expectedSignature)) { throw new Error(`${verifyErrorPrefix}: signature mismatch`); } }; diff --git a/apps/web/lib/integrations/stripe/ui/settings.tsx b/apps/web/lib/integrations/stripe/ui/settings.tsx index b4499209e02..6ca2882fe56 100644 --- a/apps/web/lib/integrations/stripe/ui/settings.tsx +++ b/apps/web/lib/integrations/stripe/ui/settings.tsx @@ -1,9 +1,10 @@ "use client"; +import { clientAccessCheck } from "@/lib/client-access-check"; import useWorkspace from "@/lib/swr/use-workspace"; import { InstalledIntegrationInfoProps } from "@/lib/types"; import { MarkdownDescription } from "@/ui/shared/markdown-description"; -import { AnimatedSizeContainer, Button, Switch } from "@dub/ui"; +import { AnimatedSizeContainer, Button, InfoTooltip, Switch } from "@dub/ui"; import { useAction } from "next-safe-action/hooks"; import { useMemo, useState } from "react"; import { toast } from "sonner"; @@ -22,7 +23,11 @@ export const StripeIntegrationSettings = ({ installed, settings, }: InstalledIntegrationInfoProps) => { - const { id: workspaceId } = useWorkspace(); + const { id: workspaceId, role } = useWorkspace(); + const { error: permissionsError } = clientAccessCheck({ + action: "integrations.write", + role, + }); const stripeSettings = stripeIntegrationSettingsSchema.parse({ ...STRIPE_DEFAULT_SETTINGS, @@ -32,6 +37,8 @@ export const StripeIntegrationSettings = ({ const initialFreeTrialsEnabled = stripeSettings?.freeTrials?.enabled ?? false; const initialTrackQuantity = stripeSettings?.freeTrials?.trackQuantity ?? false; + const initialFirstTimeTransaction = + stripeSettings.discountCodeRestrictions.firstTimeTransaction; // Track saved values that can be updated after successful save const [savedFreeTrialsEnabled, setSavedFreeTrialsEnabled] = useState( @@ -39,23 +46,32 @@ export const StripeIntegrationSettings = ({ ); const [savedTrackQuantity, setSavedTrackQuantity] = useState(initialTrackQuantity); + const [savedFirstTimeTransaction, setSavedFirstTimeTransaction] = useState( + initialFirstTimeTransaction, + ); const [freeTrialsEnabled, setFreeTrialsEnabled] = useState( initialFreeTrialsEnabled, ); const [trackQuantity, setTrackQuantity] = useState(initialTrackQuantity); + const [firstTimeTransaction, setFirstTimeTransaction] = useState( + initialFirstTimeTransaction, + ); const isDirty = useMemo(() => { return ( freeTrialsEnabled !== savedFreeTrialsEnabled || - trackQuantity !== savedTrackQuantity + (freeTrialsEnabled ? trackQuantity : false) !== savedTrackQuantity || + firstTimeTransaction !== savedFirstTimeTransaction ); }, [ freeTrialsEnabled, savedFreeTrialsEnabled, trackQuantity, savedTrackQuantity, + firstTimeTransaction, + savedFirstTimeTransaction, ]); const { executeAsync, isPending } = useAction(updateStripeSettingsAction, { @@ -63,6 +79,7 @@ export const StripeIntegrationSettings = ({ // Update saved values to match current values after successful save setSavedFreeTrialsEnabled(freeTrialsEnabled); setSavedTrackQuantity(freeTrialsEnabled ? trackQuantity : false); + setSavedFirstTimeTransaction(firstTimeTransaction); toast.success("Stripe settings updated successfully."); }, onError({ error }) { @@ -83,6 +100,9 @@ export const StripeIntegrationSettings = ({ enabled: freeTrialsEnabled, trackQuantity: freeTrialsEnabled ? trackQuantity : false, }, + discountCodeRestrictions: { + firstTimeTransaction, + }, }); }; @@ -91,7 +111,7 @@ export const StripeIntegrationSettings = ({ } return ( - +

@@ -141,6 +161,29 @@ export const StripeIntegrationSettings = ({

)} + +
+
+
+
+ + +
+ + Whether to restrict discount codes to [first-time + orders](https://docs.stripe.com/payments/advanced/discounts#limit-by-first-time-order) + only. + +
+ +
+
@@ -151,7 +194,10 @@ export const StripeIntegrationSettings = ({ text="Save changes" className="h-8 w-fit" loading={isPending} - disabled={!isDirty || isPending} + disabled={!isDirty || isPending || Boolean(permissionsError)} + {...(permissionsError && { + disabledTooltip: permissionsError, + })} />
diff --git a/apps/web/lib/integrations/stripe/update-stripe-settings.ts b/apps/web/lib/integrations/stripe/update-stripe-settings.ts index 072beb39f00..23a977c752d 100644 --- a/apps/web/lib/integrations/stripe/update-stripe-settings.ts +++ b/apps/web/lib/integrations/stripe/update-stripe-settings.ts @@ -1,21 +1,33 @@ "use server"; import { authActionClient } from "@/lib/actions/safe-action"; +import { throwIfNoPermission } from "@/lib/actions/throw-if-no-permission"; import { prisma } from "@/lib/prisma"; import { STRIPE_INTEGRATION_ID } from "@dub/utils"; import { revalidatePath } from "next/cache"; import * as z from "zod/v4"; import { stripeIntegrationSettingsSchema } from "./schema"; -const schema = stripeIntegrationSettingsSchema.extend({ +const schema = z.object({ workspaceId: z.string(), + freeTrials: stripeIntegrationSettingsSchema.shape.freeTrials, + discountCodeRestrictions: z + .object({ + firstTimeTransaction: z.boolean(), + }) + .optional(), }); export const updateStripeSettingsAction = authActionClient .inputSchema(schema) .action(async ({ parsedInput, ctx }) => { const { workspace } = ctx; - const { freeTrials } = parsedInput; + const { freeTrials, discountCodeRestrictions } = parsedInput; + + throwIfNoPermission({ + role: workspace.role, + requiredPermissions: ["integrations.write"], + }); const installedIntegration = await prisma.installedIntegration.findFirst({ where: { @@ -38,6 +50,9 @@ export const updateStripeSettingsAction = authActionClient settings: { ...current, freeTrials, + ...(discountCodeRestrictions !== undefined + ? { discountCodeRestrictions } + : {}), }, }, }); diff --git a/apps/web/lib/integrations/zapier/ui/settings.tsx b/apps/web/lib/integrations/zapier/ui/settings.tsx index 6f5c9f874c5..a3e2859dc57 100644 --- a/apps/web/lib/integrations/zapier/ui/settings.tsx +++ b/apps/web/lib/integrations/zapier/ui/settings.tsx @@ -20,6 +20,7 @@ export const ZapierSettings = (props: InstalledIntegrationInfoProps) => { "sale.created", "partner.application_submitted", "partner.enrolled", + "partner.merged", ]} /> )} diff --git a/apps/web/lib/jobs/constants.ts b/apps/web/lib/jobs/constants.ts new file mode 100644 index 00000000000..0d73eb9cc50 --- /dev/null +++ b/apps/web/lib/jobs/constants.ts @@ -0,0 +1,4 @@ +export const QSTASH_BATCH_CHUNK_SIZE = 100; +export const LAST_ERROR_MAX_LENGTH = 1000; +export const MAX_JOB_ATTEMPTS = 10; +export const MAX_JOBS_PER_BATCH = 100; diff --git a/apps/web/lib/jobs/handlers/auto-approve-partner-job.ts b/apps/web/lib/jobs/handlers/auto-approve-partner-job.ts new file mode 100644 index 00000000000..e4abdefc993 --- /dev/null +++ b/apps/web/lib/jobs/handlers/auto-approve-partner-job.ts @@ -0,0 +1,147 @@ +import { getPartnerApplicationRisks } from "@/lib/api/fraud/get-partner-application-risks"; +import { approvePartner } from "@/lib/api/partners/applications/approve-partner"; +import { evaluateApplicationRequirements } from "@/lib/partners/evaluate-application-requirements"; +import { getPlanCapabilities } from "@/lib/plan-capabilities"; +import { prisma } from "@/lib/prisma"; +import { ProgramEnrollmentStatus } from "@prisma/client"; +import * as z from "zod/v4"; +import { defineJob } from "../index"; + +const inputSchema = z.object({ + programId: z.string(), + partnerId: z.string(), +}); + +// This job is used to auto-approve a partner enrolled in a program +export const autoApprovePartnerJob = defineJob({ + name: "auto-approve-partner-job", + schema: inputSchema, + async handle(input) { + const { programId, partnerId } = input; + + const programEnrollment = await prisma.programEnrollment.findUnique({ + where: { + partnerId_programId: { + partnerId, + programId, + }, + }, + include: { + partnerGroup: true, + partner: { + include: { + platforms: true, + }, + }, + }, + }); + + if (!programEnrollment) { + console.warn(`Partner ${partnerId} not found in program ${programId}.`); + return; + } + + const group = programEnrollment.partnerGroup; + + if (!group) { + console.warn( + `Group not found for partner ${partnerId} in program ${programId}.`, + ); + return; + } + + if (!group.autoApprovePartnersEnabledAt) { + console.warn(`Group ${group.id} does not have auto-approval enabled.`); + return; + } + + if (programEnrollment.status !== ProgramEnrollmentStatus.pending) { + console.warn(`${partnerId} is in ${programEnrollment.status} status.`); + return; + } + + // 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, + }, + select: { + id: true, + applicationRequirements: true, + workspace: { + select: { + plan: true, + users: { + where: { + role: "owner", + }, + take: 1, + select: { + userId: true, + }, + }, + }, + }, + }, + }); + + const { canManageFraudEvents } = getPlanCapabilities( + program.workspace.plan, + ); + + if (canManageFraudEvents) { + const { riskSeverity } = await getPartnerApplicationRisks({ + program, + partner: programEnrollment.partner, + }); + + if (riskSeverity === "high") { + console.warn(`Partner ${partnerId} has high risk.`); + return; + } + } + + const result = evaluateApplicationRequirements({ + applicationRequirements: program.applicationRequirements, + context: { + country: programEnrollment.partner.country, + email: programEnrollment.partner.email, + }, + }); + + if (!result.valid) { + switch (result.reason) { + case "invalidRequirements": + console.warn( + `Invalid applicationRequirements for program ${programId}.`, + ); + return; + + case "requirementsNotMet": + console.warn( + `Partner ${partnerId} does not meet eligibility requirements.`, + ); + return; + } + } + + const owner = program.workspace.users[0]; + + if (!owner) { + console.warn(`Owner not found for program ${programId}.`); + return; + } + + await approvePartner({ + programId, + partnerId, + userId: owner.userId, + groupId: programEnrollment.groupId, + }); + + console.info( + `Successfully auto-approved partner ${partnerId} in program ${programId}.`, + ); + }, +}); diff --git a/apps/web/lib/jobs/handlers/auto-reject-partner-job.ts b/apps/web/lib/jobs/handlers/auto-reject-partner-job.ts new file mode 100644 index 00000000000..2c9d26eaadf --- /dev/null +++ b/apps/web/lib/jobs/handlers/auto-reject-partner-job.ts @@ -0,0 +1,174 @@ +import { resolveFraudGroups } from "@/lib/api/fraud/resolve-fraud-groups"; +import { queuePartnerSearchSync } from "@/lib/api/partners/queue-partner-search-sync"; +import { trackApplicationEvents } from "@/lib/application-events/update-application-event"; +import { evaluateApplicationRequirements } from "@/lib/partners/evaluate-application-requirements"; +import { prisma } from "@/lib/prisma"; +import { sendEmail } from "@dub/email"; +import PartnerApplicationRejected from "@dub/email/templates/partner-application-rejected"; +import { + ProgramApplicationRejectionReason, + ProgramEnrollmentStatus, +} from "@prisma/client"; +import * as z from "zod/v4"; +import { defineJob } from "../index"; + +const inputSchema = z.object({ + programId: z.string(), + partnerId: z.string(), +}); + +// This job is used to auto-reject a partner enrollment (e.g. when eligibility requirements are not met) +export const autoRejectPartnerJob = defineJob({ + name: "auto-reject-partner-job", + schema: inputSchema, + async handle(input) { + const { programId, partnerId } = input; + + 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) { + console.warn(`Partner ${partnerId} not found in program ${programId}.`); + return; + } + + if (programEnrollment.status !== ProgramEnrollmentStatus.pending) { + console.warn(`${partnerId} is in ${programEnrollment.status} status.`); + return; + } + + const result = evaluateApplicationRequirements({ + applicationRequirements: + programEnrollment.program.applicationRequirements, + context: { + country: programEnrollment.partner.country, + email: programEnrollment.partner.email, + }, + }); + + if (result.reason !== "requirementsNotMet") { + console.warn( + `Partner ${partnerId} now meets requirements for program ${programId} (reason: ${result.reason}).`, + ); + return; + } + + const { skipped } = await prisma.$transaction(async (tx) => { + const { count } = await tx.programEnrollment.updateMany({ + where: { + id: programEnrollment.id, + status: ProgramEnrollmentStatus.pending, + }, + data: { + status: ProgramEnrollmentStatus.rejected, + clickRewardId: null, + leadRewardId: null, + saleRewardId: null, + referralRewardId: null, + discountId: null, + }, + }); + + if (count === 0) { + return { + skipped: true, + }; + } + + if (programEnrollment.applicationId) { + await tx.programApplication.update({ + where: { + id: programEnrollment.applicationId, + }, + data: { + reviewedAt: new Date(), + rejectionReason: + ProgramApplicationRejectionReason.doesNotMeetRequirements, + rejectionNote: null, + }, + }); + } + + return { + skipped: false, + }; + }); + + if (skipped) { + console.warn( + `Partner ${partnerId} is no longer pending in program ${programId}.`, + ); + return; + } + + const { partner, program } = programEnrollment; + + await Promise.allSettled([ + resolveFraudGroups({ + where: { + programId, + partnerId, + }, + resolutionReason: + "Resolved automatically because the partner application was automatically rejected.", + }), + + trackApplicationEvents({ + event: "rejected", + programId, + partnerIds: [partnerId], + }), + + // Queue an index update because the enrollment status moved to rejected. + queuePartnerSearchSync({ enrollmentIds: [programEnrollment.id] }), + + partner.email && + 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, + }, + }), + }), + ]); + + console.info( + `Successfully auto-rejected partner ${partnerId} in program ${programId}.`, + ); + }, +}); diff --git a/apps/web/lib/jobs/handlers/partner-search-sync-job.ts b/apps/web/lib/jobs/handlers/partner-search-sync-job.ts new file mode 100644 index 00000000000..998176bd38c --- /dev/null +++ b/apps/web/lib/jobs/handlers/partner-search-sync-job.ts @@ -0,0 +1,109 @@ +import { + findPartnerSearchSyncEnrollmentIds, + getPartnerSearchProvider, + PARTNER_SEARCH_SYNC_BATCH_SIZE, + syncPartnerSearchDocuments, +} from "@/lib/api/partners/search"; +import * as z from "zod/v4"; +import { defineJob } from "../index"; + +/** + * How many syncs may write to the provider at once. A bulk group move fans out + * to dozens of jobs that would otherwise all write at the same moment. + * Interactive edits queue behind that, which is the trade. Tune against the + * provider's write limits. + */ +const SYNC_PARALLELISM = 20; + +/** + * Two shapes, because the two kinds of change have different blast radii. + * + * `enrollments` carries IDs the caller already has, and is the only shape that + * can express a deletion, since the handler finds out by failing to read one + * back. `partners` is for changes that fan out past one enrollment, where the + * caller knows the partner but not how many programs it reaches. + */ +const inputSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("enrollments"), + enrollmentIds: z + .array(z.string()) + .min(1) + .max(PARTNER_SEARCH_SYNC_BATCH_SIZE), + }), + z.object({ + type: z.literal("partners"), + partnerIds: z.array(z.string()).min(1).max(PARTNER_SEARCH_SYNC_BATCH_SIZE), + programId: z.string().optional(), + after: z.string().optional(), + }), +]); + +export const partnerSearchSyncJob = defineJob({ + name: "partner-search-sync-job", + schema: inputSchema, + defaults: { + retries: 3, + flowControl: { + key: "partner-search-sync", + parallelism: SYNC_PARALLELISM, + }, + }, + async handle(input) { + const searchProvider = getPartnerSearchProvider(); + + // An environment without the key should not accumulate a backlog of jobs + // that can never succeed. + if (!searchProvider) { + console.log( + "[partnerSearchSyncJob] No search provider configured. Skipping...", + ); + return; + } + + if (input.type === "enrollments") { + const { upserted, deleted } = await syncPartnerSearchDocuments({ + enrollmentIds: input.enrollmentIds, + searchProvider, + }); + + console.log( + `[partnerSearchSyncJob] Synced ${upserted} and removed ${deleted} enrollment documents.`, + ); + + return; + } + + const enrollmentIds = await findPartnerSearchSyncEnrollmentIds({ + partnerIds: input.partnerIds, + programId: input.programId, + after: input.after, + take: PARTNER_SEARCH_SYNC_BATCH_SIZE, + }); + + if (enrollmentIds.length === 0) { + return; + } + + const { upserted, deleted } = await syncPartnerSearchDocuments({ + enrollmentIds, + searchProvider, + }); + + console.log( + `[partnerSearchSyncJob] Synced ${upserted} and removed ${deleted} enrollment documents for ${input.partnerIds.length} partner(s).`, + ); + + if (enrollmentIds.length === PARTNER_SEARCH_SYNC_BATCH_SIZE) { + await partnerSearchSyncJob.dispatch( + { + ...input, + after: enrollmentIds[enrollmentIds.length - 1], + }, + { + delay: 1, + }, + ); + } + }, +}); diff --git a/apps/web/lib/jobs/handlers/partner-tag-deleted-job.ts b/apps/web/lib/jobs/handlers/partner-tag-deleted-job.ts index 2f861060364..1f259205764 100644 --- a/apps/web/lib/jobs/handlers/partner-tag-deleted-job.ts +++ b/apps/web/lib/jobs/handlers/partner-tag-deleted-job.ts @@ -1,6 +1,7 @@ import * as z from "zod/v4"; import { includeProgramEnrollment } from "../../api/links/include-program-enrollment"; import { includeTags } from "../../api/links/include-tags"; +import { queuePartnerSearchSync } from "../../api/partners/queue-partner-search-sync"; import { CRON_BATCH_SIZE } from "../../cron"; import { prisma } from "../../prisma"; import { recordLink } from "../../tinybird"; @@ -69,6 +70,13 @@ export const partnerTagDeletedJob = defineJob({ `[partnerTagDeletedJob] Deleted ${count} program–partner tag associations.`, ); + // Queue an index update because the tag was removed from these enrollments. + await queuePartnerSearchSync({ + enrollmentIds: programPartnerTags.map( + ({ programEnrollment }) => programEnrollment.id, + ), + }); + // Update the links to remove the partner tag from TB const linksToUpdate = await prisma.link.findMany({ where: { diff --git a/apps/web/lib/jobs/handlers/process-shopify-order-job.ts b/apps/web/lib/jobs/handlers/process-shopify-order-job.ts new file mode 100644 index 00000000000..dc369276ec2 --- /dev/null +++ b/apps/web/lib/jobs/handlers/process-shopify-order-job.ts @@ -0,0 +1,79 @@ +import { captureWebhookLog } from "@/lib/api-logs/capture-webhook-log"; +import { logger } from "@/lib/axiom/server"; +import { processShopifyOrder } from "@/lib/integrations/shopify/process-order"; +import { shopifyOrderSchema } from "@/lib/integrations/shopify/schema"; +import { prisma } from "@/lib/prisma"; +import { serializeError } from "@dub/utils"; +import * as z from "zod/v4"; +import { defineJob } from "../index"; + +const inputSchema = z.object({ + order: shopifyOrderSchema, + clickId: z.string().nullable(), + workspaceId: z.string(), +}); + +// Process the Shopify order +export const processShopifyOrderJob = defineJob({ + name: "process-shopify-order-job", + schema: inputSchema, + async handle({ workspaceId, clickId, order }) { + const startTime = Date.now(); + + const workspace = await prisma.project.findUniqueOrThrow({ + where: { + id: workspaceId, + }, + select: { + id: true, + defaultProgramId: true, + webhookEnabled: true, + }, + }); + + const requestLog = { + workspaceId: workspace.id, + method: "POST", + path: "/shopify/integration/webhook" as const, + requestBody: order, + userAgent: "Shopify Order Job", + }; + + try { + const result = await processShopifyOrder({ + order, + workspace, + clickId, + }); + + // Keep this for a while to help us debug issues with the job. + logger.info("shopify.order.processed", { + ...result, + workspaceId: workspace.id, + }); + + await logger.flush(); + + await captureWebhookLog({ + ...requestLog, + statusCode: 200, + duration: Date.now() - startTime, + responseBody: result, + }); + } catch (error) { + await captureWebhookLog({ + ...requestLog, + statusCode: 400, + duration: Date.now() - startTime, + responseBody: serializeError(error), + }); + + logger.error("shopify.order.failed", { + error: serializeError(error), + workspaceId: workspace.id, + }); + + await logger.flush(); + } + }, +}); diff --git a/apps/web/lib/jobs/handlers/sync-group-utm-job.ts b/apps/web/lib/jobs/handlers/sync-group-utm-job.ts new file mode 100644 index 00000000000..eab621bf3c4 --- /dev/null +++ b/apps/web/lib/jobs/handlers/sync-group-utm-job.ts @@ -0,0 +1,198 @@ +import { linkCache } from "@/lib/api/links/cache"; +import { extractAndResolveUtmParams } from "@/lib/api/utm/extract-and-resolve-utm-params"; +import { prisma } from "@/lib/prisma"; +import { LinkProps } from "@/lib/types"; +import { chunk, constructURLFromUTMParams } from "@dub/utils"; +import * as z from "zod/v4"; +import { defineJob } from "../index"; + +const PAGE_SIZE = 50; +const UPDATE_BATCH_SIZE = 25; + +const inputSchema = 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 + 4. a partner's name changes (via dispatchGroupUtmSyncForPartner) + */ +export const syncGroupUtmJob = defineJob({ + name: "sync-group-utm-job", + schema: inputSchema, + async handle(input) { + let { groupId, partnerIds, startAfterProgramEnrollmentId } = input; + + // Find the UTM template + const group = await prisma.partnerGroup.findUnique({ + where: { + id: groupId, + }, + select: { + id: true, + name: true, + utmTemplate: true, + }, + }); + + if (!group) { + console.error( + `[syncGroupUtmJob] Group ${groupId} not found. Skipping...`, + ); + return; + } + + 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", + }, + select: { + id: true, + partner: { + select: { + name: true, + }, + }, + links: { + select: { + id: true, + domain: true, + key: true, + url: true, + }, + }, + }, + }); + + if (programEnrollments.length === 0) { + console.log( + `[syncGroupUtmJob] No program enrollments found. Skipping...`, + ); + return; + } + + const linksToExpire: Pick[] = []; + const linkUpdateGroupsByUrl = new Map< + string, + { + linkIds: string[]; + data: { + url: string; + } & ReturnType; + } + >(); + + for (const { links, partner } of programEnrollments) { + for (const link of links) { + const utmContext = { + partnerName: partner.name || link.key, + partnerLinkKey: link.key, + }; + + const resolvedUtmParams = extractAndResolveUtmParams( + utmTemplate, + utmContext, + ); + + const resolvedUtmColumns = extractAndResolveUtmParams( + utmTemplate, + utmContext, + { excludeRef: true }, + ); + + const data = { + url: constructURLFromUTMParams(link.url, resolvedUtmParams), + ...resolvedUtmColumns, + }; + + const existing = linkUpdateGroupsByUrl.get(data.url); + + if (existing) { + existing.linkIds.push(link.id); + } else { + linkUpdateGroupsByUrl.set(data.url, { linkIds: [link.id], data }); + } + + linksToExpire.push({ + domain: link.domain, + key: link.key, + }); + } + } + + const linkUpdateGroups = [...linkUpdateGroupsByUrl.values()]; + const linkUpdateGroupBatches = chunk(linkUpdateGroups, UPDATE_BATCH_SIZE); + + for (const batch of linkUpdateGroupBatches) { + await Promise.all( + batch.map(({ linkIds, data }) => + prisma.link.updateMany({ + where: { + id: { + in: linkIds, + }, + }, + data, + }), + ), + ); + } + + const totalLinks = linkUpdateGroups.reduce( + (sum, { linkIds }) => sum + linkIds.length, + 0, + ); + + console.log( + `[syncGroupUtmJob] Updated ${totalLinks} links. Expiring Redis cache for ${linksToExpire.length} links...`, + ); + + await linkCache.expireMany(linksToExpire); + + if (programEnrollments.length === PAGE_SIZE) { + startAfterProgramEnrollmentId = + programEnrollments[programEnrollments.length - 1].id; + + await syncGroupUtmJob.dispatch( + { + groupId, + partnerIds, + startAfterProgramEnrollmentId, + }, + { + delay: 1, + label: groupId, + }, + ); + return; + } + + console.log( + `[syncGroupUtmJob] Finished syncing UTM settings for ${programEnrollments.length} partners in the ${group.name} group (${group.id}).`, + ); + }, +}); diff --git a/apps/web/lib/jobs/handlers/unban-partner-job.ts b/apps/web/lib/jobs/handlers/unban-partner-job.ts index 80375ec0aa8..10a9e87490d 100644 --- a/apps/web/lib/jobs/handlers/unban-partner-job.ts +++ b/apps/web/lib/jobs/handlers/unban-partner-job.ts @@ -85,7 +85,10 @@ export const unbanPartnerJob = defineJob({ status: BountySubmissionStatus.rejected, }, data: { - status: BountySubmissionStatus.submitted, + status: BountySubmissionStatus.draft, + rejectionNote: null, + rejectionReason: null, + reviewedAt: null, }, }), ]); diff --git a/apps/web/lib/jobs/handlers/welcome-user-job.ts b/apps/web/lib/jobs/handlers/welcome-user-job.ts new file mode 100644 index 00000000000..b6b9c72973d --- /dev/null +++ b/apps/web/lib/jobs/handlers/welcome-user-job.ts @@ -0,0 +1,101 @@ +import { generateUnsubscribeToken } from "@/lib/email/unsubscribe-token"; +import { prisma } from "@/lib/prisma"; +import { sendEmail } from "@dub/email"; +import WelcomeEmail from "@dub/email/templates/welcome-email"; +import WelcomeEmailPartner from "@dub/email/templates/welcome-email-partner"; +import { APP_DOMAIN, PARTNERS_DOMAIN } from "@dub/utils"; +import * as z from "zod/v4"; +import { defineJob } from "../index"; + +const inputSchema = z.object({ + userId: z.string(), +}); + +// This job is used to send a welcome email to new users + subscribe them to the corresponding Resend audience +// It is dispatched 45 minutes after a user is created. + +// Trial sequence: users who later start a paid-plan trial also receive marketing emails from +// `/api/cron/trial-emails` when due; that flow is additive (this welcome is not skipped). +export const welcomeUserJob = defineJob({ + name: "welcome-user-job", + schema: inputSchema, + async handle(input) { + const { userId } = input; + + 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, + trialEndsAt: true, + defaultProgramId: true, + }, + }, + }, + orderBy: { + createdAt: "asc", + }, + take: 1, + }, + }, + }); + + if (!user) { + console.error(`User ${userId} not found.`); + return; + } + + if (!user.email) { + console.error(`User ${userId} email not found.`); + return; + } + + const isPartner = user.partners.length > 0; + const unsubscribeUrl = `${isPartner ? PARTNERS_DOMAIN : APP_DOMAIN}/unsubscribe/${generateUnsubscribeToken(user.email)}`; + + if (isPartner) { + await sendEmail({ + variant: "marketing", + to: user.email, + replyTo: "steven.tey@dub.co", + subject: "Welcome to Dub Partners!", + react: WelcomeEmailPartner({ + email: user.email, + name: user.name, + unsubscribeUrl, + }), + }); + + // only send WelcomeEmail if the user has a workspace that: + // - is not in a trial + // - hasn't created a program yet + } else if ( + user.projects.length > 0 && + user.projects[0].project.trialEndsAt === null && + user.projects[0].project.defaultProgramId === null + ) { + await sendEmail({ + variant: "marketing", + to: user.email, + replyTo: "steven.tey@dub.co", + subject: "Welcome to Dub!", + react: WelcomeEmail({ + email: user.email, + workspace: user.projects[0].project, + unsubscribeUrl, + }), + }); + } + }, +}); diff --git a/apps/web/lib/jobs/index.ts b/apps/web/lib/jobs/index.ts index 4e1afd59d53..f8cf8085b34 100644 --- a/apps/web/lib/jobs/index.ts +++ b/apps/web/lib/jobs/index.ts @@ -1,27 +1,17 @@ -import { createId } from "@/lib/api/create-id"; -import { logger } from "@/lib/axiom/server"; +import { logger, toErrorFields } from "@/lib/axiom/server"; import { qstash } from "@/lib/cron"; -import { prisma } from "@/lib/prisma"; -import { APP_DOMAIN_WITH_NGROK, chunk } from "@dub/utils"; -import { Prisma } from "@prisma/client"; -import { PublishRequest } from "@upstash/qstash"; +import { chunk } from "@dub/utils"; import * as z from "zod/v4"; - -export type JobEnvelope = z.infer; - -type JobPublishOptions = Pick< - PublishRequest, - | "delay" - | "notBefore" - | "deduplicationId" - | "retries" - | "flowControl" - | "label" ->; - -export type JobDispatchOptions = JobPublishOptions & { - queue?: string; -}; +import { QSTASH_BATCH_CHUNK_SIZE } from "./constants"; +import { persistBackgroundJobs } from "./outbox"; +import { + buildJobLabel, + buildQStashJobRequest, + isPublishSuccess, + jobNameSchema, + type DispatchJobInput, + type JobDispatchOptions, +} from "./send-jobs"; // Per-job defaults, merged under per-dispatch options export type JobDefaults = Pick< @@ -29,76 +19,19 @@ export type JobDefaults = Pick< "retries" | "queue" | "flowControl" | "label" >; -export type DispatchResult = +type DispatchResult = | { status: "published"; messageId: string } | { status: "deferred"; backgroundJobId: string }; -export type DispatchBatchResult = { +type DispatchBatchResult = { published: number; deferred: number; failed: number; results: DispatchResult[]; }; -interface DispatchJobInput { - name: string; - payload: unknown; - options?: JobDispatchOptions; -} - -type JobReplayOptions = Pick< - JobDispatchOptions, - "deduplicationId" | "retries" | "queue" | "flowControl" | "label" ->; - -const JOBS_ENDPOINT_URL = `${APP_DOMAIN_WITH_NGROK}/api/jobs/process`; - const QSTASH_PUBLISH_MAX_RETRIES = 3; -const QSTASH_BATCH_CHUNK_SIZE = 100; - -export const jobNameSchema = z - .string() - .regex( - /^[a-z][a-z0-9]*(-[a-z0-9]+)*-job$/, - 'Job name must be kebab-case ending in "-job"', - ); - -export function getJobsEndpointUrl(name: string) { - return `${JOBS_ENDPOINT_URL}/${name}`; -} - -// QStash label: user-provided tag first, job name appended for log filtering -export function buildJobLabel(name: string, label?: string) { - return label ? `${label},${name}` : name; -} - -// QStash deduplicationId: user-provided id first, job name appended for cross-job isolation -export function buildJobDeduplicationId( - name: string, - deduplicationId?: string, -) { - if (!deduplicationId) { - return undefined; - } - - return `${deduplicationId},${name}`; -} - -// Wire format published to QStash and consumed by /api/jobs/process/[jobName] -export const jobEnvelopeSchema = z.object({ - name: jobNameSchema, - dispatchedAt: z.string(), - payload: z.unknown(), -}); - -function toErrorFields(error: unknown) { - return { - errorName: error instanceof Error ? error.name : undefined, - errorMessage: error instanceof Error ? error.message : String(error), - }; -} - async function withQStashRetry(fn: () => Promise): Promise { for (let attempt = 0; attempt <= QSTASH_PUBLISH_MAX_RETRIES; attempt++) { try { @@ -118,122 +51,6 @@ async function withQStashRetry(fn: () => Promise): Promise { throw new Error("Failed to publish to QStash."); } -function buildQStashJobRequest( - { name, payload, options }: DispatchJobInput, - opts?: { - dispatchedAt?: string; - batch?: boolean; - notBefore?: number; - }, -) { - const envelope: JobEnvelope = { - name, - payload, - dispatchedAt: opts?.dispatchedAt ?? new Date().toISOString(), - }; - - const notBefore = opts?.notBefore ?? options?.notBefore; - const deduplicationId = buildJobDeduplicationId( - name, - options?.deduplicationId, - ); - - return { - url: getJobsEndpointUrl(name), - body: envelope, - label: buildJobLabel(name, options?.label), - ...(options?.delay && - opts?.notBefore === undefined && { - delay: options.delay, - }), - ...(notBefore && { notBefore }), - ...(deduplicationId && { deduplicationId }), - ...(options?.retries !== undefined && { retries: options.retries }), - ...(options?.flowControl && { flowControl: options.flowControl }), - ...(opts?.batch && options?.queue && { queueName: options.queue }), - }; -} - -export function buildReplayRequest( - job: { - name: string; - payload: Prisma.JsonValue; - options: Prisma.JsonValue | null; - createdAt: Date; - scheduledFor: Date | null; - }, - now: Date = new Date(), -) { - const options = (job.options ?? {}) as JobReplayOptions; - - const notBefore = - job.scheduledFor && job.scheduledFor > now - ? Math.floor(job.scheduledFor.getTime() / 1000) - : undefined; - - return buildQStashJobRequest( - { - name: job.name, - payload: job.payload, - options, - }, - { - dispatchedAt: job.createdAt.toISOString(), - batch: true, - notBefore, - }, - ); -} - -export function isPublishSuccess( - response: unknown, -): response is { messageId: string } { - return ( - typeof response === "object" && - response !== null && - "messageId" in response && - typeof response.messageId === "string" - ); -} - -// Persist jobs that could not be published to QStash. The -// /api/cron/queue/retry cron republishes them and deletes the rows on success. -async function persistBackgroundJobs(inputs: DispatchJobInput[]) { - const jobs = inputs.map(({ name, payload, options }) => { - let scheduledFor: Date | null = null; - - if (options?.notBefore) { - scheduledFor = new Date(options.notBefore * 1000); - } else if (typeof options?.delay === "number") { - scheduledFor = new Date(Date.now() + options.delay * 1000); - } - - const replayOptions = { - ...(options?.deduplicationId && { - deduplicationId: options.deduplicationId, - }), - ...(options?.retries !== undefined && { retries: options.retries }), - ...(options?.queue && { queue: options.queue }), - ...(options?.flowControl && { flowControl: options.flowControl }), - ...(options?.label && { label: options.label }), - }; - - return { - id: createId({ prefix: "job_" }), - name, - payload: payload as Prisma.InputJsonValue, - options: replayOptions as Prisma.InputJsonValue, - scheduledFor, - }; - }); - - await prisma.job.createMany({ - data: jobs, - }); - - return jobs; -} - async function deferJobs(inputs: DispatchJobInput[]) { const backgroundJobs = await persistBackgroundJobs(inputs); @@ -340,7 +157,7 @@ async function dispatchJobs( logger.error("jobs.dispatch_lost", { jobName, jobCount: failedInputs.length, - ...toErrorFields(error), + error: toErrorFields(error), }); failed += failedInputs.length; @@ -362,7 +179,7 @@ async function dispatchJobs( jobName, jobCount: inputChunk.length, batch: !isSingleDispatch, - ...toErrorFields(error), + error: toErrorFields(error), }); try { @@ -373,7 +190,7 @@ async function dispatchJobs( logger.error("jobs.dispatch_lost", { jobName, jobCount: inputChunk.length, - ...toErrorFields(persistError), + error: toErrorFields(persistError), }); failed += inputChunk.length; diff --git a/apps/web/lib/jobs/outbox.ts b/apps/web/lib/jobs/outbox.ts new file mode 100644 index 00000000000..2461e0a693f --- /dev/null +++ b/apps/web/lib/jobs/outbox.ts @@ -0,0 +1,269 @@ +import { createId } from "@/lib/api/create-id"; +import { logger, toErrorFields } from "@/lib/axiom/server"; +import { Job, Prisma } from "@prisma/client"; +import { prisma } from "../prisma"; +import { MAX_JOB_ATTEMPTS, MAX_JOBS_PER_BATCH } from "./constants"; +import { isDefineJobName, sendJobs, type DispatchJobInput } from "./send-jobs"; +import { isWorkflowName, triggerWorkflows } from "./send-workflows"; +import type { PersistableJob, PublishResult } from "./types"; + +type JobTransport = { + matches: (name: string) => boolean; + send: (jobs: Job[]) => Promise; +}; + +const transports: JobTransport[] = [ + { + matches: isWorkflowName, + send: triggerWorkflows, + }, + { + matches: isDefineJobName, + send: sendJobs, + }, +]; + +// First failed publish counts as attempt 1 so retry budget matches MAX_JOB_ATTEMPTS. +function toJobCreateInput({ + job, + lastError, +}: { + job: PersistableJob; + lastError?: string | null; +}): Prisma.JobCreateManyInput { + return { + id: job.id, + name: job.name, + payload: job.payload as Prisma.InputJsonValue, + options: (job.options ?? {}) as Prisma.InputJsonValue, + scheduledAt: job.scheduledAt ?? new Date(), + lastError, + attempts: 1, + }; +} + +// Persist jobs that failed to publish. Swallow DB errors (log only) +export async function persistFailedJobs({ + jobs, + failedResults, + logEvent = "jobs.dispatch_lost", +}: { + jobs: PersistableJob[]; + failedResults: PublishResult[]; + logEvent?: string; +}) { + if (failedResults.length === 0) { + return; + } + + const failedById = new Map( + failedResults.map((result) => [result.id, result]), + ); + + const data = jobs + .filter((job) => failedById.has(job.id)) + .map((job) => + toJobCreateInput({ + job, + lastError: failedById.get(job.id)!.lastError, + }), + ); + + try { + await prisma.job.createMany({ + skipDuplicates: true, + data, + }); + } catch (error) { + logger.error(logEvent, { + jobCount: data.length, + error: toErrorFields(error), + }); + await logger.flush(); + } +} + +// Persist jobs that could not be published to QStash. +// /api/cron/queue/retry cron republishes them and deletes the rows on success. +export async function persistBackgroundJobs(inputs: DispatchJobInput[]) { + const jobs: PersistableJob[] = inputs.map(({ name, payload, options }) => { + let scheduledAt = new Date(); + + if (options?.notBefore) { + scheduledAt = new Date(options.notBefore * 1000); + } else if (typeof options?.delay === "number") { + scheduledAt = new Date(Date.now() + options.delay * 1000); + } + + const replayOptions = { + ...(options?.deduplicationId && { + deduplicationId: options.deduplicationId, + }), + ...(options?.retries !== undefined && { retries: options.retries }), + ...(options?.queue && { queue: options.queue }), + ...(options?.flowControl && { flowControl: options.flowControl }), + ...(options?.label && { label: options.label }), + }; + + return { + id: createId({ prefix: "job_" }), + name, + payload, + options: replayOptions, + scheduledAt, + }; + }); + + await prisma.job.createMany({ + data: jobs.map((job) => toJobCreateInput({ job })), + }); + + return jobs; +} + +// Delete successfully republished rows; bump attempts on failures. +export async function settlePublishResults({ + results, + jobs, +}: { + results: PublishResult[]; + jobs: Pick[]; +}) { + const publishedIds = results + .filter((result) => result.status === "published") + .map((result) => result.id); + + const failedResults = results.filter((result) => result.status === "failed"); + + if (publishedIds.length > 0) { + await prisma.job.deleteMany({ + where: { + id: { + in: publishedIds, + }, + }, + }); + } + + if (failedResults.length === 0) { + return { + published: publishedIds.length, + failed: 0, + }; + } + + const byError = new Map(); + + for (const result of failedResults) { + const key = result.lastError ?? ""; + const ids = byError.get(key) ?? []; + ids.push(result.id); + byError.set(key, ids); + } + + await Promise.all( + Array.from(byError.entries()).map(([lastError, ids]) => + prisma.job.updateMany({ + where: { + id: { + in: ids, + }, + }, + data: { + lastError, + attempts: { + increment: 1, + }, + }, + }), + ), + ); + + const failedIds = new Set(failedResults.map((result) => result.id)); + + const exhaustedJobs = jobs.filter( + (job) => failedIds.has(job.id) && job.attempts + 1 >= MAX_JOB_ATTEMPTS, + ); + + if (exhaustedJobs.length > 0) { + logger.error("jobs.retry_exhausted", { + jobs: exhaustedJobs.map(({ id, name }) => ({ id, name })), + }); + await logger.flush(); + } + + return { + published: publishedIds.length, + failed: failedResults.length, + }; +} + +// Select due rows once (indexed), send via matching transport, then settle. +export async function publishPendingJobs(): Promise<{ + attempted: number; + published: number; + failed: number; +}> { + const jobs = await prisma.job.findMany({ + where: { + scheduledAt: { + lte: new Date(), + }, + attempts: { + lt: MAX_JOB_ATTEMPTS, + }, + }, + orderBy: { + createdAt: "asc", + }, + take: MAX_JOBS_PER_BATCH, + }); + + if (jobs.length === 0) { + return { + attempted: 0, + published: 0, + failed: 0, + }; + } + + const matched = new Set(); + const pending: Promise[] = []; + + for (const transport of transports) { + const batch = jobs.filter((job) => transport.matches(job.name)); + + for (const job of batch) { + matched.add(job.id); + } + + pending.push(transport.send(batch)); + } + + const results = (await Promise.all(pending)).flat(); + + for (const job of jobs) { + if (matched.has(job.id)) { + continue; + } + + logger.error("jobs.unknown_kind", { id: job.id, name: job.name }); + results.push({ + id: job.id, + status: "failed", + lastError: `Unknown job kind: ${job.name}`, + }); + } + + if (matched.size < jobs.length) { + await logger.flush(); + } + + const settled = await settlePublishResults({ results, jobs }); + + return { + attempted: jobs.length, + published: settled.published, + failed: settled.failed, + }; +} diff --git a/apps/web/lib/jobs/publish-workflows.ts b/apps/web/lib/jobs/publish-workflows.ts new file mode 100644 index 00000000000..d1997a5c578 --- /dev/null +++ b/apps/web/lib/jobs/publish-workflows.ts @@ -0,0 +1,65 @@ +import { createId } from "@/lib/api/create-id"; +import { persistFailedJobs } from "./outbox"; +import { + triggerWorkflows, + type WorkflowName, + type WorkflowOptions, +} from "./send-workflows"; +import type { PersistableJob } from "./types"; + +type DispatchWorkflowInput = { + name: WorkflowName; + payload: unknown; + options?: WorkflowOptions; +}; + +type DispatchWorkflowsResult = { + published: number; + failed: number; + results: Awaited>; +}; + +export async function dispatchWorkflows( + input: DispatchWorkflowInput | DispatchWorkflowInput[], +): Promise { + const inputs = Array.isArray(input) ? input : [input]; + + if (inputs.length === 0) { + return { + published: 0, + failed: 0, + results: [], + }; + } + + const jobs: PersistableJob[] = inputs.map((job) => { + const id = createId({ prefix: "job_" }); + + return { + id, + name: job.name, + payload: job.payload, + options: job.options, + }; + }); + + const results = await triggerWorkflows(jobs); + + const publishedResults = results.filter( + (result) => result.status === "published", + ); + + const failedResults = results.filter((result) => result.status === "failed"); + + await persistFailedJobs({ + jobs, + failedResults, + logEvent: "workflows.dispatch_lost", + }); + + return { + published: publishedResults.length, + failed: failedResults.length, + results, + }; +} diff --git a/apps/web/lib/jobs/registry.ts b/apps/web/lib/jobs/registry.ts index a93e0a52d1d..39b8d4e7267 100644 --- a/apps/web/lib/jobs/registry.ts +++ b/apps/web/lib/jobs/registry.ts @@ -29,6 +29,32 @@ const jobLoaders = { import("./handlers/create-tremendous-campaign-job").then( (m) => m.createTremendousCampaignJob, ), + + "sync-group-utm-job": () => + import("./handlers/sync-group-utm-job").then((m) => m.syncGroupUtmJob), + + "partner-search-sync-job": () => + import("./handlers/partner-search-sync-job").then( + (m) => m.partnerSearchSyncJob, + ), + + "process-shopify-order-job": () => + import("./handlers/process-shopify-order-job").then( + (m) => m.processShopifyOrderJob, + ), + + "welcome-user-job": () => + import("./handlers/welcome-user-job").then((m) => m.welcomeUserJob), + + "auto-approve-partner-job": () => + import("./handlers/auto-approve-partner-job").then( + (m) => m.autoApprovePartnerJob, + ), + + "auto-reject-partner-job": () => + import("./handlers/auto-reject-partner-job").then( + (m) => m.autoRejectPartnerJob, + ), } as const satisfies Record Promise>; const jobCache = new Map(); diff --git a/apps/web/lib/jobs/send-jobs.ts b/apps/web/lib/jobs/send-jobs.ts new file mode 100644 index 00000000000..318e5d44781 --- /dev/null +++ b/apps/web/lib/jobs/send-jobs.ts @@ -0,0 +1,201 @@ +import { logger, toErrorFields } from "@/lib/axiom/server"; +import { qstash } from "@/lib/cron"; +import { APP_DOMAIN_WITH_NGROK, chunk, serializeError } from "@dub/utils"; +import { Job, Prisma } from "@prisma/client"; +import { PublishRequest } from "@upstash/qstash"; +import * as z from "zod/v4"; +import { LAST_ERROR_MAX_LENGTH, QSTASH_BATCH_CHUNK_SIZE } from "./constants"; +import type { PublishResult } from "./types"; + +export type JobDispatchOptions = Pick< + PublishRequest, + | "delay" + | "notBefore" + | "deduplicationId" + | "retries" + | "flowControl" + | "label" +> & { + queue?: string; +}; + +type JobReplayOptions = Pick< + JobDispatchOptions, + "deduplicationId" | "retries" | "queue" | "flowControl" | "label" +>; + +export type DispatchJobInput = { + name: string; + payload: unknown; + options?: JobDispatchOptions; +}; + +const JOBS_ENDPOINT_URL = `${APP_DOMAIN_WITH_NGROK}/api/jobs/process`; + +export const jobNameSchema = z + .string() + .regex( + /^[a-z][a-z0-9]*(-[a-z0-9]+)*-job$/, + 'Job name must be kebab-case ending in "-job"', + ); + +export const jobEnvelopeSchema = z.object({ + name: jobNameSchema, + dispatchedAt: z.string(), + payload: z.unknown(), +}); + +type JobEnvelope = z.infer; + +export function isDefineJobName(name: string) { + return jobNameSchema.safeParse(name).success; +} + +function getJobsEndpointUrl(name: string) { + return `${JOBS_ENDPOINT_URL}/${name}`; +} + +export function buildJobLabel(name: string, label?: string) { + return label ? `${label},${name}` : name; +} + +function buildJobDeduplicationId(name: string, deduplicationId?: string) { + if (!deduplicationId) { + return undefined; + } + + return `${deduplicationId},${name}`; +} + +export function buildQStashJobRequest( + { name, payload, options }: DispatchJobInput, + opts?: { + dispatchedAt?: string; + batch?: boolean; + notBefore?: number; + }, +) { + const envelope: JobEnvelope = { + name, + payload, + dispatchedAt: opts?.dispatchedAt ?? new Date().toISOString(), + }; + + const notBefore = opts?.notBefore ?? options?.notBefore; + const deduplicationId = buildJobDeduplicationId( + name, + options?.deduplicationId, + ); + + return { + url: getJobsEndpointUrl(name), + body: envelope, + label: buildJobLabel(name, options?.label), + ...(options?.delay && + opts?.notBefore === undefined && { + delay: options.delay, + }), + ...(notBefore && { notBefore }), + ...(deduplicationId && { deduplicationId }), + ...(options?.retries !== undefined && { retries: options.retries }), + ...(options?.flowControl && { flowControl: options.flowControl }), + ...(opts?.batch && options?.queue && { queueName: options.queue }), + }; +} + +function buildReplayRequest( + job: { + name: string; + payload: Prisma.JsonValue; + options: Prisma.JsonValue | null; + createdAt: Date; + scheduledAt: Date; + }, + now: Date = new Date(), +) { + const options = (job.options ?? {}) as JobReplayOptions; + + const notBefore = + job.scheduledAt && job.scheduledAt > now + ? Math.floor(job.scheduledAt.getTime() / 1000) + : undefined; + + return buildQStashJobRequest( + { + name: job.name, + payload: job.payload, + options, + }, + { + dispatchedAt: job.createdAt.toISOString(), + batch: true, + notBefore, + }, + ); +} + +export function isPublishSuccess( + response: unknown, +): response is { messageId: string } { + return ( + typeof response === "object" && + response !== null && + "messageId" in response && + typeof response.messageId === "string" + ); +} + +export async function sendJobs(jobs: Job[]): Promise { + if (jobs.length === 0) { + return []; + } + + const results: PublishResult[] = []; + const now = new Date(); + + for (const jobChunk of chunk(jobs, QSTASH_BATCH_CHUNK_SIZE)) { + try { + const responses = await qstash.batchJSON( + jobChunk.map((job) => buildReplayRequest(job, now)), + ); + + jobChunk.forEach((job, index) => { + const response = responses[index]; + + if (isPublishSuccess(response)) { + results.push({ + id: job.id, + status: "published", + lastError: null, + messageId: response.messageId, + }); + } else { + results.push({ + id: job.id, + status: "failed", + lastError: "QStash batch publish did not return a messageId", + }); + } + }); + } catch (error) { + const lastError = serializeError(error).slice(0, LAST_ERROR_MAX_LENGTH); + + logger.error("jobs.publish_failed", { + jobCount: jobChunk.length, + error: toErrorFields(error), + }); + + for (const job of jobChunk) { + results.push({ + id: job.id, + status: "failed", + lastError, + }); + } + } + } + + await logger.flush(); + + return results; +} diff --git a/apps/web/lib/jobs/send-workflows.ts b/apps/web/lib/jobs/send-workflows.ts new file mode 100644 index 00000000000..b618d65febe --- /dev/null +++ b/apps/web/lib/jobs/send-workflows.ts @@ -0,0 +1,134 @@ +import { logger, toErrorFields } from "@/lib/axiom/server"; +import { APP_DOMAIN_WITH_NGROK, chunk, serializeError } from "@dub/utils"; +import { PublishRequest } from "@upstash/qstash"; +import { Client, TriggerOptions } from "@upstash/workflow"; +import * as z from "zod/v4"; +import { LAST_ERROR_MAX_LENGTH, QSTASH_BATCH_CHUNK_SIZE } from "./constants"; +import type { PersistableJob, PublishResult } from "./types"; + +const workflowClient = new Client({ + baseUrl: process.env.QSTASH_URL || "https://qstash-us-east-1.upstash.io", + token: process.env.QSTASH_TOKEN || "", + ...(process.env.VERCEL_ENV === "preview" && { + headers: { + "x-vercel-protection-bypass": + process.env.VERCEL_AUTOMATION_BYPASS_SECRET || "", + }, + }), +}); + +export const workflowNameSchema = z + .string() + .regex( + /^[a-z][a-z0-9]*(-[a-z0-9]+)*-workflow$/, + 'Workflow name must be kebab-case ending in "-workflow"', + ); + +const workflowPathMap = { + "partner-approved-workflow": "/api/workflows/partner-approved", + "merge-partner-accounts-workflow": "/api/workflows/merge-partner-accounts", + "create-partner-commission-workflow": + "/api/workflows/create-partner-commission", +} as const; + +for (const name of Object.keys(workflowPathMap)) { + workflowNameSchema.parse(name); +} + +export type WorkflowName = keyof typeof workflowPathMap; + +export type WorkflowOptions = Pick< + PublishRequest, + "label" | "deduplicationId" | "retries" | "flowControl" +>; + +export function isWorkflowName(name: string): name is WorkflowName { + return Object.prototype.hasOwnProperty.call(workflowPathMap, name); +} + +function isTriggerSuccess( + response: unknown, +): response is { workflowRunId: string } { + return ( + typeof response === "object" && + response !== null && + "workflowRunId" in response && + typeof response.workflowRunId === "string" + ); +} + +function buildTriggerRequest(job: PersistableJob): TriggerOptions { + const name = job.name as WorkflowName; + const options = (job.options ?? {}) as WorkflowOptions; + const workflowPath = workflowPathMap[name]; + const workflowKey = workflowPath.split("/").pop()!; + + return { + url: `${APP_DOMAIN_WITH_NGROK}${workflowPath}`, + body: job.payload, + workflowRunId: options.deduplicationId ?? job.id, + retries: options.retries ?? 5, + flowControl: options.flowControl ?? { + key: workflowKey, + parallelism: 15, + }, + ...(options.label && { label: options.label }), + }; +} + +/** Publish workflows to QStash Workflows (no DB writes). */ +export async function triggerWorkflows( + jobs: PersistableJob[], +): Promise { + if (jobs.length === 0) { + return []; + } + + const results: PublishResult[] = []; + + for (const jobChunk of chunk(jobs, QSTASH_BATCH_CHUNK_SIZE)) { + try { + const responses = await workflowClient.trigger( + jobChunk.map((job) => buildTriggerRequest(job)), + ); + + jobChunk.forEach((job, index) => { + const response = responses[index]; + + if (isTriggerSuccess(response)) { + results.push({ + id: job.id, + status: "published", + lastError: null, + workflowRunId: response.workflowRunId, + }); + } else { + results.push({ + id: job.id, + status: "failed", + lastError: "Workflow trigger did not return a workflowRunId", + }); + } + }); + } catch (error) { + const lastError = serializeError(error).slice(0, LAST_ERROR_MAX_LENGTH); + + logger.error("workflows.publish_failed", { + jobCount: jobChunk.length, + error: toErrorFields(error), + }); + + for (const job of jobChunk) { + results.push({ + id: job.id, + status: "failed", + lastError, + }); + } + } + } + + await logger.flush(); + + return results; +} diff --git a/apps/web/lib/jobs/types.ts b/apps/web/lib/jobs/types.ts new file mode 100644 index 00000000000..769d18d8ff9 --- /dev/null +++ b/apps/web/lib/jobs/types.ts @@ -0,0 +1,15 @@ +export type PublishResult = { + id: string; + status: "published" | "failed"; + lastError: string | null; + messageId?: string; + workflowRunId?: string; +}; + +export type PersistableJob = { + id: string; + name: string; + payload: unknown; + options?: unknown; + scheduledAt?: Date; +}; diff --git a/apps/web/lib/lemonsqueezy/client.ts b/apps/web/lib/lemonsqueezy/client.ts new file mode 100644 index 00000000000..b2b86075611 --- /dev/null +++ b/apps/web/lib/lemonsqueezy/client.ts @@ -0,0 +1,187 @@ +import { HttpBaseClient } from "@/lib/http/base-client"; +import * as z from "zod/v4"; +import { + lemonSqueezyAffiliateSchema, + lemonSqueezyCustomerSchema, + lemonSqueezyJsonApiListSchema, + lemonSqueezyListResourcesInputSchema, + lemonSqueezyOrderSchema, + lemonSqueezyStoreSchema, + lemonSqueezySubscriptionInvoiceSchema, +} from "./schemas"; +import { + LemonSqueezyAffiliate, + LemonSqueezyCustomer, + LemonSqueezyJsonApiResource, + LemonSqueezyOrder, + LemonSqueezyStore, + LemonSqueezySubscriptionInvoice, +} from "./types"; + +const LEMONSQUEEZY_PAGE_SIZE = 100; + +function flattenResource( + resource: LemonSqueezyJsonApiResource, + schema: T, + extra?: Record, +): z.infer { + return schema.parse({ + id: resource.id, + ...resource.attributes, + ...extra, + }); +} + +function getRelationshipIds( + resource: LemonSqueezyJsonApiResource, + relationshipName: string, +): string[] { + const relationship = resource.relationships?.[relationshipName] as + | { + data?: + | { type: string; id: string } + | Array<{ type: string; id: string }> + | null; + } + | undefined; + + if (!relationship?.data) { + return []; + } + + if (Array.isArray(relationship.data)) { + return relationship.data.map((item) => item.id); + } + + return [relationship.data.id]; +} + +export class LemonSqueezyClient extends HttpBaseClient { + protected readonly vendor = "Lemon Squeezy"; + protected readonly baseUrl = "https://api.lemonsqueezy.com/v1"; + + private readonly apiKey: string; + + constructor({ apiKey }: { apiKey: string }) { + super(); + this.apiKey = apiKey; + } + + protected buildAuthHeaders() { + return { + Accept: "application/vnd.api+json", + "Content-Type": "application/vnd.api+json", + Authorization: `Bearer ${this.apiKey}`, + }; + } + + private async listResources({ + path, + storeId, + page = 1, + include, + }: { + path: string; + storeId?: string; + page?: number; + include?: string; + }) { + return await this.get(path, { + input: { + "page[number]": page, + "page[size]": LEMONSQUEEZY_PAGE_SIZE, + ...(storeId ? { "filter[store_id]": storeId } : {}), + ...(include ? { include } : {}), + }, + inputSchema: lemonSqueezyListResourcesInputSchema, + outputSchema: lemonSqueezyJsonApiListSchema, + }); + } + + async listStores(): Promise { + const { data } = await this.listResources({ path: "/stores" }); + + return data.map((resource) => + flattenResource(resource, lemonSqueezyStoreSchema), + ); + } + + async listAffiliates({ + storeId, + page = 1, + }: { + storeId: string; + page?: number; + }): Promise { + const { data } = await this.listResources({ + path: "/affiliates", + storeId, + page, + }); + + return data.map((resource) => + flattenResource(resource, lemonSqueezyAffiliateSchema), + ); + } + + async listCustomers({ + storeId, + page = 1, + include, + }: { + storeId: string; + page?: number; + include?: string; + }): Promise { + const { data } = await this.listResources({ + path: "/customers", + storeId, + page, + include, + }); + + return data.map((resource) => + flattenResource(resource, lemonSqueezyCustomerSchema, { + // When `include=affiliates` (or relationship data is sideloaded), + // JSON:API puts affiliate refs on relationships.affiliates.data + affiliate_ids: getRelationshipIds(resource, "affiliates"), + }), + ); + } + + async listOrders({ + storeId, + page = 1, + }: { + storeId: string; + page?: number; + }): Promise { + const { data } = await this.listResources({ + path: "/orders", + storeId, + page, + }); + + return data.map((resource) => + flattenResource(resource, lemonSqueezyOrderSchema), + ); + } + + async listSubscriptionInvoices({ + storeId, + page = 1, + }: { + storeId: string; + page?: number; + }): Promise { + const { data } = await this.listResources({ + path: "/subscription-invoices", + storeId, + page, + }); + + return data.map((resource) => + flattenResource(resource, lemonSqueezySubscriptionInvoiceSchema), + ); + } +} diff --git a/apps/web/lib/lemonsqueezy/import-commissions.ts b/apps/web/lib/lemonsqueezy/import-commissions.ts new file mode 100644 index 00000000000..821405a9af0 --- /dev/null +++ b/apps/web/lib/lemonsqueezy/import-commissions.ts @@ -0,0 +1,653 @@ +import { prisma } from "@/lib/prisma"; +import { sendEmail } from "@dub/email"; +import ProgramImported from "@dub/email/templates/program-imported"; +import { chunk, nanoid } from "@dub/utils"; +import { CommissionStatus, Customer, Link, Program } from "@prisma/client"; +import { convertCurrencyWithFxRates } from "../analytics/convert-currency"; +import { isFirstConversion } from "../analytics/is-first-conversion"; +import { createId } from "../api/create-id"; +import { updateLinkStatsForImporter } from "../api/links/update-link-stats-for-importer"; +import { syncPartnerLinksStats } from "../api/partners/sync-partner-links-stats"; +import { syncTotalCommissions } from "../api/partners/sync-total-commissions"; +import { getLeadEvents } from "../tinybird/get-lead-events"; +import { logImportError } from "../tinybird/log-import-error"; +import { recordSaleWithTimestamp } from "../tinybird/record-sale"; +import { LeadEventTB } from "../types"; +import { redis } from "../upstash"; +import { clickEventSchemaTB } from "../zod/schemas/clicks"; +import { LemonSqueezyClient } from "./client"; +import { LEMONSQUEEZY_MAX_BATCHES, lemonSqueezyImporter } from "./importer"; +import { + LemonSqueezyImportPayload, + LemonSqueezyOrder, + LemonSqueezySubscriptionInvoice, +} from "./types"; + +type SaleEvent = { + invoiceId: string; + affiliateId: string; + customerExternalId: string; + amount: number; + currency: string; + amountUsd: number | null | undefined; + firstOrderItemPrice?: number | null; + referralAmount: number | null | undefined; + status: string; + createdAt: string; + metadata: Record; +}; + +// Initial referral is on the Order; renewals/updates are on Invoices. +// Skip billing_reason: initial (no referral) and missing/unknown reasons. +const IMPORTABLE_INVOICE_REASONS = new Set(["renewal", "updated"]); + +export async function importCommissions(payload: LemonSqueezyImportPayload) { + const { + importId, + programId, + storeId, + userId, + page = 1, + resource = "orders", + } = payload; + + const program = await prisma.program.findUnique({ + where: { + id: programId, + }, + select: { + id: true, + name: true, + workspaceId: true, + domain: true, + }, + }); + + if (!program) { + console.error(`Program ${programId} not found.`); + return; + } + + if (!program.domain) { + console.error("Program domain not found", program.id); + return; + } + + const { apiKey } = await lemonSqueezyImporter.getCredentials( + program.workspaceId, + ); + const lemonSqueezyApi = new LemonSqueezyClient({ apiKey }); + + const fxRates = await redis.hgetall>("fxRates:usd"); + + let currentPage = page; + let hasMore = true; + let processedBatches = 0; + + while (hasMore && processedBatches < LEMONSQUEEZY_MAX_BATCHES) { + const { saleEvents, pageEmpty } = + resource === "orders" + ? await listOrderSaleEvents({ + lemonSqueezyApi, + storeId, + page: currentPage, + }) + : await listInvoiceSaleEvents({ + lemonSqueezyApi, + storeId, + page: currentPage, + }); + + if (pageEmpty) { + hasMore = false; + break; + } + + if (saleEvents.length > 0) { + await processSaleEvents({ + program, + domain: program.domain, + saleEvents, + fxRates, + importId, + }); + } + + currentPage++; + processedBatches++; + } + + if (hasMore) { + await lemonSqueezyImporter.queue({ + ...payload, + action: "import-commissions", + resource, + page: currentPage, + }); + return; + } + + // Finished orders (month-1 + one-time) → renewals/updates via subscription invoices + if (resource === "orders") { + await lemonSqueezyImporter.queue({ + ...payload, + action: "import-commissions", + resource: "subscription-invoices", + page: 1, + }); + return; + } + + const workspaceUser = await prisma.projectUsers.findUnique({ + where: { + userId_projectId: { + userId, + projectId: program.workspaceId, + }, + }, + include: { + project: true, + user: true, + }, + }); + + if (workspaceUser?.user.email) { + await sendEmail({ + to: workspaceUser.user.email, + subject: "Lemon Squeezy program imported", + react: ProgramImported({ + email: workspaceUser.user.email, + workspace: workspaceUser.project, + program, + provider: "Lemon Squeezy", + importId, + }), + }); + } + + await lemonSqueezyImporter.deleteCredentials(program.workspaceId); +} + +async function listOrderSaleEvents({ + lemonSqueezyApi, + storeId, + page, +}: { + lemonSqueezyApi: LemonSqueezyClient; + storeId: string; + page: number; +}): Promise<{ saleEvents: SaleEvent[]; pageEmpty: boolean }> { + const orders = await lemonSqueezyApi.listOrders({ + storeId, + page, + }); + + if (orders.length === 0) { + return { + saleEvents: [], + pageEmpty: true, + }; + } + + // All attributed Orders: subscription first period + one-time (LS payouts use Order) + const saleEvents = orders + .filter( + ( + order, + ): order is LemonSqueezyOrder & { + affiliate_id: number; + customer_id: number; + } => Boolean(order.affiliate_id) && order.customer_id != null, + ) + .map((order) => ({ + invoiceId: `ls_order_${order.id}`, + affiliateId: String(order.affiliate_id), + customerExternalId: String(order.customer_id), + amount: order.subtotal, + currency: order.currency, + amountUsd: order.subtotal_usd, + firstOrderItemPrice: order.first_order_item?.price, + referralAmount: order.referral_amount, + status: order.status, + createdAt: order.created_at || new Date().toISOString(), + metadata: order as unknown as Record, + })); + + return { + saleEvents, + pageEmpty: false, + }; +} + +async function listInvoiceSaleEvents({ + lemonSqueezyApi, + storeId, + page, +}: { + lemonSqueezyApi: LemonSqueezyClient; + storeId: string; + page: number; +}): Promise<{ saleEvents: SaleEvent[]; pageEmpty: boolean }> { + const invoices = await lemonSqueezyApi.listSubscriptionInvoices({ + storeId, + page, + }); + + if (invoices.length === 0) { + return { + saleEvents: [], + pageEmpty: true, + }; + } + + const saleEvents = invoices + .filter( + ( + invoice, + ): invoice is LemonSqueezySubscriptionInvoice & { + affiliate_id: number; + customer_id: number; + } => + Boolean(invoice.affiliate_id) && + invoice.customer_id != null && + Boolean( + invoice.billing_reason && + IMPORTABLE_INVOICE_REASONS.has(invoice.billing_reason), + ), + ) + .map((invoice) => ({ + invoiceId: `ls_invoice_${invoice.id}`, + affiliateId: String(invoice.affiliate_id), + customerExternalId: String(invoice.customer_id), + amount: invoice.subtotal, + currency: invoice.currency, + amountUsd: invoice.subtotal_usd, + referralAmount: invoice.referral_amount, + status: invoice.status, + createdAt: invoice.created_at || new Date().toISOString(), + metadata: invoice as unknown as Record, + })); + + return { + saleEvents, + pageEmpty: false, + }; +} + +async function processSaleEvents({ + program, + domain, + saleEvents, + fxRates, + importId, +}: { + program: Pick; + domain: string; + saleEvents: SaleEvent[]; + fxRates: Record | null; + importId: string; +}) { + const affiliateIds = [ + ...new Set(saleEvents.map((event) => event.affiliateId)), + ]; + const customerExternalIds = [ + ...new Set(saleEvents.map((event) => event.customerExternalId)), + ]; + + const [links, customersData] = await Promise.all([ + prisma.link.findMany({ + where: { + domain, + programId: program.id, + key: { + in: affiliateIds, + }, + }, + }), + + prisma.customer.findMany({ + where: { + projectId: program.workspaceId, + externalId: { + in: customerExternalIds, + }, + }, + include: { + link: true, + }, + orderBy: { + createdAt: "asc", + }, + }), + ]); + + const affiliateIdToLink = new Map(links.map((link) => [link.key, link])); + + const customerLeadEvents = await getLeadEvents({ + customerIds: customersData.map((customer) => customer.id), + }).then((res) => res.data); + + const saleChunks = chunk(saleEvents, 10); + + for (const saleChunk of saleChunks) { + const results = await Promise.allSettled( + saleChunk.map((saleEvent) => + createCommission({ + program, + saleEvent, + partnerLink: affiliateIdToLink.get(saleEvent.affiliateId), + fxRates, + importId, + customersData, + customerLeadEvents, + }), + ), + ); + + const failures = results.flatMap((result, index) => + result.status === "rejected" + ? [{ saleEvent: saleChunk[index], reason: result.reason }] + : [], + ); + + if (failures.length > 0) { + await logImportError( + failures.map(({ saleEvent, reason }) => ({ + workspace_id: program.workspaceId, + import_id: importId, + source: "lemonsqueezy" as const, + entity: "commission" as const, + entity_id: saleEvent.invoiceId, + code: "TRANSACTION_NOT_FOUND" as const, + message: `Failed to import commission ${saleEvent.invoiceId}: ${ + reason instanceof Error ? reason.message : String(reason) + }`, + })), + ); + } + } +} + +async function createCommission({ + program, + saleEvent, + partnerLink, + fxRates, + importId, + customersData, + customerLeadEvents, +}: { + program: Pick; + saleEvent: SaleEvent; + partnerLink?: Link; + fxRates: Record | null; + importId: string; + customersData: (Customer & { link: Link | null })[]; + customerLeadEvents: LeadEventTB[]; +}) { + const commonImportLogInputs = { + workspace_id: program.workspaceId, + import_id: importId, + source: "lemonsqueezy" as const, + entity: "commission" as const, + entity_id: saleEvent.invoiceId, + }; + + const status = toDubStatus(saleEvent.status); + + const existingCommission = await prisma.commission.findUnique({ + where: { + invoiceId_programId: { + invoiceId: saleEvent.invoiceId, + programId: program.id, + }, + }, + select: { + id: true, + }, + }); + + if (existingCommission) { + console.log( + `Commission ${saleEvent.invoiceId} already exists, skipping...`, + ); + return; + } + + if (!partnerLink?.partnerId) { + await logImportError({ + ...commonImportLogInputs, + code: "PARTNER_NOT_FOUND", + message: `No imported partner found for affiliate ${saleEvent.affiliateId} (commission ${saleEvent.invoiceId}).`, + }); + return; + } + + const existingCustomer = customersData.find( + ({ externalId }) => externalId === saleEvent.customerExternalId, + ); + + if (!existingCustomer) { + await logImportError({ + ...commonImportLogInputs, + code: "CUSTOMER_NOT_FOUND", + message: `No customer ${saleEvent.customerExternalId} found for commission ${saleEvent.invoiceId}.`, + }); + return; + } + + if (!existingCustomer.clickId) { + await logImportError({ + ...commonImportLogInputs, + code: "CLICK_NOT_FOUND", + message: `No click found for customer ${existingCustomer.id}.`, + }); + return; + } + + const leadEvent = customerLeadEvents.find( + (event) => event.customer_id === existingCustomer.id, + ); + + if (!leadEvent) { + await logImportError({ + ...commonImportLogInputs, + code: "LEAD_NOT_FOUND", + message: `No lead event found for customer ${existingCustomer.id}.`, + }); + return; + } + + // Prefer LS-provided USD amounts; otherwise convert. For subscription first + // charges, order subtotal is often 0 while first_order_item.price has the amount. + let saleAmount = resolveAmountUsd({ + amount: saleEvent.amount, + amountUsd: saleEvent.amountUsd, + currency: saleEvent.currency, + fxRates, + }); + + if ( + (saleAmount == null || saleAmount === 0) && + saleEvent.firstOrderItemPrice != null && + saleEvent.firstOrderItemPrice > 0 + ) { + saleAmount = resolveAmountUsd({ + amount: saleEvent.firstOrderItemPrice, + amountUsd: null, + currency: saleEvent.currency, + fxRates, + }); + } + + if (saleAmount == null) { + await logImportError({ + ...commonImportLogInputs, + code: "NOT_SUPPORTED_UNIT", + message: `Commission ${saleEvent.invoiceId} skipped: no USD amount and FX rate unavailable for currency ${saleEvent.currency}.`, + }); + return; + } + + const createdAt = new Date(saleEvent.createdAt); + + const earnings = + saleEvent.referralAmount == null + ? 0 + : resolveAmountUsd({ + amount: saleEvent.referralAmount, + amountUsd: null, + currency: saleEvent.currency, + fxRates, + }) ?? 0; + + const clickData = clickEventSchemaTB + .omit({ timestamp: true }) + .parse(leadEvent); + + const eventId = nanoid(16); + + await Promise.all([ + prisma.commission.create({ + data: { + id: createId({ prefix: "cm_" }), + eventId, + type: "sale", + programId: program.id, + partnerId: partnerLink.partnerId, + linkId: partnerLink.id, + customerId: existingCustomer.id, + amount: saleAmount, + earnings, + currency: "usd", + quantity: 1, + status, + invoiceId: saleEvent.invoiceId, + createdAt, + }, + }), + + saleAmount > 0 && + recordSaleWithTimestamp({ + ...clickData, + link_id: partnerLink.id, + domain: partnerLink.domain, + key: partnerLink.key, + url: partnerLink.url, + event_id: eventId, + event_name: "Invoice paid", + amount: saleAmount, + customer_id: existingCustomer.id, + payment_processor: "lemonsqueezy", + currency: "usd", + metadata: JSON.stringify(saleEvent.metadata), + timestamp: createdAt.toISOString(), + }), + + prisma.link.update({ + where: { + id: partnerLink.id, + }, + data: { + ...(isFirstConversion({ + customer: existingCustomer, + linkId: partnerLink.id, + }) && { + conversions: { + increment: 1, + }, + lastConversionAt: updateLinkStatsForImporter({ + currentTimestamp: partnerLink.lastConversionAt, + newTimestamp: createdAt, + }), + }), + ...(saleAmount > 0 && { + sales: { + increment: 1, + }, + saleAmount: { + increment: saleAmount, + }, + }), + }, + }), + + syncPartnerLinksStats({ + partnerId: partnerLink.partnerId, + programId: program.id, + eventType: "sale", + }), + + saleAmount > 0 && + prisma.customer.update({ + where: { + id: existingCustomer.id, + }, + data: { + sales: { + increment: 1, + }, + saleAmount: { + increment: saleAmount, + }, + firstSaleAt: existingCustomer.firstSaleAt ? undefined : createdAt, + }, + }), + ]); + + await syncTotalCommissions({ + partnerId: partnerLink.partnerId, + programId: program.id, + }); +} + +function resolveAmountUsd({ + amount, + amountUsd, + currency, + fxRates, +}: { + amount: number; + amountUsd: number | null | undefined; + currency: string; + fxRates: Record | null; +}): number | null { + if (amountUsd != null) { + return amountUsd; + } + + if (currency.toUpperCase() === "USD") { + return amount; + } + + if (!fxRates) { + return null; + } + + const converted = convertCurrencyWithFxRates({ + currency, + amount, + fxRates, + }); + + return converted.currency.toUpperCase() === "USD" ? converted.amount : null; +} + +function toDubStatus(status: string): CommissionStatus { + switch (status) { + case "paid": + return CommissionStatus.paid; + case "pending": + return CommissionStatus.pending; + case "refunded": + case "partial_refund": + return CommissionStatus.refunded; + case "fraudulent": + return CommissionStatus.fraud; + case "void": + case "failed": + return CommissionStatus.canceled; + default: + return CommissionStatus.pending; + } +} diff --git a/apps/web/lib/lemonsqueezy/import-customers.ts b/apps/web/lib/lemonsqueezy/import-customers.ts new file mode 100644 index 00000000000..25a6c6dbd03 --- /dev/null +++ b/apps/web/lib/lemonsqueezy/import-customers.ts @@ -0,0 +1,301 @@ +import { prisma } from "@/lib/prisma"; +import { chunk, nanoid } from "@dub/utils"; +import { Customer, Link, Project } from "@prisma/client"; +import { createId } from "../api/create-id"; +import { updateLinkStatsForImporter } from "../api/links/update-link-stats-for-importer"; +import { syncPartnerLinksStats } from "../api/partners/sync-partner-links-stats"; +import { recordLeadWithTimestamp } from "../tinybird"; +import { logImportError } from "../tinybird/log-import-error"; +import { recordFakeClick } from "../tinybird/record-fake-click"; +import { LemonSqueezyClient } from "./client"; +import { LEMONSQUEEZY_MAX_BATCHES, lemonSqueezyImporter } from "./importer"; +import { LemonSqueezyCustomer, LemonSqueezyImportPayload } from "./types"; + +export async function importCustomers(payload: LemonSqueezyImportPayload) { + const { importId, programId, storeId, page = 1 } = payload; + + const program = await prisma.program.findUnique({ + where: { + id: programId, + }, + select: { + id: true, + name: true, + domain: true, + url: true, + workspace: { + select: { + id: true, + plan: true, + stripeConnectId: true, + }, + }, + }, + }); + + if (!program) { + console.error(`Program ${programId} not found.`); + return; + } + + if (!program.domain || !program.url) { + console.error("Program domain or url not found", program.id); + return; + } + + const { workspace } = program; + const { apiKey } = await lemonSqueezyImporter.getCredentials(workspace.id); + const lemonSqueezyApi = new LemonSqueezyClient({ apiKey }); + + let currentPage = page; + let hasMore = true; + let processedBatches = 0; + + while (hasMore && processedBatches < LEMONSQUEEZY_MAX_BATCHES) { + const customers = await lemonSqueezyApi.listCustomers({ + storeId, + page: currentPage, + include: "affiliates", + }); + + if (customers.length === 0) { + hasMore = false; + break; + } + + // Gate: only import customers with a non-empty affiliates relationship + const referredCustomers = customers.filter( + (customer) => customer.affiliate_ids.length > 0, + ); + + if (referredCustomers.length > 0) { + const affiliateIds = [ + ...new Set( + referredCustomers.flatMap((customer) => customer.affiliate_ids), + ), + ]; + + const links = await prisma.link.findMany({ + where: { + domain: program.domain, + programId: program.id, + key: { + in: affiliateIds, + }, + }, + select: { + id: true, + key: true, + domain: true, + url: true, + projectId: true, + partnerId: true, + programId: true, + lastLeadAt: true, + }, + }); + + const affiliateIdToLink = new Map(links.map((link) => [link.key, link])); + + const customerExternalIds = referredCustomers.map( + (customer) => customer.id, + ); + + const existingCustomers = await prisma.customer.findMany({ + where: { + projectId: workspace.id, + externalId: { + in: customerExternalIds, + }, + }, + select: { + id: true, + externalId: true, + }, + }); + + const existingExternalIds = new Set( + existingCustomers.map((customer) => customer.externalId), + ); + + const newCustomers = referredCustomers.filter( + (customer) => !existingExternalIds.has(customer.id), + ); + + if (newCustomers.length > 0) { + const customerChunks = chunk(newCustomers, 10); + + for (const customerChunk of customerChunks) { + await Promise.allSettled( + customerChunk.map((customer) => { + // Deterministic: first affiliate_id that has an imported partner link + const affiliateId = customer.affiliate_ids.find((id) => + affiliateIdToLink.has(id), + ); + + return createCustomer({ + workspace, + customer, + link: affiliateId + ? affiliateIdToLink.get(affiliateId) + : undefined, + importId, + }); + }), + ); + } + } + } + + currentPage++; + processedBatches++; + } + + await lemonSqueezyImporter.queue({ + ...payload, + action: hasMore ? "import-customers" : "import-commissions", + page: hasMore ? currentPage : undefined, + resource: hasMore ? undefined : "orders", + }); +} + +async function createCustomer({ + workspace, + customer, + link, + importId, +}: { + workspace: Pick; + customer: LemonSqueezyCustomer; + link?: Pick< + Link, + | "id" + | "key" + | "domain" + | "url" + | "projectId" + | "partnerId" + | "programId" + | "lastLeadAt" + >; + importId: string; +}) { + const externalId = customer.id; + + const commonImportLogInputs = { + workspace_id: workspace.id, + import_id: importId, + source: "lemonsqueezy" as const, + entity: "customer" as const, + entity_id: externalId, + }; + + if (!customer.email) { + await logImportError({ + ...commonImportLogInputs, + code: "CUSTOMER_EMAIL_NOT_FOUND", + message: `Customer ${externalId} not imported because it has no email.`, + }); + + return; + } + + if (!link) { + await logImportError({ + ...commonImportLogInputs, + code: "LINK_NOT_FOUND", + message: `No imported partner link found for customer ${externalId} (affiliates: ${customer.affiliate_ids.join(", ")}).`, + }); + + return; + } + + const clickedAt = new Date(customer.created_at || Date.now()); + + let clickEvent: Awaited>; + + try { + clickEvent = await recordFakeClick({ + link, + customer: { + country: customer.country, + }, + timestamp: clickedAt.toISOString(), + }); + } catch { + await logImportError({ + ...commonImportLogInputs, + code: "CLICK_NOT_FOUND", + message: `Failed to record click for customer ${externalId}.`, + }); + + return; + } + + let createdCustomer: Customer | null = null; + + try { + createdCustomer = await prisma.customer.create({ + data: { + id: createId({ prefix: "cus_" }), + name: customer.name || customer.email, + email: customer.email, + externalId, + projectId: workspace.id, + projectConnectId: workspace.stripeConnectId, + clickId: clickEvent.click_id, + linkId: link.id, + programId: link.programId, + partnerId: link.partnerId, + country: customer.country || clickEvent.country, + clickedAt, + createdAt: clickedAt, + }, + }); + } catch (error) { + if (error.code === "P2002") { + console.warn( + `Customer with external ID ${externalId} already exists. Skipping...`, + ); + } else { + console.error("Error creating customer", customer, error); + } + + return; + } + + await Promise.all([ + recordLeadWithTimestamp({ + ...clickEvent, + event_id: nanoid(16), + event_name: "Sign up", + customer_id: createdCustomer.id, + timestamp: clickedAt.toISOString(), + }), + + prisma.link.update({ + where: { + id: link.id, + }, + data: { + leads: { + increment: 1, + }, + lastLeadAt: updateLinkStatsForImporter({ + currentTimestamp: link.lastLeadAt, + newTimestamp: clickedAt, + }), + }, + }), + + ...(link.partnerId && link.programId + ? [ + syncPartnerLinksStats({ + partnerId: link.partnerId, + programId: link.programId, + eventType: "lead", + }), + ] + : []), + ]); +} diff --git a/apps/web/lib/lemonsqueezy/import-partners.ts b/apps/web/lib/lemonsqueezy/import-partners.ts new file mode 100644 index 00000000000..dc2f818ab08 --- /dev/null +++ b/apps/web/lib/lemonsqueezy/import-partners.ts @@ -0,0 +1,312 @@ +import { prisma } from "@/lib/prisma"; +import { PartnerGroup, Program } from "@prisma/client"; +import { createId } from "../api/create-id"; +import { queuePartnerSearchSync } from "../api/partners/queue-partner-search-sync"; +import { createLink } from "../api/links"; +import { generatePartnerLink } from "../api/partners/generate-partner-link"; +import { logImportError } from "../tinybird/log-import-error"; +import { WorkspaceProps } from "../types"; +import { DEFAULT_PARTNER_GROUP } from "../zod/schemas/groups"; +import { LemonSqueezyClient } from "./client"; +import { LEMONSQUEEZY_MAX_BATCHES, lemonSqueezyImporter } from "./importer"; +import { LemonSqueezyAffiliate, LemonSqueezyImportPayload } from "./types"; + +export async function importPartners(payload: LemonSqueezyImportPayload) { + const { importId, programId, storeId, userId, page = 1 } = payload; + + const program = await prisma.program.findUnique({ + where: { + id: programId, + }, + select: { + id: true, + name: true, + domain: true, + url: true, + workspaceId: true, + defaultFolderId: true, + groups: { + select: { + id: true, + slug: true, + clickRewardId: true, + leadRewardId: true, + saleRewardId: true, + referralRewardId: true, + discountId: true, + }, + }, + workspace: { + select: { + id: true, + plan: true, + }, + }, + }, + }); + + if (!program) { + console.error(`Program ${programId} not found.`); + return; + } + + if (!program.domain || !program.url) { + console.error("Program domain or url not found", program.id); + return; + } + + const defaultGroup = program.groups.find( + (group) => group.slug === DEFAULT_PARTNER_GROUP.slug, + ); + + if (!defaultGroup) { + console.error(`Default group not found for program ${programId}.`); + return; + } + + const workspace = program.workspace as WorkspaceProps; + + const { apiKey } = await lemonSqueezyImporter.getCredentials(workspace.id); + const lemonSqueezyApi = new LemonSqueezyClient({ apiKey }); + + let currentPage = page; + let hasMore = true; + let processedBatches = 0; + + const commonImportLogInputs = { + workspace_id: program.workspaceId, + import_id: importId, + source: "lemonsqueezy" as const, + entity: "partner" as const, + }; + + while (hasMore && processedBatches < LEMONSQUEEZY_MAX_BATCHES) { + const affiliates = await lemonSqueezyApi.listAffiliates({ + storeId, + page: currentPage, + }); + + if (affiliates.length === 0) { + hasMore = false; + break; + } + + const activeAffiliates: LemonSqueezyAffiliate[] = []; + const notImportedAffiliates: LemonSqueezyAffiliate[] = []; + + for (const affiliate of affiliates) { + // LS has no leads count on affiliates. Import all active partners; + // pending/disabled are skipped. Customer/commission steps only + // attach referred activity. + if (affiliate.status === "active") { + activeAffiliates.push(affiliate); + } else { + notImportedAffiliates.push(affiliate); + } + } + + if (activeAffiliates.length > 0) { + const results = await Promise.allSettled( + activeAffiliates.map((affiliate) => + createPartnerAndLinks({ + workspace, + program, + affiliate, + group: defaultGroup, + userId, + importId, + }), + ), + ); + + const failures = results.flatMap((result, index) => + result.status === "rejected" + ? [{ affiliate: activeAffiliates[index], reason: result.reason }] + : [], + ); + + if (failures.length > 0) { + await logImportError( + failures.map(({ affiliate, reason }) => ({ + ...commonImportLogInputs, + entity_id: affiliate.id, + code: "PARTNER_NOT_FOUND" as const, + message: `Failed to import affiliate ${affiliate.id}: ${ + reason instanceof Error ? reason.message : String(reason) + }`, + })), + ); + } + + // Queue an index update because the imported partners were enrolled. + // Queued per page rather than per partner. + await queuePartnerSearchSync({ + partnerIds: results.flatMap((result) => + result.status === "fulfilled" && result.value ? [result.value] : [], + ), + programId: program.id, + }); + } + + if (notImportedAffiliates.length > 0) { + await logImportError( + notImportedAffiliates.map((affiliate) => ({ + ...commonImportLogInputs, + entity_id: affiliate.id, + code: "INACTIVE_PARTNER", + message: `Partner ${affiliate.user_email} not imported because status is "${affiliate.status}" (only active affiliates are imported).`, + })), + ); + } + + currentPage++; + processedBatches++; + } + + await lemonSqueezyImporter.queue({ + ...payload, + action: hasMore ? "import-partners" : "import-customers", + page: hasMore ? currentPage : undefined, + }); +} + +async function createPartnerAndLinks({ + workspace, + program, + affiliate, + group, + userId, + importId, +}: { + workspace: Pick; + program: Pick< + Program, + "id" | "workspaceId" | "domain" | "url" | "defaultFolderId" + >; + affiliate: LemonSqueezyAffiliate; + group: Pick< + PartnerGroup, + | "id" + | "discountId" + | "clickRewardId" + | "leadRewardId" + | "saleRewardId" + | "referralRewardId" + >; + userId: string; + importId: string; +}) { + if (!affiliate.user_email) { + await logImportError({ + workspace_id: program.workspaceId, + import_id: importId, + source: "lemonsqueezy", + entity: "partner", + entity_id: affiliate.id, + code: "PARTNER_NOT_FOUND", + message: `Affiliate ${affiliate.id} not imported because it has no email.`, + }); + + return; + } + + const partner = await prisma.partner.upsert({ + where: { + email: affiliate.user_email, + }, + create: { + id: createId({ prefix: "pn_" }), + name: affiliate.user_name || affiliate.user_email, + email: affiliate.user_email, + }, + update: {}, + }); + + const { links } = await prisma.programEnrollment.upsert({ + where: { + partnerId_programId: { + partnerId: partner.id, + programId: program.id, + }, + }, + create: { + id: createId({ prefix: "pge_" }), + programId: program.id, + partnerId: partner.id, + status: "approved", + groupId: group.id, + clickRewardId: group.clickRewardId, + leadRewardId: group.leadRewardId, + saleRewardId: group.saleRewardId, + referralRewardId: group.referralRewardId, + discountId: group.discountId, + }, + update: { + status: "approved", + }, + select: { + links: { + select: { + key: true, + }, + }, + }, + }); + + if (links.length > 0 && links.some((link) => link.key === affiliate.id)) { + console.log( + `Partner ${partner.id} already has a link with key ${affiliate.id}, skipping...`, + ); + return partner.id; + } + + try { + const partnerLink = await generatePartnerLink({ + workspace, + program, + partner: { + id: partner.id, + name: partner.name, + email: partner.email!, + }, + link: { + domain: program.domain!, + url: program.url!, + // Use affiliate id so commissions can map affiliate_id → partner link + key: affiliate.id, + }, + userId, + }); + + // Reject suffixed keys — customers/commissions look up by exact affiliate id + if (partnerLink.key !== affiliate.id) { + await logImportError({ + workspace_id: program.workspaceId, + import_id: importId, + source: "lemonsqueezy", + entity: "partner", + entity_id: affiliate.id, + code: "LINK_NOT_FOUND", + message: `Partner link key conflict for affiliate ${affiliate.id}: generated key "${partnerLink.key}" instead of "${affiliate.id}".`, + }); + return partner.id; + } + + await createLink(partnerLink); + } catch (error) { + console.error("Error creating partner link", error, affiliate); + await logImportError({ + workspace_id: program.workspaceId, + import_id: importId, + source: "lemonsqueezy", + entity: "partner", + entity_id: affiliate.id, + code: "LINK_NOT_FOUND", + message: `Failed to create partner link for affiliate ${affiliate.id}: ${ + error instanceof Error ? error.message : String(error) + }`, + }); + } + + return partner.id; +} diff --git a/apps/web/lib/lemonsqueezy/importer.ts b/apps/web/lib/lemonsqueezy/importer.ts new file mode 100644 index 00000000000..fcb395b874e --- /dev/null +++ b/apps/web/lib/lemonsqueezy/importer.ts @@ -0,0 +1,50 @@ +import { qstash } from "@/lib/cron"; +import { redis } from "@/lib/upstash"; +import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; +import { LemonSqueezyCredentials, LemonSqueezyImportPayload } from "./types"; + +// Lemon Squeezy rate limit is 300 requests per minute +export const LEMONSQUEEZY_MAX_BATCHES = 10; + +export const CACHE_EXPIRY = 60 * 60 * 24; +export const CACHE_KEY_PREFIX = "lemonsqueezy:import"; + +class LemonSqueezyImporter { + async setCredentials( + workspaceId: string, + credentials: LemonSqueezyCredentials, + ) { + await redis.set(`${CACHE_KEY_PREFIX}:${workspaceId}`, credentials, { + ex: CACHE_EXPIRY, + }); + } + + async getCredentials(workspaceId: string): Promise { + const credentials = await redis.get( + `${CACHE_KEY_PREFIX}:${workspaceId}`, + ); + + if (!credentials) { + throw new Error( + "Lemon Squeezy credentials not found. Please restart the import process.", + ); + } + + return credentials; + } + + async deleteCredentials(workspaceId: string) { + return await redis.del(`${CACHE_KEY_PREFIX}:${workspaceId}`); + } + + async queue(body: LemonSqueezyImportPayload, options?: { delay?: number }) { + return await qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/import/lemonsqueezy`, + body, + contentBasedDeduplication: true, + ...(options?.delay != null && { delay: options.delay }), + }); + } +} + +export const lemonSqueezyImporter = new LemonSqueezyImporter(); diff --git a/apps/web/lib/lemonsqueezy/schemas.ts b/apps/web/lib/lemonsqueezy/schemas.ts new file mode 100644 index 00000000000..422662eb646 --- /dev/null +++ b/apps/web/lib/lemonsqueezy/schemas.ts @@ -0,0 +1,173 @@ +import * as z from "zod/v4"; + +export const lemonSqueezyImportSteps = z.enum([ + "import-partners", + "import-customers", + "import-commissions", +]); + +export const lemonSqueezyImportPayloadSchema = z.object({ + importId: z.string(), + userId: z.string(), + programId: z.string(), + storeId: z.string(), + action: lemonSqueezyImportSteps, + page: z.number().optional(), + // Used by import-commissions to paginate orders first, then subscription invoices + resource: z.enum(["orders", "subscription-invoices"]).optional(), +}); + +export const lemonSqueezyJsonApiResourceSchema = z.object({ + type: z.string(), + id: z.string(), + attributes: z.record(z.string(), z.unknown()), + relationships: z.record(z.string(), z.unknown()).optional(), +}); + +export const lemonSqueezyJsonApiListSchema = z.object({ + data: z.array(lemonSqueezyJsonApiResourceSchema), + included: z.array(lemonSqueezyJsonApiResourceSchema).optional(), + meta: z + .object({ + page: z + .object({ + currentPage: z.number(), + from: z.number().nullable().optional(), + lastPage: z.number(), + perPage: z.number(), + to: z.number().nullable().optional(), + total: z.number(), + }) + .optional(), + }) + .optional(), + links: z + .object({ + first: z.string().optional(), + last: z.string().optional(), + next: z.string().nullable().optional(), + prev: z.string().nullable().optional(), + }) + .optional(), +}); + +// GET list endpoints — JSON:API pagination / filter / include query params +export const lemonSqueezyListResourcesInputSchema = z.object({ + "page[number]": z.number(), + "page[size]": z.number(), + "filter[store_id]": z.string().optional(), + include: z.string().optional(), +}); + +export const lemonSqueezyStoreSchema = z.object({ + id: z.string(), + name: z.string(), + slug: z.string(), + domain: z.string(), + url: z.string(), + currency: z.string().nullish(), + total_sales: z.number().nullish(), + total_revenue: z.number().nullish(), + created_at: z.string().nullish(), + updated_at: z.string().nullish(), +}); + +export const lemonSqueezyAffiliateSchema = z.object({ + id: z.string(), + store_id: z.number(), + user_id: z.number().nullish(), + user_name: z.string().nullish(), + user_email: z.string().nullish(), + share_domain: z.string().nullish(), + status: z.string(), + products: z.unknown().nullish(), + application_note: z.string().nullish(), + total_earnings: z.number().nullish(), + unpaid_earnings: z.number().nullish(), + created_at: z.string().nullish(), + updated_at: z.string().nullish(), + // Optional if Lemon Squeezy exposes the affiliate link token + token: z.string().nullish(), +}); + +export const lemonSqueezyCustomerSchema = z.object({ + id: z.string(), + store_id: z.number(), + name: z.string().nullish(), + email: z.string().nullish(), + status: z.string().nullish(), + city: z.string().nullish(), + region: z.string().nullish(), + country: z.string().nullish(), + total_revenue_currency: z.number().nullish(), + mrr: z.number().nullish(), + created_at: z.string().nullish(), + updated_at: z.string().nullish(), + test_mode: z.boolean().nullish(), + affiliate_ids: z.array(z.string()).default([]), +}); + +export const lemonSqueezyOrderSchema = z.object({ + id: z.string(), + store_id: z.number(), + customer_id: z.number().nullish(), + affiliate_id: z.number().nullish(), + identifier: z.string().nullish(), + order_number: z.number().nullish(), + user_name: z.string().nullish(), + user_email: z.string().nullish(), + currency: z.string(), + currency_rate: z.union([z.string(), z.number()]).nullish(), + subtotal: z.number(), + discount_total: z.number().nullish(), + tax: z.number().nullish(), + total: z.number().nullish(), + subtotal_usd: z.number().nullish(), + discount_total_usd: z.number().nullish(), + tax_usd: z.number().nullish(), + total_usd: z.number().nullish(), + refunded_amount: z.number().nullish(), + refunded_amount_usd: z.number().nullish(), + referral_amount: z.number().nullish(), + first_order_item: z + .object({ + price: z.number().nullish(), + }) + .nullish(), + status: z.string(), + refunded: z.boolean().nullish(), + refunded_at: z.string().nullish(), + created_at: z.string().nullish(), + updated_at: z.string().nullish(), + test_mode: z.boolean().nullish(), +}); + +export const lemonSqueezySubscriptionInvoiceSchema = z.object({ + id: z.string(), + store_id: z.number(), + subscription_id: z.number().nullish(), + customer_id: z.number().nullish(), + affiliate_id: z.number().nullish(), + user_name: z.string().nullish(), + user_email: z.string().nullish(), + billing_reason: z.string().nullish(), + currency: z.string(), + currency_rate: z.union([z.string(), z.number()]).nullish(), + status: z.string(), + refunded: z.boolean().nullish(), + refunded_at: z.string().nullish(), + subtotal: z.number(), + discount_total: z.number().nullish(), + tax: z.number().nullish(), + total: z.number().nullish(), + refunded_amount: z.number().nullish(), + subtotal_usd: z.number().nullish(), + discount_total_usd: z.number().nullish(), + tax_usd: z.number().nullish(), + total_usd: z.number().nullish(), + refunded_amount_usd: z.number().nullish(), + referral_amount: z.number().nullish(), + created_at: z.string().nullish(), + updated_at: z.string().nullish(), + test_mode: z.boolean().nullish(), +}); diff --git a/apps/web/lib/lemonsqueezy/types.ts b/apps/web/lib/lemonsqueezy/types.ts new file mode 100644 index 00000000000..712c261ba8d --- /dev/null +++ b/apps/web/lib/lemonsqueezy/types.ts @@ -0,0 +1,34 @@ +import * as z from "zod/v4"; +import { + lemonSqueezyAffiliateSchema, + lemonSqueezyCustomerSchema, + lemonSqueezyImportPayloadSchema, + lemonSqueezyJsonApiResourceSchema, + lemonSqueezyOrderSchema, + lemonSqueezyStoreSchema, + lemonSqueezySubscriptionInvoiceSchema, +} from "./schemas"; + +export interface LemonSqueezyCredentials { + apiKey: string; +} + +export type LemonSqueezyImportPayload = z.infer< + typeof lemonSqueezyImportPayloadSchema +>; + +export type LemonSqueezyJsonApiResource = z.infer< + typeof lemonSqueezyJsonApiResourceSchema +>; + +export type LemonSqueezyStore = z.infer; + +export type LemonSqueezyAffiliate = z.infer; + +export type LemonSqueezyCustomer = z.infer; + +export type LemonSqueezyOrder = z.infer; + +export type LemonSqueezySubscriptionInvoice = z.infer< + typeof lemonSqueezySubscriptionInvoiceSchema +>; diff --git a/apps/web/lib/marketplace/parse-public-marketplace-query.ts b/apps/web/lib/marketplace/parse-public-marketplace-query.ts new file mode 100644 index 00000000000..334993e4f2e --- /dev/null +++ b/apps/web/lib/marketplace/parse-public-marketplace-query.ts @@ -0,0 +1,34 @@ +import { getPublicNetworkProgramsQuerySchema } from "@/lib/zod/schemas/program-network"; +import { Category } from "@prisma/client"; + +export const EXTERNAL_MARKETPLACE_PAGE_SIZE = 24; + +function pickString(value: string | string[] | undefined) { + return typeof value === "string" ? value : undefined; +} + +export function parsePublicMarketplaceQuery( + searchParams: Record = {}, + fixedCategory?: Category, +) { + const input = { + category: fixedCategory ?? pickString(searchParams.category), + rewardType: pickString(searchParams.rewardType), + search: pickString(searchParams.search), + sortBy: pickString(searchParams.sortBy), + sortOrder: pickString(searchParams.sortOrder), + page: pickString(searchParams.page), + pageSize: EXTERNAL_MARKETPLACE_PAGE_SIZE, + }; + + const parsed = getPublicNetworkProgramsQuerySchema.safeParse(input); + + if (parsed.success) { + return parsed.data; + } + + return getPublicNetworkProgramsQuerySchema.parse({ + pageSize: EXTERNAL_MARKETPLACE_PAGE_SIZE, + ...(fixedCategory ? { category: fixedCategory } : {}), + }); +} diff --git a/apps/web/lib/middleware/app.ts b/apps/web/lib/middleware/app.ts index 27ec8567075..634e0c0b85d 100644 --- a/apps/web/lib/middleware/app.ts +++ b/apps/web/lib/middleware/app.ts @@ -13,6 +13,16 @@ import { isTopLevelSettingsRedirect } from "./utils/is-top-level-settings-redire import { parse } from "./utils/parse"; import { WorkspacesMiddleware } from "./workspaces"; +const isPublicPath = (path: string) => + ["/marketplace"].some((p) => path === p) || + [ + "/marketplace/", + "/share/", + "/deeplink/", + "/unsubscribe/", + "/auth/reset-password/", + ].some((p) => path.startsWith(p)); + export async function AppMiddleware(req: NextRequest) { const { path, fullPath, searchParamsString } = parse(req); @@ -29,10 +39,7 @@ export async function AppMiddleware(req: NextRequest) { path !== "/forgot-password" && path !== "/register" && path !== "/auth/saml" && - !path.startsWith("/auth/reset-password/") && - !path.startsWith("/share/") && - !path.startsWith("/deeplink/") && - !path.startsWith("/unsubscribe/") + !isPublicPath(path) ) { return NextResponse.redirect( new URL( @@ -42,7 +49,7 @@ export async function AppMiddleware(req: NextRequest) { ); // if there's a user - } else if (user) { + } else if (user && !isPublicPath(path)) { // /new is a special path that creates a new link (or workspace if the user doesn't have one yet) if (path === "/new") { return NewLinkMiddleware(req, user); diff --git a/apps/web/lib/middleware/link.ts b/apps/web/lib/middleware/link.ts index 5ebc4ec699d..35b00563e70 100644 --- a/apps/web/lib/middleware/link.ts +++ b/apps/web/lib/middleware/link.ts @@ -1,12 +1,15 @@ -import { recordClick } from "@/lib/tinybird"; +import { recordClick as recordClickJob } from "@/lib/tinybird"; import { formatRedisLink } from "@/lib/upstash"; import { APP_DOMAIN, DUB_HEADERS, LEGAL_WORKSPACE_ID, LOCALHOST_GEO_DATA, + REDIRECTION_QUERY_PARAM, isDubDomain, + isGoogleClickTrackerDomain, isUnsupportedKey, + isValidUrl, nanoid, punyEncode, } from "@dub/utils"; @@ -38,7 +41,7 @@ import { parse } from "./utils/parse"; import { resolveABTestURL } from "./utils/resolve-ab-test-url"; export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) { - let { domain, fullKey: originalKey, fullPath } = parse(req); + let { domain, fullKey: originalKey, fullPath, searchParamsObj } = parse(req); if (!domain) { return NextResponse.next(); @@ -83,9 +86,17 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) { }); } - let cachedLink = await linkCache.get({ domain, key }); + let { cachedLink, redisFailOver } = await linkCache.get({ domain, key }); let isPartnerLink = Boolean(cachedLink?.programId && cachedLink?.partnerId); + // skip click tracking during Redis failover to avoid timeouts + const recordClick = redisFailOver + ? async () => { + console.log("Redis failover detected, skipping click tracking..."); + return; + } + : recordClickJob; + if (!cachedLink) { let linkData = await getLinkViaEdge({ domain, @@ -174,6 +185,37 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) { // everything else is not indexed by default, unless the user has explicitly set it to be indexed const shouldIndex = isDubDomain(domain) || doIndex === true; + const dubIdCookieName = `dub_id_${domain}_${key}`; + + let clickId: string | undefined; + // only lookup/mint a new clickId if not in Redis failover mode + if (!redisFailOver) { + const cookieStore = await cookies(); + clickId = cookieStore.get(dubIdCookieName)?.value; + if (!clickId) { + // if we need to cache the clickId, check if clickId is cached in Redis + if (shouldCacheClickId) { + const identityHash = await getIdentityHash(req); + clickId = + (await recordClickCache + .get({ domain, key, identityHash }) + .catch(() => undefined)) || undefined; + } + + // if there's still no clickId, generate a new one + if (!clickId) { + clickId = nanoid(16); + } + } + } + + const cookieData = { + path: `/${encodeURI(originalKey)}`, + dubIdCookieName, + dubIdCookieValue: clickId, + dubTestUrlValue: testUrl, + }; + // only show inspect modal if the link is not password protected if (inspectMode && !password) { return NextResponse.rewrite( @@ -254,33 +296,45 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) { } } - const dubIdCookieName = `dub_id_${domain}_${key}`; + // for Google click tracker domains (dub.sh, dub.link): + // if there is a redirection url set, then use it instead of the target + if (isGoogleClickTrackerDomain(domain)) { + const redirectionUrl = searchParamsObj[REDIRECTION_QUERY_PARAM]; - const cookieStore = await cookies(); - let clickId = cookieStore.get(dubIdCookieName)?.value; - if (!clickId) { - // if we need to pass the clickId, check if clickId is cached in Redis - if (shouldCacheClickId) { - const identityHash = await getIdentityHash(req); - clickId = - (await recordClickCache - .get({ domain, key, identityHash }) - .catch(() => undefined)) || undefined; - } + // if a valid redirection url is present, return it immediately + if ( + redirectionUrl && + isValidUrl(redirectionUrl) && + redirectionUrl.startsWith("https://") + ) { + ev.waitUntil( + recordClick({ + req, + clickId, + workspaceId, + linkId, + domain, + key, + url: redirectionUrl, + programId: cachedLink.programId, + partnerId: cachedLink.partnerId, + shouldCacheClickId, + }), + ); - // if there's still no clickId, generate a new one - if (!clickId) { - clickId = nanoid(16); + return createResponseWithCookies( + NextResponse.redirect(redirectionUrl, { + headers: { + ...DUB_HEADERS, + ...(!shouldIndex && { "X-Robots-Tag": "googlebot: noindex" }), + }, + status: key === "_root" ? 301 : 302, + }), + cookieData, + ); } } - const cookieData = { - path: `/${encodeURI(originalKey)}`, - dubIdCookieName, - dubIdCookieValue: clickId, - dubTestUrlValue: testUrl, - }; - // for root domain links, if there's no destination URL, rewrite to placeholder page if (!url) { ev.waitUntil( @@ -434,18 +488,20 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) { isIosAppStoreUrl(ios) && !req.nextUrl.searchParams.get("skip_deeplink_preview") ) { - ev.waitUntil( - cacheDeepLinkClickData({ - req, - clickId, - link: { - id: linkId, - domain, - key, - url, // pass the main destination URL to the cache (for deferred deep linking) - }, - }), - ); + if (clickId) { + ev.waitUntil( + cacheDeepLinkClickData({ + req, + clickId, + link: { + id: linkId, + domain, + key, + url, // pass the main destination URL to the cache (for deferred deep linking) + }, + }), + ); + } // redirect to the deeplink interstitial splash page "DeepLinkPreviewPage" // we're doing this because the interstitial page needs to be on a different domain than the actual deep link domain @@ -502,18 +558,20 @@ export async function LinkMiddleware(req: NextRequest, ev: NextFetchEvent) { isGooglePlayStoreUrl(android) && !req.nextUrl.searchParams.get("skip_deeplink_preview") ) { - ev.waitUntil( - cacheDeepLinkClickData({ - req, - clickId, - link: { - id: linkId, - domain, - key, - url, // pass the main destination URL to the cache (for deferred deep linking) - }, - }), - ); + if (clickId) { + ev.waitUntil( + cacheDeepLinkClickData({ + req, + clickId, + link: { + id: linkId, + domain, + key, + url, // pass the main destination URL to the cache (for deferred deep linking) + }, + }), + ); + } // redirect to the deeplink interstitial splash page "DeepLinkPreviewPage" return createResponseWithCookies( diff --git a/apps/web/lib/middleware/utils/app-redirect.ts b/apps/web/lib/middleware/utils/app-redirect.ts index 234d8e3248c..e3cc1f0376f 100644 --- a/apps/web/lib/middleware/utils/app-redirect.ts +++ b/apps/web/lib/middleware/utils/app-redirect.ts @@ -7,7 +7,6 @@ const APP_REDIRECTS = { "/welcome": "/onboarding/welcome", "/campaigns": "/program/campaigns", "/messages": "/program/messages", - "/marketplace": "/program/network", "/fraud": "/program/risks", "/risks": "/program/risks", }; diff --git a/apps/web/lib/middleware/utils/create-response-with-cookies.ts b/apps/web/lib/middleware/utils/create-response-with-cookies.ts index 52133884ffe..a957dfa79b6 100644 --- a/apps/web/lib/middleware/utils/create-response-with-cookies.ts +++ b/apps/web/lib/middleware/utils/create-response-with-cookies.ts @@ -10,16 +10,18 @@ export function createResponseWithCookies( }: { path: string; dubIdCookieName: string; - dubIdCookieValue: string; + dubIdCookieValue?: string; dubTestUrlValue?: string | null; }, ): NextResponse { // set dub_id__ cookie // this caches dub_id for 1 hour (for deduplication) - response.cookies.set(dubIdCookieName, dubIdCookieValue, { - path, - maxAge: 60 * 60, // 1 hour - }); + if (dubIdCookieValue) { + response.cookies.set(dubIdCookieName, dubIdCookieValue, { + path, + maxAge: 60 * 60, // 1 hour + }); + } // set dub_test_url if this link has testVariants // caches for 1 week (for consistent user experience) diff --git a/apps/web/lib/middleware/utils/get-final-url.ts b/apps/web/lib/middleware/utils/get-final-url.ts index dffec203ae3..0d449507687 100644 --- a/apps/web/lib/middleware/utils/get-final-url.ts +++ b/apps/web/lib/middleware/utils/get-final-url.ts @@ -2,7 +2,6 @@ import { LOCALHOST_IP, REDIRECTION_QUERY_PARAM, } from "@dub/utils/src/constants"; -import { getUrlFromStringIfValid } from "@dub/utils/src/functions"; import { ipAddress } from "@vercel/functions"; import { NextRequest, userAgent } from "next/server"; import { isAppsFlyerTrackingUrl } from "./is-appsflyer-tracking-url"; @@ -25,13 +24,7 @@ export const getFinalUrl = ( // query is the query string (e.g. d.to/github?utm_source=twitter -> ?utm_source=twitter) const searchParams = req.nextUrl.searchParams; - // if there is a redirection url set, then use it instead of the target url - const redirectionUrl = getUrlFromStringIfValid( - searchParams.get(REDIRECTION_QUERY_PARAM) ?? "", - ); - - // get the query params of the target url - const urlObj = redirectionUrl ? new URL(redirectionUrl) : new URL(url); + const urlObj = new URL(url); if (via) { urlObj.searchParams.set("via", via); @@ -58,9 +51,6 @@ export const getFinalUrl = ( // for AppsFlyer tracking links if (isAppsFlyerTrackingUrl(url)) { - const { ua } = userAgent(req); - const ip = process.env.VERCEL === "1" ? ipAddress(req) : LOCALHOST_IP; - // set hardcoded query params urlObj.searchParams.set("pid", "dubinc_int"); diff --git a/apps/web/lib/middleware/utils/parse.ts b/apps/web/lib/middleware/utils/parse.ts index 1156f2c711f..f5a50503a64 100644 --- a/apps/web/lib/middleware/utils/parse.ts +++ b/apps/web/lib/middleware/utils/parse.ts @@ -1,4 +1,4 @@ -import { SHORT_DOMAIN } from "@dub/utils"; +import { isAppHostname, SHORT_DOMAIN } from "@dub/utils"; import { NextRequest } from "next/server"; export const parse = (req: NextRequest) => { @@ -8,7 +8,16 @@ export const parse = (req: NextRequest) => { // remove www. from domain and convert to lowercase domain = domain.replace(/^www./, "").toLowerCase(); - if (domain === "dub.localhost:8888" || domain.endsWith(".vercel.app")) { + + const isE2ERedirectTestRequest = + // local development + domain === "dub.localhost:8888" || + // preview environment + (process.env.VERCEL_ENV === "preview" && + isAppHostname(domain) && + req.headers.get("x-e2e-redirect-test") === "true"); + + if (isE2ERedirectTestRequest) { if (path.toLowerCase() === "/case-sensitive-test") { // special case for case-sensitive link test domain = "dub-internal-test.com"; diff --git a/apps/web/lib/middleware/utils/partners-redirect.ts b/apps/web/lib/middleware/utils/partners-redirect.ts index 4b79a5d3ad4..5f4e78d32ce 100644 --- a/apps/web/lib/middleware/utils/partners-redirect.ts +++ b/apps/web/lib/middleware/utils/partners-redirect.ts @@ -47,6 +47,7 @@ const PARTNERS_PROGRAM_REDIRECTS = { "voice-os": "voiceos", "speechify-inc": "speechifyai", "ggms-labs-ltd-ai": "mira", + missioncontrolhq: "squad-so", }; export const partnersProgramRedirects = (path: string) => { diff --git a/apps/web/lib/network/get-network-approval-requirements.ts b/apps/web/lib/network/get-network-approval-requirements.ts index 80011065204..db8d4d2e27a 100644 --- a/apps/web/lib/network/get-network-approval-requirements.ts +++ b/apps/web/lib/network/get-network-approval-requirements.ts @@ -1,6 +1,6 @@ import { toCentsNumber } from "@dub/utils"; import { - EXCLUDED_PROGRAM_IDS, + PARTNER_NETWORK_EXCLUDED_PROGRAM_IDS, PARTNER_NETWORK_MIN_COMMISSIONS_CENTS, } from "../constants/partner-profile"; import { PARTNER_PLATFORM_FIELDS } from "../partners/partner-platforms"; @@ -18,7 +18,7 @@ export const partnerHasEarnedCommissions = ( return ( programEnrollments.filter( (pe) => - !EXCLUDED_PROGRAM_IDS.includes(pe.programId) && + !PARTNER_NETWORK_EXCLUDED_PROGRAM_IDS.includes(pe.programId) && pe.status === "approved" && toCentsNumber(pe.totalCommissions) >= PARTNER_NETWORK_MIN_COMMISSIONS_CENTS, diff --git a/apps/web/lib/network/program-categories.ts b/apps/web/lib/network/program-categories.ts index 9640a15245a..564c9aa86ff 100644 --- a/apps/web/lib/network/program-categories.ts +++ b/apps/web/lib/network/program-categories.ts @@ -9,6 +9,7 @@ import { Icon, MarketingTarget, MoneyBill, + ShieldKeyhole, Sparkle3, User, } from "@dub/ui/icons"; @@ -25,84 +26,84 @@ export const PROGRAM_CATEGORIES: { label: "AI", icon: Sparkle3, listPageDescription: - "Browse partner programs for AI tools and machine learning platforms.", + "Browse the best SaaS affiliate programs for AI tools, agents, and chatbots.", }, { id: Category.Development, - label: "Development", + label: "DevTools", icon: Code, listPageDescription: - "Browse partner programs for developer tools and software infrastructure.", + "Browse the best SaaS affiliate programs for developer tools, APIs, and no-code platforms.", }, { id: Category.Design, label: "Design", icon: Brush, listPageDescription: - "Browse partner programs for design tools and creative software.", + "Browse the best SaaS affiliate programs for graphic design tools and creative software.", }, { id: Category.Productivity, label: "Productivity", icon: CircleHalfDottedClock, listPageDescription: - "Browse partner programs for productivity software and modern work tools.", + "Browse the best SaaS affiliate programs for productivity apps, email tools, and collaboration software.", }, { id: Category.Finance, - label: "Finance", + label: "FinTech", icon: MoneyBill, listPageDescription: - "Browse partner programs for finance software and fintech platforms.", + "Browse the best SaaS affiliate programs for trading, crypto, and finance apps.", }, { id: Category.Marketing, label: "Marketing", icon: MarketingTarget, listPageDescription: - "Browse partner programs for marketing software and growth tools.", + "Browse the best SaaS affiliate programs for marketing software, email marketing, and SEO tools.", }, { id: Category.Ecommerce, label: "Ecommerce", icon: CreditCard, listPageDescription: - "Browse partner programs for ecommerce platforms and online retail tools.", + "Browse the best SaaS affiliate programs for online stores, newsletters, and ecommerce platforms.", + }, + { + id: Category.Security, + label: "Security", + icon: ShieldKeyhole, + listPageDescription: + "Browse the best SaaS affiliate programs for cybersecurity software and privacy tools.", }, - // { - // id: Category.Security, - // label: "Security", - // icon: ShieldKeyhole, - // listPageDescription: - // "Browse partner programs for security software and privacy tools.", - // }, { id: Category.Education, label: "Education", icon: BookOpen, listPageDescription: - "Browse partner programs for education platforms and learning tools.", + "Browse the best SaaS affiliate programs for edtech, learning tools, and education software.", }, { id: Category.Health, - label: "Health", + label: "Healthcare", icon: Heart, listPageDescription: - "Browse partner programs for health software and wellness tools.", + "Browse the best SaaS affiliate programs for healthtech, wellness apps, and health software.", }, { id: Category.Consumer, label: "Consumer", icon: User, listPageDescription: - "Browse partner programs for consumer apps and lifestyle products.", + "Browse the best SaaS affiliate programs for consumer apps and lifestyle software.", }, { id: Category.Support, label: "Support", icon: Headset, listPageDescription: - "Browse partner programs for customer support and help desk tools.", + "Browse the best SaaS affiliate programs for customer support software and agentic tools.", }, ]; diff --git a/apps/web/lib/openapi/analytics/index.ts b/apps/web/lib/openapi/analytics/index.ts index d2ef6887a4f..21c36cefd3d 100644 --- a/apps/web/lib/openapi/analytics/index.ts +++ b/apps/web/lib/openapi/analytics/index.ts @@ -68,6 +68,11 @@ const retrieveAnalytics: ZodOpenApiOperationObject = { id: "AnalyticsTriggers", }), ), + z.array( + analyticsResponse.event_names.meta({ + id: "AnalyticsEventNames", + }), + ), z.array( analyticsResponse.referers.meta({ id: "AnalyticsReferers", diff --git a/apps/web/lib/openapi/commissions/create-commission.ts b/apps/web/lib/openapi/commissions/create-commission.ts index 9c6c9edf0cd..2a8744603a5 100644 --- a/apps/web/lib/openapi/commissions/create-commission.ts +++ b/apps/web/lib/openapi/commissions/create-commission.ts @@ -10,7 +10,7 @@ export const createCommission: ZodOpenApiOperationObject = { "x-speakeasy-name-override": "create", summary: "Create commission", description: - "Create one or more commissions (custom, lead or sale) for a partner. Commission creation is processed asynchronously. Use the List Commissions endpoint or webhooks to be notified when the commission is created.", + "Create one or more commissions (custom, lead or sale) for a partner. Custom commissions accept a negative `amount` to create a clawback. Commission creation is processed asynchronously – use the GET /commissions endpoint or webhooks to be notified when the commission is created.", requestBody: { content: { "application/json": { diff --git a/apps/web/lib/openapi/discount-codes/create-discount-code.ts b/apps/web/lib/openapi/discount-codes/create-discount-code.ts new file mode 100644 index 00000000000..2de1f2ebf91 --- /dev/null +++ b/apps/web/lib/openapi/discount-codes/create-discount-code.ts @@ -0,0 +1,34 @@ +import { openApiErrorResponses } from "@/lib/openapi/responses"; +import { + createDiscountCodeSchema, + DiscountCodeSchema, +} from "@/lib/zod/schemas/discount"; +import { ZodOpenApiOperationObject } from "zod-openapi"; + +export const createDiscountCode: ZodOpenApiOperationObject = { + operationId: "createDiscountCode", + "x-speakeasy-name-override": "create", + summary: "Create a discount code", + description: + "Create a discount code for a partner. The partner's group must already have a discount assigned to it, and the discount code must be associated with a link that is not already linked with another discount code.", + requestBody: { + content: { + "application/json": { + schema: createDiscountCodeSchema, + }, + }, + }, + responses: { + "200": { + description: "The created discount code.", + content: { + "application/json": { + schema: DiscountCodeSchema, + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["Discount Codes"], + security: [{ token: [] }], +}; diff --git a/apps/web/lib/openapi/discount-codes/delete-discount-code.ts b/apps/web/lib/openapi/discount-codes/delete-discount-code.ts new file mode 100644 index 00000000000..06d8d21887a --- /dev/null +++ b/apps/web/lib/openapi/discount-codes/delete-discount-code.ts @@ -0,0 +1,33 @@ +import { openApiErrorResponses } from "@/lib/openapi/responses"; +import { DiscountCodeSchema } from "@/lib/zod/schemas/discount"; +import { ZodOpenApiOperationObject } from "zod-openapi"; +import * as z from "zod/v4"; + +export const deleteDiscountCode: ZodOpenApiOperationObject = { + operationId: "deleteDiscountCode", + "x-speakeasy-name-override": "delete", + "x-speakeasy-max-method-params": 1, + summary: "Delete a discount code", + description: + "Delete a discount code for a partner by its unique ID or alphanumeric code. This will also disable the code in your connected discount provider (Stripe, Shopify, or custom via `disccount.deleted` webhook).", + requestParams: { + path: z.object({ + idOrCode: DiscountCodeSchema.shape.id.describe( + "The unique ID (e.g. `dcode_...`) or alphanumeric code (e.g. `ABC123`) of the discount code to delete.", + ), + }), + }, + responses: { + "200": { + description: "The deleted discount code unique ID (e.g. `dcode_...`).", + content: { + "application/json": { + schema: DiscountCodeSchema.pick({ id: true }), + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["Discount Codes"], + security: [{ token: [] }], +}; diff --git a/apps/web/lib/openapi/discount-codes/index.ts b/apps/web/lib/openapi/discount-codes/index.ts new file mode 100644 index 00000000000..f5ec228406e --- /dev/null +++ b/apps/web/lib/openapi/discount-codes/index.ts @@ -0,0 +1,14 @@ +import { ZodOpenApiPathsObject } from "zod-openapi"; +import { createDiscountCode } from "./create-discount-code"; +import { deleteDiscountCode } from "./delete-discount-code"; +import { listDiscountCodes } from "./list-discount-codes"; + +export const discountCodesPaths: ZodOpenApiPathsObject = { + "/discount-codes": { + get: listDiscountCodes, + post: createDiscountCode, + }, + "/discount-codes/{idOrCode}": { + delete: deleteDiscountCode, + }, +}; diff --git a/apps/web/lib/openapi/discount-codes/list-discount-codes.ts b/apps/web/lib/openapi/discount-codes/list-discount-codes.ts new file mode 100644 index 00000000000..d35965253f4 --- /dev/null +++ b/apps/web/lib/openapi/discount-codes/list-discount-codes.ts @@ -0,0 +1,31 @@ +import { openApiErrorResponses } from "@/lib/openapi/responses"; +import { + DiscountCodeSchema, + getDiscountCodesQuerySchema, +} from "@/lib/zod/schemas/discount"; +import { ZodOpenApiOperationObject } from "zod-openapi"; +import * as z from "zod/v4"; + +export const listDiscountCodes: ZodOpenApiOperationObject = { + operationId: "listDiscountCodes", + "x-speakeasy-name-override": "list", + summary: "List discount codes", + description: + "Retrieve a paginated list of discount codes for a partner / a given discount / the whole program.", + requestParams: { + query: getDiscountCodesQuerySchema, + }, + responses: { + "200": { + description: "The list of discount codes.", + content: { + "application/json": { + schema: z.array(DiscountCodeSchema), + }, + }, + }, + ...openApiErrorResponses, + }, + tags: ["Discount Codes"], + security: [{ token: [] }], +}; diff --git a/apps/web/lib/openapi/index.ts b/apps/web/lib/openapi/index.ts index 9669bda97cb..0044fc30b02 100644 --- a/apps/web/lib/openapi/index.ts +++ b/apps/web/lib/openapi/index.ts @@ -1,5 +1,6 @@ import { createDocument } from "zod-openapi"; import { webhookEventSchema } from "../webhook/schemas"; +import { DiscountCodeSchema } from "../zod/schemas/discount"; import { DomainSchema } from "../zod/schemas/domains"; import { FolderSchema } from "../zod/schemas/folders"; import { LinkErrorSchema, LinkSchema } from "../zod/schemas/links"; @@ -8,6 +9,7 @@ import { analyticsPath } from "./analytics"; import { bountiesPaths } from "./bounties"; import { commissionsPaths } from "./commissions"; import { customersPaths } from "./customers"; +import { discountCodesPaths } from "./discount-codes"; import { domainsPaths } from "./domains"; import { embedTokensPaths } from "./embed-tokens"; import { eventsPath } from "./events"; @@ -53,6 +55,7 @@ export const document = createDocument({ ...trackPaths, ...customersPaths, ...partnersPaths, + ...discountCodesPaths, ...commissionsPaths, ...payoutsPaths, ...embedTokensPaths, @@ -65,6 +68,7 @@ export const document = createDocument({ LinkTagSchema, FolderSchema, DomainSchema, + DiscountCodeSchema, webhookEventSchema, LinkErrorSchema, }, diff --git a/apps/web/lib/partner-referrals/components/attribute-referring-partner-modal.tsx b/apps/web/lib/partner-referrals/components/attribute-referring-partner-modal.tsx index 6d4354506cc..07733c34f2a 100644 --- a/apps/web/lib/partner-referrals/components/attribute-referring-partner-modal.tsx +++ b/apps/web/lib/partner-referrals/components/attribute-referring-partner-modal.tsx @@ -197,7 +197,7 @@ export function useAttributeReferringPartnerModal({ partner={partner} /> ); - }, [showModal, setShowModal, partner]); + }, [showModal, setShowModal]); return useMemo( () => ({ diff --git a/apps/web/lib/partner-referrals/create-network-referral-commission.ts b/apps/web/lib/partner-referrals/create-network-referral-commission.ts index 8eed88b52f1..94ef0d3d690 100644 --- a/apps/web/lib/partner-referrals/create-network-referral-commission.ts +++ b/apps/web/lib/partner-referrals/create-network-referral-commission.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/prisma"; import { ACME_PROGRAM_ID, + DEMO_PROGRAM_ID, currencyFormatter, log, nanoid, @@ -40,9 +41,13 @@ export const createNetworkReferralCommission = async ({ return null; } - if ([NETWORK_PROGRAM_ID, ACME_PROGRAM_ID].includes(payout.programId)) { + if ( + [NETWORK_PROGRAM_ID, ACME_PROGRAM_ID, DEMO_PROGRAM_ID].includes( + payout.programId, + ) + ) { console.error( - `Payout ${payout.id} is from Network or Acme program, skipping...`, + `Payout ${payout.id} is from Network, Acme, or Demo program, skipping...`, ); return null; } diff --git a/apps/web/lib/partners/complete-program-applications.ts b/apps/web/lib/partners/complete-program-applications.ts index 3cbd44efb3b..ad61cfa69ef 100644 --- a/apps/web/lib/partners/complete-program-applications.ts +++ b/apps/web/lib/partners/complete-program-applications.ts @@ -1,11 +1,12 @@ import { prisma } from "@/lib/prisma"; -import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { PlatformType, Prisma } from "@prisma/client"; import { createId } from "../api/create-id"; import { detectAndRecordFraudApplication } from "../api/fraud/detect-record-fraud-application"; import { notifyPartnerApplication } from "../api/partners/notify-partner-application"; +import { queuePartnerSearchSync } from "../api/partners/queue-partner-search-sync"; import { markApplicationEventSubmitted } from "../application-events/update-application-event"; -import { qstash } from "../cron"; +import { autoApprovePartnerJob } from "../jobs/handlers/auto-approve-partner-job"; +import { autoRejectPartnerJob } from "../jobs/handlers/auto-reject-partner-job"; import { buildSocialPlatformLookup } from "../social-utils"; import { sendWorkspaceWebhook } from "../webhook/publish"; import { partnerApplicationWebhookSchema } from "../zod/schemas/program-application"; @@ -89,19 +90,21 @@ export async function completeProgramApplications(userEmail: string) { const partner = user.partners[0].partner; - // Program enrollments to create - const programEnrollments: Prisma.ProgramEnrollmentCreateManyInput[] = - filteredProgramApplications.map((programApplication) => ({ - id: createId({ prefix: "pge_" }), - programId: programApplication.programId, - partnerId: user.partners[0].partnerId, - applicationId: programApplication.id, - groupId: programApplication?.partnerGroup?.id, - clickRewardId: programApplication?.partnerGroup?.clickRewardId, - leadRewardId: programApplication?.partnerGroup?.leadRewardId, - saleRewardId: programApplication?.partnerGroup?.saleRewardId, - discountId: programApplication?.partnerGroup?.discountId, - })); + // Program enrollments to create. `id` is narrowed to required because the + // search sync below reads it back, and Prisma leaves it optional here. + const programEnrollments: (Prisma.ProgramEnrollmentCreateManyInput & { + id: string; + })[] = filteredProgramApplications.map((programApplication) => ({ + id: createId({ prefix: "pge_" }), + programId: programApplication.programId, + partnerId: user.partners[0].partnerId, + applicationId: programApplication.id, + groupId: programApplication?.partnerGroup?.id, + clickRewardId: programApplication?.partnerGroup?.clickRewardId, + leadRewardId: programApplication?.partnerGroup?.leadRewardId, + saleRewardId: programApplication?.partnerGroup?.saleRewardId, + discountId: programApplication?.partnerGroup?.discountId, + })); const enrollmentsByApplicationId = new Map( programEnrollments.map((enrollment) => [ @@ -202,13 +205,15 @@ export async function completeProgramApplications(userEmail: string) { // Auto-approve the partner if the group has auto-approval enabled group?.autoApprovePartnersEnabledAt - ? qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partners/auto-approve`, - body: { + ? autoApprovePartnerJob.dispatch( + { programId: program.id, partnerId: partner.id, }, - }) + { + label: partner.id, + }, + ) : Promise.resolve(null), // Send "partner.application_submitted" webhook @@ -231,14 +236,16 @@ export async function completeProgramApplications(userEmail: string) { }), ] : [ - qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/partners/auto-reject`, - delay: 5 * 60, // 5 minutes - body: { + autoRejectPartnerJob.dispatch( + { programId: program.id, partnerId: partner.id, }, - }), + { + delay: 5 * 60, // 5 minutes + label: partner.id, + }, + ), ]), // if the application has any website or social fields but the partner doesn't have the corresponding one (maybe they forgot to add during onboarding) @@ -270,6 +277,11 @@ export async function completeProgramApplications(userEmail: string) { markApplicationEventSubmitted(programEnrollment), ), ); + + // Queue an index update because the applications completed into enrollments. + await queuePartnerSearchSync({ + enrollmentIds: programEnrollments.map(({ id }) => id), + }); } catch (error) { console.error("Failed to complete program applications", error); } diff --git a/apps/web/lib/partners/create-stablecoin-payout.ts b/apps/web/lib/partners/create-stablecoin-payout.ts index 73d6352881a..1199d67bcff 100644 --- a/apps/web/lib/partners/create-stablecoin-payout.ts +++ b/apps/web/lib/partners/create-stablecoin-payout.ts @@ -19,6 +19,7 @@ import { MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS, MIN_WITHDRAWAL_AMOUNT_CENTS, STABLECOIN_PAYOUT_FEE_RATE, + STABLECOIN_PAYOUT_FIXED_FEE_CENTS, } from "../constants/payouts"; import { enqueueBatchJobs } from "../cron/enqueue-batch-jobs"; import { createPayoutsIdempotencyKey } from "../payouts/create-payouts-idempotency-key"; @@ -26,6 +27,7 @@ import { markPayoutsAsProcessed } from "../payouts/mark-payouts-as-processed"; import { createStripeOutboundPayment } from "../stripe/create-stripe-outbound-payment"; import { fundFinancialAccount } from "../stripe/fund-financial-account"; import { getStripeRecipientAccount } from "../stripe/get-stripe-recipient-account"; +import { getStripeRecipientPayoutMethod } from "../stripe/get-stripe-recipient-payout-method"; interface CreateStablecoinPayoutParams { partnerId: string; @@ -226,6 +228,32 @@ export const createStablecoinPayout = async ({ } } + const stripePayoutMethod = await getStripeRecipientPayoutMethod( + partner.stripeRecipientId, + ); + + if (!stripePayoutMethod?.id) { + await prisma.partner.update({ + where: { + id: partner.id, + }, + data: { + payoutsEnabledAt: null, + }, + }); + + await markPayoutsAsProcessed(currentInvoicePayouts); + + const message = `Stripe recipient account for partner ${partner.email} does not have an active crypto wallet payout method.`; + + if (forceWithdrawal) { + throw new Error(message); + } else { + console.warn(message); + return; + } + } + const allPayoutsProgramNames = [ ...new Set(allPayouts.map((p) => p.program.name)), ]; @@ -238,13 +266,20 @@ export const createStablecoinPayout = async ({ if (amountToTransferToFA > 0) { await fundFinancialAccount({ - amount: amountToTransferToFA, + // if there are no current invoice payouts (meaning partner is running a forceWithdrawal for previously processed payouts) + // we need to add the STABLECOIN_PAYOUT_FIXED_FEE_CENTS to the amount to transfer to the FA (to cover the Stablecoin payout fee) + amount: + amountToTransferToFA + + (currentInvoicePayouts.length === 0 && forceWithdrawal + ? STABLECOIN_PAYOUT_FIXED_FEE_CENTS + : 0), idempotencyKey, }); } const outboundPayment = await createStripeOutboundPayment({ stripeRecipientId: partner.stripeRecipientId, + payoutMethodId: stripePayoutMethod.id, amount: totalTransferableAmount, description: `Dub Partners payout (${allPayoutsProgramNames.join(", ")})`, idempotencyKey, diff --git a/apps/web/lib/partners/determine-partner-reward.ts b/apps/web/lib/partners/determine-partner-reward.ts index 1bc18e417ec..bab73989594 100644 --- a/apps/web/lib/partners/determine-partner-reward.ts +++ b/apps/web/lib/partners/determine-partner-reward.ts @@ -150,11 +150,13 @@ export const determinePartnerRewards = ({ }); if (reward) { + // product.amount is the Stripe line total (unit × quantity). Flat + // rewards are per sale/line, so do not multiply by line.quantity. rewards.push({ reward, sale: { amount: product.amount, - quantity: product.quantity, + quantity: 1, }, }); } diff --git a/apps/web/lib/partners/dispatch-partner-utm-sync.ts b/apps/web/lib/partners/dispatch-partner-utm-sync.ts new file mode 100644 index 00000000000..a374e7fde32 --- /dev/null +++ b/apps/web/lib/partners/dispatch-partner-utm-sync.ts @@ -0,0 +1,45 @@ +import { syncGroupUtmJob } from "@/lib/jobs/handlers/sync-group-utm-job"; +import { prisma } from "@/lib/prisma"; + +// Partner UTM macros ({{PARTNER_NAME}}, {{PARTNER_LINK_KEY}}) are resolved into +// concrete Link.url / utm_* values at write time. +// When Partner.name changes, those stored values go stale unless we re-run syncGroupUtmJob. +export async function dispatchGroupUtmSyncForPartner(partnerId: string) { + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + partnerId, + groupId: { + not: null, + }, + }, + select: { + groupId: true, + }, + }); + + const groupIds = [ + ...new Set( + programEnrollments + .map((enrollment) => enrollment.groupId) + .filter((id): id is string => id != null), + ), + ]; + + if (groupIds.length === 0) { + return; + } + + await Promise.all( + groupIds.map((groupId) => + syncGroupUtmJob.dispatch( + { + groupId, + partnerIds: [partnerId], + }, + { + label: groupId, + }, + ), + ), + ); +} diff --git a/apps/web/lib/partners/macros.ts b/apps/web/lib/partners/macros.ts new file mode 100644 index 00000000000..aad7f0191db --- /dev/null +++ b/apps/web/lib/partners/macros.ts @@ -0,0 +1,54 @@ +export interface PartnerMacroContext { + partnerName: string; + partnerLinkKey: string; +} + +export const PARTNER_MACROS = [ + { + macro: "{{PARTNER_NAME}}", + description: "The partner's name (e.g. 'John Doe')", + }, + { + macro: "{{PARTNER_LINK_KEY}}", + description: "The partner's link key (e.g. 'john-doe')", + }, +] as const; + +export const PARTNER_MACRO_VALUES: readonly string[] = PARTNER_MACROS.map( + (m) => m.macro, +); + +const MACRO_TOKEN_RE = /\{\{[^}]+\}\}/g; + +// Every `{{...}}` substring must be a known partner macro. +export function isValidPartnerMacroTemplate(value: string): boolean { + const matches = value.match(MACRO_TOKEN_RE) ?? []; + return matches.every((token) => PARTNER_MACRO_VALUES.includes(token)); +} + +// Validates a free-form value (any `{{...}}` tokens must be known macros). +export function assertValidPartnerMacroValue(value: string): void { + if (!isValidPartnerMacroTemplate(value)) { + throw new Error( + `Invalid macro in value. Use only: ${PARTNER_MACRO_VALUES.join(", ")}`, + ); + } +} + +const macroReplacements: Record = { + "{{PARTNER_NAME}}": "partnerName", + "{{PARTNER_LINK_KEY}}": "partnerLinkKey", +}; + +export function resolvePartnerMacros( + value: string, + context: PartnerMacroContext, +): string { + let resolvedValue = value; + + for (const [macro, contextKey] of Object.entries(macroReplacements)) { + resolvedValue = resolvedValue.replaceAll(macro, context[contextKey] ?? ""); + } + + return resolvedValue; +} diff --git a/apps/web/lib/partners/queue-partner-commission-creation.ts b/apps/web/lib/partners/queue-partner-commission-creation.ts index bc0bf49f12a..51b7d89147c 100644 --- a/apps/web/lib/partners/queue-partner-commission-creation.ts +++ b/apps/web/lib/partners/queue-partner-commission-creation.ts @@ -1,5 +1,5 @@ import { getProgramEnrollmentOrThrow } from "../api/programs/get-program-enrollment-or-throw"; -import { triggerQStashWorkflow } from "../cron/qstash-workflow"; +import { dispatchWorkflows } from "../jobs/publish-workflows"; import { CreatePartnerCommissionProps } from "../types"; import { constructWebhookPartner } from "./constuct-webhook-partner"; @@ -19,13 +19,15 @@ export const queuePartnerCommissionCreation = async ( const { partner, links, ...programEnrollment } = result; - await triggerQStashWorkflow({ - workflowType: "create-partner-commission", - workflowLabel: bountySubmissionId ?? customerId ?? partnerId, - body: params, - flowControl: { - key: partnerId, - parallelism: 1, + await dispatchWorkflows({ + name: "create-partner-commission-workflow", + payload: params, + options: { + flowControl: { + key: partnerId, + parallelism: 1, + }, + label: bountySubmissionId ?? customerId ?? partnerId, }, }); diff --git a/apps/web/lib/partners/sync-partner-identity.ts b/apps/web/lib/partners/sync-partner-identity.ts index 4467f934407..db5830b1926 100644 --- a/apps/web/lib/partners/sync-partner-identity.ts +++ b/apps/web/lib/partners/sync-partner-identity.ts @@ -1,8 +1,11 @@ import { DubApiError } from "@/lib/api/errors"; +import { queuePartnerSearchSync } from "@/lib/api/partners/queue-partner-search-sync"; import { requestEmailChange } from "@/lib/auth/request-email-change"; +import { dispatchGroupUtmSyncForPartner } from "@/lib/partners/dispatch-partner-utm-sync"; import { prisma } from "@/lib/prisma"; import { storage } from "@/lib/storage"; import { nanoid } from "@dub/utils"; +import { waitUntil } from "@vercel/functions"; export async function assertEmailAvailableForIdentitySync({ newEmail, @@ -101,6 +104,17 @@ export async function syncNameAndImageToPartner({ ...(hasImageUpdate && { image: partnerImage ?? null }), }, }); + + // Queue an index update because the partner name changed. An image-only sync + // is skipped, since the image is not indexed. + if (hasNameUpdate && name) { + waitUntil( + Promise.all([ + dispatchGroupUtmSyncForPartner(partnerId), + queuePartnerSearchSync({ partnerIds: [partnerId] }), + ]), + ); + } } export async function syncNameAndImageToUser({ diff --git a/apps/web/lib/partnerstack/import-partners.ts b/apps/web/lib/partnerstack/import-partners.ts index a6f3eb65dee..7856db7c050 100644 --- a/apps/web/lib/partnerstack/import-partners.ts +++ b/apps/web/lib/partnerstack/import-partners.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/prisma"; import { COUNTRIES, COUNTRY_CODES } from "@dub/utils"; import { PartnerGroup, Program } from "@prisma/client"; import { createId } from "../api/create-id"; +import { queuePartnerSearchSync } from "../api/partners/queue-partner-search-sync"; import { logImportError } from "../tinybird/log-import-error"; import { redis } from "../upstash"; import { DEFAULT_PARTNER_GROUP } from "../zod/schemas/groups"; @@ -56,7 +57,7 @@ export async function importPartners(payload: PartnerStackImportPayload) { break; } - await Promise.allSettled( + const results = await Promise.allSettled( partners.map((partner) => createPartner({ program, @@ -67,6 +68,15 @@ export async function importPartners(payload: PartnerStackImportPayload) { ), ); + // Queue an index update because the imported partners were enrolled. Queued + // per page rather than per partner. + await queuePartnerSearchSync({ + partnerIds: results.flatMap((result) => + result.status === "fulfilled" && result.value ? [result.value] : [], + ), + programId, + }); + await new Promise((resolve) => setTimeout(resolve, 2000)); processedBatches++; @@ -196,7 +206,15 @@ async function createPartner({ // PS doesn't return the partner email address in the customers response // so we need to keep a map of partner_key (PS) -> partner_id (Dub) // and use it to identify the partner in the customers response - await redis.hset(`${PARTNER_IDS_KEY_PREFIX}:${program.id}`, { - [partner.key]: partnerId, - }); + try { + await redis.hset(`${PARTNER_IDS_KEY_PREFIX}:${program.id}`, { + [partner.key]: partnerId, + }); + } catch (error) { + // The enrollment is already committed, so its ID must still reach the + // page-level search sync even when the mapping write fails. + console.error("Failed to map imported partner key", error, partner.key); + } + + return partnerId; } diff --git a/apps/web/lib/rewardful/import-partners.ts b/apps/web/lib/rewardful/import-partners.ts index c982377c2a5..adaf6ccd3d1 100644 --- a/apps/web/lib/rewardful/import-partners.ts +++ b/apps/web/lib/rewardful/import-partners.ts @@ -3,6 +3,7 @@ import { nanoid } from "@dub/utils"; import { Program } from "@prisma/client"; import { createId } from "../api/create-id"; import { bulkCreateLinks } from "../api/links"; +import { queuePartnerSearchSync } from "../api/partners/queue-partner-search-sync"; import { logImportError } from "../tinybird/log-import-error"; import { redis } from "../upstash"; import { RewardfulApi } from "./api"; @@ -124,6 +125,13 @@ export async function importPartners(payload: RewardfulImportPayload) { ]), ), ); + + // Queue an index update because the imported partners were enrolled. + // Queued per page rather than per partner. + await queuePartnerSearchSync({ + partnerIds: filteredPartners.map((p) => p.dubPartnerId), + programId: program.id, + }); } } diff --git a/apps/web/lib/storage.ts b/apps/web/lib/storage.ts index dd53011bf33..6958d38b7d1 100644 --- a/apps/web/lib/storage.ts +++ b/apps/web/lib/storage.ts @@ -20,7 +20,7 @@ class StorageClient { accessKeyId: process.env.STORAGE_ACCESS_KEY_ID || "", secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY || "", service: "s3", - region: process.env.STORAGE_REGION || "eu-west-1", + region: "auto", }); } diff --git a/apps/web/lib/stripe/create-stripe-outbound-payment.ts b/apps/web/lib/stripe/create-stripe-outbound-payment.ts index 06a5baafb3d..c108dd01b71 100644 --- a/apps/web/lib/stripe/create-stripe-outbound-payment.ts +++ b/apps/web/lib/stripe/create-stripe-outbound-payment.ts @@ -1,7 +1,9 @@ +import { DubApiError } from "../api/errors"; import { STRIPE_API_VERSION, stripeV2Fetch } from "./stripe-v2-client"; export interface CreateStripeOutboundPaymentParams { stripeRecipientId: string; + payoutMethodId: string; amount: number; description: string; idempotencyKey: string; @@ -9,6 +11,7 @@ export interface CreateStripeOutboundPaymentParams { export async function createStripeOutboundPayment({ stripeRecipientId, + payoutMethodId, amount, description, idempotencyKey, @@ -35,6 +38,7 @@ export async function createStripeOutboundPayment({ to: { recipient: stripeRecipientId, currency: "usdc", + payout_method: payoutMethodId, }, amount: { value: amount, @@ -46,7 +50,12 @@ export async function createStripeOutboundPayment({ ); if (error) { - throw new Error(error.message); + throw new DubApiError({ + code: "bad_request", + message: + error.message ?? + `Failed to create Stripe outbound payment for recipient ${stripeRecipientId} and amount ${amount} (cents)`, + }); } return data; diff --git a/apps/web/lib/stripe/get-stripe-recipient-payout-method.ts b/apps/web/lib/stripe/get-stripe-recipient-payout-method.ts index cb3b3356271..9e7d99940a5 100644 --- a/apps/web/lib/stripe/get-stripe-recipient-payout-method.ts +++ b/apps/web/lib/stripe/get-stripe-recipient-payout-method.ts @@ -21,5 +21,9 @@ export async function getStripeRecipientPayoutMethod( throw new Error(error.message); } - return data.data?.find((m) => m.type === "crypto_wallet") ?? null; + return ( + data.data?.find( + (m) => m.type === "crypto_wallet" && !m.crypto_wallet?.archived, + ) ?? null + ); } diff --git a/apps/web/lib/swr/use-partner-cross-program-summary.ts b/apps/web/lib/swr/use-partner-cross-program-summary.ts deleted file mode 100644 index d49ccd2ed08..00000000000 --- a/apps/web/lib/swr/use-partner-cross-program-summary.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { partnerCrossProgramSummarySchema } from "@/lib/zod/schemas/partners"; -import { fetcher } from "@dub/utils"; -import useSWR from "swr"; -import * as z from "zod/v4"; -import useWorkspace from "./use-workspace"; - -type CrossProgramSummary = z.infer; - -export function usePartnerCrossProgramSummary({ - partnerId, - enabled = true, -}: { - partnerId: string | null | undefined; - enabled?: boolean; -}) { - const { id: workspaceId } = useWorkspace(); - - const { data, isLoading, error } = useSWR( - enabled && partnerId && workspaceId - ? `/api/partners/${partnerId}/cross-program-summary?workspaceId=${workspaceId}` - : null, - fetcher, - ); - - return { - crossProgramSummary: data, - isLoading, - error, - }; -} diff --git a/apps/web/lib/swr/use-partners-count.ts b/apps/web/lib/swr/use-partners-count.ts index 5d347baea52..9dbff9cc82b 100644 --- a/apps/web/lib/swr/use-partners-count.ts +++ b/apps/web/lib/swr/use-partners-count.ts @@ -17,21 +17,21 @@ export default function usePartnersCount({ const { id: workspaceId, defaultProgramId } = useWorkspace(); const { getQueryString } = useRouterStuff(); + // URLSearchParams stringifies an undefined value to "undefined", which then + // fails the enum parsing in partnersCountQuerySchema. The filter dropdowns hit + // this whenever a search is active, because that is when `status` is left + // undefined rather than defaulting to "approved". + const definedParams = Object.fromEntries( + Object.entries({ ...params, workspaceId }).filter( + ([, value]) => value !== undefined && value !== null, + ), + ); + const queryString = ignoreParams - ? // @ts-ignore - `?${new URLSearchParams({ - ...params, - workspaceId, - }).toString()}` - : getQueryString( - { - ...params, - workspaceId, - }, - { - exclude: ["partnerId"], - }, - ); + ? `?${new URLSearchParams(definedParams as Record).toString()}` + : getQueryString(definedParams, { + exclude: ["partnerId"], + }); const { data: partnersCount, diff --git a/apps/web/lib/swr/use-workspace.ts b/apps/web/lib/swr/use-workspace.ts index bdc652b8227..c404e59e883 100644 --- a/apps/web/lib/swr/use-workspace.ts +++ b/apps/web/lib/swr/use-workspace.ts @@ -10,8 +10,13 @@ export default function useWorkspace({ }: { swrOpts?: SWRConfiguration; } = {}) { - let { slug } = useParams() as { slug: string | null }; + let { slug } = useParams() as { slug: string | string[] | null }; const searchParams = useSearchParams(); + // catch-all routes (e.g. /marketplace/[[...slug]]) surface `slug` as an array, + // which is never a workspace slug - ignore it so we don't fetch /api/workspaces/ + if (Array.isArray(slug)) { + slug = null; + } if (!slug) { slug = searchParams.get("slug") || searchParams.get("workspace"); } diff --git a/apps/web/lib/tapfiliate/cleanup-partners.ts b/apps/web/lib/tapfiliate/cleanup-partners.ts index 67a2e332e45..06045dcf688 100644 --- a/apps/web/lib/tapfiliate/cleanup-partners.ts +++ b/apps/web/lib/tapfiliate/cleanup-partners.ts @@ -1,4 +1,6 @@ import { bulkDeleteLinks } from "@/lib/api/links/bulk-delete-links"; +import { queuePartnerSearchSync } from "@/lib/api/partners/queue-partner-search-sync"; +import { conn } from "@/lib/planetscale"; import { prisma } from "@/lib/prisma"; import { sendEmail } from "@dub/email"; import ProgramImported from "@dub/email/templates/program-imported"; @@ -57,6 +59,20 @@ export async function cleanupPartners(payload: TapfiliateImportPayload) { await bulkDeleteLinks(linksToDelete); + // Resolved before the delete, since nothing can map these partners back + // to their enrollments afterwards. + const removedEnrollments = await prisma.programEnrollment.findMany({ + where: { + programId, + partnerId: { + in: partnerIdsWithNoLeads, + }, + }, + select: { + id: true, + }, + }); + await prisma.programEnrollment.deleteMany({ where: { programId, @@ -66,6 +82,11 @@ export async function cleanupPartners(payload: TapfiliateImportPayload) { }, }); + // Queue an index update because the enrollments were deleted. + await queuePartnerSearchSync({ + enrollmentIds: removedEnrollments.map(({ id }) => id), + }); + // Remove partners that are not enrolled in any other program const otherProgramEnrollments = await prisma.programEnrollment.findMany({ where: { @@ -108,13 +129,20 @@ export async function cleanupPartners(payload: TapfiliateImportPayload) { }); if (partnersWithoutUserAccount.length > 0) { - await prisma.partner.deleteMany({ - where: { - id: { - in: partnersWithoutUserAccount.map(({ id }) => id), - }, - }, - }); + const partnerIdsToDelete = partnersWithoutUserAccount.map( + ({ id }) => id, + ); + + // using conn.execute here since Prisma throws on partner.deleteMany() + await conn.execute( + `DELETE FROM Partner WHERE id IN (${partnerIdsToDelete.map(() => "?").join(",")})`, + partnerIdsToDelete, + ); + + console.log( + "Removed the following partners", + partnersWithoutUserAccount, + ); } } } diff --git a/apps/web/lib/tapfiliate/client.ts b/apps/web/lib/tapfiliate/client.ts index 87a4f2f87df..2b2a215f526 100644 --- a/apps/web/lib/tapfiliate/client.ts +++ b/apps/web/lib/tapfiliate/client.ts @@ -57,7 +57,7 @@ export class TapfiliateClient extends HttpBaseClient { }); } - // GET /customers?program_id=&page= + // GET /customers/?program_id=&page= async listCustomers({ programId, page = 1, @@ -65,7 +65,7 @@ export class TapfiliateClient extends HttpBaseClient { programId: string; page?: number; }) { - return await this.get("/customers", { + return await this.get("/customers/", { input: { program_id: programId, page, diff --git a/apps/web/lib/tapfiliate/import-commissions.ts b/apps/web/lib/tapfiliate/import-commissions.ts index b78bfa69bde..4a2fdadd9c8 100644 --- a/apps/web/lib/tapfiliate/import-commissions.ts +++ b/apps/web/lib/tapfiliate/import-commissions.ts @@ -14,6 +14,7 @@ import { LeadEventTB } from "../types"; import { redis } from "../upstash"; import { clickEventSchemaTB } from "../zod/schemas/clicks"; import { TapfiliateClient } from "./client"; +import { getTapfiliateCustomerExternalId } from "./import-customers"; import { TAPFILIATE_MAX_BATCHES, tapfiliateImporter } from "./importer"; import { TapfiliateConversionWithCommission, @@ -72,9 +73,13 @@ export async function importCommissions(payload: TapfiliateImportPayload) { (conversion) => conversion.program?.id === tapfiliateProgramId, ); - const customerExternalIds = conversions - .map((conversion) => conversion.customer?.customer_id) - .filter((id): id is string => Boolean(id)); + const customerExternalIds = [ + ...new Set( + conversions + .map((conversion) => getTapfiliateCustomerExternalId(conversion)) + .filter((id): id is string => Boolean(id)), + ), + ]; const customersData = await prisma.customer.findMany({ where: { @@ -151,7 +156,8 @@ async function createCommission({ customersData: (Customer & { link: Link | null })[]; customerLeadEvents: LeadEventTB[]; }) { - const { commission, customer } = conversion; + const { commission } = conversion; + const customerExternalId = getTapfiliateCustomerExternalId(conversion); const commonImportLogInputs = { workspace_id: program.workspaceId, @@ -178,25 +184,25 @@ async function createCommission({ return; } - if (!customer) { + if (!customerExternalId) { await logImportError({ ...commonImportLogInputs, code: "CUSTOMER_NOT_FOUND", - message: `A customer is not associated with this commission ${commission.id}, skipping...`, + message: `No customer id or external_id associated with this commission ${commission.id}, skipping...`, }); return; } const existingCustomer = customersData.find( - ({ externalId }) => externalId === customer?.customer_id, + ({ externalId }) => externalId === customerExternalId, ); if (!existingCustomer) { await logImportError({ ...commonImportLogInputs, code: "CUSTOMER_NOT_FOUND", - message: `No customer ${customer.customer_id} found for commission ${commission.id}.`, + message: `No customer ${customerExternalId} found for commission ${commission.id}.`, }); return; diff --git a/apps/web/lib/tapfiliate/import-customers.ts b/apps/web/lib/tapfiliate/import-customers.ts index 85493a2a841..a5ee85f45ff 100644 --- a/apps/web/lib/tapfiliate/import-customers.ts +++ b/apps/web/lib/tapfiliate/import-customers.ts @@ -9,10 +9,26 @@ import { logImportError } from "../tinybird/log-import-error"; import { clickEventSchemaTB } from "../zod/schemas/clicks"; import { TapfiliateClient } from "./client"; import { TAPFILIATE_MAX_BATCHES, tapfiliateImporter } from "./importer"; -import { TapfiliateCustomer, TapfiliateImportPayload } from "./types"; +import { + TapfiliateConversion, + TapfiliateCustomer, + TapfiliateImportPayload, +} from "./types"; + +export function getTapfiliateCustomerExternalId( + conversion: Pick, +): string | null { + return conversion.customer?.customer_id ?? conversion.external_id ?? null; +} export async function importCustomers(payload: TapfiliateImportPayload) { - const { importId, programId, tapfiliateProgramId, page = 1 } = payload; + const { + importId, + programId, + tapfiliateProgramId, + page = 1, + customerSource = "customers", + } = payload; const program = await prisma.program.findUnique({ where: { @@ -47,6 +63,21 @@ export async function importCustomers(payload: TapfiliateImportPayload) { apiKey, }); + if (customerSource === "conversions") { + await importCustomersFromConversions({ + payload, + program: { + domain: program.domain, + workspace, + }, + tapfiliateApi, + tapfiliateProgramId, + importId, + page, + }); + return; + } + let currentPage = page; let hasMore = true; let processedBatches = 0; @@ -68,77 +99,94 @@ export async function importCustomers(payload: TapfiliateImportPayload) { customer.program?.id === tapfiliateProgramId && customer.affiliate?.id, ); - if (customers.length > 0) { - // Map the Tapfiliate affiliate id -> the partner's referral link (link key = affiliate id) - const affiliateIds = [ - ...new Set(customers.map((customer) => customer.affiliate!.id)), - ]; - - const links = await prisma.link.findMany({ - where: { - domain: program.domain, - key: { - in: affiliateIds, - }, - }, - select: { - id: true, - key: true, - domain: true, - url: true, - partnerId: true, - programId: true, - lastLeadAt: true, - }, - }); - - const affiliateIdToLink = new Map(links.map((link) => [link.key, link])); - const customerExternalIds = [ - ...new Set( - customers - .map((customer) => customer.customer_id) - .filter((id): id is string => id !== null), - ), - ]; - - // Find the existing customers by their external customer_id - const existingCustomers = await prisma.customer.findMany({ - where: { - projectId: workspace.id, - externalId: { - in: customerExternalIds, - }, - }, - select: { - id: true, - externalId: true, - }, - }); + await createCustomers({ + workspace, + domain: program.domain, + importId, + customers, + }); - // New customers to create - const newCustomers = customers.filter( - (customer) => - !existingCustomers.some((c) => c.externalId === customer.customer_id), - ); + currentPage++; + processedBatches++; + } + + await tapfiliateImporter.queue({ + ...payload, + action: "import-customers", + customerSource: hasMore ? "customers" : "conversions", + page: hasMore ? currentPage : 1, + }); +} + +async function importCustomersFromConversions({ + payload, + program, + tapfiliateApi, + tapfiliateProgramId, + importId, + page, +}: { + payload: TapfiliateImportPayload; + program: { + domain: string; + workspace: Pick; + }; + tapfiliateApi: TapfiliateClient; + tapfiliateProgramId: string; + importId: string; + page: number; +}) { + let currentPage = page; + let hasMore = true; + let processedBatches = 0; + + while (hasMore && processedBatches < TAPFILIATE_MAX_BATCHES) { + let conversions = await tapfiliateApi.listConversions({ + programId: tapfiliateProgramId, + page: currentPage, + }); + + if (conversions.length === 0) { + hasMore = false; + break; + } + + conversions = conversions.filter( + (conversion) => conversion.program?.id === tapfiliateProgramId, + ); - if (newCustomers.length > 0) { - const customerChunks = chunk(newCustomers, 10); - - for (const customerChunk of customerChunks) { - await Promise.all( - customerChunk.map((customer) => - createCustomer({ - workspace, - customer, - link: affiliateIdToLink.get(customer.affiliate!.id), - importId, - }), - ), - ); - } + const customersByExternalId = new Map(); + + for (const conversion of conversions) { + const externalId = getTapfiliateCustomerExternalId(conversion); + const affiliateId = + conversion.affiliate?.id ?? conversion.customer?.affiliate?.id; + + if (!externalId || !affiliateId) { + continue; + } + + const existing = customersByExternalId.get(externalId); + + if (!existing || conversion.created_at < existing.created_at) { + customersByExternalId.set(externalId, { + id: String(conversion.id), + customer_id: externalId, + created_at: conversion.created_at, + click: conversion.click ?? null, + program: conversion.program, + affiliate: { id: affiliateId }, + }); } } + await createCustomers({ + workspace: program.workspace, + domain: program.domain, + importId, + customers: [...customersByExternalId.values()], + }); + currentPage++; processedBatches++; } @@ -146,10 +194,99 @@ export async function importCustomers(payload: TapfiliateImportPayload) { await tapfiliateImporter.queue({ ...payload, action: hasMore ? "import-customers" : "import-commissions", + customerSource: hasMore ? "conversions" : undefined, page: hasMore ? currentPage : undefined, }); } +async function createCustomers({ + workspace, + domain, + importId, + customers, +}: { + workspace: Pick; + domain: string; + importId: string; + customers: TapfiliateCustomer[]; +}) { + const customersWithAffiliate = customers.filter( + (customer) => customer.affiliate?.id, + ); + + if (customersWithAffiliate.length === 0) { + return; + } + + // Map the Tapfiliate affiliate id -> the partner's referral link (link key = affiliate id) + const affiliateIds = [ + ...new Set( + customersWithAffiliate.map((customer) => customer.affiliate!.id), + ), + ]; + + const links = await prisma.link.findMany({ + where: { + domain, + key: { + in: affiliateIds, + }, + }, + select: { + id: true, + key: true, + domain: true, + url: true, + partnerId: true, + programId: true, + lastLeadAt: true, + }, + }); + + const affiliateIdToLink = new Map(links.map((link) => [link.key, link])); + const customerExternalIds = [ + ...new Set(customersWithAffiliate.map((customer) => customer.customer_id)), + ]; + + // Find the existing customers by their external customer_id + const existingCustomers = await prisma.customer.findMany({ + where: { + projectId: workspace.id, + externalId: { + in: customerExternalIds, + }, + }, + select: { + id: true, + externalId: true, + }, + }); + + const newCustomers = customersWithAffiliate.filter( + (customer) => + !existingCustomers.some((c) => c.externalId === customer.customer_id), + ); + + if (newCustomers.length === 0) { + return; + } + + const customerChunks = chunk(newCustomers, 10); + + for (const customerChunk of customerChunks) { + await Promise.all( + customerChunk.map((customer) => + createCustomer({ + workspace, + customer, + link: affiliateIdToLink.get(customer.affiliate!.id), + importId, + }), + ), + ); + } +} + async function createCustomer({ workspace, customer, diff --git a/apps/web/lib/tapfiliate/import-partners.ts b/apps/web/lib/tapfiliate/import-partners.ts index a3ae364e8b6..5a149bca50a 100644 --- a/apps/web/lib/tapfiliate/import-partners.ts +++ b/apps/web/lib/tapfiliate/import-partners.ts @@ -4,6 +4,7 @@ import slugify from "@sindresorhus/slugify"; import { createId } from "../api/create-id"; import { createLink } from "../api/links"; import { generatePartnerLink } from "../api/partners/generate-partner-link"; +import { queuePartnerSearchSync } from "../api/partners/queue-partner-search-sync"; import { logImportError } from "../tinybird/log-import-error"; import { WorkspaceProps } from "../types"; import { DEFAULT_PARTNER_GROUP } from "../zod/schemas/groups"; @@ -111,6 +112,10 @@ export async function importPartners(payload: TapfiliateImportPayload) { .map((p) => p.value); if (partnerIds.length > 0) { + // Queue an index update because the imported partners were enrolled. + // Queued per page rather than per partner. + await queuePartnerSearchSync({ partnerIds, programId }); + await tapfiliateImporter.trackImportedPartnerIds({ programId, partnerIds, diff --git a/apps/web/lib/tapfiliate/schemas.ts b/apps/web/lib/tapfiliate/schemas.ts index 5b35cae3368..5d651a063c6 100644 --- a/apps/web/lib/tapfiliate/schemas.ts +++ b/apps/web/lib/tapfiliate/schemas.ts @@ -17,6 +17,7 @@ export const tapfiliateImportPayloadSchema = z.object({ action: tapfiliateImportSteps, page: z.number().optional(), // Tapfiliate pagination startingAfter: z.string().optional(), // Dub pagination + customerSource: z.enum(["customers", "conversions"]).optional(), }); // GET /affiliates/ @@ -24,7 +25,7 @@ export const tapfiliateListPartnersInputSchema = z.object({ page: z.number(), }); -// GET /customers +// GET /customers/ export const tapfiliateListCustomersInputSchema = z.object({ program_id: z.string(), page: z.number(), @@ -63,17 +64,17 @@ export const tapfiliatePartnerSchema = z.object({ .nullable(), }); +export const tapfiliateClickSchema = z.object({ + created_at: z.string(), + referrer: z.string().nullable(), + landing_page: z.string().nullable(), +}); + export const tapfiliateCustomerSchema = z.object({ id: z.string(), customer_id: z.string().describe("External customer ID."), created_at: z.string(), - click: z - .object({ - created_at: z.string(), - referrer: z.string().nullable(), - landing_page: z.string().nullable(), - }) - .nullable(), + click: tapfiliateClickSchema.nullable(), program: tapfiliateProgramSchema .pick({ id: true, @@ -97,6 +98,8 @@ export const tapfiliateCommissionSchema = z.object({ export const tapfiliateConversionSchema = z.object({ id: z.number(), + created_at: z.string(), + external_id: z.string().nullish(), program: tapfiliateProgramSchema .pick({ id: true, @@ -105,7 +108,14 @@ export const tapfiliateConversionSchema = z.object({ customer: tapfiliateCustomerSchema .pick({ customer_id: true, + affiliate: true, }) - .nullable(), + .nullish(), + affiliate: tapfiliatePartnerSchema + .pick({ + id: true, + }) + .nullish(), + click: tapfiliateClickSchema.nullish(), commissions: z.array(tapfiliateCommissionSchema).nullable(), }); diff --git a/apps/web/lib/tapfiliate/update-stripe-customers.ts b/apps/web/lib/tapfiliate/update-stripe-customers.ts index dca4328478c..315f9639e9c 100644 --- a/apps/web/lib/tapfiliate/update-stripe-customers.ts +++ b/apps/web/lib/tapfiliate/update-stripe-customers.ts @@ -34,8 +34,13 @@ export async function updateStripeCustomers(payload: TapfiliateImportPayload) { if (!workspace.stripeConnectId) { console.error( - `Workspace ${workspace.id} has no stripeConnectId. Skipping...`, + `Workspace ${workspace.id} has no stripeConnectId. Skipping Stripe customer matching...`, ); + + await tapfiliateImporter.queue({ + ...payload, + action: "cleanup-partners", + }); return; } diff --git a/apps/web/lib/tinybird/record-fake-click.ts b/apps/web/lib/tinybird/record-fake-click.ts index 76464382dd6..5a56d2be640 100644 --- a/apps/web/lib/tinybird/record-fake-click.ts +++ b/apps/web/lib/tinybird/record-fake-click.ts @@ -1,4 +1,4 @@ -import { nanoid } from "@dub/utils"; +import { COUNTRIES_TO_CONTINENTS, nanoid } from "@dub/utils"; import { Link } from "@prisma/client"; import { clickEventSchemaTB } from "../zod/schemas/clicks"; import { recordClick } from "./record-click"; @@ -25,22 +25,51 @@ export async function recordFakeClick({ link, customer, timestamp, + referrer, + userAgent, }: { - link: Pick; + link: Pick & { + programId?: string | null; + partnerId?: string | null; + }; customer?: { country?: string | null; region?: string | null; continent?: string | null; + city?: string | null; + latitude?: string | null; + longitude?: string | null; }; timestamp?: string | number; + referrer?: string | null; + userAgent?: string | null; }) { + const country = toSafeHeaderValue(customer?.country) || "US"; + const continent = + toSafeHeaderValue(customer?.continent) || + COUNTRIES_TO_CONTINENTS[country] || + "NA"; + const dummyRequest = new Request(link.url, { headers: new Headers({ - "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + "user-agent": + toSafeHeaderValue(userAgent) || + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", "x-forwarded-for": "127.0.0.1", - "x-vercel-ip-country": toSafeHeaderValue(customer?.country) || "US", + "x-vercel-ip-country": country, "x-vercel-ip-country-region": toSafeHeaderValue(customer?.region) || "CA", - "x-vercel-ip-continent": toSafeHeaderValue(customer?.continent) || "NA", + "x-vercel-ip-continent": continent, + ...(customer?.city && { + "x-vercel-ip-city": toSafeHeaderValue(customer.city) || "Unknown", + }), + ...(customer?.latitude && { + "x-vercel-ip-latitude": + toSafeHeaderValue(customer.latitude) || "Unknown", + }), + ...(customer?.longitude && { + "x-vercel-ip-longitude": + toSafeHeaderValue(customer.longitude) || "Unknown", + }), }), }); @@ -52,8 +81,11 @@ export async function recordFakeClick({ domain: link.domain, key: link.key, url: link.url, + programId: link.programId ?? undefined, + partnerId: link.partnerId ?? undefined, skipRatelimit: true, shouldCacheClickId: true, + ...(referrer && { referrer }), ...(timestamp && { timestamp: new Date(timestamp).toISOString() }), }); diff --git a/apps/web/lib/tolt/cleanup-partners.ts b/apps/web/lib/tolt/cleanup-partners.ts index fa771fe62e7..d7009dd7b09 100644 --- a/apps/web/lib/tolt/cleanup-partners.ts +++ b/apps/web/lib/tolt/cleanup-partners.ts @@ -1,4 +1,6 @@ import { bulkDeleteLinks } from "@/lib/api/links/bulk-delete-links"; +import { queuePartnerSearchSync } from "@/lib/api/partners/queue-partner-search-sync"; +import { conn } from "@/lib/planetscale"; import { prisma } from "@/lib/prisma"; import { toltImporter } from "./importer"; @@ -51,6 +53,20 @@ export async function cleanupPartners({ programId }: { programId: string }) { await bulkDeleteLinks(linksToDelete); + // Resolved before the delete, since nothing can map these partners back + // to their enrollments afterwards. + const removedEnrollments = await prisma.programEnrollment.findMany({ + where: { + programId, + partnerId: { + in: partnerIdsToRemove, + }, + }, + select: { + id: true, + }, + }); + await prisma.programEnrollment.deleteMany({ where: { programId, @@ -60,6 +76,11 @@ export async function cleanupPartners({ programId }: { programId: string }) { }, }); + // Queue an index update because the enrollments were deleted. + await queuePartnerSearchSync({ + enrollmentIds: removedEnrollments.map(({ id }) => id), + }); + // Remove partners that are not enrolled in any other program const otherProgramEnrollments = await prisma.programEnrollment.findMany({ where: { @@ -84,38 +105,38 @@ export async function cleanupPartners({ programId }: { programId: string }) { ); if (removablePartnerIds.length > 0) { - await prisma.$transaction(async (tx) => { - // Find partners that have no user account - const partnersWithoutUserAccount = await tx.partner.findMany({ - where: { - id: { - in: removablePartnerIds, - }, - users: { - none: {}, - }, + // Find partners that have no user account + const partnersWithoutUserAccount = await prisma.partner.findMany({ + where: { + id: { + in: removablePartnerIds, }, - select: { - id: true, - email: true, + users: { + none: {}, }, - }); - - if (partnersWithoutUserAccount.length > 0) { - await tx.partner.deleteMany({ - where: { - id: { - in: partnersWithoutUserAccount.map(({ id }) => id), - }, - }, - }); - - console.log( - "Removed the following partners", - partnersWithoutUserAccount, - ); - } + }, + select: { + id: true, + email: true, + }, }); + + if (partnersWithoutUserAccount.length > 0) { + const partnerIdsToDelete = partnersWithoutUserAccount.map( + ({ id }) => id, + ); + + // using conn.execute here since Prisma throws on partner.deleteMany() + await conn.execute( + `DELETE FROM Partner WHERE id IN (${partnerIdsToDelete.map(() => "?").join(",")})`, + partnerIdsToDelete, + ); + + console.log( + "Removed the following partners", + partnersWithoutUserAccount, + ); + } } } diff --git a/apps/web/lib/tolt/import-partners.ts b/apps/web/lib/tolt/import-partners.ts index 8055d55fb33..0aedd939a00 100644 --- a/apps/web/lib/tolt/import-partners.ts +++ b/apps/web/lib/tolt/import-partners.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/prisma"; import { Partner, Program } from "@prisma/client"; import { createId } from "../api/create-id"; +import { queuePartnerSearchSync } from "../api/partners/queue-partner-search-sync"; import { logImportError } from "../tinybird/log-import-error"; import { DEFAULT_PARTNER_GROUP } from "../zod/schemas/groups"; import { ToltApi } from "./api"; @@ -84,6 +85,13 @@ export async function importPartners(payload: ToltImportPayload) { .map((p) => p.value); if (partners.length > 0) { + // Queue an index update because the imported partners were enrolled. + // Queued per page rather than per partner. + await queuePartnerSearchSync({ + partnerIds: partners.map((p) => p.id), + programId, + }); + await toltImporter.addPartners({ programId, partnerIds: partners.map((p) => p.id), diff --git a/apps/web/lib/tolt/update-stripe-customers.ts b/apps/web/lib/tolt/update-stripe-customers.ts index e2d239ea77a..8e553ee3d07 100644 --- a/apps/web/lib/tolt/update-stripe-customers.ts +++ b/apps/web/lib/tolt/update-stripe-customers.ts @@ -34,8 +34,13 @@ export async function updateStripeCustomers(payload: ToltImportPayload) { if (!workspace.stripeConnectId) { console.error( - `Workspace ${workspace.id} has no stripeConnectId. Skipping...`, + `Workspace ${workspace.id} has no stripeConnectId. Skipping Stripe customer matching...`, ); + + await toltImporter.queue({ + ...payload, + action: "cleanup-partners", + }); return; } diff --git a/apps/web/lib/tracking/build-tracking-setup.ts b/apps/web/lib/tracking/build-tracking-setup.ts new file mode 100644 index 00000000000..b8ffdcedff4 --- /dev/null +++ b/apps/web/lib/tracking/build-tracking-setup.ts @@ -0,0 +1,223 @@ +import { guides, StackItem, stackItems } from "@/ui/guides/integrations"; + +const ANALYTICS_SCRIPT_BASE = "https://www.dubcdn.com/analytics/script.js"; + +const REACT_PACKAGE_LINE = + "Use the @dub/analytics package with this workspace's publishable key. Do not add the Dub script tag unless a guide says to."; + +const KEY_PRESENT_FOOTER = + "Use the workspace values above. Do not invent a different publishable key or hostname."; + +const KEY_MISSING_FOOTER = + "Generate a publishable key in Tracking settings before tracking conversion events. Do not invent a key."; + +const ATTRIBUTION_URL = "https://dub.co/docs/concepts/attribution"; +const SERVER_TRACKING_URL = "https://dub.co/docs/quickstart/server"; +const CLIENT_TRACKING_URL = "https://dub.co/docs/quickstart/client"; + +type IntegrationSlot = { + id: string; + title: string; + url: string; + stackIds: string[]; +}; + +const INTEGRATION_SLOTS: IntegrationSlot[] = [ + { + id: "stripe", + title: "Stripe", + url: "https://dub.co/docs/integrations/stripe", + stackIds: ["stripe-checkout", "stripe-payment-links", "stripe-customers"], + }, + { + id: "shopify", + title: "Shopify", + url: "https://dub.co/docs/integrations/shopify", + stackIds: ["shopify"], + }, + { + id: "gtm", + title: "Google Tag Manager", + url: "https://dub.co/docs/integrations/google-tag-manager", + stackIds: ["gtm"], + }, + { + id: "segment", + title: "Segment", + url: "https://dub.co/docs/integrations/segment", + stackIds: ["segment"], + }, +]; + +type TrackingSetupStepType = "attribution" | "tracking" | "integration"; + +export type TrackingSetupStep = { + type: TrackingSetupStepType; + label: string; + url: string; + guideKey: string | null; + icon: StackItem["icon"] | null; + iconProps?: StackItem["iconProps"]; +}; + +type BuildTrackingSetupInput = { + stack: string[]; + hostnames: string[]; + publishableKey: string | null; + siteVisitEnabled: boolean; + outboundEnabled: boolean; +}; + +export type TrackingSetup = { + steps: TrackingSetupStep[]; + prompt: string; +}; + +function toDocUrl(step: TrackingSetupStep) { + return step.url.replace(/^https:\/\//, ""); +} + +function resolveIntegrationSteps(stack: string[]): TrackingSetupStep[] { + const seen = new Set(); + const steps: TrackingSetupStep[] = []; + + for (const id of stack) { + // "custom" has no dedicated install guide beyond the server/client + // tracking step already included above. + if (id === "custom") { + continue; + } + + const slot = INTEGRATION_SLOTS.find((item) => item.stackIds.includes(id)); + const item = stackItems.find((stackItem) => stackItem.id === id); + + if (slot) { + if (seen.has(slot.id)) continue; + + seen.add(slot.id); + steps.push({ + type: "integration", + label: slot.title, + url: slot.url, + guideKey: slot.id, + icon: item?.icon ?? null, + iconProps: item?.iconProps, + }); + continue; + } + + if (!item || seen.has(id)) continue; + + const guide = guides.find((g) => item.guideKeys.includes(g.key)); + if (!guide) continue; + + seen.add(id); + steps.push({ + type: "integration", + label: item.title, + url: guide.url, + guideKey: id, + icon: item.icon, + iconProps: item.iconProps, + }); + } + + return steps; +} + +function resolveTrackingSetupSteps({ + stack, + publishableKey, +}: Pick< + BuildTrackingSetupInput, + "stack" | "publishableKey" +>): TrackingSetupStep[] { + const trackingUrl = publishableKey + ? CLIENT_TRACKING_URL + : SERVER_TRACKING_URL; + const trackingLabel = publishableKey + ? "Client-side tracking" + : "Server-side tracking"; + + return [ + { + type: "attribution", + label: "Attribution", + url: ATTRIBUTION_URL, + guideKey: null, + icon: null, + }, + { + type: "tracking", + label: trackingLabel, + url: trackingUrl, + guideKey: null, + icon: null, + }, + ...resolveIntegrationSteps(stack), + ]; +} + +function composeTrackingSetupPrompt({ + steps, + stack, + hostnames, + publishableKey, + siteVisitEnabled, + outboundEnabled, +}: { + steps: TrackingSetupStep[]; +} & BuildTrackingSetupInput) { + const reactSelected = stack.includes("react"); + const scriptSegments = [ + siteVisitEnabled ? "site-visit" : null, + outboundEnabled ? "outbound-domains" : null, + publishableKey ? "conversion-tracking" : null, + ].filter(Boolean); + const analyticsScriptUrl = + scriptSegments.length === 0 + ? ANALYTICS_SCRIPT_BASE + : `https://www.dubcdn.com/analytics/script.${scriptSegments.join(".")}.js`; + const stackTitles = stack + .map((id) => stackItems.find((item) => item.id === id)?.title) + .filter((title): title is string => Boolean(title)); + const workspaceLines = [ + hostnames.length > 0 ? `- Hostnames: ${hostnames.join(", ")}` : null, + publishableKey + ? `- Publishable key (client-side): ${publishableKey}` + : null, + stackTitles.length > 0 ? `- Stack: ${stackTitles.join(", ")}` : null, + reactSelected ? null : `- Analytics script: ${analyticsScriptUrl}`, + ].filter(Boolean); + + return [ + "Help me set up conversion tracking with Dub.co by referencing the following articles (and the linked articles within them):", + "", + ...steps.map((step) => `- ${toDocUrl(step)}`), + "", + "Workspace:", + ...workspaceLines, + ...(reactSelected ? ["", REACT_PACKAGE_LINE] : []), + "", + "Make sure to tailor the implementation to my existing tech stack, and leverage server-side tracking if possible for the most accurate results.", + "", + publishableKey ? KEY_PRESENT_FOOTER : KEY_MISSING_FOOTER, + ].join("\n"); +} + +export function buildTrackingSetup( + input: BuildTrackingSetupInput, +): TrackingSetup { + const steps = resolveTrackingSetupSteps({ + stack: input.stack, + publishableKey: input.publishableKey, + }); + + return { + steps, + prompt: composeTrackingSetupPrompt({ + steps, + ...input, + }), + }; +} diff --git a/apps/web/lib/tremendous/constants.ts b/apps/web/lib/tremendous/constants.ts index b4d9b253e37..0688557d0c4 100644 --- a/apps/web/lib/tremendous/constants.ts +++ b/apps/web/lib/tremendous/constants.ts @@ -1,11 +1,3 @@ -import { ACME_PROGRAM_ID } from "@dub/utils"; - -export const TREMENDOUS_ENABLED_PROGRAM_IDS = [ - "prog_d8pl69xXCv4AoHNT281pHQdo", // Dub - ACME_PROGRAM_ID, - "prog_1KPAZMF49X9A1WEWRBM55KZY7", // Upheal -]; - export const TREMENDOUS_MIN_PAYOUT_AMOUNT_CENTS = 500; // $5 export const TREMENDOUS_MAX_PAYOUT_AMOUNT_CENTS = 2000_00; // $2,000 diff --git a/apps/web/lib/upstash/ratelimit-policies.ts b/apps/web/lib/upstash/ratelimit-policies.ts index 10ba05a3f64..214373c1bd7 100644 --- a/apps/web/lib/upstash/ratelimit-policies.ts +++ b/apps/web/lib/upstash/ratelimit-policies.ts @@ -95,4 +95,18 @@ export const RATELIMIT_POLICIES = { window: "1 m", keyPrefix: "rl:ai:reward:generate", }, + + // Keyed on workspace + user so one actor cannot exhaust the workspace quota + forwardDnsInstructions: { + attempts: 10, + window: "1 h", + keyPrefix: "rl:domains:forward-dns-instructions", + }, + + // Keyed on the recipient so many accounts can't spam the same address + forwardDnsInstructionsTarget: { + attempts: 10, + window: "1 h", + keyPrefix: "rl:domains:forward-dns-instructions:target", + }, } as const satisfies Record; diff --git a/apps/web/lib/webhook/constants.ts b/apps/web/lib/webhook/constants.ts index 4040bbf31aa..1cc12e5b0e8 100644 --- a/apps/web/lib/webhook/constants.ts +++ b/apps/web/lib/webhook/constants.ts @@ -22,10 +22,13 @@ export const WORKSPACE_LEVEL_WEBHOOK_TRIGGERS = [ export const PROGRAM_LEVEL_WEBHOOK_TRIGGERS = [ "partner.application_submitted", "partner.enrolled", + "partner.merged", "commission.created", "bounty.created", "bounty.updated", "payout.confirmed", + "discount_code.created", + "discount_code.deleted", ] as const; export const WEBHOOK_TRIGGERS = [ @@ -34,18 +37,26 @@ export const WEBHOOK_TRIGGERS = [ ] as const; export const WEBHOOK_TRIGGER_DESCRIPTIONS: Record = { - "link.created": "Link created", - "link.updated": "Link updated", - "link.deleted": "Link deleted", - "link.clicked": "Link clicked", - "lead.created": "Lead created", - "sale.created": "Sale created", - "partner.application_submitted": "Partner application submitted", - "partner.enrolled": "Partner enrolled", - "commission.created": "Commission created", - "bounty.created": "Bounty created", - "bounty.updated": "Bounty updated", - "payout.confirmed": "Payout confirmed", + "link.created": "Occurs whenever a link is created", + "link.updated": "Occurs whenever a link is updated", + "link.deleted": "Occurs whenever a link is deleted", + "link.clicked": "Occurs whenever a link is clicked", + "lead.created": "Occurs whenever a lead is created", + "sale.created": "Occurs whenever a sale is created", + "partner.application_submitted": + "Occurs whenever a partner submits an application to your program", + "partner.enrolled": + "Occurs whenever a partner is enrolled in your program (either their application was approved, they accepted your invite, or via the API)", + "partner.merged": "Occurs when two partner accounts are merged", + "commission.created": + "Occurs whenever a commission is created for a partner (clawbacks will also trigger this event with a negative amount)", + "bounty.created": "Occurs whenever a bounty is created in your program", + "bounty.updated": "Occurs whenever a bounty in your program is updated", + "payout.confirmed": "Occurs whenever a payout in your program is confirmed", + "discount_code.created": + "Occurs whenever a discount code is created for a partner", + "discount_code.deleted": + "Occurs whenever a discount code for a partner is deleted", } as const; export const WEBHOOK_FAILURE_NOTIFY_THRESHOLDS = [5, 10, 15] as const; diff --git a/apps/web/lib/webhook/qstash.ts b/apps/web/lib/webhook/qstash.ts index e10de21b0e3..c37ab55cdfb 100644 --- a/apps/web/lib/webhook/qstash.ts +++ b/apps/web/lib/webhook/qstash.ts @@ -1,4 +1,4 @@ -import { qstash } from "@/lib/cron"; +import { qstashWithoutBypass } from "@/lib/cron"; import { webhookPayloadSchema } from "@/lib/webhook/schemas"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { Webhook, WebhookReceiver } from "@prisma/client"; @@ -92,13 +92,25 @@ const publishWebhookEventToQStash = async ({ // TODO: // Add deduplicationId to the webhook - const response = await qstash.publishJSON({ + // here, we use qstashWithoutBypass to avoid passing the + // Vercel automation bypass secret to arbitrary third party webhook receivers + const response = await qstashWithoutBypass.publishJSON({ url: webhook.url, body: finalPayload, headers: { "Dub-Signature": signature, "Upstash-Hide-Headers": "true", + // for Vercel preview (e2e tests), we need to pass the + // Vercel automation bypass secret to the callback URL + // see: https://upstash.com/docs/qstash/features/callbacks#configuring-callbacks + ...(process.env.VERCEL_ENV === "preview" && { + "Upstash-Callback-Forward-x-vercel-protection-bypass": + process.env.VERCEL_AUTOMATION_BYPASS_SECRET || "", + "Upstash-Failure-Callback-Forward-x-vercel-protection-bypass": + process.env.VERCEL_AUTOMATION_BYPASS_SECRET || "", + }), + // Integration specific headers ...(receiver === "segment" && { "Upstash-Forward-Authorization": createSegmentBasicAuthHeader( @@ -108,6 +120,8 @@ const publishWebhookEventToQStash = async ({ }, callback: callbackUrl.href, failureCallback: failureCallbackUrl.href, + + // for E2E tests, add a 5s delay ...(process.env.NODE_ENV === "test" && { delay: 5 }), }); diff --git a/apps/web/lib/webhook/sample-events/commission-created.json b/apps/web/lib/webhook/sample-events/commission-created.json index ed322cbacc7..f612bee91cf 100644 --- a/apps/web/lib/webhook/sample-events/commission-created.json +++ b/apps/web/lib/webhook/sample-events/commission-created.json @@ -9,6 +9,7 @@ "description": null, "quantity": 1, "userId": "cludszk1h0000wmd2e0ea2b0p", + "metadata": null, "createdAt": "2025-07-16T10:48:14.722Z", "updatedAt": "2025-07-16T10:48:14.960Z", "partner": { diff --git a/apps/web/lib/webhook/sample-events/discount-code-created.json b/apps/web/lib/webhook/sample-events/discount-code-created.json new file mode 100644 index 00000000000..41bcd5d9037 --- /dev/null +++ b/apps/web/lib/webhook/sample-events/discount-code-created.json @@ -0,0 +1,14 @@ +{ + "id": "dcode_1K39DGZG3MHY9RP4PD0AS2C5P", + "code": "STEVEN10OFF", + "partnerId": "pn_1K9BZE1K285BSTX4W6MPKXJFZ", + "linkId": "link_5myDHLqhIQvUmUPjchVygF9R", + "disabledAt": null, + "discount": { + "id": "disc_1KEC01MXC5H50XQMSN83VCW65", + "amount": 10, + "type": "percentage", + "maxDuration": 6, + "provider": "custom" + } +} diff --git a/apps/web/lib/webhook/sample-events/discount-code-deleted.json b/apps/web/lib/webhook/sample-events/discount-code-deleted.json new file mode 100644 index 00000000000..41bcd5d9037 --- /dev/null +++ b/apps/web/lib/webhook/sample-events/discount-code-deleted.json @@ -0,0 +1,14 @@ +{ + "id": "dcode_1K39DGZG3MHY9RP4PD0AS2C5P", + "code": "STEVEN10OFF", + "partnerId": "pn_1K9BZE1K285BSTX4W6MPKXJFZ", + "linkId": "link_5myDHLqhIQvUmUPjchVygF9R", + "disabledAt": null, + "discount": { + "id": "disc_1KEC01MXC5H50XQMSN83VCW65", + "amount": 10, + "type": "percentage", + "maxDuration": 6, + "provider": "custom" + } +} diff --git a/apps/web/lib/webhook/sample-events/partner-merged.json b/apps/web/lib/webhook/sample-events/partner-merged.json new file mode 100644 index 00000000000..ed27a5f9efa --- /dev/null +++ b/apps/web/lib/webhook/sample-events/partner-merged.json @@ -0,0 +1,13 @@ +{ + "sourcePartner": { + "id": "pn_1K9BZE1K285BSTX4W6MPKXJFZ", + "tenantId": "64dc9a8c-5cf9-4446-b53b-cdc15199fafc", + "email": "source@example.com" + }, + "targetPartner": { + "id": "pn_1K06X6FX2GRB31NCM2VVCGJ72", + "tenantId": "64dc9a8c-5cf9-4446-b53b-cdc15199fafc", + "email": "target@example.com" + }, + "targetAlreadyEnrolled": false +} diff --git a/apps/web/lib/webhook/sample-events/payload.ts b/apps/web/lib/webhook/sample-events/payload.ts index 546af936567..3028ef21b75 100644 --- a/apps/web/lib/webhook/sample-events/payload.ts +++ b/apps/web/lib/webhook/sample-events/payload.ts @@ -2,6 +2,8 @@ import type { WebhookTrigger } from "@/lib/webhook/types"; import bountyCreated from "./bounty-created.json"; import bountyUpdated from "./bounty-updated.json"; import commissionCreated from "./commission-created.json"; +import discountCodeCreated from "./discount-code-created.json"; +import discountCodeDeleted from "./discount-code-deleted.json"; import leadCreated from "./lead-created.json"; import linkClicked from "./link-clicked.json"; import linkCreated from "./link-created.json"; @@ -9,6 +11,7 @@ import linkDeleted from "./link-deleted.json"; import linkUpdated from "./link-updated.json"; import partnerApplicationSubmitted from "./partner-application-submitted.json"; import partnerEnrolled from "./partner-enrolled.json"; +import partnerMerged from "./partner-merged.json"; import payoutConfirmed from "./payout-confirmed.json"; import saleCreated from "./sale-created.json"; @@ -21,8 +24,11 @@ export const samplePayload: Record = { "sale.created": saleCreated, "partner.application_submitted": partnerApplicationSubmitted, "partner.enrolled": partnerEnrolled, + "partner.merged": partnerMerged, "commission.created": commissionCreated, "bounty.created": bountyCreated, "bounty.updated": bountyUpdated, "payout.confirmed": payoutConfirmed, + "discount_code.created": discountCodeCreated, + "discount_code.deleted": discountCodeDeleted, }; diff --git a/apps/web/lib/webhook/schemas.ts b/apps/web/lib/webhook/schemas.ts index c67e5c8efca..f0270f0fa08 100644 --- a/apps/web/lib/webhook/schemas.ts +++ b/apps/web/lib/webhook/schemas.ts @@ -2,9 +2,11 @@ import * as z from "zod/v4"; import { clickEventSchema } from "../zod/schemas/clicks"; import { CommissionWebhookSchema } from "../zod/schemas/commissions"; import { CustomerSchema } from "../zod/schemas/customers"; +import { DiscountCodeWebhookSchema } from "../zod/schemas/discount"; import { linkEventSchema } from "../zod/schemas/links"; import { EnrolledPartnerSchema, + partnerMergedWebhookSchema, WebhookPartnerSchema, } from "../zod/schemas/partners"; import { partnerApplicationWebhookSchema } from "../zod/schemas/program-application"; @@ -156,6 +158,20 @@ export const webhookEventSchema = z outputId: "PartnerApplicationSubmittedEvent", }), + z + .object({ + id: z.string(), + event: z.literal("partner.merged"), + createdAt: z.string(), + data: partnerMergedWebhookSchema, + }) + .meta({ + description: + "Triggered when two partner accounts are merged. Fired once per program the source partner was enrolled in.", + id: "PartnerMergedEvent", + outputId: "PartnerMergedEvent", + }), + z .object({ id: z.string(), @@ -168,6 +184,22 @@ export const webhookEventSchema = z id: "CommissionCreatedEvent", outputId: "CommissionCreatedEvent", }), + + z + .object({ + id: z.string(), + event: z.union([ + z.literal("discount_code.created"), + z.literal("discount_code.deleted"), + ]), + createdAt: z.string(), + data: DiscountCodeWebhookSchema, + }) + .meta({ + description: "Triggered when a discount code is created or deleted.", + id: "DiscountCodeWebhookEvent", + outputId: "DiscountCodeWebhookEvent", + }), ]) .meta({ description: "Webhook event schema", diff --git a/apps/web/lib/webhook/timing-safe-compare.ts b/apps/web/lib/webhook/timing-safe-compare.ts new file mode 100644 index 00000000000..fc30d65d45b --- /dev/null +++ b/apps/web/lib/webhook/timing-safe-compare.ts @@ -0,0 +1,40 @@ +import crypto from "crypto"; + +/** + * Performs constant-time comparison of two strings to prevent timing attacks. + * + * This function compares two strings in a way that takes the same amount of time + * regardless of where the strings differ, mitigating timing side-channel attacks + * (CWE-208). + * + * Use this for comparing security-sensitive values like: + * - Webhook signatures + * - HMAC digests + * - API keys + * - Authentication tokens + * + * @param provided - The value provided by the client/request + * @param expected - The expected/computed value + * @returns true if the strings match, false otherwise + */ +export function timingSafeCompare( + provided: string | null | undefined, + expected: string, +): boolean { + if (!provided) { + return false; + } + + const providedBuffer = Buffer.from(provided, "utf8"); + const expectedBuffer = Buffer.from(expected, "utf8"); + + // Length check before constant-time comparison + if (providedBuffer.length !== expectedBuffer.length) { + return false; + } + + return crypto.timingSafeEqual( + Uint8Array.from(providedBuffer), + Uint8Array.from(expectedBuffer), + ); +} diff --git a/apps/web/lib/webhook/types.ts b/apps/web/lib/webhook/types.ts index b25707c8a03..eaed23b0eea 100644 --- a/apps/web/lib/webhook/types.ts +++ b/apps/web/lib/webhook/types.ts @@ -1,8 +1,12 @@ import * as z from "zod/v4"; import { BountySchema } from "../zod/schemas/bounties"; +import { DiscountCodeWebhookSchema } from "../zod/schemas/discount"; import { CommissionWebhookSchema } from "../zod/schemas/commissions"; import { linkEventSchema } from "../zod/schemas/links"; -import { EnrolledPartnerSchema } from "../zod/schemas/partners"; +import { + EnrolledPartnerSchema, + partnerMergedWebhookSchema, +} from "../zod/schemas/partners"; import { payoutWebhookEventSchema } from "../zod/schemas/payouts"; import { partnerApplicationWebhookSchema } from "../zod/schemas/program-application"; import { WEBHOOK_TRIGGERS } from "./constants"; @@ -26,6 +30,10 @@ export type PartnerApplicationWebhookPayload = z.infer< typeof partnerApplicationWebhookSchema >; +export type PartnerMergedWebhookPayload = z.infer< + typeof partnerMergedWebhookSchema +>; + export type CommissionEventWebhookPayload = z.infer< typeof CommissionWebhookSchema >; @@ -36,6 +44,10 @@ export type PayoutEventWebhookPayload = z.infer< typeof payoutWebhookEventSchema >; +export type DiscountCodeEventWebhookPayload = z.infer< + typeof DiscountCodeWebhookSchema +>; + export type WebhookEventPayload = | z.infer | ClickEventWebhookPayload @@ -43,6 +55,8 @@ export type WebhookEventPayload = | SaleEventWebhookPayload | PartnerEventWebhookPayload | PartnerApplicationWebhookPayload + | PartnerMergedWebhookPayload | CommissionEventWebhookPayload | BountyEventWebhookPayload - | PayoutEventWebhookPayload; + | PayoutEventWebhookPayload + | DiscountCodeEventWebhookPayload; diff --git a/apps/web/lib/zod/schemas/analytics-response.ts b/apps/web/lib/zod/schemas/analytics-response.ts index 2305bafbf93..89c0291b425 100644 --- a/apps/web/lib/zod/schemas/analytics-response.ts +++ b/apps/web/lib/zod/schemas/analytics-response.ts @@ -204,6 +204,27 @@ export const analyticsResponse = { triggers: analyticsTriggersResponse, trigger: analyticsTriggersResponse, // backwards compatibility + event_names: z.object({ + eventName: z + .string() + .describe("The name of the conversion event (lead or sale)"), + clicks: z + .number() + .describe("The number of clicks from this event name") + .default(0), + leads: z + .number() + .describe("The number of leads from this event name") + .default(0), + sales: z + .number() + .describe("The number of sales from this event name") + .default(0), + saleAmount: centsSchemaWithDefault.describe( + "The total amount of sales from this event name, in cents", + ), + }), + referers: z.object({ referer: z .string() diff --git a/apps/web/lib/zod/schemas/analytics.ts b/apps/web/lib/zod/schemas/analytics.ts index 1b2fafae96d..69bea88860b 100644 --- a/apps/web/lib/zod/schemas/analytics.ts +++ b/apps/web/lib/zod/schemas/analytics.ts @@ -277,6 +277,15 @@ export const analyticsQuerySchema = z.object({ "Examples: `qr`, `qr,link`, `-qr`. " + "If undefined, returns all trigger types.", ), + eventName: z + .string() + .optional() + .transform(parseFilterValue) + .describe( + "The conversion event name to retrieve analytics for. Only available for lead and sale events. " + + "Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). " + + "Examples: `Sign up`, `Sign up,Purchase`, `-Sign up`.", + ), referer: z .string() .optional() diff --git a/apps/web/lib/zod/schemas/campaigns.ts b/apps/web/lib/zod/schemas/campaigns.ts index 6259c740a19..123e80ba7f1 100644 --- a/apps/web/lib/zod/schemas/campaigns.ts +++ b/apps/web/lib/zod/schemas/campaigns.ts @@ -21,6 +21,41 @@ export const EMAIL_TEMPLATE_VARIABLES = [ "ReferralReward", ] as const; +export const EMAIL_TEMPLATE_VARIABLE_INFO: Record< + (typeof EMAIL_TEMPLATE_VARIABLES)[number], + { description: string; example: string; hideExample?: boolean } +> = { + PartnerName: { + description: "The partner's full name", + example: "John Doe", + }, + PartnerEmail: { + description: "The partner's email address", + example: "partner@acme.com", + }, + PartnerLink: { + description: "The partner's default referral link", + example: "refer.dub.co/john", + }, + SaleReward: { + description: "The partner's sale commission", + example: "30% per sale for 6 months", + }, + LeadReward: { + description: "The partner's lead commission", + example: "$10 per lead", + }, + ClickReward: { + description: "The partner's click commission", + example: "$0.50 per click", + }, + ReferralReward: { + description: "The partner's commission for referring another partner", + example: "10% per referred partner's commission for 1 year", + hideExample: true, + }, +}; + export const CAMPAIGN_FROM_FORMAT_ERROR = 'From must be an email or "Name " format.'; diff --git a/apps/web/lib/zod/schemas/commissions.ts b/apps/web/lib/zod/schemas/commissions.ts index a940a4dcfc0..7b184c2c053 100644 --- a/apps/web/lib/zod/schemas/commissions.ts +++ b/apps/web/lib/zod/schemas/commissions.ts @@ -2,6 +2,7 @@ import { DATE_RANGE_INTERVAL_PRESETS } from "@/lib/analytics/constants"; import { CommissionStatus, CommissionType } from "@prisma/client"; import * as z from "zod/v4"; import { createCustomerBodySchema, CustomerSchema } from "./customers"; +import { trackLeadRequestSchema } from "./leads"; import { LinkSchema } from "./links"; import { getCursorPaginationQuerySchema, @@ -10,6 +11,7 @@ import { import { EnrolledPartnerSchema, WebhookPartnerSchema } from "./partners"; import { PayoutSchema } from "./payouts"; import { rewardContextSchema, RewardSchema } from "./rewards"; +import { trackSaleRequestSchema } from "./sales"; import { UserSchema } from "./users"; import { centsSchema, parseDateSchema } from "./utils"; @@ -17,20 +19,52 @@ export const CommissionSchema = z.object({ id: z.string().describe("The commission's unique ID on Dub.").meta({ example: "cm_1JVR7XRCSR0EDBAF39FZ4PMYE", }), - type: z.enum(CommissionType).optional(), // Note: Not sure the type will ever be optional - amount: z.number(), - earnings: z.number(), - currency: z.string(), - status: z.enum(CommissionStatus), - invoiceId: z.string().nullable(), - description: z.string().nullable(), - quantity: z.number(), + type: z + .enum(CommissionType) + .describe( + "The type of commission. Can be `click`, `lead`, `sale`, `referral`, or `custom`.", + ), + amount: z + .number() + .describe( + "The associated event amount in cents. For sale commissions, this is the sale amount.", + ), + earnings: z.number().describe("The amount earned by the partner, in cents."), + currency: z + .string() + .describe("The currency of the commission, as an ISO 4217 currency code."), + status: z + .enum(CommissionStatus) + .describe("The current status of the commission."), + invoiceId: z + .string() + .nullable() + .describe("The associated invoice ID. Only set for sale commissions."), + description: z + .string() + .nullable() + .describe("An optional description of the commission."), + quantity: z + .number() + .describe( + "The event quantity. Used for click and lead commissions; typically `1` for sale and custom commissions.", + ), userId: z .string() .nullish() .describe("The user who created the manual commission."), - createdAt: z.date(), - updatedAt: z.date(), + metadata: z + .record(z.string(), z.any()) + .nullable() + .describe( + "User-provided metadata from the associated lead or sale event (`lead.metadata` / `sale.metadata`).", + ), + createdAt: z + .date() + .describe("The date and time when the commission was created."), + updatedAt: z + .date() + .describe("The date and time when the commission was last updated."), }); // Represents the commission object used in webhook and API responses (/api/commissions/**) @@ -99,9 +133,14 @@ export const getCommissionsQuerySchema = z .enum(CommissionType) .optional() .describe( - "Filter the list of commissions by type. " + + [ + "Filter the list of commissions by type.", "Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). " + - "Examples: `sale`, `sale,lead`, `-click`.", + "Examples:", + `- "sale"`, + `- "sale,lead"`, + `- "-click"`, + ].join("\n"), ), customerId: z .string() @@ -115,9 +154,14 @@ export const getCommissionsQuerySchema = z .string() .optional() .describe( - "Filter the list of commissions by the associated partner. When specified, takes precedence over `tenantId`. " + - "Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). " + - "Examples: `partner_abc`, `partner_abc,partner_xyz`, `-partner_abc`.", + [ + "Filter the list of commissions by the associated partner. When specified, takes precedence over `tenantId`.", + "Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`).", + "Examples:", + `- "partner_abc"`, + `- "partner_abc,partner_xyz"`, + `- "-partner_abc"`, + ].join("\n"), ), tenantId: z .string() @@ -129,17 +173,27 @@ export const getCommissionsQuerySchema = z .string() .optional() .describe( - "Filter the list of commissions by the associated partner group. " + + [ + "Filter the list of commissions by the associated partner group.", "Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). " + - "Examples: `group_abc`, `group_abc,group_xyz`, `-group_abc`.", + "Examples:", + `- "group_abc"`, + `- "group_abc,group_xyz"`, + `- "-group_abc"`, + ].join("\n"), ), partnerTagId: z .string() .optional() .describe( - "Filter the list of commissions by the associated partner tag. " + - "Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`). " + - "Examples: `ptag_abc`, `ptag_abc,ptag_xyz`, `-ptag_abc`.", + [ + "Filter the list of commissions by the associated partner tag.", + "Supports advanced filtering: single value, multiple values (comma-separated), or exclusion (prefix with `-`).", + "Examples:", + `- "ptag_abc"`, + `- "ptag_abc,ptag_xyz"`, + `- "-ptag_abc"`, + ].join("\n"), ), invoiceId: z .string() @@ -174,6 +228,18 @@ export const getCommissionsQuerySchema = z .optional() .describe("The end date of the date range to filter the commissions by."), timezone: z.string().optional(), + query: z + .string() + .max(10000) + .optional() + .meta({ + description: [ + "Filter by lead or sale event metadata. Top-level keys only. Compares string values only — numeric and boolean metadata values are not matched.", + "Examples:", + `- "metadata['key']='value'"`, + `- "metadata['key']!='value'"`, + ].join("\n"), + }), }) .extend({ ...getCursorPaginationQuerySchema({ @@ -318,15 +384,6 @@ export const CLAWBACK_REASONS_MAP = Object.fromEntries( CLAWBACK_REASONS.map((r) => [r.value, r]), ); -export const createClawbackSchema = z.object({ - workspaceId: z.string(), - partnerId: z.string(), - amount: z.number().gt(0, "Amount must be greater than 0."), - description: z.enum( - CLAWBACK_REASONS.map((r) => r.value) as [string, ...string[]], - ), -}); - export const COMMISSION_EXPORT_COLUMNS = [ { id: "id", label: "ID", type: "string", default: true }, { id: "type", label: "Type", type: "string", default: true }, @@ -335,6 +392,7 @@ export const COMMISSION_EXPORT_COLUMNS = [ { id: "currency", label: "Currency", type: "string", default: true }, { id: "status", label: "Status", type: "string", default: true }, { id: "invoiceId", label: "Invoice ID", type: "string", default: true }, + { id: "description", label: "Description", type: "string", default: false }, { id: "quantity", label: "Quantity", type: "number", default: true }, { id: "createdAt", label: "Created at", type: "date", default: true }, { id: "paidAt", label: "Paid at", type: "date", default: false }, @@ -435,6 +493,7 @@ export const createPartnerCommissionSchema = z.object({ createdAt: z.coerce.date().optional(), status: commissionPatchStatusSchema.optional(), // used for create-manual-commission (import commission as refunded) userId: z.string().optional(), + metadata: z.record(z.string(), z.any()).nullish(), context: rewardContextSchema.optional(), skipWorkflow: z.boolean().default(false).optional(), isFirstConversion: z.boolean().optional(), @@ -459,128 +518,243 @@ export const createPartnerCommissionSchema = z.object({ ), }); -export const createManualCommissionBodySchema = z - .discriminatedUnion("type", [ - // Custom commission - z.object({ - type: z.literal("custom"), - partnerId: z - .string() - .describe("The ID of the partner to create the commission for."), - amount: centsSchema - .pipe(z.number().min(1)) - .describe("The commission amount in cents."), - date: parseDateSchema - .nullish() - .describe("If not provided, the current date will be used."), - description: z - .string() - .max(190) - .nullish() - .describe("The description of the commission."), - }), +// Custom commission (negative amount = clawback) +const createCustomCommissionSchema = z.object({ + type: z.literal("custom"), + partnerId: z + .string() + .describe("The ID of the partner to create the commission for."), + amount: centsSchema + .pipe( + z.number().refine((n) => n !== 0, { + message: "Amount cannot be 0.", + }), + ) + .describe( + "The commission earnings amount in cents. Use a negative amount to create a clawback.", + ), + date: parseDateSchema + .nullish() + .describe("If not provided, the current date will be used."), + description: z + .string() + .max(190) + .nullish() + .describe( + [ + "The description of the commission. Required for clawbacks (negative `amount`).", + "May be a known clawback reason (`order_canceled`, `fraud`, `terms_violation`, `tracking_error`, `payment_failed`, `ineligible_partner`, `duplicate_commission`) or an arbitrary string (max 190 characters).", + ].join(" "), + ), +}); - // Lead commission - z.object({ - type: z.literal("lead"), - partnerId: z - .string() - .describe("The ID of the partner to create the commission for."), - customerId: z - .string() - .nullish() - .describe( - "The customer ID to associate the commission with. Useful if the customer was already created in a prior operation and you want to associate the commission with it.", - ), - customer: createCustomerBodySchema - .nullish() - .describe( - "The full customer object to associate the commission with. Useful for creating the customer on demand.", - ), - linkId: z - .string() - .nullish() - .describe( - "The partner link ID to associate the commission with. If not provided, default to the link with the most revenue.", - ), - leadEventDate: parseDateSchema - .nullish() - .describe( - "The date and time of the lead event. If not provided, defaults to the current date and time.", - ), - leadEventName: z - .string() +const createLeadCommissionSchema = z.object({ + type: z.literal("lead"), + partnerId: z + .string() + .describe("The ID of the partner to create the commission for."), + customerId: z + .string() + .nullish() + .describe( + "The customer ID to associate the commission with. Useful if the customer was already created in a prior operation and you want to associate the commission with it.", + ), + customer: createCustomerBodySchema + .nullish() + .describe( + "The full customer object to associate the commission with. Useful for creating the customer on demand.", + ), + linkId: z + .string() + .nullish() + .describe( + "The partner link ID to associate the commission with. If not provided, default to the link with the most revenue.", + ), + date: parseDateSchema + .nullish() + .describe( + "The date and time of the lead event. If not provided, defaults to the current date and time.", + ), + lead: z + .object({ + eventName: trackLeadRequestSchema.shape.eventName .nullish() - .default("Sign up") .describe( - "The name of the lead event. If not provided, defaults to 'Sign up'.", + "The name of the lead event to track. If not provided, defaults to 'Sign up'.", ), - }), + metadata: trackLeadRequestSchema.shape.metadata, + }) + .nullish() + .describe("The lead event object to associate the commission with."), - // Sale commission - z.object({ - type: z.literal("sale"), - partnerId: z - .string() - .describe("The ID of the partner to create the commission for."), - customerId: z - .string() - .nullish() - .describe( - "The customer ID to associate the commission with. Useful if the customer was already created in a prior operation and you want to associate the commission with it.", - ), - customer: createCustomerBodySchema - .nullish() - .describe( - "The full customer object to associate the commission with. Useful for creating the customer on demand.", - ), - linkId: z - .string() - .nullish() - .describe( - "The partner link ID to associate the commission with. If not provided, default to the link with the most revenue.", - ), - importStripeInvoices: z - .boolean() - .nullish() - .default(false) - .describe( - "When `true`, import all unimported paid Stripe invoices for the customer and create a commission for each. When `false`, create a single manual sale event using `saleAmount`.", - ), - saleAmount: centsSchema - .pipe(z.number().min(0)) - .nullish() - .describe( - "Required when `importStripeInvoices` is `false`. The sale amount in cents for the manual sale event. Ignored when importing from Stripe.", - ), - saleEventDate: parseDateSchema - .nullish() - .describe( - "Only used when `importStripeInvoices` is `false`. The date of the manual sale event. Defaults to the current date and time if not provided.", - ), - invoiceId: z - .string() - .nullish() - .describe( - "Only used when `importStripeInvoices` is `false`. An optional invoice ID to attach to the generated sale event and commission entry for deduplication.", - ), - productId: z - .string() - .nullish() - .describe( - "Only used when `importStripeInvoices` is `false`. An optional product ID stored on the sale event metadata – will also impact commission earnings calculation (if a `Sale` `Product ID` modifier is set).", - ), - }), - ]) + // Deprecated fields + leadEventDate: parseDateSchema + .nullish() + .describe( + "Deprecated: Use `date` instead. The date and time of the lead event. If not provided, defaults to the current date and time.", + ) + .meta({ deprecated: true }), + leadEventName: z + .string() + .nullish() + .default("Sign up") + .describe( + "Deprecated: Use `lead.eventName` instead. The name of the lead event. If not provided, defaults to 'Sign up'.", + ) + .meta({ deprecated: true }), +}); + +const createSaleCommissionSchema = z + .object({ + type: z.literal("sale"), + partnerId: z + .string() + .describe("The ID of the partner to create the commission for."), + customerId: z + .string() + .nullish() + .describe( + "The customer ID to associate the commission with. Useful if the customer was already created in a prior operation and you want to associate the commission with it.", + ), + customer: createCustomerBodySchema + .nullish() + .describe( + "The full customer object to associate the commission with. Useful for creating the customer on demand.", + ), + linkId: z + .string() + .nullish() + .describe( + "The partner link ID to associate the commission with. If neither `linkId` nor `discountCode` is provided, default to the link with the most revenue.", + ), + discountCode: z + .string() + .min(1) + .nullish() + .describe( + "The partner discount code to resolve the associated link. Use this when the link ID is unknown. Cannot be provided together with `linkId`.", + ), + importStripeInvoices: z + .boolean() + .nullish() + .default(false) + .describe( + "When `true`, import all unimported paid Stripe invoices for the customer and create a commission for each. When `false`, create a single manual sale event using `sale.amount` (or deprecated `saleAmount`).", + ), + date: parseDateSchema + .nullish() + .describe( + "Only used when `importStripeInvoices` is `false`. The date of the manual sale event. Defaults to the current date and time if not provided.", + ), + sale: z + .object({ + amount: centsSchema + .pipe(z.number().int().min(0)) + .nullish() + .describe( + "The amount of the sale in cents (for all two-decimal currencies). If the sale is in a zero-decimal currency, pass the full integer value (e.g. `1580` JPY). Learn more: https://d.to/currency", + ), + currency: trackSaleRequestSchema.shape.currency, + eventName: trackSaleRequestSchema.shape.eventName, + paymentProcessor: trackSaleRequestSchema.shape.paymentProcessor, + invoiceId: trackSaleRequestSchema.shape.invoiceId, + metadata: trackSaleRequestSchema.shape.metadata, + }) + .nullish() + .describe("The sale event object to associate the commission with."), + + // Deprecated fields + saleEventDate: parseDateSchema + .nullish() + .describe("Deprecated: Use `date` instead.") + .meta({ deprecated: true }), + saleAmount: centsSchema + .pipe(z.number().min(0)) + .nullish() + .describe("Deprecated: Use `sale.amount` instead.") + .meta({ deprecated: true }), + invoiceId: z + .string() + .nullish() + .describe("Deprecated: Use `sale.invoiceId` instead.") + .meta({ deprecated: true }), + productId: z + .string() + .nullish() + .describe("Deprecated: Use `sale.metadata.productId` instead.") + .meta({ deprecated: true }), + }) .superRefine((data, ctx) => { - if (data.type !== "sale") return; + if (data.importStripeInvoices) { + const conflicts = [ + data.sale != null && "sale", + data.date != null && "date", + data.saleAmount != null && "saleAmount", + data.saleEventDate != null && "saleEventDate", + (data.invoiceId != null || data.sale?.invoiceId != null) && "invoiceId", + (data.productId != null || data.sale?.metadata?.productId != null) && + "productId", + ].filter((field): field is string => Boolean(field)); + + if (conflicts.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${conflicts.map((field) => `\`${field}\``).join(", ")} cannot be provided when \`importStripeInvoices\` is enabled.`, + path: [conflicts[0]], + }); + } + return; + } + + const saleAmount = data.sale?.amount ?? data.saleAmount; - if (!data.importStripeInvoices && data.saleAmount == null) { + if (saleAmount == null) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: - "`saleAmount` is required when `importStripeInvoices` is false.", - path: ["saleAmount"], + "`sale.amount` or `saleAmount` is required when `importStripeInvoices` is false.", + path: data.sale ? ["sale", "amount"] : ["saleAmount"], + }); + return; + } + + if (saleAmount === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Sale amount cannot be 0.", + path: data.sale?.amount != null ? ["sale", "amount"] : ["saleAmount"], + }); + } + }); + +export const createManualCommissionBodySchema = z + .discriminatedUnion("type", [ + createCustomCommissionSchema, + createLeadCommissionSchema, + createSaleCommissionSchema, + ]) + .superRefine((data, ctx) => { + if (data.type === "custom") { + if (data.amount < 0 && !data.description?.trim()) { + ctx.addIssue({ + code: "custom", + message: + "`description` is required when creating a clawback (negative amount).", + path: ["description"], + }); + } + return; + } + + if ( + data.type === "sale" && + data.linkId != null && + data.discountCode != null + ) { + ctx.addIssue({ + code: "custom", + message: "Either `linkId` or `discountCode` may be provided, not both.", + path: ["discountCode"], }); } }); diff --git a/apps/web/lib/zod/schemas/discount.ts b/apps/web/lib/zod/schemas/discount.ts index e291a670358..a08462ac03a 100644 --- a/apps/web/lib/zod/schemas/discount.ts +++ b/apps/web/lib/zod/schemas/discount.ts @@ -33,7 +33,7 @@ export const createDiscountSchema = z.object({ amount: z.number().min(0), type: z.enum(RewardStructure).default("flat"), maxDuration: maxDurationSchema, - couponId: z.string(), + couponId: z.string().optional(), couponTestId: z.string().nullish(), groupId: z.string(), autoProvision: z.boolean().optional(), @@ -56,19 +56,41 @@ export const discountPartnersQuerySchema = z }) .extend(getPaginationQuerySchema({ pageSize: 25 })); -export const DiscountCodeSchema = z.object({ - id: z.string(), - code: z.string(), - discountId: z.string().nullable(), - partnerId: z.string(), - linkId: z.string(), - disabledAt: z.coerce - .date() - .nullish() - .describe( - "When this discount code was disabled, which happens when a partner is banned or deactivated.", - ), -}); +export const DiscountCodeSchema = z + .object({ + id: z.string().describe("The unique ID of the discount code.").meta({ + example: "dcode_1JVR7XRCSR0EDBAF39FZ4PMYE", + }), + code: z + .string() + .describe( + "The alphanumeric discount code that customers can apply at checkout.", + ) + .meta({ + example: "PARTNER10OFF", + }), + discountId: z + .string() + .nullable() + .describe("The ID of the discount this code belongs to."), + partnerId: z + .string() + .describe("The ID of the partner this discount code is assigned to."), + linkId: z + .string() + .describe( + "The ID of the partner's referral link this discount code is associated with.", + ), + disabledAt: z.coerce + .date() + .nullish() + .describe( + "When this discount code was disabled, which happens when a partner is banned or deactivated. We don't delete the discount code to avoid another partner claiming a banned/deactivated code (abuse vector).", + ), + }) + .meta({ + title: "DiscountCode", + }); export const createDiscountCodeSchema = z.object({ code: z @@ -80,11 +102,43 @@ export const createDiscountCodeSchema = z.object({ "Code can only contain letters, numbers, dashes, and underscores.", ) .optional() - .or(z.literal("").transform(() => undefined)), - partnerId: z.string(), - linkId: z.string(), + .describe( + "The discount code to create. If omitted, a unique code will be generated automatically from the partner's name.", + ), + partnerId: z + .string() + .describe("The ID of the partner to create a discount code for."), + linkId: z + .string() + .describe( + "The ID of the partner's referral link to associate this discount code with. Each link can only have one discount code.", + ), }); -export const getDiscountCodesQuerySchema = z.object({ - partnerId: z.string(), +export const getDiscountCodesQuerySchema = z + .object({ + partnerId: z + .string() + .optional() + .describe( + "The ID of the partner to retrieve discount codes for. If omitted, returns discount codes for the whole program.", + ), + discountId: z + .string() + .optional() + .describe("Filter discount codes by discount ID."), + }) + .extend(getPaginationQuerySchema({ pageSize: 100 })); + +// Schema for the discount code webhook +export const DiscountCodeWebhookSchema = DiscountCodeSchema.omit({ + discountId: true, +}).extend({ + discount: DiscountSchema.pick({ + id: true, + amount: true, + type: true, + maxDuration: true, + provider: true, + }).nullable(), }); diff --git a/apps/web/lib/zod/schemas/images.ts b/apps/web/lib/zod/schemas/images.ts index 1fb7dc433ac..2651ad4917b 100644 --- a/apps/web/lib/zod/schemas/images.ts +++ b/apps/web/lib/zod/schemas/images.ts @@ -2,24 +2,33 @@ import { GOOGLE_FAVICON_URL, R2_URL } from "@dub/utils"; import { fileTypeFromBuffer } from "file-type"; import * as z from "zod/v4"; -/** Raster data-URL prefix for link preview images (base64ImageSchema, preprocess, metatags). */ -export const linkPreviewImageBase64PrefixRegex = - /^data:image\/(png|jpeg|jpg|gif|webp);base64,/i; - const allowedImageTypes = [ "image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp", + "image/avif", ]; +const allowedImageFormats = allowedImageTypes + .map((type) => type.replace("image/", "")) + .join(", "); + +export const invalidImageFormatMessage = `Invalid image format, supports only ${allowedImageFormats}.`; + +/** Raster data-URL prefix for link preview images (base64ImageSchema, preprocess, metatags). */ +export const linkPreviewImageBase64PrefixRegex = new RegExp( + `^data:image/(${allowedImageTypes.map((type) => type.replace("image/", "")).join("|")});base64,`, + "i", +); + // Base64 encoded image export const base64ImageSchema = z .string() .trim() .regex(linkPreviewImageBase64PrefixRegex, { - message: "Invalid image format, supports only png, jpeg, jpg, gif, webp.", + message: invalidImageFormatMessage, }) .refine( async (str) => { @@ -39,7 +48,7 @@ export const base64ImageSchema = z } }, { - message: "Invalid image format, supports only png, jpeg, jpg, gif, webp.", + message: invalidImageFormatMessage, }, ) .transform((v) => v || null); diff --git a/apps/web/lib/zod/schemas/import-error-log.ts b/apps/web/lib/zod/schemas/import-error-log.ts index bde1e972d2a..f215e4107f6 100644 --- a/apps/web/lib/zod/schemas/import-error-log.ts +++ b/apps/web/lib/zod/schemas/import-error-log.ts @@ -9,6 +9,7 @@ export const importErrorLogSchema = z.object({ "partnerstack", "firstpromoter", "tapfiliate", + "lemonsqueezy", ]), entity: z.enum(["partner", "link", "customer", "commission"]), entity_id: z.string(), diff --git a/apps/web/lib/zod/schemas/integration.ts b/apps/web/lib/zod/schemas/integration.ts index 0d608750465..954296e184a 100644 --- a/apps/web/lib/zod/schemas/integration.ts +++ b/apps/web/lib/zod/schemas/integration.ts @@ -52,13 +52,13 @@ export const createIntegrationSchema = z.object({ description: z .string() .max(120, { - message: "must be less than 120 characters", + message: "must be 120 characters or fewer", }) .nullish(), readme: z .string() - .max(1000, { - message: "must be less than 1000 characters", + .max(5000, { + message: "must be 5000 characters or fewer", }) .nullish(), screenshots: z diff --git a/apps/web/lib/zod/schemas/leads.ts b/apps/web/lib/zod/schemas/leads.ts index 2d1abdd75a5..3a390d15d15 100644 --- a/apps/web/lib/zod/schemas/leads.ts +++ b/apps/web/lib/zod/schemas/leads.ts @@ -3,6 +3,7 @@ import { clickEventSchema, clickEventSchemaTB } from "./clicks"; import { CustomerSchema } from "./customers"; import { commonDeprecatedEventFields } from "./deprecated"; import { linkEventSchema, LinkSchema } from "./links"; +import { metadataSchema } from "./misc"; export const trackLeadRequestSchema = z.object({ clickId: z @@ -62,16 +63,9 @@ export const trackLeadRequestSchema = z.object({ .describe( "The numerical value associated with this lead event (e.g., number of provisioned seats in a free trial). If defined as N, the lead event will be tracked N times.", ), - metadata: z - .record(z.string(), z.any()) - .nullish() - .default(null) - .refine((val) => !val || JSON.stringify(val).length <= 10000, { - message: "Metadata must be less than 10,000 characters when stringified", - }) - .describe( - "Additional metadata to be stored with the lead event. Max 10,000 characters.", - ), + metadata: metadataSchema.describe( + "Additional metadata to be stored with the lead event. Max 10,000 characters.", + ), }); export const trackLeadResponseSchema = z.object({ diff --git a/apps/web/lib/zod/schemas/misc.ts b/apps/web/lib/zod/schemas/misc.ts index 8bde58e965e..a6f583a2dbf 100644 --- a/apps/web/lib/zod/schemas/misc.ts +++ b/apps/web/lib/zod/schemas/misc.ts @@ -93,3 +93,12 @@ export const getCursorPaginationQuerySchema = ({ example, }), }); + +export const metadataSchema = z + .record(z.string(), z.any()) + .nullish() + .default(null) + .transform((val) => (val != null && Object.keys(val).length > 0 ? val : null)) + .refine((val) => !val || JSON.stringify(val).length <= 10000, { + message: "Metadata must be less than 10,000 characters when stringified", + }); diff --git a/apps/web/lib/zod/schemas/opens.ts b/apps/web/lib/zod/schemas/opens.ts index 96f2637bb68..e8f539b7208 100644 --- a/apps/web/lib/zod/schemas/opens.ts +++ b/apps/web/lib/zod/schemas/opens.ts @@ -18,7 +18,7 @@ export const trackOpenRequestSchema = z .superRefine((data, ctx) => { if (!data.deepLink && !data.dubDomain) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: "custom", message: "You need to provide either `deepLink` or `dubDomain` for deferred deep linking.", }); diff --git a/apps/web/lib/zod/schemas/partner-profile.ts b/apps/web/lib/zod/schemas/partner-profile.ts index f8dee55ddc7..a2c7401adef 100644 --- a/apps/web/lib/zod/schemas/partner-profile.ts +++ b/apps/web/lib/zod/schemas/partner-profile.ts @@ -32,6 +32,7 @@ import { centsSchema } from "./utils"; export const PartnerEarningsSchema = CommissionSchema.omit({ userId: true, invoiceId: true, + metadata: true, }).extend({ customer: z .object({ @@ -51,6 +52,7 @@ export const getPartnerEarningsQuerySchema = getCommissionsQuerySchema .omit({ partnerId: true, sortBy: true, + query: true, }) .extend({ interval: z @@ -66,6 +68,7 @@ export const getPartnerEarningsQuerySchema = getCommissionsQuerySchema export const getPartnerEarningsCountQuerySchema = getCommissionsCountQuerySchema .omit({ partnerId: true, + query: true, }) .extend({ interval: z @@ -105,6 +108,7 @@ export const PartnerProfileCustomerSchema = CustomerEnrichedSchema.pick({ email: true, country: true, createdAt: true, + saleAmount: true, firstSaleAt: true, subscriptionCanceledAt: true, }).extend({ @@ -263,7 +267,12 @@ export const getPartnerCustomersQuerySchema = z "A filter on the list based on the customer's `linkId` field (the referral link ID).", ), sortBy: z - .enum(["createdAt", "firstSaleAt", "subscriptionCanceledAt"]) + .enum([ + "createdAt", + "saleAmount", + "firstSaleAt", + "subscriptionCanceledAt", + ]) .optional() .default("createdAt") .describe( diff --git a/apps/web/lib/zod/schemas/partners.ts b/apps/web/lib/zod/schemas/partners.ts index 9096fc14533..c120335218a 100644 --- a/apps/web/lib/zod/schemas/partners.ts +++ b/apps/web/lib/zod/schemas/partners.ts @@ -188,14 +188,14 @@ export const getPartnersQuerySchema = z .string() .optional() .describe( - "Filter the partner list based on the partner's `tenantId`. The value must be a string. Takes precedence over `email` and `search`.", + "Filter the partner list based on the partner's `tenantId`. The value must be a string. Combines with the other filters.", ) .meta({ example: "1K0NM7HCN944PEMZ3CQPH43H8" }), search: z .string() .optional() .describe( - "A search query to filter partners by ID, name, email, or company name.", + "A search query to filter partners by ID, name, email, company name, description, social platforms, or referral links. Partial matches are supported.", ) .meta({ example: "john" }), }) @@ -203,6 +203,7 @@ export const getPartnersQuerySchema = z // Only Dub UI uses the following query parameters export const getPartnersQuerySchemaExtended = getPartnersQuerySchema.extend({ + sortBy: getPartnersQuerySchema.shape.sortBy.or(z.literal("relevance")), status: z.enum(ProgramEnrollmentStatus).optional(), // TODO: refactor to use multi/negative filtering syntax partnerIds: z @@ -280,9 +281,50 @@ export const getPartnersQuerySchemaExtended = getPartnersQuerySchema.extend({ .describe("Maximum total commissions (inclusive) in USD cents."), }); +/** + * Parse schema for `GET /api/partners`. Kept here rather than inline in the + * route so the relevance guards below are testable. + */ +export const getPartnersRouteQuerySchema = getPartnersQuerySchemaExtended + .extend({ + // Also accept the sort values these columns were named before, which the + // route maps onto the current ones (`clicks` → `totalClicks`, and so on). + sortBy: getPartnersQuerySchemaExtended.shape.sortBy.or( + z.enum([ + "clicks", + "leads", + "conversions", + "sales", + "saleAmount", + "totalSales", + ]), + ), + }) + // Relevance ordering only exists when the search provider produced candidates, + // and `email` and `tenantId` both keep the query on the database path. Without + // this, getPartners quietly orders those results by totalSaleAmount instead. + .refine( + ({ sortBy, search, email, tenantId }) => + sortBy !== "relevance" || + (Boolean(search?.trim()) && !email && !tenantId), + { + message: + "sortBy=relevance requires a non-empty search, and cannot be combined with email or tenantId.", + path: ["sortBy"], + }, + ) + .refine( + ({ sortBy, sortOrder }) => sortBy !== "relevance" || sortOrder !== "asc", + { + message: "sortBy=relevance does not support sortOrder=asc.", + path: ["sortOrder"], + }, + ); + export const partnersExportQuerySchema = getPartnersQuerySchemaExtended - .omit({ page: true, pageSize: true }) + .omit({ page: true, pageSize: true, search: true }) .extend({ + sortBy: getPartnersQuerySchema.shape.sortBy, columns: z .string() .default(exportPartnersColumnsDefault.join(",")) @@ -801,7 +843,7 @@ export const createPartnerLinkSchema = partnerIdTenantIdSchema .extend({ url: parseUrlSchema .describe( - "The URL to shorten (if not provided, the program's default URL will be used). Will throw an error if the domain doesn't match the program's default URL domain.", + "The URL to shorten (if not provided, the program's default URL will be used).", ) .nullish(), key: z @@ -820,9 +862,7 @@ export const createPartnerLinkSchema = partnerIdTenantIdSchema ); export const upsertPartnerLinkSchema = createPartnerLinkSchema.extend({ - url: parseUrlSchema.describe( - "The URL to upsert for. Will throw an error if the domain doesn't match the program's default URL domain.", - ), + url: parseUrlSchema.describe("The URL to upsert for."), }); // For /api/partners/analytics @@ -1052,7 +1092,7 @@ export const partnerPayoutSettingsSchema = z.object({ taxId: z.string().max(100).trim().nullish(), }); -export const partnerCrossProgramSummarySchema = z.object({ +export const partnerNetworkActivitySummarySchema = z.object({ totalPrograms: z.number(), activePrograms: z.number(), bannedPrograms: z.number(), @@ -1070,3 +1110,30 @@ export const partnerSharedPlatformSchema = z.object({ }), ), }); + +const partnerMergedAccountSchema = z.object({ + id: z.string().describe("The partner's unique ID on Dub."), + tenantId: z + .string() + .nullable() + .describe("The partner's unique ID in your system"), + email: z.string().nullable().describe("The partner's email address."), +}); + +export const partnerMergedWebhookSchema = z.object({ + sourcePartner: partnerMergedAccountSchema.describe( + "The source partner account that was merged away. Its enrollment in this program no longer exists; use `targetPartner.id` instead.", + ), + targetPartner: partnerMergedAccountSchema.describe( + "The target partner account that the source account was merged into.", + ), + targetAlreadyEnrolled: z + .boolean() + .describe( + [ + "Whether the target partner account was already enrolled in this program before the merge.", + "If `true`, both partners were already enrolled in the program and the merge process will collapse the source account into the target account.", + "If `false`, only the source partner account was enrolled in the program, which means the partner's ID in your program will be updated to the target partner's ID.", + ].join("\n"), + ), +}); diff --git a/apps/web/lib/zod/schemas/rewards.ts b/apps/web/lib/zod/schemas/rewards.ts index e72edba86df..4fdaf662b97 100644 --- a/apps/web/lib/zod/schemas/rewards.ts +++ b/apps/web/lib/zod/schemas/rewards.ts @@ -540,7 +540,12 @@ export const rewardContextSchema = z.object({ sale: z .object({ - productId: z.string().nullish(), + // Non-string productIds (e.g. from sale.metadata) are dropped so reward + // conditions only match string product IDs. + productId: z.preprocess( + (val) => (typeof val === "string" ? val : undefined), + z.string().nullish(), + ), amount: z.number().nullish(), type: z.enum(["new", "recurring"]).nullish(), metadata: z.record(z.string(), z.unknown()).optional(), diff --git a/apps/web/lib/zod/schemas/sales.ts b/apps/web/lib/zod/schemas/sales.ts index c54f2add0c4..30ff3391f51 100644 --- a/apps/web/lib/zod/schemas/sales.ts +++ b/apps/web/lib/zod/schemas/sales.ts @@ -3,6 +3,7 @@ import { clickEventSchema, clickEventSchemaTB } from "./clicks"; import { CustomerSchema } from "./customers"; import { commonDeprecatedEventFields } from "./deprecated"; import { linkEventSchema } from "./links"; +import { metadataSchema } from "./misc"; import { centsSchema } from "./utils"; export const trackSaleRequestSchema = z.object({ @@ -45,6 +46,7 @@ export const trackSaleRequestSchema = z.object({ "paddle", "apple", "revenuecat", + "lemonsqueezy", "dub", "custom", ]) @@ -57,16 +59,9 @@ export const trackSaleRequestSchema = z.object({ .describe( "The invoice ID of the sale. Can be used as a idempotency key – only one sale event can be recorded for a given invoice ID.", ), - metadata: z - .record(z.string(), z.any()) - .nullish() - .default(null) - .refine((val) => !val || JSON.stringify(val).length <= 10000, { - message: "Metadata must be less than 10,000 characters when stringified", - }) - .describe( - "Additional metadata to be stored with the sale event. Max 10,000 characters when stringified.", - ), + metadata: metadataSchema.describe( + "Additional metadata to be stored with the sale event. Max 10,000 characters when stringified.", + ), // advanced fields: leadEventName + fields for sale tracking without a lead event leadEventName: z .string() diff --git a/apps/web/lib/zod/schemas/submitted-lead-form.ts b/apps/web/lib/zod/schemas/submitted-lead-form.ts index fef682bc803..00db9f3ed16 100644 --- a/apps/web/lib/zod/schemas/submitted-lead-form.ts +++ b/apps/web/lib/zod/schemas/submitted-lead-form.ts @@ -29,7 +29,7 @@ export const textFieldSchema = fieldCommonSchema.extend({ type: z.literal("text"), constraints: z .object({ - maxLength: z.number().int().positive().optional(), + maxLength: z.number().int().min(1).optional(), pattern: z.string().optional(), }) .optional(), @@ -40,7 +40,7 @@ export const textareaFieldSchema = fieldCommonSchema.extend({ type: z.literal("textarea"), constraints: z .object({ - maxLength: z.number().int().positive().optional(), + maxLength: z.number().int().min(1).optional(), }) .optional(), }); @@ -100,7 +100,7 @@ export const formFieldsSchema = z ctx.addIssue({ path: ["fields"], message: `Duplicate field key: ${field.key}`, - code: z.ZodIssueCode.custom, + code: "custom", }); } @@ -108,7 +108,7 @@ export const formFieldsSchema = z ctx.addIssue({ path: ["fields"], message: `Duplicate field position: ${field.position}`, - code: z.ZodIssueCode.custom, + code: "custom", }); } diff --git a/apps/web/lib/zod/schemas/tags.ts b/apps/web/lib/zod/schemas/tags.ts index 2f4116a17f2..cc9fc26ece1 100644 --- a/apps/web/lib/zod/schemas/tags.ts +++ b/apps/web/lib/zod/schemas/tags.ts @@ -69,7 +69,7 @@ export const createTagBodySchema = z .superRefine((data, ctx) => { if (!data.name && !data.tag) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: "custom", path: ["name"], message: "Name is required.", }); diff --git a/apps/web/lib/zod/schemas/token.ts b/apps/web/lib/zod/schemas/token.ts index b9e2d280256..7c6931229ac 100644 --- a/apps/web/lib/zod/schemas/token.ts +++ b/apps/web/lib/zod/schemas/token.ts @@ -51,7 +51,7 @@ export const createReferralsEmbedTokenSchema = z .superRefine((data, ctx) => { if (!data.partnerId && !data.tenantId && !data.partner) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: "custom", message: "You must provide either partnerId, tenantId, or partner.", }); } diff --git a/apps/web/lib/zod/schemas/utm.ts b/apps/web/lib/zod/schemas/utm.ts index 5dfda0513b5..aa8932a57d6 100644 --- a/apps/web/lib/zod/schemas/utm.ts +++ b/apps/web/lib/zod/schemas/utm.ts @@ -1,11 +1,18 @@ +import { + PARTNER_MACRO_VALUES, + isValidPartnerMacroTemplate, +} from "@/lib/partners/macros"; import * as z from "zod/v4"; -const UTM_TAG_MAX_LENGTH = 255; +export const UTM_TAG_MAX_LENGTH = 255; export const utmTagInputSchema = z .string() .trim() .max(UTM_TAG_MAX_LENGTH) + .refine((v) => v === "" || isValidPartnerMacroTemplate(v), { + message: `Invalid macro in value. Use only: ${PARTNER_MACRO_VALUES.join(", ")}`, + }) .transform((v) => (v === "" ? null : v)) .nullish(); diff --git a/apps/web/lib/zod/schemas/workflows.ts b/apps/web/lib/zod/schemas/workflows.ts index c6daae06265..e41a238c3a7 100644 --- a/apps/web/lib/zod/schemas/workflows.ts +++ b/apps/web/lib/zod/schemas/workflows.ts @@ -5,9 +5,6 @@ import { } from "@/lib/api/workflows/operator-definitions"; import * as z from "zod/v4"; -// Cron for scheduled workflows that use partnerEnrolledDays conditions -export const PARTNER_ENROLLED_WORKFLOW_CRON = "0 */12 * * *"; // every 12 hours - export enum WORKFLOW_ACTION_TYPES { AwardBounty = "awardBounty", SendCampaign = "sendCampaign", diff --git a/apps/web/lib/zod/schemas/workspaces.ts b/apps/web/lib/zod/schemas/workspaces.ts index f71e14f1c97..5827e3f142b 100644 --- a/apps/web/lib/zod/schemas/workspaces.ts +++ b/apps/web/lib/zod/schemas/workspaces.ts @@ -243,9 +243,11 @@ export const workspaceStoreKeys = z.enum([ "analyticsSettingsConversionTrackingEnabled", // boolean "analyticsSettingsSiteVisitTrackingEnabled", // boolean "analyticsSettingsOutboundDomainTrackingEnabled", // boolean + "analyticsSettingsSelectedStack", // string[] "analyticsSettingsConnectionSetupComplete", // boolean "analyticsSettingsLeadTrackingSetupComplete", // boolean "analyticsSettingsSaleTrackingSetupComplete", // boolean + "analyticsSettingsInstallationVerified", // { hostname, verifiedAt, user } ]); export const getWorkspaceUsersQuerySchema = z.object({ diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index 952f48eb820..622f95408f1 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -3,11 +3,10 @@ import { transformMiddlewareRequest } from "@axiomhq/nextjs"; import { ADMIN_HOSTNAMES, API_HOSTNAMES, - APP_HOSTNAMES, DEFAULT_REDIRECTS, isValidUrl, } from "@dub/utils"; -import { PARTNERS_HOSTNAMES } from "@dub/utils/src/constants"; +import { isAppHostname, PARTNERS_HOSTNAMES } from "@dub/utils/src/constants"; import { NextFetchEvent, NextRequest, NextResponse } from "next/server"; import { AdminMiddleware } from "./lib/middleware/admin"; import { ApiMiddleware } from "./lib/middleware/api"; @@ -39,12 +38,12 @@ export default async function middleware(req: NextRequest, ev: NextFetchEvent) { logger.info(...transformMiddlewareRequest(req)); ev.waitUntil(logger.flush()); - // for App - if (APP_HOSTNAMES.has(domain)) { + // for app.dub.co + if (isAppHostname(domain)) { return AppMiddleware(req); } - // for API + // for api.dub.co if (API_HOSTNAMES.has(domain)) { return ApiMiddleware(req); } diff --git a/apps/web/package.json b/apps/web/package.json index ca16b4d5978..d6892612b26 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -54,6 +54,7 @@ "@tiptap/html": "^3.15.3", "@tiptap/starter-kit": "^3.15.3", "@tiptap/static-renderer": "^3.15.3", + "@turbopuffer/turbopuffer": "^2.8.0", "@types/base-x": "^3.0.10", "@types/bcryptjs": "^2.4.6", "@types/buffer-crc32": "0.2.0", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 52af39eddad..19ba583d254 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -88,7 +88,10 @@ export default defineConfig({ port: 8888, reuseExistingServer: true, timeout: 120_000, + // Zod 422s (and other expected API errors) log to stderr via + // handleApiError; ignore both streams so GH Actions logs stay readable. stdout: "ignore", + stderr: "ignore", } : undefined, }); diff --git a/apps/web/playwright/api/bounties/bounties.spec.ts b/apps/web/playwright/api/bounties/bounties.spec.ts index 2dee72deefe..c55961419e3 100644 --- a/apps/web/playwright/api/bounties/bounties.spec.ts +++ b/apps/web/playwright/api/bounties/bounties.spec.ts @@ -4,13 +4,9 @@ import type { BountyProps } from "@/lib/types"; import { expect } from "@playwright/test"; import { BountyStartMode, type Program } from "@prisma/client"; import { addDays, addMonths, subDays } from "date-fns"; -import { randomName } from "../../utils"; +import { apiError, randomName } from "../../utils"; import { test, type ApiClient } from "../fixtures"; -test.describe.configure({ - mode: "parallel", -}); - type BountyJson = Omit< BountyProps, "startsAt" | "endsAt" | "submissionsOpenAt" | "socialMetricsLastSyncedAt" @@ -93,39 +89,6 @@ const expectedBountyDefaults = { partnerTags: [], }; -const unprocessable = (message: string) => ({ - status: 422, - data: { - error: { - code: "unprocessable_entity", - message, - doc_url: "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, -}); - -const badRequest = (message: string) => ({ - status: 400, - data: { - error: { - code: "bad_request", - message, - doc_url: "https://dub.co/docs/api-reference/errors#bad-request", - }, - }, -}); - -const notFound = (bountyId: string) => ({ - status: 404, - data: { - error: { - code: "not_found", - message: `Bounty ${bountyId} not found.`, - doc_url: "https://dub.co/docs/api-reference/errors#not-found", - }, - }, -}); - test("POST /bounties", async ({ api, program }) => { let id: string | undefined; const body = bountyPayload(program); @@ -358,7 +321,10 @@ test("DELETE /bounties/{bountyId}", async ({ api, program }) => { expect(status).toEqual(200); expect(data).toStrictEqual({ id: created.id }); expect(await api.get(`/api/bounties/${created.id}`)).toEqual( - notFound(created.id), + apiError({ + code: "not_found", + message: `Bounty ${created.id} not found.`, + }), ); }); @@ -714,9 +680,11 @@ test("PATCH /bounties/{bountyId} – submissionFrequency requires endsAt on the maxSubmissions: 4, }), ).toEqual( - badRequest( - "`endsAt` or `endsAfterDays` is required when `submissionFrequency` is set.", - ), + apiError({ + code: "bad_request", + message: + "`endsAt` or `endsAfterDays` is required when `submissionFrequency` is set.", + }), ); } finally { await deleteBounty(api, id); @@ -738,7 +706,10 @@ test("PATCH /bounties/{bountyId} – submissionsOpenAt without endsAt is rejecte submissionsOpenAt: addDays(new Date(), 5).toISOString(), }), ).toEqual( - badRequest("`endsAt` is required when `submissionsOpenAt` is set."), + apiError({ + code: "bad_request", + message: "`endsAt` is required when `submissionsOpenAt` is set.", + }), ); } finally { await deleteBounty(api, id); @@ -758,9 +729,11 @@ test("PATCH /bounties/{bountyId} – maxSubmissions below minimum is rejected", expect( await api.patch(`/api/bounties/${id}`, { maxSubmissions: 1 }), ).toEqual( - unprocessable( - "too_small: maxSubmissions: If `maxSubmissions` is set, it must be at least 2", - ), + apiError({ + code: "unprocessable_entity", + message: + "too_small: maxSubmissions: If `maxSubmissions` is set, it must be at least 2", + }), ); } finally { await deleteBounty(api, id); @@ -780,9 +753,10 @@ test("PATCH /bounties/{bountyId} – maxSubmissions above maximum is rejected", expect( await api.patch(`/api/bounties/${id}`, { maxSubmissions: 51 }), ).toEqual( - unprocessable( - "too_big: maxSubmissions: Too big: expected number to be <=50", - ), + apiError({ + code: "unprocessable_entity", + message: "too_big: maxSubmissions: Too big: expected number to be <=50", + }), ); } finally { await deleteBounty(api, id); @@ -794,7 +768,12 @@ test("POST /bounties – invalid group IDs", async ({ api, program }) => { await api.post("/api/bounties", { ...bountyPayload(program, { groupIds: ["invalid-group-id"] }), }), - ).toEqual(badRequest("Invalid group IDs detected: invalid-group-id")); + ).toEqual( + apiError({ + code: "bad_request", + message: "Invalid group IDs detected: invalid-group-id", + }), + ); }); test("POST /bounties – invalid partner tag IDs", async ({ api, program }) => { @@ -805,7 +784,10 @@ test("POST /bounties – invalid partner tag IDs", async ({ api, program }) => { }), }), ).toEqual( - badRequest("Invalid partner tag IDs detected: invalid-partner-tag-id"), + apiError({ + code: "bad_request", + message: "Invalid partner tag IDs detected: invalid-partner-tag-id", + }), ); }); @@ -895,7 +877,10 @@ test("PATCH /bounties/{bountyId} – invalid partner tag IDs", async ({ partnerTagIds: ["invalid-partner-tag-id"], }), ).toEqual( - badRequest("Invalid partner tag IDs detected: invalid-partner-tag-id"), + apiError({ + code: "bad_request", + message: "Invalid partner tag IDs detected: invalid-partner-tag-id", + }), ); } finally { await deleteBounty(api, id); @@ -911,9 +896,11 @@ test("POST /bounties – maxSubmissions below minimum is rejected", async ({ ...bountyPayload(program, { maxSubmissions: 1 }), }), ).toEqual( - unprocessable( - "too_small: maxSubmissions: If `maxSubmissions` is set, it must be at least 2", - ), + apiError({ + code: "unprocessable_entity", + message: + "too_small: maxSubmissions: If `maxSubmissions` is set, it must be at least 2", + }), ); }); @@ -926,9 +913,10 @@ test("POST /bounties – maxSubmissions above maximum is rejected", async ({ ...bountyPayload(program, { maxSubmissions: 51 }), }), ).toEqual( - unprocessable( - "too_big: maxSubmissions: Too big: expected number to be <=50", - ), + apiError({ + code: "unprocessable_entity", + message: "too_big: maxSubmissions: Too big: expected number to be <=50", + }), ); }); @@ -948,9 +936,10 @@ test("POST /bounties – submissionFrequency without maxSubmissions is rejected" }), }), ).toEqual( - badRequest( - "`maxSubmissions` is required when `submissionFrequency` is set.", - ), + apiError({ + code: "bad_request", + message: "`maxSubmissions` is required when `submissionFrequency` is set.", + }), ); }); @@ -967,9 +956,11 @@ test("POST /bounties – submissionFrequency without endsAt is rejected", async }), }), ).toEqual( - badRequest( - "`endsAt` or `endsAfterDays` is required when `submissionFrequency` is set.", - ), + apiError({ + code: "bad_request", + message: + "`endsAt` or `endsAfterDays` is required when `submissionFrequency` is set.", + }), ); }); @@ -988,7 +979,10 @@ test("POST /bounties – submissionsOpenAt without endsAt is rejected", async ({ }), }), ).toEqual( - badRequest("`endsAt` is required when `submissionsOpenAt` is set."), + apiError({ + code: "bad_request", + message: "`endsAt` is required when `submissionsOpenAt` is set.", + }), ); }); @@ -1007,7 +1001,12 @@ test("POST /bounties – submissionsOpenAt before startsAt is rejected", async ( submissionsOpenAt: subDays(new Date(startsAt), 1).toISOString(), }), }), - ).toEqual(badRequest("`submissionsOpenAt` must be on or after `startsAt`.")); + ).toEqual( + apiError({ + code: "bad_request", + message: "`submissionsOpenAt` must be on or after `startsAt`.", + }), + ); }); test("POST /bounties – submissionsOpenAt after endsAt is rejected", async ({ @@ -1025,7 +1024,12 @@ test("POST /bounties – submissionsOpenAt after endsAt is rejected", async ({ submissionsOpenAt: addDays(new Date(endsAt), 1).toISOString(), }), }), - ).toEqual(badRequest("`submissionsOpenAt` must be on or before `endsAt`.")); + ).toEqual( + apiError({ + code: "bad_request", + message: "`submissionsOpenAt` must be on or before `endsAt`.", + }), + ); }); test("POST /bounties – relative with startsAt is rejected", async ({ @@ -1041,9 +1045,10 @@ test("POST /bounties – relative with startsAt is rejected", async ({ }), }), ).toEqual( - badRequest( - "`startsAt` is not supported when the `startMode` is `relative`.", - ), + apiError({ + code: "bad_request", + message: "`startsAt` is not supported when the `startMode` is `relative`.", + }), ); }); @@ -1061,7 +1066,10 @@ test("POST /bounties – both endsAt and endsAfterDays is rejected", async ({ }), }), ).toEqual( - badRequest("Bounties cannot have both `endsAt` and `endsAfterDays`."), + apiError({ + code: "bad_request", + message: "Bounties cannot have both `endsAt` and `endsAfterDays`.", + }), ); }); @@ -1069,18 +1077,29 @@ const unknownBountyId = "bnty_does_not_exist"; test("GET /bounties/{bountyId} – not found", async ({ api }) => { expect(await api.get(`/api/bounties/${unknownBountyId}`)).toEqual( - notFound(unknownBountyId), + apiError({ + code: "not_found", + message: `Bounty ${unknownBountyId} not found.`, + }), ); }); test("PATCH /bounties/{bountyId} – not found", async ({ api }) => { expect( await api.patch(`/api/bounties/${unknownBountyId}`, { name: "x" }), - ).toEqual(notFound(unknownBountyId)); + ).toEqual( + apiError({ + code: "not_found", + message: `Bounty ${unknownBountyId} not found.`, + }), + ); }); test("DELETE /bounties/{bountyId} – not found", async ({ api }) => { expect(await api.delete(`/api/bounties/${unknownBountyId}`)).toEqual( - notFound(unknownBountyId), + apiError({ + code: "not_found", + message: `Bounty ${unknownBountyId} not found.`, + }), ); }); diff --git a/apps/web/playwright/api/campaigns/campaigns.spec.ts b/apps/web/playwright/api/campaigns/campaigns.spec.ts new file mode 100644 index 00000000000..1dc13870860 --- /dev/null +++ b/apps/web/playwright/api/campaigns/campaigns.spec.ts @@ -0,0 +1,695 @@ +import { DEFAULT_CAMPAIGN_BODY } from "@/lib/api/campaigns/constants"; +import { EMAIL_TEMPLATE_VARIABLES } from "@/lib/zod/schemas/campaigns"; +import { expect } from "@playwright/test"; +import type { CampaignType } from "@prisma/client"; +import { apiError, randomName } from "../../utils"; +import { test, type ApiClient } from "../fixtures"; +import { + campaignContent, + createCampaign, + createPartnerTag, + defaultTransactionalTriggers, + deleteCampaign, + deletePartnerTag, + mentionBodyJson, + multipleTriggerConditions, + type CampaignJson, +} from "./helpers"; + +function defaultCampaign(type: CampaignType) { + return { + id: expect.any(String), + name: "Untitled", + subject: "", + preview: null, + from: null, + bodyJson: DEFAULT_CAMPAIGN_BODY, + type, + status: "draft", + triggerConditions: + type === "transactional" ? [...defaultTransactionalTriggers] : null, + groups: [], + partnerTags: [], + scheduledAt: null, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }; +} + +async function createDraft( + api: ApiClient, + type: CampaignType = "transactional", +) { + const { status, data } = await createCampaign(api, type); + expect(status).toEqual(201); + return data.id; +} + +test("POST /campaigns – transactional", async ({ api }) => { + let id: string | undefined; + + try { + const { status, data } = await api.post<{ id: string }>("/api/campaigns", { + type: "transactional", + }); + id = data.id; + + expect(status).toEqual(201); + expect(data).toStrictEqual({ + id: expect.any(String), + }); + + const { status: getStatus, data: campaign } = await api.get( + `/api/campaigns/${id}`, + ); + + expect(getStatus).toEqual(200); + expect(campaign).toStrictEqual(defaultCampaign("transactional")); + } finally { + await deleteCampaign(api, id); + } +}); + +test("POST /campaigns – marketing", async ({ api }) => { + let id: string | undefined; + + try { + const { status, data } = await createCampaign(api, "marketing"); + id = data.id; + + expect(status).toEqual(201); + expect(data).toStrictEqual({ + id: expect.any(String), + }); + + const { data: campaign } = await api.get( + `/api/campaigns/${id}`, + ); + + expect(campaign).toStrictEqual(defaultCampaign("marketing")); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – update transactional content", async ({ + api, + program, +}) => { + let id: string | undefined; + + try { + id = await createDraft(api); + + const triggerConditions = [ + { + attribute: "totalConversions", + operator: "gte", + value: 50, + }, + ] as const; + + const body = campaignContent({ + triggerConditions, + groupIds: [program.defaultGroupId], + }); + + const { status, data } = await api.patch( + `/api/campaigns/${id}`, + body, + ); + + expect(status).toEqual(200); + expect(data).toStrictEqual({ + ...defaultCampaign("transactional"), + id, + name: body.name, + subject: body.subject, + bodyJson: body.bodyJson, + triggerConditions, + groups: [{ id: program.defaultGroupId }], + }); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – update marketing content", async ({ + api, + program, +}) => { + let id: string | undefined; + const scheduledAt = "2026-12-01T00:00:00.000Z"; + + try { + id = await createDraft(api, "marketing"); + const body = campaignContent({ + groupIds: [program.defaultGroupId], + scheduledAt, + triggerConditions: [...multipleTriggerConditions], + }); + + const { status, data } = await api.patch( + `/api/campaigns/${id}`, + body, + ); + + expect(status).toEqual(200); + expect(data).toMatchObject({ + id, + type: "marketing", + name: body.name, + subject: body.subject, + bodyJson: body.bodyJson, + triggerConditions: null, + groups: [{ id: program.defaultGroupId }], + partnerTags: [], + }); + expect(data.scheduledAt).toEqual(scheduledAt); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – transactional ignores scheduledAt", async ({ + api, +}) => { + let id: string | undefined; + + try { + id = await createDraft(api); + + const { data } = await api.patch(`/api/campaigns/${id}`, { + scheduledAt: "2026-12-01T00:00:00.000Z", + }); + + expect(data.scheduledAt).toBeNull(); + } finally { + await deleteCampaign(api, id); + } +}); + +test("GET /campaigns/:id", async ({ api }) => { + let id: string | undefined; + + try { + id = await createDraft(api); + + const { status, data } = await api.get( + `/api/campaigns/${id}`, + ); + + expect(status).toEqual(200); + expect(data).toStrictEqual({ + ...defaultCampaign("transactional"), + id, + }); + } finally { + await deleteCampaign(api, id); + } +}); + +test("GET /campaigns – list by search", async ({ api }) => { + let id: string | undefined; + const name = randomName("campaign"); + + try { + id = await createDraft(api); + await api.patch(`/api/campaigns/${id}`, { name }); + + const { status, data: campaigns } = await api.get( + `/api/campaigns?search=${encodeURIComponent(name)}`, + ); + + expect(status).toEqual(200); + + const { data: fetched } = await api.get( + `/api/campaigns/${id}`, + ); + + expect(campaigns.find((campaign) => campaign.id === id)).toStrictEqual( + fetched, + ); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – transactional status draft → active → paused → active", async ({ + api, +}) => { + let id: string | undefined; + + try { + id = await createDraft(api); + + const published = await api.patch(`/api/campaigns/${id}`, { + status: "active", + }); + expect(published.status).toEqual(200); + expect(published.data.status).toEqual("active"); + + const paused = await api.patch(`/api/campaigns/${id}`, { + status: "paused", + }); + expect(paused.status).toEqual(200); + expect(paused.data.status).toEqual("paused"); + + const resumed = await api.patch(`/api/campaigns/${id}`, { + status: "active", + }); + expect(resumed.status).toEqual(200); + expect(resumed.data.status).toEqual("active"); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – marketing status draft → scheduled → canceled", async ({ + api, +}) => { + let id: string | undefined; + const scheduledAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); + + try { + id = await createDraft(api, "marketing"); + + const scheduled = await api.patch(`/api/campaigns/${id}`, { + status: "scheduled", + scheduledAt, + }); + expect(scheduled.status).toEqual(200); + expect(scheduled.data.status).toEqual("scheduled"); + + const canceled = await api.patch(`/api/campaigns/${id}`, { + status: "canceled", + }); + expect(canceled.status).toEqual(200); + expect(canceled.data.status).toEqual("canceled"); + } finally { + await deleteCampaign(api, id); + } +}); + +test("POST /campaigns/:id/duplicate – transactional", async ({ + api, + program, +}) => { + let id: string | undefined; + let duplicateId: string | undefined; + let partnerTagId: string | undefined; + + const bodyJson = mentionBodyJson(EMAIL_TEMPLATE_VARIABLES); + const triggerConditions = [...multipleTriggerConditions]; + + try { + const partnerTag = await createPartnerTag(program.id); + partnerTagId = partnerTag.id; + + const body = campaignContent({ + triggerConditions, + groupIds: [program.defaultGroupId], + partnerTagIds: [partnerTag.id], + bodyJson, + }); + + id = await createDraft(api); + await api.patch(`/api/campaigns/${id}`, body); + + const { status, data } = await api.post<{ id: string }>( + `/api/campaigns/${id}/duplicate`, + ); + duplicateId = data.id; + + expect(status).toEqual(200); + expect(data).toStrictEqual({ + id: expect.any(String), + }); + + const { data: duplicated } = await api.get( + `/api/campaigns/${duplicateId}`, + ); + + expect(duplicated).toStrictEqual({ + ...defaultCampaign("transactional"), + id: duplicateId, + name: `${body.name} (copy)`, + subject: body.subject, + bodyJson, + triggerConditions, + groups: [{ id: program.defaultGroupId }], + partnerTags: [{ id: partnerTag.id }], + status: "draft", + }); + } finally { + await deleteCampaign(api, duplicateId); + await deleteCampaign(api, id); + await deletePartnerTag(partnerTagId); + } +}); + +test("POST /campaigns/:id/duplicate – marketing", async ({ api }) => { + let id: string | undefined; + let duplicateId: string | undefined; + const body = campaignContent(); + + try { + id = await createDraft(api, "marketing"); + await api.patch(`/api/campaigns/${id}`, body); + + const { status, data } = await api.post<{ id: string }>( + `/api/campaigns/${id}/duplicate`, + ); + duplicateId = data.id; + + expect(status).toEqual(200); + + const { data: duplicated } = await api.get( + `/api/campaigns/${duplicateId}`, + ); + + expect(duplicated).toMatchObject({ + id: duplicateId, + type: "marketing", + name: `${body.name} (copy)`, + subject: body.subject, + bodyJson: body.bodyJson, + triggerConditions: null, + partnerTags: [], + status: "draft", + }); + } finally { + await deleteCampaign(api, duplicateId); + await deleteCampaign(api, id); + } +}); + +test("DELETE /campaigns/:id", async ({ api }) => { + const id = await createDraft(api); + + const { status, data } = await api.delete<{ id: string }>( + `/api/campaigns/${id}`, + ); + + expect(status).toEqual(200); + expect(data).toStrictEqual({ id }); + expect(await api.get(`/api/campaigns/${id}`)).toEqual( + apiError({ + code: "not_found", + message: "Campaign not found.", + }), + ); +}); + +const errorCases = [ + { + name: "POST /campaigns – missing type", + body: {}, + expected: apiError({ + code: "unprocessable_entity", + message: + 'invalid_value: type: Invalid option: expected one of "marketing"|"transactional"', + }), + }, + { + name: "POST /campaigns – invalid type", + body: { type: "invalid" }, + expected: apiError({ + code: "unprocessable_entity", + message: + 'invalid_value: type: Invalid option: expected one of "marketing"|"transactional"', + }), + }, +]; + +for (const { name, body, expected } of errorCases) { + test(name, async ({ api }) => { + expect(await api.post("/api/campaigns", body)).toEqual(expected); + }); +} + +test("GET /campaigns/:id – not found", async ({ api }) => { + expect(await api.get("/api/campaigns/cmp_does_not_exist")).toEqual( + apiError({ + code: "not_found", + message: "Campaign not found.", + }), + ); +}); + +test("PATCH /campaigns/:id – marketing draft cannot become active", async ({ + api, +}) => { + let id: string | undefined; + + try { + id = await createDraft(api, "marketing"); + expect( + await api.patch(`/api/campaigns/${id}`, { status: "active" }), + ).toEqual( + apiError({ + code: "bad_request", + message: "A draft campaign can't be moved to active.", + }), + ); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – transactional draft cannot become scheduled", async ({ + api, +}) => { + let id: string | undefined; + + try { + id = await createDraft(api); + expect( + await api.patch(`/api/campaigns/${id}`, { status: "scheduled" }), + ).toEqual( + apiError({ + code: "bad_request", + message: "A draft campaign can't be moved to scheduled.", + }), + ); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – with valid partnerTagIds", async ({ + api, + program, +}) => { + let id: string | undefined; + let partnerTagId: string | undefined; + + try { + const partnerTag = await createPartnerTag(program.id); + partnerTagId = partnerTag.id; + id = await createDraft(api); + + const { status, data } = await api.patch( + `/api/campaigns/${id}`, + { + groupIds: [program.defaultGroupId], + partnerTagIds: [partnerTag.id], + }, + ); + + expect(status).toEqual(200); + expect(data.groups).toEqual([{ id: program.defaultGroupId }]); + expect(data.partnerTags).toEqual([{ id: partnerTag.id }]); + } finally { + await deleteCampaign(api, id); + await deletePartnerTag(partnerTagId); + } +}); + +test("PATCH /campaigns/:id – clear partner tags", async ({ api, program }) => { + let id: string | undefined; + let partnerTagId: string | undefined; + + try { + const partnerTag = await createPartnerTag(program.id); + partnerTagId = partnerTag.id; + id = await createDraft(api); + + const { data: withTags } = await api.patch( + `/api/campaigns/${id}`, + { partnerTagIds: [partnerTag.id] }, + ); + expect(withTags.partnerTags).toEqual([{ id: partnerTag.id }]); + + const { status, data } = await api.patch( + `/api/campaigns/${id}`, + { partnerTagIds: null }, + ); + + expect(status).toEqual(200); + expect(data.partnerTags).toEqual([]); + } finally { + await deleteCampaign(api, id); + await deletePartnerTag(partnerTagId); + } +}); + +test("PATCH /campaigns/:id – invalid partner tag IDs", async ({ api }) => { + let id: string | undefined; + + try { + id = await createDraft(api); + expect( + await api.patch(`/api/campaigns/${id}`, { + partnerTagIds: ["invalid-partner-tag-id"], + }), + ).toEqual( + apiError({ + code: "bad_request", + message: "Invalid partner tag IDs detected: invalid-partner-tag-id", + }), + ); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – multiple trigger conditions", async ({ api }) => { + let id: string | undefined; + const triggerConditions = [...multipleTriggerConditions]; + + try { + id = await createDraft(api); + + const { status, data } = await api.patch( + `/api/campaigns/${id}`, + { triggerConditions }, + ); + + expect(status).toEqual(200); + expect(data.triggerConditions).toEqual(triggerConditions); + + const { data: fetched } = await api.get( + `/api/campaigns/${id}`, + ); + expect(fetched.triggerConditions).toEqual(triggerConditions); + + const { status: listStatus, data: campaigns } = await api.get< + CampaignJson[] + >( + `/api/campaigns?triggerConditions=${encodeURIComponent(JSON.stringify(triggerConditions))}`, + ); + + expect(listStatus).toEqual(200); + expect(campaigns.find((campaign) => campaign.id === id)).toMatchObject({ + id, + triggerConditions, + }); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – duplicate trigger condition attribute", async ({ + api, +}) => { + let id: string | undefined; + + try { + id = await createDraft(api); + expect( + await api.patch(`/api/campaigns/${id}`, { + triggerConditions: [ + { attribute: "totalConversions", operator: "gte", value: 50 }, + { attribute: "totalConversions", operator: "lte", value: 100 }, + ], + }), + ).toEqual( + apiError({ + code: "bad_request", + message: "Each activity can only be used once in the campaign logic.", + }), + ); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – exclusive trigger condition cannot mix", async ({ + api, +}) => { + let id: string | undefined; + + try { + id = await createDraft(api); + expect( + await api.patch(`/api/campaigns/${id}`, { + triggerConditions: [ + { attribute: "partnerJoined", operator: "gte", value: 0 }, + { attribute: "totalConversions", operator: "gte", value: 50 }, + ], + }), + ).toEqual( + apiError({ + code: "bad_request", + message: + 'Campaign logic with "joins the program" cannot include other conditions.', + }), + ); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – triggerConditions must be an array", async ({ + api, +}) => { + let id: string | undefined; + + try { + id = await createDraft(api); + expect( + await api.patch(`/api/campaigns/${id}`, { + triggerConditions: { + attribute: "totalConversions", + operator: "gte", + value: 50, + }, + }), + ).toEqual( + apiError({ + code: "unprocessable_entity", + message: + "invalid_type: triggerConditions: Invalid input: expected array, received object", + }), + ); + } finally { + await deleteCampaign(api, id); + } +}); + +test("PATCH /campaigns/:id – email template variables in bodyJson", async ({ + api, +}) => { + let id: string | undefined; + const bodyJson = mentionBodyJson(EMAIL_TEMPLATE_VARIABLES); + + try { + id = await createDraft(api); + + const { status, data } = await api.patch( + `/api/campaigns/${id}`, + { bodyJson }, + ); + + expect(status).toEqual(200); + expect(data.bodyJson).toEqual(bodyJson); + + const { data: fetched } = await api.get( + `/api/campaigns/${id}`, + ); + expect(fetched.bodyJson).toEqual(bodyJson); + } finally { + await deleteCampaign(api, id); + } +}); diff --git a/apps/web/playwright/api/campaigns/helpers.ts b/apps/web/playwright/api/campaigns/helpers.ts new file mode 100644 index 00000000000..385fd4ca207 --- /dev/null +++ b/apps/web/playwright/api/campaigns/helpers.ts @@ -0,0 +1,95 @@ +import { createId } from "@/lib/api/create-id"; +import { prisma } from "@/lib/prisma"; +import type { Campaign } from "@/lib/types"; +import type { CampaignType } from "@prisma/client"; +import { randomName } from "../../utils"; +import type { ApiClient } from "../fixtures"; + +export type CampaignJson = Omit< + Campaign, + "scheduledAt" | "createdAt" | "updatedAt" +> & { + scheduledAt: string | null; + createdAt: string; + updatedAt: string; +}; + +export const defaultTransactionalTriggers = [ + { + attribute: "partnerJoined", + operator: "gte", + value: 0, + }, +] as const; + +export const multipleTriggerConditions = [ + { + attribute: "totalConversions", + operator: "gte", + value: 50, + }, + { + attribute: "totalLeads", + operator: "gte", + value: 10, + }, +] as const; + +export function campaignContent(overrides: Record = {}) { + return { + name: randomName("campaign"), + subject: randomName("subject"), + bodyJson: { + type: "doc", + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "Test campaign body" }], + }, + ], + }, + ...overrides, + }; +} + +export function mentionBodyJson(ids: readonly string[]) { + return { + type: "doc", + content: [ + { + type: "paragraph", + content: ids.map((id) => ({ + type: "mention", + attrs: { id }, + })), + }, + ], + }; +} + +export async function createCampaign( + api: ApiClient, + type: CampaignType = "transactional", +) { + return api.post<{ id: string }>("/api/campaigns", { type }); +} + +export async function deleteCampaign(api: ApiClient, id: string | undefined) { + if (!id) return; + await api.delete(`/api/campaigns/${id}`); +} + +export async function createPartnerTag(programId: string) { + return prisma.partnerTag.create({ + data: { + id: createId({ prefix: "ptag_" }), + programId, + name: randomName("tag"), + }, + }); +} + +export async function deletePartnerTag(id: string | undefined) { + if (!id) return; + await prisma.partnerTag.delete({ where: { id } }); +} diff --git a/apps/web/playwright/api/campaigns/send-campaign-workflow-fixtures.ts b/apps/web/playwright/api/campaigns/send-campaign-workflow-fixtures.ts new file mode 100644 index 00000000000..8a30573ccb5 --- /dev/null +++ b/apps/web/playwright/api/campaigns/send-campaign-workflow-fixtures.ts @@ -0,0 +1,13 @@ +import { test as base } from "../fixtures"; +import { + createCampaignSession, + type CampaignSession, +} from "./send-campaign-workflow-helpers"; + +export const test = base.extend<{ campaign: CampaignSession }>({ + campaign: async ({ api, program }, use) => { + const session = createCampaignSession(api, program); + await use(session); + await session.cleanup(); + }, +}); diff --git a/apps/web/playwright/api/campaigns/send-campaign-workflow-helpers.ts b/apps/web/playwright/api/campaigns/send-campaign-workflow-helpers.ts new file mode 100644 index 00000000000..74f7132e663 --- /dev/null +++ b/apps/web/playwright/api/campaigns/send-campaign-workflow-helpers.ts @@ -0,0 +1,484 @@ +import { createId } from "@/lib/api/create-id"; +import { prisma } from "@/lib/prisma"; +import type { EnrolledPartnerProps } from "@/lib/types"; +import { nanoid } from "@dub/utils"; +import { expect } from "@playwright/test"; +import { subHours } from "date-fns"; +import { randomName, randomPartnerEmail } from "../../utils"; +import { PLAYWRIGHT_API_BASE } from "../constants"; +import type { ApiClient } from "../fixtures"; +import { deletePartnerData } from "../partners/helpers"; +import { + campaignContent, + createCampaign, + createPartnerTag, + deleteCampaign, + deletePartnerTag, +} from "./helpers"; + +export const enrolledDaysCondition = { + attribute: "partnerEnrolledDays", + operator: "gte", + value: 1, +} as const; + +export async function publishTransactionalCampaign( + api: ApiClient, + overrides: Record = {}, +) { + const { status, data } = await createCampaign(api); + expect(status).toEqual(201); + + const patched = await api.patch(`/api/campaigns/${data.id}`, { + ...campaignContent({ + triggerConditions: [enrolledDaysCondition], + ...overrides, + }), + status: "active", + ...overrides, + }); + + expect(patched.status).toEqual(200); + return data.id; +} + +export async function getCampaignWorkflow(campaignId: string) { + return prisma.workflow.findFirstOrThrow({ + where: { + campaign: { + id: campaignId, + }, + }, + }); +} + +export async function runScheduledCampaignWorkflow(workflowId: string) { + const response = await fetch( + `${PLAYWRIGHT_API_BASE}/api/cron/workflows/${workflowId}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }, + ); + const message = await response.text(); + + expect(response.status).toEqual(200); + + if (message.includes("disabled")) { + return "disabled"; + } + + if (message.includes("not found")) { + return "not found"; + } + + return "finished"; +} + +export async function createTestPartner( + api: ApiClient, + overrides: Record = {}, +) { + const { status, data } = await api.post( + "/api/partners", + { + name: randomName("partner"), + email: randomPartnerEmail(), + ...overrides, + }, + ); + + expect(status).toEqual(201); + return data; +} + +export async function createPartnerMailbox(partnerId: string) { + const partner = await prisma.partner.findUniqueOrThrow({ + where: { id: partnerId }, + select: { email: true, name: true }, + }); + + const user = await prisma.user.create({ + data: { + id: createId({ prefix: "user_" }), + email: partner.email!, + name: partner.name, + emailVerified: new Date(), + defaultPartnerId: partnerId, + }, + }); + + await prisma.partnerUser.create({ + data: { + userId: user.id, + partnerId, + role: "owner", + notificationPreferences: { + create: {}, + }, + }, + }); + + return user.id; +} + +export async function backdateEnrollment({ + partnerId, + programId, + hoursAgo, +}: { + partnerId: string; + programId: string; + hoursAgo: number; +}) { + await prisma.programEnrollment.update({ + where: { + partnerId_programId: { + partnerId, + programId, + }, + }, + data: { + createdAt: subHours(new Date(), hoursAgo), + }, + }); +} + +export async function setEnrollmentStatus({ + partnerId, + programId, + status, +}: { + partnerId: string; + programId: string; + status: "pending" | "approved" | "banned"; +}) { + await prisma.programEnrollment.update({ + where: { + partnerId_programId: { + partnerId, + programId, + }, + }, + data: { status }, + }); +} + +export async function setLinkStats({ + partnerId, + programId, + leads, + conversions, + saleAmount, +}: { + partnerId: string; + programId: string; + leads?: number; + conversions?: number; + saleAmount?: number; +}) { + const link = await prisma.link.findFirst({ + where: { partnerId, programId }, + orderBy: { id: "asc" }, + select: { id: true }, + }); + + expect(link).not.toBeNull(); + + await prisma.link.update({ + where: { id: link!.id }, + data: { + ...(leads !== undefined && { leads }), + ...(conversions !== undefined && { conversions }), + ...(saleAmount !== undefined && { saleAmount }), + }, + }); +} + +export async function createTestCommission({ + programId, + partnerId, + earnings, +}: { + programId: string; + partnerId: string; + earnings: number; +}) { + return prisma.commission.create({ + data: { + id: createId({ prefix: "cm_" }), + programId, + partnerId, + type: "sale", + amount: earnings, + quantity: 1, + earnings, + status: "pending", + }, + }); +} + +export async function tagPartner({ + programId, + partnerId, + partnerTagId, +}: { + programId: string; + partnerId: string; + partnerTagId: string; +}) { + await prisma.programPartnerTag.create({ + data: { + programId, + partnerId, + partnerTagId, + }, + }); +} + +export async function insertCampaignEmail({ + campaignId, + programId, + partnerId, + recipientUserId, +}: { + campaignId: string; + programId: string; + partnerId: string; + recipientUserId: string; +}) { + return prisma.notificationEmail.create({ + data: { + id: createId({ prefix: "em_" }), + type: "Campaign", + emailId: `pw_${nanoid()}`, + campaignId, + programId, + partnerId, + recipientUserId, + }, + }); +} + +export async function campaignEmails(campaignId: string, partnerId?: string) { + return prisma.notificationEmail.findMany({ + where: { + campaignId, + type: "Campaign", + ...(partnerId && { partnerId }), + }, + }); +} + +export async function expectCampaignEmailCount({ + campaignId, + partnerId, + count, +}: { + campaignId: string; + partnerId?: string; + count: number; +}) { + if (count === 0) { + const emails = await campaignEmails(campaignId, partnerId); + expect( + emails, + "did not expect a campaign NotificationEmail", + ).toHaveLength(0); + return emails; + } + + // Event-triggered sends run in waitUntil after /api/track/lead returns. + let emails: Awaited> = []; + await expect + .poll( + async () => { + emails = await campaignEmails(campaignId, partnerId); + return emails.length; + }, + { + message: + "expected a campaign NotificationEmail (SMTP/MailHog or Resend must be configured)", + }, + ) + .toBe(count); + + return emails; +} + +export async function createTestGroup(api: ApiClient) { + const slug = `g-${nanoid(8).toLowerCase()}`; + const { status, data } = await api.post<{ id: string }>("/api/groups", { + name: randomName("group"), + slug, + color: "blue", + }); + + expect(status).toEqual(201); + return data.id; +} + +export async function deleteTestGroup(api: ApiClient, id: string | undefined) { + if (!id) return; + await api.delete(`/api/groups/${id}`); +} + +export async function deleteTestPartner(partnerId: string | undefined) { + if (!partnerId) return; + + const partnerUsers = await prisma.partnerUser.findMany({ + where: { partnerId }, + select: { userId: true }, + }); + + await prisma.programPartnerTag.deleteMany({ + where: { partnerId }, + }); + + if (partnerUsers.length > 0) { + await prisma.user.deleteMany({ + where: { + id: { + in: partnerUsers.map((row) => row.userId), + }, + }, + }); + } + + await deletePartnerData(partnerId); + + // Prisma partner.delete hits a PlanetScale relation quirk; raw SQL matches + // bulkDeletePartners cleanup used by e2e cron. Use Prisma so cleanup hits + // DATABASE_URL, not PLANETSCALE_DATABASE_URL. + await prisma.$executeRaw`DELETE FROM Partner WHERE id = ${partnerId}`; +} + +export async function cleanupCampaign(api: ApiClient, campaignId?: string) { + if (!campaignId) return; + await prisma.notificationEmail.deleteMany({ + where: { campaignId }, + }); + await deleteCampaign(api, campaignId); +} + +type CreatePartnerOptions = { + mailbox?: boolean; + hoursAgo?: number | null; + groupId?: string; +}; + +export function createCampaignSession( + api: ApiClient, + program: { id: string; defaultGroupId: string }, +) { + const partnerIds: string[] = []; + const campaignIds: string[] = []; + const groupIds: string[] = []; + const tagIds: string[] = []; + const programId = program.id; + + return { + programId, + defaultGroupId: program.defaultGroupId, + + trackPartner(partnerId: string) { + partnerIds.push(partnerId); + }, + + trackCampaign(campaignId: string) { + campaignIds.push(campaignId); + }, + + async createGroup() { + const groupId = await createTestGroup(api); + groupIds.push(groupId); + return groupId; + }, + + async createTag() { + const tag = await createPartnerTag(programId); + tagIds.push(tag.id); + return tag; + }, + + async setup(overrides: Record = {}) { + const campaignId = await publishTransactionalCampaign(api, overrides); + campaignIds.push(campaignId); + const workflow = await getCampaignWorkflow(campaignId); + + return { + id: campaignId, + workflow, + + async createPartner(options: CreatePartnerOptions = {}) { + const partner = await createTestPartner(api, { + ...(options.groupId && { groupId: options.groupId }), + }); + partnerIds.push(partner.id); + + if (options.mailbox !== false) { + await createPartnerMailbox(partner.id); + } + + if (options.hoursAgo !== null) { + await backdateEnrollment({ + partnerId: partner.id, + programId, + hoursAgo: options.hoursAgo ?? 18, + }); + } + + return partner; + }, + + async run() { + return runScheduledCampaignWorkflow(workflow.id); + }, + + async expectSentTo(partner: Pick) { + await expectCampaignEmailCount({ + campaignId, + partnerId: partner.id, + count: 1, + }); + }, + + async expectNotSentTo(partner: Pick) { + await expectCampaignEmailCount({ + campaignId, + partnerId: partner.id, + count: 0, + }); + }, + + async disableWorkflow() { + await prisma.workflow.update({ + where: { id: workflow.id }, + data: { disabledAt: new Date() }, + }); + }, + }; + }, + + async cleanup() { + for (const partnerId of partnerIds) { + await deleteTestPartner(partnerId); + } + for (const campaignId of campaignIds) { + await cleanupCampaign(api, campaignId); + } + for (const tagId of tagIds) { + await deletePartnerTag(tagId); + } + for (const groupId of groupIds) { + await deleteTestGroup(api, groupId); + } + }, + }; +} + +export type CampaignSession = ReturnType; +export type ScheduledCampaign = Awaited< + ReturnType +>; diff --git a/apps/web/playwright/api/campaigns/send-campaign-workflow.spec.ts b/apps/web/playwright/api/campaigns/send-campaign-workflow.spec.ts new file mode 100644 index 00000000000..d3d896b130f --- /dev/null +++ b/apps/web/playwright/api/campaigns/send-campaign-workflow.spec.ts @@ -0,0 +1,384 @@ +import { prisma } from "@/lib/prisma"; +import { expect } from "@playwright/test"; +import { trackClick, trackLead } from "../conversions/helpers"; +import { createCampaign } from "./helpers"; +import { test } from "./send-campaign-workflow-fixtures"; +import { + campaignEmails, + createTestCommission, + enrolledDaysCondition, + getCampaignWorkflow, + insertCampaignEmail, + runScheduledCampaignWorkflow, + setEnrollmentStatus, + setLinkStats, + tagPartner, +} from "./send-campaign-workflow-helpers"; + +test.describe("Lifecycle", () => { + test("transactional draft workflow is disabled", async ({ + api, + campaign, + }) => { + const { status, data } = await createCampaign(api); + expect(status).toEqual(201); + campaign.trackCampaign(data.id); + + const workflow = await getCampaignWorkflow(data.id); + expect(workflow.disabledAt).not.toBeNull(); + expect(workflow.actions).toEqual([ + { + type: "sendCampaign", + data: { campaignId: data.id }, + }, + ]); + }); + + test("publishing a transactional campaign enables the workflow", async ({ + campaign, + }) => { + const ctx = await campaign.setup(); + + expect(ctx.workflow.disabledAt).toBeNull(); + expect(ctx.workflow.triggerConditions).toEqual([enrolledDaysCondition]); + expect(ctx.workflow.actions).toEqual([ + { + type: "sendCampaign", + data: { campaignId: ctx.id }, + }, + ]); + }); + + test("pausing a campaign disables the workflow", async ({ + api, + campaign, + }) => { + const ctx = await campaign.setup(); + + const paused = await api.patch(`/api/campaigns/${ctx.id}`, { + status: "paused", + }); + expect(paused.status).toEqual(200); + + const workflow = await getCampaignWorkflow(ctx.id); + expect(workflow.disabledAt).not.toBeNull(); + }); + + test("draft and paused campaigns do not send", async ({ api, campaign }) => { + const { data: draft } = await createCampaign(api); + campaign.trackCampaign(draft.id); + await api.patch(`/api/campaigns/${draft.id}`, { + triggerConditions: [enrolledDaysCondition], + }); + + const paused = await campaign.setup(); + await api.patch(`/api/campaigns/${paused.id}`, { status: "paused" }); + + const partner = await paused.createPartner(); + const draftWorkflow = await getCampaignWorkflow(draft.id); + + expect(await runScheduledCampaignWorkflow(draftWorkflow.id)).toEqual( + "disabled", + ); + expect(await paused.run()).toEqual("disabled"); + await paused.expectNotSentTo(partner); + expect(await campaignEmails(draft.id, partner.id)).toHaveLength(0); + }); +}); + +test.describe("Scheduled window and recipients", () => { + test("scheduled run skips a disabled workflow", async ({ campaign }) => { + const ctx = await campaign.setup(); + const partner = await ctx.createPartner(); + await ctx.disableWorkflow(); + + expect(await ctx.run()).toEqual("disabled"); + await ctx.expectNotSentTo(partner); + }); + + test("scheduled window only includes enrollments from 12–24h ago", async ({ + campaign, + }) => { + const ctx = await campaign.setup(); + const tooRecent = await ctx.createPartner({ hoursAgo: 6 }); + const inWindow = await ctx.createPartner({ hoursAgo: 18 }); + const tooOld = await ctx.createPartner({ hoursAgo: 30 }); + + expect(await ctx.run()).toEqual("finished"); + await ctx.expectNotSentTo(tooRecent); + await ctx.expectSentTo(inWindow); + await ctx.expectNotSentTo(tooOld); + }); + + test("scheduled run skips eligible partners without a partner user", async ({ + campaign, + }) => { + const ctx = await campaign.setup(); + const partner = await ctx.createPartner({ mailbox: false }); + + expect(await ctx.run()).toEqual("finished"); + await ctx.expectNotSentTo(partner); + }); + + test("scheduled run does not send duplicate campaign emails", async ({ + campaign, + }) => { + const ctx = await campaign.setup(); + const partner = await ctx.createPartner(); + + expect(await ctx.run()).toEqual("finished"); + expect(await ctx.run()).toEqual("finished"); + + const emails = await campaignEmails(ctx.id, partner.id); + expect(emails).toHaveLength(1); + expect(emails[0].type).toEqual("Campaign"); + }); + + test("scheduled run skips partners who already received the campaign", async ({ + campaign, + }) => { + const ctx = await campaign.setup(); + const partner = await ctx.createPartner(); + const mailbox = await prisma.partnerUser.findFirstOrThrow({ + where: { partnerId: partner.id }, + select: { userId: true }, + }); + + const existing = await insertCampaignEmail({ + campaignId: ctx.id, + programId: campaign.programId, + partnerId: partner.id, + recipientUserId: mailbox.userId, + }); + + expect(await ctx.run()).toEqual("finished"); + + const emails = await campaignEmails(ctx.id, partner.id); + expect(emails).toHaveLength(1); + expect(emails[0].id).toEqual(existing.id); + }); +}); + +test.describe("Scheduled AND conditions", () => { + const scheduledAndCases = [ + { + title: "sends when enrollment window and leads match", + condition: { attribute: "totalLeads", operator: "gte", value: 1 }, + sent: true, + seed: (partner, programId) => + setLinkStats({ + partnerId: partner.id, + programId, + leads: 1, + }), + }, + { + title: "does not send when the metric condition fails", + condition: { attribute: "totalLeads", operator: "gte", value: 1 }, + sent: false, + }, + { + title: "sends when totalLeads lte 0", + condition: { attribute: "totalLeads", operator: "lte", value: 0 }, + sent: true, + }, + { + title: "sends when totalConversions matches", + condition: { + attribute: "totalConversions", + operator: "gte", + value: 1, + }, + sent: true, + seed: (partner, programId) => + setLinkStats({ + partnerId: partner.id, + programId, + conversions: 1, + }), + }, + { + title: "sends when totalSaleAmount matches", + condition: { + attribute: "totalSaleAmount", + operator: "gte", + value: 100, + }, + sent: true, + seed: (partner, programId) => + setLinkStats({ + partnerId: partner.id, + programId, + saleAmount: 100, + }), + }, + { + title: "sends when totalCommissions matches", + condition: { + attribute: "totalCommissions", + operator: "gte", + value: 1, + }, + sent: true, + seed: async (partner, programId) => { + await createTestCommission({ + programId, + partnerId: partner.id, + earnings: 500, + }); + }, + }, + ]; + + for (const { title, condition, sent, seed } of scheduledAndCases) { + test(`scheduled AND ${title}`, async ({ campaign }) => { + const ctx = await campaign.setup({ + triggerConditions: [enrolledDaysCondition, condition], + }); + const partner = await ctx.createPartner(); + await seed?.(partner, campaign.programId); + + expect(await ctx.run()).toEqual("finished"); + if (sent) { + await ctx.expectSentTo(partner); + } else { + await ctx.expectNotSentTo(partner); + } + }); + } +}); + +test.describe("Audience", () => { + test("group filter only sends to partners in selected groups", async ({ + campaign, + }) => { + const groupId = await campaign.createGroup(); + const ctx = await campaign.setup({ groupIds: [groupId] }); + const inGroup = await ctx.createPartner({ groupId }); + const outGroup = await ctx.createPartner({ + groupId: campaign.defaultGroupId, + }); + + expect(await ctx.run()).toEqual("finished"); + await ctx.expectSentTo(inGroup); + await ctx.expectNotSentTo(outGroup); + }); + + test("partner tag filter only sends to tagged partners", async ({ + campaign, + }) => { + const tag = await campaign.createTag(); + const ctx = await campaign.setup({ partnerTagIds: [tag.id] }); + const tagged = await ctx.createPartner(); + await tagPartner({ + programId: campaign.programId, + partnerId: tagged.id, + partnerTagId: tag.id, + }); + const untagged = await ctx.createPartner(); + + expect(await ctx.run()).toEqual("finished"); + await ctx.expectSentTo(tagged); + await ctx.expectNotSentTo(untagged); + }); + + test("group and tag filters both have to match", async ({ campaign }) => { + const groupId = await campaign.createGroup(); + const tag = await campaign.createTag(); + const ctx = await campaign.setup({ + groupIds: [groupId], + partnerTagIds: [tag.id], + }); + const partner = await ctx.createPartner({ groupId }); + + expect(await ctx.run()).toEqual("finished"); + await ctx.expectNotSentTo(partner); + + await tagPartner({ + programId: campaign.programId, + partnerId: partner.id, + partnerTagId: tag.id, + }); + expect(await ctx.run()).toEqual("finished"); + await ctx.expectSentTo(partner); + }); + + test("non-approved enrollments are not sent", async ({ campaign }) => { + const ctx = await campaign.setup(); + const partner = await ctx.createPartner(); + await setEnrollmentStatus({ + partnerId: partner.id, + programId: campaign.programId, + status: "pending", + }); + + expect(await ctx.run()).toEqual("finished"); + await ctx.expectNotSentTo(partner); + }); +}); + +test.describe("Event path", () => { + test("partnerJoined does not send on the scheduled runner", async ({ + campaign, + }) => { + const ctx = await campaign.setup({ + triggerConditions: [ + { attribute: "partnerJoined", operator: "gte", value: 0 }, + ], + }); + const partner = await ctx.createPartner({ hoursAgo: null }); + + expect(await ctx.run()).toEqual("finished"); + await ctx.expectNotSentTo(partner); + }); + + test("leadRecorded sends when totalLeads matches and skips when it does not", async ({ + campaign, + }) => { + const ctx = await campaign.setup({ + triggerConditions: [ + { attribute: "totalLeads", operator: "gte", value: 2 }, + ], + }); + + const match = await ctx.createPartner({ hoursAgo: null }); + await setLinkStats({ + partnerId: match.id, + programId: campaign.programId, + leads: 1, + }); + const matchClick = await trackClick({ + domain: match.links![0].domain, + key: match.links![0].key, + }); + await trackLead({ clickId: matchClick.clickId }); + + const miss = await ctx.createPartner({ hoursAgo: null }); + const missClick = await trackClick({ + domain: miss.links![0].domain, + key: miss.links![0].key, + }); + await trackLead({ clickId: missClick.clickId }); + + await ctx.expectSentTo(match); + await ctx.expectNotSentTo(miss); + }); + + test("event runner skips disabled workflows", async ({ campaign }) => { + const ctx = await campaign.setup({ + triggerConditions: [ + { attribute: "totalLeads", operator: "gte", value: 1 }, + ], + }); + const partner = await ctx.createPartner({ hoursAgo: null }); + await ctx.disableWorkflow(); + + const { clickId } = await trackClick({ + domain: partner.links![0].domain, + key: partner.links![0].key, + }); + await trackLead({ clickId }); + + await ctx.expectNotSentTo(partner); + }); +}); diff --git a/apps/web/playwright/api/commissions/clawbacks.spec.ts b/apps/web/playwright/api/commissions/clawbacks.spec.ts new file mode 100644 index 00000000000..9bdb57c9a07 --- /dev/null +++ b/apps/web/playwright/api/commissions/clawbacks.spec.ts @@ -0,0 +1,132 @@ +import { prisma } from "@/lib/prisma"; +import { nanoid } from "@dub/utils"; +import { expect } from "@playwright/test"; +import { apiError } from "../../utils"; +import { test } from "../fixtures"; +import { createPartner, deletePartner } from "../partners/helpers"; + +const expectedQueuedResponse = { + success: true, + message: "A clawback has been queued for the partner!", +}; + +async function expectClawbackCreated({ + partnerId, + programId, + amount, + description, +}: { + partnerId: string; + programId: string; + amount: number; + description: string; +}) { + await expect + .poll(async () => { + const commission = await prisma.commission.findFirst({ + where: { + partnerId, + programId, + type: "custom", + description, + }, + orderBy: { + createdAt: "desc", + }, + }); + + if (!commission) { + return null; + } + + return { + partnerId: commission.partnerId, + programId: commission.programId, + type: commission.type, + amount: Number(commission.amount), + earnings: Number(commission.earnings), + quantity: commission.quantity, + description: commission.description, + }; + }) + .toEqual({ + partnerId, + programId, + type: "custom", + amount: 0, + earnings: -amount, + quantity: 1, + description, + }); +} + +test("POST /commissions – clawback with arbitrary description", async ({ + api, + program, +}) => { + let partnerId: string | undefined; + const description = `chargeback-${nanoid()}`; + + try { + const { status: createStatus, data: created } = await createPartner(api, { + groupId: program.defaultGroupId, + }); + partnerId = created.id; + expect(createStatus).toEqual(201); + + const { status, data } = await api.post("/api/commissions", { + type: "custom", + partnerId, + amount: -100, + description, + }); + + expect(status).toEqual(202); + expect(data).toStrictEqual(expectedQueuedResponse); + + await expectClawbackCreated({ + partnerId: created.id, + programId: program.id, + amount: 100, + description, + }); + } finally { + await deletePartner(partnerId); + } +}); + +const missingPartnerId = `pn_${nanoid()}`; + +const clawbackErrorCases = [ + { + name: "POST /commissions – clawback partner not found", + body: { + type: "custom", + partnerId: missingPartnerId, + amount: -500, + description: "fraud", + }, + expected: ({ program }: { program: { id: string } }) => + apiError({ + code: "not_found", + message: `Partner ${missingPartnerId} is not enrolled in program ${program.id}.`, + }), + }, + { + name: "POST /commissions – clawback missing partnerId", + body: { type: "custom", amount: -500, description: "fraud" }, + expected: apiError({ + code: "unprocessable_entity", + message: + "invalid_type: partnerId: Invalid input: expected string, received undefined", + }), + }, +]; + +for (const { name, body, expected } of clawbackErrorCases) { + test(name, async ({ api, program }) => { + expect(await api.post("/api/commissions", body)).toEqual( + typeof expected === "function" ? expected({ program }) : expected, + ); + }); +} diff --git a/apps/web/playwright/api/commissions/commissions-create.spec.ts b/apps/web/playwright/api/commissions/commissions-create.spec.ts new file mode 100644 index 00000000000..bda20f2f83c --- /dev/null +++ b/apps/web/playwright/api/commissions/commissions-create.spec.ts @@ -0,0 +1,1029 @@ +import { createId } from "@/lib/api/create-id"; +import { prisma } from "@/lib/prisma"; +import type { CommissionResponse } from "@/lib/types"; +import { redis } from "@/lib/upstash"; +import { nanoid } from "@dub/utils"; +import { expect } from "@playwright/test"; +import { apiError, randomCustomer } from "../../utils"; +import { test } from "../fixtures"; +import { TEST_COMMISSION_REWARDS } from "../setup-test-workspace"; +import { expectCommissionCreated, withCommissionPartner } from "./helpers"; + +const expectedQueuedResponse = { + status: 202, + data: { + success: true, + message: "Your commissions are being created and will appear shortly.", + }, +}; + +const expectedClawbackResponse = { + status: 202, + data: { + success: true, + message: "A clawback has been queued for the partner!", + }, +}; + +const oversizedMetadata = { + blob: "x".repeat(10_000), +}; + +function customerBody() { + const customer = randomCustomer(); + + return { + externalId: customer.externalId, + email: customer.email, + name: customer.name, + country: "US", + }; +} + +async function seedDiscountCode({ + programId, + partnerId, + disabledAt, +}: { + programId: string; + partnerId: string; + disabledAt?: Date; +}) { + const link = await prisma.link.findFirst({ + where: { partnerId }, + orderBy: { createdAt: "asc" }, + }); + + if (!link) { + throw new Error("Partner was created without a default link."); + } + + const code = `PW${nanoid(8)}`; + + await prisma.discountCode.create({ + data: { + id: createId({ prefix: "dcode_" }), + code, + programId, + partnerId, + linkId: link.id, + disabledAt, + }, + }); + + return { code, linkId: link.id }; +} + +test.describe("Custom commissions", () => { + test("creates a custom commission", async ({ api, program }) => { + const description = `custom-${nanoid()}`; + + await withCommissionPartner(api, program, async (partnerId) => { + expect( + await api.post("/api/commissions", { + type: "custom", + partnerId, + amount: 500, + description, + }), + ).toEqual(expectedQueuedResponse); + + await expectCommissionCreated({ + api, + partnerId, + programId: program.id, + type: "custom", + description, + expectedAmount: 0, + expectedEarnings: 500, + }); + }); + }); + + test("creates a clawback", async ({ api, program }) => { + await withCommissionPartner(api, program, async (partnerId) => { + expect( + await api.post("/api/commissions", { + type: "custom", + partnerId, + amount: -500, + description: "fraud", + }), + ).toEqual(expectedClawbackResponse); + + await expectCommissionCreated({ + api, + partnerId, + programId: program.id, + type: "custom", + description: "fraud", + expectedAmount: 0, + expectedEarnings: -500, + }); + }); + }); + + test.describe("validates", () => { + const errorCases = [ + { + name: "rejects amount of 0", + body: { type: "custom", partnerId: "pn_test", amount: 0 }, + expected: apiError({ + code: "unprocessable_entity", + message: "custom: amount: Amount cannot be 0.", + }), + }, + { + name: "rejects clawback without description", + body: { type: "custom", partnerId: "pn_test", amount: -500 }, + expected: apiError({ + code: "unprocessable_entity", + message: + "custom: description: `description` is required when creating a clawback (negative amount).", + }), + }, + ]; + + for (const { name, body, expected } of errorCases) { + test(name, async ({ api }) => { + expect(await api.post("/api/commissions", body)).toEqual(expected); + }); + } + }); +}); + +test.describe("Lead commissions", () => { + test("creates a lead commission", async ({ api, program }) => { + await withCommissionPartner(api, program, async (partnerId) => { + expect( + await api.post("/api/commissions", { + type: "lead", + partnerId, + customer: customerBody(), + }), + ).toEqual(expectedQueuedResponse); + + await expectCommissionCreated({ + api, + partnerId, + programId: program.id, + type: "lead", + expectedMetadata: null, + }); + }); + }); + + test("creates using date, eventName, and metadata", async ({ + api, + program, + }) => { + const date = new Date("2024-01-10T00:00:00.000Z"); + const metadata = { plan: "pro" }; + + await withCommissionPartner(api, program, async (partnerId) => { + expect( + await api.post("/api/commissions", { + type: "lead", + partnerId, + date: date.toISOString(), + lead: { + eventName: "Requested demo", + metadata, + }, + customer: customerBody(), + }), + ).toEqual(expectedQueuedResponse); + + await expectCommissionCreated({ + api, + partnerId, + programId: program.id, + type: "lead", + expectedEarnings: + TEST_COMMISSION_REWARDS.lead.modifiers[0].amountInCents, + expectedCreatedAt: date, + expectedMetadata: metadata, + }); + }); + }); + + test("treats empty lead metadata as absent", async ({ api, program }) => { + await withCommissionPartner(api, program, async (partnerId) => { + expect( + await api.post("/api/commissions", { + type: "lead", + partnerId, + lead: { metadata: {} }, + customer: customerBody(), + }), + ).toEqual(expectedQueuedResponse); + + await expectCommissionCreated({ + api, + partnerId, + programId: program.id, + type: "lead", + expectedMetadata: null, + }); + }); + }); + + test("supports deprecated leadEventName + leadEventDate", async ({ + api, + program, + }) => { + const leadEventDate = new Date("2024-06-15T12:00:00.000Z"); + + await withCommissionPartner(api, program, async (partnerId) => { + expect( + await api.post("/api/commissions", { + type: "lead", + partnerId, + leadEventName: "Signed up", + leadEventDate: leadEventDate.toISOString(), + customer: customerBody(), + }), + ).toEqual(expectedQueuedResponse); + + await expectCommissionCreated({ + api, + partnerId, + programId: program.id, + type: "lead", + expectedCreatedAt: leadEventDate, + expectedMetadata: null, + }); + }); + }); + + test("date takes precedence over leadEventDate", async ({ api, program }) => { + const date = new Date("2024-08-01T00:00:00.000Z"); + const leadEventDate = new Date("2020-01-01T00:00:00.000Z"); + + await withCommissionPartner(api, program, async (partnerId) => { + expect( + await api.post("/api/commissions", { + type: "lead", + partnerId, + date: date.toISOString(), + leadEventDate: leadEventDate.toISOString(), + customer: customerBody(), + }), + ).toEqual(expectedQueuedResponse); + + await expectCommissionCreated({ + api, + partnerId, + programId: program.id, + type: "lead", + expectedCreatedAt: date, + }); + }); + }); + + test.describe("validates", () => { + test("rejects oversized metadata", async ({ api }) => { + expect( + await api.post("/api/commissions", { + type: "lead", + partnerId: "pn_test", + customerId: "cus_test", + lead: { metadata: oversizedMetadata }, + }), + ).toEqual( + apiError({ + code: "unprocessable_entity", + message: + "custom: lead.metadata: Metadata must be less than 10,000 characters when stringified", + }), + ); + }); + }); +}); + +test.describe("Sale commissions", () => { + test("creates a sale commission", async ({ api, program }) => { + const invoiceId = `INV_${nanoid()}`; + + await withCommissionPartner(api, program, async (partnerId) => { + expect( + await api.post("/api/commissions", { + type: "sale", + partnerId, + saleAmount: 1000, + invoiceId, + customer: customerBody(), + }), + ).toEqual(expectedQueuedResponse); + + await expectCommissionCreated({ + api, + partnerId, + programId: program.id, + type: "sale", + invoiceId, + expectedMetadata: null, + }); + }); + }); + + test("creates using discountCode", async ({ api, program }) => { + const invoiceId = `INV_${nanoid()}`; + + await withCommissionPartner(api, program, async (partnerId) => { + const { code, linkId } = await seedDiscountCode({ + programId: program.id, + partnerId, + }); + + expect( + await api.post("/api/commissions", { + type: "sale", + partnerId, + discountCode: code, + saleAmount: 1000, + invoiceId, + customer: customerBody(), + }), + ).toEqual(expectedQueuedResponse); + + await expectCommissionCreated({ + api, + partnerId, + programId: program.id, + type: "sale", + invoiceId, + expectedLinkId: linkId, + expectedMetadata: null, + }); + }); + }); + + test("creates using nested sale", async ({ api, program }) => { + const invoiceId = `INV_${nanoid()}`; + const date = new Date("2024-02-20T00:00:00.000Z"); + const metadata = { plan: "pro" }; + + await withCommissionPartner(api, program, async (partnerId) => { + expect( + await api.post("/api/commissions", { + type: "sale", + partnerId, + date: date.toISOString(), + sale: { + amount: 1000, + currency: "usd", + eventName: "Invoice paid", + paymentProcessor: "stripe", + invoiceId, + metadata, + }, + customer: customerBody(), + }), + ).toEqual(expectedQueuedResponse); + + await expectCommissionCreated({ + api, + partnerId, + programId: program.id, + type: "sale", + invoiceId, + expectedCreatedAt: date, + expectedEarnings: + TEST_COMMISSION_REWARDS.sale.modifiers[1].amountInCents, + expectedMetadata: metadata, + }); + }); + }); + + test("PATCH preserves metadata on the response", async ({ api, program }) => { + const invoiceId = `INV_${nanoid()}`; + // Avoid plan/productId keys that trigger sale reward modifiers. + const metadata = { campaign: "spring" }; + + await withCommissionPartner(api, program, async (partnerId) => { + expect( + await api.post("/api/commissions", { + type: "sale", + partnerId, + sale: { + amount: 1000, + invoiceId, + metadata, + }, + customer: customerBody(), + }), + ).toEqual(expectedQueuedResponse); + + const commissionId = await expectCommissionCreated({ + api, + partnerId, + programId: program.id, + type: "sale", + invoiceId, + expectedMetadata: metadata, + }); + + const { status, data } = await api.patch( + `/api/commissions/${commissionId}`, + { earnings: 1111 }, + ); + + expect(status).toEqual(200); + expect(data).toMatchObject({ + id: commissionId, + earnings: 1111, + metadata, + }); + }); + }); + + test("creates using nested sale with string amount", async ({ + api, + program, + }) => { + const invoiceId = `INV_${nanoid()}`; + + await withCommissionPartner(api, program, async (partnerId) => { + expect( + await api.post("/api/commissions", { + type: "sale", + partnerId, + sale: { + amount: "1000", + invoiceId, + }, + customer: customerBody(), + }), + ).toEqual(expectedQueuedResponse); + + await expectCommissionCreated({ + api, + partnerId, + programId: program.id, + type: "sale", + invoiceId, + }); + }); + }); + + test("converts nested sale.currency to USD", async ({ api, program }) => { + const invoiceId = `INV_${nanoid()}`; + const eurRate = await redis.hget("fxRates:usd", "EUR"); + test.skip(!eurRate, "EUR FX rate not available in Redis"); + + const expectedAmount = Math.round(10000 / Number(eurRate)); + + await withCommissionPartner(api, program, async (partnerId) => { + expect( + await api.post("/api/commissions", { + type: "sale", + partnerId, + sale: { + amount: 10000, + currency: "eur", + invoiceId, + }, + customer: customerBody(), + }), + ).toEqual(expectedQueuedResponse); + + await expectCommissionCreated({ + api, + partnerId, + programId: program.id, + type: "sale", + invoiceId, + expectedAmount, + }); + }); + }); + + test("supports deprecated sale fields", async ({ api, program }) => { + const invoiceId = `INV_${nanoid()}`; + const saleEventDate = new Date("2024-03-01T08:30:00.000Z"); + + await withCommissionPartner(api, program, async (partnerId) => { + expect( + await api.post("/api/commissions", { + type: "sale", + partnerId, + saleAmount: 1000, + invoiceId, + productId: "sku_pro", + saleEventDate: saleEventDate.toISOString(), + customer: customerBody(), + }), + ).toEqual(expectedQueuedResponse); + + await expectCommissionCreated({ + api, + partnerId, + programId: program.id, + type: "sale", + invoiceId, + expectedCreatedAt: saleEventDate, + expectedEarnings: + TEST_COMMISSION_REWARDS.sale.modifiers[0].amountInCents, + // Deprecated top-level productId must not leak into commission.metadata. + expectedMetadata: null, + }); + }); + }); + + test("creates with saleAmount when nested sale has no amount", async ({ + api, + program, + }) => { + const invoiceId = `INV_${nanoid()}`; + + await withCommissionPartner(api, program, async (partnerId) => { + expect( + await api.post("/api/commissions", { + type: "sale", + partnerId, + saleAmount: 1000, + sale: { + eventName: "Invoice paid", + invoiceId, + }, + customer: customerBody(), + }), + ).toEqual(expectedQueuedResponse); + + await expectCommissionCreated({ + api, + partnerId, + programId: program.id, + type: "sale", + invoiceId, + }); + }); + }); + + test("date takes precedence over saleEventDate", async ({ api, program }) => { + const date = new Date("2024-08-01T00:00:00.000Z"); + const saleEventDate = new Date("2020-01-01T00:00:00.000Z"); + + await withCommissionPartner(api, program, async (partnerId) => { + expect( + await api.post("/api/commissions", { + type: "sale", + partnerId, + saleAmount: 1000, + date: date.toISOString(), + saleEventDate: saleEventDate.toISOString(), + customer: customerBody(), + }), + ).toEqual(expectedQueuedResponse); + + await expectCommissionCreated({ + api, + partnerId, + programId: program.id, + type: "sale", + expectedCreatedAt: date, + }); + }); + }); + + test("nested sale takes precedence over deprecated fields", async ({ + api, + program, + }) => { + const invoiceId = `INV_${nanoid()}`; + const date = new Date("2024-09-15T00:00:00.000Z"); + + await withCommissionPartner(api, program, async (partnerId) => { + expect( + await api.post("/api/commissions", { + type: "sale", + partnerId, + saleAmount: 500, + invoiceId: `INV_${nanoid()}`, + productId: "sku_old", + saleEventDate: new Date("2020-01-01T00:00:00.000Z").toISOString(), + date: date.toISOString(), + sale: { + amount: 2000, + invoiceId, + metadata: { productId: "sku_pro" }, + }, + customer: customerBody(), + }), + ).toEqual(expectedQueuedResponse); + + await expectCommissionCreated({ + api, + partnerId, + programId: program.id, + type: "sale", + invoiceId, + expectedAmount: 2000, + expectedCreatedAt: date, + expectedEarnings: + TEST_COMMISSION_REWARDS.sale.modifiers[0].amountInCents, + // User-provided sale.metadata persists; deprecated productId does not. + expectedMetadata: { productId: "sku_pro" }, + }); + }); + }); + + test("coerces non-string sale.metadata.productId in reward context", async ({ + api, + program, + }) => { + const invoiceId = `INV_${nanoid()}`; + const metadata = { productId: 12345 }; + + await withCommissionPartner(api, program, async (partnerId) => { + expect( + await api.post("/api/commissions", { + type: "sale", + partnerId, + sale: { + amount: 1000, + invoiceId, + metadata, + }, + customer: customerBody(), + }), + ).toEqual(expectedQueuedResponse); + + await expectCommissionCreated({ + api, + partnerId, + programId: program.id, + type: "sale", + invoiceId, + // Non-string productId is dropped from reward context (no productId modifier). + expectedEarnings: TEST_COMMISSION_REWARDS.sale.amountInCents, + // Original metadata still persists on the commission. + expectedMetadata: metadata, + }); + }); + }); + + test("rejects duplicate invoice", async ({ api, program }) => { + const invoiceId = `INV_${nanoid()}`; + + await withCommissionPartner(api, program, async (partnerId) => { + await prisma.commission.create({ + data: { + id: createId({ prefix: "cm_" }), + programId: program.id, + partnerId, + type: "sale", + amount: 1000, + earnings: 100, + quantity: 1, + invoiceId, + }, + }); + + expect( + await api.post("/api/commissions", { + type: "sale", + partnerId, + saleAmount: 1000, + invoiceId, + customer: customerBody(), + }), + ).toEqual( + apiError({ + code: "conflict", + message: `There is already a commission for the invoice ${invoiceId}.`, + }), + ); + }); + }); + + test("rejects duplicate nested sale.invoiceId", async ({ api, program }) => { + const invoiceId = `INV_${nanoid()}`; + + await withCommissionPartner(api, program, async (partnerId) => { + await prisma.commission.create({ + data: { + id: createId({ prefix: "cm_" }), + programId: program.id, + partnerId, + type: "sale", + amount: 1000, + earnings: 100, + quantity: 1, + invoiceId, + }, + }); + + expect( + await api.post("/api/commissions", { + type: "sale", + partnerId, + sale: { amount: 1000, invoiceId }, + customer: customerBody(), + }), + ).toEqual( + apiError({ + code: "conflict", + message: `There is already a commission for the invoice ${invoiceId}.`, + }), + ); + }); + }); + + test("imports Stripe invoices", async ({ api, program, workspace }) => { + await withCommissionPartner(api, program, async (partnerId) => { + expect( + await api.post("/api/commissions", { + type: "sale", + partnerId, + importStripeInvoices: true, + customer: customerBody(), + }), + ).toEqual( + apiError({ + code: "bad_request", + message: `Your workspace isn't connected to Stripe yet. Please install the Stripe integration to continue: https://app.dub.co/${workspace.slug}/settings/integrations/stripe`, + }), + ); + }); + }); + + test.describe("validates", () => { + const errorCases = [ + { + name: "rejects missing sale.amount and saleAmount", + body: { + type: "sale", + partnerId: "pn_test", + customerId: "cus_test", + importStripeInvoices: false, + }, + expected: apiError({ + code: "unprocessable_entity", + message: + "custom: saleAmount: `sale.amount` or `saleAmount` is required when `importStripeInvoices` is false.", + }), + }, + { + name: "rejects saleAmount of 0", + body: { + type: "sale", + partnerId: "pn_test", + customerId: "cus_test", + importStripeInvoices: false, + saleAmount: 0, + }, + expected: apiError({ + code: "unprocessable_entity", + message: "custom: saleAmount: Sale amount cannot be 0.", + }), + }, + { + name: "rejects sale.amount of 0", + body: { + type: "sale", + partnerId: "pn_test", + customerId: "cus_test", + sale: { amount: 0 }, + }, + expected: apiError({ + code: "unprocessable_entity", + message: "custom: sale.amount: Sale amount cannot be 0.", + }), + }, + { + name: "rejects non-integer sale.amount", + body: { + type: "sale", + partnerId: "pn_test", + customerId: "cus_test", + sale: { amount: 10.5 }, + }, + expected: apiError({ + code: "unprocessable_entity", + message: + "invalid_type: sale.amount: Invalid input: expected int, received number", + }), + }, + { + name: "rejects nested sale fields when importing Stripe invoices", + body: { + type: "sale", + partnerId: "pn_test", + customerId: "cus_test", + importStripeInvoices: true, + date: "2024-03-01T08:30:00.000Z", + sale: { + amount: 5000, + invoiceId: "in_test", + metadata: { productId: "sku" }, + }, + }, + expected: apiError({ + code: "unprocessable_entity", + message: + "custom: sale: `sale`, `date`, `invoiceId`, `productId` cannot be provided when `importStripeInvoices` is enabled.", + }), + }, + { + name: "rejects empty invoiceId and productId when importing Stripe invoices", + body: { + type: "sale", + partnerId: "pn_test", + customerId: "cus_test", + importStripeInvoices: true, + invoiceId: "", + productId: "", + }, + expected: apiError({ + code: "unprocessable_entity", + message: + "custom: invoiceId: `invoiceId`, `productId` cannot be provided when `importStripeInvoices` is enabled.", + }), + }, + { + name: "rejects invalid paymentProcessor", + body: { + type: "sale", + partnerId: "pn_test", + customerId: "cus_test", + sale: { amount: 1000, paymentProcessor: "foo" }, + }, + expected: apiError({ + code: "unprocessable_entity", + message: + 'invalid_value: sale.paymentProcessor: Invalid option: expected one of "stripe"|"shopify"|"polar"|"paddle"|"apple"|"revenuecat"|"lemonsqueezy"|"dub"|"custom"', + }), + }, + { + name: "rejects oversized metadata", + body: { + type: "sale", + partnerId: "pn_test", + customerId: "cus_test", + sale: { amount: 1000, metadata: oversizedMetadata }, + }, + expected: apiError({ + code: "unprocessable_entity", + message: + "custom: sale.metadata: Metadata must be less than 10,000 characters when stringified", + }), + }, + { + name: "rejects linkId and discountCode together", + body: { + type: "sale", + partnerId: "pn_test", + customerId: "cus_test", + saleAmount: 1000, + linkId: "link_test", + discountCode: "SAVE10", + }, + expected: apiError({ + code: "unprocessable_entity", + message: + "custom: discountCode: Either `linkId` or `discountCode` may be provided, not both.", + }), + }, + { + name: "rejects empty discountCode", + body: { + type: "sale", + partnerId: "pn_test", + customerId: "cus_test", + saleAmount: 1000, + discountCode: "", + }, + expected: apiError({ + code: "unprocessable_entity", + message: + "too_small: discountCode: Too small: expected string to have >=1 characters", + }), + }, + ]; + + for (const { name, body, expected } of errorCases) { + test(name, async ({ api }) => { + expect(await api.post("/api/commissions", body)).toEqual(expected); + }); + } + + test("rejects unknown customer", async ({ api, program }) => { + await withCommissionPartner(api, program, async (partnerId) => { + expect( + await api.post("/api/commissions", { + type: "sale", + partnerId, + customerId: "cus_nonexistent", + saleAmount: 1000, + }), + ).toEqual( + apiError({ + code: "not_found", + message: "Customer cus_nonexistent not found.", + }), + ); + }); + }); + + test("rejects unknown discountCode", async ({ api, program }) => { + await withCommissionPartner(api, program, async (partnerId) => { + const code = `MISSING${nanoid(8)}`; + + expect( + await api.post("/api/commissions", { + type: "sale", + partnerId, + discountCode: code, + saleAmount: 1000, + customer: customerBody(), + }), + ).toEqual( + apiError({ + code: "not_found", + message: `Discount code ${code} not found.`, + }), + ); + }); + }); + + test("rejects another partner's discountCode", async ({ api, program }) => { + await withCommissionPartner(api, program, async (partnerId) => { + await withCommissionPartner(api, program, async (otherPartnerId) => { + const { code } = await seedDiscountCode({ + programId: program.id, + partnerId: otherPartnerId, + }); + + const partner = await prisma.partner.findUniqueOrThrow({ + where: { id: partnerId }, + select: { id: true, email: true }, + }); + + expect( + await api.post("/api/commissions", { + type: "sale", + partnerId, + discountCode: code, + saleAmount: 1000, + customer: customerBody(), + }), + ).toEqual( + apiError({ + code: "not_found", + message: `Discount code ${code} does not belong to partner ${partner.email} (${partner.id}).`, + }), + ); + }); + }); + }); + + test("rejects disabled discountCode", async ({ api, program }) => { + await withCommissionPartner(api, program, async (partnerId) => { + const { code } = await seedDiscountCode({ + programId: program.id, + partnerId, + disabledAt: new Date(), + }); + + expect( + await api.post("/api/commissions", { + type: "sale", + partnerId, + discountCode: code, + saleAmount: 1000, + customer: customerBody(), + }), + ).toEqual( + apiError({ + code: "bad_request", + message: `Discount code ${code} is disabled.`, + }), + ); + }); + }); + }); +}); + +const typeErrorCases = [ + { + name: "rejects missing type", + body: { partnerId: "pn_test", amount: 500 }, + }, + { + name: "rejects invalid type", + body: { type: "invalid", partnerId: "pn_test" }, + }, +]; + +for (const { name, body } of typeErrorCases) { + test(name, async ({ api }) => { + expect(await api.post("/api/commissions", body)).toEqual( + apiError({ + code: "unprocessable_entity", + message: "invalid_union: type: Invalid input", + }), + ); + }); +} diff --git a/apps/web/playwright/api/commissions/commissions-list.spec.ts b/apps/web/playwright/api/commissions/commissions-list.spec.ts new file mode 100644 index 00000000000..28046364f5c --- /dev/null +++ b/apps/web/playwright/api/commissions/commissions-list.spec.ts @@ -0,0 +1,217 @@ +import { createId } from "@/lib/api/create-id"; +import { prisma } from "@/lib/prisma"; +import type { CommissionResponse } from "@/lib/types"; +import { expect } from "@playwright/test"; +import { apiError } from "../../utils"; +import { test, type ApiClient } from "../fixtures"; +import { createPartner } from "../partners/helpers"; +import { deleteCommissionPartner } from "./helpers"; + +test.describe("GET /commissions – metadata query", () => { + // Shared Prisma seed for this describe; serial so beforeAll runs once per worker group. + test.describe.configure({ mode: "serial" }); + + const seedMetadata: Record[] = [ + { plan: "pro", tier: "gold" }, + { plan: "Pro", tier: "silver" }, + { plan: "PRO", seats: 10 }, + { plan: "enterprise", tier: "gold", active: true }, + { plan: "enterprise", seats: 5, active: false }, + { plan: "free", tier: "bronze" }, + { plan: "free", campaign: "Spring" }, + { plan: "starter", seats: 10, active: true }, + { plan: "pro", campaign: "spring" }, + { plan: "enterprise", campaign: "fall", seats: 5 }, + ]; + + let partnerId: string | undefined; + let seeded: { + id: string; + metadata: Record; + }[] = []; + + test.beforeAll(async ({ api, program }) => { + const { status, data } = await createPartner(api, { + groupId: program.defaultGroupId, + }); + expect(status).toEqual(201); + partnerId = data.id; + + const rows = seedMetadata.map((metadata, i) => ({ + id: createId({ prefix: "cm_" }), + programId: program.id, + partnerId: data.id, + type: "custom" as const, + amount: 0, + earnings: 100, + quantity: 1, + description: `meta-query-${i}`, + metadata, + })); + + await prisma.commission.createMany({ data: rows }); + seeded = rows.map((row) => ({ + id: row.id, + metadata: row.metadata, + })); + }); + + test.afterAll(async () => { + await deleteCommissionPartner({ partnerId }); + }); + + function matchingIds( + predicate: (metadata: Record) => boolean, + ) { + return seeded + .filter((row) => predicate(row.metadata)) + .map((row) => row.id) + .sort(); + } + + async function listCommissions(api: ApiClient, query: string) { + const { status, data } = await api.get( + `/api/commissions?${new URLSearchParams({ + partnerId: partnerId!, + pageSize: "100", + query, + })}`, + ); + + expect(status).toEqual(200); + return data.map((commission) => commission.id).sort(); + } + + test("filters with =", async ({ api }) => { + expect(await listCommissions(api, "metadata['plan']='pro'")).toEqual( + matchingIds((m) => m.plan === "pro"), + ); + }); + + test("filters with : as equals", async ({ api }) => { + expect(await listCommissions(api, "metadata['plan']:pro")).toEqual( + matchingIds((m) => m.plan === "pro"), + ); + }); + + test("filters are case-sensitive", async ({ api }) => { + expect(await listCommissions(api, "metadata['plan']='Pro'")).toEqual( + matchingIds((m) => m.plan === "Pro"), + ); + + expect(await listCommissions(api, "metadata['plan']='PRO'")).toEqual( + matchingIds((m) => m.plan === "PRO"), + ); + + expect(await listCommissions(api, "metadata['campaign']='spring'")).toEqual( + matchingIds((m) => m.campaign === "spring"), + ); + + expect(await listCommissions(api, "metadata['campaign']='Spring'")).toEqual( + matchingIds((m) => m.campaign === "Spring"), + ); + }); + + test("filters with !=", async ({ api }) => { + expect(await listCommissions(api, "metadata['plan']!='free'")).toEqual( + matchingIds((m) => m.plan !== "free"), + ); + }); + + test("filters with AND", async ({ api }) => { + expect( + await listCommissions( + api, + "metadata['plan']='pro' AND metadata['tier']='gold'", + ), + ).toEqual(matchingIds((m) => m.plan === "pro" && m.tier === "gold")); + }); + + test("filters with OR", async ({ api }) => { + expect( + await listCommissions( + api, + "metadata['plan']='pro' OR metadata['plan']='enterprise'", + ), + ).toEqual(matchingIds((m) => m.plan === "pro" || m.plan === "enterprise")); + }); + + test("filters with AND on campaign", async ({ api }) => { + expect( + await listCommissions( + api, + "metadata['plan']='pro' AND metadata['campaign']='spring'", + ), + ).toEqual(matchingIds((m) => m.plan === "pro" && m.campaign === "spring")); + }); + + test("filters with OR on campaign", async ({ api }) => { + expect( + await listCommissions( + api, + "metadata['campaign']='spring' OR metadata['campaign']='fall'", + ), + ).toEqual( + matchingIds((m) => m.campaign === "spring" || m.campaign === "fall"), + ); + }); + + test("does not match numeric metadata values stored as numbers", async ({ + api, + }) => { + expect(await listCommissions(api, "metadata['seats']='10'")).toEqual([]); + expect(await listCommissions(api, "metadata['seats']='5'")).toEqual([]); + }); + + test("does not match boolean metadata values stored as booleans", async ({ + api, + }) => { + expect(await listCommissions(api, "metadata['active']='true'")).toEqual([]); + expect(await listCommissions(api, "metadata['active']='false'")).toEqual( + [], + ); + }); + + const invalidQueryCases = [ + { + name: "rejects nested metadata keys", + query: "metadata['a']['b']='value'", + message: + "Invalid metadata query. Use top-level keys only, e.g. `metadata['key']='value'`.", + }, + { + name: "rejects mixed AND and OR", + query: "metadata['a']='1' AND metadata['b']='2' OR metadata['c']='3'", + message: "Metadata query cannot mix AND and OR.", + }, + { + name: "rejects unsupported comparison operators", + query: "metadata['seats']>5", + message: "Metadata query only supports `=` and `!=` operators.", + }, + { + name: "rejects non-metadata fields", + query: "status:active", + message: + "Invalid metadata query. Use top-level keys only, e.g. `metadata['key']='value'`.", + }, + ]; + + for (const { name, query, message } of invalidQueryCases) { + test(name, async ({ api }) => { + expect( + await api.get( + `/api/commissions?${new URLSearchParams({ + partnerId: partnerId!, + query, + })}`, + ), + ).toEqual( + apiError({ + code: "unprocessable_entity", + message, + }), + ); + }); + } +}); diff --git a/apps/web/playwright/api/commissions/helpers.ts b/apps/web/playwright/api/commissions/helpers.ts new file mode 100644 index 00000000000..3c13aa1b8ed --- /dev/null +++ b/apps/web/playwright/api/commissions/helpers.ts @@ -0,0 +1,187 @@ +import { prisma } from "@/lib/prisma"; +import type { CommissionResponse } from "@/lib/types"; +import { expect } from "@playwright/test"; +import type { ApiClient } from "../fixtures"; +import { createPartner, deletePartner } from "../partners/helpers"; +import { TEST_COMMISSION_REWARDS } from "../setup-test-workspace"; + +export async function withCommissionPartner( + api: ApiClient, + program: { defaultGroupId: string }, + run: (partnerId: string) => Promise, +) { + let partnerId: string | undefined; + + try { + const { status, data } = await createPartner(api, { + groupId: program.defaultGroupId, + }); + partnerId = data.id; + expect(status).toEqual(201); + await run(partnerId); + } finally { + await deleteCommissionPartner({ partnerId }); + } +} + +export async function deleteCommissionPartner({ + partnerId, +}: { + partnerId: string | undefined; +}) { + if (partnerId) { + const links = await prisma.link.findMany({ + where: { + partnerId, + }, + select: { + id: true, + }, + }); + + await prisma.customer.deleteMany({ + where: { + OR: [ + { partnerId }, + ...(links.length > 0 + ? [{ linkId: { in: links.map((link) => link.id) } }] + : []), + ], + }, + }); + } + + await deletePartner(partnerId); +} + +export async function expectCommissionCreated({ + api, + partnerId, + programId, + type, + description, + invoiceId, + expectedAmount, + expectedEarnings, + expectedCreatedAt, + expectedMetadata, + expectedLinkId, +}: { + api: ApiClient; + partnerId: string; + programId: string; + type: "custom" | "lead" | "sale"; + description?: string; + invoiceId?: string; + expectedAmount?: number; + expectedEarnings?: number; + expectedCreatedAt?: Date; + expectedMetadata?: Record | null; + expectedLinkId?: string; +}): Promise { + const amount = + expectedAmount ?? (type === "lead" ? 0 : type === "sale" ? 1000 : 0); + const earnings = + expectedEarnings ?? + (type === "lead" + ? TEST_COMMISSION_REWARDS.lead.amountInCents + : type === "sale" + ? TEST_COMMISSION_REWARDS.sale.amountInCents + : 0); + const metadata = expectedMetadata === undefined ? null : expectedMetadata; + + let commissionId: string | undefined; + + await expect + .poll(async () => { + const commission = await prisma.commission.findFirst({ + where: { + partnerId, + programId, + type, + ...(description ? { description } : {}), + ...(invoiceId ? { invoiceId } : {}), + }, + orderBy: { + createdAt: "desc", + }, + }); + + if (!commission) { + return null; + } + + commissionId = commission.id; + + return { + partnerId: commission.partnerId, + programId: commission.programId, + type: commission.type, + amount: Number(commission.amount), + earnings: Number(commission.earnings), + quantity: commission.quantity, + description: commission.description, + invoiceId: commission.invoiceId, + currency: commission.currency, + createdAt: commission.createdAt.toISOString(), + metadata: commission.metadata, + ...(expectedLinkId !== undefined ? { linkId: commission.linkId } : {}), + }; + }) + .toEqual({ + partnerId, + programId, + type, + amount, + earnings, + quantity: 1, + description: description ?? null, + invoiceId: invoiceId ?? null, + currency: "usd", + createdAt: expectedCreatedAt + ? expectedCreatedAt.toISOString() + : expect.any(String), + metadata, + ...(expectedLinkId !== undefined ? { linkId: expectedLinkId } : {}), + }); + + if (!commissionId) { + throw new Error("Commission was not created"); + } + + const listQuery = new URLSearchParams({ + partnerId, + type, + ...(invoiceId ? { invoiceId } : {}), + }); + + const { status: listStatus, data: commissions } = await api.get< + CommissionResponse[] + >(`/api/commissions?${listQuery}`); + + expect(listStatus).toEqual(200); + + const listed = invoiceId + ? commissions.find((c) => c.invoiceId === invoiceId) + : description + ? commissions.find((c) => c.description === description) + : commissions.find((c) => c.id === commissionId); + + expect(listed).toMatchObject({ + id: commissionId, + type, + metadata, + }); + + const { status: detailStatus, data: detail } = + await api.get(`/api/commissions/${commissionId}`); + + expect(detailStatus).toEqual(200); + expect(detail).toMatchObject({ + id: commissionId, + type, + metadata, + }); + + return commissionId; +} diff --git a/apps/web/playwright/api/constants.ts b/apps/web/playwright/api/constants.ts new file mode 100644 index 00000000000..ff56f224175 --- /dev/null +++ b/apps/web/playwright/api/constants.ts @@ -0,0 +1 @@ +export const PLAYWRIGHT_API_BASE = "http://localhost:8888"; diff --git a/apps/web/playwright/api/conversions/helpers.ts b/apps/web/playwright/api/conversions/helpers.ts new file mode 100644 index 00000000000..4bf14a7edfc --- /dev/null +++ b/apps/web/playwright/api/conversions/helpers.ts @@ -0,0 +1,113 @@ +import { nanoid } from "@dub/utils"; +import { expect } from "@playwright/test"; +import { readFileSync } from "fs"; +import path from "path"; +import { randomCustomer } from "../../utils"; +import { PLAYWRIGHT_API_BASE } from "../constants"; + +const TRACK_CLICK_HEADERS = { + referer: "https://dub.co", + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", +}; + +function playwrightApiToken() { + return JSON.parse( + readFileSync(path.join(__dirname, "../../.auth/api.json"), "utf-8"), + ).token as string; +} + +async function postAuthenticatedJson( + url: string, + body: unknown, + extraHeaders: Record = {}, +) { + const response = await fetch(`${PLAYWRIGHT_API_BASE}${url}`, { + method: "POST", + headers: { + Authorization: `Bearer ${playwrightApiToken()}`, + "Content-Type": "application/json", + ...extraHeaders, + }, + body: JSON.stringify(body), + }); + + return { + status: response.status, + data: (await response.json()) as Record, + }; +} + +export async function trackClick({ + domain, + key, +}: { + domain: string; + key: string; +}) { + const { status, data } = await postAuthenticatedJson( + "/api/track/click", + { domain, key }, + TRACK_CLICK_HEADERS, + ); + + expect(status, JSON.stringify(data)).toEqual(200); + expect(data.clickId).toEqual(expect.any(String)); + + return data as { clickId: string }; +} + +export async function trackLead({ + clickId, + ...overrides +}: { + clickId: string; +} & Record) { + const customer = randomCustomer(); + + const { status, data } = await postAuthenticatedJson("/api/track/lead", { + clickId, + eventName: `Signup-${nanoid()}`, + customerExternalId: customer.externalId, + customerEmail: customer.email, + customerName: customer.name, + mode: "wait", + ...overrides, + }); + + expect(status, JSON.stringify(data)).toEqual(200); + + return { + customer, + data, + }; +} + +export async function trackSale({ + customerExternalId, + ...overrides +}: { + customerExternalId: string; +} & Record) { + const invoiceId = + (overrides.invoiceId as string | undefined) ?? `INV_${nanoid()}`; + const amount = (overrides.amount as number | undefined) ?? 1000; + + const { status, data } = await postAuthenticatedJson("/api/track/sale", { + customerExternalId, + amount, + currency: "usd", + paymentProcessor: "stripe", + eventName: "Purchase", + invoiceId, + ...overrides, + }); + + expect(status, JSON.stringify(data)).toEqual(200); + + return { + invoiceId, + amount, + data, + }; +} diff --git a/apps/web/playwright/api/customers/customers-pagination.spec.ts b/apps/web/playwright/api/customers/customers-pagination.spec.ts index 9792aad5a82..5aae7a8d0b3 100644 --- a/apps/web/playwright/api/customers/customers-pagination.spec.ts +++ b/apps/web/playwright/api/customers/customers-pagination.spec.ts @@ -4,6 +4,7 @@ import type { Customer } from "@/lib/types"; import { nanoid } from "@dub/utils"; import { expect } from "@playwright/test"; import { + apiError, expectNoOverlap, expectSortedByCreatedAt, expectSortedById, @@ -13,61 +14,50 @@ import { test } from "../fixtures"; const PAGE_SIZE = 5; const SEED_COUNT = 25; -test.describe.configure({ - mode: "parallel", -}); - test("GET /customers – rejects both startingAfter and endingBefore", async ({ api, }) => { - const { status, data: error } = await api.get( - `/api/customers?${new URLSearchParams({ - pageSize: String(PAGE_SIZE), - startingAfter: "id", - endingBefore: "id", - })}`, - ); - - expect(status).toEqual(422); - expect(error).toStrictEqual({ - error: { + expect( + await api.get( + `/api/customers?${new URLSearchParams({ + pageSize: String(PAGE_SIZE), + startingAfter: "id", + endingBefore: "id", + })}`, + ), + ).toEqual( + apiError({ code: "unprocessable_entity", message: "You cannot use both startingAfter and endingBefore at the same time.", - doc_url: "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }); + }), + ); }); test("GET /customers – rejects page > MAX_OFFSET_PAGE", async ({ api }) => { - const { status, data: error } = await api.get( - `/api/customers?${new URLSearchParams({ - page: "1001", - pageSize: "10", - })}`, - ); - - expect(status).toEqual(422); - expect(error).toStrictEqual({ - error: { + expect( + await api.get( + `/api/customers?${new URLSearchParams({ + page: "1001", + pageSize: "10", + })}`, + ), + ).toEqual( + apiError({ code: "unprocessable_entity", message: "Page is too big (cannot be more than 1000), recommend using cursor-based pagination instead.", - doc_url: "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }); + }), + ); }); test("GET /customers – invalid cursor ID (startingAfter / endingBefore)", async ({ api, }) => { - const invalidCursorError = { - error: { - code: "unprocessable_entity", - message: "Invalid cursor: the provided ID does not exist.", - doc_url: "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }; + const invalidCursorError = apiError({ + code: "unprocessable_entity", + message: "Invalid cursor: the provided ID does not exist.", + }); const { status: statusAfter, data: errorAfter } = await api.get( `/api/customers?${new URLSearchParams({ @@ -76,8 +66,7 @@ test("GET /customers – invalid cursor ID (startingAfter / endingBefore)", asyn })}`, ); - expect(statusAfter).toEqual(422); - expect(errorAfter).toStrictEqual(invalidCursorError); + expect({ status: statusAfter, data: errorAfter }).toEqual(invalidCursorError); const { status: statusBefore, data: errorBefore } = await api.get( `/api/customers?${new URLSearchParams({ @@ -86,8 +75,9 @@ test("GET /customers – invalid cursor ID (startingAfter / endingBefore)", asyn })}`, ); - expect(statusBefore).toEqual(422); - expect(errorBefore).toStrictEqual(invalidCursorError); + expect({ status: statusBefore, data: errorBefore }).toEqual( + invalidCursorError, + ); }); test.describe("with seeded customers", () => { @@ -224,15 +214,11 @@ test.describe("with seeded customers", () => { test("GET /customers – rejects mixing page with startingAfter / endingBefore", async ({ api, }) => { - const mixedPaginationError = { - error: { - code: "unprocessable_entity", - message: - "You cannot use both page and startingAfter/endingBefore at the same time. Please use one pagination method.", - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }; + const mixedPaginationError = apiError({ + code: "unprocessable_entity", + message: + "You cannot use both page and startingAfter/endingBefore at the same time. Please use one pagination method.", + }); const { status: statusAfter, data: errorAfter } = await api.get( `/api/customers?${new URLSearchParams({ @@ -242,8 +228,9 @@ test.describe("with seeded customers", () => { })}`, ); - expect(statusAfter).toEqual(422); - expect(errorAfter).toStrictEqual(mixedPaginationError); + expect({ status: statusAfter, data: errorAfter }).toEqual( + mixedPaginationError, + ); const { status: statusBefore, data: errorBefore } = await api.get( `/api/customers?${new URLSearchParams({ @@ -253,8 +240,9 @@ test.describe("with seeded customers", () => { })}`, ); - expect(statusBefore).toEqual(422); - expect(errorBefore).toStrictEqual(mixedPaginationError); + expect({ status: statusBefore, data: errorBefore }).toEqual( + mixedPaginationError, + ); }); test("GET /customers – rejects cursor pagination with unsupported sort field", async ({ @@ -268,15 +256,12 @@ test.describe("with seeded customers", () => { })}`, ); - expect(status).toEqual(422); - expect(error).toStrictEqual({ - error: { + expect({ status, data: error }).toEqual( + apiError({ code: "unprocessable_entity", message: "Cursor-based pagination only supports sorting by `createdAt`. Use offset-based pagination (page/pageSize) for other sort fields.", - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }); + }), + ); }); }); diff --git a/apps/web/playwright/api/customers/customers.spec.ts b/apps/web/playwright/api/customers/customers.spec.ts index ed492a90a8e..9dd31d0035a 100644 --- a/apps/web/playwright/api/customers/customers.spec.ts +++ b/apps/web/playwright/api/customers/customers.spec.ts @@ -36,10 +36,6 @@ async function deleteCustomer(api: ApiClient, id: string | undefined) { await api.delete(`/api/customers/${id}`); } -test.describe.configure({ - mode: "parallel", -}); - test("POST /customers", async ({ api }) => { let customerId: string | undefined; const body = randomCustomer(); @@ -106,6 +102,7 @@ test("PATCH /customers/{id}", async ({ api }) => { const toUpdate = { name: "Updated", avatar: "https://api.dub.co/og/avatar/1234567890", + country: "BR", }; const { status, data } = await api.patch( diff --git a/apps/web/playwright/api/discount-codes/discount-codes.spec.ts b/apps/web/playwright/api/discount-codes/discount-codes.spec.ts new file mode 100644 index 00000000000..df7b2d04911 --- /dev/null +++ b/apps/web/playwright/api/discount-codes/discount-codes.spec.ts @@ -0,0 +1,514 @@ +import { createId } from "@/lib/api/create-id"; +import { constructDiscountCode } from "@/lib/discounts/construct-discount-code"; +import { prisma } from "@/lib/prisma"; +import { DiscountCodeSchema } from "@/lib/zod/schemas/discount"; +import { DEFAULT_ADDITIONAL_PARTNER_LINKS } from "@/lib/zod/schemas/groups"; +import { nanoid } from "@dub/utils"; +import { expect } from "@playwright/test"; +import { DiscountProvider, RewardStructure } from "@prisma/client"; +import * as z from "zod/v4"; +import { test, type ApiClient } from "../fixtures"; +import { + createPartner as createPartnerApi, + deletePartner, +} from "../partners/helpers"; +import { TEST_WORKSPACE } from "../setup-test-workspace"; + +type DiscountCode = z.infer; + +test.describe.configure({ + mode: "parallel", +}); + +const customDiscount = { + amount: 10, + type: RewardStructure.percentage, + maxDuration: 6, + provider: DiscountProvider.custom, +}; + +let customDiscountId: string | undefined; +let partnerGroupId: string | undefined; + +test.beforeAll(async ({ program }) => { + const discount = await prisma.discount.create({ + data: { + id: createId({ prefix: "disc_" }), + programId: program.id, + ...customDiscount, + }, + }); + + const group = await prisma.partnerGroup.create({ + data: { + id: createId({ prefix: "grp_" }), + programId: program.id, + slug: `pw-dcode-${nanoid(8).toLowerCase()}`, + name: "Playwright Discount Codes", + maxPartnerLinks: DEFAULT_ADDITIONAL_PARTNER_LINKS, + discountId: discount.id, + }, + }); + + await prisma.partnerGroupDefaultLink.create({ + data: { + id: createId({ prefix: "pgdl_" }), + programId: program.id, + groupId: group.id, + domain: TEST_WORKSPACE.program.domain, + url: TEST_WORKSPACE.program.url, + }, + }); + + partnerGroupId = group.id; + customDiscountId = discount.id; +}); + +test.afterAll(async () => { + if (partnerGroupId) { + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + groupId: partnerGroupId, + }, + select: { + partnerId: true, + }, + }); + + for (const enrollment of programEnrollments) { + await deletePartner(enrollment.partnerId); + } + + await prisma.partnerGroupDefaultLink.deleteMany({ + where: { + groupId: partnerGroupId, + }, + }); + + await prisma.partnerGroup.delete({ + where: { + id: partnerGroupId, + }, + }); + } + + if (customDiscountId) { + await prisma.discountCode.deleteMany({ + where: { + discountId: customDiscountId, + }, + }); + + await prisma.programEnrollment.updateMany({ + where: { + discountId: customDiscountId, + }, + data: { + discountId: null, + }, + }); + + await prisma.discount.delete({ + where: { + id: customDiscountId, + }, + }); + } +}); + +async function createPartner( + api: ApiClient, + overrides: Record = {}, +) { + if (!partnerGroupId) { + throw new Error("Custom discount group was not seeded."); + } + + return createPartnerApi(api, { + groupId: partnerGroupId, + ...overrides, + }); +} + +async function createDiscountCode( + api: ApiClient, + overrides: Record = {}, +) { + const { data: partner } = await createPartner(api); + const linkId = partner.links?.[0]?.id; + + if (!linkId) { + throw new Error("Partner was created without a default link."); + } + + const body = { + partnerId: partner.id, + linkId, + code: `PW${nanoid(8)}`, + ...overrides, + }; + + const response = await api.post("/api/discount-codes", body); + + return { partner, linkId, body, ...response }; +} + +test("POST /discount-codes", async ({ api }) => { + let partnerId: string | undefined; + + try { + const { status, data, partner, body } = await createDiscountCode(api); + partnerId = partner.id; + + expect(status).toEqual(200); + expect(data).toEqual({ + id: expect.any(String), + code: body.code, + discountId: customDiscountId, + partnerId: partner.id, + linkId: body.linkId, + disabledAt: null, + }); + } finally { + await deletePartner(partnerId); + } +}); + +test("POST /discount-codes – omits code and auto-generates", async ({ + api, +}) => { + let partnerId: string | undefined; + + try { + const { data: partner } = await createPartner(api); + partnerId = partner.id; + const linkId = partner.links?.[0]?.id; + + const { status, data } = await api.post( + "/api/discount-codes", + { + partnerId: partner.id, + linkId, + }, + ); + + expect(status).toEqual(200); + expect(data.code).toEqual(expect.any(String)); + expect(data.code.length).toBeGreaterThan(0); + expect(data.partnerId).toEqual(partner.id); + } finally { + await deletePartner(partnerId); + } +}); + +test("POST /discount-codes – empty code auto-generates", async ({ api }) => { + let partnerId: string | undefined; + + try { + const { data: partner } = await createPartner(api); + partnerId = partner.id; + const linkId = partner.links?.[0]?.id; + + const { status, data } = await api.post( + "/api/discount-codes", + { + partnerId: partner.id, + linkId, + code: "", + }, + ); + + expect(status).toEqual(200); + expect(data.code).toEqual(expect.any(String)); + expect(data.code.length).toBeGreaterThan(0); + expect(data.partnerId).toEqual(partner.id); + } finally { + await deletePartner(partnerId); + } +}); + +test("POST /discount-codes – auto-generated first-name collision retries", async ({ + api, +}) => { + let partnerIdA: string | undefined; + let partnerIdB: string | undefined; + + try { + const firstName = `Sarah${nanoid(6)}`; + const { data: partnerA } = await createPartner(api, { + name: `${firstName} One`, + }); + const { data: partnerB } = await createPartner(api, { + name: `${firstName} Two`, + }); + partnerIdA = partnerA.id; + partnerIdB = partnerB.id; + + const expectedBase = constructDiscountCode({ + partner: partnerA, + discount: customDiscount, + }); + + const first = await api.post("/api/discount-codes", { + partnerId: partnerA.id, + linkId: partnerA.links?.[0]?.id, + }); + const second = await api.post("/api/discount-codes", { + partnerId: partnerB.id, + linkId: partnerB.links?.[0]?.id, + }); + + expect(first.status).toEqual(200); + expect(second.status).toEqual(200); + expect(first.data.code).toEqual(expectedBase); + expect(second.data.code).not.toEqual(first.data.code); + expect(second.data.code.startsWith(expectedBase)).toBe(true); + expect(second.data.code.length).toEqual(expectedBase.length + 2); + } finally { + await deletePartner(partnerIdA); + await deletePartner(partnerIdB); + } +}); + +test("POST /discount-codes – same link", async ({ api }) => { + let partnerId: string | undefined; + + try { + const created = await createDiscountCode(api); + partnerId = created.partner.id; + + const { status, data } = await api.post("/api/discount-codes", { + partnerId: created.partner.id, + linkId: created.linkId, + code: `PW${nanoid(8)}`, + }); + + expect(status).toEqual(400); + expect(data).toEqual({ + error: { + code: "bad_request", + message: `This link already has a discount code (${created.data.code}) assigned.`, + doc_url: "https://dub.co/docs/api-reference/errors#bad-request", + }, + }); + } finally { + await deletePartner(partnerId); + } +}); + +test("POST /discount-codes – duplicate code", async ({ api }) => { + let partnerIdA: string | undefined; + let partnerIdB: string | undefined; + + try { + const first = await createDiscountCode(api); + partnerIdA = first.partner.id; + + const { data: partnerB } = await createPartner(api); + partnerIdB = partnerB.id; + + const { status, data } = await api.post("/api/discount-codes", { + partnerId: partnerB.id, + linkId: partnerB.links?.[0]?.id, + code: first.body.code, + }); + + expect(status).toEqual(409); + expect(data).toMatchObject({ + error: { + code: "conflict", + message: expect.stringContaining( + `This discount code "${first.body.code}" is already in use`, + ), + doc_url: "https://dub.co/docs/api-reference/errors#conflict", + }, + }); + } finally { + await deletePartner(partnerIdA); + await deletePartner(partnerIdB); + } +}); + +const invalidCodeCases = [ + { + name: "POST /discount-codes – invalid characters", + code: "NOT VALID!", + message: + "invalid_format: code: Code can only contain letters, numbers, dashes, and underscores.", + }, + { + name: "POST /discount-codes – too long", + code: "A".repeat(101), + message: "too_big: code: Code must be 100 characters or fewer.", + }, +]; + +for (const { name, code, message } of invalidCodeCases) { + test(name, async ({ api }) => { + expect( + await api.post("/api/discount-codes", { + partnerId: "pn_x", + linkId: "link_x", + code, + }), + ).toEqual({ + status: 422, + data: { + error: { + code: "unprocessable_entity", + message, + doc_url: + "https://dub.co/docs/api-reference/errors#unprocessable-entity", + }, + }, + }); + }); +} + +test("POST /discount-codes – missing partnerId", async ({ api }) => { + expect( + await api.post("/api/discount-codes", { + linkId: "link_missing", + code: `PW${nanoid(8)}`, + }), + ).toEqual({ + status: 422, + data: { + error: { + code: "unprocessable_entity", + message: + "invalid_type: partnerId: Invalid input: expected string, received undefined", + doc_url: + "https://dub.co/docs/api-reference/errors#unprocessable-entity", + }, + }, + }); +}); + +test("GET /discount-codes – by partnerId", async ({ api }) => { + let partnerId: string | undefined; + + try { + const created = await createDiscountCode(api); + partnerId = created.partner.id; + + const { status, data } = await api.get( + `/api/discount-codes?partnerId=${partnerId}`, + ); + + expect(status).toEqual(200); + expect(data).toEqual([created.data]); + } finally { + await deletePartner(partnerId); + } +}); + +test("GET /discount-codes – by discountId", async ({ api }) => { + let partnerId: string | undefined; + + try { + const created = await createDiscountCode(api); + partnerId = created.partner.id; + + const { status, data } = await api.get( + `/api/discount-codes?discountId=${created.data.discountId}&partnerId=${partnerId}`, + ); + + expect(status).toEqual(200); + expect(data.map((code) => code.id)).toContain(created.data.id); + } finally { + await deletePartner(partnerId); + } +}); + +test("GET /discount-codes – pagination", async ({ api }) => { + let partnerIdA: string | undefined; + let partnerIdB: string | undefined; + + try { + const first = await createDiscountCode(api); + const second = await createDiscountCode(api); + partnerIdA = first.partner.id; + partnerIdB = second.partner.id; + + const { status, data } = await api.get( + "/api/discount-codes?pageSize=1&page=1", + ); + + expect(status).toEqual(200); + expect(data).toHaveLength(1); + } finally { + await deletePartner(partnerIdA); + await deletePartner(partnerIdB); + } +}); + +test("GET /discount-codes – unknown partner", async ({ api, program }) => { + expect( + await api.get("/api/discount-codes?partnerId=pn_does_not_exist"), + ).toEqual({ + status: 404, + data: { + error: { + code: "not_found", + message: `Partner pn_does_not_exist is not enrolled in program ${program.id}.`, + doc_url: "https://dub.co/docs/api-reference/errors#not-found", + }, + }, + }); +}); + +test("DELETE /discount-codes/{idOrCode} – by id", async ({ api }) => { + let partnerId: string | undefined; + + try { + const created = await createDiscountCode(api); + partnerId = created.partner.id; + + const { status, data } = await api.delete<{ id: string }>( + `/api/discount-codes/${created.data.id}`, + ); + + expect(status).toEqual(200); + expect(data).toEqual({ id: created.data.id }); + } finally { + await deletePartner(partnerId); + } +}); + +test("DELETE /discount-codes/{idOrCode} – by code", async ({ api }) => { + let partnerId: string | undefined; + + try { + const created = await createDiscountCode(api); + partnerId = created.partner.id; + + const { status, data } = await api.delete<{ id: string }>( + `/api/discount-codes/${created.data.code}`, + ); + + expect(status).toEqual(200); + expect(data).toEqual({ id: created.data.id }); + } finally { + await deletePartner(partnerId); + } +}); + +for (const idOrCode of ["dcode_does_not_exist", "CODE_DOES_NOT_EXIST"]) { + test(`DELETE /discount-codes/{idOrCode} – not found (${idOrCode})`, async ({ + api, + }) => { + const { status, data } = await api.delete( + `/api/discount-codes/${idOrCode}`, + ); + + expect(status).toEqual(404); + expect(data).toEqual({ + error: { + code: "not_found", + message: `Discount code (${idOrCode}) not found.`, + doc_url: "https://dub.co/docs/api-reference/errors#not-found", + }, + }); + }); +} diff --git a/apps/web/playwright/api/discounts/discounts.spec.ts b/apps/web/playwright/api/discounts/discounts.spec.ts new file mode 100644 index 00000000000..31104f59700 --- /dev/null +++ b/apps/web/playwright/api/discounts/discounts.spec.ts @@ -0,0 +1,357 @@ +import { createId } from "@/lib/api/create-id"; +import { prisma } from "@/lib/prisma"; +import type { + Customer, + CustomerEnriched, + DiscountProps, + EnrolledPartnerProps, + GroupProps, +} from "@/lib/types"; +import { DEFAULT_ADDITIONAL_PARTNER_LINKS } from "@/lib/zod/schemas/groups"; +import { nanoid } from "@dub/utils"; +import { expect } from "@playwright/test"; +import { DiscountProvider, RewardStructure } from "@prisma/client"; +import { randomCustomer, randomName } from "../../utils"; +import { test, type ApiClient } from "../fixtures"; +import { + createPartner as createPartnerApi, + deletePartner, +} from "../partners/helpers"; +import { TEST_WORKSPACE } from "../setup-test-workspace"; + +test.describe.configure({ + mode: "parallel", +}); + +const customDiscount = { + amount: 10, + type: RewardStructure.percentage, + maxDuration: 6, + provider: DiscountProvider.custom, +}; + +const expectedCustomDiscount = { + ...customDiscount, + couponId: null, + couponTestId: null, + description: null, + autoProvisionEnabledAt: null, +}; + +const expectedCustomerDiscount = { + id: expect.any(String), + amount: customDiscount.amount, + type: customDiscount.type, + maxDuration: customDiscount.maxDuration, + couponId: null, + couponTestId: null, + description: null, +}; + +let customDiscountId: string | undefined; +let partnerGroupId: string | undefined; + +test.beforeAll(async ({ program }) => { + const discount = await prisma.discount.create({ + data: { + id: createId({ prefix: "disc_" }), + programId: program.id, + ...customDiscount, + }, + }); + + const group = await prisma.partnerGroup.create({ + data: { + id: createId({ prefix: "grp_" }), + programId: program.id, + slug: `pw-disc-${nanoid(8).toLowerCase()}`, + name: "Playwright Custom Discounts", + maxPartnerLinks: DEFAULT_ADDITIONAL_PARTNER_LINKS, + discountId: discount.id, + }, + }); + + await prisma.partnerGroupDefaultLink.create({ + data: { + id: createId({ prefix: "pgdl_" }), + programId: program.id, + groupId: group.id, + domain: TEST_WORKSPACE.program.domain, + url: TEST_WORKSPACE.program.url, + }, + }); + + partnerGroupId = group.id; + customDiscountId = discount.id; +}); + +test.afterAll(async () => { + if (partnerGroupId) { + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + groupId: partnerGroupId, + }, + select: { + partnerId: true, + }, + }); + + for (const enrollment of programEnrollments) { + await deletePartner(enrollment.partnerId); + } + + await prisma.partnerGroupDefaultLink.deleteMany({ + where: { + groupId: partnerGroupId, + }, + }); + + await prisma.partnerGroup.delete({ + where: { + id: partnerGroupId, + }, + }); + } + + if (customDiscountId) { + await prisma.discountCode.deleteMany({ + where: { + discountId: customDiscountId, + }, + }); + + await prisma.programEnrollment.updateMany({ + where: { + discountId: customDiscountId, + }, + data: { + discountId: null, + }, + }); + + await prisma.discount.delete({ + where: { + id: customDiscountId, + }, + }); + } +}); + +async function createPartner(api: ApiClient) { + if (!partnerGroupId) { + throw new Error("Custom discount group was not seeded."); + } + + return createPartnerApi(api, { + groupId: partnerGroupId, + }); +} + +async function createCustomerWithCustomDiscount({ + api, + program, +}: { + api: ApiClient; + program: { id: string }; +}) { + const { data: partner } = await createPartner(api); + const linkId = partner.links?.[0]?.id; + + if (!linkId) { + throw new Error("Partner was created without a default link."); + } + + const { data: customer } = await api.post( + "/api/customers", + randomCustomer(), + ); + + await prisma.customer.update({ + where: { + id: customer.id, + }, + data: { + linkId, + partnerId: partner.id, + programId: program.id, + }, + }); + + return { partner, customer }; +} + +async function deleteCustomer(api: ApiClient, id: string | undefined) { + if (!id) return; + await api.delete(`/api/customers/${id}`); +} + +test("GET /programs/{programId}/discounts – custom provider", async ({ + api, + program, +}) => { + const { status, data } = await api.get( + `/api/programs/${program.id}/discounts`, + ); + + expect(status).toEqual(200); + + const discount = data.find((item) => item.id === customDiscountId); + + expect(discount).toEqual({ + id: customDiscountId, + ...expectedCustomDiscount, + partnersCount: expect.any(Number), + }); +}); + +test("GET /groups/{id} – nested custom discount", async ({ api }) => { + const { status, data } = await api.get( + `/api/groups/${partnerGroupId}`, + ); + + expect(status).toEqual(200); + expect(data.discount).toEqual({ + id: customDiscountId, + ...expectedCustomDiscount, + }); +}); + +test("GET /groups/{id} – group without discount", async ({ api, program }) => { + let groupId: string | undefined; + + try { + const group = await prisma.partnerGroup.create({ + data: { + id: createId({ prefix: "grp_" }), + programId: program.id, + slug: `pw-nodisc-${nanoid(8).toLowerCase()}`, + name: randomName("group"), + maxPartnerLinks: DEFAULT_ADDITIONAL_PARTNER_LINKS, + }, + }); + groupId = group.id; + + const { status, data } = await api.get( + `/api/groups/${groupId}`, + ); + + expect(status).toEqual(200); + expect(data.discount).toBeNull(); + } finally { + if (groupId) { + await prisma.partnerGroup.delete({ + where: { + id: groupId, + }, + }); + } + } +}); + +test("GET /partners/{id} – custom discount", async ({ api }) => { + let partnerId: string | undefined; + + try { + const { data: partner } = await createPartner(api); + partnerId = partner.id; + + const { status, data } = await api.get< + EnrolledPartnerProps & { + discount: Pick | null; + } + >(`/api/partners/${partnerId}`); + + expect(status).toEqual(200); + expect(data.discount).toEqual({ + id: customDiscountId, + provider: DiscountProvider.custom, + }); + } finally { + await deletePartner(partnerId); + } +}); + +test("GET /customers/{id} – custom discount", async ({ api, program }) => { + let partnerId: string | undefined; + let customerId: string | undefined; + + try { + const { partner, customer } = await createCustomerWithCustomDiscount({ + api, + program, + }); + partnerId = partner.id; + customerId = customer.id; + + const { status, data } = await api.get( + `/api/customers/${customerId}?includeExpandedFields=true`, + ); + + expect(status).toEqual(200); + expect(data.discount).toMatchObject({ + ...expectedCustomerDiscount, + id: customDiscountId, + }); + expect(data.discount).not.toHaveProperty("provider"); + } finally { + await deleteCustomer(api, customerId); + await deletePartner(partnerId); + } +}); + +test("GET /customers?email= – custom discount", async ({ api, program }) => { + let partnerId: string | undefined; + let customerId: string | undefined; + + try { + const { partner, customer } = await createCustomerWithCustomDiscount({ + api, + program, + }); + partnerId = partner.id; + customerId = customer.id; + + const { status, data: customers } = await api.get( + `/api/customers?email=${encodeURIComponent(customer.email!)}&includeExpandedFields=true`, + ); + + expect(status).toEqual(200); + expect(customers[0].discount).toMatchObject({ + ...expectedCustomerDiscount, + id: customDiscountId, + }); + } finally { + await deleteCustomer(api, customerId); + await deletePartner(partnerId); + } +}); + +test("GET /customers?externalId= – custom discount", async ({ + api, + program, +}) => { + let partnerId: string | undefined; + let customerId: string | undefined; + + try { + const { partner, customer } = await createCustomerWithCustomDiscount({ + api, + program, + }); + partnerId = partner.id; + customerId = customer.id; + + const { status, data: customers } = await api.get( + `/api/customers?externalId=${encodeURIComponent(customer.externalId!)}&includeExpandedFields=true`, + ); + + expect(status).toEqual(200); + expect(customers[0].discount).toMatchObject({ + ...expectedCustomerDiscount, + id: customDiscountId, + }); + } finally { + await deleteCustomer(api, customerId); + await deletePartner(partnerId); + } +}); diff --git a/apps/web/playwright/api/domains/domains.spec.ts b/apps/web/playwright/api/domains/domains.spec.ts index 757b776d883..70ba8a29868 100644 --- a/apps/web/playwright/api/domains/domains.spec.ts +++ b/apps/web/playwright/api/domains/domains.spec.ts @@ -1,6 +1,6 @@ import type { DomainProps } from "@/lib/types"; import { expect } from "@playwright/test"; -import { randomName } from "../../utils"; +import { apiError, randomName } from "../../utils"; import { test, type ApiClient } from "../fixtures"; test.describe.configure({ @@ -283,51 +283,31 @@ test("DELETE /domains/{slug}", async ({ api }) => { }); test("GET /domains/status – not eligible", async ({ api }) => { - const { status, data } = await api.get( - "/api/domains/status?domains=example.link", - ); - - expect(status).toEqual(403); - expect(data).toEqual({ - error: { + expect(await api.get("/api/domains/status?domains=example.link")).toEqual( + apiError({ code: "forbidden", message: "GET /domains/status is not available for your workspace. Contact support for more information.", - doc_url: "https://dub.co/docs/api-reference/errors#forbidden", - }, - }); + }), + ); }); const errorCases = [ { name: "POST /domains – without slug", body: {}, - expected: { - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: "invalid_type: slug: slug is required", - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }, + expected: apiError({ + code: "unprocessable_entity", + message: "invalid_type: slug: slug is required", + }), }, { name: "POST /domains – invalid domain", body: { slug: "not a domain" }, - expected: { - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: "Invalid domain", - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }, + expected: apiError({ + code: "unprocessable_entity", + message: "Invalid domain", + }), }, ]; @@ -348,14 +328,12 @@ test("POST /domains – existing slug", async ({ api }) => { slug, }); - expect(status).toEqual(409); - expect(data).toEqual({ - error: { + expect({ status, data }).toEqual( + apiError({ code: "conflict", message: "Domain is already in use.", - doc_url: "https://dub.co/docs/api-reference/errors#conflict", - }, - }); + }), + ); } finally { await deleteDomain(api, slug); } @@ -366,14 +344,12 @@ test("GET /domains/{slug} – not found", async ({ api }) => { const { status, data } = await api.get(`/api/domains/${slug}`); - expect(status).toEqual(404); - expect(data).toEqual({ - error: { + expect({ status, data }).toEqual( + apiError({ code: "not_found", message: `Domain ${slug} not found.`, - doc_url: "https://dub.co/docs/api-reference/errors#not-found", - }, - }); + }), + ); }); test.describe("JSON config fields", () => { @@ -490,17 +466,12 @@ test.describe("JSON config fields", () => { ...domainBody(slug), [field]: INVALID_JSON, }), - ).toEqual({ - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: `Invalid ${label}`, - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }); + ).toEqual( + apiError({ + code: "unprocessable_entity", + message: `Invalid ${label}`, + }), + ); }); test(`PATCH /domains/{slug} – invalid ${field} JSON`, async ({ api }) => { @@ -514,17 +485,12 @@ test.describe("JSON config fields", () => { await api.patch(`/api/domains/${slug}`, { [field]: INVALID_JSON, }), - ).toEqual({ - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: `Invalid ${label}`, - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }); + ).toEqual( + apiError({ + code: "unprocessable_entity", + message: `Invalid ${label}`, + }), + ); } finally { await deleteDomain(api, slug); } diff --git a/apps/web/playwright/api/embed/referrals-links.spec.ts b/apps/web/playwright/api/embed/referrals-links.spec.ts new file mode 100644 index 00000000000..24c71ce5dcb --- /dev/null +++ b/apps/web/playwright/api/embed/referrals-links.spec.ts @@ -0,0 +1,245 @@ +import { prisma } from "@/lib/prisma"; +import { nanoid } from "@dub/utils"; +import { expect } from "@playwright/test"; +import { apiError } from "../../utils"; +import { createBearerApiClient, test } from "../fixtures"; +import { + createGroupWithAdditionalLinks, + createPartner, + deletePartner, +} from "../partners/helpers"; +import { TEST_WORKSPACE } from "../setup-test-workspace"; + +type EmbedLink = { + id: string; + domain: string; + key: string; + url: string; +}; + +test.describe.configure({ mode: "serial" }); + +test("GET /embed/referrals/links", async ({ api, playwright }) => { + let partnerId: string | undefined; + + try { + const { data: partner } = await createPartner(api); + partnerId = partner.id; + + const { status: tokenStatus, data: token } = await api.post<{ + publicToken: string; + }>("/api/tokens/embed/referrals", { partnerId: partner.id }); + expect(tokenStatus).toEqual(201); + + const { api: embedApi, dispose } = await createBearerApiClient({ + playwright, + token: token.publicToken, + }); + + try { + const { status, data } = await embedApi.get( + "/api/embed/referrals/links", + ); + + expect(status).toEqual(200); + expect(data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: partner.links![0].id, + domain: TEST_WORKSPACE.program.domain, + }), + ]), + ); + } finally { + await dispose(); + } + } finally { + await deletePartner(partnerId); + } +}); + +test("POST /embed/referrals/links - default URL", async ({ + api, + playwright, +}) => { + let partnerId: string | undefined; + + try { + const { data: partner } = await createPartner(api); + partnerId = partner.id; + + const { data: token } = await api.post<{ publicToken: string }>( + "/api/tokens/embed/referrals", + { partnerId: partner.id }, + ); + const { api: embedApi, dispose } = await createBearerApiClient({ + playwright, + token: token.publicToken, + }); + const key = nanoid(8); + + try { + const { status, data } = await embedApi.post( + "/api/embed/referrals/links", + { key }, + ); + + expect(status).toEqual(201); + expect(data).toMatchObject({ + id: expect.any(String), + domain: TEST_WORKSPACE.program.domain, + key, + shortLink: `https://${TEST_WORKSPACE.program.domain}/${key}`, + }); + } finally { + await dispose(); + } + } finally { + await deletePartner(partnerId); + } +}); + +test("POST /embed/referrals/links - URL outside additionalLinks", async ({ + api, + playwright, +}) => { + let partnerId: string | undefined; + + try { + const { data: partner } = await createPartner(api); + partnerId = partner.id; + + const { data: token } = await api.post<{ publicToken: string }>( + "/api/tokens/embed/referrals", + { partnerId: partner.id }, + ); + const { api: embedApi, dispose } = await createBearerApiClient({ + playwright, + token: token.publicToken, + }); + + try { + expect( + await embedApi.post("/api/embed/referrals/links", { + key: nanoid(8), + url: `https://github.com/dubinc/${nanoid()}`, + }), + ).toEqual( + apiError({ + code: "bad_request", + message: "You cannot create additional links for this program.", + }), + ); + } finally { + await dispose(); + } + } finally { + await deletePartner(partnerId); + } +}); + +test("POST /embed/referrals/links - allowed additionalLinks domain", async ({ + api, + program, + playwright, +}) => { + let partnerId: string | undefined; + let groupId: string | undefined; + + try { + const group = await createGroupWithAdditionalLinks(program.id); + groupId = group.id; + + const { data: partner } = await createPartner(api, { groupId }); + partnerId = partner.id; + + const { data: token } = await api.post<{ publicToken: string }>( + "/api/tokens/embed/referrals", + { partnerId: partner.id }, + ); + const { api: embedApi, dispose } = await createBearerApiClient({ + playwright, + token: token.publicToken, + }); + const url = `https://example.com/${nanoid()}`; + + try { + const { status, data } = await embedApi.post( + "/api/embed/referrals/links", + { key: nanoid(8), url }, + ); + + expect(status).toEqual(201); + expect(data.url).toEqual(url); + } finally { + await dispose(); + } + } finally { + await deletePartner(partnerId); + if (groupId) await prisma.partnerGroup.delete({ where: { id: groupId } }); + } +}); + +test("POST /embed/referrals/links - mismatched additionalLinks domain", async ({ + api, + program, + playwright, +}) => { + let partnerId: string | undefined; + let groupId: string | undefined; + + try { + const group = await createGroupWithAdditionalLinks(program.id); + groupId = group.id; + + const { data: partner } = await createPartner(api, { groupId }); + partnerId = partner.id; + + const { data: token } = await api.post<{ publicToken: string }>( + "/api/tokens/embed/referrals", + { partnerId: partner.id }, + ); + const { api: embedApi, dispose } = await createBearerApiClient({ + playwright, + token: token.publicToken, + }); + + try { + expect( + await embedApi.post("/api/embed/referrals/links", { + key: nanoid(8), + url: `https://github.com/dubinc/${nanoid()}`, + }), + ).toEqual( + apiError({ + code: "bad_request", + message: + "The provided URL's domain (github.com) does not match the program's link domains.", + }), + ); + } finally { + await dispose(); + } + } finally { + await deletePartner(partnerId); + if (groupId) await prisma.partnerGroup.delete({ where: { id: groupId } }); + } +}); + +test("GET /embed/referrals/links - invalid token", async ({ playwright }) => { + const { api: embedApi, dispose } = await createBearerApiClient({ + playwright, + token: "dub_embed_invalid", + }); + + try { + expect(await embedApi.get("/api/embed/referrals/links")).toEqual( + apiError({ + code: "unauthorized", + message: "Invalid embed public token.", + }), + ); + } finally { + await dispose(); + } +}); diff --git a/apps/web/playwright/api/fixtures.ts b/apps/web/playwright/api/fixtures.ts index 121d3f7a2dd..a22393c40d4 100644 --- a/apps/web/playwright/api/fixtures.ts +++ b/apps/web/playwright/api/fixtures.ts @@ -1,6 +1,11 @@ -import { test as base, type APIRequestContext } from "@playwright/test"; +import { + test as base, + type APIRequest, + type APIRequestContext, +} from "@playwright/test"; import { readFileSync } from "fs"; import path from "path"; +import { PLAYWRIGHT_API_BASE } from "./constants"; const authFile = path.join(__dirname, "../.auth/api.json"); @@ -16,6 +21,12 @@ export type ApiClient = { delete: (url: string) => Promise>; }; +type WorkerFixtures = { + api: ApiClient; + workspace: { id: string; slug: string }; + program: { id: string; defaultGroupId: string }; +}; + function loadApiAuth() { return JSON.parse(readFileSync(authFile, "utf-8")) as { token: string; @@ -47,36 +58,61 @@ function createApiClient(request: APIRequestContext): ApiClient { }; } -export const test = base.extend<{ - api: ApiClient; - workspace: { id: string; slug: string }; - program: { id: string; defaultGroupId: string }; -}>({ - // Authenticated API request context (token from globalSetup → .auth/api.json). - request: async ({ playwright, baseURL }, use) => { - const { token } = loadApiAuth(); - const context = await playwright.request.newContext({ - baseURL, - extraHTTPHeaders: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - }); - await use(context); - await context.dispose(); - }, +export async function createBearerApiClient({ + playwright, + token, + baseURL = PLAYWRIGHT_API_BASE, +}: { + playwright: { request: APIRequest }; + token: string; + baseURL?: string; +}) { + const context = await playwright.request.newContext({ + baseURL, + extraHTTPHeaders: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + return { + api: createApiClient(context), + dispose: () => context.dispose(), + }; +} - api: async ({ request }, use) => { - await use(createApiClient(request)); - }, +export const test = base.extend<{}, WorkerFixtures>({ + // Authenticated API client (token from globalSetup → .auth/api.json). + // Worker-scoped so beforeAll hooks can use api/program (Playwright rejects test-scoped fixtures there). + api: [ + async ({ playwright }, use, workerInfo) => { + const { token } = loadApiAuth(); + const context = await playwright.request.newContext({ + baseURL: workerInfo.project.use.baseURL, + extraHTTPHeaders: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + await use(createApiClient(context)); + await context.dispose(); + }, + { scope: "worker" }, + ], - workspace: async ({}, use) => { - const { workspaceId, workspaceSlug } = loadApiAuth(); - await use({ id: workspaceId, slug: workspaceSlug }); - }, + workspace: [ + async ({}, use) => { + const { workspaceId, workspaceSlug } = loadApiAuth(); + await use({ id: workspaceId, slug: workspaceSlug }); + }, + { scope: "worker" }, + ], - program: async ({}, use) => { - const { programId, defaultGroupId } = loadApiAuth(); - await use({ id: programId, defaultGroupId }); - }, + program: [ + async ({}, use) => { + const { programId, defaultGroupId } = loadApiAuth(); + await use({ id: programId, defaultGroupId }); + }, + { scope: "worker" }, + ], }); diff --git a/apps/web/playwright/api/folders/folders.spec.ts b/apps/web/playwright/api/folders/folders.spec.ts index ca1dcede7fe..8a5101bcc0d 100644 --- a/apps/web/playwright/api/folders/folders.spec.ts +++ b/apps/web/playwright/api/folders/folders.spec.ts @@ -31,10 +31,6 @@ async function deleteFolder(api: ApiClient, folderId: string) { await api.delete(`/api/folders/${folderId}`); } -test.describe.configure({ - mode: "parallel", -}); - test("POST /folders", async ({ api }) => { let folderId: string | undefined; const folderName = randomName("folder"); diff --git a/apps/web/playwright/api/groups/helpers.ts b/apps/web/playwright/api/groups/helpers.ts new file mode 100644 index 00000000000..97f23fd7bb1 --- /dev/null +++ b/apps/web/playwright/api/groups/helpers.ts @@ -0,0 +1,263 @@ +import { prisma } from "@/lib/prisma"; +import type { EnrolledPartnerProps, GroupProps } from "@/lib/types"; +import { nanoid } from "@dub/utils"; +import { expect } from "@playwright/test"; +import type { Workflow } from "@prisma/client"; +import { randomName } from "../../utils"; +import { trackClick, trackLead, trackSale } from "../conversions/helpers"; +import type { ApiClient } from "../fixtures"; + +export type MoveRule = { + attribute: string; + operator: string; + value: number | string | string[] | { min: number; max: number }; +}; + +export function uniqueThreshold() { + return 10_000 + Math.floor(Math.random() * 1_000_000); +} + +export async function createGroup( + api: ApiClient, + overrides: Record = {}, +) { + const slug = `g-${nanoid(8).toLowerCase()}`; + const { status, data } = await api.post("/api/groups", { + name: randomName("group"), + slug, + color: "blue", + ...overrides, + }); + + expect(status).toEqual(201); + return data; +} + +// Best effort: the response is ignored so cleanup stays safe for groups a test +// already deleted itself. +export async function deleteGroup(api: ApiClient, id: string | undefined) { + if (!id) return; + await api.delete(`/api/groups/${id}`); +} + +export async function setMoveRules( + api: ApiClient, + groupId: string, + moveRules: MoveRule[] | undefined, +) { + return api.patch(`/api/groups/${groupId}`, { + ...(moveRules !== undefined && { moveRules }), + }); +} + +export async function setGroupMoveDisabledAt( + api: ApiClient, + { + partnerId, + groupId, + groupMoveDisabledAt, + }: { + partnerId: string; + groupId: string; + groupMoveDisabledAt: Date | string; + }, +) { + const { status } = await api.post(`/api/groups/${groupId}/partners`, { + partnerIds: [partnerId], + groupMoveDisabledAt, + }); + + expect(status).toEqual(200); +} + +export async function getGroup( + api: ApiClient, + groupId: string, +): Promise<{ status: number; data: GroupProps }> { + return api.get(`/api/groups/${groupId}`); +} + +export async function trackPartnerLead( + partner: Pick, +) { + const link = partner.links![0]; + const { clickId } = await trackClick({ domain: link.domain, key: link.key }); + + return trackLead({ clickId }); +} + +export async function trackPartnerSale( + partner: Pick, + overrides: Record = {}, +) { + const link = partner.links![0]; + const { clickId } = await trackClick({ domain: link.domain, key: link.key }); + const { customer } = await trackLead({ clickId }); + + return trackSale({ + customerExternalId: customer.externalId, + ...overrides, + }); +} + +export async function getGroupWorkflow( + groupId: string, +): Promise { + return prisma.workflow.findFirst({ + where: { + partnerGroup: { + id: groupId, + }, + }, + }); +} + +export async function disableWorkflow(workflowId: string) { + return prisma.workflow.update({ + where: { + id: workflowId, + }, + data: { + disabledAt: new Date(), + }, + }); +} + +export async function getEnrollment({ + partnerId, + programId, +}: { + partnerId: string; + programId: string; +}) { + return prisma.programEnrollment.findUniqueOrThrow({ + where: { + partnerId_programId: { + partnerId, + programId, + }, + }, + select: { + groupId: true, + groupMoveDisabledAt: true, + leadRewardId: true, + saleRewardId: true, + discountId: true, + clickRewardId: true, + referralRewardId: true, + }, + }); +} + +export async function expectPartnerInGroup({ + partnerId, + programId, + expectedGroupId, +}: { + partnerId: string; + programId: string; + expectedGroupId: string; +}) { + await expect + .poll(async () => { + const enrollment = await getEnrollment({ partnerId, programId }); + return enrollment.groupId; + }) + .toBe(expectedGroupId); + + return getEnrollment({ partnerId, programId }); +} + +export async function expectPartnerStaysInGroup({ + partnerId, + programId, + expectedGroupId, +}: { + partnerId: string; + programId: string; + expectedGroupId: string; +}) { + // Give executeWorkflows (waitUntil after /track/lead) time to run (and skip). + await new Promise((resolve) => setTimeout(resolve, 5_000)); + + const enrollment = await getEnrollment({ partnerId, programId }); + expect(enrollment.groupId).toBe(expectedGroupId); + return enrollment; +} + +export async function seedLinkStats( + linkId: string, + { + leads, + conversions, + saleAmount, + }: { + leads?: number; + conversions?: number; + saleAmount?: number; + }, +) { + await prisma.link.update({ + where: { + id: linkId, + }, + data: { + ...(leads !== undefined && { leads }), + ...(conversions !== undefined && { conversions }), + ...(saleAmount !== undefined && { saleAmount }), + }, + }); +} + +export async function countGroupChangeActivityLogs({ + partnerId, + programId, +}: { + partnerId: string; + programId: string; +}) { + return prisma.activityLog.count({ + where: { + programId, + resourceType: "partner", + resourceId: partnerId, + action: "partner.groupChanged", + }, + }); +} + +// removeGroupIdFromMoveRules runs in a waitUntil after DELETE /groups/:id, so +// the scrubbed conditions land shortly after the response. +export async function expectMoveRules({ + groupId, + expected, +}: { + groupId: string; + expected: MoveRule[]; +}) { + await expect + .poll( + async () => { + const workflow = await getGroupWorkflow(groupId); + return workflow?.triggerConditions ?? null; + }, + { timeout: 15_000 }, + ) + .toEqual(expected); +} + +export async function getPartnerGroupRewards(groupId: string) { + return prisma.partnerGroup.findUniqueOrThrow({ + where: { + id: groupId, + }, + select: { + id: true, + leadRewardId: true, + saleRewardId: true, + discountId: true, + clickRewardId: true, + referralRewardId: true, + }, + }); +} diff --git a/apps/web/playwright/api/groups/move-group-fixtures.ts b/apps/web/playwright/api/groups/move-group-fixtures.ts new file mode 100644 index 00000000000..28ee5568964 --- /dev/null +++ b/apps/web/playwright/api/groups/move-group-fixtures.ts @@ -0,0 +1,61 @@ +import type { EnrolledPartnerProps, GroupProps } from "@/lib/types"; +import { expect } from "@playwright/test"; +import { randomName } from "../../utils"; +import { deleteCommissionPartner } from "../commissions/helpers"; +import { test as base } from "../fixtures"; +import { createPartner as createPartnerRequest } from "../partners/helpers"; +import { createGroup as createGroupRequest, deleteGroup } from "./helpers"; + +// Everything a test created, torn down afterwards. Partners are deleted before +// groups so a group delete doesn't have to migrate enrollments to the default +// group on the way out. +type CreatedResources = { + groupIds: string[]; + partnerIds: string[]; +}; + +export const test = base.extend<{ + created: CreatedResources; + createGroup: (overrides?: Record) => Promise; + createPartner: (options?: { + groupId?: string; + }) => Promise; +}>({ + created: async ({ api }, use) => { + const created: CreatedResources = { groupIds: [], partnerIds: [] }; + + await use(created); + + for (const partnerId of created.partnerIds) { + await deleteCommissionPartner({ partnerId }); + } + + for (const groupId of created.groupIds) { + await deleteGroup(api, groupId); + } + }, + + createGroup: async ({ api, created }, use) => { + await use(async (overrides = {}) => { + const group = await createGroupRequest(api, overrides); + created.groupIds.push(group.id); + + return group; + }); + }, + + createPartner: async ({ api, created }, use) => { + await use(async ({ groupId } = {}) => { + const { status, data } = await createPartnerRequest(api, { + name: randomName("partner"), + ...(groupId && { groupId }), + }); + + expect(status).toEqual(201); + expect(data.links?.[0]).toBeTruthy(); + created.partnerIds.push(data.id); + + return data; + }); + }, +}); diff --git a/apps/web/playwright/api/groups/move-group-workflow.spec.ts b/apps/web/playwright/api/groups/move-group-workflow.spec.ts new file mode 100644 index 00000000000..f9ea6de894b --- /dev/null +++ b/apps/web/playwright/api/groups/move-group-workflow.spec.ts @@ -0,0 +1,1048 @@ +import { prisma } from "@/lib/prisma"; +import { expect } from "@playwright/test"; +import { apiError } from "../../utils"; +import { + countGroupChangeActivityLogs, + disableWorkflow, + expectMoveRules, + expectPartnerInGroup, + expectPartnerStaysInGroup, + getEnrollment, + getGroup, + getGroupWorkflow, + getPartnerGroupRewards, + seedLinkStats, + setGroupMoveDisabledAt, + setMoveRules, + trackPartnerLead, + trackPartnerSale, + uniqueThreshold, +} from "./helpers"; +import { test } from "./move-group-fixtures"; + +// Serial: moveRules are validated program-wide against every other group's +// rules, so concurrent PATCHes in this file would flake on overlap checks. +// No retries: a failed case leaves groups/workflows that would flake on retry. +test.describe.configure({ mode: "serial", retries: 0 }); + +test.describe("Workflow execution", () => { + // totalCommissions is intentionally not covered: it only fires from the + // QStash create-partner-commission worker and would make this suite slow. + + test("moves partner when totalLeads condition is met", async ({ + api, + program, + createGroup, + createPartner, + }) => { + const source = await createGroup(); + const target = await createGroup(); + const n = uniqueThreshold(); + + const { status } = await setMoveRules(api, target.id, [ + { + attribute: "totalLeads", + operator: "between", + value: { min: n, max: n + 1 }, + }, + { + attribute: "partnerGroup", + operator: "eq", + value: source.id, + }, + ]); + expect(status).toEqual(200); + + const partner = await createPartner({ groupId: source.id }); + await seedLinkStats(partner.links![0].id, { leads: n - 1 }); + + await trackPartnerLead(partner); + + const enrollment = await expectPartnerInGroup({ + partnerId: partner.id, + programId: program.id, + expectedGroupId: target.id, + }); + + const targetRewards = await getPartnerGroupRewards(target.id); + expect(enrollment.leadRewardId).toBe(targetRewards.leadRewardId); + expect(enrollment.saleRewardId).toBe(targetRewards.saleRewardId); + expect(enrollment.discountId).toBe(targetRewards.discountId); + }); + + // `gte` is the other operator every metric attribute accepts, and it is + // inclusive — seeding n-1 leaves the partner exactly on the threshold. + test("moves partner when totalLeads meets a gte threshold exactly", async ({ + api, + program, + createGroup, + createPartner, + }) => { + const source = await createGroup(); + const target = await createGroup(); + const n = uniqueThreshold(); + + const { status } = await setMoveRules(api, target.id, [ + { + attribute: "totalLeads", + operator: "gte", + value: n, + }, + { + attribute: "partnerGroup", + operator: "eq", + value: source.id, + }, + ]); + expect(status).toEqual(200); + + const partner = await createPartner({ groupId: source.id }); + await seedLinkStats(partner.links![0].id, { leads: n - 1 }); + + await trackPartnerLead(partner); + + await expectPartnerInGroup({ + partnerId: partner.id, + programId: program.id, + expectedGroupId: target.id, + }); + }); + + test("does not move partner when totalLeads condition is not met", async ({ + api, + program, + createGroup, + createPartner, + }) => { + const source = await createGroup(); + const target = await createGroup(); + const n = uniqueThreshold(); + + const { status } = await setMoveRules(api, target.id, [ + { + attribute: "totalLeads", + operator: "between", + value: { min: n, max: n + 1 }, + }, + { + attribute: "partnerGroup", + operator: "eq", + value: source.id, + }, + ]); + expect(status).toEqual(200); + + const partner = await createPartner({ groupId: source.id }); + // Seed far below the window so one lead cannot satisfy it. + await seedLinkStats(partner.links![0].id, { leads: 0 }); + + await trackPartnerLead(partner); + + await expectPartnerStaysInGroup({ + partnerId: partner.id, + programId: program.id, + expectedGroupId: source.id, + }); + }); + + test("does not move partner when workflow is disabled", async ({ + api, + program, + createGroup, + createPartner, + }) => { + const source = await createGroup(); + const target = await createGroup(); + const n = uniqueThreshold(); + + const { status } = await setMoveRules(api, target.id, [ + { + attribute: "totalLeads", + operator: "between", + value: { min: n, max: n + 1 }, + }, + { + attribute: "partnerGroup", + operator: "eq", + value: source.id, + }, + ]); + expect(status).toEqual(200); + + const workflow = await getGroupWorkflow(target.id); + expect(workflow).not.toBeNull(); + await disableWorkflow(workflow!.id); + + const partner = await createPartner({ groupId: source.id }); + await seedLinkStats(partner.links![0].id, { leads: n - 1 }); + + await trackPartnerLead(partner); + + await expectPartnerStaysInGroup({ + partnerId: partner.id, + programId: program.id, + expectedGroupId: source.id, + }); + }); + + test("moves partner when partnerGroup matches source group", async ({ + api, + program, + createGroup, + createPartner, + }) => { + const source = await createGroup(); + const target = await createGroup(); + const n = uniqueThreshold(); + + const { status } = await setMoveRules(api, target.id, [ + { + attribute: "totalLeads", + operator: "between", + value: { min: n, max: n + 1 }, + }, + { + attribute: "partnerGroup", + operator: "eq", + value: source.id, + }, + ]); + expect(status).toEqual(200); + + const partner = await createPartner({ groupId: source.id }); + expect(partner.groupId).toBe(source.id); + await seedLinkStats(partner.links![0].id, { leads: n - 1 }); + + await trackPartnerLead(partner); + + await expectPartnerInGroup({ + partnerId: partner.id, + programId: program.id, + expectedGroupId: target.id, + }); + }); + + test("does not move partner when partnerGroup does not match", async ({ + api, + program, + createGroup, + createPartner, + }) => { + const allowedSource = await createGroup(); + const actualSource = await createGroup(); + const target = await createGroup(); + const n = uniqueThreshold(); + + const { status } = await setMoveRules(api, target.id, [ + { + attribute: "totalLeads", + operator: "between", + value: { min: n, max: n + 1 }, + }, + { + attribute: "partnerGroup", + operator: "eq", + value: allowedSource.id, + }, + ]); + expect(status).toEqual(200); + + const partner = await createPartner({ groupId: actualSource.id }); + await seedLinkStats(partner.links![0].id, { leads: n - 1 }); + + await trackPartnerLead(partner); + + await expectPartnerStaysInGroup({ + partnerId: partner.id, + programId: program.id, + expectedGroupId: actualSource.id, + }); + }); + + test("moves partner when partnerGroup is one of several source groups", async ({ + api, + program, + createGroup, + createPartner, + }) => { + const otherSource = await createGroup(); + const source = await createGroup(); + const target = await createGroup(); + const n = uniqueThreshold(); + + const { status } = await setMoveRules(api, target.id, [ + { + attribute: "totalLeads", + operator: "between", + value: { min: n, max: n + 1 }, + }, + { + attribute: "partnerGroup", + operator: "in", + value: [otherSource.id, source.id], + }, + ]); + expect(status).toEqual(200); + + const partner = await createPartner({ groupId: source.id }); + await seedLinkStats(partner.links![0].id, { leads: n - 1 }); + + await trackPartnerLead(partner); + + await expectPartnerInGroup({ + partnerId: partner.id, + programId: program.id, + expectedGroupId: target.id, + }); + }); + + test("does not move partner when partnerGroup is excluded by notIn", async ({ + api, + program, + createGroup, + createPartner, + }) => { + const source = await createGroup(); + const target = await createGroup(); + const n = uniqueThreshold(); + + const { status } = await setMoveRules(api, target.id, [ + { + attribute: "totalLeads", + operator: "between", + value: { min: n, max: n + 1 }, + }, + { + attribute: "partnerGroup", + operator: "notIn", + value: [source.id], + }, + ]); + expect(status).toEqual(200); + + const partner = await createPartner({ groupId: source.id }); + await seedLinkStats(partner.links![0].id, { leads: n - 1 }); + + await trackPartnerLead(partner); + + await expectPartnerStaysInGroup({ + partnerId: partner.id, + programId: program.id, + expectedGroupId: source.id, + }); + }); + + test("skips partner with groupMoveDisabledAt set", async ({ + api, + program, + createGroup, + createPartner, + }) => { + const source = await createGroup(); + const target = await createGroup(); + const n = uniqueThreshold(); + + const { status } = await setMoveRules(api, target.id, [ + { + attribute: "totalLeads", + operator: "between", + value: { min: n, max: n + 1 }, + }, + { + attribute: "partnerGroup", + operator: "eq", + value: source.id, + }, + ]); + expect(status).toEqual(200); + + const partner = await createPartner({ groupId: source.id }); + await setGroupMoveDisabledAt(api, { + partnerId: partner.id, + groupId: source.id, + groupMoveDisabledAt: new Date().toISOString(), + }); + + const before = await getEnrollment({ + partnerId: partner.id, + programId: program.id, + }); + expect(before.groupMoveDisabledAt).not.toBeNull(); + expect(before.groupId).toBe(source.id); + + await seedLinkStats(partner.links![0].id, { leads: n - 1 }); + await trackPartnerLead(partner); + + const after = await expectPartnerStaysInGroup({ + partnerId: partner.id, + programId: program.id, + expectedGroupId: source.id, + }); + expect(after.groupMoveDisabledAt).not.toBeNull(); + }); + + test("does not re-move partner on repeat triggers", async ({ + api, + program, + createGroup, + createPartner, + }) => { + const source = await createGroup(); + const target = await createGroup(); + const n = uniqueThreshold(); + + const { status } = await setMoveRules(api, target.id, [ + { + attribute: "totalLeads", + operator: "between", + value: { min: n, max: n + 100 }, + }, + { + attribute: "partnerGroup", + operator: "eq", + value: source.id, + }, + ]); + expect(status).toEqual(200); + + const partner = await createPartner({ groupId: source.id }); + await seedLinkStats(partner.links![0].id, { leads: n - 1 }); + + await trackPartnerLead(partner); + await expectPartnerInGroup({ + partnerId: partner.id, + programId: program.id, + expectedGroupId: target.id, + }); + + // Second lead: the already-in-target guard skips before the redis lock is + // ever reached, since the first move has already committed by now. + await trackPartnerLead(partner); + await expectPartnerStaysInGroup({ + partnerId: partner.id, + programId: program.id, + expectedGroupId: target.id, + }); + + // The enrollment update is idempotent, so only the activity log proves the + // partner was moved once rather than once per trigger. + await expect + .poll( + () => + countGroupChangeActivityLogs({ + partnerId: partner.id, + programId: program.id, + }), + { timeout: 15_000 }, + ) + .toBe(1); + }); + + test("moves partner when totalSaleAmount condition is met", async ({ + api, + program, + createGroup, + createPartner, + }) => { + const source = await createGroup(); + const target = await createGroup(); + const n = uniqueThreshold(); + + const { status } = await setMoveRules(api, target.id, [ + { + attribute: "totalSaleAmount", + operator: "between", + value: { min: n, max: n + 1 }, + }, + { + attribute: "partnerGroup", + operator: "eq", + value: source.id, + }, + ]); + expect(status).toEqual(200); + + const partner = await createPartner({ groupId: source.id }); + await trackPartnerSale(partner, { amount: n }); + + await expectPartnerInGroup({ + partnerId: partner.id, + programId: program.id, + expectedGroupId: target.id, + }); + }); + + test("moves partner when totalConversions condition is met", async ({ + api, + program, + createGroup, + createPartner, + }) => { + const source = await createGroup(); + const target = await createGroup(); + const n = uniqueThreshold(); + + const { status } = await setMoveRules(api, target.id, [ + { + attribute: "totalConversions", + operator: "gte", + value: n, + }, + { + attribute: "partnerGroup", + operator: "eq", + value: source.id, + }, + ]); + expect(status).toEqual(200); + + const partner = await createPartner({ groupId: source.id }); + await seedLinkStats(partner.links![0].id, { conversions: n - 1 }); + + // conversions only increment on a customer's first sale, which is what + // trackPartnerSale produces (fresh customer per call). + await trackPartnerSale(partner); + + await expectPartnerInGroup({ + partnerId: partner.id, + programId: program.id, + expectedGroupId: target.id, + }); + }); +}); + +test.describe("Workflow lifecycle", () => { + test("configuring moveRules creates a moveGroup workflow", async ({ + api, + createGroup, + }) => { + const target = await createGroup(); + const n = uniqueThreshold(); + const moveRules = [ + { + attribute: "totalCommissions" as const, + operator: "between" as const, + value: { min: n, max: n + 1 }, + }, + ]; + + const { status } = await setMoveRules(api, target.id, moveRules); + expect(status).toEqual(200); + + const workflow = await getGroupWorkflow(target.id); + expect(workflow).not.toBeNull(); + expect(workflow!.disabledAt).toBeNull(); + expect(workflow!.actions).toEqual([ + { + type: "moveGroup", + data: { groupId: target.id }, + }, + ]); + expect(workflow!.triggerConditions).toEqual(moveRules); + + const { status: getStatus, data: group } = await getGroup(api, target.id); + expect(getStatus).toEqual(200); + expect(group.moveRules).toEqual(moveRules); + }); + + test("changing moveRules updates the same workflow row", async ({ + api, + createGroup, + }) => { + const target = await createGroup(); + const n1 = uniqueThreshold(); + const n2 = uniqueThreshold(); + + await setMoveRules(api, target.id, [ + { + attribute: "totalLeads", + operator: "between", + value: { min: n1, max: n1 + 1 }, + }, + ]); + + const before = await getGroupWorkflow(target.id); + expect(before).not.toBeNull(); + + const nextRules = [ + { + attribute: "totalConversions" as const, + operator: "between" as const, + value: { min: n2, max: n2 + 1 }, + }, + ]; + const { status } = await setMoveRules(api, target.id, nextRules); + expect(status).toEqual(200); + + const after = await getGroupWorkflow(target.id); + expect(after).not.toBeNull(); + expect(after!.id).toBe(before!.id); + expect(after!.triggerConditions).toEqual(nextRules); + }); + + test("empty moveRules deletes the workflow", async ({ api, createGroup }) => { + const target = await createGroup(); + const n = uniqueThreshold(); + + await setMoveRules(api, target.id, [ + { + attribute: "totalLeads", + operator: "between", + value: { min: n, max: n + 1 }, + }, + ]); + expect(await getGroupWorkflow(target.id)).not.toBeNull(); + + const { status } = await setMoveRules(api, target.id, []); + expect(status).toEqual(200); + + expect(await getGroupWorkflow(target.id)).toBeNull(); + + const group = await prisma.partnerGroup.findUniqueOrThrow({ + where: { id: target.id }, + select: { workflowId: true }, + }); + expect(group.workflowId).toBeNull(); + }); + + test("PATCH without moveRules leaves the workflow untouched", async ({ + api, + createGroup, + }) => { + const target = await createGroup(); + const n = uniqueThreshold(); + const moveRules = [ + { + attribute: "totalLeads" as const, + operator: "between" as const, + value: { min: n, max: n + 1 }, + }, + ]; + + await setMoveRules(api, target.id, moveRules); + const before = await getGroupWorkflow(target.id); + expect(before).not.toBeNull(); + + const { status } = await api.patch(`/api/groups/${target.id}`, { + name: `${target.name} updated`, + }); + expect(status).toEqual(200); + + const after = await getGroupWorkflow(target.id); + expect(after).not.toBeNull(); + expect(after!.id).toBe(before!.id); + expect(after!.triggerConditions).toEqual(moveRules); + }); + + test("deleting a group deletes its attached workflow", async ({ + api, + createGroup, + }) => { + const target = await createGroup(); + const n = uniqueThreshold(); + + await setMoveRules(api, target.id, [ + { + attribute: "totalLeads", + operator: "between", + value: { min: n, max: n + 1 }, + }, + ]); + + const workflow = await getGroupWorkflow(target.id); + expect(workflow).not.toBeNull(); + const workflowId = workflow!.id; + + const { status } = await api.delete(`/api/groups/${target.id}`); + expect(status).toEqual(200); + + const deleted = await prisma.workflow.findUnique({ + where: { id: workflowId }, + }); + expect(deleted).toBeNull(); + }); + + test("deleting a source group drops it from another group's move rules", async ({ + api, + createGroup, + }) => { + const source = await createGroup(); + const target = await createGroup(); + const n = uniqueThreshold(); + const metricRule = { + attribute: "totalLeads" as const, + operator: "between" as const, + value: { min: n, max: n + 1 }, + }; + + const { status } = await setMoveRules(api, target.id, [ + metricRule, + { + attribute: "partnerGroup", + operator: "eq", + value: source.id, + }, + ]); + expect(status).toEqual(200); + + const { status: deleteStatus } = await api.delete( + `/api/groups/${source.id}`, + ); + expect(deleteStatus).toEqual(200); + + // The whole condition is dropped rather than the workflow being disabled, + // so what was "in group A and hit the metric" now matches every partner. + await expectMoveRules({ groupId: target.id, expected: [metricRule] }); + }); + + test("deleting a source group only removes it from a partnerGroup list", async ({ + api, + createGroup, + }) => { + const removed = await createGroup(); + const kept = await createGroup(); + const target = await createGroup(); + const n = uniqueThreshold(); + const metricRule = { + attribute: "totalLeads" as const, + operator: "between" as const, + value: { min: n, max: n + 1 }, + }; + + const { status } = await setMoveRules(api, target.id, [ + metricRule, + { + attribute: "partnerGroup", + operator: "in", + value: [removed.id, kept.id], + }, + ]); + expect(status).toEqual(200); + + const { status: deleteStatus } = await api.delete( + `/api/groups/${removed.id}`, + ); + expect(deleteStatus).toEqual(200); + + await expectMoveRules({ + groupId: target.id, + expected: [ + metricRule, + { + attribute: "partnerGroup", + operator: "in", + value: [kept.id], + }, + ], + }); + }); + + test("multiple metric move rules are stored in order", async ({ + api, + createGroup, + }) => { + const target = await createGroup(); + const n1 = uniqueThreshold(); + const n2 = uniqueThreshold(); + const moveRules = [ + { + attribute: "totalLeads" as const, + operator: "between" as const, + value: { min: n1, max: n1 + 1 }, + }, + { + attribute: "totalConversions" as const, + operator: "between" as const, + value: { min: n2, max: n2 + 1 }, + }, + ]; + + const { status } = await setMoveRules(api, target.id, moveRules); + expect(status).toEqual(200); + + const workflow = await getGroupWorkflow(target.id); + expect(workflow).not.toBeNull(); + expect(workflow!.triggerConditions).toEqual(moveRules); + }); + + test("metric and partnerGroup move rules are stored together", async ({ + api, + createGroup, + }) => { + const source = await createGroup(); + const target = await createGroup(); + const n = uniqueThreshold(); + const moveRules = [ + { + attribute: "totalLeads" as const, + operator: "between" as const, + value: { min: n, max: n + 1 }, + }, + { + attribute: "partnerGroup" as const, + operator: "eq" as const, + value: source.id, + }, + ]; + + const { status } = await setMoveRules(api, target.id, moveRules); + expect(status).toEqual(200); + + const workflow = await getGroupWorkflow(target.id); + expect(workflow).not.toBeNull(); + expect(workflow!.triggerConditions).toEqual(moveRules); + }); +}); + +test.describe("Move rule validation", () => { + test("rejects partnerGroup as the only condition", async ({ + api, + createGroup, + }) => { + const source = await createGroup(); + const target = await createGroup(); + + expect( + await setMoveRules(api, target.id, [ + { + attribute: "partnerGroup", + operator: "eq", + value: source.id, + }, + ]), + ).toEqual( + apiError({ + code: "bad_request", + message: + "Partner group can only be used as an additional condition alongside a metric rule.", + }), + ); + }); + + test("rejects partnerGroup pointing at the current group", async ({ + api, + createGroup, + }) => { + const target = await createGroup(); + const n = uniqueThreshold(); + + expect( + await setMoveRules(api, target.id, [ + { + attribute: "totalLeads", + operator: "between", + value: { min: n, max: n + 1 }, + }, + { + attribute: "partnerGroup", + operator: "eq", + value: target.id, + }, + ]), + ).toEqual( + apiError({ + code: "bad_request", + message: + "Condition 2: Cannot select the current group as a source group.", + }), + ); + }); + + test("rejects partnerGroup with an unknown group id", async ({ + api, + createGroup, + }) => { + const target = await createGroup(); + const n = uniqueThreshold(); + const missingGroupId = "grp_does_not_exist"; + + expect( + await setMoveRules(api, target.id, [ + { + attribute: "totalLeads", + operator: "between", + value: { min: n, max: n + 1 }, + }, + { + attribute: "partnerGroup", + operator: "eq", + value: missingGroupId, + }, + ]), + ).toEqual( + apiError({ + code: "bad_request", + message: `Condition 2: Invalid group IDs detected: ${missingGroupId}`, + }), + ); + }); + + test("rejects a partnerGroup list containing the current group", async ({ + api, + createGroup, + }) => { + const source = await createGroup(); + const target = await createGroup(); + const n = uniqueThreshold(); + + expect( + await setMoveRules(api, target.id, [ + { + attribute: "totalLeads", + operator: "between", + value: { min: n, max: n + 1 }, + }, + { + attribute: "partnerGroup", + operator: "in", + value: [source.id, target.id], + }, + ]), + ).toEqual( + apiError({ + code: "bad_request", + message: + "Condition 2: Cannot select the current group as a source group.", + }), + ); + }); + + test("rejects a partnerGroup list with an unknown group id", async ({ + api, + createGroup, + }) => { + const source = await createGroup(); + const target = await createGroup(); + const n = uniqueThreshold(); + const missingGroupId = "grp_does_not_exist"; + + expect( + await setMoveRules(api, target.id, [ + { + attribute: "totalLeads", + operator: "between", + value: { min: n, max: n + 1 }, + }, + { + attribute: "partnerGroup", + operator: "in", + value: [source.id, missingGroupId], + }, + ]), + ).toEqual( + apiError({ + code: "bad_request", + message: `Condition 2: Invalid group IDs detected: ${missingGroupId}`, + }), + ); + }); + + test("rejects operator not allowed for the attribute", async ({ + api, + createGroup, + }) => { + const target = await createGroup(); + + expect( + await setMoveRules(api, target.id, [ + { + attribute: "totalLeads", + operator: "lte", + value: 1, + }, + ]), + ).toEqual( + apiError({ + code: "bad_request", + message: + 'Operator "is less than or equal to" is not valid for the activity "totalLeads".', + }), + ); + }); + + test("rejects attribute not available for moveGroup", async ({ + api, + createGroup, + }) => { + const target = await createGroup(); + + expect( + await setMoveRules(api, target.id, [ + { + attribute: "partnerEnrolledDays", + operator: "gte", + value: 1, + }, + ]), + ).toEqual( + apiError({ + code: "bad_request", + message: "Condition 1: Invalid activity.", + }), + ); + }); + + test("rejects between with max less than or equal to min", async ({ + api, + createGroup, + }) => { + const target = await createGroup(); + const n = uniqueThreshold(); + + const result = await setMoveRules(api, target.id, [ + { + attribute: "totalLeads", + operator: "between", + value: { min: n, max: n }, + }, + ]); + + expect(result.status).toEqual(422); + expect(result.data).toEqual({ + error: { + code: "unprocessable_entity", + message: expect.stringContaining( + "Maximum value must be greater than minimum value.", + ), + doc_url: + "https://dub.co/docs/api-reference/errors#unprocessable-entity", + }, + }); + }); + + test("rejects overlapping move rules across groups", async ({ + api, + createGroup, + }) => { + const first = await createGroup(); + const second = await createGroup(); + const n = uniqueThreshold(); + const overlapping = [ + { + attribute: "totalLeads" as const, + operator: "between" as const, + value: { min: n, max: n + 1 }, + }, + ]; + + const created = await setMoveRules(api, first.id, overlapping); + expect(created.status).toEqual(200); + + expect(await setMoveRules(api, second.id, overlapping)).toEqual( + apiError({ + code: "bad_request", + message: `This rule is already in use by the ${first.name} group. Select a different activity or amount.`, + }), + ); + + const disjointN = uniqueThreshold(); + const disjoint = await setMoveRules(api, second.id, [ + { + attribute: "totalLeads", + operator: "between", + value: { min: disjointN, max: disjointN + 1 }, + }, + ]); + expect(disjoint.status).toEqual(200); + }); +}); diff --git a/apps/web/playwright/api/links/links-bulk.spec.ts b/apps/web/playwright/api/links/links-bulk.spec.ts new file mode 100644 index 00000000000..0ce500c7fd9 --- /dev/null +++ b/apps/web/playwright/api/links/links-bulk.spec.ts @@ -0,0 +1,272 @@ +import { nanoid } from "@dub/utils"; +import { expect } from "@playwright/test"; +import { apiError } from "../../utils"; +import { test, type ApiClient } from "../fixtures"; +import { createPartner, deletePartner } from "../partners/helpers"; +import { TEST_WORKSPACE } from "../setup-test-workspace"; + +type BulkLink = { + id: string; + url: string; + domain: string; + programId: string | null; + partnerId: string | null; +}; + +type BulkLinkError = { + error: string; + code: string; + link: Record; +}; + +const domain = TEST_WORKSPACE.program.domain; + +function bulkLinkBody(overrides: Record = {}) { + return { + url: `https://example.com/${nanoid()}`, + domain, + ...overrides, + }; +} + +async function createBulkLinks( + api: ApiClient, + bodies: Record[], +) { + return api.post<(BulkLink | BulkLinkError)[]>("/api/links/bulk", bodies); +} + +async function deleteLinks(api: ApiClient, ids: (string | undefined)[]) { + const linkIds = ids.filter((id): id is string => Boolean(id)); + if (linkIds.length === 0) return; + await api.delete(`/api/links/bulk?linkIds=${linkIds.join(",")}`); +} + +function isBulkError(item: BulkLink | BulkLinkError): item is BulkLinkError { + return "error" in item; +} + +function isBulkLink(item: BulkLink | BulkLinkError): item is BulkLink { + return !isBulkError(item); +} + +test("POST /links/bulk – with valid programId and partnerId", async ({ + api, + program, +}) => { + let partnerId: string | undefined; + const createdIds: string[] = []; + + try { + const { data: partner } = await createPartner(api); + partnerId = partner.id; + + const body = bulkLinkBody({ + programId: program.id, + partnerId, + }); + + const { status, data } = await createBulkLinks(api, [body]); + const created = data.filter(isBulkLink); + createdIds.push(...created.map((link) => link.id)); + + expect(status).toEqual(200); + expect(created).toHaveLength(1); + expect(created[0]).toMatchObject({ + url: body.url, + domain, + programId: program.id, + partnerId, + }); + } finally { + await deleteLinks(api, createdIds); + await deletePartner(partnerId); + } +}); + +test("POST /links/bulk – rejects invalid programId", async ({ api }) => { + const invalidProgramId = `prog_${nanoid()}`; + const validBody = bulkLinkBody(); + const invalidBody = bulkLinkBody({ + programId: invalidProgramId, + partnerId: `pn_${nanoid()}`, + }); + + const createdIds: string[] = []; + + try { + const { status, data } = await createBulkLinks(api, [ + invalidBody, + validBody, + ]); + const created = data.filter(isBulkLink); + const errors = data.filter(isBulkError); + createdIds.push(...created.map((link) => link.id)); + + expect(status).toEqual(200); + expect(created).toHaveLength(1); + expect(created[0].url).toEqual(validBody.url); + expect(errors).toEqual([ + { + error: `Invalid programId detected: ${invalidProgramId}`, + code: "unprocessable_entity", + link: expect.any(Object), + }, + ]); + } finally { + await deleteLinks(api, createdIds); + } +}); + +test("POST /links/bulk – rejects invalid partnerId", async ({ + api, + program, +}) => { + const invalidPartnerId = `pn_${nanoid()}`; + const validBody = bulkLinkBody(); + const invalidBody = bulkLinkBody({ + programId: program.id, + partnerId: invalidPartnerId, + }); + + const createdIds: string[] = []; + + try { + const { status, data } = await createBulkLinks(api, [ + invalidBody, + validBody, + ]); + const created = data.filter(isBulkLink); + const errors = data.filter(isBulkError); + createdIds.push(...created.map((link) => link.id)); + + expect(status).toEqual(200); + expect(created).toHaveLength(1); + expect(created[0].url).toEqual(validBody.url); + expect(errors).toEqual([ + { + error: `Invalid partnerId detected: ${invalidPartnerId}`, + code: "unprocessable_entity", + link: expect.any(Object), + }, + ]); + } finally { + await deleteLinks(api, createdIds); + } +}); + +test("PATCH /links/bulk – with valid programId and partnerId", async ({ + api, + program, +}) => { + let partnerId: string | undefined; + const createdIds: string[] = []; + + try { + const { data: partner } = await createPartner(api); + partnerId = partner.id; + + const { data: created } = await createBulkLinks(api, [ + bulkLinkBody(), + bulkLinkBody(), + ]); + const links = created.filter(isBulkLink); + createdIds.push(...links.map((link) => link.id)); + + expect(links).toHaveLength(2); + + const { status, data } = await api.patch("/api/links/bulk", { + linkIds: links.map((link) => link.id), + data: { + programId: program.id, + partnerId, + }, + }); + + expect(status).toEqual(200); + expect(data).toHaveLength(2); + expect(data).toEqual( + expect.arrayContaining( + links.map((link) => + expect.objectContaining({ + id: link.id, + programId: program.id, + partnerId, + }), + ), + ), + ); + } finally { + await deleteLinks(api, createdIds); + await deletePartner(partnerId); + } +}); + +test("PATCH /links/bulk – rejects invalid programId", async ({ api }) => { + const createdIds: string[] = []; + const invalidProgramId = `prog_${nanoid()}`; + + try { + const { data: created } = await createBulkLinks(api, [bulkLinkBody()]); + const links = created.filter(isBulkLink); + createdIds.push(...links.map((link) => link.id)); + + expect( + await api.patch("/api/links/bulk", { + linkIds: links.map((link) => link.id), + data: { + programId: invalidProgramId, + }, + }), + ).toEqual( + apiError({ + code: "unprocessable_entity", + message: `Invalid programId detected: ${invalidProgramId}`, + }), + ); + + const { data: unchanged } = await api.get( + `/api/links/${links[0].id}`, + ); + expect(unchanged.programId).toBeNull(); + } finally { + await deleteLinks(api, createdIds); + } +}); + +test("PATCH /links/bulk – rejects invalid partnerId", async ({ + api, + program, +}) => { + const createdIds: string[] = []; + const invalidPartnerId = `pn_${nanoid()}`; + + try { + const { data: created } = await createBulkLinks(api, [bulkLinkBody()]); + const links = created.filter(isBulkLink); + createdIds.push(...links.map((link) => link.id)); + + expect( + await api.patch("/api/links/bulk", { + linkIds: links.map((link) => link.id), + data: { + programId: program.id, + partnerId: invalidPartnerId, + }, + }), + ).toEqual( + apiError({ + code: "unprocessable_entity", + message: `Invalid partnerId detected: ${invalidPartnerId}`, + }), + ); + + const { data: unchanged } = await api.get( + `/api/links/${links[0].id}`, + ); + expect(unchanged.partnerId).toBeNull(); + expect(unchanged.programId).toBeNull(); + } finally { + await deleteLinks(api, createdIds); + } +}); diff --git a/apps/web/playwright/api/partner-profile/links.spec.ts b/apps/web/playwright/api/partner-profile/links.spec.ts new file mode 100644 index 00000000000..51054f7eb96 --- /dev/null +++ b/apps/web/playwright/api/partner-profile/links.spec.ts @@ -0,0 +1,242 @@ +import { createId } from "@/lib/api/create-id"; +import { hashToken } from "@/lib/auth/hash-token"; +import { prisma } from "@/lib/prisma"; +import { nanoid } from "@dub/utils"; +import { expect } from "@playwright/test"; +import { apiError } from "../../utils"; +import { createBearerApiClient, test } from "../fixtures"; +import { + createGroupWithAdditionalLinks, + createPartner, + deletePartner, +} from "../partners/helpers"; +import { TEST_WORKSPACE } from "../setup-test-workspace"; + +type PartnerProfileLink = { + id: string; + domain: string; + key: string; + url: string; + partnerGroupDefaultLinkId?: string | null; +}; + +async function createPartnerProfileAuth(partner: { + id: string; + email?: string | null; +}) { + const token = `dub_pw_${nanoid(24)}`; + const user = await prisma.user.create({ + data: { + id: createId({ prefix: "user_" }), + email: partner.email, + emailVerified: new Date(), + defaultPartnerId: partner.id, + partners: { + create: { + partnerId: partner.id, + role: "owner", + }, + }, + tokens: { + create: { + name: "Playwright partner profile", + hashedKey: await hashToken(token), + partialKey: `${token.slice(0, 3)}...${token.slice(-4)}`, + }, + }, + }, + }); + + return { token, userId: user.id }; +} + +test("GET /partner-profile/programs/:id/links - workspace token is rejected", async ({ + api, + program, +}) => { + expect( + await api.get(`/api/partner-profile/programs/${program.id}/links`), + ).toEqual( + apiError({ + code: "not_found", + message: "Partner profile not found.", + }), + ); +}); + +test("POST /partner-profile/programs/:id/links - default URL", async ({ + api, + program, + playwright, +}) => { + let partnerId: string | undefined; + let userId: string | undefined; + + try { + const { data: partner } = await createPartner(api); + partnerId = partner.id; + const auth = await createPartnerProfileAuth(partner); + userId = auth.userId; + + const { api: partnerApi, dispose } = await createBearerApiClient({ + playwright, + token: auth.token, + }); + + try { + const key = nanoid(8); + const { status, data } = await partnerApi.post( + `/api/partner-profile/programs/${program.id}/links`, + { key }, + ); + + expect(status).toEqual(201); + expect(data).toMatchObject({ + id: expect.any(String), + domain: TEST_WORKSPACE.program.domain, + key, + shortLink: `https://${TEST_WORKSPACE.program.domain}/${key}`, + }); + } finally { + await dispose(); + } + } finally { + if (userId) await prisma.user.delete({ where: { id: userId } }); + await deletePartner(partnerId); + } +}); + +test("POST /partner-profile/programs/:id/links - URL outside additionalLinks", async ({ + api, + program, + playwright, +}) => { + let partnerId: string | undefined; + let userId: string | undefined; + + try { + const { data: partner } = await createPartner(api); + partnerId = partner.id; + const auth = await createPartnerProfileAuth(partner); + userId = auth.userId; + + const { api: partnerApi, dispose } = await createBearerApiClient({ + playwright, + token: auth.token, + }); + + try { + expect( + await partnerApi.post( + `/api/partner-profile/programs/${program.id}/links`, + { + key: nanoid(8), + url: `https://github.com/dubinc/${nanoid()}`, + }, + ), + ).toEqual( + apiError({ + code: "bad_request", + message: "You cannot create additional links for this program.", + }), + ); + } finally { + await dispose(); + } + } finally { + if (userId) await prisma.user.delete({ where: { id: userId } }); + await deletePartner(partnerId); + } +}); + +test("POST /partner-profile/programs/:id/links - allowed additionalLinks domain", async ({ + api, + program, + playwright, +}) => { + let partnerId: string | undefined; + let userId: string | undefined; + let groupId: string | undefined; + + try { + const group = await createGroupWithAdditionalLinks(program.id); + groupId = group.id; + + const { data: partner } = await createPartner(api, { groupId }); + partnerId = partner.id; + const auth = await createPartnerProfileAuth(partner); + userId = auth.userId; + + const { api: partnerApi, dispose } = await createBearerApiClient({ + playwright, + token: auth.token, + }); + const url = `https://example.com/${nanoid()}`; + + try { + const { status, data } = await partnerApi.post( + `/api/partner-profile/programs/${program.id}/links`, + { key: nanoid(8), url }, + ); + + expect(status).toEqual(201); + expect(data.url).toEqual(url); + } finally { + await dispose(); + } + } finally { + if (userId) await prisma.user.delete({ where: { id: userId } }); + await deletePartner(partnerId); + if (groupId) await prisma.partnerGroup.delete({ where: { id: groupId } }); + } +}); + +test("PATCH /partner-profile/programs/:id/links/:linkId - cannot change default link URL", async ({ + api, + program, + playwright, +}) => { + let partnerId: string | undefined; + let userId: string | undefined; + + try { + const { data: partner } = await createPartner(api); + partnerId = partner.id; + const auth = await createPartnerProfileAuth(partner); + userId = auth.userId; + + const { api: partnerApi, dispose } = await createBearerApiClient({ + playwright, + token: auth.token, + }); + + try { + const { data: links } = await partnerApi.get( + `/api/partner-profile/programs/${program.id}/links`, + ); + const defaultLink = + links.find((link) => link.partnerGroupDefaultLinkId) ?? links[0]; + + expect( + await partnerApi.patch( + `/api/partner-profile/programs/${program.id}/links/${defaultLink.id}`, + { + key: defaultLink.key, + url: `https://example.com/${nanoid()}`, + }, + ), + ).toEqual( + apiError({ + code: "forbidden", + message: + "You cannot update the destination URL of your default link.", + }), + ); + } finally { + await dispose(); + } + } finally { + if (userId) await prisma.user.delete({ where: { id: userId } }); + await deletePartner(partnerId); + } +}); diff --git a/apps/web/playwright/api/partners/ban-partner.spec.ts b/apps/web/playwright/api/partners/ban-partner.spec.ts index 2ee5ae7b5b9..d0f71fc4083 100644 --- a/apps/web/playwright/api/partners/ban-partner.spec.ts +++ b/apps/web/playwright/api/partners/ban-partner.spec.ts @@ -1,45 +1,9 @@ -import { conn } from "@/lib/planetscale"; -import { prisma } from "@/lib/prisma"; import type { EnrolledPartnerProps } from "@/lib/types"; import { nanoid } from "@dub/utils"; import { expect } from "@playwright/test"; -import { randomName, randomPartnerEmail } from "../../utils"; +import { apiError } from "../../utils"; import { test, type ApiClient } from "../fixtures"; - -test.describe.configure({ - mode: "parallel", -}); - -async function createPartner( - api: ApiClient, - overrides: Record = {}, -) { - return api.post("/api/partners", { - name: randomName(), - email: randomPartnerEmail(), - ...overrides, - }); -} - -async function deletePartner(partnerId: string | undefined) { - if (!partnerId) return; - - await prisma.link.deleteMany({ - where: { - partnerId, - }, - }); - - await prisma.programEnrollment.deleteMany({ - where: { - partnerId, - }, - }); - - // Prisma partner.delete hits a PlanetScale relation quirk; raw SQL matches - // bulkDeletePartners cleanup used by e2e cron. - await conn.execute(`DELETE FROM Partner WHERE id = ?`, [partnerId]); -} +import { createPartner, deletePartner } from "./helpers"; async function expectPartnerBanned( api: ApiClient, @@ -136,16 +100,12 @@ test("POST /partners/ban – already banned", async ({ api, program }) => { partnerId, reason: "spam", }), - ).toEqual({ - status: 400, - data: { - error: { - code: "bad_request", - message: "This partner is already banned from your program.", - doc_url: "https://dub.co/docs/api-reference/errors#bad-request", - }, - }, - }); + ).toEqual( + apiError({ + code: "bad_request", + message: "This partner is already banned from your program.", + }), + ); } finally { await deletePartner(partnerId); } @@ -159,16 +119,12 @@ test("POST /partners/ban – partner not found", async ({ api, program }) => { partnerId, reason: "fraud", }), - ).toEqual({ - status: 404, - data: { - error: { - code: "not_found", - message: `Partner ${partnerId} is not enrolled in program ${program.id}.`, - doc_url: "https://dub.co/docs/api-reference/errors#not-found", - }, - }, - }); + ).toEqual( + apiError({ + code: "not_found", + message: `Partner ${partnerId} is not enrolled in program ${program.id}.`, + }), + ); }); test("POST /partners/ban – tenantId not found", async ({ api }) => { @@ -179,44 +135,28 @@ test("POST /partners/ban – tenantId not found", async ({ api }) => { tenantId, reason: "fraud", }), - ).toEqual({ - status: 404, - data: { - error: { - code: "not_found", - message: `Partner with tenantId ${tenantId} not found in program.`, - doc_url: "https://dub.co/docs/api-reference/errors#not-found", - }, - }, - }); + ).toEqual( + apiError({ + code: "not_found", + message: `Partner with tenantId ${tenantId} not found in program.`, + }), + ); }); -const invalidReasonError = { - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: - 'invalid_value: reason: Invalid option: expected one of "tos_violation"|"inappropriate_content"|"fake_traffic"|"fraud"|"spam"|"brand_abuse"', - doc_url: "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, -}; +const invalidReasonError = apiError({ + code: "unprocessable_entity", + message: + 'invalid_value: reason: Invalid option: expected one of "tos_violation"|"inappropriate_content"|"fake_traffic"|"fraud"|"spam"|"brand_abuse"', +}); const banErrorCases = [ { name: "POST /partners/ban – missing partnerId and tenantId", body: { reason: "fraud" }, - expected: { - status: 400, - data: { - error: { - code: "bad_request", - message: "Either `partnerId` or `tenantId` must be provided.", - doc_url: "https://dub.co/docs/api-reference/errors#bad-request", - }, - }, - }, + expected: apiError({ + code: "bad_request", + message: "Either `partnerId` or `tenantId` must be provided.", + }), }, { name: "POST /partners/ban – missing reason", diff --git a/apps/web/playwright/api/partners/helpers.ts b/apps/web/playwright/api/partners/helpers.ts new file mode 100644 index 00000000000..4ee9fb641ca --- /dev/null +++ b/apps/web/playwright/api/partners/helpers.ts @@ -0,0 +1,116 @@ +import { createId } from "@/lib/api/create-id"; +import { conn } from "@/lib/planetscale"; +import { prisma } from "@/lib/prisma"; +import type { EnrolledPartnerProps } from "@/lib/types"; +import { DEFAULT_ADDITIONAL_PARTNER_LINKS } from "@/lib/zod/schemas/groups"; +import { nanoid } from "@dub/utils"; +import { randomName, randomPartnerEmail } from "../../utils"; +import type { ApiClient } from "../fixtures"; +import { TEST_WORKSPACE } from "../setup-test-workspace"; + +export async function createPartner( + api: ApiClient, + overrides: Record = {}, +) { + return api.post("/api/partners", { + name: randomName(), + email: randomPartnerEmail(), + ...overrides, + }); +} + +export async function createGroupWithAdditionalLinks(programId: string) { + return prisma.partnerGroup.create({ + data: { + id: createId({ prefix: "grp_" }), + programId, + slug: `pw-links-${nanoid(8).toLowerCase()}`, + name: randomName("links-group"), + maxPartnerLinks: DEFAULT_ADDITIONAL_PARTNER_LINKS, + additionalLinks: [ + { + domain: "example.com", + path: "", + validationMode: "domain", + }, + ], + partnerGroupDefaultLinks: { + create: { + id: createId({ prefix: "pgdl_" }), + programId, + domain: TEST_WORKSPACE.program.domain, + url: TEST_WORKSPACE.program.url, + }, + }, + }, + }); +} + +function isRelationConstraintError(error: unknown) { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error.code === "P2014" || error.code === "P2003") + ); +} + +// Commissions/payouts/customers require ProgramEnrollment. Lead tracking also +// queues create-partner-commission via QStash, so rows can appear after the +// first deleteMany — retry until enrollment delete succeeds. +export async function deletePartnerData(partnerId: string) { + await prisma.notificationEmail.deleteMany({ + where: { partnerId }, + }); + + await prisma.discountCode.deleteMany({ + where: { partnerId }, + }); + + const deadline = Date.now() + 15_000; + + while (true) { + try { + await prisma.commission.deleteMany({ + where: { partnerId }, + }); + + await prisma.payout.deleteMany({ + where: { partnerId }, + }); + + await prisma.submittedLead.deleteMany({ + where: { partnerId }, + }); + + await prisma.customer.deleteMany({ + where: { partnerId }, + }); + + await prisma.link.deleteMany({ + where: { partnerId }, + }); + + await prisma.programEnrollment.deleteMany({ + where: { partnerId }, + }); + return; + } catch (error) { + if (!isRelationConstraintError(error) || Date.now() >= deadline) { + throw error; + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } +} + +export async function deletePartner(partnerId: string | undefined) { + if (!partnerId) return; + + await deletePartnerData(partnerId); + + // Prisma partner.delete hits a PlanetScale relation quirk; raw SQL matches + // bulkDeletePartners cleanup used by e2e cron. + await conn.execute(`DELETE FROM Partner WHERE id = ?`, [partnerId]); +} diff --git a/apps/web/playwright/api/partners/partners.spec.ts b/apps/web/playwright/api/partners/partners.spec.ts index 1fc27831ac6..cbbc82e0bd2 100644 --- a/apps/web/playwright/api/partners/partners.spec.ts +++ b/apps/web/playwright/api/partners/partners.spec.ts @@ -1,18 +1,13 @@ -import { conn } from "@/lib/planetscale"; -import { prisma } from "@/lib/prisma"; import type { EnrolledPartnerProps } from "@/lib/types"; import { EnrolledPartnerSchema as EnrolledPartnerSchemaDate } from "@/lib/zod/schemas/partners"; import { nanoid } from "@dub/utils"; import { expect } from "@playwright/test"; import slugify from "@sindresorhus/slugify"; import * as z from "zod/v4"; -import { randomName, randomPartnerEmail } from "../../utils"; -import { test, type ApiClient } from "../fixtures"; +import { apiError, randomName, randomPartnerEmail } from "../../utils"; +import { test } from "../fixtures"; import { TEST_WORKSPACE } from "../setup-test-workspace"; - -test.describe.configure({ - mode: "parallel", -}); +import { createPartner, deletePartner } from "./helpers"; const EnrolledPartnerSchema = EnrolledPartnerSchemaDate.extend({ createdAt: z.string(), @@ -26,37 +21,6 @@ function reEscape(s: string) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -async function createPartner( - api: ApiClient, - overrides: Record = {}, -) { - return api.post("/api/partners", { - name: randomName(), - email: randomPartnerEmail(), - ...overrides, - }); -} - -async function deletePartner(partnerId: string | undefined) { - if (!partnerId) return; - - await prisma.link.deleteMany({ - where: { - partnerId, - }, - }); - - await prisma.programEnrollment.deleteMany({ - where: { - partnerId, - }, - }); - - // Prisma partner.delete hits a PlanetScale relation quirk; raw SQL matches - // bulkDeletePartners cleanup used by e2e cron. - await conn.execute(`DELETE FROM Partner WHERE id = ?`, [partnerId]); -} - test("POST /partners", async ({ api, program }) => { let partnerId: string | undefined; @@ -175,18 +139,13 @@ test("POST /partners – invalid username", async ({ api }) => { email: randomPartnerEmail(), username: "invalid username", }), - ).toEqual({ - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: - "custom: username: Invalid username. Must be a URL-friendly string.", - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }); + ).toEqual( + apiError({ + code: "unprocessable_entity", + message: + "custom: username: Invalid username. Must be a URL-friendly string.", + }), + ); }); test("POST /partners – linkProps.prefix on default link", async ({ diff --git a/apps/web/playwright/api/rewards/click-reward.spec.ts b/apps/web/playwright/api/rewards/click-reward.spec.ts new file mode 100644 index 00000000000..92394691554 --- /dev/null +++ b/apps/web/playwright/api/rewards/click-reward.spec.ts @@ -0,0 +1,183 @@ +import { getRewardAmount } from "@/lib/partners/get-reward-amount"; +import type { RewardConditionsArray } from "@/lib/types"; +import { expect } from "@playwright/test"; +import { EventType, Prisma, Reward, RewardStructure } from "@prisma/client"; +import { resolveClickReward } from "../../../app/(ee)/api/cron/aggregate-clicks/resolve-click-reward-amount"; +import { test } from "../fixtures"; +import { createReward, deleteReward, updateReward } from "./helpers"; + +const countryInModifier: RewardConditionsArray = [ + { + operator: "AND", + type: RewardStructure.flat, + amountInCents: 100, + conditions: [ + { + entity: "customer", + attribute: "country", + operator: "in", + value: ["US", "GB", "AU"], + }, + ], + }, +]; + +function resolvedAmount(reward: Reward, country: string) { + const resolved = resolveClickReward({ reward, country }); + + return getRewardAmount({ + type: resolved.type, + amountInCents: resolved.amountInCents, + amountInPercentage: + resolved.amountInPercentage != null + ? Number(resolved.amountInPercentage) + : null, + }); +} + +test.describe("Click reward resolution", () => { + // Shared reward; serial so modifiers can be updated between tests. + test.describe.configure({ mode: "serial" }); + + let rewardId: string | undefined; + + test.beforeAll(async ({ program }) => { + const reward = await createReward({ + programId: program.id, + event: EventType.click, + type: RewardStructure.flat, + amountInCents: 20, + modifiers: countryInModifier, + }); + + rewardId = reward.id; + }); + + test.afterAll(async () => { + await deleteReward(rewardId); + }); + + test("countries in modifier list get modifier amount; others get base", async () => { + const reward = await updateReward(rewardId!, { + modifiers: countryInModifier, + }); + + const testCases = [ + { country: "US", expected: 100 }, + { country: "GB", expected: 100 }, + { country: "AU", expected: 100 }, + { country: "CA", expected: 20 }, + { country: "FR", expected: 20 }, + { country: "DE", expected: 20 }, + { country: "JP", expected: 20 }, + ]; + + for (const { country, expected } of testCases) { + expect(resolvedAmount(reward, country)).toBe(expected); + } + }); + + test("equals_to country modifier matches only that country", async () => { + const reward = await updateReward(rewardId!, { + modifiers: [ + { + operator: "AND", + type: RewardStructure.flat, + amountInCents: 100, + conditions: [ + { + entity: "customer", + attribute: "country", + operator: "equals_to", + value: "US", + }, + ], + }, + ], + }); + + expect(resolvedAmount(reward, "US")).toBe(100); + expect(resolvedAmount(reward, "GB")).toBe(20); + expect(resolvedAmount(reward, "CA")).toBe(20); + }); + + test("not_in country modifier excludes listed countries", async () => { + const reward = await updateReward(rewardId!, { + modifiers: [ + { + operator: "AND", + type: RewardStructure.flat, + amountInCents: 100, + conditions: [ + { + entity: "customer", + attribute: "country", + operator: "not_in", + value: ["US"], + }, + ], + }, + ], + }); + + expect(resolvedAmount(reward, "US")).toBe(20); + expect(resolvedAmount(reward, "GB")).toBe(100); + expect(resolvedAmount(reward, "CA")).toBe(100); + }); + + test("null modifiers always use base amount", async () => { + const reward = await updateReward(rewardId!, { + modifiers: Prisma.JsonNull, + }); + + expect(reward.modifiers).toBeNull(); + expect(resolvedAmount(reward, "US")).toBe(20); + expect(resolvedAmount(reward, "GB")).toBe(20); + }); + + test("overlapping modifier groups pick the highest amount", async () => { + const reward = await updateReward(rewardId!, { + modifiers: [ + { + operator: "AND", + type: RewardStructure.flat, + amountInCents: 100, + conditions: [ + { + entity: "customer", + attribute: "country", + operator: "equals_to", + value: "US", + }, + ], + }, + { + operator: "AND", + type: RewardStructure.flat, + amountInCents: 250, + conditions: [ + { + entity: "customer", + attribute: "country", + operator: "in", + value: ["US", "GB"], + }, + ], + }, + ], + }); + + expect(resolvedAmount(reward, "US")).toBe(250); + expect(resolvedAmount(reward, "GB")).toBe(250); + expect(resolvedAmount(reward, "CA")).toBe(20); + }); + + test("invalid modifiers JSON falls back to base amount", async () => { + const reward = await updateReward(rewardId!, { + modifiers: {}, + }); + + expect(resolvedAmount(reward, "US")).toBe(20); + expect(resolvedAmount(reward, "GB")).toBe(20); + }); +}); diff --git a/apps/web/playwright/api/rewards/helpers.ts b/apps/web/playwright/api/rewards/helpers.ts new file mode 100644 index 00000000000..46d8ed823f5 --- /dev/null +++ b/apps/web/playwright/api/rewards/helpers.ts @@ -0,0 +1,36 @@ +import { createId } from "@/lib/api/create-id"; +import { prisma } from "@/lib/prisma"; +import type { Prisma, Reward } from "@prisma/client"; + +export async function createReward( + data: Omit, +): Promise { + return prisma.reward.create({ + data: { + id: createId({ prefix: "rw_" }), + ...data, + }, + }); +} + +export async function updateReward( + rewardId: string, + data: Prisma.RewardUncheckedUpdateInput, +) { + return prisma.reward.update({ + where: { + id: rewardId, + }, + data, + }); +} + +export async function deleteReward(rewardId: string | undefined) { + if (!rewardId) return; + + await prisma.reward.delete({ + where: { + id: rewardId, + }, + }); +} diff --git a/apps/web/playwright/api/rewards/lead-reward.spec.ts b/apps/web/playwright/api/rewards/lead-reward.spec.ts new file mode 100644 index 00000000000..485d00717af --- /dev/null +++ b/apps/web/playwright/api/rewards/lead-reward.spec.ts @@ -0,0 +1,779 @@ +import { prisma } from "@/lib/prisma"; +import type { + CommissionResponse, + Customer, + EnrolledPartnerProps, + LinkProps, + RewardConditionsArray, +} from "@/lib/types"; +import { expect } from "@playwright/test"; +import { EventType, Prisma, RewardStructure } from "@prisma/client"; +import { randomCustomer } from "../../utils"; +import { deleteCommissionPartner } from "../commissions/helpers"; +import { trackClick, trackLead } from "../conversions/helpers"; +import { test, type ApiClient } from "../fixtures"; +import { createPartner } from "../partners/helpers"; +import { createReward, deleteReward, updateReward } from "./helpers"; + +const BASE_AMOUNT = 100; +const MODIFIER_AMOUNT = 500; + +type LeadRewardCtx = { + api: ApiClient; + partnerId: string; + programId: string; + workspaceId: string; + link: Pick; +}; + +const planProMetadataModifier: RewardConditionsArray = [ + { + operator: "AND", + type: RewardStructure.flat, + amountInCents: MODIFIER_AMOUNT, + conditions: [ + { + entity: "lead", + attribute: "metadata", + metadataField: "plan", + operator: "equals_to", + value: "pro", + }, + ], + }, +]; + +function modifier( + conditions: RewardConditionsArray[number]["conditions"], + amountInCents = MODIFIER_AMOUNT, +): RewardConditionsArray { + return [ + { + operator: "AND", + type: RewardStructure.flat, + amountInCents, + conditions, + }, + ]; +} + +async function resetPartnerState( + ctx: LeadRewardCtx, + { country = "US" }: { country?: string } = {}, +) { + await prisma.partner.update({ + where: { + id: ctx.partnerId, + }, + data: { + country, + }, + }); + + await prisma.link.update({ + where: { + id: ctx.link.id, + }, + data: { + clicks: 0, + leads: 0, + conversions: 0, + sales: 0, + saleAmount: 0, + }, + }); + + await prisma.programEnrollment.update({ + where: { + partnerId_programId: { + partnerId: ctx.partnerId, + programId: ctx.programId, + }, + }, + data: { + totalCommissions: 0, + }, + }); +} + +async function trackPartnerLead( + ctx: LeadRewardCtx, + overrides: Record = {}, +): Promise<{ customerExternalId: string }> { + const { clickId } = await trackClick({ + domain: ctx.link.domain, + key: ctx.link.key, + }); + + const { customer } = await trackLead({ + clickId, + eventName: "Signup", + ...overrides, + }); + + return { + customerExternalId: + (overrides.customerExternalId as string | undefined) ?? + customer.externalId, + }; +} + +async function expectLeadCommission( + ctx: LeadRewardCtx, + { + customerExternalId, + expectedEarnings, + expectedMetadata, + }: { + customerExternalId: string; + expectedEarnings: number; + expectedMetadata?: Record | null; + }, +) { + let customerId: string | undefined; + + await expect + .poll(async () => { + const customer = await prisma.customer.findUnique({ + where: { + projectId_externalId: { + projectId: ctx.workspaceId, + externalId: customerExternalId, + }, + }, + }); + + if (!customer) { + return null; + } + + customerId = customer.id; + + const commission = await prisma.commission.findFirst({ + where: { + partnerId: ctx.partnerId, + programId: ctx.programId, + type: "lead", + customerId, + }, + orderBy: { + createdAt: "desc", + }, + }); + + if (!commission) { + return null; + } + + return { + earnings: Number(commission.earnings), + metadata: commission.metadata, + }; + }) + .toEqual({ + earnings: expectedEarnings, + metadata: expectedMetadata === undefined ? null : expectedMetadata, + }); + + const listQuery = new URLSearchParams({ + partnerId: ctx.partnerId, + type: "lead", + customerId: customerId!, + }); + + const { status, data: commissions } = await ctx.api.get( + `/api/commissions?${listQuery}`, + ); + + expect(status).toEqual(200); + expect(commissions.length).toBeGreaterThan(0); + expect(commissions[0]).toMatchObject({ + type: "lead", + earnings: expectedEarnings, + ...(expectedMetadata !== undefined ? { metadata: expectedMetadata } : {}), + }); +} + +async function expectNoCommission( + ctx: LeadRewardCtx, + { customerExternalId }: { customerExternalId: string }, +) { + // Give the create-partner-commission workflow time to run (and skip). + await new Promise((resolve) => setTimeout(resolve, 5_000)); + + const customer = await prisma.customer.findUnique({ + where: { + projectId_externalId: { + projectId: ctx.workspaceId, + externalId: customerExternalId, + }, + }, + }); + + expect(customer).toBeTruthy(); + + const commission = await prisma.commission.findFirst({ + where: { + partnerId: ctx.partnerId, + programId: ctx.programId, + type: "lead", + customerId: customer!.id, + }, + }); + + expect(commission).toBeNull(); +} + +async function expectLeadCommissionCount( + ctx: LeadRewardCtx, + { + customerExternalId, + count, + }: { + customerExternalId: string; + count: number; + }, +) { + const customer = await prisma.customer.findUniqueOrThrow({ + where: { + projectId_externalId: { + projectId: ctx.workspaceId, + externalId: customerExternalId, + }, + }, + }); + + // Give a follow-up create-partner-commission workflow time to run (or skip). + await new Promise((resolve) => setTimeout(resolve, 5_000)); + + await expect + .poll(async () => { + return prisma.commission.count({ + where: { + partnerId: ctx.partnerId, + programId: ctx.programId, + type: "lead", + customerId: customer.id, + }, + }); + }) + .toEqual(count); +} + +test.describe("Lead rewards", () => { + // Shared reward + enrollment; serial so modifiers can be updated between tests. + // No retries: a failed case leaves shared reward/partner state that would flake on retry. + test.describe.configure({ mode: "serial", retries: 0 }); + + let rewardId: string | undefined; + let ctx: LeadRewardCtx | undefined; + + test.beforeAll(async ({ api, program, workspace }) => { + const reward = await createReward({ + programId: program.id, + event: EventType.lead, + type: RewardStructure.flat, + amountInCents: BASE_AMOUNT, + maxDuration: 0, + }); + + const { data } = await createPartner(api, { + groupId: program.defaultGroupId, + country: "US", + }); + + const partner = data as EnrolledPartnerProps; + expect(partner.links?.[0]).toBeTruthy(); + + rewardId = reward.id; + ctx = { + api, + partnerId: partner.id, + programId: program.id, + workspaceId: workspace.id, + link: partner.links![0], + }; + + // Detach the default group's lead reward so the first test has none. + await prisma.programEnrollment.update({ + where: { + partnerId_programId: { + partnerId: partner.id, + programId: program.id, + }, + }, + data: { + leadRewardId: null, + }, + }); + }); + + test.afterAll(async () => { + await deleteCommissionPartner({ partnerId: ctx?.partnerId }); + await deleteReward(rewardId); + }); + + test("no lead reward skips commission creation", async () => { + const { customerExternalId } = await trackPartnerLead(ctx!); + await expectNoCommission(ctx!, { customerExternalId }); + }); + + test("base reward with no conditions uses base amount", async () => { + await prisma.programEnrollment.update({ + where: { + partnerId_programId: { + partnerId: ctx!.partnerId, + programId: ctx!.programId, + }, + }, + data: { + leadRewardId: rewardId!, + }, + }); + + await updateReward(rewardId!, { + modifiers: Prisma.JsonNull, + }); + + const { customerExternalId } = await trackPartnerLead(ctx!); + await expectLeadCommission(ctx!, { + customerExternalId, + expectedEarnings: BASE_AMOUNT, + }); + }); + + test("does not create a second lead commission for the same customer and partner", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: Prisma.JsonNull, + }); + + const customer = randomCustomer(); + const { customerExternalId } = await trackPartnerLead(ctx!, { + eventName: "Signup", + customerExternalId: customer.externalId, + customerEmail: customer.email, + customerName: customer.name, + }); + await expectLeadCommission(ctx!, { + customerExternalId, + expectedEarnings: BASE_AMOUNT, + }); + + // Different eventName so /track/lead is not Redis-deduped; commission + // workflow should still skip because a lead commission already exists. + await trackPartnerLead(ctx!, { + eventName: "Requested demo", + customerExternalId: customer.externalId, + customerEmail: customer.email, + customerName: customer.name, + }); + await expectLeadCommissionCount(ctx!, { + customerExternalId, + count: 1, + }); + }); + + test("customer source equals_to tracked matches", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "customer", + attribute: "source", + operator: "equals_to", + value: "tracked", + }, + ]), + }); + + const { customerExternalId } = await trackPartnerLead(ctx!); + await expectLeadCommission(ctx!, { + customerExternalId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("customer source equals_to submitted misses and uses base", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "customer", + attribute: "source", + operator: "equals_to", + value: "submitted", + }, + ]), + }); + + const { customerExternalId } = await trackPartnerLead(ctx!); + await expectLeadCommission(ctx!, { + customerExternalId, + expectedEarnings: BASE_AMOUNT, + }); + }); + + test("customer country equals_to US matches", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "customer", + attribute: "country", + operator: "equals_to", + value: "US", + }, + ]), + }); + + const { customerExternalId } = await trackPartnerLead(ctx!); + await expectLeadCommission(ctx!, { + customerExternalId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("customer country equals_to CA matches pre-created customer", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "customer", + attribute: "country", + operator: "equals_to", + value: "CA", + }, + ]), + }); + + const customer = randomCustomer(); + const { status } = await ctx!.api.post("/api/customers", { + ...customer, + country: "CA", + }); + expect(status).toEqual(201); + + const { customerExternalId } = await trackPartnerLead(ctx!, { + customerExternalId: customer.externalId, + customerEmail: customer.email, + customerName: customer.name, + }); + + await expectLeadCommission(ctx!, { + customerExternalId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("partner country equals_to US matches", async () => { + await resetPartnerState(ctx!, { country: "US" }); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "partner", + attribute: "country", + operator: "equals_to", + value: "US", + }, + ]), + }); + + const { customerExternalId } = await trackPartnerLead(ctx!); + await expectLeadCommission(ctx!, { + customerExternalId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("partner country equals_to US misses for SG partner", async () => { + await resetPartnerState(ctx!, { country: "SG" }); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "partner", + attribute: "country", + operator: "equals_to", + value: "US", + }, + ]), + }); + + const { customerExternalId } = await trackPartnerLead(ctx!); + await expectLeadCommission(ctx!, { + customerExternalId, + expectedEarnings: BASE_AMOUNT, + }); + }); + + test("partner totalClicks greater_than matches seeded clicks", async () => { + await resetPartnerState(ctx!); + await prisma.link.update({ + where: { + id: ctx!.link.id, + }, + data: { + clicks: 50, + }, + }); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "partner", + attribute: "totalClicks", + operator: "greater_than", + value: 40, + }, + ]), + }); + + const { customerExternalId } = await trackPartnerLead(ctx!); + await expectLeadCommission(ctx!, { + customerExternalId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("partner totalClicks greater_than misses when clicks are zero", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "partner", + attribute: "totalClicks", + operator: "greater_than", + value: 50, + }, + ]), + }); + + const { customerExternalId } = await trackPartnerLead(ctx!); + await expectLeadCommission(ctx!, { + customerExternalId, + expectedEarnings: BASE_AMOUNT, + }); + }); + + test("partner totalLeads greater_than matches seeded leads", async () => { + await resetPartnerState(ctx!); + await prisma.link.update({ + where: { + id: ctx!.link.id, + }, + data: { + leads: 50, + }, + }); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "partner", + attribute: "totalLeads", + operator: "greater_than", + value: 40, + }, + ]), + }); + + const { customerExternalId } = await trackPartnerLead(ctx!); + await expectLeadCommission(ctx!, { + customerExternalId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("partner totalConversions greater_than matches seeded conversions", async () => { + await resetPartnerState(ctx!); + await prisma.link.update({ + where: { + id: ctx!.link.id, + }, + data: { + conversions: 50, + }, + }); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "partner", + attribute: "totalConversions", + operator: "greater_than", + value: 40, + }, + ]), + }); + + const { customerExternalId } = await trackPartnerLead(ctx!); + await expectLeadCommission(ctx!, { + customerExternalId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("partner totalSaleAmount greater_than matches seeded saleAmount", async () => { + await resetPartnerState(ctx!); + await prisma.link.update({ + where: { + id: ctx!.link.id, + }, + data: { + saleAmount: 50_00, + }, + }); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "partner", + attribute: "totalSaleAmount", + operator: "greater_than", + value: 40_00, + }, + ]), + }); + + const { customerExternalId } = await trackPartnerLead(ctx!); + await expectLeadCommission(ctx!, { + customerExternalId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("partner totalCommissions greater_than matches seeded enrollment total", async () => { + await resetPartnerState(ctx!); + await prisma.programEnrollment.update({ + where: { + partnerId_programId: { + partnerId: ctx!.partnerId, + programId: ctx!.programId, + }, + }, + data: { + totalCommissions: 50_00, + }, + }); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "partner", + attribute: "totalCommissions", + operator: "greater_than", + value: 40_00, + }, + ]), + }); + + const { customerExternalId } = await trackPartnerLead(ctx!); + await expectLeadCommission(ctx!, { + customerExternalId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("lead metadata plan equals_to pro matches", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: planProMetadataModifier, + }); + + const metadata = { plan: "pro" }; + const { customerExternalId } = await trackPartnerLead(ctx!, { metadata }); + await expectLeadCommission(ctx!, { + customerExternalId, + expectedEarnings: MODIFIER_AMOUNT, + expectedMetadata: metadata, + }); + }); + + test("lead metadata plan equals_to pro misses without metadata", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: planProMetadataModifier, + }); + + const { customerExternalId } = await trackPartnerLead(ctx!); + await expectLeadCommission(ctx!, { + customerExternalId, + expectedEarnings: BASE_AMOUNT, + }); + }); + + test("when customer country is US and partner country is US", async () => { + await resetPartnerState(ctx!, { country: "US" }); + await updateReward(rewardId!, { + modifiers: [ + { + operator: "AND", + type: RewardStructure.flat, + amountInCents: 200, + conditions: [ + { + entity: "customer", + attribute: "country", + operator: "equals_to", + value: "US", + }, + ], + }, + { + operator: "AND", + type: RewardStructure.flat, + amountInCents: 300, + conditions: [ + { + entity: "partner", + attribute: "country", + operator: "equals_to", + value: "US", + }, + ], + }, + ], + }); + + const { customerExternalId } = await trackPartnerLead(ctx!); + await expectLeadCommission(ctx!, { + customerExternalId, + expectedEarnings: 300, + }); + }); + + test("when customer country is US and partner country is not US", async () => { + await resetPartnerState(ctx!, { country: "SG" }); + await updateReward(rewardId!, { + modifiers: [ + { + operator: "AND", + type: RewardStructure.flat, + amountInCents: 200, + conditions: [ + { + entity: "customer", + attribute: "country", + operator: "equals_to", + value: "US", + }, + ], + }, + { + operator: "AND", + type: RewardStructure.flat, + amountInCents: 300, + conditions: [ + { + entity: "partner", + attribute: "country", + operator: "equals_to", + value: "US", + }, + ], + }, + ], + }); + + const { customerExternalId } = await trackPartnerLead(ctx!); + await expectLeadCommission(ctx!, { + customerExternalId, + expectedEarnings: 200, + }); + }); +}); diff --git a/apps/web/playwright/api/rewards/sale-reward.spec.ts b/apps/web/playwright/api/rewards/sale-reward.spec.ts new file mode 100644 index 00000000000..916b1d3ca12 --- /dev/null +++ b/apps/web/playwright/api/rewards/sale-reward.spec.ts @@ -0,0 +1,1146 @@ +import { prisma } from "@/lib/prisma"; +import type { + CommissionResponse, + EnrolledPartnerProps, + LinkProps, + RewardConditionsArray, +} from "@/lib/types"; +import { expect } from "@playwright/test"; +import { EventType, Prisma, RewardStructure } from "@prisma/client"; +import { deleteCommissionPartner } from "../commissions/helpers"; +import { trackClick, trackLead, trackSale } from "../conversions/helpers"; +import { test, type ApiClient } from "../fixtures"; +import { createPartner } from "../partners/helpers"; +import { createReward, deleteReward, updateReward } from "./helpers"; + +const BASE_AMOUNT = 100; +const MODIFIER_AMOUNT = 500; +const SALE_AMOUNT = 1000; + +type SaleRewardCtx = { + api: ApiClient; + partnerId: string; + programId: string; + workspaceId: string; + link: Pick; +}; + +const bookTitleMetadataModifier: RewardConditionsArray = [ + { + operator: "AND", + type: RewardStructure.flat, + amountInCents: MODIFIER_AMOUNT, + conditions: [ + { + entity: "sale", + attribute: "metadata", + metadataField: "bookTitle", + operator: "equals_to", + value: "THGTTG", + }, + ], + }, +]; + +function modifier( + conditions: RewardConditionsArray[number]["conditions"], + amountInCents = MODIFIER_AMOUNT, +): RewardConditionsArray { + return [ + { + operator: "AND", + type: RewardStructure.flat, + amountInCents, + conditions, + }, + ]; +} + +async function resetPartnerState( + ctx: SaleRewardCtx, + { country = "US" }: { country?: string } = {}, +) { + await prisma.partner.update({ + where: { + id: ctx.partnerId, + }, + data: { + country, + }, + }); + + await prisma.link.update({ + where: { + id: ctx.link.id, + }, + data: { + clicks: 0, + leads: 0, + conversions: 0, + sales: 0, + saleAmount: 0, + }, + }); + + await prisma.programEnrollment.update({ + where: { + partnerId_programId: { + partnerId: ctx.partnerId, + programId: ctx.programId, + }, + }, + data: { + totalCommissions: 0, + }, + }); +} + +async function trackPartnerSale( + ctx: SaleRewardCtx, + overrides: Record = {}, +): Promise<{ + customerExternalId: string; + invoiceId: string; + amount: number; +}> { + const customerExternalIdOverride = overrides.customerExternalId as + | string + | undefined; + const saleOverrides = { ...overrides }; + delete saleOverrides.customerExternalId; + delete saleOverrides.customerEmail; + delete saleOverrides.customerName; + + const { clickId } = await trackClick({ + domain: ctx.link.domain, + key: ctx.link.key, + }); + + const { customer } = await trackLead({ + clickId, + eventName: "Signup", + ...(customerExternalIdOverride + ? { + customerExternalId: customerExternalIdOverride, + customerEmail: overrides.customerEmail, + customerName: overrides.customerName, + } + : {}), + }); + + const customerExternalId = customerExternalIdOverride ?? customer.externalId; + + const { invoiceId, amount } = await trackSale({ + customerExternalId, + amount: SALE_AMOUNT, + ...saleOverrides, + }); + + return { + customerExternalId, + invoiceId, + amount, + }; +} + +async function trackSaleForCustomer( + ctx: SaleRewardCtx, + { + customerExternalId, + ...overrides + }: { + customerExternalId: string; + } & Record, +): Promise<{ invoiceId: string; amount: number }> { + return trackSale({ + customerExternalId, + amount: SALE_AMOUNT, + ...overrides, + }); +} + +async function expectSaleCommission( + ctx: SaleRewardCtx, + { + invoiceId, + expectedEarnings, + expectedMetadata, + }: { + invoiceId: string; + expectedEarnings: number; + expectedMetadata?: Record | null; + }, +) { + await expect + .poll(async () => { + const commission = await prisma.commission.findFirst({ + where: { + partnerId: ctx.partnerId, + programId: ctx.programId, + type: "sale", + invoiceId, + }, + orderBy: { + createdAt: "desc", + }, + }); + + if (!commission) { + return null; + } + + return { + earnings: Number(commission.earnings), + metadata: commission.metadata, + }; + }) + .toEqual({ + earnings: expectedEarnings, + metadata: expectedMetadata === undefined ? null : expectedMetadata, + }); + + const listQuery = new URLSearchParams({ + partnerId: ctx.partnerId, + type: "sale", + invoiceId, + }); + + const { status, data: commissions } = await ctx.api.get( + `/api/commissions?${listQuery}`, + ); + + expect(status).toEqual(200); + expect(commissions.length).toBeGreaterThan(0); + expect(commissions[0]).toMatchObject({ + type: "sale", + earnings: expectedEarnings, + invoiceId, + ...(expectedMetadata !== undefined ? { metadata: expectedMetadata } : {}), + }); +} + +async function expectNoSaleCommission( + ctx: SaleRewardCtx, + { invoiceId }: { invoiceId: string }, +) { + // Give the create-partner-commission workflow time to run (and skip). + await new Promise((resolve) => setTimeout(resolve, 5_000)); + + const commission = await prisma.commission.findFirst({ + where: { + partnerId: ctx.partnerId, + programId: ctx.programId, + type: "sale", + invoiceId, + }, + }); + + expect(commission).toBeNull(); +} + +async function expectSaleCommissionCount( + ctx: SaleRewardCtx, + { + customerExternalId, + count, + }: { + customerExternalId: string; + count: number; + }, +) { + const customer = await prisma.customer.findUniqueOrThrow({ + where: { + projectId_externalId: { + projectId: ctx.workspaceId, + externalId: customerExternalId, + }, + }, + }); + + // Give a follow-up create-partner-commission workflow time to run (or skip). + await new Promise((resolve) => setTimeout(resolve, 5_000)); + + await expect + .poll(async () => { + return prisma.commission.count({ + where: { + partnerId: ctx.partnerId, + programId: ctx.programId, + type: "sale", + customerId: customer.id, + }, + }); + }) + .toEqual(count); +} + +test.describe("Sale rewards", () => { + // Shared reward + enrollment; serial so modifiers can be updated between tests. + // No retries: a failed case leaves shared reward/partner state that would flake on retry. + test.describe.configure({ mode: "serial", retries: 0 }); + + let rewardId: string | undefined; + let ctx: SaleRewardCtx | undefined; + + test.beforeAll(async ({ api, program, workspace }) => { + const reward = await createReward({ + programId: program.id, + event: EventType.sale, + type: RewardStructure.flat, + amountInCents: BASE_AMOUNT, + maxDuration: null, + }); + + const { data } = await createPartner(api, { + groupId: program.defaultGroupId, + country: "US", + }); + + const partner = data as EnrolledPartnerProps; + expect(partner.links?.[0]).toBeTruthy(); + + rewardId = reward.id; + ctx = { + api, + partnerId: partner.id, + programId: program.id, + workspaceId: workspace.id, + link: partner.links![0], + }; + + // Detach default group rewards so the first test has none, and so + // track/lead does not enqueue a lead commission before sale jobs. + await prisma.programEnrollment.update({ + where: { + partnerId_programId: { + partnerId: partner.id, + programId: program.id, + }, + }, + data: { + saleRewardId: null, + leadRewardId: null, + }, + }); + }); + + test.afterAll(async () => { + await deleteCommissionPartner({ partnerId: ctx?.partnerId }); + await deleteReward(rewardId); + }); + + test("no sale reward skips commission creation", async () => { + const { invoiceId } = await trackPartnerSale(ctx!); + await expectNoSaleCommission(ctx!, { invoiceId }); + }); + + test("base reward with no conditions uses base amount", async () => { + await prisma.programEnrollment.update({ + where: { + partnerId_programId: { + partnerId: ctx!.partnerId, + programId: ctx!.programId, + }, + }, + data: { + saleRewardId: rewardId!, + }, + }); + + await updateReward(rewardId!, { + modifiers: Prisma.JsonNull, + }); + + const { invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: BASE_AMOUNT, + }); + }); + + test("does not create a second sale commission when maxDuration is 0", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: Prisma.JsonNull, + maxDuration: 0, + }); + + const { customerExternalId, invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: BASE_AMOUNT, + }); + + await trackSaleForCustomer(ctx!, { customerExternalId }); + await expectSaleCommissionCount(ctx!, { + customerExternalId, + count: 1, + }); + + await updateReward(rewardId!, { + maxDuration: null, + }); + }); + + test("customer source equals_to tracked matches", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "customer", + attribute: "source", + operator: "equals_to", + value: "tracked", + }, + ]), + }); + + const { invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("customer source equals_to submitted misses and uses base", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "customer", + attribute: "source", + operator: "equals_to", + value: "submitted", + }, + ]), + }); + + const { invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: BASE_AMOUNT, + }); + }); + + test("customer country equals_to US matches", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "customer", + attribute: "country", + operator: "equals_to", + value: "US", + }, + ]), + }); + + const { invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("customer country equals_to SG matches after updating customer country", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "customer", + attribute: "country", + operator: "equals_to", + value: "SG", + }, + ]), + }); + + // track/sale requires customer.linkId; POST /api/customers alone leaves it + // null. Create via click+lead, then override country before the sale. + const { clickId } = await trackClick({ + domain: ctx!.link.domain, + key: ctx!.link.key, + }); + const { customer } = await trackLead({ + clickId, + eventName: "Signup", + }); + + await prisma.customer.update({ + where: { + projectId_externalId: { + projectId: ctx!.workspaceId, + externalId: customer.externalId, + }, + }, + data: { + country: "SG", + }, + }); + + const { invoiceId } = await trackSale({ + customerExternalId: customer.externalId, + amount: SALE_AMOUNT, + }); + + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("customer signupDate window matches", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "customer", + attribute: "signupDate", + operator: "greater_than", + value: new Date("2026-02-16T00:00:00.000Z").getTime(), + }, + { + entity: "customer", + attribute: "signupDate", + operator: "less_than", + value: new Date("2026-02-18T00:00:00.000Z").getTime(), + }, + ]), + }); + + const { clickId } = await trackClick({ + domain: ctx!.link.domain, + key: ctx!.link.key, + }); + const { customer } = await trackLead({ + clickId, + eventName: "Signup", + }); + + await prisma.customer.update({ + where: { + projectId_externalId: { + projectId: ctx!.workspaceId, + externalId: customer.externalId, + }, + }, + data: { + createdAt: new Date("2026-02-17T00:00:00.000Z"), + }, + }); + + const { invoiceId } = await trackSale({ + customerExternalId: customer.externalId, + amount: SALE_AMOUNT, + }); + + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("customer signupDate window misses for current signup", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "customer", + attribute: "signupDate", + operator: "greater_than", + value: new Date("2026-02-16T00:00:00.000Z").getTime(), + }, + { + entity: "customer", + attribute: "signupDate", + operator: "less_than", + value: new Date("2026-02-18T00:00:00.000Z").getTime(), + }, + ]), + }); + + const { invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: BASE_AMOUNT, + }); + }); + + test("customer subscriptionDurationMonths less_than_or_equal 3 matches first sale", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "customer", + attribute: "subscriptionDurationMonths", + operator: "less_than_or_equal", + value: 3, + }, + ]), + }); + + const { invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("customer subscriptionDurationMonths less_than_or_equal 3 misses after 4 months", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "customer", + attribute: "subscriptionDurationMonths", + operator: "less_than_or_equal", + value: 3, + }, + ]), + }); + + const { customerExternalId, invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: MODIFIER_AMOUNT, + }); + + const fourMonthsAgo = new Date(); + fourMonthsAgo.setMonth(fourMonthsAgo.getMonth() - 4); + + await prisma.commission.updateMany({ + where: { + partnerId: ctx!.partnerId, + programId: ctx!.programId, + type: "sale", + invoiceId, + }, + data: { + createdAt: fourMonthsAgo, + }, + }); + + const second = await trackSaleForCustomer(ctx!, { customerExternalId }); + await expectSaleCommission(ctx!, { + invoiceId: second.invoiceId, + expectedEarnings: BASE_AMOUNT, + }); + }); + + test("customer subscriptionStartDate greater_than recent cutoff matches", async () => { + await resetPartnerState(ctx!); + const cutoff = Date.now() - 24 * 60 * 60 * 1000; + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "customer", + attribute: "subscriptionStartDate", + operator: "greater_than", + value: cutoff, + }, + ]), + }); + + const { invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("customer subscriptionStartDate greater_than future cutoff misses", async () => { + await resetPartnerState(ctx!); + const cutoff = Date.now() + 24 * 60 * 60 * 1000; + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "customer", + attribute: "subscriptionStartDate", + operator: "greater_than", + value: cutoff, + }, + ]), + }); + + const { invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: BASE_AMOUNT, + }); + }); + + test("partner country equals_to US matches", async () => { + await resetPartnerState(ctx!, { country: "US" }); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "partner", + attribute: "country", + operator: "equals_to", + value: "US", + }, + ]), + }); + + const { invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("partner country equals_to US misses for SG partner", async () => { + await resetPartnerState(ctx!, { country: "SG" }); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "partner", + attribute: "country", + operator: "equals_to", + value: "US", + }, + ]), + }); + + const { invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: BASE_AMOUNT, + }); + }); + + test("partner totalClicks greater_than matches seeded clicks", async () => { + await resetPartnerState(ctx!); + await prisma.link.update({ + where: { + id: ctx!.link.id, + }, + data: { + clicks: 50, + }, + }); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "partner", + attribute: "totalClicks", + operator: "greater_than", + value: 40, + }, + ]), + }); + + const { invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("partner totalClicks greater_than misses when clicks are zero", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "partner", + attribute: "totalClicks", + operator: "greater_than", + value: 50, + }, + ]), + }); + + const { invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: BASE_AMOUNT, + }); + }); + + test("partner totalLeads greater_than matches seeded leads", async () => { + await resetPartnerState(ctx!); + await prisma.link.update({ + where: { + id: ctx!.link.id, + }, + data: { + leads: 50, + }, + }); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "partner", + attribute: "totalLeads", + operator: "greater_than", + value: 40, + }, + ]), + }); + + const { invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("partner totalConversions greater_than matches seeded conversions", async () => { + await resetPartnerState(ctx!); + await prisma.link.update({ + where: { + id: ctx!.link.id, + }, + data: { + conversions: 50, + }, + }); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "partner", + attribute: "totalConversions", + operator: "greater_than", + value: 40, + }, + ]), + }); + + const { invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("partner totalSaleAmount greater_than matches seeded saleAmount", async () => { + await resetPartnerState(ctx!); + await prisma.link.update({ + where: { + id: ctx!.link.id, + }, + data: { + saleAmount: 50_00, + }, + }); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "partner", + attribute: "totalSaleAmount", + operator: "greater_than", + value: 40_00, + }, + ]), + }); + + const { invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("partner totalCommissions greater_than matches seeded enrollment total", async () => { + await resetPartnerState(ctx!); + await prisma.programEnrollment.update({ + where: { + partnerId_programId: { + partnerId: ctx!.partnerId, + programId: ctx!.programId, + }, + }, + data: { + totalCommissions: 50_00, + }, + }); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "partner", + attribute: "totalCommissions", + operator: "greater_than", + value: 40_00, + }, + ]), + }); + + const { invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("sale productId equals_to premiumProductId matches", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "sale", + attribute: "productId", + operator: "equals_to", + value: "premiumProductId", + }, + ]), + }); + + const { invoiceId } = await trackPartnerSale(ctx!, { + metadata: { + productId: "premiumProductId", + }, + }); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: MODIFIER_AMOUNT, + expectedMetadata: { + productId: "premiumProductId", + }, + }); + }); + + test("sale productId equals_to premiumProductId misses for regularProductId", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "sale", + attribute: "productId", + operator: "equals_to", + value: "premiumProductId", + }, + ]), + }); + + const { invoiceId } = await trackPartnerSale(ctx!, { + metadata: { + productId: "regularProductId", + }, + }); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: BASE_AMOUNT, + expectedMetadata: { + productId: "regularProductId", + }, + }); + }); + + test("sale amount greater_than 15000 matches", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "sale", + attribute: "amount", + operator: "greater_than", + value: 15000, + }, + ]), + }); + + const { invoiceId } = await trackPartnerSale(ctx!, { + amount: 17500, + }); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("sale amount greater_than 15000 misses for default amount", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "sale", + attribute: "amount", + operator: "greater_than", + value: 15000, + }, + ]), + }); + + const { invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: BASE_AMOUNT, + }); + }); + + test("sale type equals_to new matches first sale and misses recurring", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "sale", + attribute: "type", + operator: "equals_to", + value: "new", + }, + ]), + }); + + const { customerExternalId, invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: MODIFIER_AMOUNT, + }); + + const second = await trackSaleForCustomer(ctx!, { customerExternalId }); + await expectSaleCommission(ctx!, { + invoiceId: second.invoiceId, + expectedEarnings: BASE_AMOUNT, + }); + }); + + test("sale type equals_to recurring misses first sale and matches second", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: modifier([ + { + entity: "sale", + attribute: "type", + operator: "equals_to", + value: "recurring", + }, + ]), + }); + + const { customerExternalId, invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: BASE_AMOUNT, + }); + + const second = await trackSaleForCustomer(ctx!, { customerExternalId }); + await expectSaleCommission(ctx!, { + invoiceId: second.invoiceId, + expectedEarnings: MODIFIER_AMOUNT, + }); + }); + + test("sale metadata bookTitle equals_to THGTTG matches", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: bookTitleMetadataModifier, + }); + + const metadata = { bookTitle: "THGTTG" }; + const { invoiceId } = await trackPartnerSale(ctx!, { metadata }); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: MODIFIER_AMOUNT, + expectedMetadata: metadata, + }); + }); + + test("sale metadata bookTitle equals_to THGTTG misses without metadata", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: bookTitleMetadataModifier, + }); + + const { invoiceId } = await trackPartnerSale(ctx!); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: BASE_AMOUNT, + }); + }); + + test("when sale productId is premium and amount is greater than 15000", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: [ + { + operator: "AND", + type: RewardStructure.flat, + amountInCents: 200, + conditions: [ + { + entity: "sale", + attribute: "productId", + operator: "equals_to", + value: "premiumProductId", + }, + ], + }, + { + operator: "AND", + type: RewardStructure.flat, + amountInCents: 300, + conditions: [ + { + entity: "sale", + attribute: "amount", + operator: "greater_than", + value: 15000, + }, + ], + }, + ], + }); + + const { invoiceId } = await trackPartnerSale(ctx!, { + amount: 17500, + metadata: { + productId: "premiumProductId", + }, + }); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: 300, + expectedMetadata: { + productId: "premiumProductId", + }, + }); + }); + + test("when sale productId is premium and amount is not greater than 15000", async () => { + await resetPartnerState(ctx!); + await updateReward(rewardId!, { + modifiers: [ + { + operator: "AND", + type: RewardStructure.flat, + amountInCents: 200, + conditions: [ + { + entity: "sale", + attribute: "productId", + operator: "equals_to", + value: "premiumProductId", + }, + ], + }, + { + operator: "AND", + type: RewardStructure.flat, + amountInCents: 300, + conditions: [ + { + entity: "sale", + attribute: "amount", + operator: "greater_than", + value: 15000, + }, + ], + }, + ], + }); + + const { invoiceId } = await trackPartnerSale(ctx!, { + amount: SALE_AMOUNT, + metadata: { + productId: "premiumProductId", + }, + }); + await expectSaleCommission(ctx!, { + invoiceId, + expectedEarnings: 200, + expectedMetadata: { + productId: "premiumProductId", + }, + }); + }); +}); diff --git a/apps/web/playwright/api/setup-test-workspace.ts b/apps/web/playwright/api/setup-test-workspace.ts index 36cead52259..d1bd2ad4619 100644 --- a/apps/web/playwright/api/setup-test-workspace.ts +++ b/apps/web/playwright/api/setup-test-workspace.ts @@ -5,9 +5,11 @@ import { DEFAULT_ADDITIONAL_PARTNER_LINKS, DEFAULT_PARTNER_GROUP, } from "@/lib/zod/schemas/groups"; +import { EventType, RewardStructure } from "@prisma/client"; import { config as loadEnv } from "dotenv-flow"; import { mkdir, writeFile } from "fs/promises"; import path from "path"; +import { PLAYWRIGHT_API_BASE } from "./constants"; loadEnv({ silent: true, @@ -30,10 +32,73 @@ export const TEST_WORKSPACE = { domain: "playwright-api.dub-internal-test.com", url: "https://example.com", }, + shopify: { + storeId: "playwright-api.myshopify.com", + }, +} as const; + +export const TEST_COMMISSION_REWARDS = { + lead: { + id: "rw_playwright_api_lead", + event: EventType.lead, + type: RewardStructure.flat, + amountInCents: 1000, + modifiers: [ + { + operator: "AND", + type: RewardStructure.flat, + amountInCents: 5000, + conditions: [ + { + entity: "lead", + attribute: "metadata", + metadataField: "plan", + operator: "equals_to", + value: "pro", + }, + ], + }, + ], + }, + sale: { + id: "rw_playwright_api_sale", + event: EventType.sale, + type: RewardStructure.flat, + amountInCents: 2500, + maxDuration: 0, + modifiers: [ + { + operator: "AND", + type: RewardStructure.flat, + amountInCents: 5000, + conditions: [ + { + entity: "sale", + attribute: "productId", + operator: "equals_to", + value: "sku_pro", + }, + ], + }, + { + operator: "AND", + type: RewardStructure.flat, + amountInCents: 7500, + conditions: [ + { + entity: "sale", + attribute: "metadata", + metadataField: "plan", + operator: "equals_to", + value: "pro", + }, + ], + }, + ], + }, } as const; const authFile = path.join(__dirname, "../.auth/api.json"); -const apiBaseURL = "http://localhost:8888"; // Upserts a dedicated Playwright API user, workspace, membership, // RestrictedToken, and partner program. Safe to run repeatedly from globalSetup. @@ -71,6 +136,8 @@ export async function setupTestWorkspace() { foldersLimit: 100, aiLimit: 1000, partnersLimit: 1000, + groupsLimit: 100, + shopifyStoreId: TEST_WORKSPACE.shopify.storeId, }, create: { id: createId({ prefix: "ws_" }), @@ -86,6 +153,8 @@ export async function setupTestWorkspace() { foldersLimit: 100, aiLimit: 1000, partnersLimit: 1000, + groupsLimit: 100, + shopifyStoreId: TEST_WORKSPACE.shopify.storeId, }, }); @@ -153,7 +222,7 @@ export async function setupTestWorkspace() { { token, workspaceId: workspace.id, - baseURL: apiBaseURL, + baseURL: PLAYWRIGHT_API_BASE, userId: user.id, workspaceSlug: workspace.slug, programId, @@ -257,6 +326,25 @@ async function setupTestProgram({ }, }); + await Promise.all( + [TEST_COMMISSION_REWARDS.lead, TEST_COMMISSION_REWARDS.sale].map( + (reward) => { + const { id, ...data } = reward; + return prisma.reward.upsert({ + where: { + id, + }, + create: { + id, + programId: program.id, + ...data, + }, + update: data, + }); + }, + ), + ); + const group = await prisma.partnerGroup.upsert({ where: { programId_slug: { @@ -271,10 +359,14 @@ async function setupTestProgram({ name: DEFAULT_PARTNER_GROUP.name, color: DEFAULT_PARTNER_GROUP.color, maxPartnerLinks: DEFAULT_ADDITIONAL_PARTNER_LINKS, + leadRewardId: TEST_COMMISSION_REWARDS.lead.id, + saleRewardId: TEST_COMMISSION_REWARDS.sale.id, }, update: { name: DEFAULT_PARTNER_GROUP.name, maxPartnerLinks: DEFAULT_ADDITIONAL_PARTNER_LINKS, + leadRewardId: TEST_COMMISSION_REWARDS.lead.id, + saleRewardId: TEST_COMMISSION_REWARDS.sale.id, }, }); diff --git a/apps/web/playwright/api/shopify/helpers.ts b/apps/web/playwright/api/shopify/helpers.ts new file mode 100644 index 00000000000..1d1f7ed79fa --- /dev/null +++ b/apps/web/playwright/api/shopify/helpers.ts @@ -0,0 +1,169 @@ +import { createId } from "@/lib/api/create-id"; +import { prisma } from "@/lib/prisma"; +import type { Customer, EnrolledPartnerProps } from "@/lib/types"; +import { nanoid } from "@dub/utils"; +import { createHmac } from "crypto"; +import { PLAYWRIGHT_API_BASE } from "../constants"; +import type { ApiClient } from "../fixtures"; +import { TEST_WORKSPACE } from "../setup-test-workspace"; + +export function shopifyOrderPayload({ + checkoutToken, + amount = (Math.random() * 100).toFixed(2), + ...overrides +}: { + checkoutToken: string; + amount?: string | number; +} & Record) { + return { + confirmation_number: nanoid(10), + checkout_token: checkoutToken, + customer: { + id: nanoid(10), + first_name: "John", + last_name: "Doe", + email: `john.doe.${nanoid(5)}@example.com`, + }, + current_subtotal_price_set: { + shop_money: { + amount: String(amount), + currency_code: "USD", + }, + }, + discount_codes: [], + note_attributes: [], + billing_address: { + province: "California", + country_code: "US", + }, + ...overrides, + }; +} + +function shopifyWebhookSignature(body: string) { + return createHmac("sha256", `${process.env.SHOPIFY_WEBHOOK_SECRET}`) + .update(body, "utf8") + .digest("base64"); +} + +export async function postShopifyPixel({ + clickId, + checkoutToken, +}: { + clickId?: string | null; + checkoutToken?: string; +}) { + const response = await fetch(`${PLAYWRIGHT_API_BASE}/api/shopify/pixel`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + clickId, + checkoutToken, + }), + }); + + return { + status: response.status, + data: await response.json(), + }; +} + +export async function postShopifyOrdersPaidWebhook({ + checkoutToken, + storeId = TEST_WORKSPACE.shopify.storeId, + existingCustomerId, + discountCode, + amount, + ...orderOverrides +}: { + checkoutToken: string; + storeId?: string; + existingCustomerId?: string | null; + discountCode?: string | null; + amount?: string | number; +} & Record) { + const payload = shopifyOrderPayload({ + checkoutToken, + amount, + ...(existingCustomerId && { + customer: { id: existingCustomerId }, + }), + ...(discountCode && { + discount_codes: [{ code: discountCode }], + }), + ...orderOverrides, + }); + const body = JSON.stringify(payload); + const signature = shopifyWebhookSignature(body); + + const response = await fetch( + `${PLAYWRIGHT_API_BASE}/api/shopify/integration/webhook`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-shopify-topic": "orders/paid", + "x-shopify-shop-domain": storeId, + "x-shopify-hmac-sha256": signature, + }, + body, + }, + ); + + return { + status: response.status, + data: await response.text(), + payload, + }; +} + +export function partnerDefaultLink(partner: EnrolledPartnerProps) { + const link = partner.links?.[0]; + + if (!link?.id || !link.domain || !link.key) { + throw new Error("Partner was created without a default link."); + } + + return link; +} + +export async function createPartnerDiscountCode({ + programId, + partnerId, + linkId, +}: { + programId: string; + partnerId: string; + linkId: string; +}) { + const code = `PW${nanoid(8).toUpperCase()}`; + + await prisma.discountCode.create({ + data: { + id: createId({ prefix: "dcode_" }), + code, + programId, + partnerId, + linkId, + }, + }); + + return code; +} + +export async function getCustomerByExternalId( + api: ApiClient, + externalId: string, +) { + const { status, data } = await api.get( + `/api/customers?externalId=${encodeURIComponent(externalId)}`, + ); + + if (status !== 200 || !Array.isArray(data) || data.length === 0) { + return undefined; + } + + return data[0]; +} diff --git a/apps/web/playwright/api/shopify/orders.spec.ts b/apps/web/playwright/api/shopify/orders.spec.ts new file mode 100644 index 00000000000..fa777eae80c --- /dev/null +++ b/apps/web/playwright/api/shopify/orders.spec.ts @@ -0,0 +1,165 @@ +import { nanoid } from "@dub/utils"; +import { expect } from "@playwright/test"; +import { randomName } from "../../utils"; +import { trackClick, trackLead } from "../conversions/helpers"; +import { test } from "../fixtures"; +import { createPartner, deletePartner } from "../partners/helpers"; +import { + createPartnerDiscountCode, + getCustomerByExternalId, + partnerDefaultLink, + postShopifyOrdersPaidWebhook, + postShopifyPixel, +} from "./helpers"; + +test("POST /shopify/pixel – skips when checkoutToken is missing", async () => { + const pixel = await postShopifyPixel({ clickId: nanoid(16) }); + + expect(pixel).toEqual({ status: 200, data: "OK" }); +}); + +test("POST /shopify/integration/webhook – unknown shop", async () => { + const { status, data } = await postShopifyOrdersPaidWebhook({ + checkoutToken: nanoid(10), + storeId: `${randomName("pw-shopify")}.myshopify.com`, + }); + + expect(status).toEqual(200); + expect(data).toMatch(/Workspace not found for shop: .+\. Skipping\.\.\./); +}); + +test("orders/paid – waits for pixel when there is no click, customer, or discount", async () => { + const { status, data } = await postShopifyOrdersPaidWebhook({ + checkoutToken: nanoid(10), + }); + + expect(status).toEqual(200); + expect(data).toEqual("[Shopify] Waiting for pixel event to arrive..."); +}); + +test.describe("Shopify orders/paid", () => { + test.describe.configure({ mode: "serial" }); + + let partnerId: string | undefined; + let clickId: string; + let discountCode: string; + + test.beforeAll(async ({ api, program }) => { + const { data: partner } = await createPartner(api); + partnerId = partner.id; + const link = partnerDefaultLink(partner); + + ({ clickId } = await trackClick({ + domain: link.domain, + key: link.key, + })); + + discountCode = await createPartnerDiscountCode({ + programId: program.id, + partnerId: partner.id, + linkId: link.id, + }); + }); + + test.afterAll(async () => { + await deletePartner(partnerId); + }); + + test("click first, then webhook", async () => { + const checkoutToken = nanoid(10); + + const pixel = await postShopifyPixel({ clickId, checkoutToken }); + expect(pixel).toEqual({ status: 200, data: "OK" }); + + const webhook = await postShopifyOrdersPaidWebhook({ checkoutToken }); + expect(webhook.status).toEqual(200); + expect(webhook.data).toEqual( + `[Shopify] Click ID ${clickId} found. Order queued for processing.`, + ); + }); + + test("webhook first, then click", async () => { + const checkoutToken = nanoid(10); + + const webhook = await postShopifyOrdersPaidWebhook({ checkoutToken }); + expect(webhook.status).toEqual(200); + expect(webhook.data).toEqual( + "[Shopify] Waiting for pixel event to arrive...", + ); + + const pixel = await postShopifyPixel({ clickId, checkoutToken }); + expect(pixel).toEqual({ status: 200, data: "OK" }); + }); + + test("click and webhook in parallel", async () => { + const checkoutToken = nanoid(10); + + const [pixel, webhook] = await Promise.all([ + postShopifyPixel({ clickId, checkoutToken }), + postShopifyOrdersPaidWebhook({ checkoutToken }), + ]); + + expect(pixel).toEqual({ status: 200, data: "OK" }); + expect(webhook.status).toEqual(200); + expect([ + `[Shopify] Click ID ${clickId} found. Order queued for processing.`, + "[Shopify] Waiting for pixel event to arrive...", + ]).toContain(webhook.data); + }); + + test("webhook with dubClickId in note_attributes", async () => { + const checkoutToken = nanoid(10); + + const webhook = await postShopifyOrdersPaidWebhook({ + checkoutToken, + note_attributes: [{ name: "dubClickId", value: clickId }], + }); + + expect(webhook.status).toEqual(200); + expect(webhook.data).toEqual( + `[Shopify] Click ID ${clickId} found. Order queued for processing.`, + ); + }); + + test("webhook with unknown dubClickId skips the order", async () => { + const checkoutToken = nanoid(10); + + const webhook = await postShopifyOrdersPaidWebhook({ + checkoutToken, + note_attributes: [{ name: "dubClickId", value: nanoid(16) }], + }); + + expect(webhook.status).toEqual(200); + expect(webhook.data).toEqual( + "[Shopify] Click event not found. Skipping the order...", + ); + }); + + test("orders/paid – existing customer", async ({ api }) => { + const { customer } = await trackLead({ clickId }); + const created = await getCustomerByExternalId(api, customer.externalId); + expect(created?.id).toEqual(expect.any(String)); + + const { status, data } = await postShopifyOrdersPaidWebhook({ + checkoutToken: nanoid(10), + existingCustomerId: customer.externalId, + }); + + expect(status).toEqual(200); + expect(data).toEqual( + `[Shopify] Existing customer ${created!.id} found. Order queued for processing.`, + ); + }); + + test("orders/paid – partner discount code", async () => { + const { status, data } = await postShopifyOrdersPaidWebhook({ + checkoutToken: nanoid(10), + discountCode, + }); + + expect(status).toEqual(200); + expect(data).toEqual( + `[Shopify] Partner discount code ${discountCode} found. Order queued for processing.`, + ); + }); +}); diff --git a/apps/web/playwright/api/tags/tags.spec.ts b/apps/web/playwright/api/tags/tags.spec.ts index 7a51fe5adbb..787a7b6ecae 100644 --- a/apps/web/playwright/api/tags/tags.spec.ts +++ b/apps/web/playwright/api/tags/tags.spec.ts @@ -1,12 +1,8 @@ import { expect } from "@playwright/test"; import type { Tag } from "@prisma/client"; -import { randomName } from "../../utils"; +import { apiError, randomName } from "../../utils"; import { test } from "../fixtures"; -test.describe.configure({ - mode: "parallel", -}); - test("POST /tags", async ({ api }) => { let tagId: string | undefined; @@ -36,35 +32,21 @@ const errorCases = [ tag: "news", color: "invalid", }, - expected: { - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: - "invalid_value: color: Invalid color. Must be one of: red, yellow, green, blue, purple, brown, gray, pink", // TODO: update this to use RESOURCE_COLORS - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }, + expected: apiError({ + code: "unprocessable_entity", + message: + "invalid_value: color: Invalid color. Must be one of: red, yellow, green, blue, purple, brown, gray, pink", // TODO: update this to use RESOURCE_COLORS + }), }, { name: "POST /tags – without name", body: { color: "red", }, - expected: { - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: "custom: name: Name is required.", - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }, + expected: apiError({ + code: "unprocessable_entity", + message: "custom: name: Name is required.", + }), }, ]; @@ -92,14 +74,12 @@ test("POST /tags – existing name", async ({ api }) => { color: "red", }); - expect(status).toBe(409); - expect(error).toEqual({ - error: { + expect({ status, data: error }).toEqual( + apiError({ code: "conflict", message: "A tag with that name already exists.", - doc_url: "https://dub.co/docs/api-reference/errors#conflict", - }, - }); + }), + ); } finally { if (tagId) await api.delete(`/api/tags/${tagId}`); } diff --git a/apps/web/playwright/api/utm/utm.spec.ts b/apps/web/playwright/api/utm/utm.spec.ts index cce166dbcf5..c747172b25d 100644 --- a/apps/web/playwright/api/utm/utm.spec.ts +++ b/apps/web/playwright/api/utm/utm.spec.ts @@ -1,6 +1,6 @@ import { expect } from "@playwright/test"; import type { UtmTemplate } from "@prisma/client"; -import { randomName } from "../../utils"; +import { apiError, randomName } from "../../utils"; import { test, type ApiClient } from "../fixtures"; type UtmTemplateResponse = Pick< @@ -49,10 +49,6 @@ async function deleteUtmTemplate(api: ApiClient, id: string | undefined) { await api.delete(`/api/utm/${id}`); } -test.describe.configure({ - mode: "parallel", -}); - test("POST /utm", async ({ api, workspace }) => { let id: string | undefined; const body = { @@ -122,65 +118,37 @@ const errorCases = [ { name: "POST /utm – missing name", body: {}, - expected: { - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: - "invalid_type: name: Invalid input: expected string, received undefined", - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }, + expected: apiError({ + code: "unprocessable_entity", + message: + "invalid_type: name: Invalid input: expected string, received undefined", + }), }, { name: "POST /utm – empty name", body: { name: "" }, - expected: { - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: "too_small: name: UTM name is required", - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }, + expected: apiError({ + code: "unprocessable_entity", + message: "too_small: name: UTM name is required", + }), }, { name: "POST /utm – name too long", body: { name: "a".repeat(51) }, - expected: { - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: - "too_big: name: Too big: expected string to have <=50 characters", - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }, + expected: apiError({ + code: "unprocessable_entity", + message: + "too_big: name: Too big: expected string to have <=50 characters", + }), }, { name: "POST /utm – utm_source too long", body: { name: randomName("utm"), utm_source: "a".repeat(256) }, - expected: { - status: 422, - data: { - error: { - code: "unprocessable_entity", - message: - "too_big: utm_source: Too big: expected string to have <=255 characters", - doc_url: - "https://dub.co/docs/api-reference/errors#unprocessable-entity", - }, - }, - }, + expected: apiError({ + code: "unprocessable_entity", + message: + "too_big: utm_source: Too big: expected string to have <=255 characters", + }), }, ]; @@ -204,14 +172,12 @@ test("POST /utm – existing name", async ({ api }) => { name: templateName, }); - expect(status).toBe(409); - expect(error).toEqual({ - error: { + expect({ status, data: error }).toEqual( + apiError({ code: "conflict", message: "A template with that name already exists.", - doc_url: "https://dub.co/docs/api-reference/errors#conflict", - }, - }); + }), + ); } finally { await deleteUtmTemplate(api, id); } @@ -294,16 +260,12 @@ test("PATCH /utm/{id}", async ({ api }) => { test("PATCH /utm/{id} – not found", async ({ api }) => { expect( await api.patch("/api/utm/utm_missing", { name: randomName("utm") }), - ).toEqual({ - status: 404, - data: { - error: { - code: "not_found", - message: "Template not found.", - doc_url: "https://dub.co/docs/api-reference/errors#not-found", - }, - }, - }); + ).toEqual( + apiError({ + code: "not_found", + message: "Template not found.", + }), + ); }); test("DELETE /utm/{id}", async ({ api }) => { @@ -324,14 +286,10 @@ test("DELETE /utm/{id}", async ({ api }) => { }); test("DELETE /utm/{id} – not found", async ({ api }) => { - expect(await api.delete("/api/utm/utm_missing")).toEqual({ - status: 404, - data: { - error: { - code: "not_found", - message: "UTM template not found.", - doc_url: "https://dub.co/docs/api-reference/errors#not-found", - }, - }, - }); + expect(await api.delete("/api/utm/utm_missing")).toEqual( + apiError({ + code: "not_found", + message: "UTM template not found.", + }), + ); }); diff --git a/apps/web/playwright/api/workspaces/workspaces.spec.ts b/apps/web/playwright/api/workspaces/workspaces.spec.ts index 524c4d98abc..a39cdd33550 100644 --- a/apps/web/playwright/api/workspaces/workspaces.spec.ts +++ b/apps/web/playwright/api/workspaces/workspaces.spec.ts @@ -2,13 +2,10 @@ import { WorkspaceSchema } from "@/lib/zod/schemas/workspaces"; import { expect } from "@playwright/test"; import type { Project } from "@prisma/client"; import * as z from "zod/v4"; +import { apiError } from "../../utils"; import { test } from "../fixtures"; import { TEST_WORKSPACE } from "../setup-test-workspace"; -test.describe.configure({ - mode: "parallel", -}); - test("GET /workspaces/{idOrSlug} – by id", async ({ api, workspace }) => { const { status, data: workspaceFetched } = await api.get( `/api/workspaces/${workspace.id}`, @@ -48,14 +45,10 @@ test("GET /workspaces/{idOrSlug} – by slug", async ({ api, workspace }) => { }); test("GET /workspaces/{idOrSlug} – invalid slug or id", async ({ api }) => { - const { status, data: error } = await api.get(`/api/workspaces/xxxx`); - - expect(status).toEqual(404); - expect(error).toStrictEqual({ - error: { + expect(await api.get(`/api/workspaces/xxxx`)).toEqual( + apiError({ code: "not_found", message: "Workspace not found.", - doc_url: "https://dub.co/docs/api-reference/errors#not-found", - }, - }); + }), + ); }); diff --git a/apps/web/playwright/assert-local-database.ts b/apps/web/playwright/assert-local-database.ts index 897f6279ba8..7359c68faa6 100644 --- a/apps/web/playwright/assert-local-database.ts +++ b/apps/web/playwright/assert-local-database.ts @@ -16,7 +16,7 @@ function assertUrl( if (!value) { if (required) { throw new Error( - `${name} is not set. Playwright tests require a local database. ${LOCAL_URL_HINT}`, + `${name} is not set. A local database is required. ${LOCAL_URL_HINT}`, ); } return; @@ -27,13 +27,13 @@ function assertUrl( hostname = hostnameFromDatabaseUrl(value); } catch { throw new Error( - `${name} is not a valid URL. Playwright tests require a local database. ${LOCAL_URL_HINT}`, + `${name} is not a valid URL. A local database is required. ${LOCAL_URL_HINT}`, ); } if (!LOCAL_HOSTNAMES.has(hostname)) { throw new Error( - `Refusing to run Playwright tests: ${name} host "${hostname}" is not a local database. Only localhost / 127.0.0.1 / ::1 are allowed. ${LOCAL_URL_HINT}`, + `Refusing to proceed: ${name} host "${hostname}" is not a local database. Only localhost / 127.0.0.1 / ::1 are allowed. ${LOCAL_URL_HINT}`, ); } } diff --git a/apps/web/playwright/utils.ts b/apps/web/playwright/utils.ts index d7cdcae455e..ae7c445c354 100644 --- a/apps/web/playwright/utils.ts +++ b/apps/web/playwright/utils.ts @@ -1,7 +1,27 @@ +import { ErrorCodes } from "@/lib/api/error-codes"; import { generateRandomName } from "@/lib/names"; import { nanoid } from "@dub/utils"; import { expect } from "@playwright/test"; +export function apiError({ + code, + message, +}: { + code: keyof typeof ErrorCodes; + message: string; +}) { + return { + status: ErrorCodes[code], + data: { + error: { + code, + message, + doc_url: `https://dub.co/docs/api-reference/errors#${code.replace("_", "-")}`, + }, + }, + }; +} + export function randomName(prefix = "e2e", length = 5) { return `${prefix}-${nanoid(length)}`; } diff --git a/apps/web/playwright/workspaces/onboarding-dub-partners.spec.ts b/apps/web/playwright/workspaces/onboarding-dub-partners.spec.ts index f879253c8cf..e2c0580b1cf 100644 --- a/apps/web/playwright/workspaces/onboarding-dub-partners.spec.ts +++ b/apps/web/playwright/workspaces/onboarding-dub-partners.spec.ts @@ -1,5 +1,5 @@ import { nanoid } from "@dub/utils"; -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; import { finishOnboardingCheckoutWithoutStripeRedirect, installBillingCheckoutMocks, @@ -10,6 +10,41 @@ const MINIMAL_PNG = Buffer.from( "base64", ); +/** Host Playwright intercepts for the R2 PUT. CI has no STORAGE_*. */ +const MOCK_SIGNED_URL = "https://storage.example.test/e2e-program-logo"; +const MOCK_DESTINATION_URL = + "https://assets.example.test/program-logos/e2e.png"; + +/** Stubs POST /upload-url and the follow-up PUT so onboarding does not need R2. */ +async function installProgramLogoUploadMocks(page: Page) { + await page.route( + (url) => url.pathname.endsWith("/upload-url"), + async (route) => { + if (route.request().method() !== "POST") { + await route.continue(); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + key: "program-logos/e2e", + signedUrl: MOCK_SIGNED_URL, + destinationUrl: MOCK_DESTINATION_URL, + }), + }); + }, + ); + + await page.route(MOCK_SIGNED_URL, async (route) => { + if (route.request().method() !== "PUT") { + await route.continue(); + return; + } + await route.fulfill({ status: 200, body: "" }); + }); +} + function randomOnboardingDomain() { const id = nanoid(10).replace(/_/g, "-").toLowerCase(); return `e2e-${id}.invalid`; @@ -124,17 +159,29 @@ test.describe("Dub Partners onboarding", () => { await page .getByTestId("onboarding-program-company-name") .fill(`Test Program ${nanoid(4)}`); + + await installProgramLogoUploadMocks(page); + + const uploadUrlPost = page.waitForResponse( + (r) => + r.request().method() === "POST" && + new URL(r.url()).pathname.endsWith("/upload-url"), + { timeout: STEP_NAV_TIMEOUT }, + ); await page.getByTestId("onboarding-program-logo").setInputFiles({ name: "logo.png", mimeType: "image/png", buffer: MINIMAL_PNG, }); - // skipping this for now since it's a bit flaky - // await expect - // .poll(async () => page.locator('img[alt="Preview"]').count(), { - // timeout: 30_000, - // }) - // .toBeGreaterThan(0); + const uploadUrlRes = await uploadUrlPost; + if (!uploadUrlRes.ok()) { + throw new Error( + `Logo upload-url failed: HTTP ${uploadUrlRes.status()} ${await uploadUrlRes.text()}`, + ); + } + await expect(page.getByText("logo.png uploaded!")).toBeVisible({ + timeout: 30_000, + }); await page .getByTestId("onboarding-program-destination-url") diff --git a/apps/web/prisma/schema/campaign.prisma b/apps/web/prisma/schema/campaign.prisma index 7f94a4e0602..32192e4535a 100644 --- a/apps/web/prisma/schema/campaign.prisma +++ b/apps/web/prisma/schema/campaign.prisma @@ -18,28 +18,29 @@ enum CampaignStatus { } model Campaign { - id String @id + id String @id programId String - workflowId String? @unique + workflowId String? @unique userId String - qstashMessageId String? @unique + qstashMessageId String? @unique type CampaignType - status CampaignStatus @default(draft) + status CampaignStatus @default(draft) name String subject String - preview String? @db.Text + preview String? @db.Text from String? - bodyJson Json @db.Json + bodyJson Json @db.Json scheduledAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - program Program @relation(fields: [programId], references: [id]) - workflow Workflow? @relation(fields: [workflowId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + program Program @relation(fields: [programId], references: [id]) + workflow Workflow? @relation(fields: [workflowId], references: [id], onDelete: Cascade) groups CampaignGroup[] partnerTags CampaignPartnerTag[] emails NotificationEmail[] @@index(programId) + @@index([type, status, scheduledAt]) } model CampaignGroup { diff --git a/apps/web/prisma/schema/commission.prisma b/apps/web/prisma/schema/commission.prisma index ebe5879c162..23d4cc28ff0 100644 --- a/apps/web/prisma/schema/commission.prisma +++ b/apps/web/prisma/schema/commission.prisma @@ -37,6 +37,7 @@ model Commission { userId String? // user who created the manual commission sourceCommissionId String? sourcePartnerId String? + metadata Json? @db.Json createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -59,6 +60,7 @@ model Commission { @@index(payoutId) @@index(customerId) @@index(linkId) + @@index(type) @@index(status) @@index(rewardId) @@index(userId) diff --git a/apps/web/prisma/schema/discount.prisma b/apps/web/prisma/schema/discount.prisma index 8d301001629..f5251a051c6 100644 --- a/apps/web/prisma/schema/discount.prisma +++ b/apps/web/prisma/schema/discount.prisma @@ -1,6 +1,7 @@ enum DiscountProvider { stripe shopify + custom } model Discount { diff --git a/apps/web/prisma/schema/domain.prisma b/apps/web/prisma/schema/domain.prisma index 2e12039aae6..2f221982cfa 100644 --- a/apps/web/prisma/schema/domain.prisma +++ b/apps/web/prisma/schema/domain.prisma @@ -56,8 +56,6 @@ model DefaultDomains { ggllink Boolean @default(true) figpage Boolean @default(true) loooooooong Boolean @default(false) - fyicodeforafricaorg Boolean @default(true) - cfafyi Boolean @default(true) projectId String @unique project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) } diff --git a/apps/web/prisma/schema/job.prisma b/apps/web/prisma/schema/job.prisma index dbc55bae01b..3d626411212 100644 --- a/apps/web/prisma/schema/job.prisma +++ b/apps/web/prisma/schema/job.prisma @@ -1,15 +1,15 @@ // Background jobs that could not be published to QStash (e.g. QStash outage). // The /api/cron/queue/retry cron republishes these and deletes them on success. model Job { - id String @id - name String - payload Json - options Json? // dispatch options replayed verbatim: deduplicationId, retries, queue, flowControl, label - scheduledFor DateTime? // absolute time the job should run (derived from delay/notBefore at dispatch time) - attempts Int @default(0) - lastError String? @db.Text - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id + name String + payload Json + options Json? // dispatch options replayed verbatim: deduplicationId, retries, queue, flowControl, label + scheduledAt DateTime @default(now()) // when we should publish the job + attempts Int @default(0) + lastError String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - @@index([createdAt]) + @@index([scheduledAt, attempts]) // retry cron: due jobs under the attempt cap } diff --git a/apps/web/prisma/schema/schema.prisma b/apps/web/prisma/schema/schema.prisma index dda14954057..3e704a47332 100644 --- a/apps/web/prisma/schema/schema.prisma +++ b/apps/web/prisma/schema/schema.prisma @@ -56,9 +56,10 @@ model User { activityLogs ActivityLog[] createdCommissions Commission[] - @@index(sentMail) - @@index(source) + @@index(createdAt) @@index(defaultWorkspace) + @@index(source) + @@index(sentMail) } model Account { diff --git a/apps/web/scripts/dev/benchmark-partner-search.ts b/apps/web/scripts/dev/benchmark-partner-search.ts new file mode 100644 index 00000000000..e29151e2dfd --- /dev/null +++ b/apps/web/scripts/dev/benchmark-partner-search.ts @@ -0,0 +1,596 @@ +/** + * Measures p99 latency of the relevance-ranked partner list for a program. + * + * Calls `getPartners` in process, not over HTTP, so the numbers exclude route + * handling, auth, and serialization. Treat them as a floor for the endpoint. + * + * --searchOnly times the provider's candidate query alone and skips the + * database. Use it to compare providers: they are the same remote services + * production would use, while the local database is not representative and + * dominates the end-to-end numbers. It also raises the --sampleSize ceiling, + * so high request counts can keep drawing fresh queries instead of repeating + * cached ones. + * + * To avoid measuring a warm cache it samples `--sampleSize` partners strided + * across the program and exhausts a field's queries before repeating any. + * + * Exits non-zero when a field produced no successful request, when the error rate + * exceeds `--maxErrorRate`, or when a p99 reaches `--thresholdMs`. + * + * cd apps/web + * pnpm run script dev/benchmark-partner-search --programId=prog_123 [--requests=1000] + * [--sampleSize=100] [--concurrency=10] [--warmup=50] [--thresholdMs=1000] [--searchOnly] + * + * Requires TURBOPUFFER_API_KEY and an index backfilled for the program: + * pnpm run script partners/backfill-partner-search --programId=prog_123 + */ + +import { getPartners } from "@/lib/api/partners/get-partners"; +import { + getPartnerSearchProvider, + PARTNER_SEARCH_CANDIDATE_LIMIT, + partnerSearchDocumentSelect, + serializePartnerSearchDocument, + type PartnerSearchDocument, +} from "@/lib/api/partners/search"; +import { prisma } from "@/lib/prisma"; +import { + parseNonNegativeInteger, + parsePositiveInteger, +} from "@/scripts/utils/parse-cli-number"; +import { chunk } from "@dub/utils"; +import "dotenv-flow/config"; + +const DEFAULT_REQUESTS = 1_000; +const DEFAULT_WARMUP_REQUESTS = 50; +const DEFAULT_CONCURRENCY = 10; +const DEFAULT_PAGE_SIZE = 25; +const DEFAULT_THRESHOLD_MS = 1_000; +const MINIMUM_REQUESTS = 1_000; +const DEFAULT_SAMPLE_SIZE = 100; +// The sample bounds one setup hydration query per chunk; the end-to-end mode +// keeps it small because the same database also serves the measured requests. +const MAX_SAMPLE_SIZE = 1_000; +const MAX_SEARCH_ONLY_SAMPLE_SIZE = 10_000; +const SAMPLE_HYDRATION_CHUNK_SIZE = 1_000; +const DEFAULT_MAX_ERROR_RATE = 0; + +interface BenchmarkArguments { + programId: string; + requests: number; + warmupRequests: number; + concurrency: number; + pageSize: number; + thresholdMs: number; + sampleSize: number; + maxErrorRate: number; + searchOnly: boolean; +} + +interface SearchCase { + field: string; + query: string; +} + +interface SearchCasePool { + field: string; + queries: string[]; +} + +interface BenchmarkResult { + field: string; + query: string; + latencyMs: number; + error: string | null; +} + +function parsePercentage(value: string, flag: string): number { + const parsed = /^\d+(\.\d+)?$/.test(value) ? Number(value) : NaN; + + if (!Number.isFinite(parsed) || parsed > 100) { + throw new Error(`${flag} must be between 0 and 100, received: "${value}"`); + } + + return parsed; +} + +function parseArguments(args: string[]): BenchmarkArguments { + let programId: string | undefined; + let requests = DEFAULT_REQUESTS; + let warmupRequests = DEFAULT_WARMUP_REQUESTS; + let concurrency = DEFAULT_CONCURRENCY; + let pageSize = DEFAULT_PAGE_SIZE; + let thresholdMs = DEFAULT_THRESHOLD_MS; + let sampleSize = DEFAULT_SAMPLE_SIZE; + let maxErrorRate = DEFAULT_MAX_ERROR_RATE; + let searchOnly = false; + + for (const arg of args) { + if (arg.startsWith("--programId=")) { + programId = arg.slice("--programId=".length); + } else if (arg.startsWith("--requests=")) { + requests = parsePositiveInteger( + arg.slice("--requests=".length), + "--requests", + ); + } else if (arg.startsWith("--warmup=")) { + warmupRequests = parseNonNegativeInteger( + arg.slice("--warmup=".length), + "--warmup", + ); + } else if (arg.startsWith("--sampleSize=")) { + sampleSize = parsePositiveInteger( + arg.slice("--sampleSize=".length), + "--sampleSize", + ); + } else if (arg.startsWith("--maxErrorRate=")) { + maxErrorRate = parsePercentage( + arg.slice("--maxErrorRate=".length), + "--maxErrorRate", + ); + } else if (arg.startsWith("--concurrency=")) { + concurrency = parsePositiveInteger( + arg.slice("--concurrency=".length), + "--concurrency", + ); + } else if (arg.startsWith("--pageSize=")) { + pageSize = parsePositiveInteger( + arg.slice("--pageSize=".length), + "--pageSize", + ); + } else if (arg.startsWith("--thresholdMs=")) { + thresholdMs = parsePositiveInteger( + arg.slice("--thresholdMs=".length), + "--thresholdMs", + ); + } else if (arg === "--searchOnly") { + searchOnly = true; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!programId) { + throw new Error("--programId is required."); + } + if (requests < MINIMUM_REQUESTS) { + throw new Error( + `--requests must be at least ${MINIMUM_REQUESTS} for a useful p99 measurement.`, + ); + } + if (concurrency > requests) { + throw new Error("--concurrency cannot exceed --requests."); + } + if (pageSize > PARTNER_SEARCH_CANDIDATE_LIMIT) { + throw new Error( + `--pageSize cannot exceed ${PARTNER_SEARCH_CANDIDATE_LIMIT}.`, + ); + } + const maxSampleSize = searchOnly + ? MAX_SEARCH_ONLY_SAMPLE_SIZE + : MAX_SAMPLE_SIZE; + if (sampleSize > maxSampleSize) { + throw new Error( + searchOnly + ? `--sampleSize cannot exceed ${MAX_SEARCH_ONLY_SAMPLE_SIZE}.` + : `--sampleSize cannot exceed ${MAX_SAMPLE_SIZE} (${MAX_SEARCH_ONLY_SAMPLE_SIZE} with --searchOnly).`, + ); + } + + return { + programId, + requests, + warmupRequests, + concurrency, + pageSize, + thresholdMs, + sampleSize, + maxErrorRate, + searchOnly, + }; +} + +function longestSearchToken(value: string): string { + const token = value + .split(/[^\p{L}\p{N}_]+/u) + .filter(Boolean) + .sort((left, right) => right.length - left.length)[0]; + + if (!token) { + throw new Error(`Could not derive a search query from "${value}".`); + } + + return token.slice(0, 12); +} + +function emailInfix(email: string): string { + const domain = email.split("@")[1]; + if (!domain) { + return longestSearchToken(email); + } + + const domainName = domain.split(".")[0]; + return domainName.slice(0, Math.min(5, domainName.length)); +} + +function createSearchCases(document: PartnerSearchDocument): SearchCase[] { + const platformType = document.platformTypes[0]; + const platformIdentifier = document.platformIdentifiers[0]; + const linkKey = document.linkKeys[0]; + + if ( + !document.email || + !document.companyName || + !document.description || + !platformType || + !platformIdentifier || + !linkKey + ) { + throw new Error( + "The benchmark sample must have an email, company, description, platform, and link.", + ); + } + + return [ + { field: "name", query: longestSearchToken(document.name) }, + { field: "email infix", query: emailInfix(document.email) }, + { field: "company", query: longestSearchToken(document.companyName) }, + { field: "description", query: longestSearchToken(document.description) }, + { field: "platform type", query: platformType }, + { + field: "platform identifier", + query: longestSearchToken(platformIdentifier), + }, + { field: "link key", query: linkKey }, + ]; +} + +async function loadSearchCasePools( + programId: string, + sampleSize: number, + partnerCount: number, +): Promise { + const stride = Math.max(1, Math.floor(partnerCount / sampleSize)); + const where = { + programId, + partner: { + email: { not: null }, + companyName: { not: null }, + description: { not: null }, + platforms: { some: {} }, + }, + links: { some: {} }, + }; + + // Striding across the program means scanning it, so resolve bare IDs first. + // Hydrating every row to keep one in `stride` exceeds MySQL's placeholder + // limit once a program reaches 100K partners. + const candidateIds = await prisma.programEnrollment.findMany({ + where, + select: { id: true }, + orderBy: { id: "asc" }, + take: sampleSize * stride, + }); + + const sampledIds = candidateIds + .filter((_, index) => index % stride === 0) + .map(({ id }) => id); + + // Chunked so the larger --searchOnly samples keep the IN clause bounded. + const sampled = ( + await Promise.all( + chunk(sampledIds, SAMPLE_HYDRATION_CHUNK_SIZE).map((idChunk) => + prisma.programEnrollment.findMany({ + where: { id: { in: idChunk } }, + select: partnerSearchDocumentSelect, + }), + ), + ) + ).flat(); + + if (sampled.length === 0) { + throw new Error( + `No complete partner search document found for program ${programId}.`, + ); + } + + const queriesByField = new Map>(); + + for (const enrollment of sampled) { + const document = serializePartnerSearchDocument(enrollment); + + for (const { field, query } of createSearchCases(document)) { + const queries = queriesByField.get(field) ?? new Set(); + queries.add(query); + queriesByField.set(field, queries); + } + } + + return Array.from(queriesByField, ([field, queries]) => ({ + field, + queries: Array.from(queries), + })); +} + +function percentile(values: number[], quantile: number): number { + const sorted = [...values].sort((left, right) => left - right); + const index = Math.max(0, Math.ceil(sorted.length * quantile) - 1); + return sorted[index]; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error + ? `${error.name}: ${error.message}` + : String(error); +} + +function formatLatency(value: number | null): string { + return value === null ? "n/a" : value.toFixed(1); +} + +async function runWithConcurrency( + count: number, + concurrency: number, + operation: (index: number) => Promise, +): Promise { + const results = new Array(count); + let nextIndex = 0; + + async function worker() { + while (nextIndex < count) { + const index = nextIndex++; + results[index] = await operation(index); + } + } + + await Promise.all( + Array.from({ length: Math.min(count, concurrency) }, () => worker()), + ); + + return results; +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + const searchProvider = getPartnerSearchProvider(); + + if (!searchProvider) { + throw new Error( + "Partner search provider is not configured. Implement and configure it before running the benchmark.", + ); + } + + const partnerCount = await prisma.programEnrollment.count({ + where: { programId: options.programId }, + }); + if (partnerCount === 0) { + throw new Error(`Program ${options.programId} has 0 partners.`); + } + + const pools = await loadSearchCasePools( + options.programId, + options.sampleSize, + partnerCount, + ); + + const runSearch = async (index: number): Promise => { + // Rotate fields on every request and advance that field's query once per + // full rotation, so consecutive requests never repeat a query until the + // pool is exhausted. Deterministic, so two runs issue the same work. + const pool = pools[index % pools.length]; + const query = + pool.queries[Math.floor(index / pools.length) % pool.queries.length]; + const searchCase: SearchCase = { field: pool.field, query }; + const startedAt = performance.now(); + + try { + if (options.searchOnly) { + const { hits } = await searchProvider.searchCandidates({ + programId: options.programId, + query: searchCase.query, + limit: PARTNER_SEARCH_CANDIDATE_LIMIT, + }); + + if (hits.length === 0) { + throw new Error( + `Search case "${searchCase.field}" returned no candidates for "${searchCase.query}".`, + ); + } + } else { + // Opt out of the database fallback: a provider failure has to surface + // as an error here, not as a fast run measuring the wrong code path. + const partners = await getPartners( + { + programId: options.programId, + search: searchCase.query, + page: 1, + pageSize: options.pageSize, + sortBy: "relevance" as const, + sortOrder: "desc" as const, + }, + { searchProvider, throwOnSearchError: true }, + ); + + if (partners.length === 0) { + throw new Error( + `Search case "${searchCase.field}" returned no results for "${searchCase.query}".`, + ); + } + } + + return { + ...searchCase, + latencyMs: performance.now() - startedAt, + error: null, + }; + } catch (error) { + return { + ...searchCase, + latencyMs: performance.now() - startedAt, + error: getErrorMessage(error), + }; + } + }; + + console.log(`Partner search benchmark for program ${options.programId}`); + console.log(`${partnerCount.toLocaleString()} program partners`); + console.log( + `${options.requests.toLocaleString()} measured requests, ${options.warmupRequests.toLocaleString()} warm-up requests, concurrency ${options.concurrency}`, + ); + const distinctQueries = pools.reduce( + (total, { queries }) => total + queries.length, + 0, + ); + const measuredPath = options.searchOnly + ? "queries the search provider directly (no database reads)" + : "runs the relevance-ranked partner list path"; + console.log( + `Each request ${measuredPath} across ${pools.length} search cases, drawing from ${distinctQueries.toLocaleString()} distinct queries sampled from ${options.sampleSize.toLocaleString()} partners.`, + ); + const warmupResults = await runWithConcurrency( + options.warmupRequests, + options.concurrency, + runSearch, + ); + const warmupErrorCount = warmupResults.filter(({ error }) => error).length; + if (warmupErrorCount > 0) { + console.warn( + `${warmupErrorCount.toLocaleString()} of ${options.warmupRequests.toLocaleString()} warm-up requests failed. Continuing to collect measured results.`, + ); + } + + const startedAt = performance.now(); + const results = await runWithConcurrency( + options.requests, + options.concurrency, + // Continue past the warm-up indices so the measured run does not reissue the + // queries the warm-up just cached. + (index) => runSearch(options.warmupRequests + index), + ); + const elapsedMs = performance.now() - startedAt; + const successfulResults = results.filter(({ error }) => error === null); + const failedResults = results.filter(({ error }) => error !== null); + const latencies = successfulResults.map(({ latencyMs }) => latencyMs); + const mean = + latencies.length > 0 + ? latencies.reduce((total, latency) => total + latency, 0) / + latencies.length + : null; + const p99 = latencies.length > 0 ? percentile(latencies, 0.99) : null; + const caseSummaries = pools.map(({ field, queries }) => { + const query = `${queries[0]}${queries.length > 1 ? ` (+${queries.length - 1})` : ""}`; + const caseResults = results.filter((result) => result.field === field); + const caseErrors = caseResults.filter(({ error }) => error !== null).length; + const caseLatencies = caseResults + .filter(({ error }) => error === null) + .map(({ latencyMs }) => latencyMs); + + return { + field, + query, + samples: caseResults.length, + errors: caseErrors, + errorRate: + caseResults.length > 0 ? (caseErrors / caseResults.length) * 100 : 0, + p50Ms: caseLatencies.length > 0 ? percentile(caseLatencies, 0.5) : null, + p95Ms: caseLatencies.length > 0 ? percentile(caseLatencies, 0.95) : null, + p99Ms: caseLatencies.length > 0 ? percentile(caseLatencies, 0.99) : null, + maxMs: caseLatencies.length > 0 ? Math.max(...caseLatencies) : null, + }; + }); + const casesWithLatency = caseSummaries.filter( + (summary): summary is typeof summary & { p99Ms: number } => + summary.p99Ms !== null, + ); + const slowestCase = casesWithLatency.reduce< + (typeof casesWithLatency)[number] | null + >( + (slowest, current) => + !slowest || current.p99Ms > slowest.p99Ms ? current : slowest, + null, + ); + + console.table( + caseSummaries.map( + ({ field, query, samples, errors, errorRate, ...latency }) => ({ + field, + query, + samples, + errors, + errorRate: `${errorRate.toFixed(2)}%`, + ...Object.fromEntries( + Object.entries(latency).map(([key, value]) => [ + key, + formatLatency(value), + ]), + ), + }), + ), + ); + + if (failedResults.length > 0) { + const errorCounts = new Map(); + for (const { error } of failedResults) { + errorCounts.set(error!, (errorCounts.get(error!) ?? 0) + 1); + } + console.table( + Array.from(errorCounts, ([error, count]) => ({ error, count })), + ); + } + + console.table({ + samples: results.length, + successful: successfulResults.length, + errors: failedResults.length, + errorRate: `${((failedResults.length / results.length) * 100).toFixed(2)}%`, + meanMs: formatLatency(mean), + p50Ms: formatLatency( + latencies.length > 0 ? percentile(latencies, 0.5) : null, + ), + p95Ms: formatLatency( + latencies.length > 0 ? percentile(latencies, 0.95) : null, + ), + p99Ms: formatLatency(p99), + maxMs: formatLatency(latencies.length > 0 ? Math.max(...latencies) : null), + requestsPerSecond: ((options.requests * 1_000) / elapsedMs).toFixed(1), + }); + + const casesWithoutSamples = caseSummaries.filter( + ({ p99Ms }) => p99Ms === null, + ); + if (casesWithoutSamples.length > 0) { + throw new Error( + `No successful requests for: ${casesWithoutSamples + .map(({ field }) => field) + .join( + ", ", + )}. There is no p99 to compare against the threshold. Check that the search index is backfilled for this program.`, + ); + } + + const errorRate = (failedResults.length / results.length) * 100; + if (errorRate > options.maxErrorRate) { + throw new Error( + `${failedResults.length.toLocaleString()} of ${results.length.toLocaleString()} measured requests failed (${errorRate.toFixed(2)}% error rate, --maxErrorRate=${options.maxErrorRate}).`, + ); + } + + if (slowestCase && slowestCase.p99Ms >= options.thresholdMs) { + throw new Error( + `${slowestCase.field} p99 latency ${slowestCase.p99Ms.toFixed(1)}ms did not meet the <${options.thresholdMs}ms threshold.`, + ); + } + + console.log( + `Completed: every search case has p99 latency below ${options.thresholdMs}ms${failedResults.length > 0 ? `, with a ${errorRate.toFixed(2)}% error rate within the --maxErrorRate=${options.maxErrorRate} allowance` : ""}.`, + ); +} + +main() + .catch((error) => { + console.error("Partner search benchmark failed:", error); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/apps/web/scripts/dev/data.json b/apps/web/scripts/dev/data.json index d245191c346..ecf61cdc755 100644 --- a/apps/web/scripts/dev/data.json +++ b/apps/web/scripts/dev/data.json @@ -60,6 +60,13 @@ "verified": true } ], + "emailDomains": [ + { + "id": "dom_1KETZ919F83ZJH6A80EMAILDOM", + "slug": "getacme.link", + "status": "verified" + } + ], "folders": [ { "id": "fold_1K2J9DRWPPJ2F1RX53N92TSGB", diff --git a/apps/web/scripts/dev/debug-partner-search.ts b/apps/web/scripts/dev/debug-partner-search.ts new file mode 100644 index 00000000000..e6730726739 --- /dev/null +++ b/apps/web/scripts/dev/debug-partner-search.ts @@ -0,0 +1,353 @@ +import { + getPartnerSearchableValues, + getPartnerSearchProvider, + normalizePartnerSearchQuery, + PARTNER_SEARCH_CANDIDATE_LIMIT, + partnerSearchDocumentSelect, + serializePartnerSearchDocument, + type PartnerSearchDocument, + type PartnerSearchHit, +} from "@/lib/api/partners/search"; +import { PARTNER_SEARCH_NAMESPACE } from "@/lib/api/partners/search/providers/turbopuffer"; +import { prisma } from "@/lib/prisma"; +import { parsePositiveInteger } from "@/scripts/utils/parse-cli-number"; +import { ProgramEnrollmentStatus } from "@prisma/client"; +import { Turbopuffer } from "@turbopuffer/turbopuffer"; +import "dotenv-flow/config"; + +const DEFAULT_LIMIT = 10; +const MAX_LIMIT = PARTNER_SEARCH_CANDIDATE_LIMIT; + +interface DebugArguments { + programId: string; + query: string; + limit: number; + status?: ProgramEnrollmentStatus; + searchOnly: boolean; +} + +function parseArguments(args: string[]): DebugArguments { + let programId: string | undefined; + let query: string | undefined; + let limit = DEFAULT_LIMIT; + let status: ProgramEnrollmentStatus | undefined; + let searchOnly = false; + + for (const arg of args) { + if (arg.startsWith("--programId=")) { + programId = arg.slice("--programId=".length); + } else if (arg.startsWith("--query=")) { + query = arg.slice("--query=".length); + } else if (arg.startsWith("--limit=")) { + limit = parsePositiveInteger(arg.slice("--limit=".length), "--limit"); + } else if (arg.startsWith("--status=")) { + const value = arg.slice("--status=".length) as ProgramEnrollmentStatus; + if (!Object.values(ProgramEnrollmentStatus).includes(value)) { + throw new Error( + `--status must be one of: ${Object.values(ProgramEnrollmentStatus).join(", ")}.`, + ); + } + status = value; + } else if (arg === "--searchOnly") { + searchOnly = true; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!programId) { + throw new Error("--programId is required."); + } + if (!query?.trim()) { + throw new Error("--query is required."); + } + if (limit > MAX_LIMIT) { + throw new Error(`--limit cannot exceed ${MAX_LIMIT}.`); + } + // Status lives on the enrollment row, so filtering by it needs the database. + if (searchOnly && status) { + throw new Error("--searchOnly cannot be combined with --status."); + } + + return { programId, query, limit, status, searchOnly }; +} + +async function getDatabaseDocuments( + hits: PartnerSearchHit[], + status?: ProgramEnrollmentStatus, +) { + const documentIds = Array.from(new Set(hits.map(({ id }) => id))); + const enrollments = await prisma.programEnrollment.findMany({ + where: { + id: { in: documentIds }, + ...(status ? { status } : {}), + }, + select: partnerSearchDocumentSelect, + }); + + return new Map( + enrollments.map((enrollment) => { + const document = serializePartnerSearchDocument(enrollment); + return [document.id, document] as const; + }), + ); +} + +function containsLiteralQuery( + document: PartnerSearchDocument | undefined, + normalizedQuery: string, +) { + return document + ? getPartnerSearchableValues(document).some((value) => + normalizePartnerSearchQuery(value).includes(normalizedQuery), + ) + : false; +} + +const EMAIL_PATTERN = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; + +/** + * Reads the indexed `searchText` back out of turbopuffer for the hits. + * + * Debug-only: `searchCandidates` asks for no attributes, because the API only + * ever needs IDs. This exists so --searchOnly can show what was indexed when the + * rows are not in the local database, such as a production index against a seeded one. + */ +async function fetchIndexedText(ids: string[]): Promise> { + const apiKey = process.env.TURBOPUFFER_API_KEY; + + if (!apiKey || ids.length === 0) { + return new Map(); + } + + const namespace = new Turbopuffer({ + apiKey, + region: "aws-us-east-1", + }).namespace(PARTNER_SEARCH_NAMESPACE); + const { rows } = await namespace.query({ + rank_by: ["id", "asc"], + top_k: ids.length, + filters: ["id", "In", ids], + include_attributes: ["searchText"], + }); + + return new Map( + (rows ?? []).map((row) => [String(row.id), String(row.searchText ?? "")]), + ); +} + +const PLATFORM_TYPES = [ + "website", + "youtube", + "twitter", + "linkedin", + "instagram", + "tiktok", +]; + +/** + * `searchText` is every searchable value lowercased and space-joined in a fixed + * order: partner ID, name, email, company, description, platform types, handles, + * then link keys. Three of those boundaries are recoverable, since the ID is + * prefixed, the email is address-shaped, and the platform types are a known + * enum, so the blob splits into name / profile / platforms-and-keys. A document + * without platforms has no recoverable boundary after the email, so everything + * past it stays together as `profile`. + */ +function parseIndexedText(searchText: string) { + const tokens = searchText.split(" ").filter(Boolean); + const hasPartnerId = tokens[0]?.startsWith("pn_") ?? false; + const emailIndex = tokens.findIndex((token) => EMAIL_PATTERN.test(token)); + // Without an email there is no boundary after the name, so it is assumed to + // end at the fourth token, and the profile starts where the name ends. + const nameEnd = emailIndex === -1 ? 4 : emailIndex; + const profileStart = emailIndex === -1 ? nameEnd : emailIndex + 1; + const platformIndex = tokens.findIndex( + (token, index) => index >= profileStart && PLATFORM_TYPES.includes(token), + ); + + return { + tokens, + name: tokens.slice(hasPartnerId ? 1 : 0, nameEnd).join(" "), + email: emailIndex === -1 ? "" : tokens[emailIndex], + profile: tokens + .slice(profileStart, platformIndex === -1 ? undefined : platformIndex) + .join(" "), + platformsAndKeys: + platformIndex === -1 ? "" : tokens.slice(platformIndex).join(" "), + }; +} + +/** + * How often each query term occurs, and how long the document is. Both drive the + * BM25 score: term frequency raises it, and length normalization (b=0.75) pushes + * it back down, so a partner with many links can rank below a shorter document + * that matched fewer of the query's terms. + */ +function summarizeTermMatches(tokens: string[], normalizedQuery: string) { + const queryTerms = normalizedQuery.split(/\s+/u).filter(Boolean); + + return queryTerms + .map((term) => { + const count = tokens.filter((token) => token.startsWith(term)).length; + return `${term}×${count}`; + }) + .join(" "); +} + +/** + * Everything the provider returns on its own: an ID and a score. Names, emails, + * and the literal-match check all come from the database, so --searchOnly trades + * them for a run that needs no DATABASE_URL. + */ +function reportProviderHits( + hits: PartnerSearchHit[], + indexedText: Map, + normalizedQuery: string, +) { + console.table( + hits.map((hit, index) => { + const parsed = parseIndexedText(indexedText.get(hit.id) ?? ""); + + return { + rank: index + 1, + score: hit.score?.toFixed(5), + name: parsed.name, + email: parsed.email, + docTokens: parsed.tokens.length, + queryTerms: summarizeTermMatches(parsed.tokens, normalizedQuery), + }; + }), + ); + + if (indexedText.size === 0) { + return; + } + + console.log("\nIndexed document per hit"); + hits.forEach((hit, index) => { + const parsed = parseIndexedText(indexedText.get(hit.id) ?? ""); + console.log(`\n${index + 1}. ${hit.id}`); + console.log(` profile ${parsed.profile.slice(0, 150) || "—"}`); + console.log( + ` platforms+keys ${parsed.platformsAndKeys.slice(0, 150) || "—"}`, + ); + // The parsed sections are heuristic. The raw text is the ground truth for + // checking what a document actually contains, so it is never truncated. + console.log(` raw ${indexedText.get(hit.id) ?? ""}`); + }); +} + +function reportResults({ + label, + hits, + databaseDocuments, + normalizedQuery, +}: { + label: string; + hits: PartnerSearchHit[]; + databaseDocuments: Awaited>; + normalizedQuery: string; +}) { + console.log(`\n${label}`); + console.table( + hits.map((hit, index) => { + const databaseDocument = databaseDocuments.get(hit.id); + + return { + rank: index + 1, + providerScore: hit.score, + containsLiteralQuery: containsLiteralQuery( + databaseDocument, + normalizedQuery, + ), + enrollmentId: hit.id, + partnerId: databaseDocument?.partnerId ?? "missing from database", + name: databaseDocument?.name ?? "missing from database", + email: databaseDocument?.email ?? null, + company: databaseDocument?.companyName ?? null, + }; + }), + ); +} + +async function main() { + const { programId, query, limit, status, searchOnly } = parseArguments( + process.argv.slice(2), + ); + const searchProvider = getPartnerSearchProvider(); + if (!searchProvider) { + throw new Error("TURBOPUFFER_API_KEY is not configured."); + } + + const startedAt = performance.now(); + + // Step 1: Fetch the same relevance candidates used by the website + const relevanceResult = await searchProvider.searchCandidates({ + programId, + query, + limit: PARTNER_SEARCH_CANDIDATE_LIMIT, + }); + + const normalizedQuery = normalizePartnerSearchQuery(query); + + if (searchOnly) { + console.log("Partner search debug summary"); + console.table({ + programId, + query, + normalizedQuery, + candidates: relevanceResult.hits.length, + elapsedMs: (performance.now() - startedAt).toFixed(1), + }); + + const pageHits = relevanceResult.hits.slice(0, limit); + const indexedText = await fetchIndexedText(pageHits.map(({ id }) => id)); + + console.log( + "\nProvider relevance order (provider only, no database reads)", + ); + reportProviderHits(pageHits, indexedText, normalizedQuery); + return; + } + + // Step 2: Apply the requested status while loading canonical database documents + const databaseDocuments = await getDatabaseDocuments( + relevanceResult.hits, + status, + ); + const databaseMatchedHits = relevanceResult.hits.filter(({ id }) => + databaseDocuments.has(id), + ); + const filteredHits = databaseMatchedHits.slice(0, limit); + + console.log("Partner search debug summary"); + console.table({ + programId, + query, + normalizedQuery, + status: status ?? "all", + candidates: relevanceResult.hits.length, + databaseMatches: databaseMatchedHits.length, + elapsedMs: (performance.now() - startedAt).toFixed(1), + }); + console.log( + "Provider scores are provider-defined. Compare result order and database values across providers.", + ); + + reportResults({ + label: "Provider relevance order", + hits: filteredHits, + databaseDocuments, + normalizedQuery, + }); +} + +main() + .catch((error) => { + console.error("Partner search debug failed:", error); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/apps/web/scripts/dev/seed-100k-partners.ts b/apps/web/scripts/dev/seed-100k-partners.ts new file mode 100644 index 00000000000..0383bdb34e7 --- /dev/null +++ b/apps/web/scripts/dev/seed-100k-partners.ts @@ -0,0 +1,722 @@ +/** + * Bulk-seeds partners for local development and partner-search benchmarking. + * + * Writes User, Partner, PartnerUser, ProgramEnrollment, PartnerPlatform, Link, + * and ProgramPartnerTag rows (every field the search index reads) in atomic + * chunks of CHUNK_SIZE partners, so 100K partners lands ~500K rows in seconds. + * + * Statuses, groups, and tags are spread across each partner so the filters have + * something to select on: seeding everything as approved and ungrouped makes a + * filtered search look correct while matching every row. + * + * Re-running with the same `--seed` collides. `seedFingerprint` is a deterministic + * hash of the program ID and `--seed`, so identical arguments regenerate identical + * emails, usernames, and link keys, all of which are unique columns. Pass a new + * `--seed` to add another batch; the script checks up front and says so rather than + * dying on a constraint error halfway through. + * + * This inserts login-capable users with a known password, so it refuses a non-local + * DATABASE_URL unless `--allowRemoteDatabase` is passed, and refuses a production + * environment (NODE_ENV/VERCEL_ENV) outright. + * + * cd apps/web + * pnpm run script dev/seed-100k-partners [--count=100000] [--programId=prog_123] [--seed=custom-seed] + * + * Seeded partners are not searchable until the index is backfilled: + * pnpm run script partners/backfill-partner-search --programId=prog_123 + */ + +import { createId } from "@/lib/api/create-id"; +import { hashPassword } from "@/lib/auth/password"; +import { prisma } from "@/lib/prisma"; +import { parsePositiveInteger } from "@/scripts/utils/parse-cli-number"; +import { PlatformType, Prisma, ProgramEnrollmentStatus } from "@prisma/client"; +import { createHash } from "crypto"; +import "dotenv-flow/config"; + +const DEFAULT_COUNT = 100_000; +const MAX_COUNT = 1_000_000; +const DEFAULT_SEED = "partners-search"; +const CHUNK_SIZE = 2_500; +const LOCAL_DATABASE_HOSTS = new Set([ + "localhost", + "127.0.0.1", + "0.0.0.0", + "::1", + "host.docker.internal", + "mysql", + "db", +]); + +// prettier-ignore +const FIRST_NAMES = [ + "Alex", "Jordan", "Taylor", "Morgan", "Chris", "Sam", "Riley", "Casey", "Dakota", "Jamie", + "Avery", "Reese", "Skyler", "Quinn", "Rowan", "Peyton", "Finley", "Emerson", "Hayden", "Sage", + "Logan", "Jesse", "Harper", "Eden", "Kendall", "Devon", "Dallas", "Shiloh", "River", "Phoenix", + "Cameron", "Drew", "Eli", "Francis", "Greyson", "Hadley", "Jules", "Kai", "Lennon", "Marlowe", + "Adrian", "Blake", "Corey", "Delaney", "Ellis", "Frankie", "Gray", "Hollis", "Indigo", "Justice", + "Keegan", "Lane", "Micah", "Noel", "Oakley", "Parker", "Remy", "Sutton", "Tatum", "Wren", + "Arden", "Bellamy", "Campbell", "Darcy", +]; + +// prettier-ignore +const LAST_NAMES = [ + "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez", + "Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Jackson", "Martin", + "Lee", "Perez", "Thompson", "White", "Harris", "Sanchez", "Clark", "Ramirez", "Lewis", "Robinson", + "Walker", "Young", "Allen", "King", "Wright", "Scott", "Torres", "Nguyen", "Hill", "Flores", + "Okonkwo", "Bergstrom", "Castellano", "Dubois", "Eriksen", "Fitzgerald", "Grigoryan", "Halvorsen", + "Ibrahim", "Jankowski", "Kowalczyk", "Lindqvist", "Mbeki", "Nakamura", "Oyelaran", "Petrova", + "Quintero", "Rasmussen", "Silvestri", "Takahashi", "Ustinov", "Vasquez", "Whitfield", "Zielinski", +]; + +// Company names come from their own vocabulary rather than reusing the surname. +// The benchmark queries a field's longest token, so `${lastName} ${suffix}` +// mostly repeated the name field's queries and measured the same work twice. +// Every prefix is longer than every suffix, so the prefix is what gets queried. +// prettier-ignore +const COMPANY_PREFIXES = [ + "Brightwave", "Silverpine", "Northgate", "Blueshift", "Stonebridge", "Clearwater", "Ravenwood", + "Copperfield", "Emberline", "Frostpeak", "Hollowbrook", "Jadestone", "Kingfisher", "Moonstone", + "Nightingale", "Pinecrest", "Quicksilver", "Riverstone", "Sablewood", "Thornfield", "Umberwood", + "Whitestone", "Amberfall", "Bramblewood", "Cindershade", "Duskwater", "Elderbrook", "Glasshouse", + "Havenwood", "Ironbark", "Larkspire", "Meadowlark", "Opaline", "Pathfinder", "Quarrystone", + "Redcliffe", "Saltmarsh", "Tidewater", "Vireoglen", "Windrose", +]; + +// Every suffix is at most six characters and every prefix at least seven, so +// the prefix is always the longest token. +// prettier-ignore +const COMPANY_SUFFIXES = [ + "Tech", "Labs", "Media", "Agency", "Studio", "Global", "Cloud", "Group", "Growth", "Union", + "Works", "Forge", "Craft", "Point", "Scale", "Reach", "Signal", "Vector", "Summit", "Nexus", +]; + +// Email search queries the first five characters of the domain, so these are +// distinct in their first five. +// prettier-ignore +const DOMAINS = [ + "example.com", "techcorp.io", "marketing.co", "acme.dev", "growth.app", + "agency.net", "saas.com", "creator.xyz", "dub.co", "builder.build", + "pixelforge.io", "nimbus.dev", "quantum.co", "vertex.app", "zenith.net", + "orbital.io", "catalyst.co", "summit.dev", "horizon.app", "lumina.io", + "forgeworks.com", "bedrock.co", "kinetic.dev", "radiant.app", "stellar.io", + "thrive.co", "upstream.dev", "waveform.app", "yonder.io", "atlas.works", + "beacon.co", "cipher.dev", "delta.app", "ember.io", "fathom.co", + "gradient.dev", "harbor.app", "ignite.io", "juniper.co", "keystone.dev", +]; + +// prettier-ignore +const COUNTRIES = ["US", "CA", "GB", "DE", "FR", "AU", "JP", "IN", "BR", "NL", "ES", "SE", "SG"]; + +// A description's longest word becomes its benchmark query, so the specialty is +// what has to vary, since every other word here is shorter than every specialty, and +// the specialties differ within their first twelve characters (the query is +// truncated there). +const DESCRIPTION_TEMPLATES = [ + "Affiliate marketer working across %s and paid growth.", + "Tech reviewer covering %s for a global audience.", + "Runs a weekly newsletter about %s and dev tools.", + "Builds long-form guides on %s for growing teams.", + "Advises brands on %s and referral links.", + "Hosts a podcast about %s and early-stage startups.", + "Writes deep dives into %s and modern web tooling.", + "Teaches short courses on %s to first-time founders.", +]; + +// prettier-ignore +const DESCRIPTION_SPECIALTIES = [ + "observability", "personalization", "authentication", "infrastructure", "orchestration", + "virtualization", "containerization", "cybersecurity", "documentation", "localization", + "monetization", "optimization", "provisioning", "segmentation", "subscriptions", + "tokenization", "visualization", "warehousing", "attribution", "benchmarking", + "collaboration", "deliverability", "forecasting", "reconciliation", "syndication", + "accessibility", "interoperability", "experimentation", "instrumentation", "productization", + "categorization", "deduplication", "geolocation", "hyperautomation", "internationalization", + "normalization", "partitioning", "replication", +]; + +// Weighted so most partners are approved, which is what the table shows by +// default, while leaving enough of every other status to filter on. +// prettier-ignore +const STATUS_WEIGHTS: ProgramEnrollmentStatus[] = [ + ...Array(84).fill("approved"), + ...Array(6).fill("pending"), + ...Array(3).fill("rejected"), + ...Array(3).fill("banned"), + ...Array(2).fill("archived"), + ...Array(2).fill("deactivated"), +]; + +// prettier-ignore +const TAG_NAMES = [ + "VIP", "Newsletter", "YouTube", "Enterprise", "Agency", "Beta", "Inactive", "High Intent", +]; + +const SEEDED_GROUP_NAMES = ["Creators", "Agencies", "Enterprise", "Affiliates"]; + +const PLATFORM_TYPES: PlatformType[] = [ + PlatformType.website, + PlatformType.youtube, + PlatformType.twitter, + PlatformType.linkedin, + PlatformType.instagram, + PlatformType.tiktok, +]; + +type SeedArguments = { + totalCount: number; + targetProgramId: string | null; + seed: string; + allowRemoteDatabase: boolean; +}; + +type PartnerChunk = { + users: Prisma.UserCreateManyInput[]; + partners: Prisma.PartnerCreateManyInput[]; + partnerUsers: Prisma.PartnerUserCreateManyInput[]; + enrollments: Prisma.ProgramEnrollmentCreateManyInput[]; + platforms: Prisma.PartnerPlatformCreateManyInput[]; + links: Prisma.LinkCreateManyInput[]; + programPartnerTags: Prisma.ProgramPartnerTagCreateManyInput[]; +}; + +type GeneratePartnerChunkOptions = { + start: number; + end: number; + seedFingerprint: string; + passwordHash: string; + runStartedAt: Date; + programId: string; + programDomain: string | null; + workspaceId: string; + groupIds: (string | null)[]; + tagIds: string[]; +}; + +// FNV-1a with murmur3's fmix32 finalizer. Cheap enough to call several times +// per partner, unlike a crypto hash. FNV-1a alone has weak avalanche, and this +// is called with near-identical strings (same fingerprint and index, differing +// only in the field label), whose outputs stay correlated: first and last names +// stopped pairing independently and most of the 64×64 combinations never +// occurred at all. The finalizer restores independence and stays deterministic. +function hashToUint32(value: string): number { + let hash = 0x811c9dc5; + + for (let i = 0; i < value.length; i++) { + hash = Math.imul(hash ^ value.charCodeAt(i), 0x01000193); + } + + hash ^= hash >>> 16; + hash = Math.imul(hash, 0x85ebca6b); + hash ^= hash >>> 13; + hash = Math.imul(hash, 0xc2b2ae35); + hash ^= hash >>> 16; + + return hash >>> 0; +} + +function generatePartnerMetrics(index: number) { + const totalClicks = 100 + ((index * 37) % 50_000); + const leadRate = 0.05 + (index % 16) / 100; + const conversionRate = 0.1 + (index % 31) / 100; + const saleRate = 0.6 + (index % 31) / 100; + const totalLeads = Math.max(1, Math.floor(totalClicks * leadRate)); + const totalConversions = Math.max(1, Math.floor(totalLeads * conversionRate)); + const totalSales = Math.max(1, Math.floor(totalConversions * saleRate)); + const averageOrderValueCents = 2_500 + ((index * 7_919) % 197_500); + const totalSaleAmount = BigInt(totalSales * averageOrderValueCents); + const commissionRatePercent = 5 + (index % 26); + const totalCommissions = + (totalSaleAmount * BigInt(commissionRatePercent)) / BigInt(100); + + return { + totalClicks, + totalLeads, + totalConversions, + totalSales, + totalSaleAmount, + totalCommissions, + netRevenue: totalSaleAmount - totalCommissions, + earningsPerClick: Number(totalSaleAmount) / totalClicks, + averageLifetimeValue: Number(totalSaleAmount) / totalConversions, + clickToLeadRate: totalLeads / totalClicks, + clickToConversionRate: totalConversions / totalClicks, + leadToConversionRate: totalConversions / totalLeads, + returnOnAdSpend: Number(totalSaleAmount) / Number(totalCommissions), + } satisfies Partial; +} + +// Args: --count= (optional, default: 100000) - Total number of partners to seed. +// --programId= (optional) - Target program ID to seed partners into. +// --seed= (optional, default: "partners-search") - Seed string for deterministic generation. +// --allowRemoteDatabase (optional) - Permit seeding a non-local DATABASE_URL. +const parseArguments = (args: string[]): SeedArguments => { + let totalCount = DEFAULT_COUNT; + let targetProgramId: string | null = null; + let seed = DEFAULT_SEED; + let allowRemoteDatabase = false; + + for (const arg of args) { + if (arg.startsWith("--count=")) { + totalCount = parsePositiveInteger( + arg.slice("--count=".length), + "--count", + ); + } else if (arg.startsWith("--programId=")) { + targetProgramId = arg.slice("--programId=".length); + } else if (arg.startsWith("--seed=")) { + seed = arg.slice("--seed=".length); + } else if (arg === "--allowRemoteDatabase") { + allowRemoteDatabase = true; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (totalCount > MAX_COUNT) { + throw new Error( + `--count cannot exceed ${MAX_COUNT.toLocaleString()}. Run the script again with a new --seed to add more.`, + ); + } + + if (!seed || !/^[a-zA-Z0-9_-]{1,40}$/.test(seed)) { + throw new Error( + "--seed must contain 1-40 letters, numbers, underscores, or hyphens.", + ); + } + + if (targetProgramId === "") { + throw new Error("--programId cannot be empty."); + } + + return { totalCount, targetProgramId, seed, allowRemoteDatabase }; +}; + +// Refuse unless the host is local or the operator opts in explicitly. +const assertSeedableDatabase = (allowRemoteDatabase: boolean) => { + const databaseUrl = process.env.DATABASE_URL; + + if (!databaseUrl) { + throw new Error("DATABASE_URL is not set."); + } + + // The host allowlist cannot tell a truly local database from a production + // one reached through a local tunnel, so a production environment refuses + // outright. --allowRemoteDatabase does not override this. + if ( + process.env.NODE_ENV === "production" || + process.env.VERCEL_ENV === "production" + ) { + throw new Error( + "Refusing to seed in a production environment. This script inserts users with a known password.", + ); + } + + let host: string; + try { + // `URL` keeps IPv6 hosts bracketed; strip them so "::1" matches. + host = new URL(databaseUrl).hostname.replace(/^\[|\]$/g, ""); + } catch { + throw new Error("DATABASE_URL is not a valid connection URL."); + } + + if (LOCAL_DATABASE_HOSTS.has(host)) { + return; + } + + if (allowRemoteDatabase) { + console.warn( + `⚠️ Seeding the non-local database at "${host}" because --allowRemoteDatabase was passed.\n`, + ); + return; + } + + throw new Error( + `Refusing to seed the non-local database at "${host}". This script inserts users with a known password. Pass --allowRemoteDatabase if you are certain.`, + ); +}; + +const resolveProgramId = async (targetProgramId: string | null) => { + if (targetProgramId) { + return targetProgramId; + } + + const programs = await prisma.program.findMany({ + take: 2, + select: { id: true, name: true }, + }); + + if (programs.length > 1) { + throw new Error( + `Multiple programs found. Pass --programId= to choose one (for example, ${programs[0].id} for "${programs[0].name}").`, + ); + } + + return programs[0]?.id ?? null; +}; + +const generatePartnerChunk = ({ + start, + end, + seedFingerprint, + passwordHash, + runStartedAt, + programId, + programDomain, + workspaceId, + groupIds, + tagIds, +}: GeneratePartnerChunkOptions): PartnerChunk => { + const users: Prisma.UserCreateManyInput[] = []; + const partners: Prisma.PartnerCreateManyInput[] = []; + const partnerUsers: Prisma.PartnerUserCreateManyInput[] = []; + const enrollments: Prisma.ProgramEnrollmentCreateManyInput[] = []; + const platforms: Prisma.PartnerPlatformCreateManyInput[] = []; + const links: Prisma.LinkCreateManyInput[] = []; + const programPartnerTags: Prisma.ProgramPartnerTagCreateManyInput[] = []; + + // Pick from a pool by hashing rather than `index % pool.length`. The benchmark + // samples every Nth partner, so a stride sharing a factor with the pool size + // lands the whole sample on one entry: at 100K partners sampled 100 ways the + // stride is 1,000, and `index % 10` gave every sampled partner the same email + // domain. Hashing decorrelates the choice from the index and stays + // deterministic, so the same --seed still regenerates the same partners. + const pick = (pool: T[], field: string, index: number): T => + pool[hashToUint32(`${seedFingerprint}:${field}:${index}`) % pool.length]; + + for (let i = start; i < end; i++) { + const partnerId = createId({ prefix: "pn_" }); + const userId = createId({ prefix: "user_" }); + const enrollmentId = createId({ prefix: "pge_" }); + + const firstName = pick(FIRST_NAMES, "firstName", i); + const lastName = pick(LAST_NAMES, "lastName", i); + const name = `${firstName} ${lastName}`; + + // Recognizable needles for manual search testing. The moduli are primes so + // they cannot line up with a benchmark stride and swallow the whole sample. + let emailPrefix: string; + if (i % 101 === 0) { + emailPrefix = `partner.${seedFingerprint}.${i}`; + } else if (i % 137 === 0) { + emailPrefix = `substringneedle.${seedFingerprint}.${i}`; + } else if (i % 79 === 0) { + emailPrefix = `tech.creator.${seedFingerprint}.${i}`; + } else if (i % 53 === 0) { + emailPrefix = `dub.affiliate.${seedFingerprint}.${i}`; + } else { + emailPrefix = `${firstName.toLowerCase()}.${lastName.toLowerCase()}.${seedFingerprint}.${i}`; + } + + const domain = pick(DOMAINS, "domain", i); + const email = `${emailPrefix}@${domain}`; + const username = `${firstName.toLowerCase()}_${lastName.toLowerCase()}_${seedFingerprint}_${i}`; + const companyName = `${pick(COMPANY_PREFIXES, "companyPrefix", i)} ${pick(COMPANY_SUFFIXES, "companySuffix", i)}`; + const country = pick(COUNTRIES, "country", i); + const description = pick(DESCRIPTION_TEMPLATES, "description", i).replace( + "%s", + pick(DESCRIPTION_SPECIALTIES, "specialty", i), + ); + // One minute earlier per partner, wrapping at a year (index 525,600). + const createdAt = new Date( + runStartedAt.getTime() - ((i * 60_000) % (365 * 86_400_000)), + ); + + users.push({ + id: userId, + name, + email, + emailVerified: runStartedAt, + passwordHash, + defaultPartnerId: partnerId, + createdAt, + }); + + partners.push({ + id: partnerId, + name, + username, + email, + description, + country, + companyName, + networkStatus: "approved", + createdAt, + }); + + partnerUsers.push({ + userId, + partnerId, + role: "owner", + createdAt, + }); + + enrollments.push({ + id: enrollmentId, + partnerId, + programId, + groupId: pick(groupIds, "groupId", i), + status: pick(STATUS_WEIGHTS, "status", i), + ...generatePartnerMetrics(i), + createdAt, + }); + + // 0-3 tags per partner, so the array filter has partners to include and + // partners to exclude rather than everything matching. + const tagCount = hashToUint32(`${seedFingerprint}:tagCount:${i}`) % 4; + const firstTag = hashToUint32(`${seedFingerprint}:tag:${i}`); + for (let t = 0; t < tagCount; t++) { + programPartnerTags.push({ + programId, + partnerId, + partnerTagId: tagIds[(firstTag + t) % tagIds.length], + }); + } + + // Offsetting a hashed start keeps each partner's platform types distinct, + // which PartnerPlatform requires per partner. + const platformCount = + 1 + (hashToUint32(`${seedFingerprint}:platformCount:${i}`) % 3); + const firstPlatform = hashToUint32(`${seedFingerprint}:platform:${i}`); + for (let p = 0; p < platformCount; p++) { + const platformType = + PLATFORM_TYPES[(firstPlatform + p) % PLATFORM_TYPES.length]; + const identifier = + platformType === PlatformType.website + ? `https://www.${companyName.toLowerCase().replace(/[^a-z0-9]/g, "")}-${i}.${domain}` + : `@${firstName.toLowerCase()}_${lastName.toLowerCase()}_${i}`; + + platforms.push({ + partnerId, + type: platformType, + identifier, + subscribers: BigInt(100 + ((i * 37) % 50000)), + views: BigInt(500 + ((i * 123) % 500000)), + verifiedAt: createdAt, + createdAt, + }); + } + + const linkKey = `p-${seedFingerprint}-${i}`; + const linkDomain = programDomain || "dub.sh"; + links.push({ + id: createId({ prefix: "link_" }), + domain: linkDomain, + key: linkKey, + url: `https://${domain}/ref/${username}`, + shortLink: `https://${linkDomain}/${linkKey}`, + projectId: workspaceId, + programId, + partnerId, + createdAt, + }); + } + + return { + users, + partners, + partnerUsers, + enrollments, + platforms, + links, + programPartnerTags, + }; +}; + +const insertPartnerChunk = async ({ + users, + partners, + partnerUsers, + enrollments, + platforms, + links, + programPartnerTags, +}: PartnerChunk) => { + // Keep every chunk atomic so a failed write cannot leave partial relations. + const [, partnerResult] = await prisma.$transaction([ + prisma.user.createMany({ data: users }), + prisma.partner.createMany({ data: partners }), + prisma.partnerUser.createMany({ data: partnerUsers }), + prisma.programEnrollment.createMany({ data: enrollments }), + prisma.partnerPlatform.createMany({ data: platforms }), + prisma.link.createMany({ data: links }), + prisma.programPartnerTag.createMany({ data: programPartnerTags }), + ]); + + return partnerResult.count; +}; + +// Generation is deterministic given (programId, seed), so index 0's link key is +// always the same. Keyed on the link key rather than the email because the key +// is built from the fingerprint and index alone, so editing the name or domain +// pools changes index 0's email, which would let this miss an applied seed and +// surface a raw constraint error partway through instead. +const assertSeedNotAlreadyApplied = async ( + partnerChunk: PartnerChunk, + seed: string, +) => { + const firstLink = partnerChunk.links[0]; + + if (!firstLink) { + return; + } + + const existing = await prisma.link.findUnique({ + where: { + domain_key: { domain: firstLink.domain, key: firstLink.key }, + }, + select: { id: true }, + }); + + if (existing) { + throw new Error( + `Seed "${seed}" has already been applied to this program (found ${firstLink.domain}/${firstLink.key}). Re-running would collide on unique emails, usernames, and link keys. Pass a different --seed to add another batch.`, + ); + } +}; + +async function main() { + const { totalCount, targetProgramId, seed, allowRemoteDatabase } = + parseArguments(process.argv.slice(2)); + + assertSeedableDatabase(allowRemoteDatabase); + + const resolvedProgramId = await resolveProgramId(targetProgramId); + + console.log( + `\n🚀 Starting Partner Data Seed (Target Count: ${totalCount.toLocaleString()}, Seed: "${seed}")...`, + ); + + const program = resolvedProgramId + ? await prisma.program.findUnique({ where: { id: resolvedProgramId } }) + : null; + + if (!program) { + throw new Error( + "❌ No program found in database. Please run 'pnpm run script dev/seed' first to set up the default workspace and program.", + ); + } + + const workspace = await prisma.project.findUnique({ + where: { id: program.workspaceId }, + }); + + if (!workspace) { + throw new Error("❌ Program workspace not found."); + } + + console.log( + `📍 Seeding partners for Program: "${program.name}" (${program.id})`, + ); + console.log(` Workspace: "${workspace.name}" (${workspace.id})\n`); + + // Build the stable namespace shared by every generated chunk. + // Pre-compute the password hash for 'password' once to avoid computing + // a separate bcrypt hash for every generated partner. + const passwordHash = await hashPassword("password"); + const seedNamespace = `${program.id}:${seed}`; + const seedFingerprint = createHash("sha256") + .update(seedNamespace) + .digest("hex") + .slice(0, 16); + const runStartedAt = new Date(); + + // Tags and groups are created once per run and reused, so filters have a + // small stable set of values to select on rather than one value per partner. + const tagIds = TAG_NAMES.map((_, index) => createId({ prefix: "ptag_" })); + const seededGroupIds = SEEDED_GROUP_NAMES.map(() => + createId({ prefix: "grp_" }), + ); + + // The program's own default group, the seeded ones, and null, so "no group" + // is represented too, which is what a negated filter has to include. + const groupIds: (string | null)[] = [ + program.defaultGroupId, + ...seededGroupIds, + null, + ]; + + // The duplicate-seed check runs before anything is written; running it + // after the tag and group writes would leave their rows behind on a rerun. + await assertSeedNotAlreadyApplied( + generatePartnerChunk({ + start: 0, + end: Math.min(CHUNK_SIZE, totalCount), + seedFingerprint, + passwordHash, + runStartedAt, + programId: program.id, + programDomain: program.domain, + workspaceId: workspace.id, + groupIds, + tagIds, + }), + seed, + ); + + await prisma.partnerTag.createMany({ + data: TAG_NAMES.map((name, index) => ({ + id: tagIds[index], + programId: program.id, + name: `${name} ${seed}`, + })), + }); + + await prisma.partnerGroup.createMany({ + data: SEEDED_GROUP_NAMES.map((name, index) => ({ + id: seededGroupIds[index], + programId: program.id, + name: `${name} ${seed}`, + slug: `${name.toLowerCase()}-${seedFingerprint.slice(0, 6)}`, + })), + }); + + console.log( + `Created ${tagIds.length} tags and ${seededGroupIds.length} groups for filtering\n`, + ); + + const totalChunks = Math.ceil(totalCount / CHUNK_SIZE); + const startTime = Date.now(); + let insertedPartners = 0; + + for (let chunk = 0; chunk < totalChunks; chunk++) { + const chunkStart = chunk * CHUNK_SIZE; + const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, totalCount); + const partnerChunk = generatePartnerChunk({ + start: chunkStart, + end: chunkEnd, + seedFingerprint, + passwordHash, + runStartedAt, + programId: program.id, + programDomain: program.domain, + workspaceId: workspace.id, + groupIds, + tagIds, + }); + + const insertedInChunk = await insertPartnerChunk(partnerChunk); + insertedPartners += insertedInChunk; + + const elapsedSec = ((Date.now() - startTime) / 1000).toFixed(1); + const progressPct = (((chunk + 1) / totalChunks) * 100).toFixed(0); + console.log( + ` [Chunk ${chunk + 1}/${totalChunks}] (${progressPct}%) Processed ${chunkEnd.toLocaleString()}/${totalCount.toLocaleString()} partners (${insertedInChunk.toLocaleString()} new)... (${elapsedSec}s elapsed)`, + ); + } + + const totalTimeSec = ((Date.now() - startTime) / 1000).toFixed(1); + console.log( + `\n✅ Seed complete: ${insertedPartners.toLocaleString()} partners inserted (${totalTimeSec}s).`, + ); +} + +main() + .catch((e) => { + console.error("❌ Error running partner seed script:", e); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/apps/web/scripts/dev/seed.ts b/apps/web/scripts/dev/seed.ts index 4683c622ba5..9fafe6d266f 100644 --- a/apps/web/scripts/dev/seed.ts +++ b/apps/web/scripts/dev/seed.ts @@ -3,6 +3,7 @@ import { hashPassword } from "@/lib/auth/password"; import { prisma } from "@/lib/prisma"; import { Domain, + EmailDomain, Folder, Integration, Partner, @@ -18,7 +19,7 @@ import { import "dotenv-flow/config"; import fs from "fs"; import path from "path"; -import readline from "readline"; +import { assertLocalDatabaseEnv } from "../../playwright/assert-local-database"; type Workspace = Pick< Project, @@ -48,6 +49,8 @@ type Workspace = Pick< type DomainSeed = Pick; +type EmailDomainSeed = Pick; + type FolderSeed = Pick; type RewardSeed = Pick< @@ -118,6 +121,7 @@ type SeedData = { workspace: Workspace; users: WorkspaceUser[]; domains: DomainSeed[]; + emailDomains: EmailDomainSeed[]; folders: FolderSeed[]; rewards: RewardSeed[]; groups: GroupSeed[]; @@ -225,6 +229,33 @@ const createDomains = async (data: SeedData) => { console.log(`Created ${count} domains`); }; +// Create email domains +const createEmailDomains = async (data: SeedData) => { + const { emailDomains, workspace, program } = data; + + if (!emailDomains || emailDomains.length === 0) { + console.log("No email domains to insert"); + return; + } + + if (!program) { + console.log("Program is required to create email domains"); + return; + } + + const { count } = await prisma.emailDomain.createMany({ + data: emailDomains.map((emailDomain) => ({ + id: emailDomain.id, + slug: emailDomain.slug, + status: emailDomain.status, + workspaceId: workspace.id, + programId: program.id, + })), + }); + + console.log(`Created ${count} email domains`); +}; + // Create folders const createFolders = async (data: SeedData) => { const { folders, workspace } = data; @@ -566,44 +597,14 @@ const truncate = async () => { console.log("Database truncated successfully"); }; -// Ask for confirmation - requires typing "YES DELETE DATA" -const askConfirmation = (question: string): Promise => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - return new Promise((resolve) => { - rl.question( - `${question}\nType "YES DELETE DATA" to confirm: `, - (answer) => { - rl.close(); - resolve(answer === "YES DELETE DATA"); - }, - ); - }); -}; - async function main() { + assertLocalDatabaseEnv(); + // Check for --truncate flag // process.argv[0] = node, process.argv[1] = script path, process.argv[2+] = arguments const shouldTruncate = process.argv.slice(2).includes("--truncate"); if (shouldTruncate) { - console.log( - "\n⚠️ WARNING: This will delete ALL data from the database.\n", - ); - console.log("⚠️ Make sure you are NOT on production database!\n"); - const confirmed = await askConfirmation( - "Are you sure you want to delete ALL data from the database?", - ); - - if (!confirmed) { - console.log("\nTruncate canceled. Exiting..."); - process.exit(0); - } - - console.log("\n"); await truncate(); console.log("\n"); } @@ -615,6 +616,7 @@ async function main() { await createDomains(data); await createFolders(data); await createProgram(data); + await createEmailDomains(data); await createRewards(data); await createGroups(data); await createPartners(data); diff --git a/apps/web/scripts/dev/simulate-shopify-conversion.ts b/apps/web/scripts/dev/simulate-shopify-conversion.ts new file mode 100644 index 00000000000..6e6614e7f87 --- /dev/null +++ b/apps/web/scripts/dev/simulate-shopify-conversion.ts @@ -0,0 +1,134 @@ +import "dotenv-flow/config"; + +import { nanoid } from "@dub/utils"; +import { createHmac } from "crypto"; + +async function main() { + const clickId = "RZjkGhi04FGxWjcK"; + const checkoutToken = nanoid(10); + const storeId = "store.dub.co"; + const existingCustomerId = null; + const discountCode = null; + + await trackPixel({ + clickId, + checkoutToken, + }); + + await new Promise((resolve) => setTimeout(resolve, 4000)); + + await trackOrderPaid({ + checkoutToken, + storeId, + existingCustomerId, + discountCode, + }); +} + +async function trackPixel({ + clickId, + checkoutToken, +}: { + clickId: string; + checkoutToken: string; +}) { + const response = await fetch("http://localhost:8888/api/shopify/pixel", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + clickId, + checkoutToken, + }), + }); + + const data = await response.json(); + + console.log("trackPixel", data); +} + +async function trackOrderPaid({ + checkoutToken, + storeId, + existingCustomerId, + discountCode, +}: { + checkoutToken: string; + storeId: string; + existingCustomerId?: string | null; + discountCode?: string | null; +}) { + const payload = shopifyOrderPayload({ + checkoutToken, + ...(existingCustomerId && { + customer: { id: existingCustomerId }, + }), + ...(discountCode && { + discount_codes: [{ code: discountCode }], + }), + }); + + const body = JSON.stringify(payload); + const signature = shopifyWebhookSignature(body); + + const response = await fetch( + "http://localhost:8888/api/shopify/integration/webhook", + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-shopify-topic": "orders/paid", + "x-shopify-shop-domain": storeId, + ...(signature && { "x-shopify-hmac-sha256": signature }), + }, + body, + }, + ); + + const data = await response.text(); + + console.log("trackOrderPaid", data); +} + +function shopifyWebhookSignature(body: string) { + const secret = process.env.SHOPIFY_WEBHOOK_SECRET; + + if (!secret) { + return undefined; + } + + return createHmac("sha256", secret).update(body, "utf8").digest("base64"); +} + +function shopifyOrderPayload({ + checkoutToken, + ...overrides +}: { + checkoutToken: string; +} & Record) { + return { + confirmation_number: nanoid(10), + checkout_token: checkoutToken, + customer: { + id: nanoid(10), + first_name: "John", + last_name: "Doe", + email: `john.doe.${nanoid(5)}@example.com`, + }, + current_subtotal_price_set: { + shop_money: { + amount: "72", + currency_code: "USD", + }, + }, + discount_codes: [], + billing_address: { + province: "California", + country_code: "US", + }, + ...overrides, + }; +} + +main(); diff --git a/apps/web/scripts/dev/simulate-shopify-webhook.ts b/apps/web/scripts/dev/simulate-shopify-webhook.ts deleted file mode 100644 index 85d233beb3a..00000000000 --- a/apps/web/scripts/dev/simulate-shopify-webhook.ts +++ /dev/null @@ -1,48 +0,0 @@ -import "dotenv-flow/config"; - -import { orderSchema } from "@/lib/integrations/shopify/schema"; -import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; - -async function main() { - const event = orderSchema.parse({ - confirmation_number: "WDQ0YHYU32", - checkout_token: "20585787a5a40274b6b34511d2f637e92", - customer: { - id: 97773681707702, - first_name: "John", - last_name: "Doe", - email: "customer+2@dub-internal-test.com", - }, - current_subtotal_price_set: { - shop_money: { - amount: "22.50", - currency_code: "USD", - }, - }, - discount_codes: [ - { - code: "D1", - }, - ], - billing_address: { - province: "California", - country_code: "US", - }, - }); - - const response = await fetch( - `${APP_DOMAIN_WITH_NGROK}/api/shopify/integration/webhook`, - { - method: "POST", - body: JSON.stringify(event), - headers: { - "x-shopify-topic": "orders/paid", - "x-shopify-shop-domain": "dub-conversions.myshopify.com", - }, - }, - ); - - console.log(response.ok); -} - -main(); diff --git a/apps/web/scripts/dub-partner-rewind.ts b/apps/web/scripts/dub-partner-rewind.ts index defb4af0bd7..e2391a213f0 100644 --- a/apps/web/scripts/dub-partner-rewind.ts +++ b/apps/web/scripts/dub-partner-rewind.ts @@ -1,4 +1,4 @@ -import { EXCLUDED_PROGRAM_IDS } from "@/lib/constants/partner-profile"; +import { PARTNER_NETWORK_EXCLUDED_PROGRAM_IDS } from "@/lib/constants/partner-profile"; import { prisma } from "@/lib/prisma"; import { chunk, toCentsNumber } from "@dub/utils"; import "dotenv-flow/config"; @@ -10,7 +10,7 @@ async function main() { by: ["partnerId"], where: { programId: { - notIn: EXCLUDED_PROGRAM_IDS, + notIn: PARTNER_NETWORK_EXCLUDED_PROGRAM_IDS, }, totalCommissions: { gte: REWIND_EARNINGS_MINIMUM, diff --git a/apps/web/scripts/fix-broken-partner-users.ts b/apps/web/scripts/fix-broken-partner-users.ts index c22e06dc546..80d18b4503d 100644 --- a/apps/web/scripts/fix-broken-partner-users.ts +++ b/apps/web/scripts/fix-broken-partner-users.ts @@ -6,7 +6,7 @@ async function main() { while (true) { const partnerUserIds = await prisma.partnerUser.findMany({ select: { - userId: true, + partnerId: true, }, take: 5000, skip: batch * 5000, @@ -14,27 +14,43 @@ async function main() { if (partnerUserIds.length === 0) { break; } - const users = await prisma.user.findMany({ + const partners = await prisma.partner.findMany({ where: { id: { - in: partnerUserIds.map((partnerUser) => partnerUser.userId), + in: partnerUserIds.map((partnerUser) => partnerUser.partnerId), }, }, }); - const usersThatDontExist = partnerUserIds.filter( - (partnerUser) => !users.some((user) => user.id === partnerUser.userId), + const partnersThatDontExist = partnerUserIds.filter( + (partnerUser) => + !partners.some((partner) => partner.id === partnerUser.partnerId), ); - console.log(usersThatDontExist); + console.log(partnersThatDontExist); - if (usersThatDontExist.length > 0) { + if (partnersThatDontExist.length > 0) { const deletedPartnerUsers = await prisma.partnerUser.deleteMany({ where: { - userId: { - in: usersThatDontExist.map((partnerUser) => partnerUser.userId), + partnerId: { + in: partnersThatDontExist.map( + (partnerUser) => partnerUser.partnerId, + ), }, }, }); console.log(`Deleted ${deletedPartnerUsers.count} partner users`); + const updatedUsers = await prisma.user.updateMany({ + where: { + defaultPartnerId: { + in: partnersThatDontExist.map( + (partnerUser) => partnerUser.partnerId, + ), + }, + }, + data: { + defaultPartnerId: null, + }, + }); + console.log(`Reset defaultPartnerId for ${updatedUsers.count} users`); } batch++; } diff --git a/apps/web/scripts/migrations/backfill-commissions-metadata.ts b/apps/web/scripts/migrations/backfill-commissions-metadata.ts new file mode 100644 index 00000000000..5df04ae2972 --- /dev/null +++ b/apps/web/scripts/migrations/backfill-commissions-metadata.ts @@ -0,0 +1,256 @@ +import "dotenv-flow/config"; + +import { prisma } from "@/lib/prisma"; +import { tb } from "@/lib/tinybird/client"; +import { CommissionType, Prisma } from "@prisma/client"; +import * as z from "zod/v4"; + +/* +Pipe name: internal_get_events_metadata + +Create this once in Tinybird before running: + +SELECT event_id, metadata +FROM dub_lead_events_mv +WHERE event_id IN {{ Array(eventIds, 'String') }} + AND metadata != '' +UNION ALL +SELECT event_id, metadata +FROM dub_sale_events_mv +WHERE event_id IN {{ Array(eventIds, 'String') }} + AND metadata != '' +*/ + +const DRY_RUN = true; +const BATCH_SIZE = 1000; +const THROTTLE_MS = 1000; +const LAST_CURSOR_ID: string | null = null; // Paste the last printed cursor id here to resume after a crash. +const USER_METADATA_MAX_CHARS = 10_000; // Matches metadataSchema in lib/zod/schemas/misc.ts + +const getEventsMetadata = tb.buildPipe({ + pipe: "internal_get_events_metadata", + parameters: z.object({ + eventIds: z.string().array(), + }), + data: z.object({ + event_id: z.string(), + metadata: z.string(), + }), +}); + +async function sleep(ms: number) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +// Tinybird event metadata is not always user-provided. Stripe/Shopify/importers +// store full internal payloads; Commission.metadata is a public API field. +// Match nested resource shapes (not just key names) to avoid false positives +// from coincidental user metadata like { invoice: "in_123" }. +function looksLikeInternalEventPayload(metadata: Record) { + // Stripe webhook: { invoice } or { checkoutSession } — nested Stripe objects + if ( + isPlainObject(metadata.invoice) || + isPlainObject(metadata.checkoutSession) + ) { + return true; + } + + // Shopify order body + if ( + typeof metadata.checkout_token === "string" && + typeof metadata.confirmation_number === "string" && + isPlainObject(metadata.current_subtotal_price_set) + ) { + return true; + } + + // Skip Rewardful/Tapfiliate/Tolt/PartnerStack fingerprints: oversized dumps + // are already dropped by USER_METADATA_MAX_CHARS, and small payloads are + // allowed through (avoids rejecting coincidental user keys). + + return false; +} + +function parseUserProvidedMetadata( + raw: string, +): Record | null { + if (raw.length > USER_METADATA_MAX_CHARS) { + return null; + } + + const parsed: unknown = JSON.parse(raw); + + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + + const metadata = parsed as Record; + + if ( + Object.keys(metadata).length === 0 || + looksLikeInternalEventPayload(metadata) + ) { + return null; + } + + return metadata; +} + +async function main() { + console.log( + `DRY_RUN=${DRY_RUN} BATCH_SIZE=${BATCH_SIZE} THROTTLE_MS=${THROTTLE_MS}`, + ); + + let startingAfter = LAST_CURSOR_ID ?? undefined; + if (startingAfter) { + console.log(`Resuming from ${startingAfter}`); + } + + let totalScanned = 0; + let totalUpdated = 0; + let totalSkipped = 0; + let totalErrors = 0; + + while (true) { + const commissions = await prisma.commission.findMany({ + where: { + type: { + in: [CommissionType.lead, CommissionType.sale], + }, + eventId: { + not: null, + }, + metadata: { + equals: Prisma.DbNull, + }, + ...(startingAfter && { + id: { + gt: startingAfter, + }, + }), + }, + select: { + id: true, + eventId: true, + type: true, + }, + take: BATCH_SIZE, + orderBy: { + id: "asc", + }, + }); + + if (commissions.length === 0) { + break; + } + + totalScanned += commissions.length; + + const eventIds = commissions + .map((c) => c.eventId) + .filter((id): id is string => Boolean(id)); + + const { data: tbRows } = await getEventsMetadata({ + eventIds, + }); + const metadataByEventId = new Map( + tbRows.map((row) => [row.event_id, row.metadata]), + ); + + const updates: { id: string; metadata: Record }[] = []; + let batchSkipped = 0; + let batchErrors = 0; + + for (const commission of commissions) { + const raw = commission.eventId + ? metadataByEventId.get(commission.eventId) + : undefined; + + if (!raw) { + batchSkipped++; + continue; + } + + try { + const metadata = parseUserProvidedMetadata(raw); + if (!metadata) { + batchSkipped++; + continue; + } + + updates.push({ id: commission.id, metadata }); + } catch (error) { + batchErrors++; + console.error( + `Failed to parse metadata for commission ${commission.id} eventId=${commission.eventId}`, + error, + ); + } + } + + totalSkipped += batchSkipped; + totalErrors += batchErrors; + + if (DRY_RUN) { + console.table( + updates.slice(0, 10).map((u) => { + const commission = commissions.find((c) => c.id === u.id)!; + return { + id: u.id, + eventId: commission.eventId, + type: commission.type, + metadata: u.metadata, + }; + }), + ); + totalUpdated += updates.length; + console.log( + `Batch: scanned=${commissions.length} would-update=${updates.length} skipped=${batchSkipped} errors=${batchErrors}`, + ); + } else if (updates.length > 0) { + const updatedCount = await prisma.$executeRaw` + UPDATE Commission + SET + metadata = CASE id + ${Prisma.join( + updates.map( + (u) => + Prisma.sql`WHEN ${u.id} THEN ${JSON.stringify(u.metadata)}`, + ), + " ", + )} + END + WHERE id IN (${Prisma.join(updates.map((u) => u.id))}) + AND metadata IS NULL + `; + + totalUpdated += Number(updatedCount); + console.log( + `Batch: scanned=${commissions.length} updated=${updatedCount} skipped=${batchSkipped} errors=${batchErrors}`, + ); + } else { + console.log( + `Batch: scanned=${commissions.length} updated=0 skipped=${batchSkipped} errors=${batchErrors}`, + ); + } + + startingAfter = commissions[commissions.length - 1].id; + console.log(`last cursor: ${startingAfter}`); + + if (commissions.length < BATCH_SIZE) { + break; + } + + await sleep(THROTTLE_MS); + } + + console.log( + `Finished. scanned=${totalScanned} ${DRY_RUN ? "would-update" : "updated"}=${totalUpdated} skipped=${totalSkipped} errors=${totalErrors}`, + ); +} + +main(); diff --git a/apps/web/scripts/misc/restore-program-enrollments.ts b/apps/web/scripts/misc/restore-program-enrollments.ts index f3059816156..7a72a839de1 100644 --- a/apps/web/scripts/misc/restore-program-enrollments.ts +++ b/apps/web/scripts/misc/restore-program-enrollments.ts @@ -64,7 +64,7 @@ async function main() { if (commissionsToRestore.length > 0) { const { count: createdCommissionsCount } = await prisma.commission.createMany({ - data: commissionsToRestore, + data: commissionsToRestore as never, skipDuplicates: true, }); console.log(`Restored ${createdCommissionsCount} commissions`); diff --git a/apps/web/scripts/partners/backfill-partner-search-args.ts b/apps/web/scripts/partners/backfill-partner-search-args.ts new file mode 100644 index 00000000000..c57ff8f091b --- /dev/null +++ b/apps/web/scripts/partners/backfill-partner-search-args.ts @@ -0,0 +1,84 @@ +import { parsePositiveInteger } from "@/scripts/utils/parse-cli-number"; + +export const DEFAULT_BATCH_SIZE = 500; +export const MAX_BATCH_SIZE = 1_000; + +export interface BackfillArguments { + programId?: string; + all: boolean; + batchSize: number; + after?: string; + afterProgram?: string; +} + +/** + * Kept out of the script itself so the guardrails can be tested without the + * module executing a backfill on import. + */ +export function parseBackfillArguments(args: string[]): BackfillArguments { + let programId: string | undefined; + let all = false; + let batchSize = DEFAULT_BATCH_SIZE; + let after: string | undefined; + let afterProgram: string | undefined; + + for (const arg of args) { + if (arg.startsWith("--programId=")) { + programId = arg.slice("--programId=".length); + } else if (arg === "--all") { + all = true; + } else if (arg.startsWith("--batchSize=")) { + batchSize = parsePositiveInteger( + arg.slice("--batchSize=".length), + "--batchSize", + ); + } else if (arg.startsWith("--after=")) { + after = arg.slice("--after=".length); + } else if (arg.startsWith("--afterProgram=")) { + afterProgram = arg.slice("--afterProgram=".length); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + // Indexing every program is ~1.6M writes against whichever namespace the + // configured key points at, so it has to be asked for rather than defaulted + // into by running the script bare. + if (!programId && !all) { + throw new Error( + "Pass --programId= for one program, or --all for every program.", + ); + } + + if (programId && all) { + throw new Error("--programId and --all are mutually exclusive."); + } + + if (programId === "") { + throw new Error("--programId cannot be empty."); + } + + if (batchSize > MAX_BATCH_SIZE) { + throw new Error(`--batchSize cannot exceed ${MAX_BATCH_SIZE}.`); + } + + if (after === "") { + throw new Error("--after cannot be empty."); + } + + if (afterProgram === "") { + throw new Error("--afterProgram cannot be empty."); + } + + if (afterProgram && !all) { + throw new Error("--afterProgram only applies to --all runs."); + } + + // Without the program it belongs to, an enrollment cursor says nothing about + // where an --all run stopped. + if (all && after && !afterProgram) { + throw new Error("--after requires --afterProgram on an --all run."); + } + + return { programId, all, batchSize, after, afterProgram }; +} diff --git a/apps/web/scripts/partners/backfill-partner-search.ts b/apps/web/scripts/partners/backfill-partner-search.ts new file mode 100644 index 00000000000..ea0f0aea5d8 --- /dev/null +++ b/apps/web/scripts/partners/backfill-partner-search.ts @@ -0,0 +1,272 @@ +/** + * Indexes partner enrollments into the configured search provider. + * + * Runs one program or every program. Pages by enrollment ID rather than offset, + * so per-batch cost stays flat across a 100K-partner program, and iterates + * programs in ID order so a resumed run picks up exactly where it stopped. + * + * Programs are the outer loop rather than one cursor over the whole table. A + * single global cursor would be simpler, but it leaves every program partially + * indexed until the entire run finishes, so no program is ever in a state worth + * trusting. Per-program means each finished program is genuinely done. + * + * The backfill only upserts. Documents whose enrollment was deleted are removed + * by rebuilding the index wholesale, not incrementally. Delete it and backfill + * again. + * + * cd apps/web + * pnpm run script partners/backfill-partner-search --programId=prog_123 + * [--batchSize=500] [--after=pge_123] + * + * pnpm run script partners/backfill-partner-search --all + * [--batchSize=500] [--afterProgram=prog_123 --after=pge_123] + * + * Requires TURBOPUFFER_API_KEY to be configured. + */ + +import { + backfillPartnerSearch, + getPartnerSearchProvider, + type PartnerSearchBackfillProgress, +} from "@/lib/api/partners/search"; +import { prisma } from "@/lib/prisma"; +import { parseBackfillArguments } from "@/scripts/partners/backfill-partner-search-args"; +import "dotenv-flow/config"; + +const PROGRAM_PAGE_SIZE = 1_000; + +/** + * Every program ID in ID order, paged so a large account does not load them all + * in one query. Starts at `afterProgram` inclusive, because that program is the + * one a resumed run left partway through. + */ +async function* iterateProgramIds(afterProgram?: string) { + let cursor = afterProgram; + let inclusive = Boolean(afterProgram); + + while (true) { + const programs = await prisma.program.findMany({ + where: cursor ? { id: inclusive ? { gte: cursor } : { gt: cursor } } : {}, + select: { id: true }, + orderBy: { id: "asc" }, + take: PROGRAM_PAGE_SIZE, + }); + + if (programs.length === 0) { + return; + } + + for (const { id } of programs) { + yield id; + } + + cursor = programs[programs.length - 1].id; + inclusive = false; + + if (programs.length < PROGRAM_PAGE_SIZE) { + return; + } + } +} + +// Tracks where to resume from, so a failure can print a command that skips +// everything already indexed rather than starting the run over. +const resumeState: { programId?: string; after?: string } = {}; + +function createProgressReporter({ + programId, + totalDocuments, + batchSize, + label, +}: { + programId: string; + totalDocuments: number | null; + batchSize: number; + label: string; +}) { + const totalChunks = + totalDocuments === null + ? null + : Math.max(1, Math.ceil(totalDocuments / batchSize)); + const startTime = Date.now(); + let chunk = 0; + + return ({ processed, lastDocumentId }: PartnerSearchBackfillProgress) => { + resumeState.programId = programId; + resumeState.after = lastDocumentId; + chunk += 1; + + const elapsedSec = ((Date.now() - startTime) / 1000).toFixed(1); + + // `totalDocuments` is a snapshot from before the run, so enrollments + // written while it pages can push the final chunk past it, so the percentage + // is clamped rather than reporting >100%. + const progress = + totalDocuments === null + ? `chunk ${chunk}` + : `chunk ${chunk}/${totalChunks} (${Math.min( + 100, + totalDocuments > 0 ? (processed / totalDocuments) * 100 : 100, + ).toFixed(0)}%)`; + + console.log( + `${label} ${progress}: ${processed.toLocaleString()} indexed, through ${lastDocumentId} (${elapsedSec}s)`, + ); + }; +} + +async function backfillProgram({ + programId, + batchSize, + after, + label, + countDocuments, +}: { + programId: string; + batchSize: number; + after?: string; + label: string; + countDocuments: boolean; +}) { + // Skipped on --all runs: a count per program doubles the query count across + // thousands of programs to buy a percentage nobody is watching that closely. + const totalDocuments = countDocuments + ? await prisma.programEnrollment.count({ + where: { + programId, + ...(after && { id: { gt: after } }), + }, + }) + : null; + + if (totalDocuments !== null) { + console.log(`${totalDocuments.toLocaleString()} enrollments to index\n`); + } + + const { processed } = await backfillPartnerSearch({ + programId, + batchSize, + after, + onProgress: createProgressReporter({ + programId, + totalDocuments, + batchSize, + label, + }), + }); + + return processed; +} + +async function main() { + const { programId, all, batchSize, after, afterProgram } = + parseBackfillArguments(process.argv.slice(2)); + + if (!getPartnerSearchProvider()) { + throw new Error("TURBOPUFFER_API_KEY is not configured."); + } + + const startTime = Date.now(); + + if (programId) { + resumeState.programId = programId; + resumeState.after = after; + + console.log(`Starting partner search backfill for program ${programId}`); + console.log( + `Batch size: ${batchSize.toLocaleString()}${after ? `, resuming after ${after}` : ""}`, + ); + + const processed = await backfillProgram({ + programId, + batchSize, + after, + label: ` [${programId}]`, + countDocuments: true, + }); + + console.log( + `\nPartner search backfill complete: ${processed.toLocaleString()} documents indexed.`, + ); + + return; + } + + console.log("Starting partner search backfill for every program"); + console.log( + `Batch size: ${batchSize.toLocaleString()}${ + afterProgram + ? `, resuming at program ${afterProgram}${after ? ` after ${after}` : ""}` + : "" + }\n`, + ); + + let programCount = 0; + let totalProcessed = 0; + + for await (const currentProgramId of iterateProgramIds(afterProgram)) { + programCount += 1; + + // The enrollment cursor belongs to the program the run stopped inside, so + // it applies to that one only. Every program after it starts from scratch. + const programAfter = currentProgramId === afterProgram ? after : undefined; + + resumeState.programId = currentProgramId; + resumeState.after = programAfter; + + const processed = await backfillProgram({ + programId: currentProgramId, + batchSize, + after: programAfter, + label: ` [${programCount}] ${currentProgramId}`, + countDocuments: false, + }); + + totalProcessed += processed; + + console.log( + `[${programCount}] ${currentProgramId}: ${processed.toLocaleString()} indexed (${totalProcessed.toLocaleString()} total)`, + ); + } + + const elapsedMin = ((Date.now() - startTime) / 60_000).toFixed(1); + console.log( + `\nPartner search backfill complete: ${totalProcessed.toLocaleString()} documents across ${programCount.toLocaleString()} programs in ${elapsedMin} minutes.`, + ); +} + +main() + .catch((error) => { + console.error("Partner search backfill failed:", error); + + if (resumeState.programId) { + const args = process.argv + .slice(2) + .filter( + (arg) => + !arg.startsWith("--after=") && !arg.startsWith("--afterProgram="), + ); + + // An --all run needs both halves of the cursor. A single-program run + // already names its program, so it only needs the enrollment. + const resumeArgs = args.includes("--all") + ? [ + ...args, + `--afterProgram=${resumeState.programId}`, + ...(resumeState.after ? [`--after=${resumeState.after}`] : []), + ] + : [ + ...args, + ...(resumeState.after ? [`--after=${resumeState.after}`] : []), + ]; + + console.error( + `Resume with:\n pnpm run script partners/backfill-partner-search ${resumeArgs.join(" ")}`, + ); + } + + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/apps/web/scripts/partners/delete-partner-search-index.ts b/apps/web/scripts/partners/delete-partner-search-index.ts new file mode 100644 index 00000000000..711bd02a09e --- /dev/null +++ b/apps/web/scripts/partners/delete-partner-search-index.ts @@ -0,0 +1,64 @@ +/** + * Empties a partner search namespace. + * + * Takes the namespace explicitly rather than reading the one the code writes to, + * so retiring an old version after a migration is possible, and requires it + * twice, because this is not recoverable without a backfill. + * + * cd apps/web + * pnpm run script partners/delete-partner-search-index + * --indexName=partner-search-v2 --confirm=partner-search-v2 + * + * Requires TURBOPUFFER_API_KEY. + */ + +import { deleteTurbopufferPartnerSearchNamespace } from "@/lib/api/partners/search/providers/turbopuffer"; +import "dotenv-flow/config"; + +interface DeletePartnerSearchIndexArguments { + indexName: string; +} + +function parseArguments(args: string[]): DeletePartnerSearchIndexArguments { + let indexName: string | undefined; + let confirm: string | undefined; + + for (const arg of args) { + if (arg.startsWith("--indexName=")) { + indexName = arg.slice("--indexName=".length); + } else if (arg.startsWith("--confirm=")) { + confirm = arg.slice("--confirm=".length); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + if (!indexName || !/^[a-zA-Z0-9_-]{1,100}$/.test(indexName)) { + throw new Error( + "--indexName must contain 1-100 letters, numbers, underscores, or hyphens.", + ); + } + + if (confirm !== indexName) { + throw new Error(`Pass --confirm=${indexName} to confirm deletion.`); + } + + return { indexName }; +} + +async function main() { + const { indexName } = parseArguments(process.argv.slice(2)); + + if (!process.env.TURBOPUFFER_API_KEY?.trim()) { + throw new Error("TURBOPUFFER_API_KEY is not configured."); + } + + await deleteTurbopufferPartnerSearchNamespace({ namespaceName: indexName }); + + console.log(`Partner search cleanup complete: emptied ${indexName}.`); +} + +main().catch((error) => { + console.error("Failed to delete partner search index:", error); + process.exitCode = 1; +}); diff --git a/apps/web/scripts/send-batch-emails.ts b/apps/web/scripts/send-batch-emails.ts index 0f8d8b39175..87ecc848596 100644 --- a/apps/web/scripts/send-batch-emails.ts +++ b/apps/web/scripts/send-batch-emails.ts @@ -1,4 +1,4 @@ -import { EXCLUDED_PROGRAM_IDS } from "@/lib/constants/partner-profile"; +import { PARTNER_NETWORK_EXCLUDED_PROGRAM_IDS } from "@/lib/constants/partner-profile"; import { prisma } from "@/lib/prisma"; import DubLaunchWeekDay5 from "@dub/email/templates/broadcasts/launch-week-day-5"; import { chunk } from "@dub/utils"; @@ -31,7 +31,7 @@ async function main() { programs: { where: { programId: { - in: EXCLUDED_PROGRAM_IDS, + in: PARTNER_NETWORK_EXCLUDED_PROGRAM_IDS, }, }, }, diff --git a/apps/web/scripts/stripe/fix-processed-payouts.ts b/apps/web/scripts/stripe/fix-processed-payouts.ts deleted file mode 100644 index f8367b7a2ca..00000000000 --- a/apps/web/scripts/stripe/fix-processed-payouts.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { prisma } from "@/lib/prisma"; -import "dotenv-flow/config"; -import { stripeConnectClient } from "./connect-client"; - -async function main() { - const partnersWithProcessingPayouts = await prisma.partner.findMany({ - where: { - payouts: { - some: { - status: "processing", - stripeTransferId: null, - invoice: { - status: "completed", - }, - }, - }, - }, - }); - - console.log( - `Found ${partnersWithProcessingPayouts.length} partners with processing payouts`, - ); - - const results = await Promise.all( - partnersWithProcessingPayouts.map(async (partner) => { - try { - const stripeConnectAccount = - await stripeConnectClient.accounts.retrieve(partner.stripeConnectId!); - return { - partnerId: partner.id, - email: partner.email, - stripeConnectId: partner.stripeConnectId, - payoutsEnabledAt: partner.payoutsEnabledAt, - actualPayoutsEnabled: stripeConnectAccount.payouts_enabled, - transfersEnabled: stripeConnectAccount.capabilities?.transfers, - transfersEnabledStatus: stripeConnectAccount.capabilities?.transfers, - }; - } catch (error) { - return null; - } - }), - ); - - console.table(results.filter((result) => result !== null)); -} - -main(); diff --git a/apps/web/scripts/stripe/fix-processing-payouts.ts b/apps/web/scripts/stripe/fix-processing-payouts.ts new file mode 100644 index 00000000000..9d353a79c93 --- /dev/null +++ b/apps/web/scripts/stripe/fix-processing-payouts.ts @@ -0,0 +1,42 @@ +import { prisma } from "@/lib/prisma"; +import "dotenv-flow/config"; + +async function main() { + const payoutsStuckInProcessing = await prisma.payout.findMany({ + where: { + method: "stablecoin", + status: "processing", + stripeTransferId: null, + invoice: { + status: "completed", + // paid over 7 days ago + paidAt: { + lt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + }, + }, + }, + include: { + partner: true, + invoice: true, + }, + }); + + console.log( + `Found ${payoutsStuckInProcessing.length} payouts stuck in processing`, + ); + + console.table( + payoutsStuckInProcessing.map(({ partner, invoice, ...payout }) => ({ + partnerId: partner.id, + partnerEmail: partner.email, + payoutId: payout.id, + payoutAmount: payout.amount, + paidAt: invoice?.paidAt, + stripeChargeId: invoice?.stripeChargeMetadata?.["id"], + })), + ); + + // await markPayoutsAsProcessed(payoutsStuckInProcessing); +} + +main(); diff --git a/apps/web/scripts/utils/parse-cli-number.ts b/apps/web/scripts/utils/parse-cli-number.ts new file mode 100644 index 00000000000..0cef2f84bec --- /dev/null +++ b/apps/web/scripts/utils/parse-cli-number.ts @@ -0,0 +1,43 @@ +const DIGITS_ONLY = /^\d+$/; + +/** + * Parses a CLI flag value as an integer of at least `minimum`. + * + * The digits-only check has to run before `Number()`, which on its own would + * silently accept hex (`0x10` → 16), scientific notation (`1e9` → 1000000000), + * decimals (`5.0` → 5), signs (`+5` → 5), and surrounding whitespace. A flag + * like `--count=1e9` should be a typo the operator hears about, not a billion + * rows. `Number.isSafeInteger` then rejects digit strings past 2^53 - 1, which + * cannot round-trip. + */ +function parseInteger( + value: string | undefined, + flag: string, + minimum: number, + expected: string, +) { + const parsed = + value !== undefined && DIGITS_ONLY.test(value) ? Number(value) : NaN; + + if (!Number.isSafeInteger(parsed) || parsed < minimum) { + throw new Error( + `${flag} must be ${expected}, received: ${ + value === undefined ? "(missing)" : `"${value}"` + }`, + ); + } + + return parsed; +} + +export function parsePositiveInteger(value: string | undefined, flag: string) { + return parseInteger(value, flag, 1, "a positive integer"); +} + +/** For flags where zero is meaningful, such as disabling benchmark warm-up. */ +export function parseNonNegativeInteger( + value: string | undefined, + flag: string, +) { + return parseInteger(value, flag, 0, "a non-negative integer"); +} diff --git a/apps/web/tests/analytics/advanced-filter-helpers.test.ts b/apps/web/tests/analytics/advanced-filter-helpers.test.ts index 90134467c90..ad4b5b5ee00 100644 --- a/apps/web/tests/analytics/advanced-filter-helpers.test.ts +++ b/apps/web/tests/analytics/advanced-filter-helpers.test.ts @@ -294,6 +294,23 @@ describe("Advanced Filters - Unit Tests", () => { ]); }); + test("eventName field", () => { + const result = buildAdvancedFilters({ + eventName: { + operator: "IS_ONE_OF", + sqlOperator: "IN", + values: ["Sign up", "Purchase"], + }, + }); + expect(result).toEqual([ + { + field: "eventName", + operator: "IN", + values: ["Sign up", "Purchase"], + }, + ]); + }); + test("maintains insertion order", () => { const result = buildAdvancedFilters({ device: { operator: "IS", sqlOperator: "IN", values: ["Mobile"] }, diff --git a/apps/web/tests/analytics/export-analytics-to-zip.test.ts b/apps/web/tests/analytics/export-analytics-to-zip.test.ts index 46e2de96586..71d3bdbd308 100644 --- a/apps/web/tests/analytics/export-analytics-to-zip.test.ts +++ b/apps/web/tests/analytics/export-analytics-to-zip.test.ts @@ -96,6 +96,7 @@ describe("export-analytics-to-zip", () => { "os", "trigger", "triggers", + "event_names", "referers", "referer_urls", "top_folders", @@ -144,6 +145,7 @@ describe("export-analytics-to-zip", () => { "os", "trigger", "triggers", + "event_names", "referers", "referer_urls", "top_folders", diff --git a/apps/web/tests/analytics/verify-installation.test.ts b/apps/web/tests/analytics/verify-installation.test.ts new file mode 100644 index 00000000000..628c902ee8e --- /dev/null +++ b/apps/web/tests/analytics/verify-installation.test.ts @@ -0,0 +1,79 @@ +import { analyzeDubAnalyticsScript } from "@/lib/analytics/verify-installation"; +import { describe, expect, it } from "vitest"; + +const pageWithScript = (attrs: string) => + ``; + +describe("analyzeDubAnalyticsScript", () => { + it("passes a basic Dub script without a required refer domain", () => { + expect(analyzeDubAnalyticsScript(pageWithScript("defer"))).toBe("ok"); + }); + + it("requires data-domains.refer when a program domain is provided", () => { + expect( + analyzeDubAnalyticsScript(pageWithScript("defer"), { + referDomain: "refer.acme.com", + }), + ).toBe("missing_refer_domain"); + }); + + it("accepts a matching data-domains refer value", () => { + expect( + analyzeDubAnalyticsScript( + pageWithScript(`defer data-domains='{"refer":"refer.acme.com"}'`), + { referDomain: "refer.acme.com" }, + ), + ).toBe("ok"); + }); + + it("accepts HTML-encoded data-domains JSON", () => { + expect( + analyzeDubAnalyticsScript( + pageWithScript( + `defer data-domains="{"refer":"refer.acme.com"}"`, + ), + { referDomain: "refer.acme.com" }, + ), + ).toBe("ok"); + }); + + it("accepts data-domains with additional keys", () => { + expect( + analyzeDubAnalyticsScript( + pageWithScript( + `defer data-domains='{"refer":"refer.acme.com","site":"site.acme.com"}'`, + ), + { referDomain: "refer.acme.com" }, + ), + ).toBe("ok"); + }); + + it("rejects a data-domains refer value that does not match the program domain", () => { + expect( + analyzeDubAnalyticsScript( + pageWithScript(`defer data-domains='{"refer":"other.link"}'`), + { referDomain: "refer.acme.com" }, + ), + ).toBe("missing_refer_domain"); + }); + + it("normalizes protocol and casing when comparing refer domains", () => { + expect( + analyzeDubAnalyticsScript( + pageWithScript( + `defer data-domains='{"refer":"https://Refer.Acme.com/"}'`, + ), + { referDomain: "refer.acme.com" }, + ), + ).toBe("ok"); + }); + + it("accepts the legacy data-short-domain attribute", () => { + expect( + analyzeDubAnalyticsScript( + pageWithScript(`defer data-short-domain="refer.acme.com"`), + { referDomain: "refer.acme.com" }, + ), + ).toBe("ok"); + }); +}); diff --git a/apps/web/tests/campaigns/index.test.ts b/apps/web/tests/campaigns/index.test.ts deleted file mode 100644 index 4fed75034f2..00000000000 --- a/apps/web/tests/campaigns/index.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { Campaign, CampaignList } from "@/lib/types"; -import { updateCampaignSchema } from "@/lib/zod/schemas/campaigns"; -import { E2E_PARTNER_GROUP } from "tests/utils/resource"; -import { afterAll, describe, expect, test } from "vitest"; -import * as z from "zod/v4"; -import { IntegrationHarness } from "../utils/integration"; - -const campaign: z.infer = { - name: "Updated Test Campaign", - subject: "Updated Test Subject", - triggerConditions: [ - { - attribute: "totalConversions", - operator: "gte", - value: 50, - }, - ], - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - text: "Test campaign body", - }, - ], - }, - ], - }, -}; - -const expectedCampaign: Partial = { - ...campaign, - type: "transactional", - status: expect.any(String), - preview: null, - from: null, - scheduledAt: null, - groups: [{ id: E2E_PARTNER_GROUP.id }], - partnerTags: [], - createdAt: expect.any(String), - updatedAt: expect.any(String), -}; - -describe.sequential("/campaigns/**", async () => { - const h = new IntegrationHarness(); - const { http } = await h.init(); - - let campaignId = ""; - const createdCampaignIds: string[] = []; - - afterAll(async () => { - await Promise.all(createdCampaignIds.map((id) => h.deleteCampaign(id))); - }); - - test("POST /campaigns - create draft campaign", async () => { - const { status, data } = await http.post<{ id: string }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - if (data?.id) { - campaignId = data.id; - createdCampaignIds.push(data.id); - } - - expect(status).toEqual(201); - expect(data).toMatchObject({ - id: expect.any(String), - }); - }); - - test("PATCH /campaigns/[campaignId] - update campaign content", async () => { - const { status, data: updatedCampaign } = await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - ...campaign, - groupIds: [E2E_PARTNER_GROUP.id], - }, - }); - - expect(status).toEqual(200); - expect(updatedCampaign).toStrictEqual({ - ...expectedCampaign, - id: campaignId, - status: "draft", - }); - }); - - test("GET /campaigns/[campaignId] - make sure the draft campaign is created", async () => { - const { status, data: fetchedCampaign } = await http.get({ - path: `/campaigns/${campaignId}`, - }); - - expect(status).toEqual(200); - expect(fetchedCampaign).toStrictEqual({ - ...expectedCampaign, - id: campaignId, - status: "draft", - }); - }); - - test("PATCH /campaigns/[campaignId] - invalid partner tag IDs", async () => { - const { status, data } = await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - partnerTagIds: ["invalid-partner-tag-id"], - }, - }); - - expect(status).toEqual(400); - expect(data).toMatchObject({ - error: { - message: "Invalid partner tag IDs detected: invalid-partner-tag-id", - code: "bad_request", - }, - }); - }); - - test("PATCH /campaigns/[campaignId] - clear partner tags", async () => { - const { status, data: updatedCampaign } = await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - partnerTagIds: null, - }, - }); - - expect(status).toEqual(200); - expect(updatedCampaign).toStrictEqual({ - ...expectedCampaign, - id: campaignId, - status: "draft", - partnerTags: [], - }); - }); - - test("PATCH /campaigns/[campaignId] - publish campaign", async () => { - const { status, data: publishedCampaign } = await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - status: "active", - }, - }); - - expect(status).toEqual(200); - expect(publishedCampaign).toStrictEqual({ - ...expectedCampaign, - id: campaignId, - status: "active", - }); - }); - - test("PATCH /campaigns/[campaignId] - pause campaign", async () => { - const { status, data: pausedCampaign } = await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - status: "paused", - }, - }); - - expect(status).toEqual(200); - expect(pausedCampaign).toStrictEqual({ - ...expectedCampaign, - id: campaignId, - status: "paused", - }); - }); - - test("PATCH /campaigns/[campaignId] - resume campaign", async () => { - const { status, data: resumedCampaign } = await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - status: "active", - }, - }); - - expect(status).toEqual(200); - expect(resumedCampaign).toStrictEqual({ - ...expectedCampaign, - id: campaignId, - status: "active", - }); - }); - - test("POST /campaigns/[campaignId]/duplicate - duplicate campaign", async () => { - const { status, data } = await http.post<{ id: string }>({ - path: `/campaigns/${campaignId}/duplicate`, - }); - - if (data?.id) { - createdCampaignIds.push(data.id); - } - - expect(status).toEqual(200); - expect(data.id).toBeDefined(); - - const { data: duplicatedCampaign } = await http.get({ - path: `/campaigns/${data.id}`, - }); - - expect(duplicatedCampaign).toStrictEqual({ - ...expectedCampaign, - id: data.id, - name: `${expectedCampaign.name} (copy)`, - status: "draft", - }); - }); - - test("GET /campaigns - list campaigns", async () => { - const { status, data: campaigns } = await http.get({ - path: "/campaigns", - }); - - expect(status).toEqual(200); - expect(Array.isArray(campaigns)).toBe(true); - expect(campaigns.length).toBeGreaterThan(0); - - const campaign = campaigns.find((c) => c.id === campaignId); - - expect(campaign).toStrictEqual({ - ...expectedCampaign, - id: campaignId, - }); - }); - - test("GET /campaigns/[campaignId] - get single campaign", async () => { - const { status, data: fetchedCampaign } = await http.get({ - path: `/campaigns/${campaignId}`, - }); - - expect(status).toEqual(200); - expect(fetchedCampaign).toStrictEqual({ - ...expectedCampaign, - id: campaignId, - status: "active", - }); - }); - - test("DELETE /campaigns/[campaignId] - delete campaign", async () => { - const { status, data } = await http.delete<{ id: string }>({ - path: `/campaigns/${campaignId}`, - }); - - expect(status).toEqual(200); - expect(data).toStrictEqual({ - id: campaignId, - }); - }); -}); diff --git a/apps/web/tests/commissions/calculate-sale-earnings.test.ts b/apps/web/tests/commissions/calculate-sale-earnings.test.ts new file mode 100644 index 00000000000..0c9c149afaa --- /dev/null +++ b/apps/web/tests/commissions/calculate-sale-earnings.test.ts @@ -0,0 +1,162 @@ +import { calculateSaleEarnings } from "@/lib/api/sales/calculate-sale-earnings"; +import { describe, expect, test } from "vitest"; + +describe("calculateSaleEarnings", () => { + describe("percentage – same as the old truncate path", () => { + test.each([ + { + label: "whole cents: $10 sale at 20%", + saleAmount: 1000, + percent: 20, + expected: 200, + }, + { + label: "whole cents: $10 sale at 50%", + saleAmount: 1000, + percent: 50, + expected: 500, + }, + { + label: "whole cents: $19 sale at 10%", + saleAmount: 1900, + percent: 10, + expected: 190, + }, + { + label: "1.4¢ truncates and rounds to the same value", + saleAmount: 7, + percent: 20, + expected: 1, + }, + { + label: "0.4¢ stays 0 (below half a cent)", + saleAmount: 2, + percent: 20, + expected: 0, + }, + { + label: "zero sale amount", + saleAmount: 0, + percent: 20, + expected: 0, + }, + { + label: "zero percent", + saleAmount: 1000, + percent: 0, + expected: 0, + }, + ])("$label → $expected", ({ saleAmount, percent, expected }) => { + expect( + calculateSaleEarnings({ + reward: { + type: "percentage", + amountInCents: null, + amountInPercentage: percent, + }, + sale: { amount: saleAmount, quantity: 1 }, + }), + ).toBe(expected); + }); + }); + + describe("percentage – half-up (new vs old truncate)", () => { + test.each([ + { + label: "3¢ sale at 20% (ticket: 0.6¢, used to store 0)", + saleAmount: 3, + percent: 20, + expected: 1, + }, + { + label: "15¢ sale at 10% (1.5¢, used to store 1)", + saleAmount: 15, + percent: 10, + expected: 2, + }, + { + label: "1¢ sale at 50% (0.5¢, used to store 0)", + saleAmount: 1, + percent: 50, + expected: 1, + }, + { + label: "500¢ at 2.9% (14.5¢; float 2.9/100 used to round to 14)", + saleAmount: 500, + percent: 2.9, + expected: 15, + }, + ])("$label → $expected", ({ saleAmount, percent, expected }) => { + expect( + calculateSaleEarnings({ + reward: { + type: "percentage", + amountInCents: null, + amountInPercentage: percent, + }, + sale: { amount: saleAmount, quantity: 1 }, + }), + ).toBe(expected); + }); + }); + + test("percentage ignores quantity (uses sale amount only)", () => { + expect( + calculateSaleEarnings({ + reward: { + type: "percentage", + amountInCents: null, + amountInPercentage: 20, + }, + sale: { amount: 1000, quantity: 5 }, + }), + ).toBe(200); + }); + + describe("flat", () => { + test.each([ + { + label: "single sale", + amountInCents: 5000, + quantity: 1, + expected: 5000, + }, + { + label: "quantity multiplies the flat amount", + amountInCents: 500, + quantity: 2, + expected: 1000, + }, + { + label: "zero quantity", + amountInCents: 500, + quantity: 0, + expected: 0, + }, + ])("$label → $expected", ({ amountInCents, quantity, expected }) => { + expect( + calculateSaleEarnings({ + reward: { + type: "flat", + amountInCents, + amountInPercentage: null, + }, + sale: { amount: 1000, quantity }, + }), + ).toBe(expected); + }); + }); + + test("returns 0 when reward type is neither flat nor percentage", () => { + expect( + calculateSaleEarnings({ + reward: { + type: "unknown" as "flat", + amountInCents: 500, + amountInPercentage: 20, + }, + sale: { amount: 1000, quantity: 1 }, + }), + ).toBe(0); + }); +}); diff --git a/apps/web/tests/commissions/create-commission.test.ts b/apps/web/tests/commissions/create-commission.test.ts deleted file mode 100644 index 04aae324f07..00000000000 --- a/apps/web/tests/commissions/create-commission.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { CommissionResponse } from "@/lib/types"; -import { describe, expect, test } from "vitest"; -import { randomCustomer, randomId } from "../utils/helpers"; -import { IntegrationHarness } from "../utils/integration"; -import { - E2E_CUSTOMER_ID, - E2E_LEAD_REWARD, - E2E_PARTNER, -} from "../utils/resource"; -import { verifyCommission } from "../utils/verify-commission"; - -const expectedQueuedResponse = { - success: true, - message: "Your commissions are being created and will appear shortly.", -}; - -const validationCases = [ - { - name: "missing type field", - body: { partnerId: E2E_PARTNER.id, amount: 500 }, - expectedStatus: 422, - expectedMessage: "invalid_union: type: Invalid input", - }, - { - name: "invalid type value", - body: { type: "invalid", partnerId: E2E_PARTNER.id }, - expectedStatus: 422, - expectedMessage: "invalid_union: type: Invalid input", - }, - { - name: "custom commission with amount 0", - body: { type: "custom", partnerId: E2E_PARTNER.id, amount: 0 }, - expectedStatus: 422, - expectedMessage: "too_small: amount: Too small: expected number to be >=1", - }, - { - name: "sale commission missing saleAmount", - body: { - type: "sale", - partnerId: E2E_PARTNER.id, - customerId: E2E_CUSTOMER_ID, - importStripeInvoices: false, - }, - expectedStatus: 422, - expectedMessage: - "custom: saleAmount: `saleAmount` is required when `importStripeInvoices` is false.", - }, -]; - -validationCases.forEach(({ name, body, expectedStatus, expectedMessage }) => { - test(`POST /commissions - validation error: ${name}`, async (ctx) => { - const h = new IntegrationHarness(ctx); - const { http } = await h.init(); - const response = await http.post({ path: "/commissions", body }); - expect(response.status).toEqual(expectedStatus); - expect(response.data.error.code).toEqual("unprocessable_entity"); - expect(response.data.error.message).toEqual(expectedMessage); - }); -}); - -describe.concurrent("POST /commissions", async () => { - const h = new IntegrationHarness(); - const { http } = await h.init(); - - test("create custom commission with required fields", async () => { - const description = randomId(); - const { status, data } = await http.post({ - path: "/commissions", - body: { - type: "custom", - partnerId: E2E_PARTNER.id, - amount: 500, - description, - }, - }); - - expect(status).toEqual(202); - expect(data).toStrictEqual(expectedQueuedResponse); - - await verifyCommission({ - http, - description, - expectedEarnings: 500, - expectedType: "custom", - query: { - partnerId: E2E_PARTNER.id, - type: "custom", - sortBy: "createdAt", - sortOrder: "desc", - }, - }); - }); - - test("create lead commission", async () => { - const customer = randomCustomer(); - - const { status, data } = await http.post({ - path: "/commissions", - body: { - type: "lead", - partnerId: E2E_PARTNER.id, - customer: { - externalId: customer.externalId, - email: customer.email, - name: customer.name, - country: "US", - }, - }, - }); - - expect(status).toEqual(202); - expect(data).toStrictEqual(expectedQueuedResponse); - - await verifyCommission({ - http, - customerExternalId: customer.externalId, - expectedEarnings: E2E_LEAD_REWARD.modifiers[1].amountInCents, - }); - }); - - test("create sale commission with a new customer", async () => { - const invoiceId = `INV_${randomId()}`; - const customer = randomCustomer(); - - const { status, data } = await http.post({ - path: "/commissions", - body: { - type: "sale", - partnerId: E2E_PARTNER.id, - saleAmount: 1000, - invoiceId, - customer: { - externalId: customer.externalId, - email: customer.email, - name: customer.name, - country: "US", - }, - }, - }); - - expect(status).toEqual(202); - expect(data).toStrictEqual(expectedQueuedResponse); - - await verifyCommission({ - http, - invoiceId, - expectedSaleAmount: 1000, - expectedEarnings: 5000, // Earn $50 per sale for 3 months - }); - }); - - test("error when customer is not found", async () => { - const { status, data } = await http.post({ - path: "/commissions", - body: { - type: "sale", - partnerId: E2E_PARTNER.id, - customerId: "cus_nonexistent", - saleAmount: 1000, - }, - }); - expect(status).toEqual(404); - expect(data.error.code).toEqual("not_found"); - expect(data.error.message).toContain("not found"); - }); - - test("error when invoiceId already exists", async () => { - const { data: existingCommissions } = await http.get({ - path: "/commissions", - query: { - type: "sale", - sortBy: "createdAt", - sortOrder: "desc", - pageSize: "50", - }, - }); - - const commissionWithInvoice = existingCommissions.find( - (c) => c.invoiceId != null, - ); - - expect(commissionWithInvoice).toBeDefined(); - - const { status, data } = await http.post({ - path: "/commissions", - body: { - type: "sale", - partnerId: E2E_PARTNER.id, - saleAmount: 1000, - invoiceId: commissionWithInvoice!.invoiceId, - customerId: E2E_CUSTOMER_ID, - }, - }); - - expect(status).toEqual(409); - expect(data.error.code).toEqual("conflict"); - expect(data.error.message).toContain( - "There is already a commission for the invoice", - ); - }); -}); diff --git a/apps/web/tests/commissions/index.test.ts b/apps/web/tests/commissions/index.test.ts index cb6c927b4da..4ec0048f265 100644 --- a/apps/web/tests/commissions/index.test.ts +++ b/apps/web/tests/commissions/index.test.ts @@ -16,6 +16,23 @@ const expectedCommission = { customer: expect.any(Object), }; +function expectCommissionResponse( + commission: CommissionResponse, + overrides: Record = {}, +) { + expect(commission).toMatchObject({ + ...expectedCommission, + ...overrides, + }); + // metadata is nullable but must be present on workspace commission responses + expect(commission).toHaveProperty("metadata"); + expect( + commission.metadata === null || + (typeof commission.metadata === "object" && + !Array.isArray(commission.metadata)), + ).toBe(true); +} + describe.sequential("/commissions/**", async () => { const h = new IntegrationHarness(); const { http } = await h.init(); @@ -25,23 +42,41 @@ describe.sequential("/commissions/**", async () => { let testPaidCommissionId: string; test("GET /commissions", async () => { - const { status, data: commissions } = await http.get({ + const { status: saleCommissionStatus, data: saleCommissions } = + await http.get({ + path: "/commissions", + query: { + status: "processed", + type: "sale", + sortBy: "createdAt", + sortOrder: "desc", + }, + }); + + expect(saleCommissionStatus).toEqual(200); + expect(Array.isArray(saleCommissions)).toBe(true); + expect(saleCommissions.length).toBeGreaterThan(0); + expectCommissionResponse(saleCommissions[0]); + + const { status: leadStatus, data: leadCommissions } = await http.get< + CommissionResponse[] + >({ path: "/commissions", query: { status: "processed", + type: "lead", sortBy: "createdAt", sortOrder: "desc", }, }); - - expect(status).toEqual(200); - expect(Array.isArray(commissions)).toBe(true); - expect(commissions.length).toBeGreaterThan(0); - expect(commissions[0]).toMatchObject(expectedCommission); + expect(leadStatus).toEqual(200); + expect(Array.isArray(leadCommissions)).toBe(true); + expect(leadCommissions.length).toBeGreaterThan(0); + expectCommissionResponse(leadCommissions[0]); // Store the first sale and lead commission's ID for subsequent tests - testCommissionId = commissions.find((c) => c.type === "sale")!.id; - testLeadCommissionId = commissions.find((c) => c.type === "lead")!.id; + testCommissionId = saleCommissions[0].id; + testLeadCommissionId = leadCommissions[0].id; }); test("GET /commissions with filters", async () => { @@ -60,7 +95,7 @@ describe.sequential("/commissions/**", async () => { expect(paidStatus).toEqual(200); expect(Array.isArray(paidCommissions)).toBe(true); expect(paidCommissions.length).toBeGreaterThan(0); - expect(paidCommissions[0]).toMatchObject(expectedCommission); + expectCommissionResponse(paidCommissions[0]); testPaidCommissionId = paidCommissions[0].id; }); @@ -75,10 +110,7 @@ describe.sequential("/commissions/**", async () => { }); expect(status).toEqual(200); - expect(commission).toMatchObject({ - ...expectedCommission, - earnings: toUpdate.earnings, - }); + expectCommissionResponse(commission, { earnings: toUpdate.earnings }); }); test("PATCH /commissions/{id} - update saleAmount", async () => { @@ -92,10 +124,7 @@ describe.sequential("/commissions/**", async () => { }); expect(status).toEqual(200); - expect(commission).toMatchObject({ - ...expectedCommission, - amount: toUpdate.saleAmount, - }); + expectCommissionResponse(commission, { amount: toUpdate.saleAmount }); }); test("PATCH /commissions/{id} - modifySaleAmount", async () => { @@ -111,6 +140,7 @@ describe.sequential("/commissions/**", async () => { expect(status).toEqual(200); expect(commission.amount).toEqual(6000); + expectCommissionResponse(commission, { amount: 6000 }); }); test("PATCH /commissions/{id} - update amount (backward compatibility)", async () => { @@ -124,10 +154,7 @@ describe.sequential("/commissions/**", async () => { }); expect(status).toEqual(200); - expect(commission).toMatchObject({ - ...expectedCommission, - amount: toUpdate.amount, - }); + expectCommissionResponse(commission, { amount: toUpdate.amount }); }); test("PATCH /commissions/{id} - foreign currency conversion", async () => { @@ -145,6 +172,7 @@ describe.sequential("/commissions/**", async () => { expect(commission.currency).toEqual("usd"); expect(commission.amount).toBeGreaterThanOrEqual(900); // 900 cents expect(commission.amount).toBeLessThanOrEqual(1100); // 1100 cents + expectCommissionResponse(commission); }); test("PATCH /commissions/{id} - error on lead commission", async () => { @@ -186,9 +214,6 @@ describe.sequential("/commissions/**", async () => { }); expect(status).toEqual(200); - expect(commission).toMatchObject({ - ...expectedCommission, - status: toUpdate.status, - }); + expectCommissionResponse(commission, { status: toUpdate.status }); }); }); diff --git a/apps/web/tests/commissions/metadata-filters.test.ts b/apps/web/tests/commissions/metadata-filters.test.ts new file mode 100644 index 00000000000..b48592fff93 --- /dev/null +++ b/apps/web/tests/commissions/metadata-filters.test.ts @@ -0,0 +1,308 @@ +import { + buildCommissionMetadataWhere, + parseCommissionMetadataQuery, +} from "@/lib/api/commissions/metadata-filters"; +import { DubApiError } from "@/lib/api/errors"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +describe("parseCommissionMetadataQuery", () => { + it("returns undefined for absent, empty, or whitespace-only query", () => { + expect(parseCommissionMetadataQuery(undefined)).toBeUndefined(); + expect(parseCommissionMetadataQuery("")).toBeUndefined(); + expect(parseCommissionMetadataQuery(" ")).toBeUndefined(); + }); + + it("parses = operator", () => { + expect(parseCommissionMetadataQuery("metadata['plan']='pro'")).toEqual({ + logic: "AND", + filters: [{ key: "plan", op: "equals", value: "pro" }], + }); + }); + + it("parses : as equals", () => { + expect(parseCommissionMetadataQuery("metadata['plan']:pro")).toEqual({ + logic: "AND", + filters: [{ key: "plan", op: "equals", value: "pro" }], + }); + }); + + it("parses != operator", () => { + expect(parseCommissionMetadataQuery("metadata['plan']!='free'")).toEqual({ + logic: "AND", + filters: [{ key: "plan", op: "notEquals", value: "free" }], + }); + }); + + it("parses AND connective", () => { + expect( + parseCommissionMetadataQuery( + "metadata['plan']='pro' AND metadata['tier']='gold'", + ), + ).toEqual({ + logic: "AND", + filters: [ + { key: "plan", op: "equals", value: "pro" }, + { key: "tier", op: "equals", value: "gold" }, + ], + }); + }); + + it("parses OR connective (case-insensitive)", () => { + expect( + parseCommissionMetadataQuery( + "metadata['plan']='pro' or metadata['plan']='enterprise'", + ), + ).toEqual({ + logic: "OR", + filters: [ + { key: "plan", op: "equals", value: "pro" }, + { key: "plan", op: "equals", value: "enterprise" }, + ], + }); + }); + + it("keeps and/or inside quoted values as part of a single condition", () => { + expect( + parseCommissionMetadataQuery("metadata['name']='Smith and Jones'"), + ).toEqual({ + logic: "AND", + filters: [{ key: "name", op: "equals", value: "Smith and Jones" }], + }); + expect(parseCommissionMetadataQuery("metadata['label']=\"A or B\"")).toEqual( + { + logic: "AND", + filters: [{ key: "label", op: "equals", value: "A or B" }], + }, + ); + }); + + it("parses AND/OR when a quoted value contains and/or", () => { + expect( + parseCommissionMetadataQuery( + "metadata['name']='Smith and Jones' AND metadata['plan']='pro'", + ), + ).toEqual({ + logic: "AND", + filters: [ + { key: "name", op: "equals", value: "Smith and Jones" }, + { key: "plan", op: "equals", value: "pro" }, + ], + }); + expect( + parseCommissionMetadataQuery( + "metadata['label']=\"A or B\" OR metadata['plan']='enterprise'", + ), + ).toEqual({ + logic: "OR", + filters: [ + { key: "label", op: "equals", value: "A or B" }, + { key: "plan", op: "equals", value: "enterprise" }, + ], + }); + }); + + it("rejects nested metadata keys", () => { + expect(() => + parseCommissionMetadataQuery("metadata['a']['b']='value'"), + ).toThrow(DubApiError); + try { + parseCommissionMetadataQuery("metadata['a']['b']='value'"); + } catch (error) { + expect(error).toBeInstanceOf(DubApiError); + expect((error as DubApiError).code).toBe("unprocessable_entity"); + } + }); + + it("rejects dotted metadata keys with a charset message", () => { + expect(() => + parseCommissionMetadataQuery("metadata['a.b']='value'"), + ).toThrow(DubApiError); + + expect(() => + parseCommissionMetadataQuery("metadata['order-id']='123'"), + ).toThrow( + "Invalid metadata query. Metadata keys may only contain letters, numbers, and underscores.", + ); + }); + + it("rejects mixed AND and OR", () => { + expect(() => + parseCommissionMetadataQuery( + "metadata['a']='1' AND metadata['b']='2' OR metadata['c']='3'", + ), + ).toThrow(DubApiError); + try { + parseCommissionMetadataQuery( + "metadata['a']='1' AND metadata['b']='2' OR metadata['c']='3'", + ); + } catch (error) { + expect((error as DubApiError).message).toBe( + "Metadata query cannot mix AND and OR.", + ); + } + }); + + it("rejects unsupported comparison operators", () => { + for (const query of [ + "metadata['seats']>5", + "metadata['seats']<5", + "metadata['seats']>=5", + "metadata['seats']<=5", + ]) { + expect(() => parseCommissionMetadataQuery(query)).toThrow(DubApiError); + try { + parseCommissionMetadataQuery(query); + } catch (error) { + expect((error as DubApiError).message).toBe( + "Metadata query only supports `=` and `!=` operators.", + ); + } + } + }); + + it("rejects double-equals and dangling connectives", () => { + for (const query of [ + "metadata['plan']=='pro'", + "metadata['a']='1' AND", + "metadata['a']='1' AND ", + "metadata['a']='1' OR", + "metadata['a']='1' OR ", + ]) { + expect(() => parseCommissionMetadataQuery(query)).toThrow(DubApiError); + } + + try { + parseCommissionMetadataQuery("metadata['plan']=='pro'"); + } catch (error) { + expect((error as DubApiError).code).toBe("unprocessable_entity"); + expect((error as DubApiError).message).toBe( + "Metadata query only supports `=` and `!=` operators.", + ); + } + + try { + parseCommissionMetadataQuery("metadata['a']='1' AND"); + } catch (error) { + expect((error as DubApiError).code).toBe("unprocessable_entity"); + expect((error as DubApiError).message).toBe("Invalid metadata query."); + } + }); + + it("rejects non-metadata fields and junk", () => { + expect(() => parseCommissionMetadataQuery("status:active")).toThrow( + DubApiError, + ); + expect(() => parseCommissionMetadataQuery("not-a-query")).toThrow( + DubApiError, + ); + }); + + it("preserves filter values containing --, ;, \\, /*, and */", () => { + expect(parseCommissionMetadataQuery("metadata['sku']='AB--12'")).toEqual({ + logic: "AND", + filters: [{ key: "sku", op: "equals", value: "AB--12" }], + }); + expect( + parseCommissionMetadataQuery("metadata['name']='Smith; Jane'"), + ).toEqual({ + logic: "AND", + filters: [{ key: "name", op: "equals", value: "Smith; Jane" }], + }); + expect(parseCommissionMetadataQuery("metadata['path']='a\\b'")).toEqual({ + logic: "AND", + filters: [{ key: "path", op: "equals", value: "a\\b" }], + }); + expect( + parseCommissionMetadataQuery("metadata['note']='/* comment */'"), + ).toEqual({ + logic: "AND", + filters: [{ key: "note", op: "equals", value: "/* comment */" }], + }); + }); + + it("allows up to 5 conditions and rejects more", () => { + const five = [ + "metadata['a']='1'", + "metadata['b']='2'", + "metadata['c']='3'", + "metadata['d']='4'", + "metadata['e']='5'", + ].join(" AND "); + expect(parseCommissionMetadataQuery(five)?.filters).toHaveLength(5); + + const six = `${five} AND metadata['f']='6'`; + expect(() => parseCommissionMetadataQuery(six)).toThrow(DubApiError); + try { + parseCommissionMetadataQuery(six); + } catch (error) { + expect((error as DubApiError).code).toBe("unprocessable_entity"); + expect((error as DubApiError).message).toBe( + "Metadata query supports at most 5 conditions.", + ); + } + }); +}); + +describe("buildCommissionMetadataWhere", () => { + it("returns undefined for undefined input", () => { + expect(buildCommissionMetadataWhere(undefined)).toBeUndefined(); + }); + + it("builds a single equals clause", () => { + expect( + buildCommissionMetadataWhere({ + logic: "AND", + filters: [{ key: "plan", op: "equals", value: "pro" }], + }), + ).toEqual({ + metadata: { path: "$.plan", equals: "pro" }, + }); + }); + + it("builds a single notEquals clause", () => { + expect( + buildCommissionMetadataWhere({ + logic: "AND", + filters: [{ key: "plan", op: "notEquals", value: "free" }], + }), + ).toEqual({ + metadata: { path: "$.plan", not: "free" }, + }); + }); + + it("builds AND of multiple clauses", () => { + expect( + buildCommissionMetadataWhere({ + logic: "AND", + filters: [ + { key: "plan", op: "equals", value: "pro" }, + { key: "tier", op: "equals", value: "gold" }, + ], + }), + ).toEqual({ + AND: [ + { metadata: { path: "$.plan", equals: "pro" } }, + { metadata: { path: "$.tier", equals: "gold" } }, + ], + }); + }); + + it("builds OR of multiple clauses", () => { + expect( + buildCommissionMetadataWhere({ + logic: "OR", + filters: [ + { key: "plan", op: "equals", value: "pro" }, + { key: "plan", op: "equals", value: "enterprise" }, + ], + }), + ).toEqual({ + OR: [ + { metadata: { path: "$.plan", equals: "pro" } }, + { metadata: { path: "$.plan", equals: "enterprise" } }, + ], + }); + }); +}); diff --git a/apps/web/tests/discounts/index.test.ts b/apps/web/tests/discounts/index.test.ts deleted file mode 100644 index b197a93a6dd..00000000000 --- a/apps/web/tests/discounts/index.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { CustomerEnriched } from "@/lib/types"; -import { E2E_CUSTOMER_WITH_DISCOUNT, E2E_DISCOUNT } from "tests/utils/resource"; -import { describe, expect, test } from "vitest"; -import { IntegrationHarness } from "../utils/integration"; - -describe("Discounts", () => { - test("/customers?email=", async () => { - const h = new IntegrationHarness(); - const { http } = await h.init(); - - const { status, data: customers } = await http.get({ - path: `/customers?email=${E2E_CUSTOMER_WITH_DISCOUNT.email}&includeExpandedFields=true`, - }); - - expect(status).toEqual(200); - expect(customers[0].discount).toStrictEqual(E2E_DISCOUNT); - }); - - test("/customers?externalId=", async () => { - const h = new IntegrationHarness(); - const { http } = await h.init(); - - const { status, data: customers } = await http.get({ - path: `/customers?externalId=${E2E_CUSTOMER_WITH_DISCOUNT.externalId}&includeExpandedFields=true`, - }); - - expect(status).toEqual(200); - expect(customers[0].discount).toStrictEqual(E2E_DISCOUNT); - }); - - test("/customers/:id", async () => { - const h = new IntegrationHarness(); - const { http } = await h.init(); - - const { status, data: customer } = await http.get({ - path: `/customers/${E2E_CUSTOMER_WITH_DISCOUNT.id}?includeExpandedFields=true`, - }); - - expect(status).toEqual(200); - expect(customer.discount).toStrictEqual(E2E_DISCOUNT); - }); -}); diff --git a/apps/web/tests/misc/apply-group-utm-to-link.test.ts b/apps/web/tests/misc/apply-group-utm-to-link.test.ts new file mode 100644 index 00000000000..b9acb19736c --- /dev/null +++ b/apps/web/tests/misc/apply-group-utm-to-link.test.ts @@ -0,0 +1,126 @@ +import { applyGroupUtmToLink } from "@/lib/api/utm/apply-group-utm-to-link"; +import { ProcessedLinkProps } from "@/lib/types"; +import { describe, expect, it } from "vitest"; + +const baseLink = { + domain: "dub.sh", + key: "abc1234", + url: "https://example.com/path?foo=bar", + projectId: "ws_test", +} as ProcessedLinkProps; + +const template = { + utm_source: "{{PARTNER_NAME}}", + utm_medium: "affiliate", + utm_campaign: "{{PARTNER_LINK_KEY}}", + utm_term: null, + utm_content: null, + ref: "partner-{{PARTNER_LINK_KEY}}", +}; + +describe("applyGroupUtmToLink", () => { + it("returns the link unchanged when there is no UTM template", () => { + expect( + applyGroupUtmToLink({ + link: baseLink, + utmTemplate: null, + partnerName: "John Doe", + }), + ).toBe(baseLink); + + expect( + applyGroupUtmToLink({ + link: baseLink, + utmTemplate: undefined, + partnerName: "John Doe", + }), + ).toBe(baseLink); + }); + + it("resolves macros with the final processed link key (not partner name)", () => { + const result = applyGroupUtmToLink({ + link: baseLink, + utmTemplate: template, + partnerName: "John Doe", + }); + + const parsed = new URL(result.url); + expect(parsed.searchParams.get("utm_source")).toBe("John Doe"); + expect(parsed.searchParams.get("utm_medium")).toBe("affiliate"); + expect(parsed.searchParams.get("utm_campaign")).toBe("abc1234"); + expect(parsed.searchParams.get("ref")).toBe("partner-abc1234"); + expect(parsed.searchParams.get("foo")).toBe("bar"); + + expect(result.utm_source).toBe("John Doe"); + expect(result.utm_medium).toBe("affiliate"); + expect(result.utm_campaign).toBe("abc1234"); + expect(result).not.toHaveProperty("ref"); + }); + + it("uses link.key as partnerName fallback when partnerName is missing", () => { + const result = applyGroupUtmToLink({ + link: baseLink, + utmTemplate: template, + partnerName: null, + }); + + expect(new URL(result.url).searchParams.get("utm_source")).toBe("abc1234"); + }); + + it("overwrites unresolved macros already present on the destination URL", () => { + const result = applyGroupUtmToLink({ + link: { + ...baseLink, + url: "https://example.com/?utm_source={{PARTNER_NAME}}&utm_campaign={{PARTNER_LINK_KEY}}&foo=1", + }, + utmTemplate: template, + partnerName: "John Doe", + }); + + const parsed = new URL(result.url); + expect(parsed.searchParams.get("utm_source")).toBe("John Doe"); + expect(parsed.searchParams.get("utm_campaign")).toBe("abc1234"); + expect(parsed.searchParams.get("foo")).toBe("1"); + expect(parsed.searchParams.get("utm_source")).not.toContain("{{"); + }); + + it("overwrites existing static UTM params on the destination URL", () => { + const result = applyGroupUtmToLink({ + link: { + ...baseLink, + url: "https://example.com/?utm_source=old-source&utm_campaign=old-campaign", + }, + utmTemplate: template, + partnerName: "John Doe", + }); + + const parsed = new URL(result.url); + expect(parsed.searchParams.get("utm_source")).toBe("John Doe"); + expect(parsed.searchParams.get("utm_campaign")).toBe("abc1234"); + }); + + it("simulates create-without-key: random key must win over partner name for PARTNER_LINK_KEY", () => { + // Mimics processLink assigning getRandomKey() after the request omitted key. + const processedLink = { + ...baseLink, + key: "x7k9m2p", + url: "https://example.com/", + } as ProcessedLinkProps; + + const result = applyGroupUtmToLink({ + link: processedLink, + utmTemplate: { + ...template, + utm_source: "{{PARTNER_LINK_KEY}}", + utm_campaign: "{{PARTNER_NAME}}", + }, + partnerName: "John Doe", + }); + + const parsed = new URL(result.url); + expect(parsed.searchParams.get("utm_source")).toBe("x7k9m2p"); + expect(parsed.searchParams.get("utm_campaign")).toBe("John Doe"); + // Regression: old create paths fell back partnerLinkKey to partner name. + expect(parsed.searchParams.get("utm_source")).not.toBe("John Doe"); + }); +}); diff --git a/apps/web/tests/misc/base64.test.ts b/apps/web/tests/misc/base64.test.ts index 1d6fce9b7f0..9668a5f8fde 100644 --- a/apps/web/tests/misc/base64.test.ts +++ b/apps/web/tests/misc/base64.test.ts @@ -1,4 +1,7 @@ -import { base64ImageSchema } from "@/lib/zod/schemas/images"; +import { + base64ImageSchema, + invalidImageFormatMessage, +} from "@/lib/zod/schemas/images"; import { describe, expect, it } from "vitest"; describe("base64ImageSchema", () => { @@ -10,20 +13,29 @@ describe("base64ImageSchema", () => { ).resolves.not.toThrow(); }); + it("should validate a correct base64 AVIF image", async () => { + // ISO BMFF ftyp box with the avif brand — enough for file-type detection + const avifHeader = Buffer.alloc(64); + avifHeader.writeUInt32BE(0x1c, 0); + avifHeader.write("ftypavif", 4); + const validAvifImage = `data:image/avif;base64,${avifHeader.toString("base64")}`; + await expect( + base64ImageSchema.parseAsync(validAvifImage), + ).resolves.not.toThrow(); + }); + it("should reject an invalid image type", async () => { const invalidImageType = "data:image/invalid;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; await expect( base64ImageSchema.parseAsync(invalidImageType), - ).rejects.toThrow( - "Invalid image format, supports only png, jpeg, jpg, gif, webp.", - ); + ).rejects.toThrow(invalidImageFormatMessage); }); it("should reject malformed base64 data", async () => { const malformedBase64 = "data:image/png;base64,invalid-base64-data"; await expect(base64ImageSchema.parseAsync(malformedBase64)).rejects.toThrow( - "Invalid image format, supports only png, jpeg, jpg, gif, webp.", + invalidImageFormatMessage, ); }); @@ -31,7 +43,7 @@ describe("base64ImageSchema", () => { const noPrefix = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; await expect(base64ImageSchema.parseAsync(noPrefix)).rejects.toThrow( - "Invalid image format, supports only png, jpeg, jpg, gif, webp.", + invalidImageFormatMessage, ); }); @@ -41,7 +53,7 @@ describe("base64ImageSchema", () => { "data:image/png;base64," + Buffer.from("This is not an image").toString("base64"); await expect(base64ImageSchema.parseAsync(textContent)).rejects.toThrow( - "Invalid image format, supports only png, jpeg, jpg, gif, webp.", + invalidImageFormatMessage, ); }); @@ -70,7 +82,7 @@ describe("base64ImageSchema", () => { ).toString("base64"); await expect(base64ImageSchema.parseAsync(xssPayload)).rejects.toThrow( - "Invalid image format, supports only png, jpeg, jpg, gif, webp.", + invalidImageFormatMessage, ); }); @@ -100,9 +112,7 @@ describe("base64ImageSchema", () => { await expect( base64ImageSchema.parseAsync(phishingPayload), - ).rejects.toThrow( - "Invalid image format, supports only png, jpeg, jpg, gif, webp.", - ); + ).rejects.toThrow(invalidImageFormatMessage); }); }); }); diff --git a/apps/web/tests/misc/google-ads-api-error.test.ts b/apps/web/tests/misc/google-ads-api-error.test.ts new file mode 100644 index 00000000000..869a6f221f5 --- /dev/null +++ b/apps/web/tests/misc/google-ads-api-error.test.ts @@ -0,0 +1,57 @@ +import { + formatApiErrorDetail, + isGoogleAdsPermissionDenied, +} from "@/lib/integrations/google-ads/api"; +import { describe, expect, it } from "vitest"; + +// searchStream 403s arrive as a JSON array. The human message omits the +// authorization error-code enum that retry classification depends on. +const permissionDeniedWithoutCodeInMessage = [ + { + error: { + code: 403, + message: "The caller does not have permission", + status: "PERMISSION_DENIED", + details: [ + { + "@type": + "type.googleapis.com/google.ads.googleads.v22.errors.GoogleAdsFailure", + errors: [ + { + errorCode: { + authorizationError: "USER_PERMISSION_DENIED", + }, + message: + "User doesn't have permission to access customer. Note: If you're accessing a client customer, the manager's customer id must be set in the 'login-customer-id' header.", + }, + ], + requestId: "RWSdEB8C1WuiLgZl38biSQ", + }, + ], + }, + }, +]; + +describe("formatApiErrorDetail", () => { + it("preserves USER_PERMISSION_DENIED when the message omits the error code", () => { + const adsMessage = + permissionDeniedWithoutCodeInMessage[0].error.details[0].errors[0] + .message; + + expect(adsMessage).not.toContain("USER_PERMISSION_DENIED"); + + const detail = formatApiErrorDetail( + permissionDeniedWithoutCodeInMessage, + "", + ); + + expect(detail).toContain("USER_PERMISSION_DENIED"); + expect( + isGoogleAdsPermissionDenied( + new Error( + `[Google Ads API] Request failed for searchStream (403): ${detail}`, + ), + ), + ).toBe(true); + }); +}); diff --git a/apps/web/tests/misc/google-ads-lead-event-filter.test.ts b/apps/web/tests/misc/google-ads-lead-event-filter.test.ts new file mode 100644 index 00000000000..71bb7e95961 --- /dev/null +++ b/apps/web/tests/misc/google-ads-lead-event-filter.test.ts @@ -0,0 +1,122 @@ +import { + getGoogleAdsEventMappingsError, + resolveGoogleAdsConversionMapping, +} from "@/lib/integrations/google-ads/utils"; +import { describe, expect, it } from "vitest"; + +const signUpAction = "customers/1/conversionActions/signup"; +const trialAction = "customers/1/conversionActions/trial"; +const purchaseAction = "customers/1/conversionActions/purchase"; + +describe("resolveGoogleAdsConversionMapping", () => { + it("returns null when no mappings are configured", () => { + expect( + resolveGoogleAdsConversionMapping({ + mappings: [], + eventName: "Sign Up", + }), + ).toBeNull(); + }); + + it("matches a catch-all mapping with empty event names", () => { + expect( + resolveGoogleAdsConversionMapping({ + mappings: [{ conversionAction: signUpAction, eventNames: [] }], + eventName: "Sign Up", + }), + ).toEqual({ conversionAction: signUpAction, eventNames: [] }); + }); + + it("matches a mapping by event name", () => { + expect( + resolveGoogleAdsConversionMapping({ + mappings: [ + { conversionAction: signUpAction, eventNames: ["Sign Up"] }, + { conversionAction: trialAction, eventNames: ["Started Trial"] }, + ], + eventName: "Started Trial", + }), + ).toEqual({ + conversionAction: trialAction, + eventNames: ["Started Trial"], + }); + }); + + it("prefers a specific event-name mapping over a catch-all", () => { + expect( + resolveGoogleAdsConversionMapping({ + mappings: [ + { conversionAction: purchaseAction, eventNames: [] }, + { conversionAction: signUpAction, eventNames: ["Sign Up"] }, + ], + eventName: "Sign Up", + }), + ).toEqual({ + conversionAction: signUpAction, + eventNames: ["Sign Up"], + }); + }); + + it("returns null when no mapping matches the event name", () => { + expect( + resolveGoogleAdsConversionMapping({ + mappings: [{ conversionAction: signUpAction, eventNames: ["Sign Up"] }], + eventName: "Demo Booked", + }), + ).toBeNull(); + }); + + it("skips unnamed events unless a catch-all mapping exists", () => { + expect( + resolveGoogleAdsConversionMapping({ + mappings: [{ conversionAction: signUpAction, eventNames: ["Sign Up"] }], + eventName: undefined, + }), + ).toBeNull(); + + expect( + resolveGoogleAdsConversionMapping({ + mappings: [{ conversionAction: signUpAction, eventNames: [] }], + eventName: undefined, + }), + ).toEqual({ conversionAction: signUpAction, eventNames: [] }); + }); +}); + +describe("getGoogleAdsEventMappingsError", () => { + it("returns null when event names are unique", () => { + expect( + getGoogleAdsEventMappingsError([ + { conversionAction: signUpAction, eventNames: ["Sign Up"] }, + { conversionAction: trialAction, eventNames: ["Started Trial"] }, + ]), + ).toBeNull(); + }); + + it("rejects the same event name on multiple conversion actions", () => { + expect( + getGoogleAdsEventMappingsError([ + { conversionAction: signUpAction, eventNames: ["Sign Up"] }, + { conversionAction: trialAction, eventNames: ["Sign Up"] }, + ]), + ).toContain("Sign Up"); + }); + + it("rejects the same conversion action more than once", () => { + expect( + getGoogleAdsEventMappingsError([ + { conversionAction: signUpAction, eventNames: ["Sign Up"] }, + { conversionAction: signUpAction, eventNames: ["Started Trial"] }, + ]), + ).toBe("Each conversion action can only be used once."); + }); + + it("rejects more than one catch-all mapping", () => { + expect( + getGoogleAdsEventMappingsError([ + { conversionAction: signUpAction, eventNames: [] }, + { conversionAction: trialAction, eventNames: [] }, + ]), + ).toBe("Only one conversion action can receive unmatched events."); + }); +}); diff --git a/apps/web/tests/misc/scraped-count-schema.test.ts b/apps/web/tests/misc/scraped-count-schema.test.ts new file mode 100644 index 00000000000..09da077b9ef --- /dev/null +++ b/apps/web/tests/misc/scraped-count-schema.test.ts @@ -0,0 +1,21 @@ +import { scrapedCountSchema } from "@/lib/api/scrape-creators/schema"; +import { describe, expect, it } from "vitest"; + +describe("scrapedCountSchema", () => { + it("rounds abbreviated-count float artifacts to the nearest integer", () => { + expect(scrapedCountSchema.parse(16.1 * 1000)).toBe(16100); + expect(scrapedCountSchema.parse(16099.999999999998)).toBe(16100); + expect(BigInt(scrapedCountSchema.parse(16.1 * 1000))).toBe(16100n); + }); + + it("passes integers through unchanged", () => { + expect(scrapedCountSchema.parse(0)).toBe(0); + expect(scrapedCountSchema.parse(16100)).toBe(16100); + expect(scrapedCountSchema.parse(1_000_000)).toBe(1_000_000); + }); + + it("coerces null and undefined to 0", () => { + expect(scrapedCountSchema.parse(null)).toBe(0); + expect(scrapedCountSchema.parse(undefined)).toBe(0); + }); +}); diff --git a/apps/web/tests/misc/smart-truncate.test.ts b/apps/web/tests/misc/smart-truncate.test.ts new file mode 100644 index 00000000000..5fbb838dec5 --- /dev/null +++ b/apps/web/tests/misc/smart-truncate.test.ts @@ -0,0 +1,175 @@ +import { smartTruncate } from "@dub/utils"; +import { describe, expect, it } from "vitest"; + +const LIMIT = 33; + +describe("smartTruncate", () => { + describe("protocol handling", () => { + it("strips https:// and returns the pretty URL when it fits", () => { + expect(smartTruncate("https://refer.acme.com/partner", LIMIT)).toBe( + "refer.acme.com/partner", + ); + expect(smartTruncate("https://refer.acme.com/f6s", LIMIT)).toBe( + "refer.acme.com/f6s", + ); + expect(smartTruncate("https://refer.acme.com/twitter", LIMIT)).toBe( + "refer.acme.com/twitter", + ); + }); + + it("strips http:// the same way", () => { + expect(smartTruncate("http://refer.acme.com/partner", LIMIT)).toBe( + "refer.acme.com/partner", + ); + }); + + it("leaves already-pretty URLs unchanged when they fit", () => { + expect(smartTruncate("refer.acme.com/partner", LIMIT)).toBe( + "refer.acme.com/partner", + ); + }); + + it("produces the same output for stored and pretty inputs", () => { + const stored = + "https://acme.com/super-long-path-that-is-way-too-long-and-should-be-truncated"; + const pretty = + "acme.com/super-long-path-that-is-way-too-long-and-should-be-truncated"; + + expect(smartTruncate(stored, LIMIT)).toBe(smartTruncate(pretty, LIMIT)); + }); + + it("preserves www. on custom domains", () => { + expect(smartTruncate("https://www.acme.com/launch", LIMIT)).toBe( + "www.acme.com/launch", + ); + }); + }); + + describe("short links that fit after stripping the protocol", () => { + it("keeps default nanoid, dub.link, .dub.link, and branded domains", () => { + expect(smartTruncate("https://dub.sh/xYz9AbC", LIMIT)).toBe( + "dub.sh/xYz9AbC", + ); + expect(smartTruncate("https://dub.link/abcde", LIMIT)).toBe( + "dub.link/abcde", + ); + expect(smartTruncate("https://acme.dub.link/promo", LIMIT)).toBe( + "acme.dub.link/promo", + ); + expect(smartTruncate("https://git.new/dub", LIMIT)).toBe("git.new/dub"); + }); + + it("keeps dotted, hyphenated, and underscored keys", () => { + expect(smartTruncate("https://acme.com/file.name", LIMIT)).toBe( + "acme.com/file.name", + ); + expect(smartTruncate("https://acme.com/launch-2024", LIMIT)).toBe( + "acme.com/launch-2024", + ); + expect(smartTruncate("https://acme.com/my_link", LIMIT)).toBe( + "acme.com/my_link", + ); + }); + + it("keeps prefixed and nested keys that fit", () => { + expect(smartTruncate("https://dub.sh/gh/xYz9AbC", LIMIT)).toBe( + "dub.sh/gh/xYz9AbC", + ); + expect(smartTruncate("https://acme.com/linkedin/more/path", LIMIT)).toBe( + "acme.com/linkedin/more/path", + ); + }); + }); + + describe("root domain links", () => { + it("strips the protocol and does not append a slash", () => { + expect(smartTruncate("https://acme.com", LIMIT)).toBe("acme.com"); + }); + + it("truncates a long apex while preserving the TLD", () => { + expect( + smartTruncate("https://verylongcustomapexdomainnamehere.com", LIMIT), + ).toBe("verylongcustomapexdomainnam...com"); + }); + }); + + describe("path-first truncation", () => { + it("keeps a short key and TLD-truncates a long domain", () => { + expect( + smartTruncate("https://superlongcustomdomainnamehere.com/x", LIMIT), + ).toBe("superlongcustomdomainname...com/x"); + }); + + it("keeps a short domain and left-truncates a long key", () => { + expect( + smartTruncate( + "https://acme.com/super-long-path-that-is-way-too-long-and-should-be-truncated", + LIMIT, + ), + ).toBe("acme.com/super-long-path-that-..."); + }); + + it("truncates both when domain and path overflow", () => { + expect( + smartTruncate( + "https://acmesuperlongdomain.com/super-long-path-that-is-way-too-long-and-should-be-truncated", + LIMIT, + ), + ).toBe("ac...com/super-long-path-that-..."); + }); + + it("left-truncates nested keys from the start", () => { + expect( + smartTruncate( + "https://acme.com/linkedin/more/path/that/is/very/long", + LIMIT, + ), + ).toBe("acme.com/linkedin/more/path/th..."); + }); + }); + + describe("punycode and case-sensitive keys", () => { + it("parses punycode hosts and keys without decoding them", () => { + expect(smartTruncate("https://xn--n3h.com/xn--fsq", LIMIT)).toBe( + "xn--n3h.com/xn--fsq", + ); + expect( + smartTruncate( + "https://xn--n3h.com/xn--longpunycodekeythatexceedsthelimit", + LIMIT, + ), + ).toBe("xn...com/xn--longpunycodekeyth..."); + }); + + it("treats case-sensitive encoded keys as a normal path", () => { + expect(smartTruncate("https://acme.co/cAsE-sensitive-TeSt", LIMIT)).toBe( + "acme.co/cAsE-sensitive-TeSt", + ); + expect( + smartTruncate( + "https://acme.co/VeryLongCaseSensitiveEncodedKeyValueHere", + LIMIT, + ), + ).toBe("acme.co/VeryLongCaseSensitive..."); + }); + }); + + describe("length budget", () => { + it("never exceeds maxLength when truncation runs", () => { + const inputs = [ + "https://refer.acme.com/partner-name-that-is-quite-long", + "https://acmesuperlongdomain.com/super-long-path-that-is-way-too-long-and-should-be-truncated", + "https://verylongcustomapexdomainnamehere.com", + "https://superlongcustomdomainnamehere.com/x", + "https://acme.com/linkedin/more/path/that/is/very/long", + "https://xn--n3h.com/xn--longpunycodekeythatexceedsthelimit", + "http://acmesuperlongdomain.com/gh/prefixed-key-that-is-also-very-long", + ]; + + for (const input of inputs) { + const output = smartTruncate(input, LIMIT); + expect(output.length).toBeLessThanOrEqual(LIMIT); + } + }); + }); +}); diff --git a/apps/web/tests/partners/applications/approve-reject-partner-application.test.ts b/apps/web/tests/partner-applications/approve-reject-partner-application.test.ts similarity index 94% rename from apps/web/tests/partners/applications/approve-reject-partner-application.test.ts rename to apps/web/tests/partner-applications/approve-reject-partner-application.test.ts index cabfd33347c..21e47d4cf6b 100644 --- a/apps/web/tests/partners/applications/approve-reject-partner-application.test.ts +++ b/apps/web/tests/partner-applications/approve-reject-partner-application.test.ts @@ -1,9 +1,9 @@ import { generateRandomName } from "@/lib/names"; import { Partner } from "@prisma/client"; import { describe, expect, test } from "vitest"; -import { randomPartnerEmail } from "../../utils/helpers"; -import { IntegrationHarness } from "../../utils/integration"; -import { E2E_PARTNER_GROUP } from "../../utils/resource"; +import { randomPartnerEmail } from "../utils/helpers"; +import { IntegrationHarness } from "../utils/integration"; +import { E2E_PARTNER_GROUP } from "../utils/resource"; describe.sequential( "POST /partners/applications/reject and /approve", diff --git a/apps/web/tests/partners/applications/list-partner-applications.test.ts b/apps/web/tests/partner-applications/list-partner-applications.test.ts similarity index 94% rename from apps/web/tests/partners/applications/list-partner-applications.test.ts rename to apps/web/tests/partner-applications/list-partner-applications.test.ts index 63c9524674a..aefd43338c9 100644 --- a/apps/web/tests/partners/applications/list-partner-applications.test.ts +++ b/apps/web/tests/partner-applications/list-partner-applications.test.ts @@ -1,8 +1,8 @@ import { PartnerApplicationProps } from "@/lib/types"; import { PartnerApplicationSchema } from "@/lib/zod/schemas/program-application"; import { describe, expect, test } from "vitest"; -import { IntegrationHarness } from "../../utils/integration"; -import { E2E_PARTNER_GROUP, E2E_PARTNERS } from "../../utils/resource"; +import { IntegrationHarness } from "../utils/integration"; +import { E2E_PARTNER_GROUP, E2E_PARTNERS } from "../utils/resource"; describe.sequential("GET /partners/applications", async () => { const h = new IntegrationHarness(); diff --git a/apps/web/tests/partners/backfill-partner-search-args.test.ts b/apps/web/tests/partners/backfill-partner-search-args.test.ts new file mode 100644 index 00000000000..a42ffc3a960 --- /dev/null +++ b/apps/web/tests/partners/backfill-partner-search-args.test.ts @@ -0,0 +1,103 @@ +import { + DEFAULT_BATCH_SIZE, + MAX_BATCH_SIZE, + parseBackfillArguments, +} from "@/scripts/partners/backfill-partner-search-args"; +import { describe, expect, it } from "vitest"; + +describe("parseBackfillArguments", () => { + it("refuses to run bare, so a full backfill cannot start by accident", () => { + expect(() => parseBackfillArguments([])).toThrow( + "Pass --programId= for one program, or --all for every program.", + ); + }); + + it("takes a single program", () => { + expect(parseBackfillArguments(["--programId=prog_1"])).toEqual({ + programId: "prog_1", + all: false, + batchSize: DEFAULT_BATCH_SIZE, + after: undefined, + afterProgram: undefined, + }); + }); + + it("takes every program behind an explicit flag", () => { + expect(parseBackfillArguments(["--all"])).toEqual({ + programId: undefined, + all: true, + batchSize: DEFAULT_BATCH_SIZE, + after: undefined, + afterProgram: undefined, + }); + }); + + it("rejects one program and every program together", () => { + expect(() => + parseBackfillArguments(["--programId=prog_1", "--all"]), + ).toThrow("--programId and --all are mutually exclusive."); + }); + + it("resumes a single-program run from an enrollment", () => { + expect( + parseBackfillArguments(["--programId=prog_1", "--after=pge_9"]), + ).toMatchObject({ + programId: "prog_1", + after: "pge_9", + }); + }); + + it("resumes an --all run from both halves of the cursor", () => { + expect( + parseBackfillArguments([ + "--all", + "--afterProgram=prog_5", + "--after=pge_9", + ]), + ).toMatchObject({ + all: true, + afterProgram: "prog_5", + after: "pge_9", + }); + }); + + it("rejects an enrollment cursor with no program on an --all run", () => { + expect(() => parseBackfillArguments(["--all", "--after=pge_9"])).toThrow( + "--after requires --afterProgram on an --all run.", + ); + }); + + it("rejects a program cursor on a single-program run", () => { + expect(() => + parseBackfillArguments(["--programId=prog_1", "--afterProgram=prog_5"]), + ).toThrow("--afterProgram only applies to --all runs."); + }); + + it("rejects empty cursors, which would silently mean no cursor", () => { + expect(() => parseBackfillArguments(["--all", "--afterProgram="])).toThrow( + "--afterProgram cannot be empty.", + ); + expect(() => + parseBackfillArguments(["--programId=prog_1", "--after="]), + ).toThrow("--after cannot be empty."); + expect(() => parseBackfillArguments(["--programId="])).toThrow( + "Pass --programId= for one program, or --all for every program.", + ); + }); + + it("caps the batch size", () => { + expect( + parseBackfillArguments(["--all", `--batchSize=${MAX_BATCH_SIZE}`]), + ).toMatchObject({ batchSize: MAX_BATCH_SIZE }); + + expect(() => + parseBackfillArguments(["--all", `--batchSize=${MAX_BATCH_SIZE + 1}`]), + ).toThrow(`--batchSize cannot exceed ${MAX_BATCH_SIZE}.`); + }); + + it("rejects an unknown argument rather than ignoring it", () => { + expect(() => parseBackfillArguments(["--all", "--dryRun"])).toThrow( + "Unknown argument: --dryRun", + ); + }); +}); diff --git a/apps/web/tests/partners/create-partner-link.test.ts b/apps/web/tests/partners/create-partner-link.test.ts index dedcbea5166..a1f599cf087 100644 --- a/apps/web/tests/partners/create-partner-link.test.ts +++ b/apps/web/tests/partners/create-partner-link.test.ts @@ -1,3 +1,4 @@ +import { nanoid } from "@dub/utils"; import { Link } from "@prisma/client"; import { expect, onTestFinished, test } from "vitest"; import { IntegrationHarness } from "../utils/integration"; @@ -24,3 +25,28 @@ test("POST /api/partners/links", async () => { expect(LinkSchema.strict().parse(link)).toBeTruthy(); expect(link).toStrictEqual(partnerLink); }); + +test("POST /api/partners/links with a URL outside additionalLinks", async () => { + const h = new IntegrationHarness(); + const { http } = await h.init(); + const url = `https://github.com/dubinc/${nanoid()}`; + + onTestFinished(async () => { + await h.deleteLink(link.id); + }); + + const { status, data: link } = await http.post({ + path: "/partners/links", + body: { + partnerId: E2E_PARTNER.id, + url, + }, + }); + + expect(status).toEqual(201); + expect(LinkSchema.strict().parse(link)).toBeTruthy(); + expect(link).toStrictEqual({ + ...partnerLink, + url, + }); +}); diff --git a/apps/web/tests/partners/get-partners-count-search.test.ts b/apps/web/tests/partners/get-partners-count-search.test.ts new file mode 100644 index 00000000000..1d0e0e2b2c1 --- /dev/null +++ b/apps/web/tests/partners/get-partners-count-search.test.ts @@ -0,0 +1,341 @@ +import { getPartnersCount } from "@/lib/api/partners/get-partners-count"; +import { + PARTNER_SEARCH_CANDIDATE_LIMIT, + PartnerSearchProvider, +} from "@/lib/api/partners/search"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + count: vi.fn(), + enrollmentGroupBy: vi.fn(), + partnerGroupBy: vi.fn(), + partnerTagGroupBy: vi.fn(), + applicationEventGroupBy: vi.fn(), + programFindUnique: vi.fn(), +})); + +vi.mock("@/lib/prisma", () => ({ + sanitizeFullTextSearch: (value: string) => value, + prisma: { + programEnrollment: { + count: mocks.count, + groupBy: mocks.enrollmentGroupBy, + }, + partner: { groupBy: mocks.partnerGroupBy, findUnique: vi.fn() }, + programPartnerTag: { groupBy: mocks.partnerTagGroupBy }, + programApplicationEvent: { groupBy: mocks.applicationEventGroupBy }, + program: { findUnique: mocks.programFindUnique }, + }, +})); + +function createSearchProvider(total = 12_000): PartnerSearchProvider { + return { + searchCandidates: vi.fn().mockResolvedValue({ + hits: [{ id: "pge_2" }, { id: "pge_1" }], + }), + countCandidates: vi.fn().mockResolvedValue(total), + upsert: vi.fn(), + delete: vi.fn(), + }; +} + +describe("getPartnersCount search", () => { + beforeEach(() => { + for (const mock of Object.values(mocks)) { + mock.mockReset(); + } + }); + + it("counts a pasted program short link by its key", async () => { + const searchProvider = createSearchProvider(1); + mocks.programFindUnique.mockResolvedValue({ domain: "go.acme.com" }); + + const count = await getPartnersCount( + { + programId: "prog_test", + search: "https://go.acme.com/partner", + status: "approved", + }, + { searchProvider }, + ); + + expect(count).toBe(1); + expect(searchProvider.searchCandidates).toHaveBeenCalledWith( + expect.objectContaining({ query: "partner" }), + ); + expect(searchProvider.countCandidates).toHaveBeenCalledWith( + expect.objectContaining({ query: "partner" }), + ); + }); + + it("reports the truncated candidate count when the total exceeds the candidate ceiling", async () => { + const searchProvider = createSearchProvider(12_000); + mocks.count.mockResolvedValue(PARTNER_SEARCH_CANDIDATE_LIMIT); + + const count = await getPartnersCount( + { + programId: "prog_test", + search: "examp", + status: "approved", + country: ["CA"], + }, + { searchProvider }, + ); + + expect(count).toBe(PARTNER_SEARCH_CANDIDATE_LIMIT); + expect(searchProvider.countCandidates).toHaveBeenCalledWith({ + programId: "prog_test", + query: "examp", + limit: PARTNER_SEARCH_CANDIDATE_LIMIT, + filters: { + status: { values: ["approved"], exclude: false }, + groupId: undefined, + country: { values: ["CA"], exclude: false }, + partnerTagIds: undefined, + }, + }); + expect(mocks.count).not.toHaveBeenCalled(); + }); + + it.each([ + ["a tenant ID", { tenantId: "tenant_1" }], + ["explicit partner IDs", { partnerIds: ["pn_1"] }], + ["a metric range", { totalClicksMin: 10 }], + ["a referral filter", { referredByPartnerId: "pn_referrer" }], + ])( + "falls back to the database count when the provider cannot see %s", + async (_label, extra) => { + // The aggregation only knows the filters it was given, so counting with + // one of these applied would over-count. + const searchProvider = createSearchProvider(12_000); + mocks.count.mockResolvedValue(2); + + const count = await getPartnersCount( + { programId: "prog_test", search: "examp", ...extra }, + { searchProvider }, + ); + + expect(count).toBe(2); + expect(searchProvider.countCandidates).not.toHaveBeenCalled(); + expect(mocks.count).toHaveBeenCalled(); + }, + ); + + it("falls back to the database count when the provider declines to count", async () => { + const searchProvider = createSearchProvider(); + ( + searchProvider.countCandidates as ReturnType + ).mockResolvedValue(null); + mocks.count.mockResolvedValue(5); + + const count = await getPartnersCount( + { programId: "prog_test", search: "a" }, + { searchProvider }, + ); + + expect(count).toBe(5); + }); + + it("falls back to the database count when the aggregation fails", async () => { + const searchProvider = createSearchProvider(); + ( + searchProvider.countCandidates as ReturnType + ).mockRejectedValue(new Error("aggregation down")); + mocks.count.mockResolvedValue(2); + vi.spyOn(console, "error").mockImplementation(() => {}); + + const count = await getPartnersCount( + { programId: "prog_test", search: "examp" }, + { searchProvider }, + ); + + expect(count).toBe(2); + }); + + it("groups database-filtered relevance candidates", async () => { + const searchProvider = createSearchProvider(); + mocks.enrollmentGroupBy.mockResolvedValue([ + { status: "approved", _count: 1 }, + ]); + + const groups = await getPartnersCount<{ status: string; _count: number }[]>( + { programId: "prog_test", search: "examp", groupBy: "status" }, + { searchProvider }, + ); + + expect(mocks.enrollmentGroupBy).toHaveBeenCalledWith( + expect.objectContaining({ + by: ["status"], + where: expect.objectContaining({ + programId: "prog_test", + id: { in: ["pge_2", "pge_1"] }, + }), + }), + ); + expect(groups).toEqual( + expect.arrayContaining([ + { status: "approved", _count: 1 }, + { status: "pending", _count: 0 }, + ]), + ); + }); + + it("scopes every database grouping path to relevance candidates", async () => { + const searchProvider = createSearchProvider(); + mocks.partnerGroupBy.mockResolvedValue([]); + mocks.partnerTagGroupBy.mockResolvedValue([]); + mocks.applicationEventGroupBy.mockResolvedValue([]); + + await getPartnersCount( + { + programId: "prog_test", + search: "examp", + groupBy: "country", + }, + { searchProvider }, + ); + expect(mocks.partnerGroupBy).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + programs: { + some: expect.objectContaining({ + id: { in: ["pge_2", "pge_1"] }, + }), + }, + }), + }), + ); + + await getPartnersCount( + { + programId: "prog_test", + search: "examp", + groupBy: "partnerTagId", + }, + { searchProvider }, + ); + expect(mocks.partnerTagGroupBy).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + programEnrollment: expect.objectContaining({ + id: { in: ["pge_2", "pge_1"] }, + }), + }), + }), + ); + + await getPartnersCount( + { + programId: "prog_test", + search: "examp", + groupBy: "referredByPartnerId", + }, + { searchProvider }, + ); + expect(mocks.applicationEventGroupBy).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + programEnrollment: expect.objectContaining({ + id: { in: ["pge_2", "pge_1"] }, + }), + }), + }), + ); + }); + + it("does not re-apply the database full-text search to grouped counts", async () => { + // The provider already resolved the query into candidate IDs. ANDing the + // database full-text predicate on top would drop every match the database + // cannot find on its own, which is exactly what the search index is for. + mocks.enrollmentGroupBy.mockResolvedValue([]); + mocks.partnerGroupBy.mockResolvedValue([]); + mocks.partnerTagGroupBy.mockResolvedValue([]); + mocks.applicationEventGroupBy.mockResolvedValue([]); + mocks.count.mockResolvedValue(0); + + const groupings = [ + { groupBy: "status", mock: mocks.enrollmentGroupBy, extra: {} }, + { groupBy: "groupId", mock: mocks.enrollmentGroupBy, extra: {} }, + { groupBy: "country", mock: mocks.partnerGroupBy, extra: {} }, + { groupBy: "partnerTagId", mock: mocks.partnerTagGroupBy, extra: {} }, + { + groupBy: "referredByPartnerId", + mock: mocks.applicationEventGroupBy, + extra: {}, + }, + // The ungrouped count only reaches the database when a database-only + // filter forces it to. + { groupBy: undefined, mock: mocks.count, extra: { totalClicksMin: 1 } }, + ] as const; + + for (const { groupBy, mock, extra } of groupings) { + mock.mockClear(); + + await getPartnersCount( + { + programId: "prog_test", + search: "examp", + ...extra, + ...(groupBy && { groupBy }), + }, + { searchProvider: createSearchProvider() }, + ); + + const { where } = mock.mock.calls.at(-1)![0]; + expect( + JSON.stringify(where), + `groupBy: ${groupBy ?? "none"}`, + ).not.toContain('"search":"examp"'); + expect(JSON.stringify(where), `groupBy: ${groupBy ?? "none"}`).toContain( + '"id":{"in":["pge_2","pge_1"]}', + ); + } + }); + + it("keeps the database full-text search when no provider is configured", async () => { + mocks.enrollmentGroupBy.mockResolvedValue([]); + + await getPartnersCount( + { programId: "prog_test", search: "examp", groupBy: "status" }, + { searchProvider: null }, + ); + + const { where } = mocks.enrollmentGroupBy.mock.calls.at(-1)![0]; + expect(JSON.stringify(where)).toContain('"search":"examp"'); + }); + + it("falls back to the database search path when the provider fails", async () => { + const searchProvider = createSearchProvider(); + vi.mocked(searchProvider.searchCandidates).mockRejectedValue( + new Error("Provider Connection Timeout"), + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + mocks.count.mockResolvedValue(7); + + const count = await getPartnersCount( + { programId: "prog_test", search: "examp" }, + { searchProvider }, + ); + + expect(count).toBe(7); + + const { where } = mocks.count.mock.calls.at(-1)![0]; + expect(where.id).toBeUndefined(); + expect(JSON.stringify(where)).toContain('"search":"examp"'); + vi.mocked(console.error).mockRestore(); + }); + + it("surfaces provider errors when the caller opts out of the fallback", async () => { + const searchProvider = createSearchProvider(); + vi.mocked(searchProvider.searchCandidates).mockRejectedValue( + new Error("Provider Connection Timeout"), + ); + + await expect( + getPartnersCount( + { programId: "prog_test", search: "examp" }, + { searchProvider, throwOnSearchError: true }, + ), + ).rejects.toThrow("Provider Connection Timeout"); + }); +}); diff --git a/apps/web/tests/partners/get-partners-route-query.test.ts b/apps/web/tests/partners/get-partners-route-query.test.ts new file mode 100644 index 00000000000..a59f71f129b --- /dev/null +++ b/apps/web/tests/partners/get-partners-route-query.test.ts @@ -0,0 +1,91 @@ +import { + getPartnersRouteQuerySchema, + partnersExportQuerySchema, +} from "@/lib/zod/schemas/partners"; +import { describe, expect, it } from "vitest"; + +const accepts = (params: Record) => + getPartnersRouteQuerySchema.safeParse(params).success; + +describe("getPartnersRouteQuerySchema", () => { + it("accepts the shapes the partners table sends", () => { + expect(accepts({ sortBy: "relevance", search: "examp" })).toBe(true); + expect( + accepts({ sortBy: "relevance", search: "examp", sortOrder: "desc" }), + ).toBe(true); + expect(accepts({ search: "examp" })).toBe(true); + expect(accepts({ sortBy: "totalClicks", sortOrder: "asc" })).toBe(true); + expect(accepts({ email: "partner@example.com" })).toBe(true); + expect(accepts({ tenantId: "tenant_1" })).toBe(true); + }); + + it("still accepts the legacy sort aliases", () => { + for (const sortBy of [ + "clicks", + "leads", + "conversions", + "sales", + "saleAmount", + "totalSales", + ]) { + expect(accepts({ sortBy })).toBe(true); + } + }); + + it("rejects relevance without a query the provider can rank", () => { + expect(accepts({ sortBy: "relevance" })).toBe(false); + expect(accepts({ sortBy: "relevance", search: "" })).toBe(false); + expect(accepts({ sortBy: "relevance", search: " " })).toBe(false); + }); + + it("rejects relevance alongside an exact email lookup", () => { + expect( + accepts({ sortBy: "relevance", search: "examp", email: "a@b.co" }), + ).toBe(false); + }); + + it("rejects relevance alongside a tenant filter", () => { + expect( + accepts({ sortBy: "relevance", search: "examp", tenantId: "t_1" }), + ).toBe(false); + }); + + it("rejects ascending relevance", () => { + expect( + accepts({ sortBy: "relevance", search: "examp", sortOrder: "asc" }), + ).toBe(false); + }); + + it("explains why the request was rejected", () => { + const result = getPartnersRouteQuerySchema.safeParse({ + sortBy: "relevance", + }); + + expect(result.success).toBe(false); + expect(result.error?.issues[0].message).toContain( + "sortBy=relevance requires a non-empty search", + ); + }); +}); + +describe("partnersExportQuerySchema", () => { + // Exports do not apply the search field: its provider results are capped at + // the candidate ceiling, so exporting them would silently truncate the file. + it("strips the search field", () => { + const result = partnersExportQuerySchema.safeParse({ + sortBy: "totalSaleAmount", + search: "examp", + }); + + expect(result.success).toBe(true); + expect(result.data).not.toHaveProperty("search"); + }); + + it("rejects relevance, which cannot exist without a search", () => { + expect( + partnersExportQuerySchema.safeParse({ + sortBy: "relevance", + }).success, + ).toBe(false); + }); +}); diff --git a/apps/web/tests/partners/get-partners-search.test.ts b/apps/web/tests/partners/get-partners-search.test.ts new file mode 100644 index 00000000000..16f3060aa23 --- /dev/null +++ b/apps/web/tests/partners/get-partners-search.test.ts @@ -0,0 +1,292 @@ +import { getPartners } from "@/lib/api/partners/get-partners"; +import { + PARTNER_SEARCH_CANDIDATE_LIMIT, + PartnerSearchProvider, +} from "@/lib/api/partners/search"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findMany: vi.fn(), + programFindUnique: vi.fn(), +})); + +vi.mock("@/lib/prisma", () => ({ + sanitizeFullTextSearch: (value: string) => value, + prisma: { + programEnrollment: { + findMany: mocks.findMany, + }, + program: { findUnique: mocks.programFindUnique }, + }, +})); + +function enrollment(id: string, partnerId: string) { + return { + id, + programId: "prog_test", + partnerId, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + totalSaleAmount: BigInt(0), + totalCommissions: BigInt(0), + partner: { + id: partnerId, + programPartnerTags: [], + platforms: [], + }, + links: [], + }; +} + +function createSearchProvider( + hits: { id: string }[] = [], +): PartnerSearchProvider { + return { + searchCandidates: vi.fn().mockResolvedValue({ hits }), + countCandidates: vi.fn(), + upsert: vi.fn(), + delete: vi.fn(), + }; +} + +describe("getPartners search", () => { + beforeEach(() => { + mocks.findMany.mockReset(); + mocks.programFindUnique.mockReset(); + }); + + it("searches a pasted program short link by its key", async () => { + mocks.findMany.mockResolvedValue([enrollment("pge_1", "pn_1")]); + mocks.programFindUnique.mockResolvedValue({ domain: "go.acme.com" }); + const searchProvider = createSearchProvider([{ id: "pge_1" }]); + + await getPartners( + { + programId: "prog_test", + search: "https://go.acme.com/partner", + page: 1, + pageSize: 25, + sortBy: "totalSaleAmount", + sortOrder: "desc", + }, + { searchProvider }, + ); + + expect(searchProvider.searchCandidates).toHaveBeenCalledWith( + expect.objectContaining({ query: "partner" }), + ); + }); + + it("keeps a tenant filter on the database search path", async () => { + // tenantId predates the search provider, so `tenantId` + `search` still + // resolves entirely in the database rather than inheriting the candidate + // ceiling. + mocks.findMany.mockResolvedValue([enrollment("pge_1", "pn_1")]); + const searchProvider = createSearchProvider([{ id: "pge_1" }]); + + await getPartners( + { + programId: "prog_test", + search: "examp", + tenantId: "tenant_1", + page: 1, + pageSize: 25, + sortBy: "totalSaleAmount", + sortOrder: "desc", + }, + { searchProvider }, + ); + + expect(searchProvider.searchCandidates).not.toHaveBeenCalled(); + + const { where } = mocks.findMany.mock.calls.at(-1)![0]; + expect(where).toMatchObject({ tenantId: "tenant_1" }); + expect(where.id).toBeUndefined(); + expect(JSON.stringify(where)).toContain('"search":"examp"'); + }); + + it("lets the database filter, sort, and paginate search candidates", async () => { + mocks.findMany.mockResolvedValue([ + enrollment("pge_1", "pn_1"), + enrollment("pge_2", "pn_2"), + ]); + const searchProvider = createSearchProvider([ + { id: "pge_2" }, + { id: "pge_1" }, + ]); + + const partners = await getPartners( + { + programId: "prog_test", + search: "examp", + page: 1, + pageSize: 25, + sortBy: "totalSaleAmount", + sortOrder: "desc", + status: "approved", + }, + { searchProvider }, + ); + + // The status filter reaches the provider, so it narrows before the ranking + // truncates rather than after. + expect(searchProvider.searchCandidates).toHaveBeenCalledWith({ + programId: "prog_test", + query: "examp", + limit: PARTNER_SEARCH_CANDIDATE_LIMIT, + filters: { + status: { values: ["approved"], exclude: false }, + groupId: undefined, + country: undefined, + partnerTagIds: undefined, + }, + }); + expect(mocks.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + programId: "prog_test", + status: "approved", + id: { in: ["pge_2", "pge_1"] }, + }), + take: 25, + skip: 0, + orderBy: { totalSaleAmount: "desc" }, + }), + ); + expect(partners.map(({ id }) => id)).toEqual(["pn_1", "pn_2"]); + }); + + it("hydrates only the requested page, in relevance order", async () => { + // Relevance order cannot be expressed in SQL, so the page is chosen in + // memory. The first query resolves surviving candidates as bare IDs; only + // the page itself is hydrated. + mocks.findMany + .mockResolvedValueOnce([{ id: "pge_3" }, { id: "pge_1" }]) + .mockResolvedValueOnce([enrollment("pge_3", "pn_3")]); + const searchProvider = createSearchProvider([ + { id: "pge_1" }, + { id: "pge_2" }, + { id: "pge_3" }, + ]); + + const partners = await getPartners( + { + programId: "prog_test", + search: "examp", + page: 2, + pageSize: 1, + sortBy: "relevance", + sortOrder: "desc", + }, + { searchProvider }, + ); + + const [idQuery] = mocks.findMany.mock.calls[0]; + expect(idQuery.select).toEqual({ id: true }); + expect(idQuery.include).toBeUndefined(); + + // pge_1 ranks first and lands on page 1, so page 2 hydrates only pge_3. + // The candidate filters stay on the query so IDs from another program + // cannot leak through. + const [pageQuery] = mocks.findMany.mock.calls[1]; + expect(pageQuery.where).toMatchObject({ + programId: "prog_test", + id: { in: ["pge_3"] }, + }); + expect(pageQuery.include).toBeDefined(); + expect(pageQuery.take).toBeUndefined(); + + expect(partners.map(({ id }) => id)).toEqual(["pn_3"]); + }); + + it("skips the hydration query when the page is empty", async () => { + mocks.findMany.mockResolvedValueOnce([{ id: "pge_1" }]); + const searchProvider = createSearchProvider([{ id: "pge_1" }]); + + const partners = await getPartners( + { + programId: "prog_test", + search: "examp", + page: 5, + pageSize: 25, + sortBy: "relevance", + sortOrder: "desc", + }, + { searchProvider }, + ); + + expect(partners).toEqual([]); + expect(mocks.findMany).toHaveBeenCalledTimes(1); + }); + + it("falls back to the database search path when the provider fails", async () => { + const searchProvider = createSearchProvider(); + vi.mocked(searchProvider.searchCandidates).mockRejectedValue( + new Error("Provider Connection Timeout"), + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + mocks.findMany.mockResolvedValue([enrollment("pge_1", "pn_1")]); + + const partners = await getPartners( + { + programId: "prog_test", + search: "examp", + page: 1, + pageSize: 25, + sortBy: "totalSaleAmount", + sortOrder: "desc", + }, + { searchProvider }, + ); + + expect(partners).toHaveLength(1); + + // The database keeps its own search predicate and no candidate filter. + const { where } = mocks.findMany.mock.calls.at(-1)![0]; + expect(where.id).toBeUndefined(); + expect(JSON.stringify(where)).toContain('"search":"examp"'); + vi.mocked(console.error).mockRestore(); + }); + + it("surfaces provider errors when the caller opts out of the fallback", async () => { + const searchProvider = createSearchProvider(); + vi.mocked(searchProvider.searchCandidates).mockRejectedValue( + new Error("Provider Connection Timeout"), + ); + + await expect( + getPartners( + { + programId: "prog_test", + search: "examp", + page: 1, + pageSize: 25, + sortBy: "totalSaleAmount", + sortOrder: "desc", + }, + { searchProvider, throwOnSearchError: true }, + ), + ).rejects.toThrow("Provider Connection Timeout"); + }); + + it("uses the existing database sort when relevance has no provider", async () => { + mocks.findMany.mockResolvedValue([]); + + await getPartners( + { + programId: "prog_test", + search: "examp", + page: 1, + pageSize: 25, + sortBy: "relevance", + sortOrder: "desc", + }, + { searchProvider: null }, + ); + + expect(mocks.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { totalSaleAmount: "desc" }, + }), + ); + }); +}); diff --git a/apps/web/tests/partners/partner-email-search-where.test.ts b/apps/web/tests/partners/partner-email-search-where.test.ts new file mode 100644 index 00000000000..a66c6880cf2 --- /dev/null +++ b/apps/web/tests/partners/partner-email-search-where.test.ts @@ -0,0 +1,36 @@ +import { buildPartnerEmailSearchWhere } from "@/lib/api/partners/program-enrollment-query"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/prisma", () => ({ + sanitizeFullTextSearch: (value: string) => value, +})); + +describe("buildPartnerEmailSearchWhere", () => { + // The candidate path decides the exact routes on the trimmed value, so this + // builder must judge the same value: a pasted " pn_… " is routed to the + // database as an exact ID, and an untrimmed exact match would find nothing. + it.each([ + [ + "a padded partner ID", + " pn_dlszeepb38rvcnrfbd0srkzb ", + { id: "pn_dlszeepb38rvcnrfbd0srkzb" }, + ], + ["a padded email", " steven@dub.co ", { email: "steven@dub.co" }], + ])("trims %s before the exact match", (_label, search, expected) => { + expect(buildPartnerEmailSearchWhere({ search })).toEqual(expected); + }); + + it("full-text searches the trimmed free text", () => { + expect(buildPartnerEmailSearchWhere({ search: " steven " })).toEqual({ + OR: [ + { email: { search: "steven" } }, + { name: { search: "steven" } }, + { companyName: { search: "steven" } }, + ], + }); + }); + + it("treats a blank search as absent", () => { + expect(buildPartnerEmailSearchWhere({ search: " " })).toEqual({}); + }); +}); diff --git a/apps/web/tests/partners/partner-search-backfill.test.ts b/apps/web/tests/partners/partner-search-backfill.test.ts new file mode 100644 index 00000000000..62730b12943 --- /dev/null +++ b/apps/web/tests/partners/partner-search-backfill.test.ts @@ -0,0 +1,130 @@ +import { + backfillPartnerSearch, + partnerSearchDocumentSelect, + type PartnerSearchDocumentSource, + type PartnerSearchProvider, +} from "@/lib/api/partners/search"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findMany: vi.fn(), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + programEnrollment: { + findMany: mocks.findMany, + }, + }, +})); + +function createSource(id: string): PartnerSearchDocumentSource { + return { + id, + programId: "prog_test", + partnerId: `pn_${id}`, + status: "approved" as const, + groupId: null, + partner: { + name: "Rafi Hasan", + email: "partner@example.com", + companyName: "Dub Partners", + description: "Developer tools educator", + country: null, + programPartnerTags: [], + platforms: [], + }, + links: [], + }; +} + +function createProvider(): PartnerSearchProvider { + return { + searchCandidates: vi.fn(), + countCandidates: vi.fn(), + upsert: vi.fn(), + delete: vi.fn(), + }; +} + +describe("backfillPartnerSearch", () => { + beforeEach(() => { + mocks.findMany.mockReset(); + }); + + it("indexes documents in keyset-paginated batches", async () => { + const searchProvider = createProvider(); + const onProgress = vi.fn(); + mocks.findMany + .mockResolvedValueOnce([createSource("pge_1"), createSource("pge_2")]) + .mockResolvedValueOnce([createSource("pge_3")]); + + const result = await backfillPartnerSearch({ + programId: "prog_test", + batchSize: 2, + searchProvider, + onProgress, + }); + + expect(mocks.findMany).toHaveBeenNthCalledWith(1, { + where: { programId: "prog_test" }, + select: partnerSearchDocumentSelect, + orderBy: { id: "asc" }, + take: 2, + }); + expect(mocks.findMany).toHaveBeenNthCalledWith(2, { + where: { + programId: "prog_test", + id: { gt: "pge_2" }, + }, + select: partnerSearchDocumentSelect, + orderBy: { id: "asc" }, + take: 2, + }); + expect(searchProvider.upsert).toHaveBeenCalledTimes(2); + expect(onProgress).toHaveBeenLastCalledWith({ + batchSize: 1, + processed: 3, + lastDocumentId: "pge_3", + }); + expect(result).toEqual({ + processed: 3, + lastDocumentId: "pge_3", + }); + }); + + it("resumes after a document ID", async () => { + const searchProvider = createProvider(); + mocks.findMany.mockResolvedValue([]); + + const result = await backfillPartnerSearch({ + programId: "prog_test", + after: "pge_100", + searchProvider, + }); + + expect(mocks.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + programId: "prog_test", + id: { gt: "pge_100" }, + }, + }), + ); + expect(result).toEqual({ + processed: 0, + lastDocumentId: "pge_100", + }); + }); + + it("requires a configured provider", async () => { + await expect( + backfillPartnerSearch({ + programId: "prog_test", + searchProvider: null, + }), + ).rejects.toThrow("Partner search provider is not configured."); + + expect(mocks.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/tests/partners/partner-search-document.test.ts b/apps/web/tests/partners/partner-search-document.test.ts new file mode 100644 index 00000000000..200a71c4d05 --- /dev/null +++ b/apps/web/tests/partners/partner-search-document.test.ts @@ -0,0 +1,59 @@ +import { + PartnerSearchDocumentSource, + serializePartnerSearchDocument, +} from "@/lib/api/partners/search"; +import { describe, expect, it } from "vitest"; + +const source: PartnerSearchDocumentSource = { + id: "pge_test", + programId: "prog_test", + partnerId: "pn_test", + status: "approved" as const, + groupId: "grp_test", + partner: { + name: "Rafi Hasan", + email: "partner@example.com", + companyName: "Dub Partners", + description: "Developer tools educator", + country: "US", + programPartnerTags: [ + { programId: "prog_test", partnerTagId: "ptag_a" }, + // A tag from a different program must not leak into this document. + { programId: "prog_other", partnerTagId: "ptag_other" }, + ], + platforms: [ + { + type: "website", + identifier: "https://rafi.dev", + }, + { + type: "twitter", + identifier: "@rafi-on-x", + }, + ], + }, + links: [{ key: "rafi" }, { key: "rafi-tools" }], +}; + +describe("serializePartnerSearchDocument", () => { + it("serializes all partner search fields", () => { + expect(serializePartnerSearchDocument(source)).toEqual({ + id: "pge_test", + programId: "prog_test", + partnerId: "pn_test", + name: "Rafi Hasan", + email: "partner@example.com", + companyName: "Dub Partners", + description: "Developer tools educator", + platformTypes: ["website", "twitter"], + platformIdentifiers: ["https://rafi.dev", "@rafi-on-x"], + linkKeys: ["rafi", "rafi-tools"], + status: "approved", + groupId: "grp_test", + country: "US", + // The tag from prog_other is dropped: tags are per program. + partnerTagIds: ["ptag_a"], + }); + }); + +}); diff --git a/apps/web/tests/partners/partner-search-find-candidates.test.ts b/apps/web/tests/partners/partner-search-find-candidates.test.ts new file mode 100644 index 00000000000..e06bc59b640 --- /dev/null +++ b/apps/web/tests/partners/partner-search-find-candidates.test.ts @@ -0,0 +1,102 @@ +import type { PartnerSearchProvider } from "@/lib/api/partners/search"; +import { findPartnerSearchCandidates } from "@/lib/api/partners/search"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ findUnique: vi.fn() })); + +vi.mock("@/lib/prisma", () => ({ + prisma: { partner: { findUnique: mocks.findUnique } }, +})); + +function createProvider( + searchCandidates = vi.fn().mockResolvedValue({ hits: [{ id: "pge_1" }] }), +) { + return { + searchCandidates, + countCandidates: vi.fn(), + upsert: vi.fn(), + delete: vi.fn(), + } as unknown as PartnerSearchProvider & { + searchCandidates: typeof searchCandidates; + }; +} + +const query = (search: string) => ({ + programId: "prog_1", + query: search, + limit: 10, +}); + +describe("findPartnerSearchCandidates", () => { + beforeEach(() => { + mocks.findUnique.mockReset(); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("answers a known email from the database, without calling the provider", async () => { + mocks.findUnique.mockResolvedValue({ id: "pn_1" }); + const provider = createProvider(); + + await expect( + findPartnerSearchCandidates(provider, query("steven@dub.co")), + ).resolves.toBeNull(); + + expect(mocks.findUnique).toHaveBeenCalledWith({ + where: { email: "steven@dub.co" }, + select: { id: true }, + }); + expect(provider.searchCandidates).not.toHaveBeenCalled(); + }); + + it("falls back to the provider when no partner has that address", async () => { + // `steven@dub.co` on the way to `steven@dub.com` is a complete address that + // matches nothing, and n-grams still find the longer one. + mocks.findUnique.mockResolvedValue(null); + const provider = createProvider(); + + await expect( + findPartnerSearchCandidates(provider, query("steven@dub.co")), + ).resolves.toEqual({ hits: [{ id: "pge_1" }] }); + + expect(provider.searchCandidates).toHaveBeenCalledOnce(); + }); + + it.each([ + ["half-typed", "steven@"], + ["domain only", "@dub.co"], + ["no dot in the domain", "steven@dub"], + ["a name", "steven tey"], + ])( + "does not treat %s as an address, so the database is not queried", + async (_label, search) => { + const provider = createProvider(); + + await findPartnerSearchCandidates(provider, query(search)); + + expect(mocks.findUnique).not.toHaveBeenCalled(); + expect(provider.searchCandidates).toHaveBeenCalledOnce(); + }, + ); + + it("degrades to the database search path when the provider throws", async () => { + const provider = createProvider( + vi.fn().mockRejectedValue(new Error("down")), + ); + + await expect( + findPartnerSearchCandidates(provider, query("steven")), + ).resolves.toBeNull(); + }); + + it("surfaces provider failures when the caller opts in", async () => { + const provider = createProvider( + vi.fn().mockRejectedValue(new Error("down")), + ); + + await expect( + findPartnerSearchCandidates(provider, query("steven"), { + throwOnError: true, + }), + ).rejects.toThrow("down"); + }); +}); diff --git a/apps/web/tests/partners/partner-search-order.test.ts b/apps/web/tests/partners/partner-search-order.test.ts new file mode 100644 index 00000000000..de3cd2fd44a --- /dev/null +++ b/apps/web/tests/partners/partner-search-order.test.ts @@ -0,0 +1,21 @@ +import { orderByPartnerSearchHits } from "@/lib/api/partners/search"; +import { describe, expect, it } from "vitest"; + +describe("orderByPartnerSearchHits", () => { + it("restores provider order and ignores missing records", () => { + const records = [ + { id: "pge_1", name: "First" }, + { id: "pge_2", name: "Second" }, + ]; + const hits = [ + { id: "pge_2", partnerId: "pn_2" }, + { id: "pge_missing", partnerId: "pn_missing" }, + { id: "pge_1", partnerId: "pn_1" }, + ]; + + expect(orderByPartnerSearchHits(records, hits)).toEqual([ + { id: "pge_2", name: "Second" }, + { id: "pge_1", name: "First" }, + ]); + }); +}); diff --git a/apps/web/tests/partners/partner-search-provider-switches.test.ts b/apps/web/tests/partners/partner-search-provider-switches.test.ts new file mode 100644 index 00000000000..78e2db08cee --- /dev/null +++ b/apps/web/tests/partners/partner-search-provider-switches.test.ts @@ -0,0 +1,79 @@ +import { + getPartnerSearchProvider, + getPartnerSearchReadProvider, + isPartnerSearchReadEnabled, +} from "@/lib/api/partners/search"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/api/partners/search/providers/turbopuffer", () => ({ + createTurbopufferPartnerSearchProvider: () => ({ + searchCandidates: vi.fn(), + countCandidates: vi.fn(), + upsert: vi.fn(), + delete: vi.fn(), + }), +})); + +const originalEnv = { ...process.env }; + +describe("partner search switches", () => { + beforeEach(() => { + delete process.env.TURBOPUFFER_API_KEY; + delete process.env.PARTNER_SEARCH_READ_ENABLED; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it("writes nowhere until the key is configured", () => { + expect(getPartnerSearchProvider()).toBeNull(); + }); + + // The rollout depends on this. Syncs have to run for the whole backfill + // before the index is complete enough to read, so the key alone must not + // turn reads on. + it("keeps reads on the database when only the key is configured", () => { + process.env.TURBOPUFFER_API_KEY = "tpuf_test"; + + expect(getPartnerSearchProvider()).not.toBeNull(); + expect(isPartnerSearchReadEnabled()).toBe(false); + expect(getPartnerSearchReadProvider()).toBeNull(); + }); + + it("reads from the index once both switches are on", () => { + process.env.TURBOPUFFER_API_KEY = "tpuf_test"; + process.env.PARTNER_SEARCH_READ_ENABLED = "true"; + + expect(getPartnerSearchReadProvider()).not.toBeNull(); + }); + + // The point of the split: clearing the read flag stops search without + // stopping indexing, so deletions keep being applied while rolled back and + // coming back needs no rebuild. + it("keeps writing while reads are switched off", () => { + process.env.TURBOPUFFER_API_KEY = "tpuf_test"; + process.env.PARTNER_SEARCH_READ_ENABLED = "false"; + + expect(getPartnerSearchReadProvider()).toBeNull(); + expect(getPartnerSearchProvider()).not.toBeNull(); + }); + + // Otherwise the read flag alone would look enabled with no index behind it, + // which is the one combination that returns empty results rather than + // falling back. + it("cannot read without a key, however the read flag is set", () => { + process.env.PARTNER_SEARCH_READ_ENABLED = "true"; + + expect(getPartnerSearchReadProvider()).toBeNull(); + }); + + it('treats anything other than "true" as off', () => { + process.env.TURBOPUFFER_API_KEY = "tpuf_test"; + + for (const value of ["", "false", "1", "yes", "TRUE"]) { + process.env.PARTNER_SEARCH_READ_ENABLED = value; + expect(getPartnerSearchReadProvider()).toBeNull(); + } + }); +}); diff --git a/apps/web/tests/partners/partner-search-query.test.ts b/apps/web/tests/partners/partner-search-query.test.ts new file mode 100644 index 00000000000..497b4cf8c50 --- /dev/null +++ b/apps/web/tests/partners/partner-search-query.test.ts @@ -0,0 +1,152 @@ +import { + buildPartnerSearchCandidateQuery, + isLinkShapedQuery, + PARTNER_SEARCH_CANDIDATE_LIMIT, + stripProgramDomain, +} from "@/lib/api/partners/search"; +import { describe, expect, it } from "vitest"; + +const defaultInput = { + programId: "prog_test", + search: " examp ", + page: 3, + pageSize: 25, + sortBy: "totalSaleAmount" as const, + sortOrder: "desc" as const, +}; + +describe("buildPartnerSearchCandidateQuery", () => { + it("builds a provider-neutral relevance candidate request", () => { + expect(buildPartnerSearchCandidateQuery(defaultInput)).toEqual({ + programId: "prog_test", + query: "examp", + limit: PARTNER_SEARCH_CANDIDATE_LIMIT, + filters: { + status: undefined, + groupId: undefined, + country: undefined, + partnerTagIds: undefined, + }, + }); + }); + + it("passes the discrete filters through, with exclusion", () => { + expect( + buildPartnerSearchCandidateQuery({ + ...defaultInput, + status: "approved", + groupId: ["grp_1", "grp_2"], + country: "US", + countryOperator: "NOT IN", + partnerTagId: ["ptag_1"], + }), + ).toMatchObject({ + filters: { + status: { values: ["approved"], exclude: false }, + groupId: { values: ["grp_1", "grp_2"], exclude: false }, + // Exclusion also matches partners with no country, matching the + // database's OR against IS NULL. + country: { values: ["US"], exclude: true }, + partnerTagIds: { values: ["ptag_1"], exclude: false }, + }, + }); + }); + + it.each([ + ["missing search", { ...defaultInput, search: undefined }], + ["empty search", { ...defaultInput, search: " " }], + ["exact email", { ...defaultInput, email: "partner@example.com" }], + ["tenant ID", { ...defaultInput, tenantId: "tenant_test" }], + ])("keeps %s on the database path", (_name, input) => { + expect(buildPartnerSearchCandidateQuery(input)).toBeNull(); + }); + + describe("pasted partner IDs", () => { + it.each([ + ["24-char suffix, the production minimum", "pn_dlszeepb38rvcnrfbd0srkzb"], + ["25-char suffix, the common case", "pn_1K0NM7HCN944PEMZ3CQPH43H8"], + ])( + "keeps %s on the database, which has it as a primary key", + (_label, search) => { + expect( + buildPartnerSearchCandidateQuery({ programId: "prog_1", search }), + ).toBeNull(); + }, + ); + + it.each([ + ["a bare prefix", "pn_"], + ["a partial ID", "pn_dls"], + ["one character short of the minimum", "pn_dlszeepb38rvcnrfbd0srkz"], + ["an ID with a space", "pn_dlszeepb38rvcnrfbd0srkzb other"], + ])("sends %s to the provider", (_label, search) => { + expect( + buildPartnerSearchCandidateQuery({ programId: "prog_1", search }), + ).toMatchObject({ query: search }); + }); + }); +}); + +describe("isLinkShapedQuery", () => { + it.each([ + "go.acme.com/partner", + "https://go.acme.com/partner", + "www.go.acme.com/partner", + "go.acme.com", + " go.acme.com/partner ", + ])("recognizes %s", (query) => { + expect(isLinkShapedQuery(query)).toBe(true); + }); + + it.each(["steven", "steven tey", "steven@dub.co", "pn_123", "acme/partner"])( + "does not recognize %s", + (query) => { + expect(isLinkShapedQuery(query)).toBe(false); + }, + ); +}); + +describe("stripProgramDomain", () => { + const domain = "go.acme.com"; + + it.each([ + ["go.acme.com/partner", "partner"], + ["go.acme.com/partner/", "partner"], + ["https://go.acme.com/partner", "partner"], + ["https://go.acme.com/partner/", "partner"], + ["http://www.go.acme.com/partner", "partner"], + ["GO.ACME.COM/Partner", "Partner"], + ["go.acme.com/partner?utm_source=x", "partner"], + ["go.acme.com/partner/?utm_source=x", "partner"], + ["go.acme.com/partner#top", "partner"], + ["go.acme.com/partner/#top", "partner"], + ["go.acme.com/nested/partner", "nested/partner"], + ["go.acme.com/nested/partner/", "nested/partner"], + [" go.acme.com/partner ", "partner"], + ])("reduces %s to its key", (query, key) => { + expect(stripProgramDomain(query, domain)).toBe(key); + }); + + it("matches a program domain stored with www.", () => { + expect(stripProgramDomain("go.acme.com/partner", "www.go.acme.com")).toBe( + "partner", + ); + }); + + it.each([ + ["another domain", "dub.sh/partner"], + ["a longer host", "app.go.acme.com/partner"], + ["the bare domain", "go.acme.com"], + ["the bare domain with a slash", "go.acme.com/"], + ["a plain word", "partner"], + ["an email", "steven@go.acme.com"], + ])("leaves %s unchanged", (_label, query) => { + expect(stripProgramDomain(query, domain)).toBe(query); + }); + + it("leaves the query unchanged when the program has no domain", () => { + expect(stripProgramDomain("go.acme.com/partner", null)).toBe( + "go.acme.com/partner", + ); + }); +}); diff --git a/apps/web/tests/partners/partner-search-resilience.test.ts b/apps/web/tests/partners/partner-search-resilience.test.ts new file mode 100644 index 00000000000..a76739d17ba --- /dev/null +++ b/apps/web/tests/partners/partner-search-resilience.test.ts @@ -0,0 +1,37 @@ +import { withTransientRetry } from "@/lib/api/partners/search/providers/resilience"; +import { APIError } from "@turbopuffer/turbopuffer"; +import { describe, expect, it, vi } from "vitest"; + +const apiError = (status: number) => + new APIError(status, {}, `status ${status}`, new Headers()); + +const failingOnce = (error: unknown) => + vi + .fn() + .mockRejectedValueOnce(error) + .mockResolvedValueOnce("ok"); + +describe("withTransientRetry", () => { + it.each([ + ["a rate limit", apiError(429)], + ["a server error", apiError(503)], + ["a network failure", new Error("fetch failed")], + ])("retries %s", async (_label, error) => { + const operation = failingOnce(error); + + await expect(withTransientRetry(operation)).resolves.toBe("ok"); + expect(operation).toHaveBeenCalledTimes(2); + }); + + it.each([ + // The status comes from the typed error, not the message, so digits in an + // ordinary message cannot make a permanent failure look transient. + ["a message that merely contains 500", new Error("wrote 500 documents")], + ["a client error", apiError(400)], + ])("does not retry %s", async (_label, error) => { + const operation = vi.fn().mockRejectedValue(error); + + await expect(withTransientRetry(operation)).rejects.toBe(error); + expect(operation).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/tests/partners/partner-search-resolve-query.test.ts b/apps/web/tests/partners/partner-search-resolve-query.test.ts new file mode 100644 index 00000000000..4602dc4efcb --- /dev/null +++ b/apps/web/tests/partners/partner-search-resolve-query.test.ts @@ -0,0 +1,71 @@ +import { resolvePartnerSearchCandidateQuery } from "@/lib/api/partners/search"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ findUnique: vi.fn() })); + +vi.mock("@/lib/prisma", () => ({ + prisma: { program: { findUnique: mocks.findUnique } }, +})); + +const input = (search: string) => ({ + programId: "prog_test", + search, + page: 1, + pageSize: 25, + sortBy: "totalSaleAmount" as const, + sortOrder: "desc" as const, +}); + +describe("resolvePartnerSearchCandidateQuery", () => { + beforeEach(() => { + mocks.findUnique.mockReset(); + }); + + it("reduces a link on the program domain to its key", async () => { + mocks.findUnique.mockResolvedValue({ domain: "go.acme.com" }); + + const query = await resolvePartnerSearchCandidateQuery( + input("https://go.acme.com/partner"), + ); + + expect(query?.query).toBe("partner"); + expect(mocks.findUnique).toHaveBeenCalledWith({ + where: { id: "prog_test" }, + select: { domain: true }, + }); + }); + + it("keeps a link on another domain", async () => { + mocks.findUnique.mockResolvedValue({ domain: "go.acme.com" }); + + const query = await resolvePartnerSearchCandidateQuery( + input("dub.sh/partner"), + ); + + expect(query?.query).toBe("dub.sh/partner"); + }); + + it("keeps the link when the program is not found", async () => { + mocks.findUnique.mockResolvedValue(null); + + const query = await resolvePartnerSearchCandidateQuery( + input("go.acme.com/partner"), + ); + + expect(query?.query).toBe("go.acme.com/partner"); + }); + + it("does not look the program up for a plain query", async () => { + const query = await resolvePartnerSearchCandidateQuery(input("steven")); + + expect(query?.query).toBe("steven"); + expect(mocks.findUnique).not.toHaveBeenCalled(); + }); + + it("returns null when there is no candidate query", async () => { + await expect(resolvePartnerSearchCandidateQuery(input(""))).resolves.toBe( + null, + ); + expect(mocks.findUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/tests/partners/partner-search-sync-job.test.ts b/apps/web/tests/partners/partner-search-sync-job.test.ts new file mode 100644 index 00000000000..89062a12664 --- /dev/null +++ b/apps/web/tests/partners/partner-search-sync-job.test.ts @@ -0,0 +1,166 @@ +import { partnerSearchSyncJob } from "@/lib/jobs/handlers/partner-search-sync-job"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// A small batch size keeps the pagination cases readable. +const BATCH_SIZE = 3; + +const mocks = vi.hoisted(() => ({ + getPartnerSearchProvider: vi.fn(), + syncPartnerSearchDocuments: vi.fn(), + findPartnerSearchSyncEnrollmentIds: vi.fn(), +})); + +vi.mock("@/lib/api/partners/search", () => ({ + PARTNER_SEARCH_SYNC_BATCH_SIZE: 3, + getPartnerSearchProvider: mocks.getPartnerSearchProvider, + syncPartnerSearchDocuments: mocks.syncPartnerSearchDocuments, + findPartnerSearchSyncEnrollmentIds: mocks.findPartnerSearchSyncEnrollmentIds, +})); + +const searchProvider = { name: "turbopuffer" }; + +describe("partnerSearchSyncJob", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mocks.getPartnerSearchProvider.mockReset().mockReturnValue(searchProvider); + mocks.syncPartnerSearchDocuments + .mockReset() + .mockResolvedValue({ upserted: 0, deleted: 0 }); + mocks.findPartnerSearchSyncEnrollmentIds.mockReset().mockResolvedValue([]); + }); + + it("skips entirely when no provider is configured", async () => { + mocks.getPartnerSearchProvider.mockReturnValue(null); + + await partnerSearchSyncJob.execute({ + type: "enrollments", + enrollmentIds: ["pge_1"], + }); + + expect(mocks.syncPartnerSearchDocuments).not.toHaveBeenCalled(); + expect(mocks.findPartnerSearchSyncEnrollmentIds).not.toHaveBeenCalled(); + }); + + it("syncs the enrollment ids it is given", async () => { + await partnerSearchSyncJob.execute({ + type: "enrollments", + enrollmentIds: ["pge_1", "pge_2"], + }); + + expect(mocks.syncPartnerSearchDocuments).toHaveBeenCalledWith({ + enrollmentIds: ["pge_1", "pge_2"], + searchProvider, + }); + expect(mocks.findPartnerSearchSyncEnrollmentIds).not.toHaveBeenCalled(); + }); + + it("resolves a partner fan-out before syncing", async () => { + mocks.findPartnerSearchSyncEnrollmentIds.mockResolvedValue([ + "pge_1", + "pge_2", + ]); + + await partnerSearchSyncJob.execute({ + type: "partners", + partnerIds: ["pn_1"], + }); + + expect(mocks.findPartnerSearchSyncEnrollmentIds).toHaveBeenCalledWith({ + partnerIds: ["pn_1"], + programId: undefined, + after: undefined, + take: BATCH_SIZE, + }); + expect(mocks.syncPartnerSearchDocuments).toHaveBeenCalledWith({ + enrollmentIds: ["pge_1", "pge_2"], + searchProvider, + }); + }); + + it("continues from the last enrollment when a page comes back full", async () => { + const dispatch = vi + .spyOn(partnerSearchSyncJob, "dispatch") + .mockResolvedValue({ status: "published", messageId: "msg_1" }); + + mocks.findPartnerSearchSyncEnrollmentIds.mockResolvedValue([ + "pge_1", + "pge_2", + "pge_3", + ]); + + await partnerSearchSyncJob.execute({ + type: "partners", + partnerIds: ["pn_1"], + programId: "prog_1", + }); + + expect(dispatch).toHaveBeenCalledWith( + { + type: "partners", + partnerIds: ["pn_1"], + programId: "prog_1", + after: "pge_3", + }, + { delay: 1 }, + ); + }); + + it("stops when a page comes back short", async () => { + const dispatch = vi + .spyOn(partnerSearchSyncJob, "dispatch") + .mockResolvedValue({ status: "published", messageId: "msg_1" }); + + mocks.findPartnerSearchSyncEnrollmentIds.mockResolvedValue([ + "pge_1", + "pge_2", + ]); + + await partnerSearchSyncJob.execute({ + type: "partners", + partnerIds: ["pn_1"], + }); + + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("does not sync or continue when a partner has no enrollments", async () => { + const dispatch = vi + .spyOn(partnerSearchSyncJob, "dispatch") + .mockResolvedValue({ status: "published", messageId: "msg_1" }); + + mocks.findPartnerSearchSyncEnrollmentIds.mockResolvedValue([]); + + await partnerSearchSyncJob.execute({ + type: "partners", + partnerIds: ["pn_1"], + }); + + expect(mocks.syncPartnerSearchDocuments).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("rejects a payload with no ids, so an empty fan-out cannot be queued", async () => { + await expect( + partnerSearchSyncJob.execute({ type: "enrollments", enrollmentIds: [] }), + ).rejects.toThrow(); + + await expect( + partnerSearchSyncJob.execute({ type: "partners", partnerIds: [] }), + ).rejects.toThrow(); + }); + + it("rejects a payload larger than one batch", async () => { + await expect( + partnerSearchSyncJob.execute({ + type: "enrollments", + enrollmentIds: ["pge_1", "pge_2", "pge_3", "pge_4"], + }), + ).rejects.toThrow(); + }); + + it("rejects an unknown payload shape", async () => { + await expect( + partnerSearchSyncJob.execute({ enrollmentIds: ["pge_1"] }), + ).rejects.toThrow(); + }); +}); diff --git a/apps/web/tests/partners/partner-search-sync.test.ts b/apps/web/tests/partners/partner-search-sync.test.ts new file mode 100644 index 00000000000..6973a2e49fe --- /dev/null +++ b/apps/web/tests/partners/partner-search-sync.test.ts @@ -0,0 +1,231 @@ +import { + findPartnerSearchSyncEnrollmentIds, + syncPartnerSearchDocuments, + type PartnerSearchDocumentSource, + type PartnerSearchProvider, +} from "@/lib/api/partners/search"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + findMany: vi.fn(), +})); + +vi.mock("@/lib/prisma", () => ({ + prisma: { + programEnrollment: { + findMany: mocks.findMany, + }, + }, +})); + +function createSource( + id: string, + overrides: Partial = {}, +): PartnerSearchDocumentSource { + return { + id, + programId: "prog_test", + partnerId: `pn_${id}`, + status: "approved" as const, + groupId: null, + partner: { + name: "Rafi Hasan", + email: "partner@example.com", + companyName: "Dub Partners", + description: "Developer tools educator", + country: null, + programPartnerTags: [], + platforms: [], + }, + links: [], + ...overrides, + }; +} + +function createProvider(): PartnerSearchProvider { + return { + searchCandidates: vi.fn(), + countCandidates: vi.fn(), + upsert: vi.fn(), + delete: vi.fn(), + }; +} + +describe("syncPartnerSearchDocuments", () => { + beforeEach(() => { + mocks.findMany.mockReset(); + }); + + it("upserts the enrollments the database still has", async () => { + const searchProvider = createProvider(); + mocks.findMany.mockResolvedValueOnce([ + createSource("pge_1"), + createSource("pge_2"), + ]); + + const result = await syncPartnerSearchDocuments({ + enrollmentIds: ["pge_1", "pge_2"], + searchProvider, + }); + + expect(result).toEqual({ upserted: 2, deleted: 0 }); + expect(searchProvider.delete).not.toHaveBeenCalled(); + expect(searchProvider.upsert).toHaveBeenCalledTimes(1); + expect( + vi.mocked(searchProvider.upsert).mock.calls[0][0].map(({ id }) => id), + ).toEqual(["pge_1", "pge_2"]); + }); + + it("deletes the enrollments the database no longer has", async () => { + const searchProvider = createProvider(); + mocks.findMany.mockResolvedValueOnce([createSource("pge_1")]); + + const result = await syncPartnerSearchDocuments({ + enrollmentIds: ["pge_1", "pge_deleted"], + searchProvider, + }); + + expect(result).toEqual({ upserted: 1, deleted: 1 }); + expect(searchProvider.delete).toHaveBeenCalledWith(["pge_deleted"]); + }); + + it("deletes without upserting when every enrollment is gone", async () => { + const searchProvider = createProvider(); + mocks.findMany.mockResolvedValueOnce([]); + + const result = await syncPartnerSearchDocuments({ + enrollmentIds: ["pge_gone"], + searchProvider, + }); + + expect(result).toEqual({ upserted: 0, deleted: 1 }); + expect(searchProvider.upsert).not.toHaveBeenCalled(); + expect(searchProvider.delete).toHaveBeenCalledWith(["pge_gone"]); + }); + + it("deduplicates the requested ids so one enrollment is written once", async () => { + const searchProvider = createProvider(); + mocks.findMany.mockResolvedValueOnce([createSource("pge_1")]); + + await syncPartnerSearchDocuments({ + enrollmentIds: ["pge_1", "pge_1", "pge_1"], + searchProvider, + }); + + expect(mocks.findMany.mock.calls[0][0].where.id.in).toEqual(["pge_1"]); + expect(vi.mocked(searchProvider.upsert).mock.calls[0][0]).toHaveLength(1); + }); + + it("does not touch the database when no provider is configured", async () => { + const result = await syncPartnerSearchDocuments({ + enrollmentIds: ["pge_1"], + searchProvider: null, + }); + + expect(result).toEqual({ upserted: 0, deleted: 0 }); + expect(mocks.findMany).not.toHaveBeenCalled(); + }); + + it("does not touch the database when there is nothing to sync", async () => { + const searchProvider = createProvider(); + + const result = await syncPartnerSearchDocuments({ + enrollmentIds: [], + searchProvider, + }); + + expect(result).toEqual({ upserted: 0, deleted: 0 }); + expect(mocks.findMany).not.toHaveBeenCalled(); + }); + + it("serializes the document the index stores", async () => { + const searchProvider = createProvider(); + mocks.findMany.mockResolvedValueOnce([ + createSource("pge_1", { + partner: { + name: "Rafi Hasan", + email: "rafi@example.com", + companyName: null, + description: null, + country: "US", + // Tags from another program must not leak into this document. + programPartnerTags: [ + { programId: "prog_test", partnerTagId: "ptag_1" }, + { programId: "prog_other", partnerTagId: "ptag_other" }, + ], + platforms: [{ type: "youtube" as const, identifier: "rafi" }], + }, + }), + ]); + + await syncPartnerSearchDocuments({ + enrollmentIds: ["pge_1"], + searchProvider, + }); + + expect(vi.mocked(searchProvider.upsert).mock.calls[0][0][0]).toMatchObject({ + id: "pge_1", + country: "US", + partnerTagIds: ["ptag_1"], + platformIdentifiers: ["rafi"], + }); + }); +}); + +describe("findPartnerSearchSyncEnrollmentIds", () => { + beforeEach(() => { + mocks.findMany.mockReset(); + }); + + it("resolves every enrollment a partner has when no program is given", async () => { + mocks.findMany.mockResolvedValueOnce([{ id: "pge_1" }, { id: "pge_2" }]); + + const ids = await findPartnerSearchSyncEnrollmentIds({ + partnerIds: ["pn_1"], + }); + + expect(ids).toEqual(["pge_1", "pge_2"]); + + const { where, orderBy } = mocks.findMany.mock.calls[0][0]; + expect(where).toEqual({ partnerId: { in: ["pn_1"] } }); + expect(orderBy).toEqual({ id: "asc" }); + }); + + it("narrows to a single program when one is given", async () => { + mocks.findMany.mockResolvedValueOnce([{ id: "pge_1" }]); + + await findPartnerSearchSyncEnrollmentIds({ + partnerIds: ["pn_1"], + programId: "prog_test", + }); + + expect(mocks.findMany.mock.calls[0][0].where).toEqual({ + partnerId: { in: ["pn_1"] }, + programId: "prog_test", + }); + }); + + it("pages past the cursor so a large fan-out can resume", async () => { + mocks.findMany.mockResolvedValueOnce([{ id: "pge_3" }]); + + await findPartnerSearchSyncEnrollmentIds({ + partnerIds: ["pn_1"], + after: "pge_2", + take: 2, + }); + + const { where, take } = mocks.findMany.mock.calls[0][0]; + expect(where).toEqual({ + partnerId: { in: ["pn_1"] }, + id: { gt: "pge_2" }, + }); + expect(take).toBe(2); + }); + + it("does not query when there are no partners", async () => { + const ids = await findPartnerSearchSyncEnrollmentIds({ partnerIds: [] }); + + expect(ids).toEqual([]); + expect(mocks.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/tests/partners/queue-partner-search-sync.test.ts b/apps/web/tests/partners/queue-partner-search-sync.test.ts new file mode 100644 index 00000000000..62e9a628c70 --- /dev/null +++ b/apps/web/tests/partners/queue-partner-search-sync.test.ts @@ -0,0 +1,262 @@ +import { + PARTNER_SEARCH_SYNC_DELAY_SECONDS, + queuePartnerSearchSync, + queuePartnerSearchSyncForLinks, +} from "@/lib/api/partners/queue-partner-search-sync"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const BATCH_SIZE = 3; + +const mocks = vi.hoisted(() => ({ + getPartnerSearchProvider: vi.fn(), + dispatchBatch: vi.fn(), +})); + +vi.mock("@/lib/api/partners/search", () => ({ + PARTNER_SEARCH_SYNC_BATCH_SIZE: 3, + getPartnerSearchProvider: mocks.getPartnerSearchProvider, +})); + +vi.mock("@/lib/jobs/handlers/partner-search-sync-job", () => ({ + partnerSearchSyncJob: { + dispatchBatch: mocks.dispatchBatch, + }, +})); + +/** The options the dispatcher computed for each payload it queued. */ +function dispatchedWithOptions() { + const [payloads, getOptions] = mocks.dispatchBatch.mock.calls[0]; + + return payloads.map((payload: unknown, index: number) => ({ + payload, + options: getOptions?.(payload, index), + })); +} + +describe("queuePartnerSearchSync", () => { + beforeEach(() => { + mocks.getPartnerSearchProvider + .mockReset() + .mockReturnValue({ name: "turbopuffer" }); + mocks.dispatchBatch.mockReset().mockResolvedValue({ + published: 1, + deferred: 0, + failed: 0, + results: [], + }); + }); + + it("queues nothing when no provider is configured", async () => { + mocks.getPartnerSearchProvider.mockReturnValue(null); + + await queuePartnerSearchSync({ enrollmentIds: ["pge_1"] }); + + expect(mocks.dispatchBatch).not.toHaveBeenCalled(); + }); + + it("queues nothing when there is nothing to sync", async () => { + await queuePartnerSearchSync({ enrollmentIds: [], partnerIds: [] }); + + expect(mocks.dispatchBatch).not.toHaveBeenCalled(); + }); + + it("deduplicates ids before queueing", async () => { + await queuePartnerSearchSync({ enrollmentIds: ["pge_1", "pge_1"] }); + + expect(mocks.dispatchBatch.mock.calls[0][0]).toEqual([ + { type: "enrollments", enrollmentIds: ["pge_1"] }, + ]); + }); + + it("chunks past the batch size so no payload exceeds what the job accepts", async () => { + await queuePartnerSearchSync({ + enrollmentIds: ["pge_1", "pge_2", "pge_3", "pge_4"], + }); + + expect(mocks.dispatchBatch.mock.calls[0][0]).toEqual([ + { type: "enrollments", enrollmentIds: ["pge_1", "pge_2", "pge_3"] }, + { type: "enrollments", enrollmentIds: ["pge_4"] }, + ]); + }); + + it("queues both shapes when a caller has enrollments and partners", async () => { + await queuePartnerSearchSync({ + enrollmentIds: ["pge_1"], + partnerIds: ["pn_1"], + programId: "prog_1", + }); + + expect(mocks.dispatchBatch.mock.calls[0][0]).toEqual([ + { type: "enrollments", enrollmentIds: ["pge_1"] }, + { type: "partners", partnerIds: ["pn_1"], programId: "prog_1" }, + ]); + }); + + // QStash suppresses a repeated key for ten minutes from the first publish, + // not just while one is pending, so keying by subject would drop the second of + // two changes rather than collapse them. + it("never deduplicates, so a later change cannot be dropped", async () => { + await queuePartnerSearchSync({ enrollmentIds: ["pge_1"] }); + await queuePartnerSearchSync({ enrollmentIds: ["pge_1"] }); + + expect(mocks.dispatchBatch).toHaveBeenCalledTimes(2); + + for (const [, getOptions] of mocks.dispatchBatch.mock.calls) { + const options = getOptions( + { type: "enrollments", enrollmentIds: ["pge_1"] }, + 0, + ); + expect(options.deduplicationId).toBeUndefined(); + expect(options.delay).toBe(PARTNER_SEARCH_SYNC_DELAY_SECONDS); + } + }); + + it("swallows a dispatch failure so it cannot break the mutation that queued it", async () => { + mocks.dispatchBatch.mockRejectedValue(new Error("qstash unreachable")); + + await expect( + queuePartnerSearchSync({ enrollmentIds: ["pge_1"] }), + ).resolves.toBeUndefined(); + }); + + it("never builds a payload larger than the job's batch size", async () => { + await queuePartnerSearchSync({ + partnerIds: Array.from({ length: 7 }, (_, index) => `pn_${index}`), + }); + + for (const payload of mocks.dispatchBatch.mock.calls[0][0]) { + expect(payload.partnerIds.length).toBeLessThanOrEqual(BATCH_SIZE); + } + }); +}); + +describe("queuePartnerSearchSyncForLinks", () => { + beforeEach(() => { + mocks.getPartnerSearchProvider + .mockReset() + .mockReturnValue({ name: "turbopuffer" }); + mocks.dispatchBatch.mockReset().mockResolvedValue({ + published: 1, + deferred: 0, + failed: 0, + results: [], + }); + }); + + // The bulk link helpers call this on every write, including workspace-link + // imports of a hundred thousand rows. Those carry no partner, so this must + // cost nothing rather than being guarded at each call site. + it("queues nothing for links with no program or partner", async () => { + await queuePartnerSearchSyncForLinks([ + { programId: null, partnerId: null }, + { programId: "prog_1", partnerId: null }, + { programId: null, partnerId: "pn_1" }, + ]); + + expect(mocks.dispatchBatch).not.toHaveBeenCalled(); + }); + + it("queues one payload per program, not per link", async () => { + await queuePartnerSearchSyncForLinks([ + { programId: "prog_1", partnerId: "pn_1" }, + { programId: "prog_1", partnerId: "pn_2" }, + { programId: "prog_1", partnerId: "pn_1" }, + { programId: "prog_2", partnerId: "pn_3" }, + ]); + + expect(mocks.dispatchBatch).toHaveBeenCalledTimes(2); + + const queued = mocks.dispatchBatch.mock.calls.map( + ([payloads]) => payloads[0], + ); + + expect(queued).toEqual([ + { type: "partners", partnerIds: ["pn_1", "pn_2"], programId: "prog_1" }, + { type: "partners", partnerIds: ["pn_3"], programId: "prog_2" }, + ]); + }); + + it("passes an explicit delay through", async () => { + await queuePartnerSearchSyncForLinks( + [{ programId: "prog_1", partnerId: "pn_1" }], + { delay: 60 }, + ); + + const [, getOptions] = mocks.dispatchBatch.mock.calls[0]; + + expect( + getOptions({ type: "partners", partnerIds: ["pn_1"] }, 0).delay, + ).toBe(60); + }); + + it("defaults to the interactive delay when none is given", async () => { + await queuePartnerSearchSyncForLinks([ + { programId: "prog_1", partnerId: "pn_1" }, + ]); + + const [, getOptions] = mocks.dispatchBatch.mock.calls[0]; + + expect( + getOptions({ type: "partners", partnerIds: ["pn_1"] }, 0).delay, + ).toBe(PARTNER_SEARCH_SYNC_DELAY_SECONDS); + }); + + it("queues nothing for an empty link set", async () => { + await queuePartnerSearchSyncForLinks([]); + + expect(mocks.dispatchBatch).not.toHaveBeenCalled(); + }); +}); + +describe("queuePartnerSearchSyncForLinks: ownership transfer", () => { + beforeEach(() => { + mocks.getPartnerSearchProvider + .mockReset() + .mockReturnValue({ name: "turbopuffer" }); + mocks.dispatchBatch.mockReset().mockResolvedValue({ + published: 1, + deferred: 0, + failed: 0, + results: [], + }); + }); + + // A link update can rewrite partnerId, so the former owner has to be + // re-serialized without the link. Syncing only the new owner would leave it + // searchable under a partner who no longer has it. + it("syncs both owners when a link moves between partners", async () => { + await queuePartnerSearchSyncForLinks([ + { programId: "prog_1", partnerId: "pn_old" }, + { programId: "prog_1", partnerId: "pn_new" }, + ]); + + expect(mocks.dispatchBatch.mock.calls[0][0]).toEqual([ + { + type: "partners", + partnerIds: ["pn_old", "pn_new"], + programId: "prog_1", + }, + ]); + }); + + it("syncs both programs when a link moves across programs", async () => { + await queuePartnerSearchSyncForLinks([ + { programId: "prog_old", partnerId: "pn_1" }, + { programId: "prog_new", partnerId: "pn_1" }, + ]); + + expect(mocks.dispatchBatch).toHaveBeenCalledTimes(2); + }); + + // An unowned link gaining a partner has no former owner to re-serialize. + it("skips the null side when a link gains its first owner", async () => { + await queuePartnerSearchSyncForLinks([ + { programId: null, partnerId: null }, + { programId: "prog_1", partnerId: "pn_1" }, + ]); + + expect(mocks.dispatchBatch.mock.calls[0][0]).toEqual([ + { type: "partners", partnerIds: ["pn_1"], programId: "prog_1" }, + ]); + }); +}); diff --git a/apps/web/tests/partners/turbopuffer-partner-search-provider.test.ts b/apps/web/tests/partners/turbopuffer-partner-search-provider.test.ts new file mode 100644 index 00000000000..cbc217b6d89 --- /dev/null +++ b/apps/web/tests/partners/turbopuffer-partner-search-provider.test.ts @@ -0,0 +1,485 @@ +import { + PARTNER_SEARCH_CANDIDATE_LIMIT, + type PartnerSearchDocument, +} from "@/lib/api/partners/search"; +import { + createTurbopufferPartnerSearchProvider, + deleteTurbopufferPartnerSearchNamespace, + PARTNER_SEARCH_NAMESPACE, + type TurbopufferNamespace, +} from "@/lib/api/partners/search/providers/turbopuffer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const document: PartnerSearchDocument = { + id: "pge_test", + programId: "prog_test", + partnerId: "pn_test", + name: "Rafi Hasan", + email: "partner@example.com", + companyName: "Hasan Labs", + description: "Affiliate marketer", + platformTypes: ["youtube"], + platformIdentifiers: ["@rafi"], + linkKeys: ["rafi-link"], + status: "approved", + groupId: "grp_test", + country: "US", + partnerTagIds: ["ptag_a", "ptag_b"], +}; + +const mocks = vi.hoisted(() => ({ + write: vi.fn(), + multiQuery: vi.fn(), + query: vi.fn(), + deleteAll: vi.fn(), +})); + +function createNamespaceMock(): TurbopufferNamespace { + return { + write: mocks.write, + multiQuery: mocks.multiQuery, + query: mocks.query, + deleteAll: mocks.deleteAll, + } as unknown as TurbopufferNamespace; +} + +function branchesOf(call: number = 0) { + const [{ queries }] = mocks.multiQuery.mock.calls[call]; + return { + all: queries, + // Located by attribute rather than position, so adding a branch does not + // break every assertion. + rankedOn: (attribute: string) => + queries.filter((branch: any) => branch.rank_by[0] === attribute), + filteredOn: (attribute: string) => + queries.filter((branch: any) => + JSON.stringify(branch.filters).includes( + `["${attribute}","ContainsAllTokens"`, + ), + ), + }; +} + +function createProvider() { + return createTurbopufferPartnerSearchProvider({ + namespace: createNamespaceMock(), + namespaceName: "test-namespace", + }); +} + +describe("Turbopuffer partner search provider", () => { + beforeEach(() => { + for (const mock of Object.values(mocks)) { + mock.mockReset(); + } + mocks.write.mockResolvedValue({ rows_affected: 1 }); + mocks.multiQuery.mockResolvedValue({ results: [] }); + mocks.query.mockResolvedValue({ aggregations: { total: 12_000 } }); + }); + + // In afterEach rather than the test body, so a failed assertion cannot leak + // a stubbed env var into later tests. + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("indexes identity fields separately, and only those fields", async () => { + await createProvider().upsert([document]); + + const [{ upsert_rows, schema }] = mocks.write.mock.calls[0]; + const [row] = upsert_rows; + + expect(row.identityText).toBe( + "pn_test rafi hasan partner@example.com hasan labs", + ); + // Description, platforms, and links stay out, so a name cannot be + // out-weighted by how much else a partner has. + for (const value of [ + "affiliate marketer", + "youtube", + "@rafi", + "rafi-link", + ]) { + expect(row.identityText).not.toContain(value); + } + // ContainsAllTokens reads the BM25 index, so no text attribute needs a + // filter index, and turbopuffer bills FTS + filterable at 200%. + expect(schema.identityText.filterable).toBeUndefined(); + expect(schema.emailNgrams.filterable).toBeUndefined(); + expect(schema.programId).toMatchObject({ filterable: true }); + }); + + it("flattens every searchable field into one BM25 attribute", async () => { + await createProvider().upsert([document]); + + const [{ upsert_rows, schema }] = mocks.write.mock.calls[0]; + expect(upsert_rows).toHaveLength(1); + + const [row] = upsert_rows; + expect(row.id).toBe("pge_test"); + expect(row.programId).toBe("prog_test"); + for (const value of [ + "rafi hasan", + "partner@example.com", + "hasan labs", + "affiliate marketer", + "youtube", + "@rafi", + "rafi-link", + ]) { + expect(row.searchText).toContain(value); + } + }); + + it("pins a tokenizer that splits URLs into their components", async () => { + // The default tokenizer keeps a URL as one token, so "scottdigital" cannot + // match "https://www.scottdigital-42.techcorp.io" + // and websites + partner links are all searchable here + await createProvider().upsert([document]); + + const [{ schema }] = mocks.write.mock.calls[0]; + expect(schema.searchText.full_text_search).toMatchObject({ + tokenizer: "word_v2", + }); + expect(schema.emailNgrams.full_text_search).toEqual({ + tokenizer: "word_v2", + }); + }); + + it("always searches identity separately, so single-word queries can rank", async () => { + // A single-token last_as_prefix query scores every match at exactly 1, so + // the broad branch returns them unordered. Matching an identity field puts a + // document in two branches, which is what the rank fusion orders on. + await createProvider().searchCandidates({ + programId: "prog_test", + query: "rafi", + limit: 10, + }); + + const [{ queries }] = mocks.multiQuery.mock.calls[0]; + expect(queries.map((branch: any) => branch.rank_by[0])).toEqual( + expect.arrayContaining(["searchText", "identityText"]), + ); + }); + + it("scopes both branches to the program and matches prefixes", async () => { + await createProvider().searchCandidates({ + programId: "prog_test", + query: "rafi", + limit: 10, + }); + + const [{ queries }] = mocks.multiQuery.mock.calls[0]; + const [textBranch] = queries; + + expect(textBranch.rank_by).toEqual([ + "searchText", + "BM25", + "rafi", + { last_as_prefix: true }, + ]); + expect(textBranch.filters).toEqual(["programId", "Eq", "prog_test"]); + + for (const branch of queries) { + expect(JSON.stringify(branch.filters)).toContain("prog_test"); + expect(branch.top_k).toBe(10); + } + }); + + it("adds an all-terms branch for a multi-word query", async () => { + // BM25 alone does not require every word, and length normalization can rank + // a long document matching both words below a short one matching only the + // first. This branch admits only documents containing every term. + await createProvider().searchCandidates({ + programId: "prog_test", + query: "steven tey", + limit: 10, + }); + + const [allTermsBranch] = branchesOf().filteredOn("identityText"); + expect(allTermsBranch.rank_by).toEqual([ + "identityText", + "BM25", + "steven tey", + { last_as_prefix: true }, + ]); + expect(allTermsBranch.filters).toEqual([ + "And", + [ + ["programId", "Eq", "prog_test"], + [ + "identityText", + "ContainsAllTokens", + "steven tey", + { last_as_prefix: true }, + ], + ], + ]); + }); + + it("keeps the last token a prefix while it is still being typed", async () => { + // An exact-token filter would match nothing for a half-typed final word, so + // the all-terms boost would vanish exactly while the user is typing. + // Measured on the production index: "steven te" puts Steven Tey at rank 2 + // with the prefix and rank 4 without it. + await createProvider().searchCandidates({ + programId: "prog_test", + query: "steven te", + limit: 10, + }); + + const [allTermsBranch] = branchesOf().filteredOn("identityText"); + + expect(allTermsBranch.filters[1][1]).toEqual([ + "identityText", + "ContainsAllTokens", + "steven te", + { last_as_prefix: true }, + ]); + }); + + it("skips the all-terms branch for a single-word query", async () => { + await createProvider().searchCandidates({ + programId: "prog_test", + query: "steven", + limit: 10, + }); + + expect(branchesOf().filteredOn("identityText")).toHaveLength(0); + }); + + it("narrows every branch with the discrete filters", async () => { + // The filters have to sit inside each branch: applying them after the + // ranking truncates is what made a broad query with country=US return 85 + // rows out of 7,698 real matches. + await createProvider().searchCandidates({ + programId: "prog_test", + query: "examp", + limit: 10, + filters: { + status: { values: ["approved"] }, + country: { values: ["US", "CA"], exclude: true }, + partnerTagIds: { values: ["ptag_1"] }, + }, + }); + + const [{ queries }] = mocks.multiQuery.mock.calls[0]; + expect(queries.length).toBeGreaterThan(1); + + for (const branch of queries) { + const filters = JSON.stringify(branch.filters); + expect(filters).toContain('["programId","Eq","prog_test"]'); + expect(filters).toContain('["status","In",["approved"]]'); + // Exclusion uses NotIn, which also matches documents that omit country. + expect(filters).toContain('["country","NotIn",["US","CA"]]'); + expect(filters).toContain('["partnerTagIds","ContainsAny",["ptag_1"]]'); + } + }); + + it("omits absent filters rather than sending empty clauses", async () => { + await createProvider().searchCandidates({ + programId: "prog_test", + query: "examp", + limit: 10, + filters: { status: undefined, country: { values: [] } }, + }); + + const [{ queries }] = mocks.multiQuery.mock.calls[0]; + for (const branch of queries) { + expect(JSON.stringify(branch.filters)).not.toContain('"country"'); + } + }); + + it("requires every trigram on the n-gram branch", async () => { + // BM25 alone would score a document sharing a single trigram, which is how + // an unrelated address looks like a partial-email match. + await createProvider().searchCandidates({ + programId: "prog_test", + query: "examp", + limit: 10, + }); + + const [ngramBranch] = branchesOf().rankedOn("emailNgrams"); + expect(ngramBranch.rank_by).toEqual(["emailNgrams", "BM25", "exa xam amp"]); + expect(ngramBranch.filters).toEqual([ + "And", + [ + ["programId", "Eq", "prog_test"], + ["emailNgrams", "ContainsAllTokens", "exa xam amp"], + ], + ]); + }); + + it("skips the n-gram branch when the query cannot produce trigrams", async () => { + await createProvider().searchCandidates({ + programId: "prog_test", + query: "hi", + limit: 10, + }); + + expect(branchesOf().rankedOn("emailNgrams")).toHaveLength(0); + }); + + it("fuses branches by rank, boosting documents found by both", async () => { + // Raw $dist values are incomparable across branches, so they must not + // decide the order: pge_2 carries the highest raw score but only one + // branch found it, while both branches found pge_1. + mocks.multiQuery.mockResolvedValue({ + results: [ + { rows: [{ id: "pge_1", $dist: 0.4 }] }, + { + rows: [ + { id: "pge_2", $dist: 7.6 }, + { id: "pge_1", $dist: 0.9 }, + ], + }, + ], + }); + + const { hits } = await createProvider().searchCandidates({ + programId: "prog_test", + query: "examp", + limit: 10, + }); + + expect(hits.map(({ id }) => id)).toEqual(["pge_1", "pge_2"]); + // RRF: rank 1 + rank 2 across branches vs rank 1 in one branch. + expect(hits[0].score).toBeCloseTo(1 / 61 + 1 / 62, 10); + expect(hits[1].score).toBeCloseTo(1 / 61, 10); + }); + + it("breaks cross-branch rank ties deterministically by ID", async () => { + mocks.multiQuery.mockResolvedValue({ + results: [ + { rows: [{ id: "pge_b", $dist: 9.5 }] }, + { rows: [{ id: "pge_a", $dist: 0.2 }] }, + ], + }); + + const { hits } = await createProvider().searchCandidates({ + programId: "prog_test", + query: "examp", + limit: 10, + }); + + expect(hits.map(({ id }) => id)).toEqual(["pge_a", "pge_b"]); + }); + + it("never returns more than the requested limit", async () => { + mocks.multiQuery.mockResolvedValue({ + results: [ + { rows: [{ id: "pge_1", $dist: 0.5 }] }, + { rows: [{ id: "pge_2", $dist: 0.4 }] }, + ], + }); + + const { hits } = await createProvider().searchCandidates({ + programId: "prog_test", + query: "examp", + limit: 1, + }); + + expect(hits).toHaveLength(1); + }); + + it("rejects a limit above the candidate ceiling", async () => { + await expect( + createProvider().searchCandidates({ + programId: "prog_test", + query: "rafi", + limit: PARTNER_SEARCH_CANDIDATE_LIMIT + 1, + }), + ).rejects.toThrow("Partner search candidate limit"); + }); + + it("counts a three-character prefix, the shortest it will answer", async () => { + const total = await createProvider().countCandidates({ + programId: "prog_test", + query: "ale", + limit: 10, + }); + + expect(total).toBe(12_000); + }); + + it("returns null when the response carries no aggregate", async () => { + // Zero would render an unanswered count as an exact empty result. + mocks.query.mockResolvedValue({}); + + await expect( + createProvider().countCandidates({ + programId: "prog_test", + query: "creator", + limit: 10, + }), + ).resolves.toBeNull(); + }); + + it("counts matches without the candidate ceiling", async () => { + const total = await createProvider().countCandidates({ + programId: "prog_test", + query: "creator", + limit: 10, + filters: { status: { values: ["approved"] } }, + }); + + expect(total).toBe(12_000); + + const [request] = mocks.query.mock.calls[0]; + expect(request.aggregate_by).toEqual({ total: ["Count"] }); + // One clause covers both text branches, since identityText holds a subset + // of what searchText holds. + const filters = JSON.stringify(request.filters); + expect(filters).toContain('["searchText","ContainsAnyToken","creator"'); + expect(filters).toContain('["status","In",["approved"]]'); + }); + + it.each([ + ["a single character", "a"], + ["a half-typed final token", "steven a"], + ])( + "declines to count %s, which the prefix expands too far", + async (_label, query) => { + // Measured against 626K documents: a one-character prefix takes the + // aggregation from ~50ms to ~1.2s, past the deadline every time. + const total = await createProvider().countCandidates({ + programId: "prog_test", + query, + limit: 10, + }); + + expect(total).toBeNull(); + expect(mocks.query).not.toHaveBeenCalled(); + }, + ); + + it("deletes by document ID", async () => { + await createProvider().delete(["pge_1", "pge_2"]); + + expect(mocks.write).toHaveBeenCalledWith({ + deletes: ["pge_1", "pge_2"], + }); + }); + + it("empties the namespace", async () => { + mocks.deleteAll.mockResolvedValue({}); + + const result = await deleteTurbopufferPartnerSearchNamespace({ + namespace: createNamespaceMock(), + namespaceName: "test-namespace", + }); + + expect(mocks.deleteAll).toHaveBeenCalledOnce(); + expect(result).toEqual({ namespaceName: "test-namespace" }); + }); + + it("uses the pinned namespace when none is passed", async () => { + mocks.deleteAll.mockResolvedValue({}); + + const { namespaceName } = await deleteTurbopufferPartnerSearchNamespace({ + namespace: createNamespaceMock(), + }); + + expect(namespaceName).toBe(PARTNER_SEARCH_NAMESPACE); + }); +}); diff --git a/apps/web/tests/redirects/index.test.ts b/apps/web/tests/redirects/index.test.ts index 803fb80a415..8d22b62c76a 100644 --- a/apps/web/tests/redirects/index.test.ts +++ b/apps/web/tests/redirects/index.test.ts @@ -9,6 +9,7 @@ const fetchOptions: RequestInit = { redirect: "manual", headers: { "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + "x-e2e-redirect-test": "true", }, }; @@ -59,276 +60,283 @@ async function assertRedirectWithDubIdCookie( expect(dubIdCookie).toMatch(/Max-Age=3600\b/i); } -describe.runIf(env.CI)("Link Redirects", async () => { - const h = new IntegrationHarness(); +describe.runIf(env.CI && env.VERCEL_ENV !== "Production")( + "Link Redirects", + async () => { + const h = new IntegrationHarness(); - test("root", async () => { - const response = await fetch(h.baseUrl, fetchOptions); + test("root", async () => { + const response = await fetch(h.baseUrl, fetchOptions); - // the location should start with "https://dub.co" - expect(response.headers.get("location")).toMatch(/^https:\/\/dub\.co\//); - expect(response.headers.get("x-powered-by")).toBe(poweredBy); - expect(response.status).toBe(301); - }); + // the location should start with "https://dub.co" + expect(response.headers.get("location")).toMatch(/^https:\/\/dub\.co\//); + expect(response.headers.get("x-powered-by")).toBe(poweredBy); + expect(response.status).toBe(301); + }); - test("regular", async () => { - const response = await fetch(`${h.baseUrl}/checkly-check`, fetchOptions); + test("regular", async () => { + const response = await fetch(`${h.baseUrl}/checkly-check`, fetchOptions); - expect(response.headers.get("location")).toBe("https://www.checklyhq.com/"); - expect(response.headers.get("x-powered-by")).toBe(poweredBy); - expect(response.status).toBe(302); - }); + expect(response.headers.get("location")).toBe( + "https://www.checklyhq.com/", + ); + expect(response.headers.get("x-powered-by")).toBe(poweredBy); + expect(response.status).toBe(302); + }); - test("disabled link", async () => { - const response = await fetch(`${h.baseUrl}/disabled`, fetchOptions); + test("disabled link", async () => { + const response = await fetch(`${h.baseUrl}/disabled`, fetchOptions); - // Special case for disabled/notfound links since we're using redirect() from next/navigation: - // - doesn't support x-powered-by header - // - uses 307 status code instead of 302 - // This is the same as case-sensitive (incorrect) key test below. - expect(response.headers.get("location")).toBe("https://dub.co/links"); - expect(response.status).toBe(307); - }); + // Special case for disabled/notfound links since we're using redirect() from next/navigation: + // - doesn't support x-powered-by header + // - uses 307 status code instead of 302 + // This is the same as case-sensitive (incorrect) key test below. + expect(response.headers.get("location")).toBe("https://dub.co/links"); + expect(response.status).toBe(307); + }); - test("with slash", async () => { - const response = await fetch(`${h.baseUrl}/checkly/check`, fetchOptions); + test("with slash", async () => { + const response = await fetch(`${h.baseUrl}/checkly/check`, fetchOptions); - expect(response.headers.get("location")).toBe("https://www.checklyhq.com/"); - expect(response.headers.get("x-powered-by")).toBe(poweredBy); - expect(response.status).toBe(302); - }); + expect(response.headers.get("location")).toBe( + "https://www.checklyhq.com/", + ); + expect(response.headers.get("x-powered-by")).toBe(poweredBy); + expect(response.status).toBe(302); + }); - test("korean unicode key", async () => { - const hangulKey = "한글링크테스트"; - const url = new URL(h.baseUrl); - url.pathname = `/${hangulKey}`; + test("korean unicode key", async () => { + const hangulKey = "한글링크테스트"; + const url = new URL(h.baseUrl); + url.pathname = `/${hangulKey}`; - const response = await fetch(url.href, fetchOptions); + const response = await fetch(url.href, fetchOptions); - expect(response.headers.get("location")).toBe( - "https://youtu.be/9bZkp7q19f0", - ); - expect(response.headers.get("x-powered-by")).toBe(poweredBy); - expect(response.status).toBe(302); - }); + expect(response.headers.get("location")).toBe( + "https://youtu.be/9bZkp7q19f0", + ); + expect(response.headers.get("x-powered-by")).toBe(poweredBy); + expect(response.status).toBe(302); + }); - test("hebrew unicode key", async () => { - const hebrewKey = "שלום"; - const url = new URL(h.baseUrl); - url.pathname = `/${hebrewKey}`; + test("hebrew unicode key", async () => { + const hebrewKey = "שלום"; + const url = new URL(h.baseUrl); + url.pathname = `/${hebrewKey}`; - const response = await fetch(url.href, fetchOptions); + const response = await fetch(url.href, fetchOptions); - expect(response.headers.get("location")).toBe( - "https://youtube.com/shorts/IdP2WdnJK1o", - ); - expect(response.headers.get("x-powered-by")).toBe(poweredBy); - expect(response.status).toBe(302); - }); + expect(response.headers.get("location")).toBe( + "https://youtube.com/shorts/IdP2WdnJK1o", + ); + expect(response.headers.get("x-powered-by")).toBe(poweredBy); + expect(response.status).toBe(302); + }); - test("with dub_id", async () => { - await assertRedirectWithDubIdCookie(h.baseUrl, "conversion-tracking"); - }); + test("with dub_id", async () => { + await assertRedirectWithDubIdCookie(h.baseUrl, "conversion-tracking"); + }); - test("with dub_id (and slash in key)", async () => { - await assertRedirectWithDubIdCookie(h.baseUrl, "conversion/tracking"); - }); + test("with dub_id (and slash in key)", async () => { + await assertRedirectWithDubIdCookie(h.baseUrl, "conversion/tracking"); + }); - test("with dub_id and via", async () => { - await assertRedirectWithDubIdCookie(h.baseUrl, "track-test", { - via: "track-test", + test("with dub_id and via", async () => { + await assertRedirectWithDubIdCookie(h.baseUrl, "track-test", { + via: "track-test", + }); }); - }); - test("with dub_client_reference_id", async () => { - const response = await fetch( - `${h.baseUrl}/client_reference_id`, - fetchOptions, - ); - - // the location should contain `?client_reference_id=dub_id_` query param - expect(response.headers.get("location")).toMatch( - /client_reference_id=dub_id_[a-zA-Z0-9]+/, - ); - expect(response.headers.get("x-powered-by")).toBe(poweredBy); - expect(response.status).toBe(302); - }); + test("with dub_client_reference_id", async () => { + const response = await fetch( + `${h.baseUrl}/client_reference_id`, + fetchOptions, + ); + + // the location should contain `?client_reference_id=dub_id_` query param + expect(response.headers.get("location")).toMatch( + /client_reference_id=dub_id_[a-zA-Z0-9]+/, + ); + expect(response.headers.get("x-powered-by")).toBe(poweredBy); + expect(response.status).toBe(302); + }); - test("with passthrough query", async () => { - const response = await fetch( - `${h.baseUrl}/checkly-check-passthrough?utm_source=checkly`, - fetchOptions, - ); - - expect(response.headers.get("location")).toBe( - "https://www.checklyhq.com/?utm_source=checkly&utm_medium=social&utm_campaign=checks", - ); - expect(response.headers.get("x-powered-by")).toBe(poweredBy); - expect(response.status).toBe(302); - }); + test("with passthrough query", async () => { + const response = await fetch( + `${h.baseUrl}/checkly-check-passthrough?utm_source=checkly`, + fetchOptions, + ); + + expect(response.headers.get("location")).toBe( + "https://www.checklyhq.com/?utm_source=checkly&utm_medium=social&utm_campaign=checks", + ); + expect(response.headers.get("x-powered-by")).toBe(poweredBy); + expect(response.status).toBe(302); + }); - test("with complex query", async () => { - const response = await fetch( - `${h.baseUrl}/checkly-check-query`, - fetchOptions, - ); - - expect(response.headers.get("location")).toBe( - "https://guides.apple.com/?ug=CglEVUIgR3VpZGUSDgjZMhDEo%2BGA%2BZKqpJUBEg4I2TIQw7y33%2B%2B6ifL%2BARIOCNkyEJC988jqgIrQjQESDgjZMhCB%2B7XSiPTwrfUBEg4I2TIQ5J25xZOynPDxARINCNkyENuVr4POz8aMcBIOCMI7EK36pfjQuerJ0gESDQjCOxDSuurnjM6T7mASDQjCOxD3vr%2F%2Fkq%2FLqUwSDQjCOxCg9cK%2BjeOhnS4%3D", - ); - expect(response.headers.get("x-powered-by")).toBe(poweredBy); - expect(response.status).toBe(302); - }); + test("with complex query", async () => { + const response = await fetch( + `${h.baseUrl}/checkly-check-query`, + fetchOptions, + ); + + expect(response.headers.get("location")).toBe( + "https://guides.apple.com/?ug=CglEVUIgR3VpZGUSDgjZMhDEo%2BGA%2BZKqpJUBEg4I2TIQw7y33%2B%2B6ifL%2BARIOCNkyEJC988jqgIrQjQESDgjZMhCB%2B7XSiPTwrfUBEg4I2TIQ5J25xZOynPDxARINCNkyENuVr4POz8aMcBIOCMI7EK36pfjQuerJ0gESDQjCOxDSuurnjM6T7mASDQjCOxD3vr%2F%2Fkq%2FLqUwSDQjCOxCg9cK%2BjeOhnS4%3D", + ); + expect(response.headers.get("x-powered-by")).toBe(poweredBy); + expect(response.status).toBe(302); + }); - test("appsflyer tracking url", async () => { - const response = await fetch(`${h.baseUrl}/appsflyer`, fetchOptions); + test("appsflyer tracking url", async () => { + const response = await fetch(`${h.baseUrl}/appsflyer`, fetchOptions); - // location to include clickid, af_siteid query params - expect(response.headers.get("location")).toMatch(/pid=dubinc_int/); - expect(response.headers.get("location")).toMatch(/clickid=[a-zA-Z0-9]+/); - expect(response.headers.get("x-powered-by")).toBe(poweredBy); - expect(response.status).toBe(302); - }); + // location to include clickid, af_siteid query params + expect(response.headers.get("location")).toMatch(/pid=dubinc_int/); + expect(response.headers.get("location")).toMatch(/clickid=[a-zA-Z0-9]+/); + expect(response.headers.get("x-powered-by")).toBe(poweredBy); + expect(response.status).toBe(302); + }); - test("singular tracking url", async () => { - const response = await fetch(`${h.baseUrl}/singular`, fetchOptions); + test("singular tracking url", async () => { + const response = await fetch(`${h.baseUrl}/singular`, fetchOptions); - // location to include cl, ua, ip query params - expect(response.headers.get("location")).toMatch(/cl=[a-zA-Z0-9]+/); - expect(response.headers.get("location")).toMatch(/ua=[a-zA-Z0-9]+/); - expect(response.headers.get("location")).toMatch(/ip=[a-zA-Z0-9]+/); - expect(response.headers.get("x-powered-by")).toBe(poweredBy); - expect(response.status).toBe(302); - }); + // location to include cl, ua, ip query params + expect(response.headers.get("location")).toMatch(/cl=[a-zA-Z0-9]+/); + expect(response.headers.get("location")).toMatch(/ua=[a-zA-Z0-9]+/); + expect(response.headers.get("location")).toMatch(/ip=[a-zA-Z0-9]+/); + expect(response.headers.get("x-powered-by")).toBe(poweredBy); + expect(response.status).toBe(302); + }); - test("singular polyfill wpcn & wpcl params", async () => { - const response = await fetch( - `${h.baseUrl}/singular-polyfill`, - fetchOptions, - ); + test("singular polyfill wpcn & wpcl params", async () => { + const response = await fetch( + `${h.baseUrl}/singular-polyfill`, + fetchOptions, + ); - const location = response.headers.get("location"); - expect(location).toBeTruthy(); + const location = response.headers.get("location"); + expect(location).toBeTruthy(); - const url = new URL(location!); + const url = new URL(location!); - // wpcn should be replaced from {via} template to actual via value - expect(url.searchParams.get("wpcn")).toBe("singular-polyfill"); + // wpcn should be replaced from {via} template to actual via value + expect(url.searchParams.get("wpcn")).toBe("singular-polyfill"); - // wpcl should be replaced from {dub_id} template to actual dub_id value - expect(url.searchParams.get("wpcl")).toMatch(/^[a-zA-Z0-9]+$/); + // wpcl should be replaced from {dub_id} template to actual dub_id value + expect(url.searchParams.get("wpcl")).toMatch(/^[a-zA-Z0-9]+$/); - expect(response.headers.get("x-powered-by")).toBe(poweredBy); - expect(response.status).toBe(302); - }); + expect(response.headers.get("x-powered-by")).toBe(poweredBy); + expect(response.status).toBe(302); + }); - test("google play store url", async () => { - const response = await fetch(`${h.baseUrl}/gps`, fetchOptions); - const location = response.headers.get("location"); + test("google play store url", async () => { + const response = await fetch(`${h.baseUrl}/gps`, fetchOptions); + const location = response.headers.get("location"); - expect(response.status).toBe(302); - expect(response.headers.get("x-powered-by")).toBe(poweredBy); - expect(location).toBeTruthy(); + expect(response.status).toBe(302); + expect(response.headers.get("x-powered-by")).toBe(poweredBy); + expect(location).toBeTruthy(); - const url = new URL(location!); - const referrerEncoded = url.searchParams.get("referrer"); - expect(referrerEncoded).toBeTruthy(); + const url = new URL(location!); + const referrerEncoded = url.searchParams.get("referrer"); + expect(referrerEncoded).toBeTruthy(); - const referrer = decodeURIComponent(referrerEncoded!); - const params = new URLSearchParams(referrer); + const referrer = decodeURIComponent(referrerEncoded!); + const params = new URLSearchParams(referrer); - expect(params.get("deepLink")).toBe("https://dub.sh/gps"); - }); + expect(params.get("deepLink")).toBe("https://dub.sh/gps"); + }); - test("google play store url with existing referrer", async () => { - const response = await fetch( - `${h.baseUrl}/gps-with-referrer`, - fetchOptions, - ); - const location = response.headers.get("location"); + test("google play store url with existing referrer", async () => { + const response = await fetch( + `${h.baseUrl}/gps-with-referrer`, + fetchOptions, + ); + const location = response.headers.get("location"); - expect(response.status).toBe(302); - expect(response.headers.get("x-powered-by")).toBe(poweredBy); - expect(location).toBeTruthy(); + expect(response.status).toBe(302); + expect(response.headers.get("x-powered-by")).toBe(poweredBy); + expect(location).toBeTruthy(); - const url = new URL(location!); - const referrerEncoded = url.searchParams.get("referrer"); - expect(referrerEncoded).toBeTruthy(); + const url = new URL(location!); + const referrerEncoded = url.searchParams.get("referrer"); + expect(referrerEncoded).toBeTruthy(); - const referrer = decodeURIComponent(referrerEncoded!); - const params = new URLSearchParams(referrer); + const referrer = decodeURIComponent(referrerEncoded!); + const params = new URLSearchParams(referrer); - expect(params.get("utm_source")).toBe("google"); - expect(params.get("deepLink")).toBe("https://dub.sh/gps-with-referrer"); - }); + expect(params.get("utm_source")).toBe("google"); + expect(params.get("deepLink")).toBe("https://dub.sh/gps-with-referrer"); + }); - test("query params with no value", async () => { - const response = await fetch( - `${h.baseUrl}/query-params-no-value`, - fetchOptions, - ); - - expect(response.headers.get("location")).toBe( - "https://dub.co/blog?emptyquery", - ); - expect(response.headers.get("x-powered-by")).toBe(poweredBy); - expect(response.status).toBe(302); - }); + test("query params with no value", async () => { + const response = await fetch( + `${h.baseUrl}/query-params-no-value`, + fetchOptions, + ); + + expect(response.headers.get("location")).toBe( + "https://dub.co/blog?emptyquery", + ); + expect(response.headers.get("x-powered-by")).toBe(poweredBy); + expect(response.status).toBe(302); + }); - test("with case-sensitive (correct) key", async () => { - const response = await fetch( - `${h.baseUrl}/cAsE-sensitive-test`, - fetchOptions, - ); - - expect(response.headers.get("location")).toBe( - "https://dub.co/changelog/case-insensitive-links", - ); - expect(response.headers.get("x-powered-by")).toBe(poweredBy); - expect(response.status).toBe(302); - }); + test("with case-sensitive (correct) key", async () => { + const response = await fetch( + `${h.baseUrl}/cAsE-sensitive-test`, + fetchOptions, + ); + + expect(response.headers.get("location")).toBe( + "https://dub.co/changelog/case-insensitive-links", + ); + expect(response.headers.get("x-powered-by")).toBe(poweredBy); + expect(response.status).toBe(302); + }); - test("with case-sensitive (incorrect) key", async () => { - const response = await fetch( - `${h.baseUrl}/case-sensitive-test`, - fetchOptions, - ); + test("with case-sensitive (incorrect) key", async () => { + const response = await fetch( + `${h.baseUrl}/case-sensitive-test`, + fetchOptions, + ); - expect(response.headers.get("location")).toBe("https://dub.co/links"); - expect(response.status).toBe(307); - }); + expect(response.headers.get("location")).toBe("https://dub.co/links"); + expect(response.status).toBe(307); + }); - test("with case-sensitive key (and dub_id)", async () => { - await assertRedirectWithDubIdCookie(h.baseUrl, "cAsE-sensitive-TeSt"); - }); + test("with case-sensitive key (and dub_id)", async () => { + await assertRedirectWithDubIdCookie(h.baseUrl, "cAsE-sensitive-TeSt"); + }); - test("with password", async () => { - const response = await fetch( - `${h.baseUrl}/password/check?pw=dub`, - fetchOptions, - ); + test("with password", async () => { + const response = await fetch( + `${h.baseUrl}/password/check?pw=dub`, + fetchOptions, + ); - expect(response.headers.get("location")).toBe("https://dub.co/"); - expect(response.headers.get("x-powered-by")).toBe(poweredBy); - expect(response.status).toBe(302); - }); + expect(response.headers.get("location")).toBe("https://dub.co/"); + expect(response.headers.get("x-powered-by")).toBe(poweredBy); + expect(response.status).toBe(302); + }); - test("unsupported key", async () => { - const response = await fetch(`${h.baseUrl}/wp-admin.php`, fetchOptions); + test("unsupported key", async () => { + const response = await fetch(`${h.baseUrl}/wp-admin.php`, fetchOptions); - expect(response.headers.get("location")).toMatch(/\/\?dub-no-track=1$/); - expect(response.headers.get("x-powered-by")).toBe(poweredBy); - expect(response.status).toBe(302); - }); + expect(response.headers.get("location")).toMatch(/\/\?dub-no-track=1$/); + expect(response.headers.get("x-powered-by")).toBe(poweredBy); + expect(response.status).toBe(302); + }); - test("redirection url", async () => { - const response = await fetch( - `${h.baseUrl}/redir-url-test?${REDIRECTION_QUERY_PARAM}=https://dub.co/blog`, - fetchOptions, - ); + test("redirection url", async () => { + const response = await fetch( + `${h.baseUrl}/redir-url-test?${REDIRECTION_QUERY_PARAM}=https://dub.co/blog`, + fetchOptions, + ); - expect(response.headers.get("location")).toBe("https://dub.co/blog"); - expect(response.headers.get("x-powered-by")).toBe(poweredBy); - expect(response.status).toBe(302); - }); -}); + expect(response.headers.get("location")).toBe("https://dub.co/blog"); + expect(response.headers.get("x-powered-by")).toBe(poweredBy); + expect(response.status).toBe(302); + }); + }, +); diff --git a/apps/web/tests/rewards/click-reward.test.ts b/apps/web/tests/rewards/click-reward.test.ts deleted file mode 100644 index 9ce0035c94b..00000000000 --- a/apps/web/tests/rewards/click-reward.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { serializeReward } from "@/lib/api/partners/serialize-reward"; -import { getRewardAmount } from "@/lib/partners/get-reward-amount"; -import { Reward } from "@prisma/client"; -import { describe, expect, test, vi } from "vitest"; -import { resolveClickReward } from "../../app/(ee)/api/cron/aggregate-clicks/resolve-click-reward-amount"; -import { IntegrationHarness } from "../utils/integration"; - -// Mock server-only module -vi.mock("server-only", () => ({})); - -const REWARD_ID = "rw_ZE0KAEtZuOGwNHtoVm1U0JpF"; - -describe.sequential("Click reward resolution", async () => { - const h = new IntegrationHarness(); - const { http } = await h.init(); - - // Fetch the reward from the API - const { status, data: reward } = await http.get({ - path: `/rewards/${REWARD_ID}`, - }); - - if (status !== 200 || !reward) { - throw new Error(`Failed to fetch reward from API: ${status}`); - } - - test("countries in modifier list (US, GB, AU) get modifier amount", () => { - // The reward has modifiers: US, GB, AU should get 100 cents - const modifierCountries = ["US", "GB", "AU"]; - - modifierCountries.forEach((country) => { - const clickReward = resolveClickReward({ - reward, - country, - }); - - const amount = getRewardAmount(serializeReward(clickReward)); - - expect(amount).toBe(100); - }); - }); - - test("countries not in modifier list get base amount", () => { - // The reward base amount is 20 cents - // Countries not in the modifier list should get the base amount - const otherCountries = ["CA", "FR", "DE", "JP"]; - - otherCountries.forEach((country) => { - const clickReward = resolveClickReward({ - reward, - country, - }); - - const amount = getRewardAmount(serializeReward(clickReward)); - - expect(amount).toBe(20); - }); - }); - - test("all countries return expected amounts", () => { - // Test all countries to ensure correct behavior - const testCases = [ - { country: "US", expected: 100 }, - { country: "GB", expected: 100 }, - { country: "AU", expected: 100 }, - { country: "CA", expected: 20 }, - { country: "FR", expected: 20 }, - { country: "DE", expected: 20 }, - { country: "JP", expected: 20 }, - ]; - - testCases.forEach(({ country, expected }) => { - const clickReward = resolveClickReward({ - reward, - country, - }); - - const amount = getRewardAmount(serializeReward(clickReward)); - - expect(amount).toBe(expected); - }); - }); -}); diff --git a/apps/web/tests/rewards/determine-partner-rewards.test.ts b/apps/web/tests/rewards/determine-partner-rewards.test.ts new file mode 100644 index 00000000000..caf0b134114 --- /dev/null +++ b/apps/web/tests/rewards/determine-partner-rewards.test.ts @@ -0,0 +1,183 @@ +import { calculateSaleEarnings } from "@/lib/api/sales/calculate-sale-earnings"; +import { determinePartnerRewards } from "@/lib/partners/determine-partner-reward"; +import { Prisma, Reward } from "@prisma/client"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +const ADDON_PRODUCT_ID = "prod_abc"; + +function saleReward(overrides: Partial = {}): Reward { + return { + id: "rw_test", + programId: "prog_test", + description: null, + tooltipDescription: null, + event: "sale", + type: "percentage", + amountInCents: null, + amountInPercentage: new Prisma.Decimal(30), + maxDuration: 12, + modifiers: null, + config: null, + spendLimitAmount: null, + spendLimitInterval: null, + createdAt: new Date("2026-01-01"), + updatedAt: new Date("2026-01-01"), + ...overrides, + }; +} + +function enrollment(saleRewardValue: Reward) { + return { + partner: { country: null }, + links: null, + totalCommissions: 0, + saleReward: saleRewardValue, + }; +} + +describe("determinePartnerRewards", () => { + test("does not multiply a flat reward by Stripe line quantity", () => { + const rewards = determinePartnerRewards({ + event: "sale", + programEnrollment: enrollment( + saleReward({ + modifiers: [ + { + id: "product-zero-percent", + type: "percentage", + operator: "AND", + conditions: [ + { + value: [ADDON_PRODUCT_ID, "prod_xyz"], + entity: "sale", + operator: "in", + attribute: "productId", + }, + ], + maxDuration: 12, + amountInPercentage: 0, + }, + { + id: "amount-gte-200", + type: "flat", + operator: "AND", + conditions: [ + { + value: 20000, + entity: "sale", + operator: "greater_than_or_equal", + attribute: "amount", + }, + ], + maxDuration: 0, + amountInCents: 6000, + }, + ], + }), + ), + context: { + sale: { + amount: 20000, + products: [ + { + id: ADDON_PRODUCT_ID, + amount: 20000, + quantity: 200, + }, + ], + }, + }, + amount: 20000, + quantity: 1, + }); + + expect(rewards).toHaveLength(1); + expect(rewards[0].sale).toEqual({ amount: 20000, quantity: 1 }); + expect(rewards[0].reward.type).toBe("flat"); + expect(rewards[0].reward.amountInCents).toBe(6000); + expect( + calculateSaleEarnings({ + reward: rewards[0].reward, + sale: rewards[0].sale, + }), + ).toBe(6000); + }); + + test("applies percentage rewards to the line total, ignoring line quantity", () => { + const rewards = determinePartnerRewards({ + event: "sale", + programEnrollment: enrollment( + saleReward({ + modifiers: [ + { + type: "percentage", + operator: "AND", + amountInPercentage: 10, + conditions: [ + { + entity: "sale", + attribute: "productId", + operator: "equals_to", + value: ADDON_PRODUCT_ID, + }, + ], + }, + ], + }), + ), + context: { + sale: { + products: [ + { + id: ADDON_PRODUCT_ID, + amount: 20000, + quantity: 200, + }, + ], + }, + }, + amount: 20000, + quantity: 1, + }); + + expect(rewards).toHaveLength(1); + expect(rewards[0].sale.quantity).toBe(1); + expect( + calculateSaleEarnings({ + reward: rewards[0].reward, + sale: rewards[0].sale, + }), + ).toBe(2000); + }); + + test("uses the sale quantity when there is no productId modifier", () => { + const rewards = determinePartnerRewards({ + event: "sale", + programEnrollment: enrollment( + saleReward({ + type: "flat", + amountInCents: 500, + amountInPercentage: null, + }), + ), + context: { + sale: { + products: [ + { + id: ADDON_PRODUCT_ID, + amount: 20000, + quantity: 200, + }, + ], + }, + }, + amount: 20000, + quantity: 1, + }); + + expect(rewards).toHaveLength(1); + expect(rewards[0].sale).toEqual({ amount: 20000, quantity: 1 }); + }); +}); diff --git a/apps/web/tests/rewards/lead-reward.test.ts b/apps/web/tests/rewards/lead-reward.test.ts deleted file mode 100644 index abf28bc9064..00000000000 --- a/apps/web/tests/rewards/lead-reward.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { TrackLeadResponse } from "@/lib/types"; -import { randomCustomer } from "tests/utils/helpers"; -import { - E2E_LEAD_REWARD, - E2E_PARTNERS, - E2E_TRACK_CLICK_HEADERS, -} from "tests/utils/resource"; -import { verifyCommission } from "tests/utils/verify-commission"; -import { describe, expect, test } from "vitest"; -import { IntegrationHarness } from "../utils/integration"; - -describe.concurrent("Lead rewards", async () => { - const h = new IntegrationHarness(); - const { http } = await h.init(); - - test("when customer country is US and partner country is US", async () => { - // Track the click - const clickResponse = await http.post<{ clickId: string }>({ - path: "/track/click", - headers: E2E_TRACK_CLICK_HEADERS, - body: E2E_PARTNERS[0].shortLink, - }); - - expect(clickResponse.status).toEqual(200); - - const clickId = clickResponse.data.clickId; - const customer = randomCustomer(); - - // Track the lead - const trackLeadResponse = await http.post({ - path: "/track/lead", - body: { - clickId, - eventName: "Signup", - customerExternalId: customer.externalId, - customerName: customer.name, - customerEmail: customer.email, - customerAvatar: customer.avatar, - }, - }); - - expect(trackLeadResponse.status).toEqual(200); - - // Verify the commission - await verifyCommission({ - http, - customerExternalId: customer.externalId, - expectedEarnings: E2E_LEAD_REWARD.modifiers[1].amountInCents, - }); - }); - - test("when customer country is US and partner country is not US", async () => { - // Track the click - const clickResponse = await http.post<{ clickId: string }>({ - path: "/track/click", - headers: E2E_TRACK_CLICK_HEADERS, - body: { - ...E2E_PARTNERS[1].shortLink, - }, - }); - - expect(clickResponse.status).toEqual(200); - - const clickId = clickResponse.data.clickId; - const customer = randomCustomer(); - - // Track the lead - const trackLeadResponse = await http.post({ - path: "/track/lead", - body: { - clickId, - eventName: "Signup", - customerExternalId: customer.externalId, - customerName: customer.name, - customerEmail: customer.email, - customerAvatar: customer.avatar, - }, - }); - - expect(trackLeadResponse.status).toEqual(200); - - // Verify the commission - await verifyCommission({ - http, - customerExternalId: customer.externalId, - expectedEarnings: E2E_LEAD_REWARD.modifiers[0].amountInCents, - }); - }); -}); diff --git a/apps/web/tests/rewards/sale-reward.test.ts b/apps/web/tests/rewards/sale-reward.test.ts deleted file mode 100644 index fa0c166e4a2..00000000000 --- a/apps/web/tests/rewards/sale-reward.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { TrackLeadResponse, TrackSaleResponse } from "@/lib/types"; -import { - randomCustomer, - randomId, - randomSaleAmount, -} from "tests/utils/helpers"; -import { - E2E_CUSTOMER_COUNTRY_CONDITIONS_EXTERNAL_ID, - E2E_CUSTOMER_SIGNUP_DATE_CONDITIONS_EXTERNAL_ID, - E2E_PARTNERS, - E2E_SALE_REWARD, - E2E_TRACK_CLICK_HEADERS, -} from "tests/utils/resource"; -import { verifyCommission } from "tests/utils/verify-commission"; -import { describe, expect, test } from "vitest"; -import { IntegrationHarness } from "../utils/integration"; - -describe("Sale rewards with conditions", async () => { - const h = new IntegrationHarness(); - const { http } = await h.init(); - - const clickResponse = await http.post<{ clickId: string }>({ - path: "/track/click", - headers: E2E_TRACK_CLICK_HEADERS, - body: E2E_PARTNERS[0].shortLink, - }); - expect(clickResponse.status).toEqual(200); - const clickId = clickResponse.data.clickId; - const newCustomer = randomCustomer(); - const trackLeadResponse = await http.post({ - path: "/track/lead", - body: { - clickId, - eventName: "Signup", - customerExternalId: newCustomer.externalId, - customerName: newCustomer.name, - customerEmail: newCustomer.email, - customerAvatar: newCustomer.avatar, - mode: "wait", - }, - }); - expect(trackLeadResponse.status).toEqual(200); - - const randomSale = (eventName = "Payment") => ({ - eventName, - currency: "usd", - paymentProcessor: "stripe", - amount: randomSaleAmount(), - invoiceId: `INV_${randomId()}`, - }); - - describe.sequential("sequential track/sale tests", () => { - test("when {Sale} {Type} is {new} vs {recurring}", async () => { - const sale = randomSale("E2E first sale"); - - const trackSaleResponse = await http.post({ - path: "/track/sale", - body: { - ...sale, - customerExternalId: newCustomer.externalId, - }, - }); - - expect(trackSaleResponse.status).toEqual(200); - - await new Promise((resolve) => setTimeout(resolve, 3000)); - - await verifyCommission({ - http, - invoiceId: sale.invoiceId, - expectedEarnings: E2E_SALE_REWARD.modifiers[5].amountInCents!, - }); - - // no need to verify second sale since it will be verified below - // in the {Customer} {Subscription Duration} is {less than or equal to} {3} test - }); - - test("when {Customer} {Subscription Duration} is {less than or equal to} {3}", async () => { - const sale = randomSale("E2E customer subscription duration condition"); - const trackSaleResponse = await http.post({ - path: "/track/sale", - body: { - ...sale, - customerExternalId: newCustomer.externalId, - }, - }); - - expect(trackSaleResponse.status).toEqual(200); - - await verifyCommission({ - http, - invoiceId: sale.invoiceId, - expectedEarnings: E2E_SALE_REWARD.modifiers[3].amountInCents!, - }); - }); - }); - - describe.concurrent("concurrent track/sale tests", () => { - // skipping this for now because we're using a newCustomer for each test, - // and {Customer} {Subscription Duration} is {less than or equal to} {3} blocks the base case - test.skip("When {Sale} {Product ID} is {regularProductId}", async () => { - const sale = randomSale("E2E base condition"); - - const response = await http.post({ - path: "/track/sale", - body: { - ...sale, - customerExternalId: newCustomer.externalId, - metadata: { - productId: "regularProductId", - }, - }, - }); - - expect(response.status).toEqual(200); - - await verifyCommission({ - http, - invoiceId: sale.invoiceId, - expectedEarnings: E2E_SALE_REWARD.amountInCents, - }); - }); - - test("When {Sale} {Product ID} is {premiumProductId}", async () => { - const sale = randomSale("E2E sale product ID condition"); - - const response = await http.post({ - path: "/track/sale", - body: { - ...sale, - customerExternalId: newCustomer.externalId, - metadata: { - productId: "premiumProductId", - }, - }, - }); - - expect(response.status).toEqual(200); - - await verifyCommission({ - http, - invoiceId: sale.invoiceId, - expectedEarnings: E2E_SALE_REWARD.modifiers[0].amountInCents!, - }); - }); - - test("When {Sale} {Amount} is greater than {15000}", async () => { - const sale = randomSale("E2E sale amount condition"); - - const response = await http.post({ - path: "/track/sale", - body: { - ...sale, - amount: 17500, - customerExternalId: newCustomer.externalId, - metadata: { - productId: "premiumProductId", - }, - }, - }); - - expect(response.status).toEqual(200); - - await verifyCommission({ - http, - invoiceId: sale.invoiceId, - expectedEarnings: E2E_SALE_REWARD.modifiers[1].amountInCents!, - }); - }); - - test("when {Customer} {Country} is {SG}", async () => { - const sale = randomSale("E2E customer country condition"); - - const trackSaleResponse = await http.post({ - path: "/track/sale", - body: { - ...sale, - customerExternalId: E2E_CUSTOMER_COUNTRY_CONDITIONS_EXTERNAL_ID, - }, - }); - - expect(trackSaleResponse.status).toEqual(200); - - await verifyCommission({ - http, - invoiceId: sale.invoiceId, - expectedEarnings: E2E_SALE_REWARD.modifiers[2].amountInCents!, - }); - }); - - test("when {Customer} {Signup Date} is {greater than} {Feb 16, 2026} AND {less than} {Feb 18, 2026}", async () => { - const sale = randomSale("E2E customer signup date condition"); - - const trackSaleResponse = await http.post({ - path: "/track/sale", - body: { - ...sale, - customerExternalId: E2E_CUSTOMER_SIGNUP_DATE_CONDITIONS_EXTERNAL_ID, - }, - }); - - expect(trackSaleResponse.status).toEqual(200); - - await verifyCommission({ - http, - invoiceId: sale.invoiceId, - expectedEarnings: E2E_SALE_REWARD.modifiers[4].amountInCents!, - }); - }); - - test("when {Sale} {Metadata} {Key} is {Value}", async () => { - const sale = randomSale("E2E sale metadata key-value condition"); - - const trackSaleResponse = await http.post({ - path: "/track/sale", - body: { - ...sale, - customerExternalId: newCustomer.externalId, - metadata: { - bookTitle: "THGTTG", - }, - }, - }); - - expect(trackSaleResponse.status).toEqual(200); - - await verifyCommission({ - http, - invoiceId: sale.invoiceId, - expectedEarnings: E2E_SALE_REWARD.modifiers[6].amountInCents!, - }); - }); - }); -}); diff --git a/apps/web/tests/scripts/parse-cli-number.test.ts b/apps/web/tests/scripts/parse-cli-number.test.ts new file mode 100644 index 00000000000..942b7f56531 --- /dev/null +++ b/apps/web/tests/scripts/parse-cli-number.test.ts @@ -0,0 +1,70 @@ +import { + parseNonNegativeInteger, + parsePositiveInteger, +} from "@/scripts/utils/parse-cli-number"; +import { describe, expect, it } from "vitest"; + +describe("parsePositiveInteger", () => { + it("parses plain digit strings", () => { + expect(parsePositiveInteger("1", "--count")).toBe(1); + expect(parsePositiveInteger("100000", "--count")).toBe(100_000); + expect(parsePositiveInteger("007", "--count")).toBe(7); + }); + + it.each([ + ["0x10", "hex"], + ["1e9", "scientific notation"], + ["5.0", "decimal"], + ["+5", "explicit sign"], + [" 5 ", "surrounding whitespace"], + ["Infinity", "infinity"], + ["9007199254740992", "above Number.MAX_SAFE_INTEGER"], + ])("rejects %s (%s)", (value) => { + expect(() => parsePositiveInteger(value, "--count")).toThrow( + "--count must be a positive integer", + ); + }); + + it.each([["0"], ["-1"], [""], ["abc"]])("rejects %j", (value) => { + expect(() => parsePositiveInteger(value, "--count")).toThrow( + "--count must be a positive integer", + ); + }); + + it("reports the offending value and the flag name", () => { + expect(() => parsePositiveInteger("1e9", "--requests")).toThrow( + '--requests must be a positive integer, received: "1e9"', + ); + expect(() => parsePositiveInteger(undefined, "--requests")).toThrow( + "--requests must be a positive integer, received: (missing)", + ); + }); +}); + +describe("parseNonNegativeInteger", () => { + it("accepts zero, which parsePositiveInteger rejects", () => { + expect(parseNonNegativeInteger("0", "--warmup")).toBe(0); + expect(() => parsePositiveInteger("0", "--warmup")).toThrow( + "--warmup must be a positive integer", + ); + }); + + it("parses plain digit strings", () => { + expect(parseNonNegativeInteger("50", "--warmup")).toBe(50); + }); + + it.each([["0x10"], ["1e9"], ["5.0"], ["+5"], [" 5 "], ["-1"], [""], ["abc"]])( + "applies the same coercion guard to %j", + (value) => { + expect(() => parseNonNegativeInteger(value, "--warmup")).toThrow( + "--warmup must be a non-negative integer", + ); + }, + ); + + it("reports the offending value and the flag name", () => { + expect(() => parseNonNegativeInteger("abc", "--warmup")).toThrow( + '--warmup must be a non-negative integer, received: "abc"', + ); + }); +}); diff --git a/apps/web/tests/setupTests.ts b/apps/web/tests/setupTests.ts index 4d6837a3262..06ca1b636b4 100644 --- a/apps/web/tests/setupTests.ts +++ b/apps/web/tests/setupTests.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import { vi } from "vitest"; +import { getTrustedSourcesHeaders } from "./utils/trusted-sources"; Object.defineProperty(globalThis, "crypto", { value: crypto, @@ -47,3 +48,35 @@ vi.mock("@axiomhq/nextjs", () => ({ createOnRequestError: vi.fn(() => vi.fn()), transformMiddlewareRequest: vi.fn(() => []), })); + +// Attach Vercel Trusted Sources OIDC header to requests against the e2e +// deployment. Tokens are minted on demand from the GitHub Actions runner. +const originalFetch = globalThis.fetch.bind(globalThis); +const e2eBaseUrl = process.env.E2E_BASE_URL?.replace(/\/$/, ""); + +globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const requestUrl = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + + if (!e2eBaseUrl || !requestUrl.startsWith(e2eBaseUrl)) { + return originalFetch(input, init); + } + + const trustedHeaders = await getTrustedSourcesHeaders(originalFetch); + if (Object.keys(trustedHeaders).length === 0) { + return originalFetch(input, init); + } + + const headers = new Headers( + init?.headers ?? (input instanceof Request ? input.headers : undefined), + ); + for (const [key, value] of Object.entries(trustedHeaders)) { + headers.set(key, value); + } + + return originalFetch(input, { ...init, headers }); +}) as typeof fetch; diff --git a/apps/web/tests/utils/env.ts b/apps/web/tests/utils/env.ts index 06462a0dffe..94845cb5ea1 100644 --- a/apps/web/tests/utils/env.ts +++ b/apps/web/tests/utils/env.ts @@ -10,6 +10,7 @@ export const integrationTestEnv = z.object({ .string() .default("false") .transform((v) => v === "true"), + VERCEL_ENV: z.string().optional(), }); export const env = integrationTestEnv.parse(process.env); diff --git a/apps/web/tests/utils/trusted-sources.ts b/apps/web/tests/utils/trusted-sources.ts new file mode 100644 index 00000000000..14dd458286f --- /dev/null +++ b/apps/web/tests/utils/trusted-sources.ts @@ -0,0 +1,107 @@ +const TRUSTED_OIDC_HEADER = "x-vercel-trusted-oidc-idp-token"; +const EARLY_REFRESH_MS = 60_000; + +type FetchFn = typeof fetch; + +let cachedToken: string | null = null; +let cachedExpiresAtMs = 0; +let inflight: Promise | null = null; + +/** + * Headers that bypass Vercel Deployment Protection via Trusted Sources. + * + * GitHub Actions OIDC tokens last 5 minutes, so we mint on demand from the + * runner (`ACTIONS_ID_TOKEN_REQUEST_*`) and refresh before expiry. Falls back + * to `VERCEL_OIDC_TOKEN` for local runs against a protected preview. + * + * @see https://vercel.com/docs/deployment-protection/methods-to-bypass-deployment-protection/trusted-sources + */ +export async function getTrustedSourcesHeaders( + fetchFn: FetchFn = fetch, +): Promise> { + const token = await getOidcToken(fetchFn); + if (!token) return {}; + return { [TRUSTED_OIDC_HEADER]: token }; +} + +async function getOidcToken(fetchFn: FetchFn): Promise { + const now = Date.now(); + if (cachedToken && now < cachedExpiresAtMs - EARLY_REFRESH_MS) { + return cachedToken; + } + + if (inflight) return inflight; + + inflight = (async () => { + try { + const minted = await mintFromGitHubActionsRunner(fetchFn); + if (minted) { + cachedToken = minted.token; + cachedExpiresAtMs = minted.expiresAtMs; + return minted.token; + } + + const envToken = process.env.VERCEL_OIDC_TOKEN; + if (envToken) { + cachedToken = envToken; + cachedExpiresAtMs = Number.POSITIVE_INFINITY; + return envToken; + } + + return null; + } finally { + inflight = null; + } + })(); + + return inflight; +} + +async function mintFromGitHubActionsRunner( + fetchFn: FetchFn, +): Promise<{ token: string; expiresAtMs: number } | null> { + const url = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; + const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + if (!url || !requestToken) return null; + + const res = await fetchFn(url, { + method: "GET", + headers: { + Authorization: `Bearer ${requestToken}`, + Accept: "application/json", + }, + }); + + if (!res.ok) { + throw new Error( + `Failed to mint GitHub Actions OIDC token: ${res.status} ${res.statusText}`, + ); + } + + const body = (await res.json()) as { value?: string }; + const token = body.value; + if (!token) { + throw new Error( + "Failed to mint GitHub Actions OIDC token: empty/invalid response body", + ); + } + + return { token, expiresAtMs: readJwtExpMs(token) }; +} + +function readJwtExpMs(jwt: string): number { + const fallback = Date.now() + 5 * 60 * 1000; + const parts = jwt.split("."); + if (parts.length !== 3) return fallback; + + try { + const payload = JSON.parse( + Buffer.from(parts[1], "base64url").toString("utf8"), + ); + if (typeof payload?.exp === "number") return payload.exp * 1000; + } catch { + // ignore — use fallback + } + + return fallback; +} diff --git a/apps/web/tests/utils/verify-commission.ts b/apps/web/tests/utils/verify-commission.ts index 5251d95c044..b6ade6b10e4 100644 --- a/apps/web/tests/utils/verify-commission.ts +++ b/apps/web/tests/utils/verify-commission.ts @@ -14,6 +14,7 @@ interface VerifyCommissionProps { expectedSaleAmount?: number; expectedEarnings: number; expectedType?: string; + expectedMetadata?: CommissionResponse["metadata"]; query?: Record; // to pass additional query params to GET /commissions } @@ -25,6 +26,7 @@ export const verifyCommission = async ({ expectedSaleAmount, expectedEarnings, expectedType, + expectedMetadata, query: queryOverrides, }: VerifyCommissionProps) => { let customerId: string | undefined; @@ -95,6 +97,10 @@ export const verifyCommission = async ({ expect(commission.type).toEqual(expectedType); } + if (expectedMetadata !== undefined) { + expect(commission.metadata).toEqual(expectedMetadata); + } + return; } diff --git a/apps/web/tests/webhooks/index.test.ts b/apps/web/tests/webhooks/index.test.ts index 863522a25f9..368180c6398 100644 --- a/apps/web/tests/webhooks/index.test.ts +++ b/apps/web/tests/webhooks/index.test.ts @@ -11,8 +11,12 @@ import type { WebhookTrigger } from "@/lib/webhook/types"; import { BountySchema } from "@/lib/zod/schemas/bounties"; import { CommissionWebhookSchema } from "@/lib/zod/schemas/commissions"; import { CustomerSchema } from "@/lib/zod/schemas/customers"; +import { DiscountCodeWebhookSchema } from "@/lib/zod/schemas/discount"; import { linkEventSchema } from "@/lib/zod/schemas/links"; -import { EnrolledPartnerSchema } from "@/lib/zod/schemas/partners"; +import { + EnrolledPartnerSchema, + partnerMergedWebhookSchema, +} from "@/lib/zod/schemas/partners"; import { payoutWebhookEventSchema } from "@/lib/zod/schemas/payouts"; import { partnerApplicationWebhookSchema } from "@/lib/zod/schemas/program-application"; import { describe, expect, test } from "vitest"; @@ -95,10 +99,13 @@ const eventSchemas: Record = { "sale.created": saleWebhookEventSchemaExtended, "partner.application_submitted": partnerApplicationWebhookSchema, "partner.enrolled": enrolledPartnerSchemaExtended, + "partner.merged": partnerMergedWebhookSchema, "commission.created": commissionWebhookEventSchemaExtended, "bounty.created": bountyWebhookEventSchemaExtended, "bounty.updated": bountyWebhookEventSchemaExtended, "payout.confirmed": payoutWebhookEventSchemaExtended, + "discount_code.created": DiscountCodeWebhookSchema, + "discount_code.deleted": DiscountCodeWebhookSchema, }; describe("Webhooks", () => { diff --git a/apps/web/tests/workflows/merge-partner-accounts-workflow.test.ts b/apps/web/tests/workflows/merge-partner-accounts-workflow.test.ts new file mode 100644 index 00000000000..7b9812a9b98 --- /dev/null +++ b/apps/web/tests/workflows/merge-partner-accounts-workflow.test.ts @@ -0,0 +1,158 @@ +import { + VITEST_POLL_INTERVAL_MS, + VITEST_TEST_TIMEOUT_MS, +} from "@/lib/constants/misc"; +import { EnrolledPartnerProps } from "@/lib/types"; +import { describe, expect, test } from "vitest"; +import { randomPartnerEmail } from "../utils/helpers"; +import { IntegrationHarness } from "../utils/integration"; +import { E2E_PARTNER_GROUP } from "../utils/resource"; +import { verifyMergeCompleted } from "./utils/verify-merge-completed"; + +describe.sequential("Workflow - MergePartnerAccounts", async () => { + const h = new IntegrationHarness(); + const { http } = await h.init(); + + // Creates a partner enrolled (approved) in the Acme program with a default link. + async function createEnrolledPartner(label: string) { + const { status, data: partner } = await http.post({ + path: "/partners", + body: { + name: `E2E Merge ${label}`, + email: randomPartnerEmail(), + groupId: E2E_PARTNER_GROUP.id, + }, + }); + + expect(status).toEqual(201); + expect(partner.links).not.toBeNull(); + expect(partner.links!.length).toBeGreaterThan(0); + + return partner; + } + + test( + "Overlap merge transfers child data and deletes source", + { timeout: VITEST_TEST_TIMEOUT_MS }, + async () => { + const source = await createEnrolledPartner("source"); + const target = await createEnrolledPartner("target"); + const sourceLinkId = source.links![0].id; + + const { status: triggerStatus, data: triggerRes } = await http.post<{ + workflowRunId?: string; + }>({ + path: "/e2e/trigger-merge-accounts", + body: { sourceEmail: source.email, targetEmail: target.email }, + }); + + expect(triggerStatus).toEqual(200); + expect(triggerRes).not.toBeNull(); + + const merged = await verifyMergeCompleted({ + http, + sourcePartnerId: source.id, + targetPartnerId: target.id, + expectedLinkId: sourceLinkId, + }); + + expect(merged.links!.map((link) => link.id)).toContain(sourceLinkId); + }, + ); + + test( + "Overlap merge upgrades target status from pending to approved", + { timeout: VITEST_TEST_TIMEOUT_MS }, + async () => { + const sourcePartner = await createEnrolledPartner("upgrade-source"); + const targetPartner = await createEnrolledPartner("upgrade-target"); + + const { status: pendingStatus } = await http.post({ + path: "/e2e/partners/pending-program-application", + body: { partnerId: targetPartner.id }, + }); + expect(pendingStatus).toEqual(200); + + const { status: triggerStatus } = await http.post({ + path: "/e2e/trigger-merge-accounts", + body: { + sourceEmail: sourcePartner.email, + targetEmail: targetPartner.email, + }, + }); + expect(triggerStatus).toEqual(200); + + const startTime = Date.now(); + let lastTargetStatus: string | undefined; + + while (Date.now() - startTime < VITEST_TEST_TIMEOUT_MS) { + const [sourceRes, targetRes] = await Promise.all([ + http.get({ path: `/partners/${sourcePartner.id}` }), + http.get({ + path: `/partners/${targetPartner.id}`, + }), + ]); + + lastTargetStatus = + targetRes.status === 200 ? targetRes.data.status : undefined; + + if (sourceRes.status === 404 && lastTargetStatus === "approved") { + expect(lastTargetStatus).toBe("approved"); + return; + } + + await new Promise((resolve) => + setTimeout(resolve, VITEST_POLL_INTERVAL_MS), + ); + } + + throw new Error( + `Target status was not upgraded to approved within ${VITEST_TEST_TIMEOUT_MS / 1000}s. ` + + `Last seen status: ${lastTargetStatus}`, + ); + }, + ); + + test( + "Repeat merge is rejected once the source is already merged", + { timeout: VITEST_TEST_TIMEOUT_MS }, + async () => { + const source = await createEnrolledPartner("repeat-source"); + const target = await createEnrolledPartner("repeat-target"); + const sourceLinkId = source.links![0].id; + + const { status: firstTrigger } = await http.post({ + path: "/e2e/trigger-merge-accounts", + body: { sourceEmail: source.email, targetEmail: target.email }, + }); + expect(firstTrigger).toEqual(200); + + await verifyMergeCompleted({ + http, + sourcePartnerId: source.id, + targetPartnerId: target.id, + expectedLinkId: sourceLinkId, + }); + + const { data: afterFirst } = await http.get({ + path: `/partners/${target.id}`, + }); + const linkCountAfterFirst = afterFirst.links!.length; + + // The source partner no longer exists, so triggering again is rejected by + // the guard (both partners must be enrolled in the Acme program) - the + // merge can't be double-processed. + const { status: secondTrigger } = await http.post({ + path: "/e2e/trigger-merge-accounts", + body: { sourceEmail: source.email, targetEmail: target.email }, + }); + expect(secondTrigger).toEqual(400); + + const { data: afterSecond } = await http.get({ + path: `/partners/${target.id}`, + }); + + expect(afterSecond.links!.length).toBe(linkCountAfterFirst); + }, + ); +}); diff --git a/apps/web/tests/workflows/move-group-workflow.test.ts b/apps/web/tests/workflows/move-group-workflow.test.ts deleted file mode 100644 index f0192d76c4a..00000000000 --- a/apps/web/tests/workflows/move-group-workflow.test.ts +++ /dev/null @@ -1,789 +0,0 @@ -import { EnrolledPartnerProps } from "@/lib/types"; -import { RESOURCE_COLORS } from "@/ui/colors"; -import { randomValue } from "@dub/utils"; -import { PartnerGroup } from "@prisma/client"; -import { E2E_PARTNER } from "tests/utils/resource"; -import { describe, expect, onTestFinished, test } from "vitest"; -import { randomPartnerEmail } from "../utils/helpers"; -import { IntegrationHarness } from "../utils/integration"; -import { trackE2ELead } from "./utils/track-e2e-lead"; -import { verifyPartnerGroupMove } from "./utils/verify-partner-group-move"; - -async function cleanupOrphanedGroup( - http: any, - slug: string, - allGroups: PartnerGroup[], -) { - const orphan = allGroups.find((g) => g.slug === slug); - if (orphan) await http.delete({ path: `/groups/${orphan.id}` }); -} - -describe.sequential("Workflow - MoveGroup", async () => { - const h = new IntegrationHarness(); - const { http } = await h.init(); - - const { data: allGroupsForCleanup } = await http.get({ - path: "/groups", - }); - - test("Workflow is created when move rules are configured", async () => { - const slug = "e2e-target-config"; - await cleanupOrphanedGroup(http, slug, allGroupsForCleanup); - - const { status: targetStatus, data: targetGroup } = - await http.post({ - path: "/groups", - body: { - name: "E2E Target Group - Config Test", - slug, - color: randomValue(RESOURCE_COLORS), - }, - }); - - expect(targetStatus).toEqual(201); - - onTestFinished(async () => { - await http.delete({ path: `/groups/${targetGroup.id}` }); - }); - - const { status: patchStatus } = await http.patch({ - path: `/groups/${targetGroup.id}`, - body: { - moveRules: [ - { - attribute: "totalLeads", - operator: "between", - value: { min: 3, max: 5 }, - }, - ], - }, - }); - - expect(patchStatus).toEqual(200); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { groupId: targetGroup.id }, - }); - - expect(workflow).not.toBeNull(); - expect(workflow.disabledAt).toBeNull(); - - const workflowActions = workflow.actions as any[]; - expect(workflowActions).toHaveLength(1); - expect(workflowActions[0].type).toBe("moveGroup"); - expect(workflowActions[0].data.groupId).toBe(targetGroup.id); - - const workflowConditions = workflow.triggerConditions as any[]; - expect(workflowConditions).toHaveLength(1); - expect(workflowConditions[0].attribute).toBe("totalLeads"); - expect(workflowConditions[0].operator).toBe("between"); - expect(workflowConditions[0].value).toStrictEqual({ min: 3, max: 5 }); - }); - - test("Workflow is deleted when move rules are removed", async () => { - const slug = "e2e-remove-rules"; - await cleanupOrphanedGroup(http, slug, allGroupsForCleanup); - - const { status: groupStatus, data: group } = await http.post({ - path: "/groups", - body: { - name: "E2E Group - Remove Rules", - slug, - color: randomValue(RESOURCE_COLORS), - }, - }); - - expect(groupStatus).toEqual(201); - - onTestFinished(async () => { - await http.delete({ path: `/groups/${group.id}` }); - }); - - const { status: addStatus } = await http.patch({ - path: `/groups/${group.id}`, - body: { - moveRules: [ - { - attribute: "totalLeads", - operator: "between", - value: { min: 2, max: 3 }, - }, - ], - }, - }); - - expect(addStatus).toEqual(200); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { groupId: group.id }, - }); - - expect(workflow).not.toBeNull(); - - const { status: removeStatus } = await http.patch({ - path: `/groups/${group.id}`, - body: { - moveRules: [], - }, - }); - - expect(removeStatus).toEqual(200); - - await new Promise((resolve) => setTimeout(resolve, 5000)); - - const { data: deletedWorkflow } = await http.get({ - path: "/e2e/workflows", - query: { groupId: group.id }, - }); - - expect(deletedWorkflow).toBeNull(); - }); - - test("Disabled workflow doesn't execute partner move", async () => { - const slug = "e2e-target-disabled"; - await cleanupOrphanedGroup(http, slug, allGroupsForCleanup); - - const { data: existingGroups } = await http.get({ - path: "/groups", - }); - - expect(existingGroups.length).toBeGreaterThan(0); - const sourceGroup = existingGroups[0]; - - const { status: targetStatus, data: targetGroup } = - await http.post({ - path: "/groups", - body: { - name: "E2E Target Group - Disabled Move", - slug, - color: randomValue(RESOURCE_COLORS), - }, - }); - - expect(targetStatus).toEqual(201); - - onTestFinished(async () => { - await http.delete({ path: `/groups/${targetGroup.id}` }); - }); - - const { status: patchStatus } = await http.patch({ - path: `/groups/${targetGroup.id}`, - body: { - moveRules: [ - { - attribute: "totalLeads", - operator: "between", - value: { min: 2, max: 3 }, - }, - ], - }, - }); - - expect(patchStatus).toEqual(200); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { groupId: targetGroup.id }, - }); - - expect(workflow).not.toBeNull(); - expect(workflow.disabledAt).toBeNull(); - - await http.patch({ - path: `/e2e/workflows/${workflow.id}`, - body: { disabledAt: new Date().toISOString() }, - }); - - const { status: partnerStatus, data: partner } = - await http.post({ - path: "/partners", - body: { - name: "E2E Test Partner - Disabled Move", - email: randomPartnerEmail(), - groupId: sourceGroup.id, - }, - }); - - expect(partnerStatus).toEqual(201); - expect(partner.links).not.toBeNull(); - - const partnerLink = partner.links![0]; - - await trackE2ELead(http, partnerLink); - - await new Promise((resolve) => setTimeout(resolve, 10000)); - - const { data: partnerAfter } = await http.get({ - path: `/partners/${partner.id}`, - }); - - expect(partnerAfter.groupId).toBe(sourceGroup.id); - }); - - test("Workflow doesn't execute when conditions are not met", async () => { - const slug = "e2e-target-not-met"; - await cleanupOrphanedGroup(http, slug, allGroupsForCleanup); - - const { data: existingGroups } = await http.get({ - path: "/groups", - }); - - expect(existingGroups.length).toBeGreaterThan(0); - const sourceGroup = existingGroups[0]; - - const { status: targetStatus, data: targetGroup } = - await http.post({ - path: "/groups", - body: { - name: "E2E Target Group - Not Met", - slug, - color: randomValue(RESOURCE_COLORS), - }, - }); - - expect(targetStatus).toEqual(201); - - onTestFinished(async () => { - await http.delete({ path: `/groups/${targetGroup.id}` }); - }); - - const { status: patchStatus } = await http.patch({ - path: `/groups/${targetGroup.id}`, - body: { - moveRules: [ - { - attribute: "totalLeads", - operator: "between", - value: { min: 4, max: 5 }, - }, - ], - }, - }); - - expect(patchStatus).toEqual(200); - - const { status: partnerStatus, data: partner } = - await http.post({ - path: "/partners", - body: { - name: "E2E Test Partner - Not Met", - email: randomPartnerEmail(), - groupId: sourceGroup.id, - }, - }); - - expect(partnerStatus).toEqual(201); - expect(partner.links).not.toBeNull(); - - const partnerLink = partner.links![0]; - - await trackE2ELead(http, partnerLink); - - await new Promise((resolve) => setTimeout(resolve, 10000)); - - const { data: partnerAfter } = await http.get({ - path: `/partners/${partner.id}`, - }); - - expect(partnerAfter.groupId).toBe(sourceGroup.id); - }); - - test( - "Workflow executes when conditions are met - partner moves to target group", - { timeout: 90000 }, - async () => { - const slug = "e2e-target-exec"; - await cleanupOrphanedGroup(http, slug, allGroupsForCleanup); - - const { data: existingGroups } = await http.get({ - path: "/groups", - }); - - expect(existingGroups.length).toBeGreaterThan(0); - const sourceGroup = existingGroups[0]; - - const { status: targetStatus, data: targetGroup } = - await http.post({ - path: "/groups", - body: { - name: "E2E Target Group - Move Execution", - slug, - color: randomValue(RESOURCE_COLORS), - }, - }); - - expect(targetStatus).toEqual(201); - - onTestFinished(async () => { - await http.delete({ path: `/groups/${targetGroup.id}` }); - }); - - const { status: patchStatus } = await http.patch({ - path: `/groups/${targetGroup.id}`, - body: { - moveRules: [ - { - attribute: "totalLeads", - operator: "between", - value: { min: 1, max: 2 }, - }, - ], - }, - }); - - expect(patchStatus).toEqual(200); - - const { status: partnerStatus, data: partner } = - await http.post({ - path: "/partners", - body: { - name: "E2E Test Partner - Move Execution", - email: randomPartnerEmail(), - groupId: sourceGroup.id, - }, - }); - - expect(partnerStatus).toEqual(201); - expect(partner.links).not.toBeNull(); - expect(partner.groupId).toBe(sourceGroup.id); - - const partnerLink = partner.links![0]; - - await trackE2ELead(http, partnerLink); - - await verifyPartnerGroupMove({ - http, - partnerId: partner.id, - expectedGroupId: targetGroup.id, - }); - }, - ); - - test( - "No duplicate group moves on multiple triggers", - { timeout: 90000 }, - async () => { - const slug = "e2e-target-no-dup"; - await cleanupOrphanedGroup(http, slug, allGroupsForCleanup); - - const { data: existingGroups } = await http.get({ - path: "/groups", - }); - - expect(existingGroups.length).toBeGreaterThan(0); - const sourceGroup = existingGroups[0]; - - const { status: targetStatus, data: targetGroup } = - await http.post({ - path: "/groups", - body: { - name: "E2E Target Group - No Dup Move", - slug, - color: randomValue(RESOURCE_COLORS), - }, - }); - - expect(targetStatus).toEqual(201); - - onTestFinished(async () => { - await http.delete({ path: `/groups/${targetGroup.id}` }); - }); - - const { status: patchStatus } = await http.patch({ - path: `/groups/${targetGroup.id}`, - body: { - moveRules: [ - { - attribute: "totalLeads", - operator: "between", - value: { min: 1, max: 2 }, - }, - ], - }, - }); - - expect(patchStatus).toEqual(200); - - const { status: partnerStatus, data: partner } = - await http.post({ - path: "/partners", - body: { - name: "E2E Test Partner - No Dup Move", - email: randomPartnerEmail(), - groupId: sourceGroup.id, - }, - }); - - expect(partnerStatus).toEqual(201); - expect(partner.links).not.toBeNull(); - - const partnerLink = partner.links![0]; - - await trackE2ELead(http, partnerLink); - - await verifyPartnerGroupMove({ - http, - partnerId: partner.id, - expectedGroupId: targetGroup.id, - }); - - const { data: partnerAfter } = await http.get({ - path: `/partners/${partner.id}`, - }); - - expect(partnerAfter.groupId).toBe(targetGroup.id); - }, - ); - - test("Multiple move rules can be configured (AND operator)", async () => { - const slug = "e2e-multi-rules"; - await cleanupOrphanedGroup(http, slug, allGroupsForCleanup); - - const { status: groupStatus, data: group } = await http.post({ - path: "/groups", - body: { - name: "E2E Group - Multiple Rules", - slug, - color: randomValue(RESOURCE_COLORS), - }, - }); - - expect(groupStatus).toEqual(201); - - onTestFinished(async () => { - await http.delete({ path: `/groups/${group.id}` }); - }); - - const { status: patchStatus } = await http.patch({ - path: `/groups/${group.id}`, - body: { - moveRules: [ - { - attribute: "totalLeads", - operator: "between", - value: { min: 2, max: 3 }, - }, - { - attribute: "totalConversions", - operator: "between", - value: { min: 1, max: 2 }, - }, - ], - }, - }); - - expect(patchStatus).toEqual(200); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { groupId: group.id }, - }); - - expect(workflow).not.toBeNull(); - - const workflowConditions = workflow.triggerConditions as any[]; - expect(workflowConditions).toHaveLength(2); - expect(workflowConditions[0].attribute).toBe("totalLeads"); - expect(workflowConditions[0].value).toStrictEqual({ min: 2, max: 3 }); - expect(workflowConditions[1].attribute).toBe("totalConversions"); - expect(workflowConditions[1].value).toStrictEqual({ min: 1, max: 2 }); - }); - - test("Metric + partnerGroup move rules create workflow with both conditions", async () => { - const slug = "e2e-partner-group-config"; - await cleanupOrphanedGroup(http, slug, allGroupsForCleanup); - - const { data: existingGroups } = await http.get({ - path: "/groups", - }); - - expect(existingGroups.length).toBeGreaterThan(0); - const sourceGroup = existingGroups[0]; - - const { status: groupStatus, data: group } = await http.post({ - path: "/groups", - body: { - name: "E2E Group - Partner Group Config", - slug, - color: randomValue(RESOURCE_COLORS), - }, - }); - - expect(groupStatus).toEqual(201); - - onTestFinished(async () => { - await http.delete({ path: `/groups/${group.id}` }); - }); - - const { status: patchStatus } = await http.patch({ - path: `/groups/${group.id}`, - body: { - moveRules: [ - { - attribute: "totalLeads", - operator: "between", - value: { min: 2, max: 3 }, - }, - { - attribute: "partnerGroup", - operator: "eq", - value: sourceGroup.id, - }, - ], - }, - }); - - expect(patchStatus).toEqual(200); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { groupId: group.id }, - }); - - expect(workflow).not.toBeNull(); - - const workflowConditions = workflow.triggerConditions as any[]; - expect(workflowConditions).toHaveLength(2); - expect(workflowConditions[0].attribute).toBe("totalLeads"); - expect(workflowConditions[0].operator).toBe("between"); - expect(workflowConditions[0].value).toStrictEqual({ min: 2, max: 3 }); - expect(workflowConditions[1].attribute).toBe("partnerGroup"); - expect(workflowConditions[1].operator).toBe("eq"); - expect(workflowConditions[1].value).toBe(sourceGroup.id); - }); - - test( - "Workflow executes when partnerGroup matches source group", - { timeout: 90000 }, - async () => { - const slug = "e2e-partner-group-match"; - await cleanupOrphanedGroup(http, slug, allGroupsForCleanup); - - const { data: existingGroups } = await http.get({ - path: "/groups", - }); - - expect(existingGroups.length).toBeGreaterThan(0); - const sourceGroup = existingGroups[0]; - - const { status: targetStatus, data: targetGroup } = - await http.post({ - path: "/groups", - body: { - name: "E2E Target Group - Partner Group Match", - slug, - color: randomValue(RESOURCE_COLORS), - }, - }); - - expect(targetStatus).toEqual(201); - - onTestFinished(async () => { - await http.delete({ path: `/groups/${targetGroup.id}` }); - }); - - const { status: patchStatus } = await http.patch({ - path: `/groups/${targetGroup.id}`, - body: { - moveRules: [ - { - attribute: "totalLeads", - operator: "between", - value: { min: 1, max: 2 }, - }, - { - attribute: "partnerGroup", - operator: "eq", - value: sourceGroup.id, - }, - ], - }, - }); - - expect(patchStatus).toEqual(200); - - const { status: partnerStatus, data: partner } = - await http.post({ - path: "/partners", - body: { - name: "E2E Test Partner - Partner Group Match", - email: randomPartnerEmail(), - groupId: sourceGroup.id, - }, - }); - - expect(partnerStatus).toEqual(201); - expect(partner.links).not.toBeNull(); - expect(partner.groupId).toBe(sourceGroup.id); - - const partnerLink = partner.links![0]; - - await trackE2ELead(http, partnerLink); - - await verifyPartnerGroupMove({ - http, - partnerId: partner.id, - expectedGroupId: targetGroup.id, - }); - }, - ); - - test("Workflow doesn't execute when partnerGroup does not match source group", async () => { - const slug = "e2e-partner-group-mismatch"; - const sourceSlug = "e2e-partner-group-mismatch-src"; - await cleanupOrphanedGroup(http, slug, allGroupsForCleanup); - await cleanupOrphanedGroup(http, sourceSlug, allGroupsForCleanup); - - const { data: existingGroups } = await http.get({ - path: "/groups", - }); - - expect(existingGroups.length).toBeGreaterThan(0); - const allowedSourceGroup = existingGroups[0]; - - const { status: nonMatchingSourceStatus, data: nonMatchingSourceGroup } = - await http.post({ - path: "/groups", - body: { - name: "E2E Source Group - Partner Group Mismatch", - slug: sourceSlug, - color: randomValue(RESOURCE_COLORS), - }, - }); - - expect(nonMatchingSourceStatus).toEqual(201); - - const { status: targetStatus, data: targetGroup } = - await http.post({ - path: "/groups", - body: { - name: "E2E Target Group - Partner Group Mismatch", - slug, - color: randomValue(RESOURCE_COLORS), - }, - }); - - expect(targetStatus).toEqual(201); - - onTestFinished(async () => { - await http.delete({ path: `/groups/${targetGroup.id}` }); - await http.delete({ path: `/groups/${nonMatchingSourceGroup.id}` }); - }); - - const { status: patchStatus } = await http.patch({ - path: `/groups/${targetGroup.id}`, - body: { - moveRules: [ - { - attribute: "totalLeads", - operator: "between", - value: { min: 1, max: 2 }, - }, - { - attribute: "partnerGroup", - operator: "eq", - value: allowedSourceGroup.id, - }, - ], - }, - }); - - expect(patchStatus).toEqual(200); - - const { status: partnerStatus, data: partner } = - await http.post({ - path: "/partners", - body: { - name: "E2E Test Partner - Partner Group Mismatch", - email: randomPartnerEmail(), - groupId: nonMatchingSourceGroup.id, - }, - }); - - expect(partnerStatus).toEqual(201); - expect(partner.links).not.toBeNull(); - expect(partner.groupId).toBe(nonMatchingSourceGroup.id); - - const partnerLink = partner.links![0]; - - await trackE2ELead(http, partnerLink); - - await new Promise((resolve) => setTimeout(resolve, 10000)); - - const { data: partnerAfter } = await http.get({ - path: `/partners/${partner.id}`, - }); - - expect(partnerAfter.groupId).toBe(nonMatchingSourceGroup.id); - }); - - test("Workflow skips partner with groupMoveDisabledAt set", async () => { - const slug = "e2e-target-skip-partner-move"; - - // Get the current group of E2E_PARTNER - const { data: partner, status: partnerStatus } = await http.get< - EnrolledPartnerProps & { groupMoveDisabledAt: Date } - >({ - path: `/partners/${E2E_PARTNER.id}`, - }); - - expect(partnerStatus).toEqual(200); - expect(partner).not.toBeNull(); - expect(partner.groupMoveDisabledAt).not.toBeNull(); - - const partnerLink = partner.links![0]; - - // Create a new group - const { data: targetGroup } = await http.post({ - path: "/groups", - body: { - name: "E2E Target Group - Skip Partner Move", - slug, - color: randomValue(RESOURCE_COLORS), - }, - }); - - expect(targetGroup).not.toBeNull(); - - onTestFinished(async () => { - await http.delete({ - path: `/groups/${targetGroup.id}`, - }); - }); - - // Update the group with move rule - const { status: patchStatus } = await http.patch({ - path: `/groups/${targetGroup.id}`, - body: { - moveRules: [ - { - attribute: "totalLeads", - operator: "gte", - value: 1000, - }, - ], - }, - }); - - expect(patchStatus).toEqual(200); - - await trackE2ELead(http, partnerLink); - - await verifyPartnerGroupMove({ - http, - partnerId: partner.id, - expectedGroupId: partner.groupId!, - }); - - const { data: partnerAfter } = await http.get< - EnrolledPartnerProps & { groupMoveDisabledAt: Date } - >({ - path: `/partners/${partner.id}`, - }); - - expect(partnerAfter.groupId).toBe(partner.groupId); - expect(partnerAfter.groupMoveDisabledAt).not.toBeNull(); - }); -}); diff --git a/apps/web/tests/workflows/send-campaign-workflow.test.ts b/apps/web/tests/workflows/send-campaign-workflow.test.ts deleted file mode 100644 index e8dcd031321..00000000000 --- a/apps/web/tests/workflows/send-campaign-workflow.test.ts +++ /dev/null @@ -1,876 +0,0 @@ -import { EnrolledPartnerProps } from "@/lib/types"; -import { Campaign } from "@prisma/client"; -import { subHours } from "date-fns"; -import { describe, expect, onTestFinished, test } from "vitest"; -import { randomPartnerEmail } from "../utils/helpers"; -import { IntegrationHarness } from "../utils/integration"; -import { E2E_USER_ID } from "../utils/resource"; -import { verifyCampaignSent } from "./utils/verify-campaign-sent"; - -describe.sequential("Workflow - SendCampaign", async () => { - const h = new IntegrationHarness(); - const { http } = await h.init(); - - test("Workflow is created when transactional campaign is published", async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - expect(campaign.id).toBeDefined(); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await h.deleteCampaign(campaignId); - }); - - const { status: updateStatus } = await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E Test Campaign", - subject: "Welcome to our program!", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - text: "Thank you for joining!", - }, - ], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 1, - }, - ], - }, - }); - - expect(updateStatus).toEqual(200); - - const { status: publishStatus, data: publishedCampaign } = - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - status: "active", - }, - }); - - expect(publishStatus).toEqual(200); - expect(publishedCampaign.status).toBe("active"); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - expect(workflow.disabledAt).toBeNull(); - - const workflowActions = workflow.actions as any[]; - expect(workflowActions[0].type).toBe("sendCampaign"); - expect(workflowActions[0].data.campaignId).toBe(campaignId); - }); - - test("Workflow doesn't execute when campaign is in draft", async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await h.deleteCampaign(campaignId); - }); - - const { status: updateStatus, data: updatedCampaign } = - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E Draft Campaign", - subject: "This should not be sent", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { - type: "text", - text: "Draft content", - }, - ], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 1, - }, - ], - }, - }); - - expect(updateStatus).toEqual(200); - expect(updatedCampaign.status).toBe("draft"); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - expect(workflow.disabledAt).not.toBeNull(); - }); - - test("Cron executes send campaign workflow", async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await h.deleteCampaign(campaignId); - }); - - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E Cron Campaign", - subject: "Welcome!", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Test content" }], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 1, - }, - ], - status: "active", - }, - }); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - - const { status, data } = await http.post<{ message: string }>({ - path: `/e2e/trigger-workflow/${workflow.id}`, - }); - - expect(status).toEqual(200); - expect(data.message).toContain("Finished executing workflow"); - }); - - test("Cron skips disabled send campaign workflow", async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await h.deleteCampaign(campaignId); - }); - - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E Disabled Cron Campaign", - subject: "Should not be sent", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Test" }], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 1, - }, - ], - status: "active", - }, - }); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - - // Disable workflow via E2E endpoint - await http.patch({ - path: `/e2e/workflows/${workflow.id}`, - body: { disabledAt: new Date().toISOString() }, - }); - - const { status, data } = await http.post<{ message: string }>({ - path: `/e2e/trigger-workflow/${workflow.id}`, - }); - - expect(status).toEqual(200); - expect(data.message).toContain("disabled"); - - const { data: emailsSent } = await http.get({ - path: "/e2e/notification-emails", - query: { campaignId }, - }); - - expect(emailsSent).toHaveLength(0); - }); - - test("Cron processes eligible partner enrollment", async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await h.deleteCampaign(campaignId); - }); - - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E Send Campaign", - subject: "Welcome partner!", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Hello!" }], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 1, - }, - ], - status: "active", - }, - }); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - - const { status: partnerStatus, data: partner } = - await http.post({ - path: "/partners", - body: { - name: "E2E Test Partner - Campaign Send", - email: randomPartnerEmail(), - }, - }); - - expect(partnerStatus).toEqual(201); - - // Backdate the enrollment to 18h ago so it falls in the cron window - await http.patch({ - path: "/e2e/enrollments", - body: { - partnerId: partner.id, - createdAt: subHours(new Date(), 18).toISOString(), - }, - }); - - const { status, data } = await http.post<{ message: string }>({ - path: `/e2e/trigger-workflow/${workflow.id}`, - }); - - expect(status).toEqual(200); - expect(data.message).toContain("Finished executing workflow"); - }); - - test("Cron doesn't send campaign when partner doesn't meet conditions", async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await h.deleteCampaign(campaignId); - }); - - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E No Match Campaign", - subject: "Should not be sent", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Test" }], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 1, - }, - ], - status: "active", - }, - }); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - - // Create a partner enrolled just now — doesn't match the 12-24h window - const { status: partnerStatus, data: partner } = - await http.post({ - path: "/partners", - body: { - name: "E2E Test Partner - No Match", - email: randomPartnerEmail(), - }, - }); - - expect(partnerStatus).toEqual(201); - - const { status, data: triggerData } = await http.post<{ message: string }>({ - path: `/e2e/trigger-workflow/${workflow.id}`, - }); - - expect(status).toEqual(200); - expect(triggerData.message).toContain("Finished executing workflow"); - - const { data: emailsSent } = await http.get({ - path: "/e2e/notification-emails", - query: { campaignId, partnerId: partner.id }, - }); - - expect(emailsSent).toHaveLength(0); - }); - - test("No duplicate campaign sends on multiple cron executions", async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await http.delete({ - path: "/e2e/notification-emails", - query: { campaignId }, - }); - await h.deleteCampaign(campaignId); - }); - - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E No Dup Campaign", - subject: "No duplicates!", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Hello!" }], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 1, - }, - ], - status: "active", - }, - }); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - - const { status: partnerStatus, data: partner } = - await http.post({ - path: "/partners", - body: { - name: "E2E Test Partner - No Dup Campaign", - email: randomPartnerEmail(), - }, - }); - - expect(partnerStatus).toEqual(201); - - // Backdate enrollment to match the cron window - await http.patch({ - path: "/e2e/enrollments", - body: { - partnerId: partner.id, - createdAt: subHours(new Date(), 18).toISOString(), - }, - }); - - // Pre-insert a notification email to simulate a previous send - const { data: existingEmail } = await http.post({ - path: "/e2e/notification-emails", - body: { - campaignId, - partnerId: partner.id, - recipientUserId: E2E_USER_ID, - }, - }); - - expect(existingEmail).not.toBeNull(); - - // Trigger the workflow — should skip this partner (already sent) - const { status, data: triggerData } = await http.post<{ message: string }>({ - path: `/e2e/trigger-workflow/${workflow.id}`, - }); - - expect(status).toEqual(200); - expect(triggerData.message).toContain("Finished executing workflow"); - - // Verify still only 1 notification email (no duplicate) - const { data: emails } = await http.get({ - path: "/e2e/notification-emails", - query: { campaignId, partnerId: partner.id }, - }); - - expect(emails).toHaveLength(1); - expect(emails[0].id).toBe(existingEmail.id); - }); - - test("Campaign workflow configuration can be updated", async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await h.deleteCampaign(campaignId); - }); - - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E Campaign Config Test", - subject: "Test", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Test" }], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 1, - }, - ], - status: "active", - }, - }); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - const conditions1 = workflow.triggerConditions as any[]; - expect(conditions1[0].value).toBe(1); - - const { status: pauseStatus, data: pausedCampaign } = - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - status: "paused", - }, - }); - - expect(pauseStatus).toEqual(200); - expect(pausedCampaign.status).toBe("paused"); - - const { data: pausedWorkflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(pausedWorkflow.disabledAt).not.toBeNull(); - }); - - test("Campaign supports mixed enrollment and metric conditions", async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await h.deleteCampaign(campaignId); - }); - - const { status: updateStatus, data: updatedCampaign } = - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E Mixed Conditions Campaign", - subject: "Still no leads after 30 days", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Let's get those leads!" }], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 30, - }, - { - attribute: "totalLeads", - operator: "lte", - value: 0, - }, - ], - status: "active", - }, - }); - - expect(updateStatus).toEqual(200); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - expect(workflow.disabledAt).toBeNull(); - - const conditions = workflow.triggerConditions as any[]; - expect(conditions).toHaveLength(2); - expect(conditions[0]).toMatchObject({ - attribute: "partnerEnrolledDays", - operator: "gte", - value: 30, - }); - expect(conditions[1]).toMatchObject({ - attribute: "totalLeads", - operator: "lte", - value: 0, - }); - - // Response shape exposes the full conditions array - expect((updatedCampaign as any).triggerConditions).toEqual(conditions); - }); - - test( - "Cron sends campaign when all conditions are met (AND)", - { timeout: 90000 }, - async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await http.delete({ - path: "/e2e/notification-emails", - query: { campaignId }, - }); - await h.deleteCampaign(campaignId); - }); - - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E AND Match Campaign", - subject: "You got a lead!", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Congrats on your first lead!" }], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 1, - }, - { - attribute: "totalLeads", - operator: "gte", - value: 1, - }, - ], - status: "active", - }, - }); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - - const { status: partnerStatus, data: partner } = - await http.post({ - path: "/partners", - body: { - name: "E2E Test Partner - AND Match", - email: randomPartnerEmail(), - }, - }); - - expect(partnerStatus).toEqual(201); - expect(partner.links).not.toBeNull(); - - // API-created partners have no PartnerUser, and campaign emails only - // go to users with an email. Create one and set link.leads directly so - // the cron AND conditions are met without racing Tinybird waitUntil. - const { status: enrollmentStatus } = await http.patch({ - path: "/e2e/enrollments", - body: { - partnerId: partner.id, - createdAt: subHours(new Date(), 18).toISOString(), - leads: 1, - createUser: true, - }, - }); - - expect(enrollmentStatus).toEqual(200); - - const { status, data } = await http.post<{ message: string }>({ - path: `/e2e/trigger-workflow/${workflow.id}`, - }); - - expect(status).toEqual(200); - expect(data.message).toContain("Finished executing workflow"); - - await verifyCampaignSent({ - http, - campaignId, - partnerId: partner.id, - }); - }, - ); - - test("Cron doesn't send when metric condition fails (AND)", async () => { - const { status: createStatus, data: campaign } = await http.post<{ - id: string; - }>({ - path: "/campaigns", - body: { - type: "transactional", - }, - }); - - expect(createStatus).toEqual(201); - - const campaignId = campaign.id; - - onTestFinished(async () => { - await h.deleteCampaign(campaignId); - }); - - await http.patch({ - path: `/campaigns/${campaignId}`, - body: { - name: "E2E AND Partial Match Campaign", - subject: "Should not be sent", - bodyJson: { - type: "doc", - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "Test" }], - }, - ], - }, - triggerConditions: [ - { - attribute: "partnerEnrolledDays", - operator: "gte", - value: 1, - }, - { - attribute: "totalLeads", - operator: "gte", - value: 1, - }, - ], - status: "active", - }, - }); - - const { data: workflow } = await http.get({ - path: "/e2e/workflows", - query: { campaignId }, - }); - - expect(workflow).not.toBeNull(); - - const { status: partnerStatus, data: partner } = - await http.post({ - path: "/partners", - body: { - name: "E2E Test Partner - AND Partial", - email: randomPartnerEmail(), - }, - }); - - expect(partnerStatus).toEqual(201); - - // Enrollment window matches, but totalLeads is 0 — AND fails - await http.patch({ - path: "/e2e/enrollments", - body: { - partnerId: partner.id, - createdAt: subHours(new Date(), 18).toISOString(), - }, - }); - - const { status, data: triggerData } = await http.post<{ message: string }>({ - path: `/e2e/trigger-workflow/${workflow.id}`, - }); - - expect(status).toEqual(200); - expect(triggerData.message).toContain("Finished executing workflow"); - - const { data: emailsSent } = await http.get({ - path: "/e2e/notification-emails", - query: { campaignId, partnerId: partner.id }, - }); - - expect(emailsSent).toHaveLength(0); - }); -}); diff --git a/apps/web/tests/workflows/utils/verify-campaign-sent.ts b/apps/web/tests/workflows/utils/verify-campaign-sent.ts deleted file mode 100644 index aa8a433748e..00000000000 --- a/apps/web/tests/workflows/utils/verify-campaign-sent.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - VITEST_POLL_INTERVAL_MS, - VITEST_TEST_TIMEOUT_MS, -} from "@/lib/constants/misc"; -import { expect } from "vitest"; -import { HttpClient } from "../../utils/http"; - -interface VerifyCampaignSentProps { - http: HttpClient; - campaignId: string; - partnerId: string; -} - -export const verifyCampaignSent = async ({ - http, - campaignId, - partnerId, -}: VerifyCampaignSentProps) => { - const startTime = Date.now(); - - while (Date.now() - startTime < VITEST_TEST_TIMEOUT_MS) { - const { data: emails } = await http.get({ - path: "/e2e/notification-emails", - query: { campaignId, partnerId }, - }); - - const emailSent = emails?.[0]; - - if (emailSent) { - expect(emailSent.type).toBe("Campaign"); - expect(emailSent.campaignId).toBe(campaignId); - expect(emailSent.partnerId).toBe(partnerId); - return emailSent; - } - - await new Promise((resolve) => - setTimeout(resolve, VITEST_POLL_INTERVAL_MS), - ); - } - - throw new Error( - `Campaign email not found within ${VITEST_TEST_TIMEOUT_MS / 1000} seconds. ` + - `campaignId: ${campaignId}, partnerId: ${partnerId}`, - ); -}; diff --git a/apps/web/tests/workflows/utils/verify-merge-completed.ts b/apps/web/tests/workflows/utils/verify-merge-completed.ts new file mode 100644 index 00000000000..491b44bc9b6 --- /dev/null +++ b/apps/web/tests/workflows/utils/verify-merge-completed.ts @@ -0,0 +1,64 @@ +import { + VITEST_POLL_INTERVAL_MS, + VITEST_TEST_TIMEOUT_MS, +} from "@/lib/constants/misc"; +import { EnrolledPartnerProps } from "@/lib/types"; +import { expect } from "vitest"; +import { HttpClient } from "../../utils/http"; + +interface VerifyMergeCompletedProps { + http: HttpClient; + sourcePartnerId: string; + targetPartnerId: string; + // A link id that belonged to the source partner and should end up on the target + expectedLinkId: string; +} + +/** + * Polls until the merge-partner-accounts workflow has finished: + * - the source partner is deleted (GET /partners/:id returns 404), and + * - the target partner now owns the source's moved link. + */ +export const verifyMergeCompleted = async ({ + http, + sourcePartnerId, + targetPartnerId, + expectedLinkId, +}: VerifyMergeCompletedProps) => { + const startTime = Date.now(); + + let lastSourceStatus: number | null = null; + let lastTargetLinkIds: string[] = []; + + while (Date.now() - startTime < VITEST_TEST_TIMEOUT_MS) { + const [sourceRes, targetRes] = await Promise.all([ + http.get<{ error?: unknown }>({ path: `/partners/${sourcePartnerId}` }), + http.get({ path: `/partners/${targetPartnerId}` }), + ]); + + lastSourceStatus = sourceRes.status; + + const sourceDeleted = sourceRes.status === 404; + const targetLinks = + targetRes.status === 200 ? targetRes.data.links ?? [] : []; + lastTargetLinkIds = targetLinks.map((link) => link.id); + const targetOwnsLink = lastTargetLinkIds.includes(expectedLinkId); + + if (sourceDeleted && targetOwnsLink) { + expect(sourceRes.status).toBe(404); + expect(lastTargetLinkIds).toContain(expectedLinkId); + return targetRes.data; + } + + await new Promise((resolve) => + setTimeout(resolve, VITEST_POLL_INTERVAL_MS), + ); + } + + throw new Error( + `Merge did not complete within ${VITEST_TEST_TIMEOUT_MS / 1000} seconds. ` + + `sourcePartnerId: ${sourcePartnerId} (last status: ${lastSourceStatus}), ` + + `targetPartnerId: ${targetPartnerId}, expectedLinkId: ${expectedLinkId}. ` + + `Last seen target link ids: [${lastTargetLinkIds.join(", ")}]`, + ); +}; diff --git a/apps/web/tests/workflows/utils/verify-partner-group-move.ts b/apps/web/tests/workflows/utils/verify-partner-group-move.ts deleted file mode 100644 index 2a9bd2a6aa2..00000000000 --- a/apps/web/tests/workflows/utils/verify-partner-group-move.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - VITEST_POLL_INTERVAL_MS, - VITEST_TEST_TIMEOUT_MS, -} from "@/lib/constants/misc"; -import { EnrolledPartnerProps } from "@/lib/types"; -import { expect } from "vitest"; -import { HttpClient } from "../../utils/http"; - -interface VerifyPartnerGroupMoveProps { - http: HttpClient; - partnerId: string; - expectedGroupId: string; - query?: Record; -} - -export const verifyPartnerGroupMove = async ({ - http, - partnerId, - expectedGroupId, - query = {}, -}: VerifyPartnerGroupMoveProps) => { - const startTime = Date.now(); - let lastGroupId: string | null = null; - - while (Date.now() - startTime < VITEST_TEST_TIMEOUT_MS) { - const { data: partner } = await http.get({ - path: `/partners/${partnerId}`, - query, - }); - - lastGroupId = partner?.groupId ?? null; - - if (partner?.groupId === expectedGroupId) { - expect(partner.groupId).toBe(expectedGroupId); - return partner; - } - - await new Promise((resolve) => - setTimeout(resolve, VITEST_POLL_INTERVAL_MS), - ); - } - - throw new Error( - `Partner group move not found within ${VITEST_TEST_TIMEOUT_MS / 1000} seconds. ` + - `partnerId: ${partnerId}, expectedGroupId: ${expectedGroupId}. ` + - `Last seen groupId: ${lastGroupId}`, - ); -}; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 8d9d479a08f..85734462009 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -8,6 +8,7 @@ "baseUrl": ".", "paths": { "@/pages/*": ["pages/*"], + "@/scripts/*": ["scripts/*"], "@/styles/*": ["styles/*"], "@/ui/*": ["ui/*"], "@/lib/*": ["lib/*"] diff --git a/apps/web/ui/account/upload-avatar.tsx b/apps/web/ui/account/upload-avatar.tsx index 81e79f03fff..8537a0d5fd3 100644 --- a/apps/web/ui/account/upload-avatar.tsx +++ b/apps/web/ui/account/upload-avatar.tsx @@ -87,8 +87,8 @@ export default function UploadAvatar({

- Square image recommended. Accepted file types: .png, .jpg. Max file - size: 2MB. + Square image recommended. Accepted file types: .png, .jpg, .webp, + .avif. Max file size: 2MB.

+ )} +
); } diff --git a/apps/web/ui/guides/integrations.ts b/apps/web/ui/guides/integrations.ts index bad5247cd39..890f4ee82c5 100644 --- a/apps/web/ui/guides/integrations.ts +++ b/apps/web/ui/guides/integrations.ts @@ -29,6 +29,7 @@ export type IntegrationGuide = { recommended?: boolean; content?: string; url: string; + stepLabel?: string; }; export const sections: { @@ -56,6 +57,142 @@ export const sections: { }, ]; +export type StackItem = { + id: string; + title: string; + icon: any; + iconProps?: { + fullSize?: boolean; + }; + guideKeys: string[]; + type: IntegrationType; +}; + +export const stackItems: StackItem[] = [ + { + id: "react", + title: "React", + icon: React, + guideKeys: ["react"], + type: "client-sdk", + }, + { + id: "gtm", + title: "Google Tag Manager", + icon: GoogleTagManager, + guideKeys: ["gtm-client-sdk", "gtm-track-lead", "gtm-track-sale"], + type: "client-sdk", + }, + { + id: "framer", + title: "Framer", + icon: Framer, + guideKeys: ["framer"], + type: "client-sdk", + }, + { + id: "wordpress", + title: "WordPress", + icon: Wordpress, + guideKeys: ["wordpress"], + type: "client-sdk", + }, + { + id: "webflow", + title: "Webflow", + icon: Webflow, + guideKeys: ["webflow"], + type: "client-sdk", + }, + { + id: "shopify", + title: "Shopify", + icon: Shopify, + guideKeys: ["shopify"], + type: "client-sdk", + }, + { + id: "stripe-checkout", + title: "Stripe Checkout", + icon: StripeIcon, + iconProps: { fullSize: true }, + guideKeys: ["stripe-checkout"], + type: "track-sale", + }, + { + id: "stripe-payment-links", + title: "Stripe Payment Links", + icon: StripeIcon, + iconProps: { fullSize: true }, + guideKeys: ["stripe-payment-links"], + type: "track-sale", + }, + { + id: "stripe-customers", + title: "Stripe Customers", + icon: StripeIcon, + iconProps: { fullSize: true }, + guideKeys: ["stripe-customers"], + type: "track-sale", + }, + { + id: "segment", + title: "Segment", + icon: Segment, + guideKeys: ["segment-track-lead", "segment-track-sale"], + type: "track-lead", + }, + { + id: "clerk", + title: "Clerk", + icon: Clerk, + guideKeys: ["clerk"], + type: "track-lead", + }, + { + id: "better-auth", + title: "Better Auth", + icon: BetterAuth, + guideKeys: ["better-auth"], + type: "track-lead", + }, + { + id: "next-auth", + title: "NextAuth.js", + icon: NextAuth, + guideKeys: ["next-auth"], + type: "track-lead", + }, + { + id: "supabase", + title: "Supabase", + icon: Supabase, + guideKeys: ["supabase"], + type: "track-lead", + }, + { + id: "auth0", + title: "Auth0", + icon: Auth0, + guideKeys: ["auth0"], + type: "track-lead", + }, + { + id: "appwrite", + title: "Appwrite", + icon: Appwrite, + guideKeys: ["appwrite"], + type: "track-lead", + }, + { + id: "custom", + title: "Custom integration", + icon: CodeEditor, + guideKeys: ["manual-client-sdk", "manual-track-lead"], + type: "client-sdk", + }, +]; + export const guides: IntegrationGuide[] = [ // Client SDK { @@ -116,7 +253,8 @@ export const guides: IntegrationGuide[] = [ title: "Custom Integration", description: "Manual Lead Tracking", icon: CodeEditor, - url: "https://dub.co/docs/conversions/leads/introduction", + url: "https://dub.co/docs/quickstart/server", + stepLabel: "Server-side", }, { type: "track-lead", @@ -233,6 +371,7 @@ export const guides: IntegrationGuide[] = [ title: "Custom Integration", description: "Manual Sale Tracking", icon: CodeEditor, - url: "https://dub.co/docs/conversions/sales/introduction", + url: "https://dub.co/docs/quickstart/server", + stepLabel: "Server-side", }, ]; diff --git a/apps/web/ui/layout/sidebar/app-sidebar-nav.tsx b/apps/web/ui/layout/sidebar/app-sidebar-nav.tsx index d5da8a07f84..5e60a285573 100644 --- a/apps/web/ui/layout/sidebar/app-sidebar-nav.tsx +++ b/apps/web/ui/layout/sidebar/app-sidebar-nav.tsx @@ -295,7 +295,6 @@ const NAV_AREAS: SidebarNavAreas = { // short links links: ({ slug, pathname, queryString }) => ({ title: "Short Links", - showNews: true, direction: "left", content: [ { @@ -570,9 +569,6 @@ export function AppSidebarNav({ () => router.push(`/${slug}/${defaultProduct}`), { enabled: currentArea === "workspaceSettings", - priority: 2, - modal: false, - sheet: false, }, ); @@ -650,6 +646,9 @@ export function AppSidebarNav({ ) : null; + const freePlanOrTrial = + plan && (plan === "free" || isWorkspaceBillingTrialActive(trialEndsAt)); + return ( } toolContent={toolContent} - newsContent={ - plan && - (plan === "free" || isWorkspaceBillingTrialActive(trialEndsAt) ? ( - - ) : ( - newsContent - )) + bottomContent={ + <> +
{AppBottomContent}
+ {freePlanOrTrial && } + } - switcher={} - bottom={
{AppBottomContent}
} + newsContent={!freePlanOrTrial && currentArea === "links" && newsContent} /> ); } diff --git a/apps/web/ui/layout/sidebar/partner-program-dropdown.tsx b/apps/web/ui/layout/sidebar/partner-program-dropdown.tsx index bed4d990546..e557adc0613 100644 --- a/apps/web/ui/layout/sidebar/partner-program-dropdown.tsx +++ b/apps/web/ui/layout/sidebar/partner-program-dropdown.tsx @@ -125,7 +125,7 @@ export function PartnerProgramDropdown() { className="size-5 flex-none shrink-0 overflow-hidden rounded-full" /> )} -
+
{selectedProgram?.name || "Your programs"}
@@ -141,10 +141,17 @@ export function PartnerProgramDropdown() { function PartnerDropdownPlaceholder() { return ( -
-
-
-
+ +
{testVariantsParent && ( -
- -

- Changing the original A/B test settings will impact your future - analytics and event tracking. -

-
+ + Changing the original A/B test settings will impact your future + analytics and event tracking. + )}
diff --git a/apps/web/ui/modals/partner-link-modal.tsx b/apps/web/ui/modals/partner-link-modal.tsx index ce228bc3efd..53a409be6ab 100644 --- a/apps/web/ui/modals/partner-link-modal.tsx +++ b/apps/web/ui/modals/partner-link-modal.tsx @@ -183,13 +183,21 @@ function PartnerLinkModalContent({ }; }, [programEnrollment]); - const destinationDomains = useMemo( - () => - additionalLinks - .map((link) => link.domain) - .filter((d): d is string => d != null), - [additionalLinks], - ); + const destinationDomains = useMemo(() => { + const domains = additionalLinks + .map((link) => link.domain) + .filter((d): d is string => d != null); + + if (domains.length > 0) { + return domains; + } + + const programUrlDomain = programEnrollment?.program?.url + ? getDomainWithoutWWW(programEnrollment.program.url) + : null; + + return programUrlDomain ? [programUrlDomain] : []; + }, [additionalLinks, programEnrollment?.program?.url]); const [destinationDomain, setDestinationDomain] = useState( link diff --git a/apps/web/ui/modals/reactivate-partner-modal.tsx b/apps/web/ui/modals/reactivate-partner-modal.tsx index 3b8595713a3..be3755b1812 100644 --- a/apps/web/ui/modals/reactivate-partner-modal.tsx +++ b/apps/web/ui/modals/reactivate-partner-modal.tsx @@ -113,7 +113,7 @@ export function useReactivatePartnerModal({ partner={partner} /> ); - }, [showReactivatePartnerModal, setShowReactivatePartnerModal, partner]); + }, [showReactivatePartnerModal, setShowReactivatePartnerModal]); return useMemo( () => ({ diff --git a/apps/web/ui/modals/register-domain-modal.tsx b/apps/web/ui/modals/register-domain-modal.tsx index d3bb13d675a..3a203a28092 100644 --- a/apps/web/ui/modals/register-domain-modal.tsx +++ b/apps/web/ui/modals/register-domain-modal.tsx @@ -1,4 +1,4 @@ -import { Modal, useRouterStuff } from "@dub/ui"; +import { Modal, useLatestCallback, useRouterStuff } from "@dub/ui"; import { useCallback, useMemo, useState } from "react"; import { RegisterDomainForm } from "../domains/register-domain-form"; @@ -46,17 +46,26 @@ const RegisterDomain = ({ export function useRegisterDomainModal( props: Omit = {}, ) { + const { onSuccess, setRegisteredParam } = props; const [showRegisterDomainModal, setShowRegisterDomainModal] = useState(false); + const onSuccessCallback = useLatestCallback(onSuccess); + const RegisterDomainModal = useCallback(() => { return ( ); - }, [showRegisterDomainModal, setShowRegisterDomainModal, props]); + }, [ + showRegisterDomainModal, + setShowRegisterDomainModal, + onSuccessCallback, + setRegisteredParam, + ]); return useMemo( () => ({ setShowRegisterDomainModal, RegisterDomainModal }), diff --git a/apps/web/ui/modals/remove-workspace-user-modal.tsx b/apps/web/ui/modals/remove-workspace-user-modal.tsx index a6960c25270..f161ade0e78 100644 --- a/apps/web/ui/modals/remove-workspace-user-modal.tsx +++ b/apps/web/ui/modals/remove-workspace-user-modal.tsx @@ -1,9 +1,9 @@ import { mutatePrefix } from "@/lib/swr/mutate"; import useWorkspace from "@/lib/swr/use-workspace"; import { TokenProps, UserProps } from "@/lib/types"; +import { Callout } from "@/ui/shared/callout"; import { UserAvatar } from "@/ui/users/user-avatar"; import { Button, Modal, useMediaQuery } from "@dub/ui"; -import { TriangleWarning } from "@dub/ui/icons"; import { fetcher, timeAgo } from "@dub/utils"; import { useSession } from "next-auth/react"; import { useRouter, useSearchParams } from "next/navigation"; @@ -91,40 +91,34 @@ function RemoveWorkspaceUserModal({ const content = ( <> {restrictedTokens && restrictedTokens.length > 0 && ( -
-
- -
- -

- Warning: Active tokens detected -

- -

- {self ? "You have" : "This user has"} {restrictedTokens.length}{" "} - active tokens. {self ? "Leaving" : "Removing this user"} will - invalidate these tokens and may disrupt the integration. -

+ +
+

Warning: Active tokens detected

+ +

+ {self ? "You have" : "This user has"} {restrictedTokens.length}{" "} + active tokens. {self ? "Leaving" : "Removing this user"} will + invalidate these tokens and may disrupt the integration. +

-
-
    +
      {restrictedTokens.map((token, index) => (
    • {token.name} - + used {timeAgo(token.lastUsed, { withAgo: true })}
    • ))}
-
+
)}
@@ -239,7 +233,7 @@ export function useRemoveWorkspaceUserModal({ user }: { user: UserProps }) { user={user} /> ); - }, [showRemoveWorkspaceUserModal, setShowRemoveWorkspaceUserModal, user]); + }, [showRemoveWorkspaceUserModal, setShowRemoveWorkspaceUserModal]); return useMemo( () => ({ diff --git a/apps/web/ui/modals/send-test-webhook-modal.tsx b/apps/web/ui/modals/send-test-webhook-modal.tsx index cec9ee9f239..2df29f8edf0 100644 --- a/apps/web/ui/modals/send-test-webhook-modal.tsx +++ b/apps/web/ui/modals/send-test-webhook-modal.tsx @@ -1,7 +1,7 @@ import { sendTestWebhookEvent } from "@/lib/actions/send-test-webhook"; import useWorkspace from "@/lib/swr/use-workspace"; import { WebhookProps } from "@/lib/types"; -import { WEBHOOK_TRIGGER_DESCRIPTIONS } from "@/lib/webhook/constants"; +import { WEBHOOK_TRIGGERS } from "@/lib/webhook/constants"; import type { WebhookTrigger } from "@/lib/webhook/types"; import { Button, Combobox, ComboboxOption, Modal } from "@dub/ui"; import { useAction } from "next-safe-action/hooks"; @@ -38,13 +38,6 @@ function SendTestWebhookModal({ }, }); - const triggers = Object.entries(WEBHOOK_TRIGGER_DESCRIPTIONS).map( - ([key, value]) => ({ - value: key, - label: value, - }), - ); - return ( ({ + value: trigger, + label: trigger, + }))} selected={selectedTrigger} setSelected={setSelectedTrigger} placeholder="Select a webhook event" matchTriggerWidth caret + labelProps={{ className: "font-mono text-sm text-neutral-800" }} + optionClassName="font-mono" />
diff --git a/apps/web/ui/modals/share-dashboard-modal.tsx b/apps/web/ui/modals/share-dashboard-modal.tsx index 65db3bb119c..1bf63cd1def 100644 --- a/apps/web/ui/modals/share-dashboard-modal.tsx +++ b/apps/web/ui/modals/share-dashboard-modal.tsx @@ -500,7 +500,7 @@ export function useShareDashboardModal(props: ShareDashboardModalInnerProps) { {...props} /> ); - }, [showShareDashboardModal, setShowShareDashboardModal, props]); + }, [showShareDashboardModal, setShowShareDashboardModal]); return useMemo( () => ({ diff --git a/apps/web/ui/modals/unban-partner-modal.tsx b/apps/web/ui/modals/unban-partner-modal.tsx index e57d59cbf55..1dcd1632211 100644 --- a/apps/web/ui/modals/unban-partner-modal.tsx +++ b/apps/web/ui/modals/unban-partner-modal.tsx @@ -156,7 +156,7 @@ export function useUnbanPartnerModal({ partner={partner} /> ); - }, [showUnbanPartnerModal, setShowUnbanPartnerModal, partner]); + }, [showUnbanPartnerModal, setShowUnbanPartnerModal]); return useMemo( () => ({ diff --git a/apps/web/ui/modals/update-workspace-user-role.tsx b/apps/web/ui/modals/update-workspace-user-role.tsx index e3e3dcc3c71..94686888dd5 100644 --- a/apps/web/ui/modals/update-workspace-user-role.tsx +++ b/apps/web/ui/modals/update-workspace-user-role.tsx @@ -136,7 +136,7 @@ export function useWorkspaceUserRoleModal({ role={role} /> ); - }, [showWorkspaceUserRoleModal, setShowWorkspaceUserRoleModal, user, role]); + }, [showWorkspaceUserRoleModal, setShowWorkspaceUserRoleModal]); return useMemo( () => ({ diff --git a/apps/web/ui/modals/use-import-modal-param.ts b/apps/web/ui/modals/use-import-modal-param.ts new file mode 100644 index 00000000000..8c11cbffc2a --- /dev/null +++ b/apps/web/ui/modals/use-import-modal-param.ts @@ -0,0 +1,18 @@ +import { useSearchParams } from "next/navigation"; +import { Dispatch, SetStateAction, useEffect, useState } from "react"; + +// Keeps an import modal's show state in sync with the `?import=` query param +// at the hook level rather than in the modal component itself, which remounts +// on every open/close and would re-open from a stale param mid-navigation +export function useImportModalParam( + provider: string, +): [boolean, Dispatch>] { + const [showModal, setShowModal] = useState(false); + const searchParams = useSearchParams(); + + useEffect(() => { + setShowModal(searchParams?.get("import") === provider); + }, [searchParams, provider]); + + return [showModal, setShowModal]; +} diff --git a/apps/web/ui/partners/bounties/reject-bounty-submission-modal.tsx b/apps/web/ui/partners/bounties/reject-bounty-submission-modal.tsx index e0b9fd58edc..b5bfe9dc152 100644 --- a/apps/web/ui/partners/bounties/reject-bounty-submission-modal.tsx +++ b/apps/web/ui/partners/bounties/reject-bounty-submission-modal.tsx @@ -1,18 +1,15 @@ -import { rejectBountySubmissionAction } from "@/lib/actions/partners/reject-bounty-submission"; import { BOUNTY_MAX_SUBMISSION_REJECTION_NOTE_LENGTH, REJECT_BOUNTY_SUBMISSION_REASONS, } from "@/lib/bounty/constants"; import { mutatePrefix } from "@/lib/swr/mutate"; -import useBounty from "@/lib/swr/use-bounty"; -import useWorkspace from "@/lib/swr/use-workspace"; +import { useApiMutation } from "@/lib/swr/use-api-mutation"; import { BountySubmissionProps } from "@/lib/types"; import { rejectBountySubmissionBodySchema } from "@/lib/zod/schemas/bounties"; import { MaxCharactersCounter } from "@/ui/shared/max-characters-counter"; import { Button, Modal, useKeyboardShortcut } from "@dub/ui"; import { cn } from "@dub/utils"; -import { useAction } from "next-safe-action/hooks"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import * as z from "zod/v4"; @@ -30,12 +27,11 @@ const RejectBountySubmissionModal = ({ setShowModal, onReject, }: RejectBountySubmissionModalProps) => { - const { bounty } = useBounty(); - const workspace = useWorkspace(); + const { makeRequest: rejectBountySubmission, isSubmitting } = + useApiMutation(); const { register, - watch, getValues, control, formState: { errors }, @@ -46,35 +42,39 @@ const RejectBountySubmissionModal = ({ }, }); - const { executeAsync: rejectBountySubmission, isPending } = useAction( - rejectBountySubmissionAction, - { - onSuccess: () => { - toast.success("Bounty submission rejected successfully!"); - setShowModal(false); - onReject ? onReject() : null; - mutatePrefix(`/api/bounties/${bounty?.id}/submissions`); - }, - onError({ error }) { - toast.error(error.serverError); - }, - }, - ); - const handleReject = useCallback(async () => { - if (!workspace.id || !submission?.id) { + if (!submission?.id || !submission.bountyId) { return; } const formData = getValues(); - await rejectBountySubmission({ - ...formData, - rejectionReason: formData.rejectionReason, - workspaceId: workspace.id, - submissionId: submission.id, - }); - }, [workspace.id, submission?.id, getValues, rejectBountySubmission]); + await rejectBountySubmission( + `/api/bounties/${submission.bountyId}/submissions/${submission.id}/reject`, + { + method: "POST", + body: { + rejectionReason: formData.rejectionReason || undefined, + rejectionNote: formData.rejectionNote, + }, + onSuccess: async () => { + toast.success("Bounty submission rejected successfully!"); + setShowModal(false); + onReject?.(); + await mutatePrefix( + `/api/bounties/${submission.bountyId}/submissions`, + ); + }, + }, + ); + }, [ + submission?.id, + submission.bountyId, + getValues, + rejectBountySubmission, + setShowModal, + onReject, + ]); // Handle keyboard shortcut for Reject button useKeyboardShortcut("r", handleReject, { @@ -105,7 +105,7 @@ const RejectBountySubmissionModal = ({
@@ -456,6 +460,10 @@ function DiscountSheetContent({ <> {effectiveProvider === DiscountProvider.shopify ? ( + ) : effectiveProvider === DiscountProvider.custom ? ( +
+ +
) : ( )} diff --git a/apps/web/ui/partners/fraud-risks/partner-application-risk-summary-modal.tsx b/apps/web/ui/partners/fraud-risks/partner-application-risk-summary-modal.tsx index bff173dbead..83f90ae5053 100644 --- a/apps/web/ui/partners/fraud-risks/partner-application-risk-summary-modal.tsx +++ b/apps/web/ui/partners/fraud-risks/partner-application-risk-summary-modal.tsx @@ -13,6 +13,7 @@ import { useState, } from "react"; import { PartnerApplicationFraudSeverityIndicator } from "./partner-application-fraud-severity-indicator"; +import { RiskDisclaimerBanner } from "./risk-disclaimer-banner"; interface PartnerApplicationRiskSummaryModalProps { showModal: boolean; @@ -47,6 +48,10 @@ function PartnerApplicationRiskSummaryModal({
+ {severity === "high" && ( + + )} +
    diff --git a/apps/web/ui/partners/fraud-risks/partner-application-risk-summary.tsx b/apps/web/ui/partners/fraud-risks/partner-application-risk-summary.tsx index f04d9720171..e850a7230a3 100644 --- a/apps/web/ui/partners/fraud-risks/partner-application-risk-summary.tsx +++ b/apps/web/ui/partners/fraud-risks/partner-application-risk-summary.tsx @@ -11,8 +11,6 @@ import Link from "next/link"; import { useAdvancedUpsellModal } from "../advanced-upsell-modal"; import { PartnerApplicationFraudSeverityIndicator } from "./partner-application-fraud-severity-indicator"; import { usePartnerApplicationRiskSummaryModal } from "./partner-application-risk-summary-modal"; -import { PartnerCrossProgramSummary } from "./partner-cross-program-summary"; -import { RiskDisclaimerBanner } from "./risk-disclaimer-banner"; interface PartnerApplicationRiskSummaryProps { partner: { @@ -88,17 +86,6 @@ export function PartnerApplicationRiskSummary({
-
-

- Program owner activity -

- - - {severity === "high" && ( - - )} -
- ); diff --git a/apps/web/ui/partners/fraud-risks/partner-cross-program-summary.tsx b/apps/web/ui/partners/fraud-risks/partner-network-activity-summary.tsx similarity index 66% rename from apps/web/ui/partners/fraud-risks/partner-cross-program-summary.tsx rename to apps/web/ui/partners/fraud-risks/partner-network-activity-summary.tsx index 8fe10c09127..87692ef237b 100644 --- a/apps/web/ui/partners/fraud-risks/partner-cross-program-summary.tsx +++ b/apps/web/ui/partners/fraud-risks/partner-network-activity-summary.tsx @@ -1,25 +1,41 @@ "use client"; -import { usePartnerCrossProgramSummary } from "@/lib/swr/use-partner-cross-program-summary"; +import useWorkspace from "@/lib/swr/use-workspace"; +import { partnerNetworkActivitySummarySchema } from "@/lib/zod/schemas/partners"; import { ActivityRing, User, UserCheck, UserXmark } from "@dub/ui"; +import { fetcher } from "@dub/utils"; +import useSWR from "swr"; +import * as z from "zod/v4"; -export function PartnerCrossProgramSummary({ +type NetworkActivitySummary = z.infer< + typeof partnerNetworkActivitySummarySchema +>; + +export function PartnerNetworkActivitySummary({ partnerId, }: { partnerId: string; }) { - const { crossProgramSummary, isLoading } = usePartnerCrossProgramSummary({ - partnerId, - }); + const { id: workspaceId } = useWorkspace(); + + const { data, isLoading } = useSWR( + workspaceId + ? `/api/partners/${partnerId}/network-activity?workspaceId=${workspaceId}` + : null, + fetcher, + { + revalidateOnMount: true, + }, + ); - if (isLoading || !crossProgramSummary) { + if (!data || isLoading) { return ; } - const { totalPrograms, activePrograms, bannedPrograms } = crossProgramSummary; + const { totalPrograms, activePrograms, bannedPrograms } = data; return ( -
+
{label}
- {value} - of {total} + + {value} + + + of {total} +
); @@ -65,7 +85,7 @@ function StatRow({ function LoadingSkeleton() { return ( -
+
diff --git a/apps/web/ui/partners/fraud-risks/risk-disclaimer-banner.tsx b/apps/web/ui/partners/fraud-risks/risk-disclaimer-banner.tsx index 3aabd7b9dec..ba92fa5c4de 100644 --- a/apps/web/ui/partners/fraud-risks/risk-disclaimer-banner.tsx +++ b/apps/web/ui/partners/fraud-risks/risk-disclaimer-banner.tsx @@ -1,28 +1,18 @@ -import { TriangleWarning } from "@dub/ui/icons"; -import { cn } from "@dub/utils"; +import { Callout } from "../../shared/callout"; export function RiskDisclaimerBanner({ className }: { className?: string }) { return ( -
- -

- We recommend reviewing the risk events thoroughly before taking action. - Unresolved events expire after 30 days, except confirmed network-level - bans.{" "} - - Learn more - - . -

-
+ + We recommend reviewing the risk events thoroughly before taking action. + Unresolved events expire after 30 days, except confirmed network-level + bans.{" "} + + Learn more + + ); } diff --git a/apps/web/ui/partners/fraud-risks/risk-review-sheet.tsx b/apps/web/ui/partners/fraud-risks/risk-review-sheet.tsx index 0a615d351a8..2eeb815fc4f 100644 --- a/apps/web/ui/partners/fraud-risks/risk-review-sheet.tsx +++ b/apps/web/ui/partners/fraud-risks/risk-review-sheet.tsx @@ -29,7 +29,7 @@ import useSWR from "swr"; import { AssociatedCommissionsTable } from "./associated-commissions-table"; import { FraudEventsTableWrapper } from "./fraud-events-tables"; import { useMarkAllAsFraudModal } from "./mark-all-as-fraud-modal"; -import { PartnerCrossProgramSummary } from "./partner-cross-program-summary"; +import { PartnerNetworkActivitySummary } from "./partner-network-activity-summary"; import { RequestDetailsBanner } from "./request-details-banner"; import { useResolveFraudGroupModal } from "./resolve-fraud-group-modal"; import { ResolvedRiskEventsTable } from "./resolved-risk-events-table"; @@ -226,12 +226,12 @@ function RiskReviewSheetContent({
-
+

- Program owner activity + Network activity

- +
diff --git a/apps/web/ui/partners/merge-accounts/merge-account-form.tsx b/apps/web/ui/partners/merge-accounts/merge-account-form.tsx index 4c6ccce7892..103c18dfe5a 100644 --- a/apps/web/ui/partners/merge-accounts/merge-account-form.tsx +++ b/apps/web/ui/partners/merge-accounts/merge-account-form.tsx @@ -1,8 +1,9 @@ import { mergePartnerAccountsAction } from "@/lib/actions/partners/merge-partner-accounts"; import useUser from "@/lib/swr/use-user"; import { PartnerAvatar } from "@/ui/partners/partner-avatar"; +import { Callout } from "@/ui/shared/callout"; import { Button } from "@dub/ui"; -import { AlertTriangle, ArrowDown } from "lucide-react"; +import { ArrowDown } from "lucide-react"; import { signOut } from "next-auth/react"; import { useAction } from "next-safe-action/hooks"; import { toast } from "sonner"; @@ -100,22 +101,22 @@ export function MergeAccountForm({
-
- -

- This action can't be undone. -

-

- All data — including links, commissions, and payouts from{" "} - {sourceAccount.email} will be transferred to {targetAccount.email}. - Duplicate bounty submissions from {sourceAccount.email} will also be - deleted. -
-
- After the merge, {sourceAccount.email} will be permanently deleted. - If you're unsure, please contact our support team before proceeding. -

-
+ +
+

This action can't be undone.

+

+ All data — including links, commissions, and payouts from{" "} + {sourceAccount.email} will be transferred to{" "} + {targetAccount.email}. Duplicate bounty submissions from{" "} + {sourceAccount.email} will also be deleted. +
+
+ After the merge, {sourceAccount.email} will be permanently + deleted. If you're unsure, please contact our support team before + proceeding. +

+
+
diff --git a/apps/web/ui/partners/partner-advanced-settings-modal.tsx b/apps/web/ui/partners/partner-advanced-settings-modal.tsx index 381c419f04d..3b4c342ab8e 100644 --- a/apps/web/ui/partners/partner-advanced-settings-modal.tsx +++ b/apps/web/ui/partners/partner-advanced-settings-modal.tsx @@ -282,11 +282,7 @@ export function usePartnerAdvancedSettingsModal({ partner={partner} /> ); - }, [ - showPartnerAdvancedSettingsModal, - setShowPartnerAdvancedSettingsModal, - partner, - ]); + }, [showPartnerAdvancedSettingsModal, setShowPartnerAdvancedSettingsModal]); return useMemo( () => ({ diff --git a/apps/web/ui/partners/partner-info-cards.tsx b/apps/web/ui/partners/partner-info-cards.tsx index cb1c5ae8c65..a7ac9cab843 100644 --- a/apps/web/ui/partners/partner-info-cards.tsx +++ b/apps/web/ui/partners/partner-info-cards.tsx @@ -40,6 +40,7 @@ import Link from "next/link"; import { Fragment, ReactNode, createElement } from "react"; import useSWR from "swr"; import { PartnerApplicationRiskSummary } from "./fraud-risks/partner-application-risk-summary"; +import { PartnerNetworkActivitySummary } from "./fraud-risks/partner-network-activity-summary"; import { PartnerApplicationRiskBanner, PartnerRiskBanner, @@ -100,7 +101,8 @@ export function PartnerInfoCards({ }: PartnerInfoCardsProps) { const { id: workspaceId, slug: workspaceSlug, plan } = useWorkspace(); - const { canCreateReferralReward } = getPlanCapabilities(plan); + const { canCreateReferralReward, canManageFraudEvents } = + getPlanCapabilities(plan); const isEnrolled = type === "enrolled" || type === undefined; const isNetwork = type === "network"; @@ -373,6 +375,17 @@ export function PartnerInfoCards({ {partner && isEnrolled && showApplicationRiskAnalysis && ( )} + {partner && + isEnrolled && + showApplicationRiskAnalysis && + canManageFraudEvents && ( +
+

+ Network activity +

+ +
+ )}
diff --git a/apps/web/ui/partners/payouts/bank-account-requirements-modal.tsx b/apps/web/ui/partners/payouts/bank-account-requirements-modal.tsx index b002e352bf1..8ac30fff515 100644 --- a/apps/web/ui/partners/payouts/bank-account-requirements-modal.tsx +++ b/apps/web/ui/partners/payouts/bank-account-requirements-modal.tsx @@ -2,9 +2,9 @@ import usePartnerProfile from "@/lib/swr/use-partner-profile"; import { Button, Modal } from "@dub/ui"; -import { TriangleWarning } from "@dub/ui/icons"; import { COUNTRIES, COUNTRY_CURRENCY_CODES } from "@dub/utils"; import { Dispatch, SetStateAction, useMemo, useState } from "react"; +import { Callout } from "../../shared/callout"; import { Markdown } from "../../shared/markdown"; function BankAccountRequirementsModal({ @@ -40,13 +40,10 @@ function BankAccountRequirementsModal({
-
- -

- If your bank account does not meet these requirements, payouts may - be delayed or rejected. -

-
+ + If your bank account does not meet these requirements, payouts may be + delayed or rejected. +

Requirements:

diff --git a/apps/web/ui/partners/payouts/stablecoin-payout-modal.tsx b/apps/web/ui/partners/payouts/stablecoin-payout-modal.tsx index 1b2f1ba58db..d51c6199be6 100644 --- a/apps/web/ui/partners/payouts/stablecoin-payout-modal.tsx +++ b/apps/web/ui/partners/payouts/stablecoin-payout-modal.tsx @@ -1,8 +1,8 @@ "use client"; +import { Callout } from "@/ui/shared/callout"; import { MarkdownDescription } from "@/ui/shared/markdown-description"; import { Badge, Button, CircleDollar3, Modal, ShimmerDots } from "@dub/ui"; -import { TriangleWarning } from "@dub/ui/icons"; import { Dispatch, SetStateAction, useMemo, useState } from "react"; function StablecoinPayoutModal({ @@ -94,18 +94,15 @@ function StablecoinPayoutModal({ waiting up to 15 business days with your bank account. -
- -

- Make sure to triple-check that you’ve entered the{" "} - - correct stablecoin wallet address and network - {" "} - when connecting your wallet. Since stablecoin payouts are - irreversible, incorrect details may result in payout failures and - lost funds. -

-
+ + Make sure to triple-check that you’ve entered the{" "} + + correct stablecoin wallet address and network + {" "} + when connecting your wallet. Since stablecoin payouts are + irreversible, incorrect details may result in payout failures and + lost funds. +