Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
173 changes: 173 additions & 0 deletions apps/web/app/(ee)/api/gad/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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<string>(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}` : ""}`,
);
};
63 changes: 63 additions & 0 deletions apps/web/app/(ee)/api/gad/conversion-actions/route.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
14 changes: 14 additions & 0 deletions apps/web/app/(ee)/api/gad/upload-conversion/route.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -48,6 +49,7 @@ import {
DUB_WORKSPACE_ID,
formatDate,
getDomainWithoutWWW,
GOOGLE_ADS_INTEGRATION_ID,
SEGMENT_INTEGRATION_ID,
SLACK_INTEGRATION_ID,
STRIPE_INTEGRATION_ID,
Expand All @@ -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 = {
Expand All @@ -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({
Expand All @@ -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) {
Expand Down Expand Up @@ -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 ? (
<TooltipContent
Expand Down
3 changes: 3 additions & 0 deletions apps/web/lib/actions/get-integration-install-url.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use server";

import * as z from "zod/v4";
import { googleAdsOAuthProvider } from "../integrations/google-ads/oauth";
import { hubSpotOAuthProvider } from "../integrations/hubspot/oauth";
import { intercomOAuthProvider } from "../integrations/intercom/oauth";
import { slackOAuthProvider } from "../integrations/slack/oauth";
Expand Down Expand Up @@ -32,6 +33,8 @@ export const getIntegrationInstallUrl = authActionClient
url = await hubSpotOAuthProvider.generateAuthUrl(workspace.id);
} else if (integrationSlug === "intercom") {
url = await intercomOAuthProvider.generateAuthUrl(workspace.id);
} else if (integrationSlug === "google-ads") {
url = await googleAdsOAuthProvider.generateAuthUrl(workspace.id);
} else {
throw new Error("Invalid integration slug");
}
Expand Down
11 changes: 10 additions & 1 deletion apps/web/lib/api/conversions/track-lead.ts
Original file line number Diff line number Diff line change
@@ -1,6 +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 { generateRandomName } from "@/lib/names";
import { queuePartnerCommissionCreation } from "@/lib/partners/queue-partner-commission-creation";
import { sendPartnerPostback } from "@/lib/postback/send-partner-postback";
Expand All @@ -16,7 +17,7 @@ import {
trackLeadResponseSchema,
} from "@/lib/zod/schemas/leads";
import { nanoid, R2_URL } from "@dub/utils";
import { Link } from "@prisma/client";
import { EventType, Link } from "@prisma/client";
import { waitUntil } from "@vercel/functions";
import * as z from "zod/v4";
import { syncPartnerLinksStats } from "../partners/sync-partner-links-stats";
Expand Down Expand Up @@ -349,6 +350,14 @@ export const trackLead = async ({
workspace,
}),

queueGoogleAdsConversionUpload({
workspaceId: workspace.id,
eventType: EventType.lead,
clickId,
eventId: leadEventId,
conversionDateTime: `${clickData.timestamp}Z`,
}),

...(link.partnerId
? [
sendPartnerPostback({
Expand Down
Loading
Loading