From a780a776456f1ae7d86bc8e48d5fd72ea336551a Mon Sep 17 00:00:00 2001 From: Rafi Date: Tue, 1 Sep 2026 12:06:31 -0400 Subject: [PATCH 01/14] Show raw indexed text in the search debug script The parsed sections are heuristic and collapsed to nothing on documents without platforms, which hid the old-format link URLs during the v4 migration check. The raw line is the ground truth; the parser now also extends the profile to the end when no platform boundary exists. --- apps/web/scripts/dev/debug-partner-search.ts | 33 +++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/apps/web/scripts/dev/debug-partner-search.ts b/apps/web/scripts/dev/debug-partner-search.ts index 516f7461011..e6730726739 100644 --- a/apps/web/scripts/dev/debug-partner-search.ts +++ b/apps/web/scripts/dev/debug-partner-search.ts @@ -148,32 +148,32 @@ const PLATFORM_TYPES = [ /** * `searchText` is every searchable value lowercased and space-joined in a fixed * order: partner ID, name, email, company, description, platform types, handles, - * then links. 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-links. Company and description - * cannot be told apart once joined, so they stay together as `profile`. + * 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 > emailIndex && PLATFORM_TYPES.includes(token), + (token, index) => index >= profileStart && PLATFORM_TYPES.includes(token), ); return { tokens, - name: tokens - .slice(hasPartnerId ? 1 : 0, emailIndex === -1 ? 4 : emailIndex) - .join(" "), + name: tokens.slice(hasPartnerId ? 1 : 0, nameEnd).join(" "), email: emailIndex === -1 ? "" : tokens[emailIndex], profile: tokens - .slice( - emailIndex + 1, - platformIndex === -1 ? emailIndex + 1 : platformIndex, - ) + .slice(profileStart, platformIndex === -1 ? undefined : platformIndex) .join(" "), - platformsAndLinks: + platformsAndKeys: platformIndex === -1 ? "" : tokens.slice(platformIndex).join(" "), }; } @@ -228,10 +228,13 @@ function reportProviderHits( 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(` profile ${parsed.profile.slice(0, 150) || "—"}`); console.log( - ` platforms ${parsed.platformsAndLinks.slice(0, 150) || "—"}`, + ` 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) ?? ""}`); }); } From 7f58786c6d0572f9dd6f283d7392665bceb1d939 Mon Sep 17 00:00:00 2001 From: Rafi Date: Tue, 1 Sep 2026 18:14:10 -0400 Subject: [PATCH 02/14] Strip the program domain from pasted short links before searching A pasted link like go.acme.com/partner sent every partner in the program the domain tokens, which also appear in company names and websites. The list and the count now reduce a link on the program's own domain to its key before it reaches the provider. Links on other domains are unchanged. --- .../lib/api/partners/get-partners-count.ts | 7 +- apps/web/lib/api/partners/get-partners.ts | 4 +- .../api/partners/search/build-search-query.ts | 44 +++++++++++++ apps/web/lib/api/partners/search/index.ts | 1 + .../search/resolve-candidate-query.ts | 32 +++++++++ .../partners/partner-search-query.test.ts | 66 +++++++++++++++++++ .../partner-search-resolve-query.test.ts | 61 +++++++++++++++++ 7 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 apps/web/lib/api/partners/search/resolve-candidate-query.ts create mode 100644 apps/web/tests/partners/partner-search-resolve-query.test.ts diff --git a/apps/web/lib/api/partners/get-partners-count.ts b/apps/web/lib/api/partners/get-partners-count.ts index 171073316c5..c0d832ccfea 100644 --- a/apps/web/lib/api/partners/get-partners-count.ts +++ b/apps/web/lib/api/partners/get-partners-count.ts @@ -10,10 +10,10 @@ import { mergePartnerCountryAndSearchWhere, } from "./program-enrollment-query"; import { - buildPartnerSearchCandidateQuery, findPartnerSearchCandidates, getPartnerSearchReadProvider, PartnerSearchProvider, + resolvePartnerSearchCandidateQuery, } from "./search"; type PartnersCountFilters = z.infer & { @@ -35,7 +35,10 @@ export async function getPartnersCount( ): Promise { const { groupBy, programId, ...enrollmentFilters } = filters; const candidateQuery = searchProvider - ? buildPartnerSearchCandidateQuery({ ...enrollmentFilters, programId }) + ? await resolvePartnerSearchCandidateQuery({ + ...enrollmentFilters, + programId, + }) : null; const candidateResult = searchProvider && candidateQuery diff --git a/apps/web/lib/api/partners/get-partners.ts b/apps/web/lib/api/partners/get-partners.ts index 6ddfb78e4a0..9650a8ad025 100644 --- a/apps/web/lib/api/partners/get-partners.ts +++ b/apps/web/lib/api/partners/get-partners.ts @@ -3,12 +3,12 @@ import { toCentsNumber } from "@dub/utils"; import { Prisma } from "@prisma/client"; import { buildProgramEnrollmentWhereForList } from "./program-enrollment-query"; import { - buildPartnerSearchCandidateQuery, findPartnerSearchCandidates, getPartnerSearchReadProvider, orderByPartnerSearchHits, PartnerSearchProvider, PartnerSearchQueryInput, + resolvePartnerSearchCandidateQuery, } from "./search"; type PartnerFilters = PartnerSearchQueryInput & { @@ -39,7 +39,7 @@ export async function getPartners( } = filters; const candidateQuery = searchProvider - ? buildPartnerSearchCandidateQuery(filters) + ? await resolvePartnerSearchCandidateQuery(filters) : null; const candidateResult = searchProvider && candidateQuery diff --git a/apps/web/lib/api/partners/search/build-search-query.ts b/apps/web/lib/api/partners/search/build-search-query.ts index af7894ad3aa..1a9453d4e4f 100644 --- a/apps/web/lib/api/partners/search/build-search-query.ts +++ b/apps/web/lib/api/partners/search/build-search-query.ts @@ -81,3 +81,47 @@ export function buildPartnerSearchCandidateQuery({ }, }; } + +/** + * A dotted host with an optional protocol, `www.`, and URL suffix. Plain words + * and email addresses do not match. This limits the program lookup to + * link-shaped search values. + */ +const LINK_SHAPED_QUERY = + /^(?:https?:\/\/)?(?:www\.)?([a-z0-9-]+(?:\.[a-z0-9-]+)+)([/?#]\S*)?$/i; + +export function isLinkShapedQuery(query: string): boolean { + return LINK_SHAPED_QUERY.test(query.trim()); +} + +/** + * Removes the program domain from a matching short link. The search index + * stores link keys, but it does not store link domains. Removing the domain + * prevents unrelated domain tokens from adding matches or changing result + * order. Other domains and bare domains are returned unchanged. + */ +export function stripProgramDomain( + query: string, + programDomain: string | null | undefined, +): string { + const match = query.trim().match(LINK_SHAPED_QUERY); + + if (!match || !programDomain) { + return query; + } + + const [, host, rest = ""] = match; + const normalizedDomain = programDomain.toLowerCase().replace(/^www\./, ""); + + if (host.toLowerCase() !== normalizedDomain) { + return query; + } + + // Query strings and fragments are never part of a key + const key = rest + .split(/[?#]/)[0] + .replace(/^\/+|\/+$/g, "") + .trim(); + + return key || query; +} diff --git a/apps/web/lib/api/partners/search/index.ts b/apps/web/lib/api/partners/search/index.ts index 0fd472fb0f5..fda2558bf49 100644 --- a/apps/web/lib/api/partners/search/index.ts +++ b/apps/web/lib/api/partners/search/index.ts @@ -4,6 +4,7 @@ export * from "./find-candidates"; export * from "./index-enrollments"; export * from "./order-search-results"; export * from "./provider"; +export * from "./resolve-candidate-query"; export * from "./searchable-values"; export * from "./serialize-document"; export * from "./sync"; diff --git a/apps/web/lib/api/partners/search/resolve-candidate-query.ts b/apps/web/lib/api/partners/search/resolve-candidate-query.ts new file mode 100644 index 00000000000..829b3c766ae --- /dev/null +++ b/apps/web/lib/api/partners/search/resolve-candidate-query.ts @@ -0,0 +1,32 @@ +import { prisma } from "@/lib/prisma"; +import { + buildPartnerSearchCandidateQuery, + isLinkShapedQuery, + stripProgramDomain, +} from "./build-search-query"; +import type { PartnerSearchCandidateQuery } from "./types"; + +/** + * Builds the candidate query and removes the program domain from a matching + * short link. Shared by the list and the count so both send the provider the + * same query. + */ +export async function resolvePartnerSearchCandidateQuery( + input: Parameters[0], +): Promise { + const candidateQuery = buildPartnerSearchCandidateQuery(input); + + if (!candidateQuery || !isLinkShapedQuery(candidateQuery.query)) { + return candidateQuery; + } + + const program = await prisma.program.findUnique({ + where: { id: candidateQuery.programId }, + select: { domain: true }, + }); + + return { + ...candidateQuery, + query: stripProgramDomain(candidateQuery.query, program?.domain), + }; +} diff --git a/apps/web/tests/partners/partner-search-query.test.ts b/apps/web/tests/partners/partner-search-query.test.ts index da45d2b81fa..497b4cf8c50 100644 --- a/apps/web/tests/partners/partner-search-query.test.ts +++ b/apps/web/tests/partners/partner-search-query.test.ts @@ -1,6 +1,8 @@ import { buildPartnerSearchCandidateQuery, + isLinkShapedQuery, PARTNER_SEARCH_CANDIDATE_LIMIT, + stripProgramDomain, } from "@/lib/api/partners/search"; import { describe, expect, it } from "vitest"; @@ -84,3 +86,67 @@ describe("buildPartnerSearchCandidateQuery", () => { }); }); }); + +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-resolve-query.test.ts b/apps/web/tests/partners/partner-search-resolve-query.test.ts new file mode 100644 index 00000000000..c1ebc3bb380 --- /dev/null +++ b/apps/web/tests/partners/partner-search-resolve-query.test.ts @@ -0,0 +1,61 @@ +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("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(); + }); +}); From dc435cf284e1dd9f797d8846e038eb6b85424b63 Mon Sep 17 00:00:00 2001 From: Rafi Date: Tue, 1 Sep 2026 19:08:44 -0400 Subject: [PATCH 03/14] Cover the list, count and missing-program paths for pasted links --- .../get-partners-count-search.test.ts | 24 ++++++++++++++++++ .../partners/get-partners-search.test.ts | 25 +++++++++++++++++++ .../partner-search-resolve-query.test.ts | 10 ++++++++ 3 files changed, 59 insertions(+) diff --git a/apps/web/tests/partners/get-partners-count-search.test.ts b/apps/web/tests/partners/get-partners-count-search.test.ts index 1fa49e6e4ba..1d0e0e2b2c1 100644 --- a/apps/web/tests/partners/get-partners-count-search.test.ts +++ b/apps/web/tests/partners/get-partners-count-search.test.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ partnerGroupBy: vi.fn(), partnerTagGroupBy: vi.fn(), applicationEventGroupBy: vi.fn(), + programFindUnique: vi.fn(), })); vi.mock("@/lib/prisma", () => ({ @@ -23,6 +24,7 @@ vi.mock("@/lib/prisma", () => ({ partner: { groupBy: mocks.partnerGroupBy, findUnique: vi.fn() }, programPartnerTag: { groupBy: mocks.partnerTagGroupBy }, programApplicationEvent: { groupBy: mocks.applicationEventGroupBy }, + program: { findUnique: mocks.programFindUnique }, }, })); @@ -44,6 +46,28 @@ describe("getPartnersCount search", () => { } }); + 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); diff --git a/apps/web/tests/partners/get-partners-search.test.ts b/apps/web/tests/partners/get-partners-search.test.ts index 25ddb843f86..16f3060aa23 100644 --- a/apps/web/tests/partners/get-partners-search.test.ts +++ b/apps/web/tests/partners/get-partners-search.test.ts @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ findMany: vi.fn(), + programFindUnique: vi.fn(), })); vi.mock("@/lib/prisma", () => ({ @@ -15,6 +16,7 @@ vi.mock("@/lib/prisma", () => ({ programEnrollment: { findMany: mocks.findMany, }, + program: { findUnique: mocks.programFindUnique }, }, })); @@ -49,6 +51,29 @@ function createSearchProvider( 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 () => { diff --git a/apps/web/tests/partners/partner-search-resolve-query.test.ts b/apps/web/tests/partners/partner-search-resolve-query.test.ts index c1ebc3bb380..4602dc4efcb 100644 --- a/apps/web/tests/partners/partner-search-resolve-query.test.ts +++ b/apps/web/tests/partners/partner-search-resolve-query.test.ts @@ -45,6 +45,16 @@ describe("resolvePartnerSearchCandidateQuery", () => { 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")); From a655c92d425c84c6be1a421a9a5ce2790c6072fb Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 2 Sep 2026 22:24:47 +0530 Subject: [PATCH 04/14] Show an error when magic-link login fails instead of a success toast. --- apps/web/ui/auth/login/email-sign-in.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/ui/auth/login/email-sign-in.tsx b/apps/web/ui/auth/login/email-sign-in.tsx index 2a622ea4db1..8611da49d00 100644 --- a/apps/web/ui/auth/login/email-sign-in.tsx +++ b/apps/web/ui/auth/login/email-sign-in.tsx @@ -100,7 +100,7 @@ export const EmailSignIn = ({ next }: { next?: string }) => { return; } - if (!response.ok && response.error) { + if (response.error) { if (errorCodes[response.error]) { toast.error(errorCodes[response.error]); } else { From a1363dad2d356b582e66b1317bddb7a089233726 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Wed, 2 Sep 2026 15:02:54 -0300 Subject: [PATCH 05/14] revalidate public partner pages with after() --- .../app/(ee)/api/bounties/[bountyId]/route.ts | 8 +-- apps/web/app/(ee)/api/bounties/route.ts | 6 +-- .../groups/[groupIdOrSlug]/default/route.ts | 3 +- .../lib/actions/partners/update-discount.ts | 12 +++-- .../actions/partners/update-group-branding.ts | 13 ++--- .../lib/actions/partners/update-program.ts | 6 ++- .../web/lib/actions/partners/update-reward.ts | 4 +- .../revalidate-program-public-pages.ts | 53 ++++++++++--------- 8 files changed, 53 insertions(+), 52 deletions(-) diff --git a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts index 344c6963cfe..d8a5ea2111a 100644 --- a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts +++ b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts @@ -361,6 +361,8 @@ export const PATCH = withWorkspace( archivedAt: data.archivedAt, }); + revalidateProgramPublicPages(programId); + waitUntil( Promise.allSettled([ recordAuditLog({ @@ -394,8 +396,6 @@ export const PATCH = withWorkspace( notBefore: Math.floor(data.startsAt.getTime() / 1000), }), }), - - revalidateProgramPublicPages(programId), ]), ); @@ -453,6 +453,8 @@ export const DELETE = withWorkspace( const deletedBounty = BountySchema.parse(transformBounty(bounty)); + revalidateProgramPublicPages(programId); + waitUntil( Promise.allSettled([ recordAuditLog({ @@ -469,8 +471,6 @@ export const DELETE = withWorkspace( }, ], }), - - revalidateProgramPublicPages(programId), ]), ); diff --git a/apps/web/app/(ee)/api/bounties/route.ts b/apps/web/app/(ee)/api/bounties/route.ts index 08314e08ea5..a3f4206d220 100644 --- a/apps/web/app/(ee)/api/bounties/route.ts +++ b/apps/web/app/(ee)/api/bounties/route.ts @@ -4,8 +4,8 @@ import { DubApiError } from "@/lib/api/errors"; import { throwIfInvalidGroupIds } from "@/lib/api/groups/throw-if-invalid-group-ids"; import { throwIfInvalidPartnerTagIds } from "@/lib/api/partner-tags/throw-if-invalid-partner-tag-ids"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; -import { revalidateProgramPublicPages } from "@/lib/api/programs/revalidate-program-public-pages"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; +import { revalidateProgramPublicPages } from "@/lib/api/programs/revalidate-program-public-pages"; import { parseRequestBody } from "@/lib/api/utils"; import { WorkflowAction } from "@/lib/api/workflows/types"; import { validateWorkflowConditions } from "@/lib/api/workflows/validate-workflow-conditions"; @@ -329,6 +329,8 @@ export const POST = withWorkspace( canSendEmailCampaigns && bounty.startMode !== BountyStartMode.relative; + revalidateProgramPublicPages(programId); + waitUntil( Promise.allSettled([ recordAuditLog({ @@ -373,8 +375,6 @@ export const POST = withWorkspace( notBefore: Math.floor(bounty.startsAt.getTime() / 1000), }), }), - - revalidateProgramPublicPages(programId), ]), ); diff --git a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/default/route.ts b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/default/route.ts index 76503ff3569..b0cf1d956fa 100644 --- a/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/default/route.ts +++ b/apps/web/app/(ee)/api/groups/[groupIdOrSlug]/default/route.ts @@ -7,7 +7,6 @@ import { DEFAULT_PARTNER_GROUP, GroupSchema } from "@/lib/zod/schemas/groups"; import { RESOURCE_COLORS } from "@/ui/colors"; import { nanoid, randomValue } from "@dub/utils"; import slugify from "@sindresorhus/slugify"; -import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // POST /api/groups/[groupIdOrSlug]/default – set a group as default @@ -85,7 +84,7 @@ export const POST = withWorkspace( }); }); - waitUntil(revalidateProgramPublicPages(programId)); + revalidateProgramPublicPages(programId); return NextResponse.json(GroupSchema.parse(updatedGroup)); }, diff --git a/apps/web/lib/actions/partners/update-discount.ts b/apps/web/lib/actions/partners/update-discount.ts index fda8409245e..b83a31d99d5 100644 --- a/apps/web/lib/actions/partners/update-discount.ts +++ b/apps/web/lib/actions/partners/update-discount.ts @@ -47,11 +47,15 @@ export const updateDiscountAction = authActionClient }, }); + const shouldExpireCache = + discount.couponTestId !== updatedDiscount.couponTestId; + + if (shouldExpireCache) { + revalidateProgramPublicPages(programId); + } + waitUntil( (async () => { - const shouldExpireCache = - discount.couponTestId !== updatedDiscount.couponTestId; - await Promise.allSettled([ ...(shouldExpireCache ? [ @@ -61,8 +65,6 @@ export const updateDiscountAction = authActionClient groupId: partnerGroup?.id, }, }), - - revalidateProgramPublicPages(programId), ] : []), diff --git a/apps/web/lib/actions/partners/update-group-branding.ts b/apps/web/lib/actions/partners/update-group-branding.ts index 49c2f117399..0d509d3ba2f 100644 --- a/apps/web/lib/actions/partners/update-group-branding.ts +++ b/apps/web/lib/actions/partners/update-group-branding.ts @@ -112,18 +112,13 @@ export const updateGroupBrandingAction = authActionClient }, }); + if (landerDataInput || applicationFormDataInput || unpublish) { + revalidateProgramPublicPages(programId); + } + waitUntil( (async () => { const res = await Promise.allSettled([ - /* - Revalidate public pages if the following fields were updated: - - lander data - - application form data - */ - ...(landerDataInput || applicationFormDataInput || unpublish - ? [revalidateProgramPublicPages(programId)] - : []), - recordAuditLog({ workspaceId: workspace.id, programId: program.id, diff --git a/apps/web/lib/actions/partners/update-program.ts b/apps/web/lib/actions/partners/update-program.ts index e33711e82df..09f9c7d5fff 100644 --- a/apps/web/lib/actions/partners/update-program.ts +++ b/apps/web/lib/actions/partners/update-program.ts @@ -75,10 +75,12 @@ export const updateProgramAction = authActionClient }, }); + if (updatedProgram.termsUrl !== program.termsUrl) { + revalidateProgramPublicPages(programId); + } + waitUntil( Promise.allSettled([ - updatedProgram.termsUrl !== program.termsUrl && - revalidateProgramPublicPages(programId), recordAuditLog({ workspaceId: workspace.id, programId: program.id, diff --git a/apps/web/lib/actions/partners/update-reward.ts b/apps/web/lib/actions/partners/update-reward.ts index d95f019d7fd..3fb38389bb0 100644 --- a/apps/web/lib/actions/partners/update-reward.ts +++ b/apps/web/lib/actions/partners/update-reward.ts @@ -141,6 +141,8 @@ export const updateRewardAction = authActionClient }, }); + revalidateProgramPublicPages(programId); + waitUntil( Promise.allSettled([ recordAuditLog({ @@ -169,8 +171,6 @@ export const updateRewardAction = authActionClient new: updatedReward, description: activityDescription, }), - - revalidateProgramPublicPages(programId), ]), ); }); diff --git a/apps/web/lib/api/programs/revalidate-program-public-pages.ts b/apps/web/lib/api/programs/revalidate-program-public-pages.ts index e3f16e606e0..8fa2d7391be 100644 --- a/apps/web/lib/api/programs/revalidate-program-public-pages.ts +++ b/apps/web/lib/api/programs/revalidate-program-public-pages.ts @@ -1,32 +1,35 @@ import { prisma } from "@/lib/prisma"; import { DEFAULT_PARTNER_GROUP } from "@/lib/zod/schemas/groups"; import { revalidatePath } from "next/cache"; +import { after } from "next/server"; -export async function revalidateProgramPublicPages(programId: string) { - const program = await prisma.program.findUniqueOrThrow({ - where: { id: programId }, - select: { - slug: true, - addedToMarketplaceAt: true, - groups: { select: { slug: true } }, - }, - }); +export function revalidateProgramPublicPages(programId: string) { + after(async () => { + const program = await prisma.program.findUniqueOrThrow({ + where: { id: programId }, + select: { + slug: true, + addedToMarketplaceAt: true, + groups: { select: { slug: true } }, + }, + }); - const paths = [ - `/partners.dub.co/${program.slug}`, - `/partners.dub.co/${program.slug}/apply`, - `/partners.dub.co/${program.slug}/apply/success`, - ...program.groups - .filter((group) => group.slug !== DEFAULT_PARTNER_GROUP.slug) - .flatMap((group) => [ - `/partners.dub.co/${program.slug}/${group.slug}`, - `/partners.dub.co/${program.slug}/${group.slug}/apply`, - `/partners.dub.co/${program.slug}/${group.slug}/apply/success`, - ]), - ...(program.addedToMarketplaceAt - ? [`/partners.dub.co/marketplace/${program.slug}`] - : []), - ]; + const paths = [ + `/partners.dub.co/${program.slug}`, + `/partners.dub.co/${program.slug}/apply`, + `/partners.dub.co/${program.slug}/apply/success`, + ...program.groups + .filter((group) => group.slug !== DEFAULT_PARTNER_GROUP.slug) + .flatMap((group) => [ + `/partners.dub.co/${program.slug}/${group.slug}`, + `/partners.dub.co/${program.slug}/${group.slug}/apply`, + `/partners.dub.co/${program.slug}/${group.slug}/apply/success`, + ]), + ...(program.addedToMarketplaceAt + ? [`/partners.dub.co/marketplace/${program.slug}`] + : []), + ]; - paths.forEach((path) => revalidatePath(path)); + paths.forEach((path) => revalidatePath(path)); + }); } From 28888ee85e69d6508ad3f81e1826ffe0e0713c27 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Wed, 2 Sep 2026 11:24:51 -0700 Subject: [PATCH 06/14] Update customer-subscription-deleted.ts --- .../(ee)/api/stripe/webhook/customer-subscription-deleted.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts b/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts index 659fc7c12ed..a0ee946a324 100644 --- a/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts +++ b/apps/web/app/(ee)/api/stripe/webhook/customer-subscription-deleted.ts @@ -172,7 +172,7 @@ export async function customerSubscriptionDeleted( "`* deleted their *`" + capitalize(workspace.plan) + "`* subscription", - type: "cron", + type: "alerts", mention: true, }), From 13cd490be9d58a020b73807611c1ef679158ff93 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Wed, 2 Sep 2026 16:20:53 -0300 Subject: [PATCH 07/14] persist country on customer PATCH --- apps/web/app/(ee)/api/customers/[id]/route.ts | 3 ++- apps/web/playwright/api/customers/customers.spec.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/app/(ee)/api/customers/[id]/route.ts b/apps/web/app/(ee)/api/customers/[id]/route.ts index 0d50c27a706..edc16ce163b 100644 --- a/apps/web/app/(ee)/api/customers/[id]/route.ts +++ b/apps/web/app/(ee)/api/customers/[id]/route.ts @@ -50,7 +50,7 @@ export const PATCH = withWorkspace( const { includeExpandedFields } = getCustomersQuerySchema.parse(searchParams); - const { name, email, avatar, externalId, stripeCustomerId } = + const { name, email, avatar, externalId, stripeCustomerId, country } = updateCustomerBodySchema.parse(await parseRequestBody(req)); const customer = await getCustomerOrThrow( @@ -84,6 +84,7 @@ export const PATCH = withWorkspace( avatar: finalCustomerAvatar, externalId, stripeCustomerId, + country, }, }); diff --git a/apps/web/playwright/api/customers/customers.spec.ts b/apps/web/playwright/api/customers/customers.spec.ts index 0fdbc971cd1..f0796eeb145 100644 --- a/apps/web/playwright/api/customers/customers.spec.ts +++ b/apps/web/playwright/api/customers/customers.spec.ts @@ -102,6 +102,7 @@ test("PATCH /customers/{id}", async ({ api }) => { const toUpdate = { name: "Updated", avatar: "https://api.dub.co/og/avatar/1234567890", + country: "US", }; const { status, data } = await api.patch( From 83993a7d2f2eed6573a4727a3fd508a5c7dabc9a Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Wed, 2 Sep 2026 16:51:40 -0300 Subject: [PATCH 08/14] test improvements --- apps/web/playwright/api/customers/customers.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/playwright/api/customers/customers.spec.ts b/apps/web/playwright/api/customers/customers.spec.ts index f0796eeb145..9dd31d0035a 100644 --- a/apps/web/playwright/api/customers/customers.spec.ts +++ b/apps/web/playwright/api/customers/customers.spec.ts @@ -102,7 +102,7 @@ test("PATCH /customers/{id}", async ({ api }) => { const toUpdate = { name: "Updated", avatar: "https://api.dub.co/og/avatar/1234567890", - country: "US", + country: "BR", }; const { status, data } = await api.patch( From c53cd237155628c9eb9bc31b7b32899d204f96e5 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Wed, 2 Sep 2026 18:11:30 -0300 Subject: [PATCH 09/14] allow creating sale commissions by discount code --- .../commissions/create-manual-commissions.ts | 46 ++++- apps/web/lib/zod/schemas/commissions.ts | 17 +- .../commissions/commissions-create.spec.ts | 159 ++++++++++++++++++ .../web/playwright/api/commissions/helpers.ts | 7 +- 4 files changed, 222 insertions(+), 7 deletions(-) diff --git a/apps/web/lib/api/commissions/create-manual-commissions.ts b/apps/web/lib/api/commissions/create-manual-commissions.ts index 6998a52aa90..53f08033b76 100644 --- a/apps/web/lib/api/commissions/create-manual-commissions.ts +++ b/apps/web/lib/api/commissions/create-manual-commissions.ts @@ -259,7 +259,9 @@ async function resolveLinkAndCustomer(args: ResolveLinkAndCustomerArgs) { let targetLink: Link; let targetCustomer: Customer | null = null; - const { workspace, partner, links, linkId, customerId, customer } = args; + const { workspace, partner, links, programId, linkId, customerId, customer } = + args; + const discountCode = type === "sale" ? args.discountCode : undefined; if (links.length === 0) { throw new DubApiError({ @@ -282,13 +284,49 @@ async function resolveLinkAndCustomer(args: ResolveLinkAndCustomerArgs) { }); } - if (linkId) { - const link = links.find((l) => l.id === linkId); + let resolvedLinkId = linkId ?? null; + + if (discountCode) { + const found = await prisma.discountCode.findUnique({ + where: { + programId_code: { + programId, + code: discountCode, + }, + }, + }); + + if (!found) { + throw new DubApiError({ + code: "not_found", + message: `Discount code ${discountCode} not found.`, + }); + } + + if (found.partnerId !== partner.id) { + throw new DubApiError({ + code: "not_found", + message: `Discount code ${discountCode} does not belong to partner ${partner.email} (${partner.id}).`, + }); + } + + if (found.disabledAt) { + throw new DubApiError({ + code: "bad_request", + message: `Discount code ${discountCode} is disabled.`, + }); + } + + resolvedLinkId = found.linkId; + } + + if (resolvedLinkId) { + const link = links.find((l) => l.id === resolvedLinkId); if (!link) { throw new DubApiError({ code: "not_found", - message: `Link ${linkId} does not belong to partner ${partner.email} (${partner.id}).`, + message: `Link ${resolvedLinkId} does not belong to partner ${partner.email} (${partner.id}).`, }); } diff --git a/apps/web/lib/zod/schemas/commissions.ts b/apps/web/lib/zod/schemas/commissions.ts index d4cf40b96bb..9842ae68bb5 100644 --- a/apps/web/lib/zod/schemas/commissions.ts +++ b/apps/web/lib/zod/schemas/commissions.ts @@ -625,7 +625,13 @@ const createSaleCommissionSchema = z .string() .nullish() .describe( - "The partner link ID to associate the commission with. If not provided, default to the link with the most revenue.", + "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() + .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() @@ -736,6 +742,15 @@ export const createManualCommissionBodySchema = z path: ["description"], }); } + return; + } + + if (data.type === "sale" && data.linkId && data.discountCode) { + ctx.addIssue({ + code: "custom", + message: "Either `linkId` or `discountCode` may be provided, not both.", + path: ["discountCode"], + }); } }); diff --git a/apps/web/playwright/api/commissions/commissions-create.spec.ts b/apps/web/playwright/api/commissions/commissions-create.spec.ts index da07659ceb3..75e0bb7f908 100644 --- a/apps/web/playwright/api/commissions/commissions-create.spec.ts +++ b/apps/web/playwright/api/commissions/commissions-create.spec.ts @@ -40,6 +40,40 @@ function customerBody() { }; } +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()}`; @@ -293,6 +327,38 @@ test.describe("Sale commissions", () => { }); }); + 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"); @@ -804,6 +870,22 @@ test.describe("Sale commissions", () => { "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.", + }), + }, ]; for (const { name, body, expected } of errorCases) { @@ -829,6 +911,83 @@ test.describe("Sale commissions", () => { ); }); }); + + 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.`, + }), + ); + }); + }); }); }); diff --git a/apps/web/playwright/api/commissions/helpers.ts b/apps/web/playwright/api/commissions/helpers.ts index 1252491b019..3c13aa1b8ed 100644 --- a/apps/web/playwright/api/commissions/helpers.ts +++ b/apps/web/playwright/api/commissions/helpers.ts @@ -65,6 +65,7 @@ export async function expectCommissionCreated({ expectedEarnings, expectedCreatedAt, expectedMetadata, + expectedLinkId, }: { api: ApiClient; partnerId: string; @@ -76,6 +77,7 @@ export async function expectCommissionCreated({ expectedEarnings?: number; expectedCreatedAt?: Date; expectedMetadata?: Record | null; + expectedLinkId?: string; }): Promise { const amount = expectedAmount ?? (type === "lead" ? 0 : type === "sale" ? 1000 : 0); @@ -86,8 +88,7 @@ export async function expectCommissionCreated({ : type === "sale" ? TEST_COMMISSION_REWARDS.sale.amountInCents : 0); - const metadata = - expectedMetadata === undefined ? null : expectedMetadata; + const metadata = expectedMetadata === undefined ? null : expectedMetadata; let commissionId: string | undefined; @@ -124,6 +125,7 @@ export async function expectCommissionCreated({ currency: commission.currency, createdAt: commission.createdAt.toISOString(), metadata: commission.metadata, + ...(expectedLinkId !== undefined ? { linkId: commission.linkId } : {}), }; }) .toEqual({ @@ -140,6 +142,7 @@ export async function expectCommissionCreated({ ? expectedCreatedAt.toISOString() : expect.any(String), metadata, + ...(expectedLinkId !== undefined ? { linkId: expectedLinkId } : {}), }); if (!commissionId) { From 8ad98a0fbf607c29d2987914f8395182a1d1d436 Mon Sep 17 00:00:00 2001 From: Pedro Ladeira Date: Wed, 2 Sep 2026 18:26:05 -0300 Subject: [PATCH 10/14] code improvements --- apps/web/lib/zod/schemas/commissions.ts | 1 + .../api/commissions/commissions-create.spec.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/apps/web/lib/zod/schemas/commissions.ts b/apps/web/lib/zod/schemas/commissions.ts index 9842ae68bb5..2e7fe55f463 100644 --- a/apps/web/lib/zod/schemas/commissions.ts +++ b/apps/web/lib/zod/schemas/commissions.ts @@ -629,6 +629,7 @@ const createSaleCommissionSchema = z ), 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`.", diff --git a/apps/web/playwright/api/commissions/commissions-create.spec.ts b/apps/web/playwright/api/commissions/commissions-create.spec.ts index 75e0bb7f908..bda20f2f83c 100644 --- a/apps/web/playwright/api/commissions/commissions-create.spec.ts +++ b/apps/web/playwright/api/commissions/commissions-create.spec.ts @@ -886,6 +886,21 @@ test.describe("Sale commissions", () => { "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) { From 1eba8c1b5cf20e2561df867b2fe330adad3141d3 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Wed, 2 Sep 2026 14:32:04 -0700 Subject: [PATCH 11/14] Improvements to tracking page --- .../settings/tracking/add-hostname-modal.tsx | 174 +++++++++--------- .../tracking/configure-tracking-section.tsx | 5 + .../(ee)/settings/tracking/hostname-field.tsx | 28 ++- .../(ee)/settings/tracking/verify-install.tsx | 37 ++-- .../web/lib/actions/verify-workspace-setup.ts | 29 ++- apps/web/lib/analytics/verify-installation.ts | 51 +++++ apps/web/lib/api/fraud/constants.ts | 2 +- .../analytics/verify-installation.test.ts | 79 ++++++++ 8 files changed, 283 insertions(+), 122 deletions(-) create mode 100644 apps/web/tests/analytics/verify-installation.test.ts diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/add-hostname-modal.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/add-hostname-modal.tsx index da4b4dfe100..6595eaec62b 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/add-hostname-modal.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/add-hostname-modal.tsx @@ -3,7 +3,7 @@ import { X } from "@/ui/shared/icons"; import { Button, Modal, useMediaQuery } from "@dub/ui"; import { cn, validDomainRegex } from "@dub/utils"; -import { useCallback, useMemo, useState } from "react"; +import { useState } from "react"; import { toast } from "sonner"; const isValidHostname = (hostname: string) => { @@ -14,87 +14,54 @@ const isValidHostname = (hostname: string) => { ); }; -const AddHostnameForm = ({ +const AddHostnameModal = ({ + showModal, + setShowModal, existingHostnames, onAdd, - onCancel, }: { + showModal: boolean; + setShowModal: (showModal: boolean) => void; existingHostnames: string[]; - onAdd: (hostname: string) => void; - onCancel?: () => void; + onAdd: (hostname: string) => void | Promise; }) => { const [hostname, setHostname] = useState(""); + const [isLoading, setIsLoading] = useState(false); const { isMobile } = useMediaQuery(); - return ( -
{ - e.preventDefault(); - e.stopPropagation(); + const close = () => { + if (isLoading) { + return; + } - if (existingHostnames.includes(hostname)) { - toast.error("Hostname already exists."); - return; - } + setShowModal(false); + }; - if (!isValidHostname(hostname)) { - toast.error("Enter a valid domain."); - return; - } + const handleAdd = async () => { + if (isLoading) { + return; + } - onAdd(hostname); - setHostname(""); - }} - > -
- 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", - )} - /> -
+ if (existingHostnames.includes(hostname)) { + toast.error("Hostname already exists."); + return; + } -
-
-
- ); -}; + if (!isValidHostname(hostname)) { + toast.error("Enter a valid domain."); + return; + } -interface AddHostnameModalProps { - showModal: boolean; - setShowModal: (showModal: boolean) => void; - existingHostnames: string[]; - onAdd: (hostname: string) => void; -} + setIsLoading(true); + try { + await onAdd(hostname); + setHostname(""); + setShowModal(false); + } finally { + setIsLoading(false); + } + }; -const AddHostnameModal = ({ - showModal, - setShowModal, - existingHostnames, - onAdd, -}: AddHostnameModalProps) => { - const close = () => setShowModal(false); return (
@@ -108,16 +75,48 @@ const AddHostnameModal = ({
-
- { - onAdd(hostname); - close(); - }} - /> -
+
{ + 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", + )} + /> +
+ +
+
+
); }; @@ -127,26 +126,19 @@ export function useAddHostnameModal({ onAdd, }: { existingHostnames: string[]; - onAdd: (hostname: string) => void; + onAdd: (hostname: string) => void | Promise; }) { const [showAddHostnameModal, setShowAddHostnameModal] = useState(false); - const AddHostnameModalCallback = useCallback(() => { - return ( + return { + setShowAddHostnameModal, + addHostnameModal: ( - ); - }, [showAddHostnameModal, existingHostnames, onAdd]); - - return useMemo( - () => ({ - setShowAddHostnameModal, - AddHostnameModal: AddHostnameModalCallback, - }), - [AddHostnameModalCallback], - ); + ), + }; } diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/configure-tracking-section.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/configure-tracking-section.tsx index 468be47485b..8028ea23bbf 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/configure-tracking-section.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/configure-tracking-section.tsx @@ -267,6 +267,11 @@ export function ConfigureTrackingSection() { + handleSubmit((data) => + onSubmit({ ...data, allowedHostnames }), + )() + } disabled={disabled} disabledTooltip={disabledTooltip} /> diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/hostname-field.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/hostname-field.tsx index 9492bff2881..f1792ccf223 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/hostname-field.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/tracking/hostname-field.tsx @@ -13,18 +13,22 @@ import { HostnameMenu } from "./hostname-menu"; export function HostnameField({ hostnames, onChange, + onSave, disabled, disabledTooltip, }: { hostnames: string[]; onChange: (hostnames: string[]) => void; + onSave?: (hostnames: string[]) => void | Promise; disabled?: boolean; disabledTooltip?: string; }) { - const { AddHostnameModal, setShowAddHostnameModal } = useAddHostnameModal({ + const { addHostnameModal, setShowAddHostnameModal } = useAddHostnameModal({ existingHostnames: hostnames, - onAdd: (hostname) => { - onChange([...hostnames, hostname]); + onAdd: async (hostname) => { + const nextHostnames = [...hostnames, hostname]; + await onSave?.(nextHostnames); + onChange(nextHostnames); }, }); @@ -55,9 +59,13 @@ export function HostnameField({ key={hostname} hostname={hostname} disabled={disabled} - onDelete={() => - onChange(hostnames.filter((item) => item !== hostname)) - } + onDelete={async () => { + const nextHostnames = hostnames.filter( + (item) => item !== hostname, + ); + await onSave?.(nextHostnames); + onChange(nextHostnames); + }} /> ))} @@ -73,7 +81,7 @@ export function HostnameField({ )} - + {addHostnameModal} ); } @@ -85,12 +93,12 @@ function HostnameRow({ }: { hostname: string; disabled?: boolean; - onDelete: () => void; + onDelete: () => void | Promise; }) { const { setShowConfirmModal, confirmModal } = useConfirmModal({ - title: "Delete Hostname", + title: "Delete hostname", description: `Are you sure you want to delete "${hostname}"? This action cannot be undone.`, - confirmText: "Delete Hostname", + confirmText: "Delete hostname", onConfirm: onDelete, }); 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 4652931a9cd..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,15 +1,12 @@ "use client"; import { verifyWorkspaceSetup } from "@/lib/actions/verify-workspace-setup"; -import { - toVerifySiteUrl, - type VerifyInstallationResult, -} from "@/lib/analytics/verify-installation"; +import type { VerifyInstallationResult } from "@/lib/analytics/verify-installation"; import { clientAccessCheck } from "@/lib/client-access-check"; import useWorkspace from "@/lib/swr/use-workspace"; import { UserAvatar } from "@/ui/users/user-avatar"; import { Button, Combobox, Globe } from "@dub/ui"; -import { cn, OG_AVATAR_URL, timeAgo } from "@dub/utils"; +import { cn, getPrettyUrl, OG_AVATAR_URL, timeAgo } from "@dub/utils"; import { useAction } from "next-safe-action/hooks"; import { type ReactNode, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; @@ -17,18 +14,34 @@ import { toast } from "sonner"; const HOSTNAME_REQUIRED_MESSAGE = "A hostname is required in order to verify installation."; -const VERIFY_DOCS_HREF = "https://dub.co/docs/sdks/client-side"; +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 LastVerified = { hostname: string; verifiedAt: string; @@ -67,9 +80,9 @@ export function VerifyInstall({ hostnames }: { hostnames: string[] }) { const hostnameOptions = useMemo( () => - hostnames.map((hostname) => ({ + hostnames.filter(isVerifiableHostname).map((hostname) => ({ value: hostname, - label: hostname.startsWith("*.") ? hostname : toVerifySiteUrl(hostname), + label: getPrettyUrl(hostname), icon: , })), [hostnames], @@ -153,12 +166,12 @@ export function VerifyInstall({ hostnames }: { hostnames: string[] }) { searchPlaceholder="Search hostnames..." buttonProps={{ className: cn("h-10 w-full", helperTone && "bg-bg-default"), - disabled: hostnames.length === 0 || isPending, + disabled: hostnameOptions.length === 0 || isPending, disabledTooltip: - hostnames.length === 0 + hostnameOptions.length === 0 ? HOSTNAME_REQUIRED_MESSAGE : isPending - ? "Verification in progress" + ? "Verification in progress..." : undefined, }} matchTriggerWidth @@ -172,7 +185,7 @@ export function VerifyInstall({ hostnames }: { hostnames: string[] }) { {showError && resultForSelection?.status === "error" && ( - {ERROR_HEADLINE[resultForSelection.error]} After correcting, try + {getErrorHeadline(resultForSelection)} After correcting, try verifying again and if the issue still persists, check out our{" "} ]*>/gi; @@ -33,9 +35,46 @@ const CONVERSION_SRC_RE = const SDK_NAME_RE = /data-sdkn\s*=\s*(?:["']@dub\/analytics["']|@dub\/analytics(?=[\s>/]))/i; const PUBLISHABLE_KEY_RE = /data-publishable-key=/i; +const ATTR_RE = (name: string) => + new RegExp(`${name}\\s*=\\s*(["'])([\\s\\S]*?)\\1`, "i"); + +const decodeHtmlEntities = (value: string) => + value + .replace(/"/gi, '"') + .replace(/"/g, '"') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/'/g, "'") + .replace(/&/gi, "&"); + +const normalizeReferDomain = (value: string) => + value.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/$/, ""); + +const getScriptAttr = (tag: string, name: string) => { + const match = tag.match(ATTR_RE(name)); + return match ? decodeHtmlEntities(match[2]) : null; +}; + +const parseScriptReferDomain = (tag: string) => { + const rawDomains = getScriptAttr(tag, "data-domains"); + if (rawDomains) { + try { + const parsed = JSON.parse(rawDomains) as { refer?: unknown }; + if (typeof parsed.refer === "string" && parsed.refer.trim()) { + return parsed.refer.trim(); + } + } catch { + // Fall through to the legacy data-short-domain attribute. + } + } + + const shortDomain = getScriptAttr(tag, "data-short-domain"); + return shortDomain?.trim() || null; +}; export function analyzeDubAnalyticsScript( html: string, + { referDomain }: { referDomain?: string | null } = {}, ): "ok" | VerifyErrorCode { const scriptTags = html.match(SCRIPT_TAG_RE) ?? []; const dubScripts = scriptTags.filter((tag) => DUB_SCRIPT_RE.test(tag)); @@ -68,6 +107,18 @@ export function analyzeDubAnalyticsScript( return "missing_attributes"; } + const expectedReferDomain = referDomain?.trim(); + if (expectedReferDomain) { + const scriptReferDomain = parseScriptReferDomain(tag); + if ( + !scriptReferDomain || + normalizeReferDomain(scriptReferDomain) !== + normalizeReferDomain(expectedReferDomain) + ) { + return "missing_refer_domain"; + } + } + return "ok"; } diff --git a/apps/web/lib/api/fraud/constants.ts b/apps/web/lib/api/fraud/constants.ts index 7fdc01cdd46..c1098d837d7 100644 --- a/apps/web/lib/api/fraud/constants.ts +++ b/apps/web/lib/api/fraud/constants.ts @@ -33,7 +33,7 @@ export const FRAUD_RULES: FraudRuleInfo[] = [ type: "referralSourceBanned", name: "Banned referral source", description: - "A conversion, event, or click was made on a banned referral domain.", + "A conversion, event, or click was made on a banned referral source.", scope: "conversionEvent", configurable: true, }, 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"); + }); +}); From c06aeaae620e9d16d7b08aa2c3ee9d157a093714 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Wed, 2 Sep 2026 14:36:48 -0700 Subject: [PATCH 12/14] Update apps/web/lib/zod/schemas/commissions.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- apps/web/lib/zod/schemas/commissions.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/lib/zod/schemas/commissions.ts b/apps/web/lib/zod/schemas/commissions.ts index 2e7fe55f463..7b184c2c053 100644 --- a/apps/web/lib/zod/schemas/commissions.ts +++ b/apps/web/lib/zod/schemas/commissions.ts @@ -746,7 +746,11 @@ export const createManualCommissionBodySchema = z return; } - if (data.type === "sale" && data.linkId && data.discountCode) { + if ( + data.type === "sale" && + data.linkId != null && + data.discountCode != null + ) { ctx.addIssue({ code: "custom", message: "Either `linkId` or `discountCode` may be provided, not both.", From 6fb40dddd348467a07a62c894448f25e5073706b Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Wed, 2 Sep 2026 15:37:26 -0700 Subject: [PATCH 13/14] Update partners-redirect.ts --- apps/web/lib/middleware/utils/partners-redirect.ts | 1 + 1 file changed, 1 insertion(+) 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) => { From 4989f63d074720902289dfeb8d6379fc33467aa5 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Wed, 2 Sep 2026 16:26:39 -0700 Subject: [PATCH 14/14] fix cron/domains/update --- .../app/(ee)/api/cron/domains/update/route.ts | 31 ++++++++++--------- apps/web/app/api/links/sync/route.ts | 1 + 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/domains/update/route.ts b/apps/web/app/(ee)/api/cron/domains/update/route.ts index 651219e6528..4453848210c 100644 --- a/apps/web/app/(ee)/api/cron/domains/update/route.ts +++ b/apps/web/app/(ee)/api/cron/domains/update/route.ts @@ -43,7 +43,12 @@ export async function POST(req: Request) { const linksToUpdate = await prisma.link.findMany({ where: { domain: oldDomain, - ...(programId && { programId }), + ...(programId && { + programId, + key: { + not: "_root", // edge case, but don't update the root link (since there's already a link for it) + }, + }), }, take: LINK_BATCH_SIZE, ...(startingAfter && { @@ -65,20 +70,18 @@ export async function POST(req: Request) { const linkIdsToUpdate = linksToUpdate.map((link) => link.id); - try { - await prisma.link.updateMany({ - where: { - id: { - in: linkIdsToUpdate, - }, - }, - data: { - domain: newDomain, + const { count } = await prisma.link.updateMany({ + where: { + id: { + in: linkIdsToUpdate, }, - }); - } catch (error) { - console.error(error); - } + }, + data: { + domain: newDomain, + }, + }); + + console.log(`Updated ${count} links for domain ${oldDomain}`); const updatedLinks = await prisma.link.findMany({ where: { 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, }, });