diff --git a/apps/web/.env.example b/apps/web/.env.example index fe76db1ae1d..c2e7a4a2a64 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -183,6 +183,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/cron/google-ads/sync-installed-workspaces/route.ts b/apps/web/app/(ee)/api/cron/google-ads/sync-installed-workspaces/route.ts new file mode 100644 index 00000000000..efc1513e5be --- /dev/null +++ b/apps/web/app/(ee)/api/cron/google-ads/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/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/google-ads/callback/route.ts b/apps/web/app/(ee)/api/google-ads/callback/route.ts new file mode 100644 index 00000000000..2519373f39e --- /dev/null +++ b/apps/web/app/(ee)/api/google-ads/callback/route.ts @@ -0,0 +1,133 @@ +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, + 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 { 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/google-ads/callback - OAuth callback from Google Ads +export const GET = async (req: Request) => { + let workspace: + | (Pick & { plan: string }) + | null = null; + + try { + const session = await getSession(); + + if (!session?.user.id) { + throw new DubApiError({ + code: "unauthorized", + message: "Unauthorized. Please login to continue.", + }); + } + + const { token, contextId: workspaceId } = + await googleAdsOAuthProvider.exchangeCodeForToken(req); + + 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, + }, + }, + }, + }); + + 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, + }); + + waitUntil(googleAdsInstalledWorkspaces.add(workspaceId)); + } catch (error) { + return handleAndReturnErrorResponse(error); + } + + redirect(`/${workspace.slug}/settings/integrations/google-ads`); +}; diff --git a/apps/web/app/(ee)/api/google-ads/conversion-actions/route.ts b/apps/web/app/(ee)/api/google-ads/conversion-actions/route.ts new file mode 100644 index 00000000000..58e335cf0b6 --- /dev/null +++ b/apps/web/app/(ee)/api/google-ads/conversion-actions/route.ts @@ -0,0 +1,80 @@ +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/google-ads/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.getAccessToken(installedIntegration); + + const currentSettings = googleAdsSettingsSchema.parse( + 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, + }); + + 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/app/(ee)/api/google-ads/upload-conversion/route.ts b/apps/web/app/(ee)/api/google-ads/upload-conversion/route.ts new file mode 100644 index 00000000000..97532852563 --- /dev/null +++ b/apps/web/app/(ee)/api/google-ads/upload-conversion/route.ts @@ -0,0 +1,21 @@ +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"; + +// 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)); + + const { message, status } = await uploadGoogleAdsConversion(payload); + + if (status === "failed") { + return logAndRespond(message, { status: 500, logLevel: "error" }); + } + + return logAndRespond(message, { + logLevel: status === "skipped" ? "warn" : "info", + }); +}); 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 7de2a24755f..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 @@ -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, @@ -69,6 +71,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 +80,14 @@ export default function IntegrationPageClient({ integration: InstalledIntegrationInfoProps; }) { const { id: workspaceId, slug, plan, role, stripeConnectId } = useWorkspace(); + const { isMobile } = useMediaQuery(); + const [openPopover, setOpenPopover] = useState(false); 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 +355,7 @@ export default function IntegrationPageClient({ HUBSPOT_INTEGRATION_ID, APPSFLYER_INTEGRATION_ID, INTERCOM_INTEGRATION_ID, + GOOGLE_ADS_INTEGRATION_ID, ].includes(integration.id) && !canInstallAdvancedIntegrations ? ( { + 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/integrations/google-ads/api.ts b/apps/web/lib/integrations/google-ads/api.ts new file mode 100644 index 00000000000..b2783d6dfb8 --- /dev/null +++ b/apps/web/lib/integrations/google-ads/api.ts @@ -0,0 +1,409 @@ +import * as z from "zod/v4"; +import { GOOGLE_ADS_API_VERSION } from "./constants"; +import { + googleAdsConversionActionSchema, + googleAdsConversionUploadSchema, + googleAdsCustomerSchema, +} from "./schema"; + +export type GoogleAdsClickId = + | { gclid: string } + | { gbraid: string } + | { wbraid: string }; + +type UploadClickConversionParams = { + customerId: string; + conversionAction: string; + googleClickId: GoogleAdsClickId; +} & Pick< + z.infer, + | "conversionDateTime" + | "eventId" + | "conversionValue" + | "currencyCode" + | "conversionCount" +>; + +type GoogleAdsRequestOptions = { + accessToken: string; + loginCustomerId?: string | null; +}; + +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}): ${text || "Unknown error"}`, + ); + } + + if (!response.ok) { + console.error("[Google Ads API]", path, data); + + throw new Error( + `[Google Ads API] Request failed for ${path} (${response.status}): ${formatApiErrorDetail(data, text)}`, + ); + } + + 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}): ${text || "Unknown error"}`, + ); + } + + if (!response.ok) { + console.error("[Data Manager API]", path, data); + + throw new Error( + `[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, + ...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 is NonNullable => + conversionAction != null, + ); + + return conversionActions.map((conversionAction) => + googleAdsConversionActionSchema.parse({ + id: conversionAction.id!.toString(), + resourceName: conversionAction.resourceName!, + name: conversionAction.name!, + }), + ); + } + + // Uploads an offline click conversion via the Data Manager API. + // New integrations cannot use ConversionUploadService.UploadClickConversions. + async uploadClickConversion({ + customerId, + conversionAction, + googleClickId, + conversionDateTime, + conversionValue, + currencyCode, + conversionCount, + 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 event: Record = { + eventTimestamp: formatGoogleAdsEventTimestamp(conversionDateTime), + transactionId: eventId, + eventSource: "WEB", + adIdentifiers: googleClickId, + consent: { + adUserData: "CONSENT_GRANTED", + }, + }; + + if (conversionValue !== undefined) { + event.conversionValue = conversionValue; + } + + if (currencyCode) { + event.currency = currencyCode.toUpperCase(); + } + + if (conversionCount !== undefined) { + event.conversionCount = conversionCount; + } + + return dataManagerFetch<{ requestId: string }>({ + accessToken: this.options.accessToken, + path: "events:ingest", + body: { + destinations: [destination], + events: [event], + }, + }); + } +} + +// 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 RFC 3339 for Data Manager API event uploads. +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/constants.ts b/apps/web/lib/integrations/google-ads/constants.ts new file mode 100644 index 00000000000..c706ab9006a --- /dev/null +++ b/apps/web/lib/integrations/google-ads/constants.ts @@ -0,0 +1,21 @@ +import { DUB_WORKSPACE_ID } from "@dub/utils"; + +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", + "https://www.googleapis.com/auth/datamanager", +].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/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/oauth.ts b/apps/web/lib/integrations/google-ads/oauth.ts new file mode 100644 index 00000000000..b0cb88a1d49 --- /dev/null +++ b/apps/web/lib/integrations/google-ads/oauth.ts @@ -0,0 +1,233 @@ +import { decrypt, encrypt } from "@/lib/encryption"; +import { prisma } from "@/lib/prisma"; +import { APP_DOMAIN_WITH_NGROK, 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 getAccessToken( + installation: Pick, + ): Promise> { + const existingCredentials = this.decryptCredentials( + installation.credentials, + ); + + 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; + } + + 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: installationId, + }, + data: { + credentials: googleAdsAuthTokenSchema.parse({ + ...newCredentials, + access_token: encrypt(newCredentials.access_token), + refresh_token: encrypt(newCredentials.refresh_token), + }), + }, + }); + + 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", + 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: `${APP_DOMAIN_WITH_NGROK}/api/google-ads/callback`, + 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..93920941e6c --- /dev/null +++ b/apps/web/lib/integrations/google-ads/schema.ts @@ -0,0 +1,46 @@ +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"]), + click: z.object({ + id: z.string(), + url: z.string(), + }), + conversionDateTime: z.string(), + 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/ui/settings.tsx b/apps/web/lib/integrations/google-ads/ui/settings.tsx new file mode 100644 index 00000000000..51d86d3d535 --- /dev/null +++ b/apps/web/lib/integrations/google-ads/ui/settings.tsx @@ -0,0 +1,311 @@ +"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/google-ads/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) { + return; + } + + setValue("loginCustomerId", conversionActionsData.loginCustomerId ?? ""); + }, [conversionActionsData, 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("loginCustomerId", ""); + 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..417f090ccd0 --- /dev/null +++ b/apps/web/lib/integrations/google-ads/update-google-ads-settings.ts @@ -0,0 +1,118 @@ +"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 { 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, + customerName, + leadConversionAction, + 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, + projectId: workspace.id, + }, + }); + + if (!installedIntegration) { + throw new Error( + "Google Ads integration is not installed on your workspace.", + ); + } + + const currentSettings = googleAdsSettingsSchema.parse( + installedIntegration.settings ?? {}, + ); + + if (customerId) { + const normalizedCustomerId = customerId.replace(/-/g, ""); + const selectedCustomer = currentSettings.customers.find( + (customer) => customer.id.replace(/-/g, "") === normalizedCustomerId, + ); + + if (!selectedCustomer) { + throw new Error( + "The selected Google Ads account is not available for this workspace. Please reconnect the integration.", + ); + } + } + + const resolvedLoginCustomerId = customerId + ? inferLoginCustomerId({ + customers: currentSettings.customers, + selectedCustomerId: customerId, + }) + : 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/`; + + 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, + }, + 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..746112d75b8 --- /dev/null +++ b/apps/web/lib/integrations/google-ads/upload-conversion.ts @@ -0,0 +1,225 @@ +import { getErrorMetadata, logger } from "@/lib/axiom/server"; +import { qstash } from "@/lib/cron"; +import { prisma } from "@/lib/prisma"; +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 { googleAdsInstalledWorkspaces } from "./installed-workspaces"; +import { googleAdsOAuthProvider } from "./oauth"; +import { + googleAdsConversionUploadSchema, + googleAdsSettingsSchema, +} from "./schema"; + +const extractGoogleAdsClickId = (url: string): GoogleAdsClickId | null => { + try { + 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; + } catch { + return null; + } +}; + +export const queueGoogleAdsConversionUpload = async ( + payload: z.infer, +) => { + if (!extractGoogleAdsClickId(payload.click.url)) { + return; + } + + if (!(await googleAdsInstalledWorkspaces.has(payload.workspaceId))) { + return; + } + + try { + const response = await qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/google-ads/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.click.id, + }, + }); + + await logger.flush(); + throw error; + } +}; + +export type GoogleAdsConversionUploadResult = { + message: string; + status: "failed" | "skipped" | "uploaded"; +}; + +export const uploadGoogleAdsConversion = async ( + payload: z.infer, +): Promise => { + const { + workspaceId, + eventType, + click, + conversionDateTime, + eventId, + conversionValue, + currencyCode, + conversionCount, + } = googleAdsConversionUploadSchema.parse(payload); + + try { + const installedIntegration = await prisma.installedIntegration.findFirst({ + where: { + integrationId: GOOGLE_ADS_INTEGRATION_ID, + projectId: workspaceId, + }, + select: { + id: true, + settings: true, + credentials: true, + }, + }); + + if (!installedIntegration) { + return { + message: `Google Ads integration not installed for workspace ${workspaceId}. Skipping...`, + status: "skipped", + }; + } + + const settings = googleAdsSettingsSchema.parse( + installedIntegration.settings ?? {}, + ); + + const conversionAction = + eventType === "lead" + ? settings.leadConversionAction + : settings.saleConversionAction; + + if (!settings.customerId || !conversionAction) { + return { + message: `Missing ${!settings.customerId ? "customerId" : `${eventType}ConversionAction`}. Skipping...`, + status: "skipped", + }; + } + + const googleClickId = extractGoogleAdsClickId(click.url); + + if (!googleClickId) { + return { + message: `No gclid/gbraid/wbraid found on click ${click.id}. Skipping...`, + status: "skipped", + }; + } + + 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, + conversionCount, + eventId, + }); + + return { + message: `Uploaded ${eventType} conversion for workspace ${workspaceId} (requestId: ${response.requestId})`, + status: "uploaded", + }; + } catch (error) { + if (attempt < maxRetries) { + await new Promise((resolve) => + setTimeout(resolve, 1000 * Math.pow(2, attempt)), + ); + continue; + } + + 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), + correlation: { + workspaceId, + eventId, + eventType, + clickId: click.id, + }, + }); + + await logger.flush(); + + return { + message: `Failed to upload ${eventType} conversion for workspace ${workspaceId}: ${errorMessage}`, + status: "failed", + }; + } +}; 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)); 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/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, 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/apps/web/vercel.json b/apps/web/vercel.json index ab9891b7170..4371002b06d 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/google-ads/sync-installed-workspaces", + "schedule": "* * * * *" } ], "functions": { 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";