From dff2ce26a572d91ae703efddc8f14034168adb1a Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 9 Jul 2026 15:08:01 +0530 Subject: [PATCH 01/18] Add Google Ads integration for OAuth install and conversion uploads --- apps/web/.env.example | 5 + apps/web/app/(ee)/api/gad/callback/route.ts | 173 ++++++++ .../(ee)/api/gad/conversion-actions/route.ts | 63 +++ .../(ee)/api/gad/upload-conversion/route.ts | 14 + .../[integrationSlug]/page-client.tsx | 26 +- .../actions/get-integration-install-url.ts | 3 + apps/web/lib/api/conversions/track-lead.ts | 11 +- apps/web/lib/api/conversions/track-sale.ts | 13 +- apps/web/lib/integrations/google-ads/api.ts | 375 ++++++++++++++++++ .../lib/integrations/google-ads/constants.ts | 12 + apps/web/lib/integrations/google-ads/oauth.ts | 142 +++++++ .../web/lib/integrations/google-ads/schema.ts | 42 ++ .../integrations/google-ads/ui/settings.tsx | 308 ++++++++++++++ .../google-ads/update-google-ads-settings.ts | 70 ++++ .../google-ads/upload-conversion.ts | 138 +++++++ apps/web/lib/integrations/install.ts | 3 + apps/web/lib/webhook/utils.ts | 14 +- apps/web/scripts/create-integration.ts | 30 +- packages/utils/src/constants/integrations.ts | 1 + 19 files changed, 1416 insertions(+), 27 deletions(-) create mode 100644 apps/web/app/(ee)/api/gad/callback/route.ts create mode 100644 apps/web/app/(ee)/api/gad/conversion-actions/route.ts create mode 100644 apps/web/app/(ee)/api/gad/upload-conversion/route.ts create mode 100644 apps/web/lib/integrations/google-ads/api.ts create mode 100644 apps/web/lib/integrations/google-ads/constants.ts create mode 100644 apps/web/lib/integrations/google-ads/oauth.ts create mode 100644 apps/web/lib/integrations/google-ads/schema.ts create mode 100644 apps/web/lib/integrations/google-ads/ui/settings.tsx create mode 100644 apps/web/lib/integrations/google-ads/update-google-ads-settings.ts create mode 100644 apps/web/lib/integrations/google-ads/upload-conversion.ts diff --git a/apps/web/.env.example b/apps/web/.env.example index 033f320ab94..9b7f072c9b4 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -182,6 +182,11 @@ HUBSPOT_CLIENT_SECRET= INTERCOM_CLIENT_ID= INTERCOM_CLIENT_SECRET= +# Google Ads integration +GOOGLE_ADS_CLIENT_ID= +GOOGLE_ADS_CLIENT_SECRET= +GOOGLE_ADS_DEVELOPER_TOKEN= + # E2E Playwright Tests E2E_PARTNER_EMAIL= E2E_PARTNER_PASSWORD= diff --git a/apps/web/app/(ee)/api/gad/callback/route.ts b/apps/web/app/(ee)/api/gad/callback/route.ts new file mode 100644 index 00000000000..19da99d3cab --- /dev/null +++ b/apps/web/app/(ee)/api/gad/callback/route.ts @@ -0,0 +1,173 @@ +import { DubApiError } from "@/lib/api/errors"; +import { getSession } from "@/lib/auth"; +import { encrypt } from "@/lib/encryption"; +import { + GoogleAdsApi, + inferLoginCustomerId, +} from "@/lib/integrations/google-ads/api"; +import { googleAdsOAuthProvider } from "@/lib/integrations/google-ads/oauth"; +import { + googleAdsAuthTokenSchema, + googleAdsSettingsSchema, +} from "@/lib/integrations/google-ads/schema"; +import { installIntegration } from "@/lib/integrations/install"; +import { getPlanCapabilities } from "@/lib/plan-capabilities"; +import { prisma } from "@/lib/prisma"; +import { GOOGLE_ADS_INTEGRATION_ID } from "@dub/utils"; +import { redirect } from "next/navigation"; + +export const dynamic = "force-dynamic"; + +// GET /api/gad/callback - OAuth callback from Google Ads +export const GET = async (req: Request) => { + const { searchParams } = new URL(req.url); + + const session = await getSession(); + + if (!session?.user.id) { + const callbackPath = `/api/gad/callback?${searchParams.toString()}`; + redirect(`/login?next=${encodeURIComponent(callbackPath)}`); + } + + const integration = await prisma.integration.findFirstOrThrow({ + where: { + id: GOOGLE_ADS_INTEGRATION_ID, + }, + select: { + slug: true, + }, + }); + + let workspaceSlug: string | null = null; + let errorMessage: string | null = null; + + try { + const { token, contextId: workspaceId } = + await googleAdsOAuthProvider.exchangeCodeForToken(req); + + const workspace = await prisma.project.findUniqueOrThrow({ + where: { + id: workspaceId, + }, + select: { + id: true, + slug: true, + plan: true, + users: { + where: { + userId: session.user.id, + }, + select: { + role: true, + defaultFolderId: true, + }, + }, + }, + }); + + workspaceSlug = workspace.slug; + + if (workspace.users.length === 0) { + throw new DubApiError({ + code: "bad_request", + message: "You are not a member of this workspace.", + }); + } + + if (workspace.users[0].role !== "owner") { + throw new DubApiError({ + code: "bad_request", + message: "Only workspace owners can install integrations.", + }); + } + + if (!getPlanCapabilities(workspace.plan).canInstallAdvancedIntegrations) { + throw new DubApiError({ + code: "forbidden", + message: + "Google Ads integration is only available on Advanced and Enterprise plans.", + }); + } + + const credentials = googleAdsAuthTokenSchema.parse({ + ...token, + created_at: Date.now(), + access_token: encrypt(token.access_token), + refresh_token: encrypt(token.refresh_token), + }); + + const googleAdsApi = new GoogleAdsApi({ + accessToken: token.access_token, + }); + + const customers = await googleAdsApi.listAccessibleCustomers(); + + let customerName: string | null = null; + let customerId: string | null = null; + let loginCustomerId: string | null = null; + + // Just one customer, so we can use the first one + if (customers.length === 1) { + customerName = customers[0].descriptiveName; + customerId = customers[0].id; + loginCustomerId = inferLoginCustomerId({ + customers, + selectedCustomerId: customerId, + }); + } + + const settings = googleAdsSettingsSchema.parse({ + customers, + customerId, + customerName, + loginCustomerId, + }); + + await installIntegration({ + integrationId: GOOGLE_ADS_INTEGRATION_ID, + userId: session.user.id, + workspaceId, + credentials, + settings, + }); + } catch (error) { + errorMessage = + error instanceof DubApiError || error instanceof Error + ? error.message + : "Failed to connect Google Ads. Please try again."; + } + + if (!workspaceSlug) { + redirect( + `/login?error=${encodeURIComponent(errorMessage || "Failed to connect Google Ads. Please try again.")}`, + ); + } + + redirectToIntegrationPage({ + workspaceSlug, + integrationSlug: integration.slug, + error: errorMessage ?? undefined, + }); +}; + +const redirectToIntegrationPage = ({ + workspaceSlug, + integrationSlug, + error, +}: { + workspaceSlug: string; + integrationSlug: string; + error?: string; +}) => { + const params = new URLSearchParams(); + + if (error) { + params.set("error", error); + } + + const query = params.toString(); + + redirect( + `/${workspaceSlug}/settings/integrations/${integrationSlug}${query ? `?${query}` : ""}`, + ); +}; diff --git a/apps/web/app/(ee)/api/gad/conversion-actions/route.ts b/apps/web/app/(ee)/api/gad/conversion-actions/route.ts new file mode 100644 index 00000000000..3a728141a15 --- /dev/null +++ b/apps/web/app/(ee)/api/gad/conversion-actions/route.ts @@ -0,0 +1,63 @@ +import { DubApiError } from "@/lib/api/errors"; +import { withWorkspace } from "@/lib/auth"; +import { + GoogleAdsApi, + inferLoginCustomerId, +} from "@/lib/integrations/google-ads/api"; +import { googleAdsOAuthProvider } from "@/lib/integrations/google-ads/oauth"; +import { googleAdsSettingsSchema } from "@/lib/integrations/google-ads/schema"; +import { prisma } from "@/lib/prisma"; +import { GOOGLE_ADS_INTEGRATION_ID } from "@dub/utils"; +import { NextResponse } from "next/server"; +import * as z from "zod/v4"; + +// GET /api/gad/conversion-actions - List UPLOAD_CLICKS conversion actions for a customer +export const GET = withWorkspace(async ({ workspace, searchParams }) => { + const { customerId } = z + .object({ + customerId: z.string().min(1), + }) + .parse(searchParams); + + const installedIntegration = await prisma.installedIntegration.findFirst({ + where: { + integrationId: GOOGLE_ADS_INTEGRATION_ID, + projectId: workspace.id, + }, + }); + + if (!installedIntegration) { + throw new DubApiError({ + code: "bad_request", + message: "Google Ads integration is not installed on your workspace.", + }); + } + + const token = + await googleAdsOAuthProvider.refreshTokenForInstallation( + installedIntegration, + ); + + const currentSettings = googleAdsSettingsSchema.parse( + installedIntegration.settings ?? {}, + ); + + const loginCustomerId = inferLoginCustomerId({ + customers: currentSettings.customers, + selectedCustomerId: customerId, + }); + + const googleAdsApi = new GoogleAdsApi({ + accessToken: token.access_token, + loginCustomerId, + customerId, + }); + + const conversionActions = + await googleAdsApi.listUploadClickConversionActions(customerId); + + return NextResponse.json({ + conversionActions, + loginCustomerId, + }); +}); diff --git a/apps/web/app/(ee)/api/gad/upload-conversion/route.ts b/apps/web/app/(ee)/api/gad/upload-conversion/route.ts new file mode 100644 index 00000000000..21dd7e3782f --- /dev/null +++ b/apps/web/app/(ee)/api/gad/upload-conversion/route.ts @@ -0,0 +1,14 @@ +import { withCron } from "@/lib/cron/with-cron"; +import { googleAdsConversionUploadSchema } from "@/lib/integrations/google-ads/schema"; +import { uploadGoogleAdsConversion } from "@/lib/integrations/google-ads/upload-conversion"; + +export const dynamic = "force-dynamic"; + +// POST /api/gad/upload-conversion - Upload a conversion to Google Ads +export const POST = withCron(async ({ rawBody }) => { + const payload = googleAdsConversionUploadSchema.parse(JSON.parse(rawBody)); + + await uploadGoogleAdsConversion(payload); + + return new Response("OK"); +}); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page-client.tsx index 7de2a24755f..d1b505228cd 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page-client.tsx @@ -4,6 +4,7 @@ import { getIntegrationInstallUrl } from "@/lib/actions/get-integration-install- import { clientAccessCheck } from "@/lib/client-access-check"; import { installAppsFlyerAction } from "@/lib/integrations/appsflyer/install"; import { AppsFlyerSettings } from "@/lib/integrations/appsflyer/ui/settings"; +import { GoogleAdsSettings } from "@/lib/integrations/google-ads/ui/settings"; import { HubSpotSettings } from "@/lib/integrations/hubspot/ui/settings"; import { SegmentSettings } from "@/lib/integrations/segment/ui/settings"; import { SlackSettings } from "@/lib/integrations/slack/ui/settings"; @@ -48,6 +49,7 @@ import { DUB_WORKSPACE_ID, formatDate, getDomainWithoutWWW, + GOOGLE_ADS_INTEGRATION_ID, SEGMENT_INTEGRATION_ID, SLACK_INTEGRATION_ID, STRIPE_INTEGRATION_ID, @@ -59,7 +61,8 @@ import { } from "@dub/utils/src/constants/integrations"; import { useAction } from "next-safe-action/hooks"; import Link from "next/link"; -import { useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; const integrationSettings = { @@ -69,6 +72,7 @@ const integrationSettings = { [HUBSPOT_INTEGRATION_ID]: HubSpotSettings, [STRIPE_INTEGRATION_ID]: StripeIntegrationSettings, [APPSFLYER_INTEGRATION_ID]: AppsFlyerSettings, + [GOOGLE_ADS_INTEGRATION_ID]: GoogleAdsSettings, }; export default function IntegrationPageClient({ @@ -77,14 +81,29 @@ export default function IntegrationPageClient({ integration: InstalledIntegrationInfoProps; }) { const { id: workspaceId, slug, plan, role, stripeConnectId } = useWorkspace(); + const searchParams = useSearchParams(); + const { isMobile } = useMediaQuery(); + const [openPopover, setOpenPopover] = useState(false); + + useEffect(() => { + const error = searchParams?.get("error"); + + if (!error) { + return; + } + + toast.error(error); + + const url = new URL(window.location.href); + url.searchParams.delete("error"); + window.history.replaceState({}, "", url.toString()); + }, [searchParams]); const permissionsError = clientAccessCheck({ action: "integrations.write", role, }).error; - const { isMobile } = useMediaQuery(); - const [openPopover, setOpenPopover] = useState(false); const { execute, isPending } = useAction(getIntegrationInstallUrl, { onSuccess: ({ data }) => { if (!data?.url) { @@ -352,6 +371,7 @@ export default function IntegrationPageClient({ HUBSPOT_INTEGRATION_ID, APPSFLYER_INTEGRATION_ID, INTERCOM_INTEGRATION_ID, + GOOGLE_ADS_INTEGRATION_ID, ].includes(integration.id) && !canInstallAdvancedIntegrations ? ( , + "conversionDateTime" | "eventId" | "conversionValue" | "currencyCode" +>; + +type GoogleAdsRequestOptions = { + accessToken: string; + loginCustomerId?: string | null; +}; + +export const queueGoogleAdsConversionUpload = async ( + payload: z.infer, +) => { + // TODO: + // How to optimize this call? + + const installedIntegration = await prisma.installedIntegration.findFirst({ + where: { + integrationId: GOOGLE_ADS_INTEGRATION_ID, + projectId: payload.workspaceId, + }, + select: { + id: true, + }, + }); + + if (!installedIntegration) { + return; + } + + const response = await qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/gad/upload-conversion`, + body: payload, + retries: 3, + deduplicationId: `google-ads-${payload.workspaceId}-${payload.eventId}`, + }); + + if (!response.messageId) { + throw new Error("Failed to queue Google Ads conversion upload"); + } + + return response; +}; + +const getGoogleAdsHeaders = ({ + accessToken, + loginCustomerId, +}: GoogleAdsRequestOptions) => { + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + "developer-token": process.env.GOOGLE_ADS_DEVELOPER_TOKEN!, + "Content-Type": "application/json", + }; + + if (loginCustomerId) { + headers["login-customer-id"] = loginCustomerId.replace(/-/g, ""); + } + + return headers; +}; + +const googleAdsFetch = async ({ + path, + method = "GET", + body, + ...options +}: GoogleAdsRequestOptions & { + path: string; + method?: "GET" | "POST"; + body?: unknown; +}): Promise => { + const response = await fetch( + `https://googleads.googleapis.com/${GOOGLE_ADS_API_VERSION}/${path}`, + { + method, + headers: getGoogleAdsHeaders(options), + ...(body ? { body: JSON.stringify(body) } : {}), + }, + ); + + const text = await response.text(); + let data: any; + + try { + data = text ? JSON.parse(text) : null; + } catch { + console.error("[Google Ads API]", path, text); + + throw new Error( + `[Google Ads API] Request failed for ${path} (${response.status}). Please try again.`, + ); + } + + if (!response.ok) { + console.error("[Google Ads API]", path, data); + + throw new Error( + `[Google Ads API] Request failed for ${path} (${response.status}). Please try again.`, + ); + } + + return data as T; +}; + +const searchStream = async ({ + customerId, + query, + ...options +}: GoogleAdsRequestOptions & { + customerId: string; + query: string; +}) => { + const normalizedCustomerId = customerId.replace(/-/g, ""); + + const response = await googleAdsFetch< + { + results?: { + customer?: { + id?: string; + descriptiveName?: string; + manager?: boolean; + }; + conversionAction?: { + id?: string; + resourceName?: string; + name?: string; + }; + }[]; + }[] + >({ + ...options, + path: `customers/${normalizedCustomerId}/googleAds:searchStream`, + method: "POST", + body: { query }, + }); + + return response.flatMap((batch) => batch.results ?? []); +}; + +export class GoogleAdsApi { + constructor( + private options: GoogleAdsRequestOptions & { + customerId?: string | null; + }, + ) {} + + // Lists accounts the OAuth user can access, then hydrates each with name/manager + // via searchStream. Client accounts under an MCC often need login-customer-id. + async listAccessibleCustomers() { + const response = await googleAdsFetch<{ + resourceNames?: string[]; + }>({ + ...this.options, + path: "customers:listAccessibleCustomers", + }); + + const resourceNames = response.resourceNames ?? []; + + const fetchCustomer = async ({ + resourceName, + loginCustomerId, + }: { + resourceName: string; + loginCustomerId?: string | null; + }) => { + const customerId = resourceName.replace("customers/", ""); + + const results = await searchStream({ + ...this.options, + customerId, + loginCustomerId, + query: + "SELECT customer.id, customer.descriptive_name, customer.manager FROM customer LIMIT 1", + }); + + const customer = results[0]?.customer; + + return googleAdsCustomerSchema.parse({ + id: customer?.id?.toString() ?? customerId, + resourceName, + descriptiveName: customer?.descriptiveName ?? `Account ${customerId}`, + manager: customer?.manager ?? false, + }); + }; + + const initialResults = await Promise.all( + resourceNames.map(async (resourceName) => { + try { + return { + resourceName, + customer: await fetchCustomer({ resourceName }), + }; + } catch (error) { + console.error( + `[Google Ads API] Failed to fetch customer ${resourceName.replace("customers/", "")}`, + error, + ); + + return { + resourceName, + customer: null, + }; + } + }), + ); + + const managerAccounts = initialResults + .map((result) => result.customer) + .filter((customer): customer is z.infer => + Boolean(customer?.manager), + ); + + const loginCustomerId = + managerAccounts.length === 1 ? managerAccounts[0].id : null; + + const customers = await Promise.all( + initialResults.map(async ({ resourceName, customer }) => { + if (customer) { + return customer; + } + + if (!loginCustomerId) { + return null; + } + + const customerId = resourceName.replace("customers/", ""); + + try { + return await fetchCustomer({ resourceName, loginCustomerId }); + } catch (error) { + console.error( + `[Google Ads API] Failed to fetch customer ${customerId} with login-customer-id ${loginCustomerId}`, + error, + ); + + return null; + } + }), + ); + + // Only keep accounts we could actually read (skip permission-denied ones). + return customers.filter( + (customer): customer is z.infer => + customer !== null, + ); + } + + async listUploadClickConversionActions(customerId: string) { + const results = await searchStream({ + ...this.options, + customerId, + query: + "SELECT conversion_action.id, conversion_action.name, conversion_action.resource_name FROM conversion_action WHERE conversion_action.type = UPLOAD_CLICKS AND conversion_action.status = ENABLED", + }); + + const conversionActions = results + .map((result) => result.conversionAction) + .filter((conversionAction) => conversionAction != null); + + return conversionActions.map((conversionAction) => + googleAdsConversionActionSchema.parse({ + id: conversionAction.id!.toString(), + resourceName: conversionAction.resourceName!, + name: conversionAction.name!, + }), + ); + } + + async uploadClickConversion({ + customerId, + conversionAction, + clickIds, + conversionDateTime, + conversionValue, + currencyCode, + eventId, + }: UploadClickConversionParams) { + const normalizedCustomerId = customerId.replace(/-/g, ""); + + const conversion: Record = { + conversionAction, + conversionDateTime, + orderId: eventId, + consent: { + adUserData: "GRANTED", + }, + ...clickIds, + }; + + if (conversionValue !== undefined) { + conversion.conversionValue = conversionValue; + } + + if (currencyCode) { + conversion.currencyCode = currencyCode.toUpperCase(); + } + + return googleAdsFetch({ + ...this.options, + path: `customers/${normalizedCustomerId}:uploadClickConversions`, + method: "POST", + body: { + conversions: [conversion], + partialFailure: true, + }, + }); + } +} + +// Resolves the login-customer-id header: use the selected account if it's a +// manager, otherwise the sole accessible manager account (or null if ambiguous). +export const inferLoginCustomerId = ({ + customers, + selectedCustomerId, +}: { + customers: { + id: string; + manager: boolean; + }[]; + selectedCustomerId: string; +}) => { + const normalizedSelectedId = selectedCustomerId.replace(/-/g, ""); + const selectedCustomer = customers.find( + (customer) => customer.id.replace(/-/g, "") === normalizedSelectedId, + ); + + if (selectedCustomer?.manager) { + return normalizedSelectedId; + } + + const managerAccounts = customers.filter((customer) => customer.manager); + + if (managerAccounts.length === 1) { + return managerAccounts[0].id.replace(/-/g, ""); + } + + return null; +}; + +// Formats a date as `yyyy-MM-dd HH:mm:ss+00:00` for Google Ads conversion uploads. +export const formatGoogleAdsConversionDateTime = (input: string | Date) => { + const date = typeof input === "string" ? new Date(input) : input; + + const pad = (value: number) => value.toString().padStart(2, "0"); + + const year = date.getUTCFullYear(); + const month = pad(date.getUTCMonth() + 1); + const day = pad(date.getUTCDate()); + const hours = pad(date.getUTCHours()); + const minutes = pad(date.getUTCMinutes()); + const seconds = pad(date.getUTCSeconds()); + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}+00:00`; +}; diff --git a/apps/web/lib/integrations/google-ads/constants.ts b/apps/web/lib/integrations/google-ads/constants.ts new file mode 100644 index 00000000000..6fa5b0f5ca7 --- /dev/null +++ b/apps/web/lib/integrations/google-ads/constants.ts @@ -0,0 +1,12 @@ +export const GOOGLE_ADS_DEFAULT_SETTINGS = { + customers: [], + customerId: null, + loginCustomerId: null, + customerName: null, + leadConversionAction: null, + saleConversionAction: null, +} as const; + +export const GOOGLE_ADS_OAUTH_SCOPE = "https://www.googleapis.com/auth/adwords"; + +export const GOOGLE_ADS_API_VERSION = "v22"; diff --git a/apps/web/lib/integrations/google-ads/oauth.ts b/apps/web/lib/integrations/google-ads/oauth.ts new file mode 100644 index 00000000000..96f13ed0e35 --- /dev/null +++ b/apps/web/lib/integrations/google-ads/oauth.ts @@ -0,0 +1,142 @@ +import { decrypt, encrypt } from "@/lib/encryption"; +import { prisma } from "@/lib/prisma"; +import { nanoid } from "@dub/utils"; +import { InstalledIntegration } from "@prisma/client"; +import * as z from "zod/v4"; +import { redis } from "../../upstash"; +import { OAuthProvider, OAuthProviderConfig } from "../oauth-provider"; +import { GOOGLE_ADS_OAUTH_SCOPE } from "./constants"; +import { googleAdsAuthTokenSchema } from "./schema"; + +class GoogleAdsOAuthProvider extends OAuthProvider< + typeof googleAdsAuthTokenSchema +> { + private readonly config: OAuthProviderConfig; + + constructor(provider: OAuthProviderConfig) { + super(provider); + this.config = provider; + } + + async generateAuthUrl(contextId: string | Record) { + const state = nanoid(16); + await redis.set(`${this.config.redisStatePrefix}:${state}`, contextId, { + ex: 30 * 60, + }); + + const searchParams = new URLSearchParams({ + client_id: this.config.clientId, + redirect_uri: this.config.redirectUri, + scope: GOOGLE_ADS_OAUTH_SCOPE, + response_type: "code", + state, + access_type: "offline", + prompt: "consent", + }); + + return `${this.config.authUrl}?${searchParams.toString()}`; + } + + async refreshTokenForInstallation( + installation: InstalledIntegration, + ): Promise> { + let existingCredentials = googleAdsAuthTokenSchema.parse( + installation.credentials, + ); + + existingCredentials = { + ...existingCredentials, + access_token: decrypt(existingCredentials.access_token), + refresh_token: decrypt(existingCredentials.refresh_token), + }; + + if (this.isTokenValid(existingCredentials)) { + return existingCredentials; + } + + if (!existingCredentials.refresh_token) { + throw new Error( + "[Google Ads] Missing refresh token. Please reconnect the integration.", + ); + } + + const newToken = await this.fetchRefreshedToken( + existingCredentials.refresh_token, + ); + + const newCredentials = { + ...existingCredentials, + ...newToken, + }; + + await prisma.installedIntegration.update({ + where: { + id: installation.id, + }, + data: { + credentials: googleAdsAuthTokenSchema.parse({ + ...newCredentials, + access_token: encrypt(newCredentials.access_token), + refresh_token: encrypt(newCredentials.refresh_token), + }), + }, + }); + + return newCredentials; + } + + private async fetchRefreshedToken(refreshToken: string) { + const response = await fetch(this.config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + }), + }); + + const newToken = await response.json(); + + if (!response.ok) { + console.error(`[${this.config.name}] refreshToken`, newToken); + + throw new Error( + `[${this.config.name}] Failed to refresh the access token. Please try again.`, + ); + } + + return googleAdsAuthTokenSchema.parse({ + ...newToken, + refresh_token: newToken.refresh_token ?? refreshToken, + created_at: Date.now(), + }); + } + + isTokenValid(token: z.infer) { + if (!token.created_at) { + return false; + } + + const buffer = 60 * 1000; + const expiresAt = token.created_at + token.expires_in * 1000; + + return Date.now() < expiresAt - buffer; + } +} + +export const googleAdsOAuthProvider = new GoogleAdsOAuthProvider({ + name: "Google Ads", + clientId: process.env.GOOGLE_ADS_CLIENT_ID!, + clientSecret: process.env.GOOGLE_ADS_CLIENT_SECRET!, + authUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + redirectUri: `http://localhost:8888/api/gad/callback`, // change this before merging + redisStatePrefix: "google-ads:oauth:state", + tokenSchema: googleAdsAuthTokenSchema, + bodyFormat: "form", + authorizationMethod: "body", +}); diff --git a/apps/web/lib/integrations/google-ads/schema.ts b/apps/web/lib/integrations/google-ads/schema.ts new file mode 100644 index 00000000000..41fdeb49934 --- /dev/null +++ b/apps/web/lib/integrations/google-ads/schema.ts @@ -0,0 +1,42 @@ +import * as z from "zod/v4"; + +export const googleAdsAuthTokenSchema = z.object({ + access_token: z.string(), + refresh_token: z.string(), + expires_in: z.number(), + scope: z.string().optional(), + token_type: z.string().optional(), + created_at: z.number().optional(), +}); + +export const googleAdsCustomerSchema = z.object({ + id: z.string(), + resourceName: z.string(), + descriptiveName: z.string(), + manager: z.boolean(), +}); + +export const googleAdsSettingsSchema = z.object({ + customers: z.array(googleAdsCustomerSchema).default([]), + customerId: z.string().nullish(), + loginCustomerId: z.string().nullish(), + customerName: z.string().nullish(), + leadConversionAction: z.string().nullish(), + saleConversionAction: z.string().nullish(), +}); + +export const googleAdsConversionActionSchema = z.object({ + id: z.string(), + resourceName: z.string(), + name: z.string(), +}); + +export const googleAdsConversionUploadSchema = z.object({ + workspaceId: z.string(), + eventType: z.enum(["lead", "sale"]), + clickId: z.string(), + conversionDateTime: z.string(), + eventId: z.string(), + conversionValue: z.number().optional(), + currencyCode: z.string().optional(), +}); diff --git a/apps/web/lib/integrations/google-ads/ui/settings.tsx b/apps/web/lib/integrations/google-ads/ui/settings.tsx new file mode 100644 index 00000000000..93352454d27 --- /dev/null +++ b/apps/web/lib/integrations/google-ads/ui/settings.tsx @@ -0,0 +1,308 @@ +"use client"; + +import useWorkspace from "@/lib/swr/use-workspace"; +import { InstalledIntegrationInfoProps } from "@/lib/types"; +import { Button, Combobox, ComboboxOption } from "@dub/ui"; +import { fetcher } from "@dub/utils"; +import { ChevronDown } from "lucide-react"; +import { useAction } from "next-safe-action/hooks"; +import { useEffect, useMemo } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { toast } from "sonner"; +import useSWR from "swr"; +import * as z from "zod/v4"; +import { GOOGLE_ADS_DEFAULT_SETTINGS } from "../constants"; +import { + googleAdsConversionActionSchema, + googleAdsSettingsSchema, +} from "../schema"; +import { updateGoogleAdsSettingsAction } from "../update-google-ads-settings"; + +type GoogleAdsCustomerOption = ComboboxOption<{ + descriptiveName: string; + manager: boolean; +}>; + +type FormData = { + [K in keyof Omit< + z.infer, + "customers" + >]: string; +}; + +type ConversionActionsResponse = { + conversionActions: z.infer[]; + loginCustomerId: string | null; +}; + +export const GoogleAdsSettings = ({ + installed, + settings, +}: InstalledIntegrationInfoProps) => { + const { id: workspaceId } = useWorkspace(); + + const googleAdsSettings = googleAdsSettingsSchema.parse({ + ...GOOGLE_ADS_DEFAULT_SETTINGS, + ...(settings as any), + }); + + const { control, handleSubmit, watch, setValue } = useForm({ + defaultValues: { + customerId: googleAdsSettings.customerId ?? "", + loginCustomerId: googleAdsSettings.loginCustomerId ?? "", + customerName: googleAdsSettings.customerName ?? "", + leadConversionAction: googleAdsSettings.leadConversionAction ?? "", + saleConversionAction: googleAdsSettings.saleConversionAction ?? "", + }, + }); + + const customerId = watch("customerId"); + const leadConversionAction = watch("leadConversionAction"); + const saleConversionAction = watch("saleConversionAction"); + + const customerOptions = useMemo( + () => + googleAdsSettings.customers.map((customer) => ({ + value: customer.id, + label: customer.descriptiveName, + meta: { + descriptiveName: customer.descriptiveName, + manager: customer.manager, + }, + })), + [googleAdsSettings.customers], + ); + + const { + data: conversionActionsData, + isLoading: isLoadingOptions, + error: conversionActionsError, + } = useSWR( + workspaceId && installed && customerId + ? `/api/gad/conversion-actions?workspaceId=${workspaceId}&customerId=${customerId}` + : null, + fetcher, + ); + + useEffect(() => { + if (conversionActionsError) { + toast.error( + conversionActionsError.message || + "Failed to load Google Ads conversion actions.", + ); + } + }, [conversionActionsError]); + + useEffect(() => { + if (conversionActionsData?.loginCustomerId) { + setValue("loginCustomerId", conversionActionsData.loginCustomerId); + } + }, [conversionActionsData?.loginCustomerId, setValue]); + + const conversionActionOptions = useMemo( + () => + (conversionActionsData?.conversionActions ?? []).map((action) => ({ + value: action.resourceName, + label: action.name, + })), + [conversionActionsData?.conversionActions], + ); + + const { executeAsync: saveSettings, isPending: isSaving } = useAction( + updateGoogleAdsSettingsAction, + { + onSuccess() { + toast.success("Google Ads settings updated successfully."); + }, + onError({ error }) { + toast.error( + error.serverError || "Failed to update Google Ads settings.", + ); + }, + }, + ); + + const selectedCustomer = useMemo( + () => customerOptions.find((option) => option.value === customerId) ?? null, + [customerOptions, customerId], + ); + + const selectedLeadAction = useMemo( + () => + conversionActionOptions.find( + (option) => option.value === leadConversionAction, + ) ?? null, + [conversionActionOptions, leadConversionAction], + ); + + const selectedSaleAction = useMemo( + () => + conversionActionOptions.find( + (option) => option.value === saleConversionAction, + ) ?? null, + [conversionActionOptions, saleConversionAction], + ); + + const onSubmit = async (data: FormData) => { + if (!workspaceId) { + return; + } + + await saveSettings({ + workspaceId, + customerId: data.customerId || null, + loginCustomerId: data.loginCustomerId || null, + customerName: data.customerName || null, + leadConversionAction: data.leadConversionAction || null, + saleConversionAction: data.saleConversionAction || null, + }); + }; + + if (!installed) { + return null; + } + + return ( +
+
+
+

+ Google Ads Integration Settings +

+
+ +
+
+

+ Google Ads account +

+

+ Select the Google Ads account where Dub should upload offline + click conversions. +

+ ( + { + if (!option) { + return; + } + + setValue("customerId", option.value); + setValue("customerName", option.label); + setValue("leadConversionAction", ""); + setValue("saleConversionAction", ""); + }} + placeholder="Select account" + 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", + }} + /> + )} + /> +

+ Only accounts you have permission to access are shown. If an + account is missing, check your Google Ads access and reconnect. +

+
+ + {customerId && ( + <> +
+

+ Lead conversion action +

+

+ Map Dub lead events to an existing Google Ads conversion + action with type UPLOAD_CLICKS. +

+ ( + { + if (option) { + field.onChange(option.value); + } + }} + placeholder={ + isLoadingOptions + ? "Loading conversion actions..." + : "Select lead conversion action" + } + 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", + }} + /> + )} + /> +
+ +
+

+ Sale conversion action +

+

+ Map Dub sale events to an existing Google Ads conversion + action with type UPLOAD_CLICKS. +

+ ( + { + if (option) { + field.onChange(option.value); + } + }} + placeholder={ + isLoadingOptions + ? "Loading conversion actions..." + : "Select sale conversion action" + } + 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", + }} + /> + )} + /> +
+ + )} + +
+
+
+ ); +}; 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 new file mode 100644 index 00000000000..fe2649f61dd --- /dev/null +++ b/apps/web/lib/integrations/google-ads/update-google-ads-settings.ts @@ -0,0 +1,70 @@ +"use server"; + +import { authActionClient } from "@/lib/actions/safe-action"; +import { prisma } from "@/lib/prisma"; +import { GOOGLE_ADS_INTEGRATION_ID } from "@dub/utils"; +import { revalidatePath } from "next/cache"; +import * as z from "zod/v4"; +import { inferLoginCustomerId } from "./api"; +import { googleAdsSettingsSchema } from "./schema"; + +const schema = googleAdsSettingsSchema.omit({ customers: true }).extend({ + workspaceId: z.string(), +}); + +export const updateGoogleAdsSettingsAction = authActionClient + .inputSchema(schema) + .action(async ({ parsedInput, ctx }) => { + const { workspace } = ctx; + const { + customerId, + loginCustomerId, + customerName, + leadConversionAction, + saleConversionAction, + } = parsedInput; + + const installedIntegration = await prisma.installedIntegration.findFirst({ + where: { + integrationId: GOOGLE_ADS_INTEGRATION_ID, + projectId: workspace.id, + }, + }); + + if (!installedIntegration) { + throw new Error( + "Google Ads integration is not installed on your workspace.", + ); + } + + const currentSettings = googleAdsSettingsSchema.parse( + installedIntegration.settings ?? {}, + ); + + let resolvedLoginCustomerId = loginCustomerId ?? null; + + if (!resolvedLoginCustomerId && customerId) { + resolvedLoginCustomerId = inferLoginCustomerId({ + customers: currentSettings.customers, + selectedCustomerId: customerId, + }); + } + + await prisma.installedIntegration.update({ + where: { + id: installedIntegration.id, + }, + data: { + settings: { + ...currentSettings, + customerId, + loginCustomerId: resolvedLoginCustomerId, + customerName, + leadConversionAction, + saleConversionAction, + }, + }, + }); + + revalidatePath(`/${workspace.slug}/settings/integrations/google-ads`); + }); diff --git a/apps/web/lib/integrations/google-ads/upload-conversion.ts b/apps/web/lib/integrations/google-ads/upload-conversion.ts new file mode 100644 index 00000000000..f2c0cdd566b --- /dev/null +++ b/apps/web/lib/integrations/google-ads/upload-conversion.ts @@ -0,0 +1,138 @@ +import { prisma } from "@/lib/prisma"; +import { getClickEvent } from "@/lib/tinybird"; +import { getSearchParams, GOOGLE_ADS_INTEGRATION_ID } from "@dub/utils"; +import * as z from "zod/v4"; +import { + formatGoogleAdsConversionDateTime, + GoogleAdsApi, + GoogleAdsClickIds, +} from "./api"; +import { googleAdsOAuthProvider } from "./oauth"; +import { + googleAdsConversionUploadSchema, + googleAdsSettingsSchema, +} from "./schema"; + +const extractGoogleAdsClickIds = (url: string): GoogleAdsClickIds => { + if (!url) { + return {}; + } + + const queryParams = getSearchParams(url); + + return { + ...(queryParams.gclid ? { gclid: queryParams.gclid } : {}), + ...(queryParams.gbraid ? { gbraid: queryParams.gbraid } : {}), + ...(queryParams.wbraid ? { wbraid: queryParams.wbraid } : {}), + }; +}; + +export const uploadGoogleAdsConversion = async ( + payload: z.infer, +) => { + const { + workspaceId, + eventType, + clickId, + conversionDateTime, + eventId, + conversionValue, + currencyCode, + } = googleAdsConversionUploadSchema.parse(payload); + + const installedIntegration = await prisma.installedIntegration.findFirst({ + where: { + integrationId: GOOGLE_ADS_INTEGRATION_ID, + projectId: workspaceId, + }, + }); + + if (!installedIntegration) { + console.warn( + `[Google Ads] Skipping ${eventType} conversion upload: Google Ads integration not installed for workspace ${workspaceId}`, + ); + return; + } + + const settings = googleAdsSettingsSchema.parse( + installedIntegration.settings ?? {}, + ); + + const conversionAction = + eventType === "lead" + ? settings.leadConversionAction + : settings.saleConversionAction; + + if (!settings.customerId || !conversionAction) { + console.warn( + `[Google Ads] Skipping ${eventType} conversion upload for workspace ${workspaceId}: missing ${!settings.customerId ? "customerId" : `${eventType}ConversionAction`}`, + ); + return; + } + + const clickEvent = await getClickEvent({ clickId }); + + if (!clickEvent?.url) { + console.warn( + `[Google Ads] Skipping ${eventType} conversion upload for workspace ${workspaceId}: no click event URL found for clickId ${clickId}`, + ); + return; + } + + const clickIds = extractGoogleAdsClickIds(clickEvent.url); + + const hasGoogleAdsClickId = Boolean( + clickIds.gclid || clickIds.gbraid || clickIds.wbraid, + ); + + if (!hasGoogleAdsClickId) { + console.warn( + `[Google Ads] Skipping ${eventType} conversion upload for workspace ${workspaceId}: no gclid/gbraid/wbraid found on click ${clickId}`, + ); + return; + } + + const token = + await googleAdsOAuthProvider.refreshTokenForInstallation( + installedIntegration, + ); + + let loginCustomerId: string | null = null; + + if ( + settings.loginCustomerId && + settings.loginCustomerId !== settings.customerId + ) { + loginCustomerId = settings.loginCustomerId; + } + + const googleAdsApi = new GoogleAdsApi({ + accessToken: token.access_token, + loginCustomerId, + customerId: settings.customerId, + }); + + const response = await googleAdsApi.uploadClickConversion({ + customerId: settings.customerId, + conversionAction, + clickIds, + conversionDateTime: formatGoogleAdsConversionDateTime(conversionDateTime), + conversionValue, + currencyCode, + eventId, + }); + + console.log("uploadClickConversion response", response); + + const partialFailureError = (response as any)?.partialFailureError; + + if (partialFailureError?.message) { + console.error( + `[Google Ads] Partial failure uploading ${eventType} conversion for workspace ${workspaceId}:`, + partialFailureError, + ); + throw new Error(partialFailureError.message); + } + + return response; +}; diff --git a/apps/web/lib/integrations/install.ts b/apps/web/lib/integrations/install.ts index 70be0f4c40c..818acb430c6 100644 --- a/apps/web/lib/integrations/install.ts +++ b/apps/web/lib/integrations/install.ts @@ -17,6 +17,7 @@ export const installIntegration = async ({ workspaceId, integrationId, credentials, + settings, }: InstallIntegration) => { const installation = await prisma.installedIntegration.upsert({ create: { @@ -24,9 +25,11 @@ export const installIntegration = async ({ projectId: workspaceId, integrationId, credentials, + settings, }, update: { credentials, + ...(settings ? { settings } : {}), }, where: { userId_integrationId_projectId: { diff --git a/apps/web/lib/webhook/utils.ts b/apps/web/lib/webhook/utils.ts index 556f362f4ee..648a5ae13d2 100644 --- a/apps/web/lib/webhook/utils.ts +++ b/apps/web/lib/webhook/utils.ts @@ -1,6 +1,4 @@ -import { Webhook, WebhookReceiver } from "@prisma/client"; -import { LINK_CLICK_WEBHOOK_TRIGGER } from "./constants"; -import type { WebhookTrigger } from "./types"; +import { WebhookReceiver } from "@prisma/client"; const webhookReceivers: Record = { "zapier.com": "zapier", @@ -10,16 +8,6 @@ const webhookReceivers: Record = { "api.segment.io": "segment", }; -export const hasLinkClickTrigger = (webhook: Pick) => { - if (!webhook.triggers) { - return false; - } - - const triggers = webhook.triggers as WebhookTrigger[]; - - return triggers.includes(LINK_CLICK_WEBHOOK_TRIGGER); -}; - export const identifyWebhookReceiver = (url: string): WebhookReceiver => { const { hostname } = new URL(url); diff --git a/apps/web/scripts/create-integration.ts b/apps/web/scripts/create-integration.ts index 68a214cddc4..8caf57529d7 100644 --- a/apps/web/scripts/create-integration.ts +++ b/apps/web/scripts/create-integration.ts @@ -1,20 +1,32 @@ -import { createId } from "@/lib/api/create-id"; import { prisma } from "@/lib/prisma"; import { DUB_WORKSPACE_ID } from "@dub/utils"; +import { GOOGLE_ADS_INTEGRATION_ID } from "@dub/utils/src/constants/integrations"; import "dotenv-flow/config"; async function main() { - const integration = await prisma.integration.create({ - data: { - id: createId({ prefix: "int_" }), - name: "Intercom", - slug: "intercom", - description: "Intercom integration", + const integration = await prisma.integration.upsert({ + where: { + id: GOOGLE_ADS_INTEGRATION_ID, + }, + create: { + id: GOOGLE_ADS_INTEGRATION_ID, + name: "Google Ads", + slug: "google-ads", + description: + "Upload offline click conversions to Google Ads to optimize ad performance.", developer: "Dub", - website: "https://dub.co", + website: "https://ads.google.com", verified: true, projectId: DUB_WORKSPACE_ID, - category: "Support", + category: "Analytics", + }, + update: { + name: "Google Ads", + slug: "google-ads", + description: + "Upload offline click conversions to Google Ads to optimize ad performance.", + verified: true, + category: "Analytics", }, }); diff --git a/packages/utils/src/constants/integrations.ts b/packages/utils/src/constants/integrations.ts index e9f7537cfde..6d9ca30be12 100644 --- a/packages/utils/src/constants/integrations.ts +++ b/packages/utils/src/constants/integrations.ts @@ -6,3 +6,4 @@ export const SHOPIFY_INTEGRATION_ID = "int_iWOtrZgmcyU6XDwKr4AYYqLN"; export const HUBSPOT_INTEGRATION_ID = "int_ffw3qgrFAahY6qs1hXaH3wHS"; export const APPSFLYER_INTEGRATION_ID = "int_1KN8JP7ET3VQQRF7ZQEVNFPJ5"; export const INTERCOM_INTEGRATION_ID = "int_1KV6R1E61E0044C0VFQKV2Q6K"; +export const GOOGLE_ADS_INTEGRATION_ID = "int_G7oOgLeAdsUpld01"; From e6a49bfdd57070dcfadc528ae565f885803a4f4a Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 9 Jul 2026 15:18:33 +0530 Subject: [PATCH 02/18] Harden Google Ads settings and API access with plan and permission checks. --- .../(ee)/api/gad/conversion-actions/route.ts | 96 ++++++++++--------- apps/web/lib/integrations/google-ads/oauth.ts | 6 +- .../google-ads/update-google-ads-settings.ts | 60 +++++++++++- .../google-ads/upload-conversion.ts | 5 + 4 files changed, 117 insertions(+), 50 deletions(-) diff --git a/apps/web/app/(ee)/api/gad/conversion-actions/route.ts b/apps/web/app/(ee)/api/gad/conversion-actions/route.ts index 3a728141a15..1540bf9fd73 100644 --- a/apps/web/app/(ee)/api/gad/conversion-actions/route.ts +++ b/apps/web/app/(ee)/api/gad/conversion-actions/route.ts @@ -12,52 +12,58 @@ import { NextResponse } from "next/server"; import * as z from "zod/v4"; // GET /api/gad/conversion-actions - List UPLOAD_CLICKS conversion actions for a customer -export const GET = withWorkspace(async ({ workspace, searchParams }) => { - const { customerId } = z - .object({ - customerId: z.string().min(1), - }) - .parse(searchParams); - - const installedIntegration = await prisma.installedIntegration.findFirst({ - where: { - integrationId: GOOGLE_ADS_INTEGRATION_ID, - projectId: workspace.id, - }, - }); - - if (!installedIntegration) { - throw new DubApiError({ - code: "bad_request", - message: "Google Ads integration is not installed on your workspace.", +export const GET = withWorkspace( + async ({ workspace, searchParams }) => { + const { customerId } = z + .object({ + customerId: z.string().min(1), + }) + .parse(searchParams); + + const installedIntegration = await prisma.installedIntegration.findFirst({ + where: { + integrationId: GOOGLE_ADS_INTEGRATION_ID, + projectId: workspace.id, + }, }); - } - const token = - await googleAdsOAuthProvider.refreshTokenForInstallation( - installedIntegration, + if (!installedIntegration) { + throw new DubApiError({ + code: "bad_request", + message: "Google Ads integration is not installed on your workspace.", + }); + } + + const token = + await googleAdsOAuthProvider.refreshTokenForInstallation( + installedIntegration, + ); + + const currentSettings = googleAdsSettingsSchema.parse( + installedIntegration.settings ?? {}, ); - const currentSettings = googleAdsSettingsSchema.parse( - installedIntegration.settings ?? {}, - ); - - const loginCustomerId = inferLoginCustomerId({ - customers: currentSettings.customers, - selectedCustomerId: customerId, - }); - - const googleAdsApi = new GoogleAdsApi({ - accessToken: token.access_token, - loginCustomerId, - customerId, - }); - - const conversionActions = - await googleAdsApi.listUploadClickConversionActions(customerId); - - return NextResponse.json({ - conversionActions, - loginCustomerId, - }); -}); + const loginCustomerId = inferLoginCustomerId({ + customers: currentSettings.customers, + selectedCustomerId: customerId, + }); + + const googleAdsApi = new GoogleAdsApi({ + accessToken: token.access_token, + loginCustomerId, + customerId, + }); + + const conversionActions = + await googleAdsApi.listUploadClickConversionActions(customerId); + + return NextResponse.json({ + conversionActions, + loginCustomerId, + }); + }, + { + requiredPermissions: ["integrations.write"], + requiredPlan: ["advanced", "enterprise"], + }, +); diff --git a/apps/web/lib/integrations/google-ads/oauth.ts b/apps/web/lib/integrations/google-ads/oauth.ts index 96f13ed0e35..5941fa31075 100644 --- a/apps/web/lib/integrations/google-ads/oauth.ts +++ b/apps/web/lib/integrations/google-ads/oauth.ts @@ -1,6 +1,6 @@ import { decrypt, encrypt } from "@/lib/encryption"; import { prisma } from "@/lib/prisma"; -import { nanoid } from "@dub/utils"; +import { APP_DOMAIN_WITH_NGROK, nanoid } from "@dub/utils"; import { InstalledIntegration } from "@prisma/client"; import * as z from "zod/v4"; import { redis } from "../../upstash"; @@ -38,7 +38,7 @@ class GoogleAdsOAuthProvider extends OAuthProvider< } async refreshTokenForInstallation( - installation: InstalledIntegration, + installation: Pick, ): Promise> { let existingCredentials = googleAdsAuthTokenSchema.parse( installation.credentials, @@ -134,7 +134,7 @@ export const googleAdsOAuthProvider = new GoogleAdsOAuthProvider({ clientSecret: process.env.GOOGLE_ADS_CLIENT_SECRET!, authUrl: "https://accounts.google.com/o/oauth2/v2/auth", tokenUrl: "https://oauth2.googleapis.com/token", - redirectUri: `http://localhost:8888/api/gad/callback`, // change this before merging + redirectUri: `${APP_DOMAIN_WITH_NGROK}/api/gad/callback`, redisStatePrefix: "google-ads:oauth:state", tokenSchema: googleAdsAuthTokenSchema, bodyFormat: "form", 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 fe2649f61dd..ec3544105bd 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 @@ -1,11 +1,13 @@ "use server"; import { authActionClient } from "@/lib/actions/safe-action"; +import { throwIfNoPermission } from "@/lib/actions/throw-if-no-permission"; +import { getPlanCapabilities } from "@/lib/plan-capabilities"; import { prisma } from "@/lib/prisma"; import { GOOGLE_ADS_INTEGRATION_ID } from "@dub/utils"; import { revalidatePath } from "next/cache"; import * as z from "zod/v4"; -import { inferLoginCustomerId } from "./api"; +import { findGoogleAdsCustomer, inferLoginCustomerId } from "./api"; import { googleAdsSettingsSchema } from "./schema"; const schema = googleAdsSettingsSchema.omit({ customers: true }).extend({ @@ -24,6 +26,17 @@ export const updateGoogleAdsSettingsAction = authActionClient saleConversionAction, } = parsedInput; + throwIfNoPermission({ + role: workspace.role, + requiredPermissions: ["integrations.write"], + }); + + if (!getPlanCapabilities(workspace.plan).canInstallAdvancedIntegrations) { + throw new Error( + "Google Ads integration is only available on Advanced and Enterprise plans.", + ); + } + const installedIntegration = await prisma.installedIntegration.findFirst({ where: { integrationId: GOOGLE_ADS_INTEGRATION_ID, @@ -41,15 +54,58 @@ export const updateGoogleAdsSettingsAction = authActionClient installedIntegration.settings ?? {}, ); + if (customerId) { + const selectedCustomer = findGoogleAdsCustomer({ + customers: currentSettings.customers, + customerId, + }); + + if (!selectedCustomer) { + throw new Error( + "The selected Google Ads account is not available for this workspace. Please reconnect the integration.", + ); + } + } + let resolvedLoginCustomerId = loginCustomerId ?? null; - if (!resolvedLoginCustomerId && customerId) { + if (resolvedLoginCustomerId) { + const loginCustomer = findGoogleAdsCustomer({ + customers: currentSettings.customers, + customerId: resolvedLoginCustomerId, + }); + + if (!loginCustomer) { + throw new Error( + "The selected Google Ads login account is not available for this workspace. Please reconnect the integration.", + ); + } + } else if (customerId) { resolvedLoginCustomerId = inferLoginCustomerId({ customers: currentSettings.customers, selectedCustomerId: customerId, }); } + if (customerId) { + const normalizedCustomerId = customerId.replace(/-/g, ""); + const expectedPrefix = `customers/${normalizedCustomerId}/conversionActions/`; + + if ( + leadConversionAction && + !leadConversionAction.startsWith(expectedPrefix) + ) { + throw new Error("Invalid lead conversion action."); + } + + if ( + saleConversionAction && + !saleConversionAction.startsWith(expectedPrefix) + ) { + throw new Error("Invalid sale conversion action."); + } + } + await prisma.installedIntegration.update({ where: { id: installedIntegration.id, diff --git a/apps/web/lib/integrations/google-ads/upload-conversion.ts b/apps/web/lib/integrations/google-ads/upload-conversion.ts index f2c0cdd566b..ea114dbf1ab 100644 --- a/apps/web/lib/integrations/google-ads/upload-conversion.ts +++ b/apps/web/lib/integrations/google-ads/upload-conversion.ts @@ -45,6 +45,11 @@ export const uploadGoogleAdsConversion = async ( integrationId: GOOGLE_ADS_INTEGRATION_ID, projectId: workspaceId, }, + select: { + id: true, + settings: true, + credentials: true, + }, }); if (!installedIntegration) { From 1d411c55554055ee55a31aab23ef6ece29972613 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 9 Jul 2026 17:02:41 +0530 Subject: [PATCH 03/18] Upload Google Ads conversions via the Data Manager API. --- .../(ee)/api/gad/conversion-actions/route.ts | 4 +- apps/web/lib/integrations/google-ads/api.ts | 118 +++++++++++++----- .../lib/integrations/google-ads/constants.ts | 5 +- apps/web/lib/integrations/google-ads/oauth.ts | 2 +- .../google-ads/update-google-ads-settings.ts | 22 ++-- .../google-ads/upload-conversion.ts | 60 ++++----- 6 files changed, 128 insertions(+), 83 deletions(-) diff --git a/apps/web/app/(ee)/api/gad/conversion-actions/route.ts b/apps/web/app/(ee)/api/gad/conversion-actions/route.ts index 1540bf9fd73..b6589983e4d 100644 --- a/apps/web/app/(ee)/api/gad/conversion-actions/route.ts +++ b/apps/web/app/(ee)/api/gad/conversion-actions/route.ts @@ -35,9 +35,7 @@ export const GET = withWorkspace( } const token = - await googleAdsOAuthProvider.refreshTokenForInstallation( - installedIntegration, - ); + await googleAdsOAuthProvider.getAccessToken(installedIntegration); const currentSettings = googleAdsSettingsSchema.parse( installedIntegration.settings ?? {}, diff --git a/apps/web/lib/integrations/google-ads/api.ts b/apps/web/lib/integrations/google-ads/api.ts index f9b61439515..df666a3a724 100644 --- a/apps/web/lib/integrations/google-ads/api.ts +++ b/apps/web/lib/integrations/google-ads/api.ts @@ -9,16 +9,15 @@ import { googleAdsCustomerSchema, } from "./schema"; -export type GoogleAdsClickIds = { - gclid?: string; - gbraid?: string; - wbraid?: string; -}; +export type GoogleAdsClickId = + | { gclid: string } + | { gbraid: string } + | { wbraid: string }; type UploadClickConversionParams = { customerId: string; conversionAction: string; - clickIds: GoogleAdsClickIds; + googleClickId: GoogleAdsClickId; } & Pick< z.infer, "conversionDateTime" | "eventId" | "conversionValue" | "currencyCode" @@ -123,6 +122,48 @@ const googleAdsFetch = async ({ return data as T; }; +const dataManagerFetch = async ({ + accessToken, + path, + body, +}: { + accessToken: string; + path: string; + body: unknown; +}): Promise => { + const response = await fetch(`https://datamanager.googleapis.com/v1/${path}`, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + const text = await response.text(); + let data: any; + + try { + data = text ? JSON.parse(text) : null; + } catch { + console.error("[Data Manager API]", path, text); + + throw new Error( + `[Data Manager API] Request failed for ${path} (${response.status}). Please try again.`, + ); + } + + if (!response.ok) { + console.error("[Data Manager API]", path, data); + + throw new Error( + `[Data Manager API] Request failed for ${path} (${response.status}). Please try again.`, + ); + } + + return data as T; +}; + const searchStream = async ({ customerId, query, @@ -287,42 +328,61 @@ export class GoogleAdsApi { ); } + // Uploads an offline click conversion via the Data Manager API. + // New integrations cannot use ConversionUploadService.UploadClickConversions. async uploadClickConversion({ customerId, conversionAction, - clickIds, + googleClickId, conversionDateTime, conversionValue, currencyCode, eventId, }: UploadClickConversionParams) { const normalizedCustomerId = customerId.replace(/-/g, ""); + const conversionActionId = conversionAction.includes("/") + ? conversionAction.split("/").pop()! + : conversionAction; + + const destination: Record = { + operatingAccount: { + accountType: "GOOGLE_ADS", + accountId: normalizedCustomerId, + }, + productDestinationId: conversionActionId, + }; + + if (this.options.loginCustomerId) { + destination.loginAccount = { + accountType: "GOOGLE_ADS", + accountId: this.options.loginCustomerId.replace(/-/g, ""), + }; + } - const conversion: Record = { - conversionAction, - conversionDateTime, - orderId: eventId, + const event: Record = { + eventTimestamp: formatGoogleAdsEventTimestamp(conversionDateTime), + transactionId: eventId, + eventSource: "WEB", + adIdentifiers: googleClickId, consent: { - adUserData: "GRANTED", + adUserData: "CONSENT_GRANTED", }, - ...clickIds, }; if (conversionValue !== undefined) { - conversion.conversionValue = conversionValue; + event.conversionValue = conversionValue; } if (currencyCode) { - conversion.currencyCode = currencyCode.toUpperCase(); + event.currency = currencyCode.toUpperCase(); } - return googleAdsFetch({ - ...this.options, - path: `customers/${normalizedCustomerId}:uploadClickConversions`, - method: "POST", + return dataManagerFetch({ + accessToken: this.options.accessToken, + path: "events:ingest", body: { - conversions: [conversion], - partialFailure: true, + destinations: [destination], + events: [event], }, }); } @@ -358,18 +418,8 @@ export const inferLoginCustomerId = ({ return null; }; -// Formats a date as `yyyy-MM-dd HH:mm:ss+00:00` for Google Ads conversion uploads. -export const formatGoogleAdsConversionDateTime = (input: string | Date) => { +// Formats a date as RFC 3339 for Data Manager API event uploads. +export const formatGoogleAdsEventTimestamp = (input: string | Date) => { const date = typeof input === "string" ? new Date(input) : input; - - const pad = (value: number) => value.toString().padStart(2, "0"); - - const year = date.getUTCFullYear(); - const month = pad(date.getUTCMonth() + 1); - const day = pad(date.getUTCDate()); - const hours = pad(date.getUTCHours()); - const minutes = pad(date.getUTCMinutes()); - const seconds = pad(date.getUTCSeconds()); - - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}+00:00`; + return date.toISOString(); }; diff --git a/apps/web/lib/integrations/google-ads/constants.ts b/apps/web/lib/integrations/google-ads/constants.ts index 6fa5b0f5ca7..9ba2cbaa268 100644 --- a/apps/web/lib/integrations/google-ads/constants.ts +++ b/apps/web/lib/integrations/google-ads/constants.ts @@ -7,6 +7,9 @@ export const GOOGLE_ADS_DEFAULT_SETTINGS = { saleConversionAction: null, } as const; -export const GOOGLE_ADS_OAUTH_SCOPE = "https://www.googleapis.com/auth/adwords"; +export const GOOGLE_ADS_OAUTH_SCOPE = [ + "https://www.googleapis.com/auth/adwords", + "https://www.googleapis.com/auth/datamanager", +].join(" "); export const GOOGLE_ADS_API_VERSION = "v22"; diff --git a/apps/web/lib/integrations/google-ads/oauth.ts b/apps/web/lib/integrations/google-ads/oauth.ts index 5941fa31075..1a9c5735d4e 100644 --- a/apps/web/lib/integrations/google-ads/oauth.ts +++ b/apps/web/lib/integrations/google-ads/oauth.ts @@ -37,7 +37,7 @@ class GoogleAdsOAuthProvider extends OAuthProvider< return `${this.config.authUrl}?${searchParams.toString()}`; } - async refreshTokenForInstallation( + async getAccessToken( installation: Pick, ): Promise> { let existingCredentials = googleAdsAuthTokenSchema.parse( 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 ec3544105bd..6fc36768db9 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 @@ -7,7 +7,7 @@ import { prisma } from "@/lib/prisma"; import { GOOGLE_ADS_INTEGRATION_ID } from "@dub/utils"; import { revalidatePath } from "next/cache"; import * as z from "zod/v4"; -import { findGoogleAdsCustomer, inferLoginCustomerId } from "./api"; +import { inferLoginCustomerId } from "./api"; import { googleAdsSettingsSchema } from "./schema"; const schema = googleAdsSettingsSchema.omit({ customers: true }).extend({ @@ -55,10 +55,10 @@ export const updateGoogleAdsSettingsAction = authActionClient ); if (customerId) { - const selectedCustomer = findGoogleAdsCustomer({ - customers: currentSettings.customers, - customerId, - }); + const normalizedCustomerId = customerId.replace(/-/g, ""); + const selectedCustomer = currentSettings.customers.find( + (customer) => customer.id.replace(/-/g, "") === normalizedCustomerId, + ); if (!selectedCustomer) { throw new Error( @@ -70,10 +70,14 @@ export const updateGoogleAdsSettingsAction = authActionClient let resolvedLoginCustomerId = loginCustomerId ?? null; if (resolvedLoginCustomerId) { - const loginCustomer = findGoogleAdsCustomer({ - customers: currentSettings.customers, - customerId: resolvedLoginCustomerId, - }); + const normalizedLoginCustomerId = resolvedLoginCustomerId.replace( + /-/g, + "", + ); + const loginCustomer = currentSettings.customers.find( + (customer) => + customer.id.replace(/-/g, "") === normalizedLoginCustomerId, + ); if (!loginCustomer) { throw new Error( diff --git a/apps/web/lib/integrations/google-ads/upload-conversion.ts b/apps/web/lib/integrations/google-ads/upload-conversion.ts index ea114dbf1ab..1bf8b446ab4 100644 --- a/apps/web/lib/integrations/google-ads/upload-conversion.ts +++ b/apps/web/lib/integrations/google-ads/upload-conversion.ts @@ -2,29 +2,35 @@ import { prisma } from "@/lib/prisma"; import { getClickEvent } from "@/lib/tinybird"; import { getSearchParams, GOOGLE_ADS_INTEGRATION_ID } from "@dub/utils"; import * as z from "zod/v4"; -import { - formatGoogleAdsConversionDateTime, - GoogleAdsApi, - GoogleAdsClickIds, -} from "./api"; +import { GoogleAdsApi, GoogleAdsClickId } from "./api"; import { googleAdsOAuthProvider } from "./oauth"; import { googleAdsConversionUploadSchema, googleAdsSettingsSchema, } from "./schema"; -const extractGoogleAdsClickIds = (url: string): GoogleAdsClickIds => { - if (!url) { - return {}; +const extractGoogleAdsClickId = (url: string): GoogleAdsClickId | null => { + const queryParams = getSearchParams(url); + + if (queryParams.gclid) { + return { + gclid: queryParams.gclid, + }; } - const queryParams = getSearchParams(url); + if (queryParams.gbraid) { + return { + gbraid: queryParams.gbraid, + }; + } + + if (queryParams.wbraid) { + return { + wbraid: queryParams.wbraid, + }; + } - return { - ...(queryParams.gclid ? { gclid: queryParams.gclid } : {}), - ...(queryParams.gbraid ? { gbraid: queryParams.gbraid } : {}), - ...(queryParams.wbraid ? { wbraid: queryParams.wbraid } : {}), - }; + return null; }; export const uploadGoogleAdsConversion = async ( @@ -84,13 +90,9 @@ export const uploadGoogleAdsConversion = async ( return; } - const clickIds = extractGoogleAdsClickIds(clickEvent.url); + const googleClickId = extractGoogleAdsClickId(clickEvent.url); - const hasGoogleAdsClickId = Boolean( - clickIds.gclid || clickIds.gbraid || clickIds.wbraid, - ); - - if (!hasGoogleAdsClickId) { + if (!googleClickId) { console.warn( `[Google Ads] Skipping ${eventType} conversion upload for workspace ${workspaceId}: no gclid/gbraid/wbraid found on click ${clickId}`, ); @@ -98,9 +100,7 @@ export const uploadGoogleAdsConversion = async ( } const token = - await googleAdsOAuthProvider.refreshTokenForInstallation( - installedIntegration, - ); + await googleAdsOAuthProvider.getAccessToken(installedIntegration); let loginCustomerId: string | null = null; @@ -120,8 +120,8 @@ export const uploadGoogleAdsConversion = async ( const response = await googleAdsApi.uploadClickConversion({ customerId: settings.customerId, conversionAction, - clickIds, - conversionDateTime: formatGoogleAdsConversionDateTime(conversionDateTime), + googleClickId, + conversionDateTime, conversionValue, currencyCode, eventId, @@ -129,15 +129,5 @@ export const uploadGoogleAdsConversion = async ( console.log("uploadClickConversion response", response); - const partialFailureError = (response as any)?.partialFailureError; - - if (partialFailureError?.message) { - console.error( - `[Google Ads] Partial failure uploading ${eventType} conversion for workspace ${workspaceId}:`, - partialFailureError, - ); - throw new Error(partialFailureError.message); - } - return response; }; From 42833c45442d9025232f930d2b37c37e05fbe04b Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 9 Jul 2026 17:03:37 +0530 Subject: [PATCH 04/18] Update api.ts --- apps/web/lib/integrations/google-ads/api.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/lib/integrations/google-ads/api.ts b/apps/web/lib/integrations/google-ads/api.ts index df666a3a724..5ad7b8dca7f 100644 --- a/apps/web/lib/integrations/google-ads/api.ts +++ b/apps/web/lib/integrations/google-ads/api.ts @@ -317,7 +317,12 @@ export class GoogleAdsApi { const conversionActions = results .map((result) => result.conversionAction) - .filter((conversionAction) => conversionAction != null); + .filter( + ( + conversionAction, + ): conversionAction is NonNullable => + conversionAction != null, + ); return conversionActions.map((conversionAction) => googleAdsConversionActionSchema.parse({ From b4f012c2b077992f53687d64305dea9a9d32e994 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Thu, 9 Jul 2026 22:45:14 +0530 Subject: [PATCH 05/18] Limit Google Ads integration and conversion uploads to Dub workspace --- .../actions/get-integration-install-url.ts | 7 ++++ apps/web/lib/api/conversions/track-lead.ts | 2 +- apps/web/lib/api/conversions/track-sale.ts | 2 +- apps/web/lib/integrations/google-ads/api.ts | 37 ------------------- .../lib/integrations/google-ads/constants.ts | 6 +++ .../google-ads/upload-conversion.ts | 29 ++++++++++++++- apps/web/lib/integrations/google-ads/utils.ts | 5 +++ 7 files changed, 48 insertions(+), 40 deletions(-) create mode 100644 apps/web/lib/integrations/google-ads/utils.ts diff --git a/apps/web/lib/actions/get-integration-install-url.ts b/apps/web/lib/actions/get-integration-install-url.ts index 38c768a307e..096a625dd3d 100644 --- a/apps/web/lib/actions/get-integration-install-url.ts +++ b/apps/web/lib/actions/get-integration-install-url.ts @@ -1,6 +1,7 @@ "use server"; import * as z from "zod/v4"; +import { isGoogleAdsAllowedWorkspace } from "../integrations/google-ads/utils"; import { googleAdsOAuthProvider } from "../integrations/google-ads/oauth"; import { hubSpotOAuthProvider } from "../integrations/hubspot/oauth"; import { intercomOAuthProvider } from "../integrations/intercom/oauth"; @@ -34,6 +35,12 @@ export const getIntegrationInstallUrl = authActionClient } else if (integrationSlug === "intercom") { url = await intercomOAuthProvider.generateAuthUrl(workspace.id); } else if (integrationSlug === "google-ads") { + if (!isGoogleAdsAllowedWorkspace(workspace.id)) { + throw new Error( + "Google Ads integration is not available for this workspace", + ); + } + url = await googleAdsOAuthProvider.generateAuthUrl(workspace.id); } else { throw new Error("Invalid integration slug"); diff --git a/apps/web/lib/api/conversions/track-lead.ts b/apps/web/lib/api/conversions/track-lead.ts index ccd6db5a419..13577c6feaa 100644 --- a/apps/web/lib/api/conversions/track-lead.ts +++ b/apps/web/lib/api/conversions/track-lead.ts @@ -1,7 +1,7 @@ import { createId } from "@/lib/api/create-id"; import { DubApiError } from "@/lib/api/errors"; import { includeTags } from "@/lib/api/links/include-tags"; -import { queueGoogleAdsConversionUpload } from "@/lib/integrations/google-ads/api"; +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"; diff --git a/apps/web/lib/api/conversions/track-sale.ts b/apps/web/lib/api/conversions/track-sale.ts index 596395059d2..27671628c7c 100644 --- a/apps/web/lib/api/conversions/track-sale.ts +++ b/apps/web/lib/api/conversions/track-sale.ts @@ -2,7 +2,7 @@ import { convertCurrency } from "@/lib/analytics/convert-currency"; import { isFirstConversion } from "@/lib/analytics/is-first-conversion"; import { DubApiError } from "@/lib/api/errors"; import { includeTags } from "@/lib/api/links/include-tags"; -import { queueGoogleAdsConversionUpload } from "@/lib/integrations/google-ads/api"; +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"; diff --git a/apps/web/lib/integrations/google-ads/api.ts b/apps/web/lib/integrations/google-ads/api.ts index 5ad7b8dca7f..3e59bfba2d5 100644 --- a/apps/web/lib/integrations/google-ads/api.ts +++ b/apps/web/lib/integrations/google-ads/api.ts @@ -1,6 +1,3 @@ -import { qstash } from "@/lib/cron"; -import { prisma } from "@/lib/prisma"; -import { APP_DOMAIN_WITH_NGROK, GOOGLE_ADS_INTEGRATION_ID } from "@dub/utils"; import * as z from "zod/v4"; import { GOOGLE_ADS_API_VERSION } from "./constants"; import { @@ -28,40 +25,6 @@ type GoogleAdsRequestOptions = { loginCustomerId?: string | null; }; -export const queueGoogleAdsConversionUpload = async ( - payload: z.infer, -) => { - // TODO: - // How to optimize this call? - - const installedIntegration = await prisma.installedIntegration.findFirst({ - where: { - integrationId: GOOGLE_ADS_INTEGRATION_ID, - projectId: payload.workspaceId, - }, - select: { - id: true, - }, - }); - - if (!installedIntegration) { - return; - } - - const response = await qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/gad/upload-conversion`, - body: payload, - retries: 3, - deduplicationId: `google-ads-${payload.workspaceId}-${payload.eventId}`, - }); - - if (!response.messageId) { - throw new Error("Failed to queue Google Ads conversion upload"); - } - - return response; -}; - const getGoogleAdsHeaders = ({ accessToken, loginCustomerId, diff --git a/apps/web/lib/integrations/google-ads/constants.ts b/apps/web/lib/integrations/google-ads/constants.ts index 9ba2cbaa268..c706ab9006a 100644 --- a/apps/web/lib/integrations/google-ads/constants.ts +++ b/apps/web/lib/integrations/google-ads/constants.ts @@ -1,3 +1,5 @@ +import { DUB_WORKSPACE_ID } from "@dub/utils"; + export const GOOGLE_ADS_DEFAULT_SETTINGS = { customers: [], customerId: null, @@ -13,3 +15,7 @@ export const GOOGLE_ADS_OAUTH_SCOPE = [ ].join(" "); export const GOOGLE_ADS_API_VERSION = "v22"; + +export const GOOGLE_ADS_ALLOWED_WORKSPACE_IDS = new Set([ + DUB_WORKSPACE_ID, +]); diff --git a/apps/web/lib/integrations/google-ads/upload-conversion.ts b/apps/web/lib/integrations/google-ads/upload-conversion.ts index 1bf8b446ab4..01e9d304473 100644 --- a/apps/web/lib/integrations/google-ads/upload-conversion.ts +++ b/apps/web/lib/integrations/google-ads/upload-conversion.ts @@ -1,14 +1,41 @@ +import { qstash } from "@/lib/cron"; import { prisma } from "@/lib/prisma"; import { getClickEvent } from "@/lib/tinybird"; -import { getSearchParams, GOOGLE_ADS_INTEGRATION_ID } from "@dub/utils"; +import { + APP_DOMAIN_WITH_NGROK, + getSearchParams, + GOOGLE_ADS_INTEGRATION_ID, +} from "@dub/utils"; import * as z from "zod/v4"; import { GoogleAdsApi, GoogleAdsClickId } from "./api"; +import { isGoogleAdsAllowedWorkspace } from "./utils"; import { googleAdsOAuthProvider } from "./oauth"; import { googleAdsConversionUploadSchema, googleAdsSettingsSchema, } from "./schema"; +export const queueGoogleAdsConversionUpload = async ( + payload: z.infer, +) => { + if (!isGoogleAdsAllowedWorkspace(payload.workspaceId)) { + return; + } + + const response = await qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/gad/upload-conversion`, + body: payload, + retries: 3, + deduplicationId: `google-ads-${payload.workspaceId}-${payload.eventId}`, + }); + + if (!response.messageId) { + throw new Error("Failed to queue Google Ads conversion upload"); + } + + return response; +}; + const extractGoogleAdsClickId = (url: string): GoogleAdsClickId | null => { const queryParams = getSearchParams(url); diff --git a/apps/web/lib/integrations/google-ads/utils.ts b/apps/web/lib/integrations/google-ads/utils.ts new file mode 100644 index 00000000000..5c85b99cdd6 --- /dev/null +++ b/apps/web/lib/integrations/google-ads/utils.ts @@ -0,0 +1,5 @@ +import { normalizeWorkspaceId } from "@/lib/api/workspaces/workspace-id"; +import { GOOGLE_ADS_ALLOWED_WORKSPACE_IDS } from "./constants"; + +export const isGoogleAdsAllowedWorkspace = (workspaceId: string) => + GOOGLE_ADS_ALLOWED_WORKSPACE_IDS.has(normalizeWorkspaceId(workspaceId)); From d6e6b246d3dddf3ea44c5f9c868e75e07191b5d8 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 15 Jul 2026 15:26:59 +0530 Subject: [PATCH 06/18] Add getErrorMetadata helper and improve Google Ads OAuth auth errors --- apps/web/app/(ee)/api/gad/callback/route.ts | 14 +++++++------- .../app/(ee)/api/gad/conversion-actions/route.ts | 13 +++++++++++++ .../lib/api/rewards/queue-reward-processing.ts | 5 ++--- apps/web/lib/axiom/server.ts | 16 ++++++++++++++++ apps/web/lib/cron/qstash-workflow.ts | 5 ++--- .../attribute-referring-partner.ts | 5 ++--- .../redis-streams/workspace-click-events.ts | 5 ++--- 7 files changed, 44 insertions(+), 19 deletions(-) diff --git a/apps/web/app/(ee)/api/gad/callback/route.ts b/apps/web/app/(ee)/api/gad/callback/route.ts index 19da99d3cab..e1305c3bbda 100644 --- a/apps/web/app/(ee)/api/gad/callback/route.ts +++ b/apps/web/app/(ee)/api/gad/callback/route.ts @@ -20,15 +20,8 @@ export const dynamic = "force-dynamic"; // GET /api/gad/callback - OAuth callback from Google Ads export const GET = async (req: Request) => { - const { searchParams } = new URL(req.url); - const session = await getSession(); - if (!session?.user.id) { - const callbackPath = `/api/gad/callback?${searchParams.toString()}`; - redirect(`/login?next=${encodeURIComponent(callbackPath)}`); - } - const integration = await prisma.integration.findFirstOrThrow({ where: { id: GOOGLE_ADS_INTEGRATION_ID, @@ -42,6 +35,13 @@ export const GET = async (req: Request) => { let errorMessage: string | null = null; try { + if (!session?.user.id) { + throw new DubApiError({ + code: "unauthorized", + message: "Unauthorized. Please login to continue.", + }); + } + const { token, contextId: workspaceId } = await googleAdsOAuthProvider.exchangeCodeForToken(req); diff --git a/apps/web/app/(ee)/api/gad/conversion-actions/route.ts b/apps/web/app/(ee)/api/gad/conversion-actions/route.ts index b6589983e4d..58346ff45ab 100644 --- a/apps/web/app/(ee)/api/gad/conversion-actions/route.ts +++ b/apps/web/app/(ee)/api/gad/conversion-actions/route.ts @@ -41,6 +41,19 @@ export const GET = withWorkspace( installedIntegration.settings ?? {}, ); + const normalizedCustomerId = customerId.replace(/-/g, ""); + const selectedCustomer = currentSettings.customers.find( + (customer) => customer.id.replace(/-/g, "") === normalizedCustomerId, + ); + + if (!selectedCustomer) { + throw new DubApiError({ + code: "bad_request", + message: + "The selected Google Ads account is not available for this workspace. Please reconnect the integration.", + }); + } + const loginCustomerId = inferLoginCustomerId({ customers: currentSettings.customers, selectedCustomerId: customerId, diff --git a/apps/web/lib/api/rewards/queue-reward-processing.ts b/apps/web/lib/api/rewards/queue-reward-processing.ts index f6d3bb1aae8..eef30f47e4a 100644 --- a/apps/web/lib/api/rewards/queue-reward-processing.ts +++ b/apps/web/lib/api/rewards/queue-reward-processing.ts @@ -1,4 +1,4 @@ -import { logger } from "@/lib/axiom/server"; +import { getErrorMetadata, logger } from "@/lib/axiom/server"; import { qstash } from "@/lib/cron"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { EventType } from "@prisma/client"; @@ -63,8 +63,7 @@ export async function queueRewardProcessing(params: RewardJob) { service: "qstash", event: "publishJSON.failed", url: `/api/cron/rewards/process`, - errorName: error instanceof Error ? error.name : undefined, - errorStack: error instanceof Error ? error.stack : undefined, + ...getErrorMetadata(error), correlation: { event: params.event, groupId: params.groupId, diff --git a/apps/web/lib/axiom/server.ts b/apps/web/lib/axiom/server.ts index 520a3fd979f..82f676a2eef 100644 --- a/apps/web/lib/axiom/server.ts +++ b/apps/web/lib/axiom/server.ts @@ -61,3 +61,19 @@ export const withAxiomBodyLog = createAxiomRouteHandler(logger, { }); export const withAxiom = createAxiomRouteHandler(logger); + +export const getErrorMetadata = (error: unknown) => { + if (error instanceof Error) { + return { + errorName: error.name, + errorMessage: error.message, + errorStack: error.stack, + }; + } + + return { + errorName: undefined, + errorMessage: String(error), + errorStack: undefined, + }; +}; diff --git a/apps/web/lib/cron/qstash-workflow.ts b/apps/web/lib/cron/qstash-workflow.ts index 911ca419894..9db95748bf9 100644 --- a/apps/web/lib/cron/qstash-workflow.ts +++ b/apps/web/lib/cron/qstash-workflow.ts @@ -1,4 +1,4 @@ -import { logger } from "@/lib/axiom/server"; +import { getErrorMetadata, logger } from "@/lib/axiom/server"; import { APP_DOMAIN_WITH_NGROK, pluralize } from "@dub/utils"; import { FlowControl } from "@upstash/qstash"; import { Client } from "@upstash/workflow"; @@ -62,8 +62,7 @@ export async function triggerQStashWorkflow( service: "qstash", event: "workflow.trigger_failed", workflowType: workflow.workflowType, - errorName: error instanceof Error ? error.name : undefined, - errorStack: error instanceof Error ? error.stack : undefined, + ...getErrorMetadata(error), correlation, }); } diff --git a/apps/web/lib/partner-referrals/attribute-referring-partner.ts b/apps/web/lib/partner-referrals/attribute-referring-partner.ts index 42aba8428b6..0430cc8f4f1 100644 --- a/apps/web/lib/partner-referrals/attribute-referring-partner.ts +++ b/apps/web/lib/partner-referrals/attribute-referring-partner.ts @@ -9,7 +9,7 @@ import { subMinutes } from "date-fns"; import { authActionClient } from "../actions/safe-action"; import { throwIfNoPermission } from "../actions/throw-if-no-permission"; import { createId } from "../api/create-id"; -import { logger } from "../axiom/server"; +import { getErrorMetadata, logger } from "../axiom/server"; import { qstash } from "../cron"; import { attributeReferringPartnerSchema } from "./schemas"; @@ -153,8 +153,7 @@ export const attributeReferringPartnerAction = authActionClient service: "qstash", event: "publishJSON.failed", url: `/api/cron/commissions/referrals/backfill`, - errorName: error instanceof Error ? error.name : undefined, - errorStack: error instanceof Error ? error.stack : undefined, + ...getErrorMetadata(error), correlation: { programId, partnerId, diff --git a/apps/web/lib/upstash/redis-streams/workspace-click-events.ts b/apps/web/lib/upstash/redis-streams/workspace-click-events.ts index 2868bd98470..d41341cabee 100644 --- a/apps/web/lib/upstash/redis-streams/workspace-click-events.ts +++ b/apps/web/lib/upstash/redis-streams/workspace-click-events.ts @@ -1,4 +1,4 @@ -import { logger } from "@/lib/axiom/server"; +import { getErrorMetadata, logger } from "@/lib/axiom/server"; import { clickWebhookWorkspaces } from "@/lib/webhook/click-webhook-workspaces"; import { clickEventSchemaTB } from "@/lib/zod/schemas/clicks"; import { redis } from "../redis"; @@ -29,8 +29,7 @@ export const publishWorkspaceClickEvent = async (event) => { logger.error("stream.publish_failed", { service: "upstash", streamKey: STREAM_KEY, - errorName: error instanceof Error ? error.name : undefined, - errorStack: error instanceof Error ? error.stack : undefined, + ...getErrorMetadata(error), correlation: { workspaceId: event.workspace_id, clickId: event.click_id, From 983c417cdd874e131178a4338b23aae797e4010e Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 15 Jul 2026 15:28:07 +0530 Subject: [PATCH 07/18] Improve Google Ads login customer handling and conversion upload reliability --- .../integrations/google-ads/ui/settings.tsx | 12 +- .../google-ads/update-google-ads-settings.ts | 30 +-- .../google-ads/upload-conversion.ts | 234 +++++++++++------- 3 files changed, 156 insertions(+), 120 deletions(-) diff --git a/apps/web/lib/integrations/google-ads/ui/settings.tsx b/apps/web/lib/integrations/google-ads/ui/settings.tsx index 93352454d27..b8faad18c16 100644 --- a/apps/web/lib/integrations/google-ads/ui/settings.tsx +++ b/apps/web/lib/integrations/google-ads/ui/settings.tsx @@ -94,10 +94,15 @@ export const GoogleAdsSettings = ({ }, [conversionActionsError]); useEffect(() => { - if (conversionActionsData?.loginCustomerId) { - setValue("loginCustomerId", conversionActionsData.loginCustomerId); + if (!conversionActionsData) { + return; } - }, [conversionActionsData?.loginCustomerId, setValue]); + + setValue( + "loginCustomerId", + conversionActionsData.loginCustomerId ?? "", + ); + }, [conversionActionsData, setValue]); const conversionActionOptions = useMemo( () => @@ -194,6 +199,7 @@ export const GoogleAdsSettings = ({ setValue("customerId", option.value); setValue("customerName", option.label); + setValue("loginCustomerId", ""); setValue("leadConversionAction", ""); setValue("saleConversionAction", ""); }} 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 6fc36768db9..315b3e2e1bd 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 @@ -20,7 +20,6 @@ export const updateGoogleAdsSettingsAction = authActionClient const { workspace } = ctx; const { customerId, - loginCustomerId, customerName, leadConversionAction, saleConversionAction, @@ -67,29 +66,12 @@ export const updateGoogleAdsSettingsAction = authActionClient } } - let resolvedLoginCustomerId = loginCustomerId ?? null; - - if (resolvedLoginCustomerId) { - const normalizedLoginCustomerId = resolvedLoginCustomerId.replace( - /-/g, - "", - ); - const loginCustomer = currentSettings.customers.find( - (customer) => - customer.id.replace(/-/g, "") === normalizedLoginCustomerId, - ); - - if (!loginCustomer) { - throw new Error( - "The selected Google Ads login account is not available for this workspace. Please reconnect the integration.", - ); - } - } else if (customerId) { - resolvedLoginCustomerId = inferLoginCustomerId({ - customers: currentSettings.customers, - selectedCustomerId: customerId, - }); - } + const resolvedLoginCustomerId = customerId + ? inferLoginCustomerId({ + customers: currentSettings.customers, + selectedCustomerId: customerId, + }) + : null; if (customerId) { const normalizedCustomerId = customerId.replace(/-/g, ""); diff --git a/apps/web/lib/integrations/google-ads/upload-conversion.ts b/apps/web/lib/integrations/google-ads/upload-conversion.ts index 01e9d304473..22b8877d9e1 100644 --- a/apps/web/lib/integrations/google-ads/upload-conversion.ts +++ b/apps/web/lib/integrations/google-ads/upload-conversion.ts @@ -1,3 +1,4 @@ +import { getErrorMetadata, logger } from "@/lib/axiom/server"; import { qstash } from "@/lib/cron"; import { prisma } from "@/lib/prisma"; import { getClickEvent } from "@/lib/tinybird"; @@ -8,12 +9,12 @@ import { } from "@dub/utils"; import * as z from "zod/v4"; import { GoogleAdsApi, GoogleAdsClickId } from "./api"; -import { isGoogleAdsAllowedWorkspace } from "./utils"; import { googleAdsOAuthProvider } from "./oauth"; import { googleAdsConversionUploadSchema, googleAdsSettingsSchema, } from "./schema"; +import { isGoogleAdsAllowedWorkspace } from "./utils"; export const queueGoogleAdsConversionUpload = async ( payload: z.infer, @@ -22,18 +23,34 @@ export const queueGoogleAdsConversionUpload = async ( return; } - const response = await qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/gad/upload-conversion`, - body: payload, - retries: 3, - deduplicationId: `google-ads-${payload.workspaceId}-${payload.eventId}`, - }); - - if (!response.messageId) { - throw new Error("Failed to queue Google Ads conversion upload"); + try { + const response = await qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/gad/upload-conversion`, + body: payload, + retries: 3, + deduplicationId: `google-ads-${payload.workspaceId}-${payload.eventId}`, + }); + + if (!response.messageId) { + throw new Error("Failed to queue Google Ads conversion upload"); + } + + return response; + } catch (error) { + logger.error("google-ads.queue_conversion_failed", { + service: "google-ads", + ...getErrorMetadata(error), + correlation: { + workspaceId: payload.workspaceId, + eventId: payload.eventId, + eventType: payload.eventType, + clickId: payload.clickId, + }, + }); + + await logger.flush(); + throw error; } - - return response; }; const extractGoogleAdsClickId = (url: string): GoogleAdsClickId | null => { @@ -73,88 +90,119 @@ export const uploadGoogleAdsConversion = async ( currencyCode, } = googleAdsConversionUploadSchema.parse(payload); - const installedIntegration = await prisma.installedIntegration.findFirst({ - where: { - integrationId: GOOGLE_ADS_INTEGRATION_ID, - projectId: workspaceId, - }, - select: { - id: true, - settings: true, - credentials: true, - }, - }); - - if (!installedIntegration) { - console.warn( - `[Google Ads] Skipping ${eventType} conversion upload: Google Ads integration not installed for workspace ${workspaceId}`, - ); - return; - } - - const settings = googleAdsSettingsSchema.parse( - installedIntegration.settings ?? {}, - ); - - const conversionAction = - eventType === "lead" - ? settings.leadConversionAction - : settings.saleConversionAction; - - if (!settings.customerId || !conversionAction) { - console.warn( - `[Google Ads] Skipping ${eventType} conversion upload for workspace ${workspaceId}: missing ${!settings.customerId ? "customerId" : `${eventType}ConversionAction`}`, - ); - return; - } - - const clickEvent = await getClickEvent({ clickId }); - - if (!clickEvent?.url) { - console.warn( - `[Google Ads] Skipping ${eventType} conversion upload for workspace ${workspaceId}: no click event URL found for clickId ${clickId}`, - ); - return; - } - - const googleClickId = extractGoogleAdsClickId(clickEvent.url); - - if (!googleClickId) { - console.warn( - `[Google Ads] Skipping ${eventType} conversion upload for workspace ${workspaceId}: no gclid/gbraid/wbraid found on click ${clickId}`, + try { + const installedIntegration = await prisma.installedIntegration.findFirst({ + where: { + integrationId: GOOGLE_ADS_INTEGRATION_ID, + projectId: workspaceId, + }, + select: { + id: true, + settings: true, + credentials: true, + }, + }); + + if (!installedIntegration) { + console.warn( + `[Google Ads] Skipping ${eventType} conversion upload: Google Ads integration not installed for workspace ${workspaceId}`, + ); + return; + } + + const settings = googleAdsSettingsSchema.parse( + installedIntegration.settings ?? {}, ); - return; - } - - const token = - await googleAdsOAuthProvider.getAccessToken(installedIntegration); - - let loginCustomerId: string | null = null; - if ( - settings.loginCustomerId && - settings.loginCustomerId !== settings.customerId - ) { - loginCustomerId = settings.loginCustomerId; + const conversionAction = + eventType === "lead" + ? settings.leadConversionAction + : settings.saleConversionAction; + + if (!settings.customerId || !conversionAction) { + console.warn( + `[Google Ads] Skipping ${eventType} conversion upload for workspace ${workspaceId}: missing ${!settings.customerId ? "customerId" : `${eventType}ConversionAction`}`, + ); + return; + } + + const clickEvent = await getClickEvent({ clickId }); + + if (!clickEvent?.url) { + console.warn( + `[Google Ads] Skipping ${eventType} conversion upload for workspace ${workspaceId}: no click event URL found for clickId ${clickId}`, + ); + return; + } + + const googleClickId = extractGoogleAdsClickId(clickEvent.url); + + if (!googleClickId) { + console.warn( + `[Google Ads] Skipping ${eventType} conversion upload for workspace ${workspaceId}: no gclid/gbraid/wbraid found on click ${clickId}`, + ); + return; + } + + const token = + await googleAdsOAuthProvider.getAccessToken(installedIntegration); + + let loginCustomerId: string | null = null; + + if ( + settings.loginCustomerId && + settings.loginCustomerId !== settings.customerId + ) { + loginCustomerId = settings.loginCustomerId; + } + + const googleAdsApi = new GoogleAdsApi({ + accessToken: token.access_token, + loginCustomerId, + customerId: settings.customerId, + }); + + const maxRetries = 3; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const response = await googleAdsApi.uploadClickConversion({ + customerId: settings.customerId, + conversionAction, + googleClickId, + conversionDateTime, + conversionValue, + currencyCode, + eventId, + }); + + console.log("uploadClickConversion response", response); + + return response; + } catch (error) { + if (attempt < maxRetries) { + await new Promise((resolve) => + setTimeout(resolve, 1000 * Math.pow(2, attempt)), + ); + continue; + } + + throw error; + } + } + } catch (error) { + logger.error("google-ads.upload_conversion_failed", { + service: "google-ads", + ...getErrorMetadata(error), + correlation: { + workspaceId, + eventId, + eventType, + clickId, + }, + }); + + await logger.flush(); + throw error; } - - const googleAdsApi = new GoogleAdsApi({ - accessToken: token.access_token, - loginCustomerId, - customerId: settings.customerId, - }); - - const response = await googleAdsApi.uploadClickConversion({ - customerId: settings.customerId, - conversionAction, - googleClickId, - conversionDateTime, - conversionValue, - currencyCode, - eventId, - }); - - console.log("uploadClickConversion response", response); - - return response; }; From 3e46399f11ed2c3f295f45dbf0cd75fa747bde67 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 15 Jul 2026 15:38:31 +0530 Subject: [PATCH 08/18] Pass conversion count to Google Ads and require an account for conversion actions. --- apps/web/lib/api/conversions/track-lead.ts | 1 + apps/web/lib/integrations/google-ads/api.ts | 28 +++++++++++++------ .../web/lib/integrations/google-ads/schema.ts | 1 + .../google-ads/update-google-ads-settings.ts | 6 ++++ .../google-ads/upload-conversion.ts | 2 ++ 5 files changed, 30 insertions(+), 8 deletions(-) diff --git a/apps/web/lib/api/conversions/track-lead.ts b/apps/web/lib/api/conversions/track-lead.ts index 29c4b485ff6..f7a9eb811b8 100644 --- a/apps/web/lib/api/conversions/track-lead.ts +++ b/apps/web/lib/api/conversions/track-lead.ts @@ -359,6 +359,7 @@ export const trackLead = async ({ clickId, eventId: leadEventId, conversionDateTime: `${clickData.timestamp}Z`, + conversionCount: eventQuantity ?? undefined, }), ...(link.partnerId diff --git a/apps/web/lib/integrations/google-ads/api.ts b/apps/web/lib/integrations/google-ads/api.ts index 3e59bfba2d5..17268415fbe 100644 --- a/apps/web/lib/integrations/google-ads/api.ts +++ b/apps/web/lib/integrations/google-ads/api.ts @@ -17,7 +17,11 @@ type UploadClickConversionParams = { googleClickId: GoogleAdsClickId; } & Pick< z.infer, - "conversionDateTime" | "eventId" | "conversionValue" | "currencyCode" + | "conversionDateTime" + | "eventId" + | "conversionValue" + | "currencyCode" + | "conversionCount" >; type GoogleAdsRequestOptions = { @@ -94,14 +98,17 @@ const dataManagerFetch = async ({ path: string; body: unknown; }): Promise => { - const response = await fetch(`https://datamanager.googleapis.com/v1/${path}`, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", + const response = await fetch( + `https://datamanager.googleapis.com/v1/${path}`, + { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), }, - body: JSON.stringify(body), - }); + ); const text = await response.text(); let data: any; @@ -305,6 +312,7 @@ export class GoogleAdsApi { conversionDateTime, conversionValue, currencyCode, + conversionCount, eventId, }: UploadClickConversionParams) { const normalizedCustomerId = customerId.replace(/-/g, ""); @@ -345,6 +353,10 @@ export class GoogleAdsApi { event.currency = currencyCode.toUpperCase(); } + if (conversionCount !== undefined) { + event.conversionCount = conversionCount; + } + return dataManagerFetch({ accessToken: this.options.accessToken, path: "events:ingest", diff --git a/apps/web/lib/integrations/google-ads/schema.ts b/apps/web/lib/integrations/google-ads/schema.ts index 41fdeb49934..299fd61636a 100644 --- a/apps/web/lib/integrations/google-ads/schema.ts +++ b/apps/web/lib/integrations/google-ads/schema.ts @@ -39,4 +39,5 @@ export const googleAdsConversionUploadSchema = z.object({ eventId: z.string(), conversionValue: z.number().optional(), currencyCode: z.string().optional(), + conversionCount: z.number().positive().optional(), }); 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 315b3e2e1bd..417f090ccd0 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 @@ -73,6 +73,12 @@ export const updateGoogleAdsSettingsAction = authActionClient }) : null; + if (!customerId && (leadConversionAction || saleConversionAction)) { + throw new Error( + "A Google Ads account is required to configure conversion actions.", + ); + } + if (customerId) { const normalizedCustomerId = customerId.replace(/-/g, ""); const expectedPrefix = `customers/${normalizedCustomerId}/conversionActions/`; diff --git a/apps/web/lib/integrations/google-ads/upload-conversion.ts b/apps/web/lib/integrations/google-ads/upload-conversion.ts index 22b8877d9e1..f374eda04d0 100644 --- a/apps/web/lib/integrations/google-ads/upload-conversion.ts +++ b/apps/web/lib/integrations/google-ads/upload-conversion.ts @@ -88,6 +88,7 @@ export const uploadGoogleAdsConversion = async ( eventId, conversionValue, currencyCode, + conversionCount, } = googleAdsConversionUploadSchema.parse(payload); try { @@ -173,6 +174,7 @@ export const uploadGoogleAdsConversion = async ( conversionDateTime, conversionValue, currencyCode, + conversionCount, eventId, }); From 4fb5fe0d4b52b565a3640d5939b895a93b055346 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 15 Jul 2026 15:46:18 +0530 Subject: [PATCH 09/18] Fix Google Ads conversion values and serialize OAuth token refresh. --- apps/web/lib/api/conversions/track-lead.ts | 2 +- apps/web/lib/api/conversions/track-sale.ts | 3 +- apps/web/lib/integrations/google-ads/oauth.ts | 103 +++++++++++++++++- 3 files changed, 100 insertions(+), 8 deletions(-) diff --git a/apps/web/lib/api/conversions/track-lead.ts b/apps/web/lib/api/conversions/track-lead.ts index f7a9eb811b8..2344b912ad4 100644 --- a/apps/web/lib/api/conversions/track-lead.ts +++ b/apps/web/lib/api/conversions/track-lead.ts @@ -358,7 +358,7 @@ export const trackLead = async ({ eventType: EventType.lead, clickId, eventId: leadEventId, - conversionDateTime: `${clickData.timestamp}Z`, + conversionDateTime: new Date().toISOString(), conversionCount: eventQuantity ?? undefined, }), diff --git a/apps/web/lib/api/conversions/track-sale.ts b/apps/web/lib/api/conversions/track-sale.ts index 30ded5503cd..e487bc30207 100644 --- a/apps/web/lib/api/conversions/track-sale.ts +++ b/apps/web/lib/api/conversions/track-sale.ts @@ -627,7 +627,8 @@ const _trackSale = async ({ clickId: leadEventData.click_id, conversionDateTime: new Date().toISOString(), eventId: invoiceId || saleData.event_id, - conversionValue: amount, + // Dub stores USD cents; Data Manager expects major currency units + conversionValue: amount / 100, currencyCode: currency, }), diff --git a/apps/web/lib/integrations/google-ads/oauth.ts b/apps/web/lib/integrations/google-ads/oauth.ts index 1a9c5735d4e..386e6d6feab 100644 --- a/apps/web/lib/integrations/google-ads/oauth.ts +++ b/apps/web/lib/integrations/google-ads/oauth.ts @@ -40,15 +40,87 @@ class GoogleAdsOAuthProvider extends OAuthProvider< async getAccessToken( installation: Pick, ): Promise> { - let existingCredentials = googleAdsAuthTokenSchema.parse( + const existingCredentials = this.decryptCredentials( installation.credentials, ); - existingCredentials = { - ...existingCredentials, - access_token: decrypt(existingCredentials.access_token), - refresh_token: decrypt(existingCredentials.refresh_token), + if (this.isTokenValid(existingCredentials)) { + return existingCredentials; + } + + if (!existingCredentials.refresh_token) { + throw new Error( + "[Google Ads] Missing refresh token. Please reconnect the integration.", + ); + } + + const lockKey = `googleAds:oauth:refresh:${installation.id}`; + + for (let attempt = 0; attempt < 2; attempt++) { + const refreshed = await this.withRefreshLock(lockKey, () => + this.refreshCredentialsUnderLock(installation.id), + ); + + if (refreshed) { + return refreshed; + } + + const waited = await this.waitForRefreshedCredentials(installation.id); + + if (waited) { + return waited; + } + } + + throw new Error( + "[Google Ads] Failed to refresh the access token. Please try again.", + ); + } + + private async withRefreshLock( + lockKey: string, + fn: () => Promise, + ): Promise { + const acquired = await redis.set(lockKey, "1", { nx: true, ex: 20 }); + + if (!acquired) { + return null; + } + + try { + return await fn(); + } finally { + await redis.del(lockKey); + } + } + + private decryptCredentials( + credentials: InstalledIntegration["credentials"], + ): z.infer { + const parsed = googleAdsAuthTokenSchema.parse(credentials); + + return { + ...parsed, + access_token: decrypt(parsed.access_token), + refresh_token: decrypt(parsed.refresh_token), }; + } + + private async loadCredentials(installationId: string) { + const installation = await prisma.installedIntegration.findUniqueOrThrow({ + where: { + id: installationId, + }, + select: { + credentials: true, + }, + }); + + return this.decryptCredentials(installation.credentials); + } + + private async refreshCredentialsUnderLock(installationId: string) { + const existingCredentials = await this.loadCredentials(installationId); if (this.isTokenValid(existingCredentials)) { return existingCredentials; @@ -71,7 +143,7 @@ class GoogleAdsOAuthProvider extends OAuthProvider< await prisma.installedIntegration.update({ where: { - id: installation.id, + id: installationId, }, data: { credentials: googleAdsAuthTokenSchema.parse({ @@ -85,6 +157,25 @@ class GoogleAdsOAuthProvider extends OAuthProvider< return newCredentials; } + private async waitForRefreshedCredentials(installationId: string) { + const pollIntervalMs = 200; + const timeoutMs = 5_000; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const delay = pollIntervalMs + Math.floor(Math.random() * pollIntervalMs); + await new Promise((resolve) => setTimeout(resolve, delay)); + + const credentials = await this.loadCredentials(installationId); + + if (this.isTokenValid(credentials)) { + return credentials; + } + } + + return null; + } + private async fetchRefreshedToken(refreshToken: string) { const response = await fetch(this.config.tokenUrl, { method: "POST", From 46efd8d11dab03db897551e3e9a1a8bf6435a2b0 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 15 Jul 2026 16:20:27 +0530 Subject: [PATCH 10/18] Track Google Ads installed workspaces in Redis for conversion gating. --- .../gad/sync-installed-workspaces/route.ts | 16 +++++ apps/web/app/(ee)/api/gad/callback/route.ts | 68 ++++--------------- .../app/api/integrations/uninstall/route.ts | 6 +- .../[integrationSlug]/page-client.tsx | 18 +---- .../google-ads/installed-workspaces.ts | 55 +++++++++++++++ .../google-ads/upload-conversion.ts | 4 +- apps/web/vercel.json | 4 ++ 7 files changed, 97 insertions(+), 74 deletions(-) create mode 100644 apps/web/app/(ee)/api/cron/gad/sync-installed-workspaces/route.ts create mode 100644 apps/web/lib/integrations/google-ads/installed-workspaces.ts diff --git a/apps/web/app/(ee)/api/cron/gad/sync-installed-workspaces/route.ts b/apps/web/app/(ee)/api/cron/gad/sync-installed-workspaces/route.ts new file mode 100644 index 00000000000..737dcef5606 --- /dev/null +++ b/apps/web/app/(ee)/api/cron/gad/sync-installed-workspaces/route.ts @@ -0,0 +1,16 @@ +import { withCron } from "@/lib/cron/with-cron"; +import { syncGoogleAdsInstalledWorkspaceSet } from "@/lib/integrations/google-ads/installed-workspaces"; +import { logAndRespond } from "../../utils"; + +export const dynamic = "force-dynamic"; + +// GET /api/cron/gad/sync-installed-workspaces +// Rebuild the Redis set of workspaces with Google Ads installed. +// Runs every minute (* * * * *) +export const GET = withCron(async () => { + const synced = await syncGoogleAdsInstalledWorkspaceSet(); + + return logAndRespond( + `Synced ${synced} workspace(s) with Google Ads installed.`, + ); +}); diff --git a/apps/web/app/(ee)/api/gad/callback/route.ts b/apps/web/app/(ee)/api/gad/callback/route.ts index e1305c3bbda..f29a172432b 100644 --- a/apps/web/app/(ee)/api/gad/callback/route.ts +++ b/apps/web/app/(ee)/api/gad/callback/route.ts @@ -1,10 +1,11 @@ -import { DubApiError } from "@/lib/api/errors"; +import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors"; import { getSession } from "@/lib/auth"; import { encrypt } from "@/lib/encryption"; import { GoogleAdsApi, inferLoginCustomerId, } from "@/lib/integrations/google-ads/api"; +import { googleAdsInstalledWorkspaces } from "@/lib/integrations/google-ads/installed-workspaces"; import { googleAdsOAuthProvider } from "@/lib/integrations/google-ads/oauth"; import { googleAdsAuthTokenSchema, @@ -13,28 +14,22 @@ import { import { installIntegration } from "@/lib/integrations/install"; import { getPlanCapabilities } from "@/lib/plan-capabilities"; import { prisma } from "@/lib/prisma"; +import { WorkspaceProps } from "@/lib/types"; import { GOOGLE_ADS_INTEGRATION_ID } from "@dub/utils"; +import { waitUntil } from "@vercel/functions"; import { redirect } from "next/navigation"; export const dynamic = "force-dynamic"; // GET /api/gad/callback - OAuth callback from Google Ads export const GET = async (req: Request) => { - const session = await getSession(); - - const integration = await prisma.integration.findFirstOrThrow({ - where: { - id: GOOGLE_ADS_INTEGRATION_ID, - }, - select: { - slug: true, - }, - }); - - let workspaceSlug: string | null = null; - let errorMessage: string | null = null; + let workspace: + | (Pick & { plan: string }) + | null = null; try { + const session = await getSession(); + if (!session?.user.id) { throw new DubApiError({ code: "unauthorized", @@ -45,7 +40,7 @@ export const GET = async (req: Request) => { const { token, contextId: workspaceId } = await googleAdsOAuthProvider.exchangeCodeForToken(req); - const workspace = await prisma.project.findUniqueOrThrow({ + workspace = await prisma.project.findUniqueOrThrow({ where: { id: workspaceId, }, @@ -65,8 +60,6 @@ export const GET = async (req: Request) => { }, }); - workspaceSlug = workspace.slug; - if (workspace.users.length === 0) { throw new DubApiError({ code: "bad_request", @@ -130,44 +123,11 @@ export const GET = async (req: Request) => { credentials, settings, }); - } catch (error) { - errorMessage = - error instanceof DubApiError || error instanceof Error - ? error.message - : "Failed to connect Google Ads. Please try again."; - } - if (!workspaceSlug) { - redirect( - `/login?error=${encodeURIComponent(errorMessage || "Failed to connect Google Ads. Please try again.")}`, - ); - } - - redirectToIntegrationPage({ - workspaceSlug, - integrationSlug: integration.slug, - error: errorMessage ?? undefined, - }); -}; - -const redirectToIntegrationPage = ({ - workspaceSlug, - integrationSlug, - error, -}: { - workspaceSlug: string; - integrationSlug: string; - error?: string; -}) => { - const params = new URLSearchParams(); - - if (error) { - params.set("error", error); + waitUntil(googleAdsInstalledWorkspaces.add(workspaceId)); + } catch (error) { + return handleAndReturnErrorResponse(error); } - const query = params.toString(); - - redirect( - `/${workspaceSlug}/settings/integrations/${integrationSlug}${query ? `?${query}` : ""}`, - ); + redirect(`/${workspace.slug}/settings/integrations/google-ads`); }; diff --git a/apps/web/app/api/integrations/uninstall/route.ts b/apps/web/app/api/integrations/uninstall/route.ts index e83903676cb..8fb4c89e344 100644 --- a/apps/web/app/api/integrations/uninstall/route.ts +++ b/apps/web/app/api/integrations/uninstall/route.ts @@ -1,8 +1,9 @@ import { DubApiError } from "@/lib/api/errors"; import { withWorkspace } from "@/lib/auth"; +import { googleAdsInstalledWorkspaces } from "@/lib/integrations/google-ads/installed-workspaces"; import { slackOAuthProvider } from "@/lib/integrations/slack/oauth"; import { prisma } from "@/lib/prisma"; -import { SLACK_INTEGRATION_ID } from "@dub/utils"; +import { GOOGLE_ADS_INTEGRATION_ID, SLACK_INTEGRATION_ID } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; @@ -54,6 +55,9 @@ export const DELETE = withWorkspace( ...(integrationId === SLACK_INTEGRATION_ID ? [slackOAuthProvider.uninstall(installation)] : []), + ...(integrationId === GOOGLE_ADS_INTEGRATION_ID + ? [googleAdsInstalledWorkspaces.remove(workspace.id)] + : []), ]), ); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page-client.tsx index d1b505228cd..f08462e6c1f 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/integrations/[integrationSlug]/page-client.tsx @@ -61,8 +61,7 @@ import { } from "@dub/utils/src/constants/integrations"; import { useAction } from "next-safe-action/hooks"; import Link from "next/link"; -import { useSearchParams } from "next/navigation"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { toast } from "sonner"; const integrationSettings = { @@ -81,24 +80,9 @@ export default function IntegrationPageClient({ integration: InstalledIntegrationInfoProps; }) { const { id: workspaceId, slug, plan, role, stripeConnectId } = useWorkspace(); - const searchParams = useSearchParams(); const { isMobile } = useMediaQuery(); const [openPopover, setOpenPopover] = useState(false); - useEffect(() => { - const error = searchParams?.get("error"); - - if (!error) { - return; - } - - toast.error(error); - - const url = new URL(window.location.href); - url.searchParams.delete("error"); - window.history.replaceState({}, "", url.toString()); - }, [searchParams]); - const permissionsError = clientAccessCheck({ action: "integrations.write", role, diff --git a/apps/web/lib/integrations/google-ads/installed-workspaces.ts b/apps/web/lib/integrations/google-ads/installed-workspaces.ts new file mode 100644 index 00000000000..94c71a036b0 --- /dev/null +++ b/apps/web/lib/integrations/google-ads/installed-workspaces.ts @@ -0,0 +1,55 @@ +import { prisma } from "@/lib/prisma"; +import { redis } from "@/lib/upstash"; +import { GOOGLE_ADS_INTEGRATION_ID } from "@dub/utils"; + +const REDIS_KEY = "googleAdsInstalledWorkspaces"; +const TMP_REDIS_KEY = `${REDIS_KEY}:tmp`; + +class GoogleAdsInstalledWorkspaces { + async add(workspaceId: string) { + return await redis.sadd(REDIS_KEY, workspaceId); + } + + async remove(workspaceId: string) { + return await redis.srem(REDIS_KEY, workspaceId); + } + + async has(workspaceId: string) { + return await redis.sismember(REDIS_KEY, workspaceId); + } +} + +export const googleAdsInstalledWorkspaces = new GoogleAdsInstalledWorkspaces(); + +// Rebuild the Redis set of workspaces with Google Ads installed +export const syncGoogleAdsInstalledWorkspaceSet = async () => { + const installations = await prisma.installedIntegration.findMany({ + where: { + integrationId: GOOGLE_ADS_INTEGRATION_ID, + project: { + plan: { + in: ["advanced", "enterprise"], + }, + }, + }, + select: { + projectId: true, + }, + distinct: ["projectId"], + }); + + const workspaceIds = installations.map( + (installation) => installation.projectId, + ); + + if (workspaceIds.length === 0) { + await redis.del(REDIS_KEY); + return 0; + } + + await redis.del(TMP_REDIS_KEY); + await redis.sadd(TMP_REDIS_KEY, ...(workspaceIds as [string, ...string[]])); + await redis.rename(TMP_REDIS_KEY, REDIS_KEY); + + return workspaceIds.length; +}; diff --git a/apps/web/lib/integrations/google-ads/upload-conversion.ts b/apps/web/lib/integrations/google-ads/upload-conversion.ts index f374eda04d0..0dd7a2e8e24 100644 --- a/apps/web/lib/integrations/google-ads/upload-conversion.ts +++ b/apps/web/lib/integrations/google-ads/upload-conversion.ts @@ -9,17 +9,17 @@ import { } from "@dub/utils"; import * as z from "zod/v4"; import { GoogleAdsApi, GoogleAdsClickId } from "./api"; +import { googleAdsInstalledWorkspaces } from "./installed-workspaces"; import { googleAdsOAuthProvider } from "./oauth"; import { googleAdsConversionUploadSchema, googleAdsSettingsSchema, } from "./schema"; -import { isGoogleAdsAllowedWorkspace } from "./utils"; export const queueGoogleAdsConversionUpload = async ( payload: z.infer, ) => { - if (!isGoogleAdsAllowedWorkspace(payload.workspaceId)) { + if (!(await googleAdsInstalledWorkspaces.has(payload.workspaceId))) { return; } diff --git a/apps/web/vercel.json b/apps/web/vercel.json index ab9891b7170..df3616b4be2 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -83,6 +83,10 @@ { "path": "/api/cron/webhooks/sync-click-workspaces", "schedule": "*/5 * * * *" + }, + { + "path": "/api/cron/gad/sync-installed-workspaces", + "schedule": "* * * * *" } ], "functions": { From 285996cfc8cad83213ade1bc012075b4957fe045 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 15 Jul 2026 16:31:36 +0530 Subject: [PATCH 11/18] Rename Google Ads API routes from /gad to /google-ads. --- .../cron/{gad => google-ads}/sync-installed-workspaces/route.ts | 2 +- apps/web/app/(ee)/api/{gad => google-ads}/callback/route.ts | 2 +- .../(ee)/api/{gad => google-ads}/conversion-actions/route.ts | 2 +- .../app/(ee)/api/{gad => google-ads}/upload-conversion/route.ts | 2 +- apps/web/lib/integrations/google-ads/api.ts | 2 +- apps/web/lib/integrations/google-ads/oauth.ts | 2 +- apps/web/lib/integrations/google-ads/ui/settings.tsx | 2 +- apps/web/lib/integrations/google-ads/upload-conversion.ts | 2 +- apps/web/vercel.json | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) rename apps/web/app/(ee)/api/cron/{gad => google-ads}/sync-installed-workspaces/route.ts (90%) rename apps/web/app/(ee)/api/{gad => google-ads}/callback/route.ts (98%) rename apps/web/app/(ee)/api/{gad => google-ads}/conversion-actions/route.ts (95%) rename apps/web/app/(ee)/api/{gad => google-ads}/upload-conversion/route.ts (86%) diff --git a/apps/web/app/(ee)/api/cron/gad/sync-installed-workspaces/route.ts b/apps/web/app/(ee)/api/cron/google-ads/sync-installed-workspaces/route.ts similarity index 90% rename from apps/web/app/(ee)/api/cron/gad/sync-installed-workspaces/route.ts rename to apps/web/app/(ee)/api/cron/google-ads/sync-installed-workspaces/route.ts index 737dcef5606..efc1513e5be 100644 --- a/apps/web/app/(ee)/api/cron/gad/sync-installed-workspaces/route.ts +++ b/apps/web/app/(ee)/api/cron/google-ads/sync-installed-workspaces/route.ts @@ -4,7 +4,7 @@ import { logAndRespond } from "../../utils"; export const dynamic = "force-dynamic"; -// GET /api/cron/gad/sync-installed-workspaces +// GET /api/cron/google-ads/sync-installed-workspaces // Rebuild the Redis set of workspaces with Google Ads installed. // Runs every minute (* * * * *) export const GET = withCron(async () => { diff --git a/apps/web/app/(ee)/api/gad/callback/route.ts b/apps/web/app/(ee)/api/google-ads/callback/route.ts similarity index 98% rename from apps/web/app/(ee)/api/gad/callback/route.ts rename to apps/web/app/(ee)/api/google-ads/callback/route.ts index f29a172432b..2519373f39e 100644 --- a/apps/web/app/(ee)/api/gad/callback/route.ts +++ b/apps/web/app/(ee)/api/google-ads/callback/route.ts @@ -21,7 +21,7 @@ import { redirect } from "next/navigation"; export const dynamic = "force-dynamic"; -// GET /api/gad/callback - OAuth callback from Google Ads +// GET /api/google-ads/callback - OAuth callback from Google Ads export const GET = async (req: Request) => { let workspace: | (Pick & { plan: string }) diff --git a/apps/web/app/(ee)/api/gad/conversion-actions/route.ts b/apps/web/app/(ee)/api/google-ads/conversion-actions/route.ts similarity index 95% rename from apps/web/app/(ee)/api/gad/conversion-actions/route.ts rename to apps/web/app/(ee)/api/google-ads/conversion-actions/route.ts index 58346ff45ab..58e335cf0b6 100644 --- a/apps/web/app/(ee)/api/gad/conversion-actions/route.ts +++ b/apps/web/app/(ee)/api/google-ads/conversion-actions/route.ts @@ -11,7 +11,7 @@ import { GOOGLE_ADS_INTEGRATION_ID } from "@dub/utils"; import { NextResponse } from "next/server"; import * as z from "zod/v4"; -// GET /api/gad/conversion-actions - List UPLOAD_CLICKS conversion actions for a customer +// GET /api/google-ads/conversion-actions - List UPLOAD_CLICKS conversion actions for a customer export const GET = withWorkspace( async ({ workspace, searchParams }) => { const { customerId } = z diff --git a/apps/web/app/(ee)/api/gad/upload-conversion/route.ts b/apps/web/app/(ee)/api/google-ads/upload-conversion/route.ts similarity index 86% rename from apps/web/app/(ee)/api/gad/upload-conversion/route.ts rename to apps/web/app/(ee)/api/google-ads/upload-conversion/route.ts index 21dd7e3782f..7e7d9ebf087 100644 --- a/apps/web/app/(ee)/api/gad/upload-conversion/route.ts +++ b/apps/web/app/(ee)/api/google-ads/upload-conversion/route.ts @@ -4,7 +4,7 @@ import { uploadGoogleAdsConversion } from "@/lib/integrations/google-ads/upload- export const dynamic = "force-dynamic"; -// POST /api/gad/upload-conversion - Upload a conversion to Google Ads +// POST /api/google-ads/upload-conversion - Upload a conversion to Google Ads export const POST = withCron(async ({ rawBody }) => { const payload = googleAdsConversionUploadSchema.parse(JSON.parse(rawBody)); diff --git a/apps/web/lib/integrations/google-ads/api.ts b/apps/web/lib/integrations/google-ads/api.ts index 17268415fbe..3f45de15899 100644 --- a/apps/web/lib/integrations/google-ads/api.ts +++ b/apps/web/lib/integrations/google-ads/api.ts @@ -399,7 +399,7 @@ export const inferLoginCustomerId = ({ }; // Formats a date as RFC 3339 for Data Manager API event uploads. -export const formatGoogleAdsEventTimestamp = (input: string | Date) => { +const formatGoogleAdsEventTimestamp = (input: string | Date) => { const date = typeof input === "string" ? new Date(input) : input; return date.toISOString(); }; diff --git a/apps/web/lib/integrations/google-ads/oauth.ts b/apps/web/lib/integrations/google-ads/oauth.ts index 386e6d6feab..b0cb88a1d49 100644 --- a/apps/web/lib/integrations/google-ads/oauth.ts +++ b/apps/web/lib/integrations/google-ads/oauth.ts @@ -225,7 +225,7 @@ export const googleAdsOAuthProvider = new GoogleAdsOAuthProvider({ clientSecret: process.env.GOOGLE_ADS_CLIENT_SECRET!, authUrl: "https://accounts.google.com/o/oauth2/v2/auth", tokenUrl: "https://oauth2.googleapis.com/token", - redirectUri: `${APP_DOMAIN_WITH_NGROK}/api/gad/callback`, + redirectUri: `${APP_DOMAIN_WITH_NGROK}/api/google-ads/callback`, redisStatePrefix: "google-ads:oauth:state", tokenSchema: googleAdsAuthTokenSchema, bodyFormat: "form", diff --git a/apps/web/lib/integrations/google-ads/ui/settings.tsx b/apps/web/lib/integrations/google-ads/ui/settings.tsx index b8faad18c16..ec84cbb6ba9 100644 --- a/apps/web/lib/integrations/google-ads/ui/settings.tsx +++ b/apps/web/lib/integrations/google-ads/ui/settings.tsx @@ -79,7 +79,7 @@ export const GoogleAdsSettings = ({ error: conversionActionsError, } = useSWR( workspaceId && installed && customerId - ? `/api/gad/conversion-actions?workspaceId=${workspaceId}&customerId=${customerId}` + ? `/api/google-ads/conversion-actions?workspaceId=${workspaceId}&customerId=${customerId}` : null, fetcher, ); diff --git a/apps/web/lib/integrations/google-ads/upload-conversion.ts b/apps/web/lib/integrations/google-ads/upload-conversion.ts index 0dd7a2e8e24..138fe75f164 100644 --- a/apps/web/lib/integrations/google-ads/upload-conversion.ts +++ b/apps/web/lib/integrations/google-ads/upload-conversion.ts @@ -25,7 +25,7 @@ export const queueGoogleAdsConversionUpload = async ( try { const response = await qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/gad/upload-conversion`, + url: `${APP_DOMAIN_WITH_NGROK}/api/google-ads/upload-conversion`, body: payload, retries: 3, deduplicationId: `google-ads-${payload.workspaceId}-${payload.eventId}`, diff --git a/apps/web/vercel.json b/apps/web/vercel.json index df3616b4be2..4371002b06d 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -85,7 +85,7 @@ "schedule": "*/5 * * * *" }, { - "path": "/api/cron/gad/sync-installed-workspaces", + "path": "/api/cron/google-ads/sync-installed-workspaces", "schedule": "* * * * *" } ], From 908b916d5e20c989825fd3bb927bba5629e7fb19 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 15 Jul 2026 17:01:34 +0530 Subject: [PATCH 12/18] Pass click URL into Google Ads conversion uploads to skip Tinybird lookup. --- apps/web/lib/api/conversions/track-lead.ts | 5 +- apps/web/lib/api/conversions/track-sale.ts | 10 +-- .../web/lib/integrations/google-ads/schema.ts | 5 +- .../google-ads/upload-conversion.ts | 72 +++++++++---------- 4 files changed, 47 insertions(+), 45 deletions(-) diff --git a/apps/web/lib/api/conversions/track-lead.ts b/apps/web/lib/api/conversions/track-lead.ts index 2344b912ad4..e095f18f030 100644 --- a/apps/web/lib/api/conversions/track-lead.ts +++ b/apps/web/lib/api/conversions/track-lead.ts @@ -356,10 +356,13 @@ export const trackLead = async ({ queueGoogleAdsConversionUpload({ workspaceId: workspace.id, eventType: EventType.lead, - clickId, eventId: leadEventId, conversionDateTime: new Date().toISOString(), conversionCount: eventQuantity ?? undefined, + click: { + id: clickData.click_id, + url: clickData.url, + }, }), ...(link.partnerId diff --git a/apps/web/lib/api/conversions/track-sale.ts b/apps/web/lib/api/conversions/track-sale.ts index e487bc30207..15a35dc7bed 100644 --- a/apps/web/lib/api/conversions/track-sale.ts +++ b/apps/web/lib/api/conversions/track-sale.ts @@ -624,12 +624,14 @@ const _trackSale = async ({ queueGoogleAdsConversionUpload({ workspaceId: workspace.id, eventType: EventType.sale, - clickId: leadEventData.click_id, conversionDateTime: new Date().toISOString(), - eventId: invoiceId || saleData.event_id, - // Dub stores USD cents; Data Manager expects major currency units - conversionValue: amount / 100, + eventId: saleData.event_id, + conversionValue: amount / 100, // Data Manager expects major currency units currencyCode: currency, + click: { + id: leadEventData.click_id, + url: leadEventData.url, + }, }), ...(link.partnerId diff --git a/apps/web/lib/integrations/google-ads/schema.ts b/apps/web/lib/integrations/google-ads/schema.ts index 299fd61636a..93920941e6c 100644 --- a/apps/web/lib/integrations/google-ads/schema.ts +++ b/apps/web/lib/integrations/google-ads/schema.ts @@ -34,7 +34,10 @@ export const googleAdsConversionActionSchema = z.object({ export const googleAdsConversionUploadSchema = z.object({ workspaceId: z.string(), eventType: z.enum(["lead", "sale"]), - clickId: z.string(), + click: z.object({ + id: z.string(), + url: z.string(), + }), conversionDateTime: z.string(), eventId: z.string(), conversionValue: z.number().optional(), diff --git a/apps/web/lib/integrations/google-ads/upload-conversion.ts b/apps/web/lib/integrations/google-ads/upload-conversion.ts index 138fe75f164..8bea21086e5 100644 --- a/apps/web/lib/integrations/google-ads/upload-conversion.ts +++ b/apps/web/lib/integrations/google-ads/upload-conversion.ts @@ -1,7 +1,6 @@ import { getErrorMetadata, logger } from "@/lib/axiom/server"; import { qstash } from "@/lib/cron"; import { prisma } from "@/lib/prisma"; -import { getClickEvent } from "@/lib/tinybird"; import { APP_DOMAIN_WITH_NGROK, getSearchParams, @@ -16,9 +15,37 @@ import { googleAdsSettingsSchema, } from "./schema"; +const extractGoogleAdsClickId = (url: string): GoogleAdsClickId | null => { + const queryParams = getSearchParams(url); + + if (queryParams.gclid) { + return { + gclid: queryParams.gclid, + }; + } + + if (queryParams.gbraid) { + return { + gbraid: queryParams.gbraid, + }; + } + + if (queryParams.wbraid) { + return { + wbraid: queryParams.wbraid, + }; + } + + return null; +}; + export const queueGoogleAdsConversionUpload = async ( payload: z.infer, ) => { + if (!extractGoogleAdsClickId(payload.click.url)) { + return; + } + if (!(await googleAdsInstalledWorkspaces.has(payload.workspaceId))) { return; } @@ -44,7 +71,7 @@ export const queueGoogleAdsConversionUpload = async ( workspaceId: payload.workspaceId, eventId: payload.eventId, eventType: payload.eventType, - clickId: payload.clickId, + clickId: payload.click.id, }, }); @@ -53,37 +80,13 @@ export const queueGoogleAdsConversionUpload = async ( } }; -const extractGoogleAdsClickId = (url: string): GoogleAdsClickId | null => { - const queryParams = getSearchParams(url); - - if (queryParams.gclid) { - return { - gclid: queryParams.gclid, - }; - } - - if (queryParams.gbraid) { - return { - gbraid: queryParams.gbraid, - }; - } - - if (queryParams.wbraid) { - return { - wbraid: queryParams.wbraid, - }; - } - - return null; -}; - export const uploadGoogleAdsConversion = async ( payload: z.infer, ) => { const { workspaceId, eventType, - clickId, + click, conversionDateTime, eventId, conversionValue, @@ -127,20 +130,11 @@ export const uploadGoogleAdsConversion = async ( return; } - const clickEvent = await getClickEvent({ clickId }); - - if (!clickEvent?.url) { - console.warn( - `[Google Ads] Skipping ${eventType} conversion upload for workspace ${workspaceId}: no click event URL found for clickId ${clickId}`, - ); - return; - } - - const googleClickId = extractGoogleAdsClickId(clickEvent.url); + const googleClickId = extractGoogleAdsClickId(click.url); if (!googleClickId) { console.warn( - `[Google Ads] Skipping ${eventType} conversion upload for workspace ${workspaceId}: no gclid/gbraid/wbraid found on click ${clickId}`, + `[Google Ads] Skipping ${eventType} conversion upload for workspace ${workspaceId}: no gclid/gbraid/wbraid found on click ${click.id}`, ); return; } @@ -200,7 +194,7 @@ export const uploadGoogleAdsConversion = async ( workspaceId, eventId, eventType, - clickId, + clickId: click.id, }, }); From 7db806fc1d8674d4a0d771b4f4d7b6a06a85022f Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 15 Jul 2026 17:09:31 +0530 Subject: [PATCH 13/18] Return Google Ads upload status to QStash and retry only on failure. --- .../api/google-ads/upload-conversion/route.ts | 11 +++- apps/web/lib/integrations/google-ads/api.ts | 14 +++-- .../google-ads/upload-conversion.ts | 51 ++++++++++++------- 3 files changed, 52 insertions(+), 24 deletions(-) diff --git a/apps/web/app/(ee)/api/google-ads/upload-conversion/route.ts b/apps/web/app/(ee)/api/google-ads/upload-conversion/route.ts index 7e7d9ebf087..97532852563 100644 --- a/apps/web/app/(ee)/api/google-ads/upload-conversion/route.ts +++ b/apps/web/app/(ee)/api/google-ads/upload-conversion/route.ts @@ -1,6 +1,7 @@ import { withCron } from "@/lib/cron/with-cron"; import { googleAdsConversionUploadSchema } from "@/lib/integrations/google-ads/schema"; import { uploadGoogleAdsConversion } from "@/lib/integrations/google-ads/upload-conversion"; +import { logAndRespond } from "../../cron/utils"; export const dynamic = "force-dynamic"; @@ -8,7 +9,13 @@ export const dynamic = "force-dynamic"; export const POST = withCron(async ({ rawBody }) => { const payload = googleAdsConversionUploadSchema.parse(JSON.parse(rawBody)); - await uploadGoogleAdsConversion(payload); + const { message, status } = await uploadGoogleAdsConversion(payload); - return new Response("OK"); + if (status === "failed") { + return logAndRespond(message, { status: 500, logLevel: "error" }); + } + + return logAndRespond(message, { + logLevel: status === "skipped" ? "warn" : "info", + }); }); diff --git a/apps/web/lib/integrations/google-ads/api.ts b/apps/web/lib/integrations/google-ads/api.ts index 3f45de15899..b2783d6dfb8 100644 --- a/apps/web/lib/integrations/google-ads/api.ts +++ b/apps/web/lib/integrations/google-ads/api.ts @@ -74,7 +74,7 @@ const googleAdsFetch = async ({ console.error("[Google Ads API]", path, text); throw new Error( - `[Google Ads API] Request failed for ${path} (${response.status}). Please try again.`, + `[Google Ads API] Request failed for ${path} (${response.status}): ${text || "Unknown error"}`, ); } @@ -82,7 +82,7 @@ const googleAdsFetch = async ({ console.error("[Google Ads API]", path, data); throw new Error( - `[Google Ads API] Request failed for ${path} (${response.status}). Please try again.`, + `[Google Ads API] Request failed for ${path} (${response.status}): ${formatApiErrorDetail(data, text)}`, ); } @@ -119,7 +119,7 @@ const dataManagerFetch = async ({ console.error("[Data Manager API]", path, text); throw new Error( - `[Data Manager API] Request failed for ${path} (${response.status}). Please try again.`, + `[Data Manager API] Request failed for ${path} (${response.status}): ${text || "Unknown error"}`, ); } @@ -127,13 +127,17 @@ const dataManagerFetch = async ({ console.error("[Data Manager API]", path, data); throw new Error( - `[Data Manager API] Request failed for ${path} (${response.status}). Please try again.`, + `[Data Manager API] Request failed for ${path} (${response.status}): ${formatApiErrorDetail(data, text)}`, ); } return data as T; }; +const formatApiErrorDetail = (data: any, rawText: string) => { + return data?.error?.message ?? JSON.stringify(data) ?? rawText; +}; + const searchStream = async ({ customerId, query, @@ -357,7 +361,7 @@ export class GoogleAdsApi { event.conversionCount = conversionCount; } - return dataManagerFetch({ + return dataManagerFetch<{ requestId: string }>({ accessToken: this.options.accessToken, path: "events:ingest", body: { diff --git a/apps/web/lib/integrations/google-ads/upload-conversion.ts b/apps/web/lib/integrations/google-ads/upload-conversion.ts index 8bea21086e5..d03f797dc74 100644 --- a/apps/web/lib/integrations/google-ads/upload-conversion.ts +++ b/apps/web/lib/integrations/google-ads/upload-conversion.ts @@ -80,9 +80,14 @@ export const queueGoogleAdsConversionUpload = async ( } }; +export type GoogleAdsConversionUploadResult = { + message: string; + status: "failed" | "skipped" | "uploaded"; +}; + export const uploadGoogleAdsConversion = async ( payload: z.infer, -) => { +): Promise => { const { workspaceId, eventType, @@ -108,10 +113,10 @@ export const uploadGoogleAdsConversion = async ( }); if (!installedIntegration) { - console.warn( - `[Google Ads] Skipping ${eventType} conversion upload: Google Ads integration not installed for workspace ${workspaceId}`, - ); - return; + return { + message: `Google Ads integration not installed for workspace ${workspaceId}. Skipping...`, + status: "skipped", + }; } const settings = googleAdsSettingsSchema.parse( @@ -124,19 +129,19 @@ export const uploadGoogleAdsConversion = async ( : settings.saleConversionAction; if (!settings.customerId || !conversionAction) { - console.warn( - `[Google Ads] Skipping ${eventType} conversion upload for workspace ${workspaceId}: missing ${!settings.customerId ? "customerId" : `${eventType}ConversionAction`}`, - ); - return; + return { + message: `Missing ${!settings.customerId ? "customerId" : `${eventType}ConversionAction`}. Skipping...`, + status: "skipped", + }; } const googleClickId = extractGoogleAdsClickId(click.url); if (!googleClickId) { - console.warn( - `[Google Ads] Skipping ${eventType} conversion upload for workspace ${workspaceId}: no gclid/gbraid/wbraid found on click ${click.id}`, - ); - return; + return { + message: `No gclid/gbraid/wbraid found on click ${click.id}. Skipping...`, + status: "skipped", + }; } const token = @@ -172,9 +177,10 @@ export const uploadGoogleAdsConversion = async ( eventId, }); - console.log("uploadClickConversion response", response); - - return response; + return { + message: `Uploaded ${eventType} conversion for workspace ${workspaceId} (requestId: ${response.requestId})`, + status: "uploaded", + }; } catch (error) { if (attempt < maxRetries) { await new Promise((resolve) => @@ -186,7 +192,14 @@ export const uploadGoogleAdsConversion = async ( throw error; } } + + return { + message: `Failed to upload ${eventType} conversion for workspace ${workspaceId}: unknown error`, + status: "failed", + }; } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error("google-ads.upload_conversion_failed", { service: "google-ads", ...getErrorMetadata(error), @@ -199,6 +212,10 @@ export const uploadGoogleAdsConversion = async ( }); await logger.flush(); - throw error; + + return { + message: `Failed to upload ${eventType} conversion for workspace ${workspaceId}: ${errorMessage}`, + status: "failed", + }; } }; From c94daee20fefb98e84094fb0d0861f8f3aca7ce6 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 15 Jul 2026 17:55:10 +0530 Subject: [PATCH 14/18] Format --- .../actions/get-integration-install-url.ts | 2 +- .../integrations/google-ads/ui/settings.tsx | 5 +-- .../google-ads/upload-conversion.ts | 38 ++++++++++--------- 3 files changed, 23 insertions(+), 22 deletions(-) diff --git a/apps/web/lib/actions/get-integration-install-url.ts b/apps/web/lib/actions/get-integration-install-url.ts index 096a625dd3d..dc2f8021063 100644 --- a/apps/web/lib/actions/get-integration-install-url.ts +++ b/apps/web/lib/actions/get-integration-install-url.ts @@ -1,8 +1,8 @@ "use server"; import * as z from "zod/v4"; -import { isGoogleAdsAllowedWorkspace } from "../integrations/google-ads/utils"; import { googleAdsOAuthProvider } from "../integrations/google-ads/oauth"; +import { isGoogleAdsAllowedWorkspace } from "../integrations/google-ads/utils"; import { hubSpotOAuthProvider } from "../integrations/hubspot/oauth"; import { intercomOAuthProvider } from "../integrations/intercom/oauth"; import { slackOAuthProvider } from "../integrations/slack/oauth"; diff --git a/apps/web/lib/integrations/google-ads/ui/settings.tsx b/apps/web/lib/integrations/google-ads/ui/settings.tsx index ec84cbb6ba9..51d86d3d535 100644 --- a/apps/web/lib/integrations/google-ads/ui/settings.tsx +++ b/apps/web/lib/integrations/google-ads/ui/settings.tsx @@ -98,10 +98,7 @@ export const GoogleAdsSettings = ({ return; } - setValue( - "loginCustomerId", - conversionActionsData.loginCustomerId ?? "", - ); + setValue("loginCustomerId", conversionActionsData.loginCustomerId ?? ""); }, [conversionActionsData, setValue]); const conversionActionOptions = useMemo( diff --git a/apps/web/lib/integrations/google-ads/upload-conversion.ts b/apps/web/lib/integrations/google-ads/upload-conversion.ts index d03f797dc74..746112d75b8 100644 --- a/apps/web/lib/integrations/google-ads/upload-conversion.ts +++ b/apps/web/lib/integrations/google-ads/upload-conversion.ts @@ -16,27 +16,31 @@ import { } from "./schema"; const extractGoogleAdsClickId = (url: string): GoogleAdsClickId | null => { - const queryParams = getSearchParams(url); + try { + const queryParams = getSearchParams(url); - if (queryParams.gclid) { - return { - gclid: queryParams.gclid, - }; - } + if (queryParams.gclid) { + return { + gclid: queryParams.gclid, + }; + } - if (queryParams.gbraid) { - return { - gbraid: queryParams.gbraid, - }; - } + if (queryParams.gbraid) { + return { + gbraid: queryParams.gbraid, + }; + } - if (queryParams.wbraid) { - return { - wbraid: queryParams.wbraid, - }; - } + if (queryParams.wbraid) { + return { + wbraid: queryParams.wbraid, + }; + } - return null; + return null; + } catch { + return null; + } }; export const queueGoogleAdsConversionUpload = async ( From 6143309da80cc81666f868fb8f0519f102067a6f Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Tue, 28 Jul 2026 22:12:47 -0700 Subject: [PATCH 15/18] toErrorFields --- apps/web/lib/api/rewards/queue-reward-processing.ts | 4 ++-- apps/web/lib/cron/qstash-workflow.ts | 4 ++-- apps/web/lib/integrations/google-ads/upload-conversion.ts | 6 +++--- .../lib/partner-referrals/attribute-referring-partner.ts | 4 ++-- .../web/lib/upstash/redis-streams/workspace-click-events.ts | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/web/lib/api/rewards/queue-reward-processing.ts b/apps/web/lib/api/rewards/queue-reward-processing.ts index eef30f47e4a..929bb6252fc 100644 --- a/apps/web/lib/api/rewards/queue-reward-processing.ts +++ b/apps/web/lib/api/rewards/queue-reward-processing.ts @@ -1,4 +1,4 @@ -import { getErrorMetadata, logger } from "@/lib/axiom/server"; +import { logger, toErrorFields } from "@/lib/axiom/server"; import { qstash } from "@/lib/cron"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; import { EventType } from "@prisma/client"; @@ -63,7 +63,7 @@ export async function queueRewardProcessing(params: RewardJob) { service: "qstash", event: "publishJSON.failed", url: `/api/cron/rewards/process`, - ...getErrorMetadata(error), + error: toErrorFields(error), correlation: { event: params.event, groupId: params.groupId, diff --git a/apps/web/lib/cron/qstash-workflow.ts b/apps/web/lib/cron/qstash-workflow.ts index 9db95748bf9..4d6fc4151f4 100644 --- a/apps/web/lib/cron/qstash-workflow.ts +++ b/apps/web/lib/cron/qstash-workflow.ts @@ -1,4 +1,4 @@ -import { getErrorMetadata, logger } from "@/lib/axiom/server"; +import { logger, toErrorFields } from "@/lib/axiom/server"; import { APP_DOMAIN_WITH_NGROK, pluralize } from "@dub/utils"; import { FlowControl } from "@upstash/qstash"; import { Client } from "@upstash/workflow"; @@ -62,7 +62,7 @@ export async function triggerQStashWorkflow( service: "qstash", event: "workflow.trigger_failed", workflowType: workflow.workflowType, - ...getErrorMetadata(error), + error: toErrorFields(error), correlation, }); } diff --git a/apps/web/lib/integrations/google-ads/upload-conversion.ts b/apps/web/lib/integrations/google-ads/upload-conversion.ts index 746112d75b8..696f952c785 100644 --- a/apps/web/lib/integrations/google-ads/upload-conversion.ts +++ b/apps/web/lib/integrations/google-ads/upload-conversion.ts @@ -1,4 +1,4 @@ -import { getErrorMetadata, logger } from "@/lib/axiom/server"; +import { logger, toErrorFields } from "@/lib/axiom/server"; import { qstash } from "@/lib/cron"; import { prisma } from "@/lib/prisma"; import { @@ -70,7 +70,7 @@ export const queueGoogleAdsConversionUpload = async ( } catch (error) { logger.error("google-ads.queue_conversion_failed", { service: "google-ads", - ...getErrorMetadata(error), + error: toErrorFields(error), correlation: { workspaceId: payload.workspaceId, eventId: payload.eventId, @@ -206,7 +206,7 @@ export const uploadGoogleAdsConversion = async ( logger.error("google-ads.upload_conversion_failed", { service: "google-ads", - ...getErrorMetadata(error), + error: toErrorFields(error), correlation: { workspaceId, eventId, diff --git a/apps/web/lib/partner-referrals/attribute-referring-partner.ts b/apps/web/lib/partner-referrals/attribute-referring-partner.ts index 0430cc8f4f1..8502dfee0c9 100644 --- a/apps/web/lib/partner-referrals/attribute-referring-partner.ts +++ b/apps/web/lib/partner-referrals/attribute-referring-partner.ts @@ -9,7 +9,7 @@ import { subMinutes } from "date-fns"; import { authActionClient } from "../actions/safe-action"; import { throwIfNoPermission } from "../actions/throw-if-no-permission"; import { createId } from "../api/create-id"; -import { getErrorMetadata, logger } from "../axiom/server"; +import { logger, toErrorFields } from "../axiom/server"; import { qstash } from "../cron"; import { attributeReferringPartnerSchema } from "./schemas"; @@ -153,7 +153,7 @@ export const attributeReferringPartnerAction = authActionClient service: "qstash", event: "publishJSON.failed", url: `/api/cron/commissions/referrals/backfill`, - ...getErrorMetadata(error), + error: toErrorFields(error), correlation: { programId, partnerId, diff --git a/apps/web/lib/upstash/redis-streams/workspace-click-events.ts b/apps/web/lib/upstash/redis-streams/workspace-click-events.ts index 68d251b8648..0a26f718e36 100644 --- a/apps/web/lib/upstash/redis-streams/workspace-click-events.ts +++ b/apps/web/lib/upstash/redis-streams/workspace-click-events.ts @@ -1,4 +1,4 @@ -import { getErrorMetadata, logger } from "@/lib/axiom/server"; +import { logger, toErrorFields } from "@/lib/axiom/server"; import { clickWebhookWorkspaces } from "@/lib/webhook/click-webhook-workspaces"; import { clickEventSchemaTB } from "@/lib/zod/schemas/clicks"; import { redis } from "../redis"; @@ -30,7 +30,7 @@ export const publishWorkspaceClickEvent = async (event) => { logger.error("stream.publish_failed", { service: "upstash", streamKey: STREAM_KEY, - ...getErrorMetadata(error), + error: toErrorFields(error), correlation: { workspaceId: event.workspace_id, clickId: event.click_id, From 15dfba5935dc713dcfb44745bb456305a479299b Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Tue, 28 Jul 2026 22:17:56 -0700 Subject: [PATCH 16/18] sync-click-workspaces cron --- .../sync-installed-workspaces/route.ts | 16 ------- .../cleanup-redundant-link-webhook-entries.ts | 46 +++++++++++++++++++ .../api/cron/sync-redis-resources/route.ts | 39 ++++++++++++++++ .../sync-click-webhook-workspace-set.ts} | 0 .../webhooks/sync-click-workspaces/route.ts | 20 -------- apps/web/vercel.json | 2 +- 6 files changed, 86 insertions(+), 37 deletions(-) delete mode 100644 apps/web/app/(ee)/api/cron/google-ads/sync-installed-workspaces/route.ts create mode 100644 apps/web/app/(ee)/api/cron/sync-redis-resources/cleanup-redundant-link-webhook-entries.ts create mode 100644 apps/web/app/(ee)/api/cron/sync-redis-resources/route.ts rename apps/web/app/(ee)/api/cron/{webhooks/sync-click-workspaces/utils.ts => sync-redis-resources/sync-click-webhook-workspace-set.ts} (100%) delete mode 100644 apps/web/app/(ee)/api/cron/webhooks/sync-click-workspaces/route.ts diff --git a/apps/web/app/(ee)/api/cron/google-ads/sync-installed-workspaces/route.ts b/apps/web/app/(ee)/api/cron/google-ads/sync-installed-workspaces/route.ts deleted file mode 100644 index efc1513e5be..00000000000 --- a/apps/web/app/(ee)/api/cron/google-ads/sync-installed-workspaces/route.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { withCron } from "@/lib/cron/with-cron"; -import { syncGoogleAdsInstalledWorkspaceSet } from "@/lib/integrations/google-ads/installed-workspaces"; -import { logAndRespond } from "../../utils"; - -export const dynamic = "force-dynamic"; - -// GET /api/cron/google-ads/sync-installed-workspaces -// Rebuild the Redis set of workspaces with Google Ads installed. -// Runs every minute (* * * * *) -export const GET = withCron(async () => { - const synced = await syncGoogleAdsInstalledWorkspaceSet(); - - return logAndRespond( - `Synced ${synced} workspace(s) with Google Ads installed.`, - ); -}); diff --git a/apps/web/app/(ee)/api/cron/sync-redis-resources/cleanup-redundant-link-webhook-entries.ts b/apps/web/app/(ee)/api/cron/sync-redis-resources/cleanup-redundant-link-webhook-entries.ts new file mode 100644 index 00000000000..ad4f2b51757 --- /dev/null +++ b/apps/web/app/(ee)/api/cron/sync-redis-resources/cleanup-redundant-link-webhook-entries.ts @@ -0,0 +1,46 @@ +import { prisma } from "@/lib/prisma"; +import { LINK_CLICK_WEBHOOK_TRIGGER } from "@/lib/webhook/constants"; + +// periodically remove redundant LinkWebhook entries for webhooks that are not scoped to links (folders, workspace) +// we do this in case clients are still passing webhookIds when creating links, which will create LinkWebhook entries +export const cleanupRedundantLinkWebhookEntries = async () => { + const nonLinkScopeWebhooks = await prisma.webhook.findMany({ + where: { + triggers: { + array_contains: [LINK_CLICK_WEBHOOK_TRIGGER], + }, + linkScope: { + not: "links", + }, + links: { + some: {}, + }, + }, + }); + + let deletedCount = 0; + while (true) { + const linksToDelete = await prisma.linkWebhook.findMany({ + where: { + webhookId: { + in: nonLinkScopeWebhooks.map((webhook) => webhook.id), + }, + }, + take: 250, + }); + const deleted = await prisma.linkWebhook.deleteMany({ + where: { + id: { + in: linksToDelete.map((link) => link.id), + }, + }, + }); + deletedCount += deleted.count; + if (deleted.count === 0) { + console.log("No more redundant LinkWebhook entries to delete"); + break; + } + console.log(`Deleted ${deleted.count} redundant LinkWebhook entries`); + } + return deletedCount; +}; diff --git a/apps/web/app/(ee)/api/cron/sync-redis-resources/route.ts b/apps/web/app/(ee)/api/cron/sync-redis-resources/route.ts new file mode 100644 index 00000000000..7bfbcc98f12 --- /dev/null +++ b/apps/web/app/(ee)/api/cron/sync-redis-resources/route.ts @@ -0,0 +1,39 @@ +import { withCron } from "@/lib/cron/with-cron"; +import { syncGoogleAdsInstalledWorkspaceSet } from "@/lib/integrations/google-ads/installed-workspaces"; +import { logAndRespond } from "../utils"; +import { cleanupRedundantLinkWebhookEntries } from "./cleanup-redundant-link-webhook-entries"; +import { syncClickWebhookWorkspaceSet } from "./sync-click-webhook-workspace-set"; + +export const dynamic = "force-dynamic"; + +/* + GET /api/cron/sync-redis-resources + Rebuild various Redis resources: + - syncClickWebhookWorkspaceSet: rebuild the Redis set of workspaces with active link.clicked webhooks + - cleanupRedundantLinkWebhookEntries: remove redundant LinkWebhook entries for webhooks that are not scoped to links (folders, workspace) +*/ + +// Runs every 5 minutes (*/5 * * * *) +export const GET = withCron(async () => { + const result = await Promise.allSettled([ + syncClickWebhookWorkspaceSet(), + cleanupRedundantLinkWebhookEntries(), + syncGoogleAdsInstalledWorkspaceSet(), + ]); + + [ + "syncClickWebhookWorkspaceSet", + "cleanupRedundantLinkWebhookEntries", + "syncGoogleAdsInstalledWorkspaceSet", + ].map((name, index) => { + if (result[index].status === "fulfilled") { + console.log(`${name}: ${result[index].value}`); + } else if (result[index].status === "rejected") { + console.error(`${name}: ${result[index].reason}`); + } else { + console.error(`${name}: unknown error`); + } + }); + + return logAndRespond("Synced Redis resources."); +}); diff --git a/apps/web/app/(ee)/api/cron/webhooks/sync-click-workspaces/utils.ts b/apps/web/app/(ee)/api/cron/sync-redis-resources/sync-click-webhook-workspace-set.ts similarity index 100% rename from apps/web/app/(ee)/api/cron/webhooks/sync-click-workspaces/utils.ts rename to apps/web/app/(ee)/api/cron/sync-redis-resources/sync-click-webhook-workspace-set.ts diff --git a/apps/web/app/(ee)/api/cron/webhooks/sync-click-workspaces/route.ts b/apps/web/app/(ee)/api/cron/webhooks/sync-click-workspaces/route.ts deleted file mode 100644 index 145c4cf96c7..00000000000 --- a/apps/web/app/(ee)/api/cron/webhooks/sync-click-workspaces/route.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { withCron } from "@/lib/cron/with-cron"; -import { logAndRespond } from "../../utils"; -import { - cleanupRedundantLinkWebhookEntries, - syncClickWebhookWorkspaceSet, -} from "./utils"; - -export const dynamic = "force-dynamic"; - -// GET /api/cron/webhooks/sync-click-workspaces -// Rebuild the Redis set of workspaces with active link.clicked webhooks. -// Runs every 5 minutes (*/5 * * * *) -export const GET = withCron(async () => { - const synced = await syncClickWebhookWorkspaceSet(); - const deleted = await cleanupRedundantLinkWebhookEntries(); - - return logAndRespond( - `Synced ${synced} workspace(s) with link.clicked webhooks. Deleted ${deleted} redundant LinkWebhook entries.`, - ); -}); diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 8c45a4a8965..a65dc47c628 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -81,7 +81,7 @@ "schedule": "0 2 * * *" }, { - "path": "/api/cron/webhooks/sync-click-workspaces", + "path": "/api/cron/sync-redis-resources", "schedule": "*/5 * * * *" }, { From 14f898c92477f4ce14886ecf8a60f70b0c2b6d06 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Tue, 28 Jul 2026 22:23:52 -0700 Subject: [PATCH 17/18] Update sync-click-webhook-workspace-set.ts --- .../sync-click-webhook-workspace-set.ts | 44 ------------------- 1 file changed, 44 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/sync-redis-resources/sync-click-webhook-workspace-set.ts b/apps/web/app/(ee)/api/cron/sync-redis-resources/sync-click-webhook-workspace-set.ts index 72d245bc84c..82766f3a5cd 100644 --- a/apps/web/app/(ee)/api/cron/sync-redis-resources/sync-click-webhook-workspace-set.ts +++ b/apps/web/app/(ee)/api/cron/sync-redis-resources/sync-click-webhook-workspace-set.ts @@ -38,47 +38,3 @@ export const syncClickWebhookWorkspaceSet = async () => { return workspaceIds.length; }; - -// periodically remove redundant LinkWebhook entries for webhooks that are not scoped to links (folders, workspace) -// we do this in case clients are still passing webhookIds when creating links, which will create LinkWebhook entries -export const cleanupRedundantLinkWebhookEntries = async () => { - const nonLinkScopeWebhooks = await prisma.webhook.findMany({ - where: { - triggers: { - array_contains: [LINK_CLICK_WEBHOOK_TRIGGER], - }, - linkScope: { - not: "links", - }, - links: { - some: {}, - }, - }, - }); - - let deletedCount = 0; - while (true) { - const linksToDelete = await prisma.linkWebhook.findMany({ - where: { - webhookId: { - in: nonLinkScopeWebhooks.map((webhook) => webhook.id), - }, - }, - take: 250, - }); - const deleted = await prisma.linkWebhook.deleteMany({ - where: { - id: { - in: linksToDelete.map((link) => link.id), - }, - }, - }); - deletedCount += deleted.count; - if (deleted.count === 0) { - console.log("No more redundant LinkWebhook entries to delete"); - break; - } - console.log(`Deleted ${deleted.count} redundant LinkWebhook entries`); - } - return deletedCount; -}; From be0642ecae5dc0bdac1ffb59ab7addcffd2db96b Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Tue, 28 Jul 2026 22:24:26 -0700 Subject: [PATCH 18/18] Update route.ts --- apps/web/app/(ee)/api/cron/sync-redis-resources/route.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/app/(ee)/api/cron/sync-redis-resources/route.ts b/apps/web/app/(ee)/api/cron/sync-redis-resources/route.ts index 7bfbcc98f12..36affdbaaef 100644 --- a/apps/web/app/(ee)/api/cron/sync-redis-resources/route.ts +++ b/apps/web/app/(ee)/api/cron/sync-redis-resources/route.ts @@ -11,6 +11,7 @@ export const dynamic = "force-dynamic"; Rebuild various Redis resources: - syncClickWebhookWorkspaceSet: rebuild the Redis set of workspaces with active link.clicked webhooks - cleanupRedundantLinkWebhookEntries: remove redundant LinkWebhook entries for webhooks that are not scoped to links (folders, workspace) + - syncGoogleAdsInstalledWorkspaceSet: rebuild the Redis set of workspaces with Google Ads installed */ // Runs every 5 minutes (*/5 * * * *)