diff --git a/backend/.env.example b/backend/.env.example index a50c10c95..cd632d211 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -70,6 +70,14 @@ GOOGLE_GSC_CLIENT_SECRET= GOOGLE_GA4_CLIENT_ID= GOOGLE_GA4_CLIENT_SECRET= +# Google Ads integration (Cloud edition only). +# The OAuth client's Authorized redirect URI must be set to ${BASE_URL}/ads-connected. +# The developer token is attached to a Google Ads manager account (API Center); +# Basic access is enough for reporting-only usage. +GOOGLE_ADS_CLIENT_ID= +GOOGLE_ADS_CLIENT_SECRET= +GOOGLE_ADS_DEVELOPER_TOKEN= + # Github SSO GITHUB_OAUTH2_CLIENT_ID= GITHUB_OAUTH2_CLIENT_SECRET= diff --git a/backend/apps/cloud/src/ads/adapters/google-ads.adapter.ts b/backend/apps/cloud/src/ads/adapters/google-ads.adapter.ts new file mode 100644 index 000000000..f5673e73f --- /dev/null +++ b/backend/apps/cloud/src/ads/adapters/google-ads.adapter.ts @@ -0,0 +1,356 @@ +import { Injectable, InternalServerErrorException } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' +import _round from 'lodash/round' + +import { AppLoggerService } from '../../logger/logger.service' +import { CurrencyService } from '../../revenue/currency.service' +import { AdsService } from '../ads.service' +import { + AdMetricRow, + AdsAccount, + AdsProvider, + ADS_SYNC_LOOKBACK_DAYS, + GOOGLE_ADS_API_VERSION, +} from '../interfaces/ads.interface' + +dayjs.extend(utc) + +const API_BASE = `https://googleads.googleapis.com/${GOOGLE_ADS_API_VERSION}` + +const FETCH_TIMEOUT_MS = 30_000 +const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]) +const MAX_FETCH_ATTEMPTS = 3 + +interface GoogleAdsSearchResult { + campaign?: { + id?: string + name?: string + status?: string + } + customer?: { + id?: string + descriptiveName?: string + currencyCode?: string + } + customerClient?: { + id?: string + descriptiveName?: string + manager?: boolean + currencyCode?: string + level?: string + } + metrics?: { + costMicros?: string + clicks?: string + impressions?: string + conversions?: number + conversionsValue?: number + } + segments?: { + date?: string + } +} + +interface GoogleAdsSearchResponse { + results?: GoogleAdsSearchResult[] + nextPageToken?: string +} + +export interface GoogleAdsSyncTarget { + id: string + googleAdsCustomerId: string + googleAdsLoginCustomerId: string | null + googleAdsCurrency: string | null + revenueCurrency: string | null +} + +@Injectable() +export class GoogleAdsAdapter { + constructor( + private readonly configService: ConfigService, + private readonly logger: AppLoggerService, + private readonly adsService: AdsService, + private readonly currencyService: CurrencyService, + ) {} + + private getDeveloperToken(): string { + const token = this.configService.get('GOOGLE_ADS_DEVELOPER_TOKEN') + + if (!token) { + throw new InternalServerErrorException( + 'Google Ads developer token is not configured', + ) + } + + return token + } + + /* + fetch() with a per-attempt timeout (Google Ads calls run on request paths, + so they must never hang) and a small backoff retry on transient failures. + Non-retryable error statuses are returned as-is for the caller to parse. + */ + private async fetchWithRetry( + url: string, + init: RequestInit, + ): Promise { + let lastError: unknown + + for (let attempt = 1; attempt <= MAX_FETCH_ATTEMPTS; attempt++) { + if (attempt > 1) { + await new Promise((resolve) => { + setTimeout(resolve, 500 * 2 ** (attempt - 2)) + }) + } + + try { + const res = await fetch(url, { + ...init, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }) + + if ( + RETRYABLE_STATUS_CODES.has(res.status) && + attempt < MAX_FETCH_ATTEMPTS + ) { + continue + } + + return res + } catch (error) { + lastError = error + } + } + + throw lastError + } + + private async search( + accessToken: string, + customerId: string, + query: string, + loginCustomerId?: string | null, + ): Promise { + const results: GoogleAdsSearchResult[] = [] + let pageToken: string | undefined + + do { + const res = await this.fetchWithRetry( + `${API_BASE}/customers/${customerId}/googleAds:search`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'developer-token': this.getDeveloperToken(), + ...(loginCustomerId + ? { 'login-customer-id': loginCustomerId } + : {}), + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query, + ...(pageToken ? { pageToken } : {}), + }), + }, + ) + + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`Google Ads API error (${res.status}): ${text}`) + } + + const data: GoogleAdsSearchResponse = await res.json() + results.push(...(data.results || [])) + pageToken = data.nextPageToken + } while (pageToken) + + return results + } + + /* + Lists all non-manager ad accounts the OAuth grant can access, walking one + level down into manager (MCC) accounts. Individual roots that error (e.g. + cancelled accounts) are skipped. + */ + async listAccessibleAccounts(accessToken: string): Promise { + const res = await this.fetchWithRetry( + `${API_BASE}/customers:listAccessibleCustomers`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'developer-token': this.getDeveloperToken(), + }, + }, + ) + + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`Google Ads API error (${res.status}): ${text}`) + } + + const data: { resourceNames?: string[] } = await res.json() + const rootIds = (data.resourceNames || []) + .map((name) => name.split('/')[1]) + .filter(Boolean) + + const accounts = new Map() + + for (const rootId of rootIds) { + try { + const results = await this.search( + accessToken, + rootId, + `SELECT customer_client.id, customer_client.descriptive_name, + customer_client.manager, customer_client.currency_code, + customer_client.level + FROM customer_client + WHERE customer_client.level <= 1`, + rootId, + ) + + for (const result of results) { + const client = result.customerClient + if (!client?.id || client.manager) { + continue + } + + const isRoot = String(client.level) === '0' + + if (!accounts.has(client.id)) { + accounts.set(client.id, { + customerId: client.id, + name: client.descriptiveName || client.id, + currency: client.currencyCode || null, + isManager: false, + loginCustomerId: isRoot ? null : rootId, + }) + } + } + } catch (error) { + this.logger.warn( + { error, rootId }, + 'Failed to list Google Ads client accounts for an accessible customer', + ) + } + } + + return Array.from(accounts.values()) + } + + async getAccountCurrency( + accessToken: string, + customerId: string, + loginCustomerId?: string | null, + ): Promise { + try { + const results = await this.search( + accessToken, + customerId, + 'SELECT customer.id, customer.currency_code FROM customer', + loginCustomerId, + ) + + return results[0]?.customer?.currencyCode || null + } catch (error) { + this.logger.warn( + { error, customerId }, + 'Failed to fetch Google Ads account currency', + ) + return null + } + } + + /* + Fetches daily campaign metrics for the lookback window and upserts them + into the ad_metrics table (idempotent via ReplacingMergeTree(synced_at)). + */ + async syncCampaignMetrics( + project: GoogleAdsSyncTarget, + lookbackDays: number = ADS_SYNC_LOOKBACK_DAYS, + ): Promise { + const accessToken = await this.adsService.getAuthedAccessToken(project.id) + + const to = dayjs.utc().format('YYYY-MM-DD') + const from = dayjs.utc().subtract(lookbackDays, 'day').format('YYYY-MM-DD') + + const results = await this.search( + accessToken, + project.googleAdsCustomerId, + `SELECT campaign.id, campaign.name, campaign.status, segments.date, + metrics.cost_micros, metrics.clicks, metrics.impressions, + metrics.conversions, metrics.conversions_value + FROM campaign + WHERE segments.date BETWEEN '${from}' AND '${to}'`, + project.googleAdsLoginCustomerId, + ) + + const targetCurrency = (project.revenueCurrency || 'USD').toUpperCase() + + // When the ads account currency is unknown, assuming USD would silently + // misconvert non-USD accounts - store the amounts unconverted instead + const originalCurrency = project.googleAdsCurrency + ? project.googleAdsCurrency.toUpperCase() + : targetCurrency + + if (!project.googleAdsCurrency) { + this.logger.warn( + { projectId: project.id }, + 'Google Ads account currency is unknown; storing campaign metrics without currency conversion', + ) + } + + // Conversion is linear, so resolve the rate once instead of per row + const conversionRate = await this.currencyService.convert( + 1, + originalCurrency, + targetCurrency, + ) + const syncedAt = new Date() + + const rows: AdMetricRow[] = [] + + for (const result of results) { + const campaignId = result.campaign?.id + const date = result.segments?.date + + if (!campaignId || !date) { + continue + } + + const originalCost = Number(result.metrics?.costMicros || 0) / 1e6 + const cost = originalCost * conversionRate + const conversionsValue = + Number(result.metrics?.conversionsValue || 0) * conversionRate + + rows.push({ + pid: project.id, + provider: AdsProvider.GOOGLE, + accountId: project.googleAdsCustomerId, + campaignId, + campaignName: result.campaign?.name || campaignId, + campaignStatus: result.campaign?.status || 'UNKNOWN', + date, + impressions: Number(result.metrics?.impressions || 0), + clicks: Number(result.metrics?.clicks || 0), + cost: _round(cost, 4), + originalCost: _round(originalCost, 4), + originalCurrency, + currency: targetCurrency, + conversions: _round(Number(result.metrics?.conversions || 0), 2), + conversionsValue: _round(conversionsValue, 4), + syncedAt, + }) + } + + await this.adsService.insertAdMetrics(rows) + + this.logger.log( + { projectId: project.id, rows: rows.length }, + 'Google Ads sync completed', + ) + + return rows.length + } +} diff --git a/backend/apps/cloud/src/ads/ads-analytics.controller.ts b/backend/apps/cloud/src/ads/ads-analytics.controller.ts new file mode 100644 index 000000000..f9d2ece39 --- /dev/null +++ b/backend/apps/cloud/src/ads/ads-analytics.controller.ts @@ -0,0 +1,214 @@ +import { + Controller, + Get, + Query, + Headers, + NotFoundException, +} from '@nestjs/common' +import { ApiBearerAuth, ApiResponse, ApiTags } from '@nestjs/swagger' +import _isEmpty from 'lodash/isEmpty' + +import { Auth } from '../auth/decorators' +import { CurrentUserId } from '../auth/decorators/current-user-id.decorator' +import { ProjectService } from '../project/project.service' +import { AppLoggerService } from '../logger/logger.service' +import { + AnalyticsService, + getLowestPossibleTimeBucket, +} from '../analytics/analytics.service' +import { TimeBucketType } from '../analytics/dto/getData.dto' +import { AdsService, AdsCampaignRow, AdsChart, AdsStats } from './ads.service' +import { GetAdsDto } from './dto/get-ads.dto' + +// ad_metrics is daily-grain; sub-day buckets cannot be served +const clampTimeBucket = (timeBucket: TimeBucketType): TimeBucketType => { + if ( + timeBucket === TimeBucketType.MINUTE || + timeBucket === TimeBucketType.HOUR + ) { + return TimeBucketType.DAY + } + + return timeBucket +} + +@ApiTags('Ads Analytics') +@Controller(['log/ads', 'v1/log/ads']) +export class AdsAnalyticsController { + constructor( + private readonly adsService: AdsService, + private readonly projectService: ProjectService, + private readonly analyticsService: AnalyticsService, + private readonly logger: AppLoggerService, + ) {} + + private async getViewableProject( + pid: string, + userId: string, + password?: string, + ) { + const project = await this.projectService.getFullProject(pid) + + if (_isEmpty(project)) { + throw new NotFoundException('Project not found') + } + + this.projectService.allowedToView(project, userId, password) + + return project + } + + private getRange(dto: GetAdsDto) { + const safeTimezone = this.analyticsService.getSafeTimezone(dto.timezone) + const timeBucket = clampTimeBucket( + dto.timeBucket || + getLowestPossibleTimeBucket(dto.period, dto.from, dto.to), + ) + + const { groupFromUTC, groupToUTC } = this.analyticsService.getGroupFromTo( + dto.from, + dto.to, + timeBucket, + dto.period, + safeTimezone, + ) + + return { safeTimezone, timeBucket, groupFromUTC, groupToUTC } + } + + @ApiBearerAuth() + @Get('/') + @Auth(true, true) + @ApiResponse({ status: 200 }) + async getAdsData( + @CurrentUserId() userId: string, + @Query() dto: GetAdsDto, + @Headers() headers: { 'x-password'?: string }, + ): Promise< + | { notConnected: true } + | { + notConnected: false + currency: string + stats: AdsStats + chart: AdsChart + } + > { + this.logger.log({ userId, ...dto }, 'GET /log/ads') + + const project = await this.getViewableProject( + dto.pid, + userId, + headers['x-password'], + ) + + if (!project.googleAdsCustomerId) { + return { notConnected: true } + } + + const { safeTimezone, timeBucket, groupFromUTC, groupToUTC } = + this.getRange(dto) + + const { xShifted } = this.analyticsService.generateXAxis( + timeBucket, + groupFromUTC, + groupToUTC, + safeTimezone, + ) + + const [stats, chart] = await Promise.all([ + this.adsService.getAdsStats(dto.pid, groupFromUTC, groupToUTC), + this.adsService.getAdsChart( + dto.pid, + groupFromUTC, + groupToUTC, + timeBucket, + safeTimezone, + xShifted, + ), + ]) + + return { + notConnected: false, + currency: project.revenueCurrency || 'USD', + stats, + chart, + } + } + + @ApiBearerAuth() + @Get('/campaigns') + @Auth(true, true) + @ApiResponse({ status: 200 }) + async getCampaigns( + @CurrentUserId() userId: string, + @Query() dto: GetAdsDto, + @Headers() headers: { 'x-password'?: string }, + ): Promise<{ campaigns: AdsCampaignRow[] }> { + this.logger.log({ userId, ...dto }, 'GET /log/ads/campaigns') + + const project = await this.getViewableProject( + dto.pid, + userId, + headers['x-password'], + ) + + if (!project.googleAdsCustomerId) { + return { campaigns: [] } + } + + const { groupFromUTC, groupToUTC } = this.getRange(dto) + + const campaigns = await this.adsService.getCampaignRows( + dto.pid, + groupFromUTC, + groupToUTC, + ) + + return { campaigns } + } + + @ApiBearerAuth() + @Get('/campaign-map') + @Auth(true, true) + @ApiResponse({ status: 200 }) + async getCampaignMap( + @CurrentUserId() userId: string, + @Query() dto: GetAdsDto, + @Headers() headers: { 'x-password'?: string }, + ): Promise<{ + map: Record< + string, + { + campaignId: string + name: string + cost: number + clicks: number + cpc: number + } + > + currency: string + }> { + this.logger.log({ userId, ...dto }, 'GET /log/ads/campaign-map') + + const project = await this.getViewableProject( + dto.pid, + userId, + headers['x-password'], + ) + const currency = project.revenueCurrency || 'USD' + + if (!project.googleAdsCustomerId) { + return { map: {}, currency } + } + + const { groupFromUTC, groupToUTC } = this.getRange(dto) + + const map = await this.adsService.getCampaignMap( + dto.pid, + groupFromUTC, + groupToUTC, + ) + + return { map, currency } + } +} diff --git a/backend/apps/cloud/src/ads/ads.controller.ts b/backend/apps/cloud/src/ads/ads.controller.ts new file mode 100644 index 000000000..35357bf7b --- /dev/null +++ b/backend/apps/cloud/src/ads/ads.controller.ts @@ -0,0 +1,228 @@ +import { + Controller, + Post, + Get, + Delete, + Param, + Body, + UseGuards, + BadRequestException, + HttpCode, + Ip, + Headers, +} from '@nestjs/common' +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger' +import _isEmpty from 'lodash/isEmpty' + +import { AuthenticationGuard } from '../auth/guards/authentication.guard' +import { Auth } from '../auth/decorators' +import { CurrentUserId } from '../auth/decorators/current-user-id.decorator' +import { ProjectService } from '../project/project.service' +import { AppLoggerService } from '../logger/logger.service' +import { trackCustom } from '../common/analytics' +import { getIPFromHeaders } from '../common/utils' +import { AdsService } from './ads.service' +import { GoogleAdsAdapter } from './adapters/google-ads.adapter' +import { SelectAdsAccountDto } from './dto/select-account.dto' +import { ADS_BACKFILL_DAYS } from './interfaces/ads.interface' + +@ApiTags('Project - Google Ads') +@UseGuards(AuthenticationGuard) +@Controller({ path: 'project/ads', version: '1' }) +export class AdsController { + constructor( + private readonly adsService: AdsService, + private readonly googleAdsAdapter: GoogleAdsAdapter, + private readonly projectService: ProjectService, + private readonly logger: AppLoggerService, + ) {} + + @Post('process-token') + @Auth() + async processAdsToken( + @Body() body: { code: string; state: string }, + @CurrentUserId() uid: string, + @Headers() headers: Record, + @Ip() requestIp: string, + ) { + const ip = getIPFromHeaders(headers) || requestIp || '' + const { code, state } = body + + if (!code || !state) { + throw new BadRequestException('Invalid Google Ads token parameters') + } + + const { pid } = await this.adsService.handleOAuthCallback(uid, code, state) + + await trackCustom(ip, headers['user-agent'], { + ev: 'ADS_CONNECTED', + }) + + return { pid } + } + + @ApiBearerAuth() + @Post(':pid/connect') + @Auth() + async connect(@Param('pid') pid: string, @CurrentUserId() uid: string) { + const project = await this.projectService.getRedisProject(pid) + this.projectService.allowedToManage(project, uid) + return this.adsService.generateConnectURL(uid, pid) + } + + @ApiBearerAuth() + @Get(':pid/status') + @Auth() + async status(@Param('pid') pid: string, @CurrentUserId() uid: string) { + const project = await this.projectService.getRedisProject(pid) + this.projectService.allowedToManage(project, uid) + return this.adsService.getStatus(pid) + } + + @ApiBearerAuth() + @Get(':pid/accounts') + @Auth() + async accounts(@Param('pid') pid: string, @CurrentUserId() uid: string) { + const project = await this.projectService.getRedisProject(pid) + this.projectService.allowedToManage(project, uid) + + const accessToken = await this.adsService.getAuthedAccessToken(pid) + return this.googleAdsAdapter.listAccessibleAccounts(accessToken) + } + + @ApiBearerAuth() + @Post(':pid/account') + @Auth() + async setAccount( + @Param('pid') pid: string, + @CurrentUserId() uid: string, + @Body() body: SelectAdsAccountDto, + ) { + const project = await this.projectService.getRedisProject(pid) + this.projectService.allowedToManage(project, uid) + + const accessToken = await this.adsService.getAuthedAccessToken(pid) + const accounts = + await this.googleAdsAdapter.listAccessibleAccounts(accessToken) + + const account = accounts.find((a) => a.customerId === body.customerId) + + if (!account) { + throw new BadRequestException( + 'The provided customerId is not available for the connected account', + ) + } + + const currency = + account.currency || + (await this.googleAdsAdapter.getAccountCurrency( + accessToken, + account.customerId, + account.loginCustomerId, + )) + + await this.adsService.setAccount( + pid, + account.customerId, + account.loginCustomerId, + currency, + ) + + // Kick off the initial backfill without blocking the response + this.backfill(pid).catch((error) => { + this.logger.error( + { error, pid }, + 'Google Ads initial backfill failed after account selection', + ) + }) + + return {} + } + + @ApiBearerAuth() + @Post(':pid/sync') + @Auth() + async sync(@Param('pid') pid: string, @CurrentUserId() uid: string) { + const project = await this.projectService.getRedisProject(pid) + this.projectService.allowedToManage(project, uid) + + const fullProject = await this.projectService.findOne({ + where: { id: pid }, + select: [ + 'id', + 'googleAdsCustomerId', + 'googleAdsLoginCustomerId', + 'googleAdsCurrency', + 'revenueCurrency', + ], + }) + + if (_isEmpty(fullProject?.googleAdsCustomerId)) { + throw new BadRequestException( + 'Google Ads account is not selected for this project', + ) + } + + await this.adsService.clearSyncError(pid) + await this.googleAdsAdapter.syncCampaignMetrics({ + id: fullProject.id, + googleAdsCustomerId: fullProject.googleAdsCustomerId, + googleAdsLoginCustomerId: fullProject.googleAdsLoginCustomerId, + googleAdsCurrency: fullProject.googleAdsCurrency, + revenueCurrency: fullProject.revenueCurrency, + }) + await this.adsService.updateLastSyncAt(pid) + + return { success: true } + } + + @ApiBearerAuth() + @Delete(':pid/disconnect') + @Auth() + @HttpCode(204) + async disconnect( + @Param('pid') pid: string, + @CurrentUserId() uid: string, + @Headers() headers: Record, + @Ip() requestIp: string, + ) { + const ip = getIPFromHeaders(headers) || requestIp || '' + + const project = await this.projectService.getRedisProject(pid) + this.projectService.allowedToManage(project, uid) + await this.adsService.disconnect(pid) + + await trackCustom(ip, headers['user-agent'], { + ev: 'ADS_DISCONNECTED', + }) + } + + private async backfill(pid: string) { + const project = await this.projectService.findOne({ + where: { id: pid }, + select: [ + 'id', + 'googleAdsCustomerId', + 'googleAdsLoginCustomerId', + 'googleAdsCurrency', + 'revenueCurrency', + ], + }) + + if (_isEmpty(project?.googleAdsCustomerId)) { + return + } + + await this.googleAdsAdapter.syncCampaignMetrics( + { + id: project.id, + googleAdsCustomerId: project.googleAdsCustomerId, + googleAdsLoginCustomerId: project.googleAdsLoginCustomerId, + googleAdsCurrency: project.googleAdsCurrency, + revenueCurrency: project.revenueCurrency, + }, + ADS_BACKFILL_DAYS, + ) + await this.adsService.updateLastSyncAt(pid) + } +} diff --git a/backend/apps/cloud/src/ads/ads.module.ts b/backend/apps/cloud/src/ads/ads.module.ts new file mode 100644 index 000000000..8bf9c8f54 --- /dev/null +++ b/backend/apps/cloud/src/ads/ads.module.ts @@ -0,0 +1,23 @@ +import { Module, forwardRef } from '@nestjs/common' + +import { ProjectModule } from '../project/project.module' +import { AppLoggerModule } from '../logger/logger.module' +import { RevenueModule } from '../revenue/revenue.module' +import { AnalyticsModule } from '../analytics/analytics.module' +import { AdsService } from './ads.service' +import { AdsController } from './ads.controller' +import { AdsAnalyticsController } from './ads-analytics.controller' +import { GoogleAdsAdapter } from './adapters/google-ads.adapter' + +@Module({ + imports: [ + forwardRef(() => ProjectModule), + AppLoggerModule, + forwardRef(() => RevenueModule), + forwardRef(() => AnalyticsModule), + ], + providers: [AdsService, GoogleAdsAdapter], + exports: [AdsService, GoogleAdsAdapter], + controllers: [AdsController, AdsAnalyticsController], +}) +export class AdsModule {} diff --git a/backend/apps/cloud/src/ads/ads.service.ts b/backend/apps/cloud/src/ads/ads.service.ts new file mode 100644 index 000000000..04f71f5e0 --- /dev/null +++ b/backend/apps/cloud/src/ads/ads.service.ts @@ -0,0 +1,906 @@ +import { randomBytes } from 'crypto' +import _isEmpty from 'lodash/isEmpty' +import _round from 'lodash/round' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' +import { + Injectable, + BadRequestException, + InternalServerErrorException, +} from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { OAuth2Client } from 'google-auth-library' +import CryptoJS from 'crypto-js' + +import { isDevelopment, PRODUCTION_ORIGIN, redis } from '../common/constants' +import { deriveKey } from '../common/utils' +import { clickhouse } from '../common/integrations/clickhouse' +import { ProjectService } from '../project/project.service' +import { + AdMetricRow, + AdsProvider, + GOOGLE_PAID_UTM_SOURCES, + PAID_UTM_MEDIUMS, + normalizeCampaignKey, +} from './interfaces/ads.interface' + +dayjs.extend(utc) + +export interface AdsCampaignRow { + campaignId: string + campaignName: string + campaignStatus: string + cost: number + clicks: number + impressions: number + ctr: number + cpc: number + conversions: number + conversionsValue: number + sessions: number + revenue: number + purchases: number + roas: number | null + cpa: number | null +} + +export interface AdsStats { + cost: number + clicks: number + impressions: number + conversions: number + ctr: number + cpc: number + sessions: number + revenue: number + purchases: number + roas: number | null + cpa: number | null + previous: { + cost: number + clicks: number + sessions: number + revenue: number + } +} + +export interface AdsChart { + x: string[] + cost: number[] + clicks: number[] + sessions: number[] +} + +// Latest-state-per-(campaign, day) view over the ReplacingMergeTree table +const AD_METRICS_DEDUP_SUBQUERY = ` + SELECT + campaign_id, + date, + argMax(campaign_name, synced_at) AS campaign_name, + argMax(campaign_status, synced_at) AS campaign_status, + argMax(cost, synced_at) AS cost, + argMax(clicks, synced_at) AS clicks, + argMax(impressions, synced_at) AS impressions, + argMax(conversions, synced_at) AS conversions, + argMax(conversions_value, synced_at) AS conversions_value + FROM ad_metrics + WHERE + pid = {pid:FixedString(12)} + AND provider = {provider:String} + AND date BETWEEN toDate({groupFrom:String}) AND toDate({groupTo:String}) + GROUP BY campaign_id, date +` + +const PAID_TRAFFIC_GUARD = `(so IN {paidSources:Array(String)} OR me IN {paidMediums:Array(String)})` + +type StoredTokens = { + access_token?: string + refresh_token?: string + scope?: string + expiry_date?: number +} + +const REDIS_STATE_PREFIX = 'ads:state:' + +const ADS_REDIRECT_URL = isDevelopment + ? 'http://localhost:3000/ads-connected' + : `${PRODUCTION_ORIGIN}/ads-connected` + +const ENCRYPTION_KEY = deriveKey('google-ads-token') + +@Injectable() +export class AdsService { + constructor( + private readonly configService: ConfigService, + private readonly projectService: ProjectService, + ) {} + + isConfigured(): boolean { + return Boolean( + this.configService.get('GOOGLE_ADS_CLIENT_ID') && + this.configService.get('GOOGLE_ADS_CLIENT_SECRET') && + this.configService.get('GOOGLE_ADS_DEVELOPER_TOKEN'), + ) + } + + private getOAuthClient() { + const clientId = this.configService.get('GOOGLE_ADS_CLIENT_ID') + const clientSecret = this.configService.get( + 'GOOGLE_ADS_CLIENT_SECRET', + ) + + if (!clientId || !clientSecret) { + throw new InternalServerErrorException( + 'Google Ads Client is not configured', + ) + } + + return new OAuth2Client(clientId, clientSecret, ADS_REDIRECT_URL) + } + + async generateConnectURL(uid: string, pid: string): Promise<{ url: string }> { + const project = await this.projectService.getRedisProject(pid) + this.projectService.allowedToManage(project, uid) + + const oauth2Client = this.getOAuthClient() + + const state = randomBytes(32).toString('hex') + await redis.set( + REDIS_STATE_PREFIX + state, + JSON.stringify({ uid, pid }), + 'EX', + 600, + ) + + const url = oauth2Client.generateAuthUrl({ + access_type: 'offline', + scope: [ + 'https://www.googleapis.com/auth/adwords', + 'https://www.googleapis.com/auth/userinfo.email', + ], + prompt: 'consent', + state, + }) + + return { url } + } + + async handleOAuthCallback(uid: string, code: string, state: string) { + if (!state) { + throw new BadRequestException('Invalid or expired OAuth state') + } + + const cached = await redis.get(REDIS_STATE_PREFIX + state) + if (!cached) { + throw new BadRequestException('Invalid or expired OAuth state') + } + + let payload: { uid?: string; pid?: string } + try { + payload = JSON.parse(cached) + } catch { + throw new BadRequestException('Invalid or expired OAuth state') + } + + if (!payload?.pid || !payload?.uid || payload.uid !== uid) { + throw new BadRequestException('Invalid or expired OAuth state') + } + + const { pid } = payload + + const project = await this.projectService.getRedisProject(pid) + + this.projectService.allowedToManage(project, uid) + + const oauth2Client = this.getOAuthClient() + const { tokens } = await oauth2Client.getToken(code) + oauth2Client.setCredentials(tokens) + + let accountEmail: string | null = null + try { + const res = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + }, + // The email is nice-to-have; never let a slow Google response hang + // the OAuth callback + signal: AbortSignal.timeout(15_000), + }) + const data = await res.json() + accountEmail = data?.email || null + } catch { + // + } + + const previous = await this.getStoredTokens(pid) + + const toStore: StoredTokens = { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token || previous.refresh_token, + expiry_date: tokens.expiry_date, + scope: tokens.scope, + } + + await this.setStoredTokens(pid, toStore) + + await this.projectService.update({ id: pid }, { + googleAdsAccountEmail: accountEmail, + googleAdsSyncError: null, + } as any) + + await redis.del(REDIS_STATE_PREFIX + state) + + return { pid } + } + + private async getStoredTokens(pid: string): Promise { + const project = await this.projectService.findOne({ + where: { id: pid }, + select: [ + 'googleAdsAccessTokenEnc', + 'googleAdsRefreshTokenEnc', + 'googleAdsTokenExpiry', + 'googleAdsScope', + ], + }) + + if (!project) return {} + + const decrypt = (val?: string | null) => { + if (!val) return undefined + try { + const bytes = CryptoJS.Rabbit.decrypt(val, ENCRYPTION_KEY) + return bytes.toString(CryptoJS.enc.Utf8) || undefined + } catch { + return undefined + } + } + + const expiry = project.googleAdsTokenExpiry + ? Number(project.googleAdsTokenExpiry) + : undefined + + return { + access_token: decrypt(project.googleAdsAccessTokenEnc), + refresh_token: decrypt(project.googleAdsRefreshTokenEnc), + expiry_date: expiry, + scope: project.googleAdsScope || undefined, + } + } + + private async setStoredTokens(pid: string, tokens: StoredTokens) { + const encrypt = (val?: string) => + val ? CryptoJS.Rabbit.encrypt(val, ENCRYPTION_KEY).toString() : null + + await this.projectService.update({ id: pid }, { + googleAdsAccessTokenEnc: encrypt(tokens.access_token), + googleAdsRefreshTokenEnc: encrypt(tokens.refresh_token), + googleAdsTokenExpiry: tokens.expiry_date as any, + googleAdsScope: tokens.scope || null, + } as any) + } + + async disconnect(pid: string) { + await this.projectService.update({ id: pid }, { + googleAdsCustomerId: null, + googleAdsLoginCustomerId: null, + googleAdsAccessTokenEnc: null, + googleAdsRefreshTokenEnc: null, + googleAdsTokenExpiry: null, + googleAdsScope: null, + googleAdsAccountEmail: null, + googleAdsCurrency: null, + googleAdsLastSyncAt: null, + googleAdsSyncError: null, + } as any) + } + + async isConnected(pid: string) { + const tokens = await this.getStoredTokens(pid) + return !_isEmpty(tokens?.refresh_token || tokens?.access_token) + } + + async getStatus(pid: string): Promise<{ + connected: boolean + available: boolean + email: string | null + customerId: string | null + currency: string | null + lastSyncAt: string | null + syncError: string | null + }> { + const available = this.isConfigured() + const connected = available && (await this.isConnected(pid)) + + if (!connected) { + return { + connected, + available, + email: null, + customerId: null, + currency: null, + lastSyncAt: null, + syncError: null, + } + } + + const project = await this.projectService.findOne({ + where: { id: pid }, + select: [ + 'googleAdsAccountEmail', + 'googleAdsCustomerId', + 'googleAdsCurrency', + 'googleAdsLastSyncAt', + 'googleAdsSyncError', + ], + }) + + return { + connected, + available, + email: project?.googleAdsAccountEmail || null, + customerId: project?.googleAdsCustomerId || null, + currency: project?.googleAdsCurrency || null, + lastSyncAt: project?.googleAdsLastSyncAt?.toISOString() || null, + syncError: project?.googleAdsSyncError || null, + } + } + + /* + Returns a valid access token for the project, refreshing (and persisting) + it when expired. Throws with the underlying OAuth error message on refresh + failure so callers can detect revoked grants ('invalid_grant'). + */ + async getAuthedAccessToken(pid: string): Promise { + const tokens = await this.getStoredTokens(pid) + if (_isEmpty(tokens) || !(tokens.refresh_token || tokens.access_token)) { + throw new BadRequestException( + 'Google Ads is not connected for this project', + ) + } + + const oauth2Client = this.getOAuthClient() + oauth2Client.setCredentials(tokens) + + const needsRefresh = + !tokens.access_token || + (tokens.expiry_date && tokens.expiry_date <= Date.now()) + + if (needsRefresh) { + const refreshed = await oauth2Client.getAccessToken() + const newAccess = + typeof refreshed === 'string' ? refreshed : refreshed?.token + if (newAccess) { + const updated: StoredTokens = { + ...tokens, + access_token: newAccess, + expiry_date: Date.now() + 55 * 60 * 1000, + } + await this.setStoredTokens(pid, updated) + return newAccess + } + } + + return tokens.access_token as string + } + + async setAccount( + pid: string, + customerId: string, + loginCustomerId: string | null, + currency: string | null, + ) { + await this.projectService.update({ id: pid }, { + googleAdsCustomerId: customerId, + googleAdsLoginCustomerId: loginCustomerId, + googleAdsCurrency: currency, + googleAdsLastSyncAt: null, + googleAdsSyncError: null, + } as any) + } + + async updateLastSyncAt(pid: string) { + await this.projectService.update({ id: pid }, { + googleAdsLastSyncAt: new Date(), + } as any) + } + + async markSyncError(pid: string, error: string) { + await this.projectService.update({ id: pid }, { + googleAdsSyncError: error.slice(0, 512), + } as any) + } + + async clearSyncError(pid: string) { + await this.projectService.update({ id: pid }, { + googleAdsSyncError: null, + } as any) + } + + async insertAdMetrics(rows: AdMetricRow[]): Promise { + if (_isEmpty(rows)) { + return + } + + const formatDateForCH = (date: Date): string => { + return date + .toISOString() + .replace('T', ' ') + .replace(/\.\d{3}Z$/, '') + } + + await clickhouse.insert({ + table: 'ad_metrics', + values: rows.map((row) => ({ + pid: row.pid, + provider: row.provider, + account_id: row.accountId, + campaign_id: row.campaignId, + campaign_name: row.campaignName, + campaign_status: row.campaignStatus, + date: row.date, + impressions: row.impressions, + clicks: row.clicks, + cost: row.cost, + original_cost: row.originalCost, + original_currency: row.originalCurrency, + currency: row.currency, + conversions: row.conversions, + conversions_value: row.conversionsValue, + synced_at: formatDateForCH(row.syncedAt), + })), + format: 'JSONEachRow', + }) + } + + private async getAdAggregates( + pid: string, + groupFrom: string, + groupTo: string, + ): Promise< + { + campaign_id: string + campaign_name: string + campaign_status: string + cost: number + clicks: number + impressions: number + conversions: number + conversions_value: number + }[] + > { + const query = ` + SELECT + campaign_id, + argMax(campaign_name, date) AS campaign_name, + argMax(campaign_status, date) AS campaign_status, + sum(cost) AS cost, + sum(clicks) AS clicks, + sum(impressions) AS impressions, + sum(conversions) AS conversions, + sum(conversions_value) AS conversions_value + FROM (${AD_METRICS_DEDUP_SUBQUERY}) + GROUP BY campaign_id + ORDER BY cost DESC + ` + + const { data } = await clickhouse + .query({ + query, + query_params: { + pid, + provider: AdsProvider.GOOGLE, + groupFrom, + groupTo, + }, + }) + .then((resultSet) => resultSet.json()) + + return data + } + + private async getSessionsPerCampaignValue( + pid: string, + groupFrom: string, + groupTo: string, + ): Promise<{ ca: string; sessions: number }[]> { + const query = ` + SELECT + ca, + uniqExact(psid) AS sessions + FROM events + WHERE + pid = {pid:FixedString(12)} + AND type = 'pageview' + AND psid IS NOT NULL + AND ca IS NOT NULL + AND ${PAID_TRAFFIC_GUARD} + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + GROUP BY ca + ` + + const { data } = await clickhouse + .query({ + query, + query_params: { + pid, + groupFrom, + groupTo, + paidSources: GOOGLE_PAID_UTM_SOURCES, + paidMediums: PAID_UTM_MEDIUMS, + }, + }) + .then((resultSet) => resultSet.json<{ ca: string; sessions: number }>()) + + return data + } + + private async getRevenuePerCampaignValue( + pid: string, + groupFrom: string, + groupTo: string, + ): Promise<{ ca: string; revenue: number; purchases: number }[]> { + // First-touch campaign of the purchasing session; revenue rows are + // linked to sessions via the swetrix_session_id metadata (session_id) + const query = ` + SELECT + s.ca AS ca, + sum(r.amount) AS revenue, + count() AS purchases + FROM ( + SELECT + toString(psid) AS psid_str, + argMin(ca, created) AS ca + FROM events + WHERE + pid = {pid:FixedString(12)} + AND type = 'pageview' + AND psid IS NOT NULL + AND ca IS NOT NULL + AND ${PAID_TRAFFIC_GUARD} + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + GROUP BY psid + ) AS s + INNER JOIN ( + SELECT + argMax(toString(session_id), synced_at) AS session_id, + argMax(amount, synced_at) AS amount, + argMax(type, synced_at) AS type + FROM revenue + WHERE + pid = {pid:FixedString(12)} + AND session_id IS NOT NULL + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + GROUP BY pid, transaction_id + ) AS r ON r.session_id = s.psid_str + WHERE r.type IN ('sale', 'subscription') + GROUP BY s.ca + ` + + const { data } = await clickhouse + .query({ + query, + query_params: { + pid, + groupFrom, + groupTo, + paidSources: GOOGLE_PAID_UTM_SOURCES, + paidMediums: PAID_UTM_MEDIUMS, + }, + }) + .then((resultSet) => + resultSet.json<{ ca: string; revenue: number; purchases: number }>(), + ) + + return data + } + + /* + Campaign rows with Swetrix-side metrics merged in. Campaigns are matched + to utm_campaign values by campaign id or (lowercased) campaign name - + users are instructed to use a final URL suffix with {campaignid}. + */ + async getCampaignRows( + pid: string, + groupFrom: string, + groupTo: string, + ): Promise { + const [adAggregates, sessionRows, revenueRows] = await Promise.all([ + this.getAdAggregates(pid, groupFrom, groupTo), + this.getSessionsPerCampaignValue(pid, groupFrom, groupTo), + this.getRevenuePerCampaignValue(pid, groupFrom, groupTo), + ]) + + const rows: AdsCampaignRow[] = adAggregates.map((agg) => ({ + campaignId: agg.campaign_id, + campaignName: agg.campaign_name, + campaignStatus: agg.campaign_status, + cost: _round(Number(agg.cost), 2), + clicks: Number(agg.clicks), + impressions: Number(agg.impressions), + ctr: + Number(agg.impressions) > 0 + ? _round((Number(agg.clicks) / Number(agg.impressions)) * 100, 2) + : 0, + cpc: + Number(agg.clicks) > 0 + ? _round(Number(agg.cost) / Number(agg.clicks), 2) + : 0, + conversions: _round(Number(agg.conversions), 2), + conversionsValue: _round(Number(agg.conversions_value), 2), + sessions: 0, + revenue: 0, + purchases: 0, + roas: null, + cpa: null, + })) + + const byKey = new Map() + for (const row of rows) { + const idKey = row.campaignId.toLowerCase() + const nameKey = row.campaignName.toLowerCase().trim() + if (!byKey.has(idKey)) byKey.set(idKey, row) + if (nameKey && !byKey.has(nameKey)) byKey.set(nameKey, row) + } + + for (const sessionRow of sessionRows) { + const match = byKey.get(normalizeCampaignKey(sessionRow.ca)) + if (match) { + match.sessions += Number(sessionRow.sessions) + } + } + + for (const revenueRow of revenueRows) { + const match = byKey.get(normalizeCampaignKey(revenueRow.ca)) + if (match) { + match.revenue = _round(match.revenue + Number(revenueRow.revenue), 2) + match.purchases += Number(revenueRow.purchases) + } + } + + for (const row of rows) { + row.roas = row.cost > 0 ? _round(row.revenue / row.cost, 2) : null + row.cpa = row.purchases > 0 ? _round(row.cost / row.purchases, 2) : null + } + + return rows + } + + async getAdsStats( + pid: string, + groupFrom: string, + groupTo: string, + ): Promise { + const periodDays = dayjs.utc(groupTo).diff(dayjs.utc(groupFrom), 'day') || 1 + + // End the previous window just before the current one starts - ad_metrics + // is daily-grain, so sharing the boundary would count that whole day twice + const previousTo = dayjs + .utc(groupFrom) + .subtract(1, 'second') + .format('YYYY-MM-DD HH:mm:ss') + const previousFrom = dayjs + .utc(previousTo) + .subtract(periodDays, 'day') + .format('YYYY-MM-DD HH:mm:ss') + + const [current, previous] = await Promise.all([ + this.getCampaignRows(pid, groupFrom, groupTo), + this.getCampaignRows(pid, previousFrom, previousTo), + ]) + + const totals = (campaignRows: AdsCampaignRow[]) => + campaignRows.reduce( + (acc, row) => ({ + cost: acc.cost + row.cost, + clicks: acc.clicks + row.clicks, + impressions: acc.impressions + row.impressions, + conversions: acc.conversions + row.conversions, + sessions: acc.sessions + row.sessions, + revenue: acc.revenue + row.revenue, + purchases: acc.purchases + row.purchases, + }), + { + cost: 0, + clicks: 0, + impressions: 0, + conversions: 0, + sessions: 0, + revenue: 0, + purchases: 0, + }, + ) + + const cur = totals(current) + const prev = totals(previous) + + return { + cost: _round(cur.cost, 2), + clicks: cur.clicks, + impressions: cur.impressions, + conversions: _round(cur.conversions, 2), + ctr: + cur.impressions > 0 + ? _round((cur.clicks / cur.impressions) * 100, 2) + : 0, + cpc: cur.clicks > 0 ? _round(cur.cost / cur.clicks, 2) : 0, + sessions: cur.sessions, + revenue: _round(cur.revenue, 2), + purchases: cur.purchases, + roas: cur.cost > 0 ? _round(cur.revenue / cur.cost, 2) : null, + cpa: cur.purchases > 0 ? _round(cur.cost / cur.purchases, 2) : null, + previous: { + cost: _round(prev.cost, 2), + clicks: prev.clicks, + sessions: prev.sessions, + revenue: _round(prev.revenue, 2), + }, + } + } + + async getAdsChart( + pid: string, + groupFrom: string, + groupTo: string, + timeBucket: string, + timezone: string, + xAxis: string[], + ): Promise { + // ad_metrics is daily-grain (account-local days), so buckets are derived + // from the date column directly - no timezone shifting is possible + const bucketExpr = + timeBucket === 'year' + ? 'toStartOfYear(date)' + : timeBucket === 'month' + ? 'toStartOfMonth(date)' + : 'date' + + const bucketFormat = + timeBucket === 'year' ? 4 : timeBucket === 'month' ? 7 : 10 + + const adQuery = ` + SELECT + toString(${bucketExpr}) AS bucket, + sum(cost) AS cost, + sum(clicks) AS clicks + FROM (${AD_METRICS_DEDUP_SUBQUERY}) + GROUP BY bucket + ORDER BY bucket + ` + + const timeBucketFunc = + timeBucket === 'year' + ? 'toStartOfYear' + : timeBucket === 'month' + ? 'toStartOfMonth' + : 'toStartOfDay' + + const sessionsQuery = ` + SELECT + toString(${timeBucketFunc}(toTimeZone(created, {timezone:String}))) AS bucket, + uniqExact(psid) AS sessions + FROM events + WHERE + pid = {pid:FixedString(12)} + AND type = 'pageview' + AND psid IS NOT NULL + AND ca IS NOT NULL + AND ${PAID_TRAFFIC_GUARD} + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + GROUP BY bucket + ORDER BY bucket + ` + + const [{ data: adData }, { data: sessionsData }] = await Promise.all([ + clickhouse + .query({ + query: adQuery, + query_params: { + pid, + provider: AdsProvider.GOOGLE, + groupFrom, + groupTo, + }, + }) + .then((resultSet) => + resultSet.json<{ bucket: string; cost: number; clicks: number }>(), + ), + clickhouse + .query({ + query: sessionsQuery, + query_params: { + pid, + groupFrom, + groupTo, + timezone, + paidSources: GOOGLE_PAID_UTM_SOURCES, + paidMediums: PAID_UTM_MEDIUMS, + }, + }) + .then((resultSet) => + resultSet.json<{ bucket: string; sessions: number }>(), + ), + ]) + + const cost = Array(xAxis.length).fill(0) + const clicks = Array(xAxis.length).fill(0) + const sessions = Array(xAxis.length).fill(0) + + for (const row of adData) { + const index = xAxis.indexOf(row.bucket.slice(0, bucketFormat)) + if (index !== -1) { + cost[index] = _round(Number(row.cost), 2) + clicks[index] = Number(row.clicks) + } + } + + for (const row of sessionsData) { + const index = xAxis.indexOf(row.bucket.slice(0, bucketFormat)) + if (index !== -1) { + sessions[index] = Number(row.sessions) + } + } + + return { + x: xAxis, + cost, + clicks, + sessions, + } + } + + /* + Lightweight map of normalized utm_campaign keys -> campaign ad metrics, + used to enrich Traffic tab campaign rows with spend/CPC chips + */ + async getCampaignMap( + pid: string, + groupFrom: string, + groupTo: string, + ): Promise< + Record< + string, + { + campaignId: string + name: string + cost: number + clicks: number + cpc: number + } + > + > { + const adAggregates = await this.getAdAggregates(pid, groupFrom, groupTo) + + const map: Record< + string, + { + campaignId: string + name: string + cost: number + clicks: number + cpc: number + } + > = {} + + for (const agg of adAggregates) { + const entry = { + campaignId: agg.campaign_id, + name: agg.campaign_name, + cost: _round(Number(agg.cost), 2), + clicks: Number(agg.clicks), + cpc: + Number(agg.clicks) > 0 + ? _round(Number(agg.cost) / Number(agg.clicks), 2) + : 0, + } + + const idKey = agg.campaign_id.toLowerCase() + const nameKey = agg.campaign_name.toLowerCase().trim() + + if (!map[idKey]) map[idKey] = entry + if (nameKey && !map[nameKey]) map[nameKey] = entry + } + + return map + } +} diff --git a/backend/apps/cloud/src/ads/dto/get-ads.dto.ts b/backend/apps/cloud/src/ads/dto/get-ads.dto.ts new file mode 100644 index 000000000..c7b894455 --- /dev/null +++ b/backend/apps/cloud/src/ads/dto/get-ads.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' +import { IsNotEmpty, IsString, IsOptional, IsEnum } from 'class-validator' +import { TimeBucketType } from '../../analytics/dto/getData.dto' + +export class GetAdsDto { + @ApiProperty({ description: 'Project ID' }) + @IsNotEmpty() + @IsString() + pid: string + + @ApiProperty({ description: 'Time period', example: '7d' }) + @IsNotEmpty() + @IsString() + period: string + + @ApiPropertyOptional({ description: 'Start date for custom period' }) + @IsOptional() + @IsString() + from?: string + + @ApiPropertyOptional({ description: 'End date for custom period' }) + @IsOptional() + @IsString() + to?: string + + @ApiPropertyOptional({ description: 'Timezone', default: 'UTC' }) + @IsOptional() + @IsString() + timezone?: string + + @ApiPropertyOptional({ + description: 'Time bucket for chart data (day or coarser)', + enum: TimeBucketType, + }) + @IsOptional() + @IsEnum(TimeBucketType) + timeBucket?: TimeBucketType +} diff --git a/backend/apps/cloud/src/ads/dto/select-account.dto.ts b/backend/apps/cloud/src/ads/dto/select-account.dto.ts new file mode 100644 index 000000000..29d2dcbf5 --- /dev/null +++ b/backend/apps/cloud/src/ads/dto/select-account.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger' +import { IsNotEmpty, IsString, Matches } from 'class-validator' + +export class SelectAdsAccountDto { + @ApiProperty({ description: 'Google Ads customer id (digits only)' }) + @IsString() + @IsNotEmpty() + @Matches(/^\d{1,20}$/) + customerId: string +} diff --git a/backend/apps/cloud/src/ads/interfaces/ads.interface.ts b/backend/apps/cloud/src/ads/interfaces/ads.interface.ts new file mode 100644 index 000000000..b5bcb9c0c --- /dev/null +++ b/backend/apps/cloud/src/ads/interfaces/ads.interface.ts @@ -0,0 +1,62 @@ +export enum AdsProvider { + GOOGLE = 'google', +} + +// Pinned Google Ads API version. Versions sunset roughly a year after +// release (v23 released 2026-01, supported until ~2027-01) - bump regularly. +export const GOOGLE_ADS_API_VERSION = 'v23' + +export const ADS_SYNC_ERROR_TOKEN_REVOKED = 'token_revoked' + +// How many days back the incremental sync re-fetches: Google restates +// conversion metrics for up to ~30 days after the click +export const ADS_SYNC_LOOKBACK_DAYS = 35 + +// How many days of history the initial sync fetches after account selection +export const ADS_BACKFILL_DAYS = 90 + +// Heuristic guard for which events count as paid Google traffic when +// attributing Swetrix sessions/revenue to campaigns +export const GOOGLE_PAID_UTM_SOURCES = ['google', 'googleads', 'google_ads'] +export const PAID_UTM_MEDIUMS = ['cpc', 'ppc', 'paid'] + +/* + Normalizes a utm_campaign value for matching against ad campaign ids/names: + URL-decoded, lowercased, trimmed +*/ +export const normalizeCampaignKey = (value: string): string => { + try { + return decodeURIComponent(value).toLowerCase().trim() + } catch { + return value.toLowerCase().trim() + } +} + +export interface AdsAccount { + customerId: string + name: string + currency: string | null + isManager: boolean + // Manager (MCC) account id that grants access to this account, sent as the + // login-customer-id header; null when the account is accessed directly + loginCustomerId: string | null +} + +export interface AdMetricRow { + pid: string + provider: AdsProvider + accountId: string + campaignId: string + campaignName: string + campaignStatus: string + date: string // YYYY-MM-DD, in the ads account's timezone + impressions: number + clicks: number + cost: number + originalCost: number + originalCurrency: string + currency: string + conversions: number + conversionsValue: number + syncedAt: Date +} diff --git a/backend/apps/cloud/src/analytics/analytics.service.ts b/backend/apps/cloud/src/analytics/analytics.service.ts index 08cde15b7..c71fab772 100644 --- a/backend/apps/cloud/src/analytics/analytics.service.ts +++ b/backend/apps/cloud/src/analytics/analytics.service.ts @@ -68,6 +68,7 @@ import { } from './bot-detection.service' import { AppLoggerService } from '../logger/logger.service' import { clickhouse } from '../common/integrations/clickhouse' +import { normalizeCampaignKey } from '../ads/interfaces/ads.interface' import { getDomainsForRefName } from './utils/referrers.map' import { calculateRelativePercentage, @@ -7650,6 +7651,10 @@ export class AnalyticsService { const replay = await this.getSessionReplaySummary(pid, psid) + const adCampaign = details?.ca + ? await this.findAdCampaignByUtm(pid, details.ca) + : null + return { pages: this.processPageflow(pages), details: { @@ -7658,6 +7663,7 @@ export class AnalyticsService { isLive, revenue: revenueTotals.revenue || 0, refunds: revenueTotals.refunds || 0, + adCampaign, }, psid, chart: chartData, @@ -7666,6 +7672,59 @@ export class AnalyticsService { } } + /* + Matches a utm_campaign value against ad campaigns synced into the + ad_metrics table (by campaign id or name), so sessions/profiles arriving + via paid campaigns can be badged in the UI. + */ + private async findAdCampaignByUtm( + pid: string, + utmCampaign: string, + ): Promise<{ + provider: string + campaignId: string + campaignName: string + } | null> { + try { + const { data } = await clickhouse + .query({ + query: ` + SELECT DISTINCT provider, campaign_id, campaign_name + FROM ad_metrics + WHERE pid = {pid:FixedString(12)} + `, + query_params: { pid }, + }) + .then((resultSet) => + resultSet.json<{ + provider: string + campaign_id: string + campaign_name: string + }>(), + ) + + const key = normalizeCampaignKey(utmCampaign) + + const match = data.find( + (row) => + row.campaign_id.toLowerCase() === key || + row.campaign_name.toLowerCase().trim() === key, + ) + + if (!match) { + return null + } + + return { + provider: match.provider, + campaignId: match.campaign_id, + campaignName: match.campaign_name, + } + } catch { + return null + } + } + async getSessionsList( filtersQuery: string, paramsData: any, @@ -8536,6 +8595,21 @@ export class AnalyticsService { ) ` + // First-touch acquisition source/campaign of the profile. + // The aggregate aliases must not shadow the so/ca source columns — the + // ClickHouse 24.8+ analyzer would resolve the WHERE references to the + // aggregates and fail with ILLEGAL_AGGREGATION. + const queryAcquisition = ` + SELECT + argMin(so, created) AS acquisitionSource, + argMin(ca, created) AS acquisitionCampaign + FROM events + WHERE pid = {pid:FixedString(12)} + AND profileId = {profileId:String} + AND type IN ('pageview', 'custom_event', 'error') + AND (so IS NOT NULL OR ca IS NOT NULL) + ` + const params = { pid, profileId } const [ @@ -8546,6 +8620,7 @@ export class AnalyticsService { errorsResult, detailsResult, revenueResult, + acquisitionResult, ] = await Promise.all([ clickhouse .query({ query: querySessionCount, query_params: params }) @@ -8568,6 +8643,9 @@ export class AnalyticsService { clickhouse .query({ query: queryRevenue, query_params: params }) .then((resultSet) => resultSet.json()), + clickhouse + .query({ query: queryAcquisition, query_params: params }) + .then((resultSet) => resultSet.json()), ]) const sessionCount = (sessionCountResult.data[0] || {}) as Record< @@ -8580,6 +8658,11 @@ export class AnalyticsService { const errors = (errorsResult.data[0] || {}) as Record const details = (detailsResult.data[0] || {}) as Record const revenue = (revenueResult.data[0] || {}) as Record + const acquisition = (acquisitionResult.data[0] || {}) as Record + + const acquisitionCampaign = acquisition.acquisitionCampaign + ? await this.findAdCampaignByUtm(pid, acquisition.acquisitionCampaign) + : null return { profileId, @@ -8593,6 +8676,11 @@ export class AnalyticsService { avgDuration: avgDuration.avgDuration || 0, totalRevenue: revenue.totalRevenue || 0, revenueCurrency: revenue.revenueCurrency || null, + acquisition: { + so: acquisition.acquisitionSource || null, + ca: acquisition.acquisitionCampaign || null, + adCampaign: acquisitionCampaign, + }, ...details, } } diff --git a/backend/apps/cloud/src/app.module.ts b/backend/apps/cloud/src/app.module.ts index 6cdd3bd40..6c908b992 100644 --- a/backend/apps/cloud/src/app.module.ts +++ b/backend/apps/cloud/src/app.module.ts @@ -31,6 +31,7 @@ import { AppController } from './app.controller' import { isPrimaryNode, isPrimaryClusterNode } from './common/utils' import { OrganisationModule } from './organisation/organisation.module' import { RevenueModule } from './revenue/revenue.module' +import { AdsModule } from './ads/ads.module' import { BullModule } from '@nestjs/bullmq' import { ToolsModule } from './tools/tools.module' import { PendingInvitationModule } from './pending-invitation/pending-invitation.module' @@ -99,6 +100,7 @@ const modules = [ HealthModule, OrganisationModule, RevenueModule, + AdsModule, ToolsModule, PendingInvitationModule, DataImportModule, diff --git a/backend/apps/cloud/src/common/utils.ts b/backend/apps/cloud/src/common/utils.ts index 2da201d66..3a6fbeccf 100644 --- a/backend/apps/cloud/src/common/utils.ts +++ b/backend/apps/cloud/src/common/utils.ts @@ -37,6 +37,7 @@ export const deriveKey = ( | 'access-token' | 'gsc-token' | 'ga4-token' + | 'google-ads-token' | 'revenue', length = 32, ) => { diff --git a/backend/apps/cloud/src/project/entity/project.entity.ts b/backend/apps/cloud/src/project/entity/project.entity.ts index 665aaf43b..a84b55fbf 100644 --- a/backend/apps/cloud/src/project/entity/project.entity.ts +++ b/backend/apps/cloud/src/project/entity/project.entity.ts @@ -1,4 +1,11 @@ -import { Entity, Column, PrimaryColumn, ManyToOne, OneToMany } from 'typeorm' +import { + Entity, + Column, + Index, + PrimaryColumn, + ManyToOne, + OneToMany, +} from 'typeorm' import { ApiProperty } from '@nestjs/swagger' import { Alert } from '../../alert/entity/alert.entity' @@ -26,6 +33,13 @@ enum CaptchaDifficultyMode { } // In case of modifying some properties here add them to the GDPR data export email template +// Supports the syncAdsData cron lookup (customerId IS NOT NULL, syncError IS +// NULL) without scanning the whole table; the TEXT token column is not +// indexable and is implied by a selected account anyway +@Index('idx_project_google_ads_sync', [ + 'googleAdsCustomerId', + 'googleAdsSyncError', +]) @Entity() export class Project { @ApiProperty() @@ -176,6 +190,39 @@ export class Project { @Column('varchar', { nullable: true, default: null, length: 256 }) gscAccountEmail: string | null + // Google Ads integration + @Column('varchar', { nullable: true, default: null, length: 32 }) + googleAdsCustomerId: string | null + + // Manager (MCC) account id used as login-customer-id header, if any + @Column('varchar', { nullable: true, default: null, length: 32 }) + googleAdsLoginCustomerId: string | null + + @Column('text', { nullable: true, default: null }) + googleAdsAccessTokenEnc: string | null + + @Column('text', { nullable: true, default: null }) + googleAdsRefreshTokenEnc: string | null + + @Column('bigint', { nullable: true, default: null }) + googleAdsTokenExpiry: string | null + + @Column('varchar', { nullable: true, default: null, length: 512 }) + googleAdsScope: string | null + + @Column('varchar', { nullable: true, default: null, length: 256 }) + googleAdsAccountEmail: string | null + + // Currency of the connected Google Ads account (customer.currency_code) + @Column('varchar', { nullable: true, default: null, length: 3 }) + googleAdsCurrency: string | null + + @Column('datetime', { nullable: true, default: null }) + googleAdsLastSyncAt: Date | null + + @Column('varchar', { nullable: true, default: null, length: 512 }) + googleAdsSyncError: string | null + // Revenue / Payment provider integration @Column('text', { nullable: true, default: null }) paddleApiKeyEnc: string | null diff --git a/backend/apps/cloud/src/task-manager/task-manager.module.ts b/backend/apps/cloud/src/task-manager/task-manager.module.ts index 83d5b1776..ea1b9a64b 100644 --- a/backend/apps/cloud/src/task-manager/task-manager.module.ts +++ b/backend/apps/cloud/src/task-manager/task-manager.module.ts @@ -14,6 +14,7 @@ import { DiscordModule } from '../integrations/discord/discord.module' import { SlackModule } from '../integrations/slack/slack.module' import { GoalModule } from '../goal/goal.module' import { RevenueModule } from '../revenue/revenue.module' +import { AdsModule } from '../ads/ads.module' import { NotificationChannelModule } from '../notification-channel/notification-channel.module' import { NotificationChannel } from '../notification-channel/entity/notification-channel.entity' import { DemoDataModule } from '../demo-data/demo-data.module' @@ -32,6 +33,7 @@ import { DemoDataModule } from '../demo-data/demo-data.module' SlackModule, GoalModule, RevenueModule, + AdsModule, NotificationChannelModule, DemoDataModule, ], diff --git a/backend/apps/cloud/src/task-manager/task-manager.service.ts b/backend/apps/cloud/src/task-manager/task-manager.service.ts index 148d1ea6b..09adcbd45 100644 --- a/backend/apps/cloud/src/task-manager/task-manager.service.ts +++ b/backend/apps/cloud/src/task-manager/task-manager.service.ts @@ -72,6 +72,9 @@ import { SlackService } from '../integrations/slack/slack.service' import { RevenueService } from '../revenue/revenue.service' import { PaddleAdapter } from '../revenue/adapters/paddle.adapter' import { StripeAdapter } from '../revenue/adapters/stripe.adapter' +import { AdsService } from '../ads/ads.service' +import { GoogleAdsAdapter } from '../ads/adapters/google-ads.adapter' +import { ADS_SYNC_ERROR_TOKEN_REVOKED } from '../ads/interfaces/ads.interface' import { ProxyDomainService } from '../project/proxy-domain.service' import { ChannelDispatcherService } from '../notification-channel/dispatchers/channel-dispatcher.service' import { @@ -437,6 +440,8 @@ export class TaskManagerService { private readonly revenueService: RevenueService, private readonly paddleAdapter: PaddleAdapter, private readonly stripeAdapter: StripeAdapter, + private readonly adsService: AdsService, + private readonly googleAdsAdapter: GoogleAdsAdapter, private readonly proxyDomainService: ProxyDomainService, private readonly channelDispatcher: ChannelDispatcherService, private readonly templateRenderer: TemplateRendererService, @@ -1483,6 +1488,73 @@ export class TaskManagerService { }) } + // Sync Google Ads campaign metrics every 6 hours. The data is daily-grain, + // so more frequent syncs only burn developer-token quota (Basic access is + // capped at 15k operations/day across all connected projects). + @Cron(CronExpression.EVERY_6_HOURS) + async syncAdsData() { + if (!this.adsService.isConfigured()) { + return + } + + const projects = await this.projectService.find({ + where: { + googleAdsRefreshTokenEnc: Not(IsNull()), + googleAdsCustomerId: Not(IsNull()), + googleAdsSyncError: IsNull(), + admin: { + planCode: Not(PlanCode.none), + dashboardBlockReason: IsNull(), + }, + }, + select: [ + 'id', + 'googleAdsCustomerId', + 'googleAdsLoginCustomerId', + 'googleAdsCurrency', + 'revenueCurrency', + ], + }) + + if (_isEmpty(projects)) { + return + } + + // Bounded concurrency: syncing every project at once would blow through + // the shared Google Ads developer-token quota + await mapLimit(projects, 5, async (project) => { + try { + await this.googleAdsAdapter.syncCampaignMetrics({ + id: project.id, + googleAdsCustomerId: project.googleAdsCustomerId, + googleAdsLoginCustomerId: project.googleAdsLoginCustomerId, + googleAdsCurrency: project.googleAdsCurrency, + revenueCurrency: project.revenueCurrency, + }) + + await this.adsService.updateLastSyncAt(project.id) + } catch (error) { + const message = String(error?.message || error) + + // A revoked grant won't recover on retry - flag the project so it is + // excluded from future syncs and the UI can prompt a reconnect + if ( + message.includes('invalid_grant') || + message.includes('(401)') || + message.includes('unauthorized_client') + ) { + await this.adsService + .markSyncError(project.id, ADS_SYNC_ERROR_TOKEN_REVOKED) + .catch(() => {}) + } + + this.logger.error( + `[CRON WORKER](syncAdsData) Error syncing project ${project.id}: ${error}`, + ) + } + }) + } + // EVERY SUNDAY AT 2:30 AM @Cron('30 02 * * 0') async weeklyReportsHandler() { diff --git a/backend/migrations/clickhouse/2026_07_07_ad_metrics.js b/backend/migrations/clickhouse/2026_07_07_ad_metrics.js new file mode 100644 index 000000000..2cef98114 --- /dev/null +++ b/backend/migrations/clickhouse/2026_07_07_ad_metrics.js @@ -0,0 +1,29 @@ +const { queriesRunner, dbName } = require('./setup') + +const queries = [ + // Daily campaign-level metrics synced from ad platforms (Google Ads, etc.) + `CREATE TABLE IF NOT EXISTS ${dbName}.ad_metrics + ( + pid FixedString(12), + provider LowCardinality(String), + account_id String CODEC(ZSTD(3)), + campaign_id String CODEC(ZSTD(3)), + campaign_name String CODEC(ZSTD(3)), + campaign_status LowCardinality(String), + date Date, + impressions UInt64, + clicks UInt64, + cost Decimal64(4), + original_cost Decimal64(4), + original_currency LowCardinality(String), + currency LowCardinality(String), + conversions Decimal64(2), + conversions_value Decimal64(4), + synced_at DateTime('UTC') CODEC(Delta(4), LZ4) + ) + ENGINE = ReplacingMergeTree(synced_at) + PARTITION BY toYYYYMM(date) + ORDER BY (pid, provider, campaign_id, date);`, +] + +queriesRunner(queries) diff --git a/backend/migrations/clickhouse/initialise_database.js b/backend/migrations/clickhouse/initialise_database.js index cc079eae6..e000acf8b 100644 --- a/backend/migrations/clickhouse/initialise_database.js +++ b/backend/migrations/clickhouse/initialise_database.js @@ -149,6 +149,30 @@ const CLICKHOUSE_INIT_QUERIES = [ PARTITION BY toYYYYMM(created) ORDER BY (pid, transaction_id);`, + // Daily campaign-level metrics synced from ad platforms (Google Ads, etc.) + `CREATE TABLE IF NOT EXISTS ${dbName}.ad_metrics + ( + pid FixedString(12), + provider LowCardinality(String), + account_id String CODEC(ZSTD(3)), + campaign_id String CODEC(ZSTD(3)), + campaign_name String CODEC(ZSTD(3)), + campaign_status LowCardinality(String), + date Date, + impressions UInt64, + clicks UInt64, + cost Decimal64(4), + original_cost Decimal64(4), + original_currency LowCardinality(String), + currency LowCardinality(String), + conversions Decimal64(2), + conversions_value Decimal64(4), + synced_at DateTime('UTC') CODEC(Delta(4), LZ4) + ) + ENGINE = ReplacingMergeTree(synced_at) + PARTITION BY toYYYYMM(date) + ORDER BY (pid, provider, campaign_id, date);`, + `CREATE TABLE IF NOT EXISTS ${dbName}.session_replay_chunks ( pid FixedString(12), diff --git a/backend/migrations/mysql/2026_07_07_google_ads.sql b/backend/migrations/mysql/2026_07_07_google_ads.sql new file mode 100644 index 000000000..0d2321684 --- /dev/null +++ b/backend/migrations/mysql/2026_07_07_google_ads.sql @@ -0,0 +1,14 @@ +ALTER TABLE project + ADD COLUMN `googleAdsCustomerId` varchar(32) DEFAULT NULL AFTER `gscAccountEmail`, + ADD COLUMN `googleAdsLoginCustomerId` varchar(32) DEFAULT NULL AFTER `googleAdsCustomerId`, + ADD COLUMN `googleAdsAccessTokenEnc` text DEFAULT NULL AFTER `googleAdsLoginCustomerId`, + ADD COLUMN `googleAdsRefreshTokenEnc` text DEFAULT NULL AFTER `googleAdsAccessTokenEnc`, + ADD COLUMN `googleAdsTokenExpiry` bigint DEFAULT NULL AFTER `googleAdsRefreshTokenEnc`, + ADD COLUMN `googleAdsScope` varchar(512) DEFAULT NULL AFTER `googleAdsTokenExpiry`, + ADD COLUMN `googleAdsAccountEmail` varchar(256) DEFAULT NULL AFTER `googleAdsScope`, + ADD COLUMN `googleAdsCurrency` varchar(3) DEFAULT NULL AFTER `googleAdsAccountEmail`, + ADD COLUMN `googleAdsLastSyncAt` datetime DEFAULT NULL AFTER `googleAdsCurrency`, + ADD COLUMN `googleAdsSyncError` varchar(512) DEFAULT NULL AFTER `googleAdsLastSyncAt`; + +ALTER TABLE project + ADD KEY `idx_project_google_ads_sync` (`googleAdsCustomerId`, `googleAdsSyncError`); diff --git a/docs/components/IntegrationsGrid.tsx b/docs/components/IntegrationsGrid.tsx index 89740373b..ead944078 100644 --- a/docs/components/IntegrationsGrid.tsx +++ b/docs/components/IntegrationsGrid.tsx @@ -104,6 +104,11 @@ const INTEGRATIONS: { icon: siGhost, iconClassName: "dark:text-white", }, + { + name: "Google Ads", + href: "/integrations/google-ads", + customIcon: GoogleGIcon, + }, { name: "Search Console", href: "/integrations/google-search-console", diff --git a/docs/content/docs/analytics-dashboard/ads.mdx b/docs/content/docs/analytics-dashboard/ads.mdx new file mode 100644 index 000000000..107538abc --- /dev/null +++ b/docs/content/docs/analytics-dashboard/ads.mdx @@ -0,0 +1,76 @@ +--- +title: Ads +slug: /analytics-dashboard/ads +--- + +The Ads dashboard shows your ad campaign performance next to your analytics, powered by the [Google Ads](/integrations/google-ads) integration. It combines campaign metrics from Google (spend, clicks, impressions, conversions) with your Swetrix data (sessions, revenue) to answer the question ad platforms can't: what did each campaign actually bring in? + + + +The Ads tab requires a connected Google Ads account. If you haven't set this up yet, see [how to connect Google Ads](/integrations/google-ads). The integration is currently available on Swetrix Cloud only. + + + +## Performance Overview + +At the top of the Ads dashboard, metric cards summarise your ad performance for the selected period: + +- **Spend** — Total ad spend across campaigns, converted to your reporting currency. +- **Ad clicks** — Clicks on your ads, as reported by Google. +- **CPC** — Average cost per click (spend ÷ clicks). +- **CTR** — Click-through rate (clicks ÷ impressions). +- **Ad sessions** — Sessions on your site that arrived via paid Google traffic with a matched campaign. +- **Attributed revenue** — Revenue from sessions attributed to your campaigns (requires [Revenue Tracking](/analytics-dashboard/revenue-tracking)). +- **ROAS** — Return on ad spend (attributed revenue ÷ spend). +- **CPA** — Cost per acquisition (spend ÷ attributed purchases). + +Spend, clicks, sessions, and revenue show comparison badges against the previous period. + +### Trend Chart + +Below the metrics, a chart visualises spend (area, left axis) alongside ad clicks and ad sessions (lines, right axis) over time. A widening gap between clicks and sessions can indicate tracking problems — for example, a missing final URL suffix or landing pages without the Swetrix script. + +## Campaigns + +The campaigns panel breaks performance down per campaign. Hover over a row for the full picture, or expand the panel for a sortable table: + +| Column | Source | Description | +| ---------------------- | ------- | ------------------------------------------------- | +| **Spend** | Google | Campaign cost in your reporting currency | +| **CPC** | Google | Average cost per click | +| **Ad sessions** | Swetrix | Sessions on your site attributed to this campaign | +| **Attributed revenue** | Swetrix | Revenue from sessions this campaign drove | +| **ROAS** | Both | Attributed revenue ÷ spend | + +Impressions, CTR, conversions (Google-reported), and CPA are shown in the row tooltip. + +## How attribution works + +Swetrix matches campaigns to your traffic through the `utm_campaign` parameter: + +1. A visitor clicks your ad and lands with `utm_source=google&utm_medium=cpc&utm_campaign={campaignid}` (set via the [final URL suffix](/integrations/google-ads#set-up-campaign-attribution)). +2. Swetrix matches the `utm_campaign` value against your synced campaigns, by campaign **ID or name** (case-insensitive). +3. The session — and any revenue it generates — is credited to that campaign (first-touch: the campaign of the session's first event). + +Sessions only count as ad traffic when their source or medium looks paid (`utm_source=google` or `utm_medium=cpc`/`ppc`/`paid`). Google's auto-tagging (`gclid`) is recognised as `google / cpc` automatically, but campaign-level matching needs the `utm_campaign` parameter. + + + +**Attributed revenue** requires two things: [Revenue Tracking](/analytics-dashboard/revenue-tracking) connected, and the Swetrix session ID passed with your transactions (`swetrix_session_id` metadata for Stripe/Paddle, `sessionId` on the revenue API). Purchases without a session link can't be traced back to a campaign, so attributed revenue is a floor, not the full total. + + + +## Ad data across the dashboard + +Once connected, campaign context appears beyond the Ads tab: + +- **Session details** — Sessions that arrived via a matched campaign show a Google Ads badge with the campaign name. +- **Profiles** — A profile's detail view shows which campaign originally acquired the user ("Acquired via"). +- **Traffic tab** — In the Campaign panel of the traffic sources breakdown, matched campaigns show their spend and CPC inline. + +## Data freshness & granularity + +- Campaign metrics sync from Google Ads several times a day. You can trigger a manual re-sync from **Project Settings > Integrations**. +- Each sync re-fetches the last 35 days, because Google restates conversion metrics for up to ~30 days after a click. Recent conversion numbers may shift slightly between syncs. +- Google reports campaign data per **day** (in your ads account's timezone), so the Ads tab has no hourly granularity and short periods like "Today" or "Last hour" are unavailable. +- Spend is converted from your ads account currency to your project's reporting currency (the same one used by Revenue Tracking), so ROAS compares like with like. diff --git a/docs/content/docs/analytics-dashboard/meta.json b/docs/content/docs/analytics-dashboard/meta.json index 33a9ef014..c46b1acb9 100644 --- a/docs/content/docs/analytics-dashboard/meta.json +++ b/docs/content/docs/analytics-dashboard/meta.json @@ -4,6 +4,7 @@ "ask-ai", "traffic", "seo", + "ads", "performance", "journeys", "funnels", diff --git a/docs/content/docs/analytics-dashboard/revenue-tracking.mdx b/docs/content/docs/analytics-dashboard/revenue-tracking.mdx index 78aca17e6..d57ee02c2 100644 --- a/docs/content/docs/analytics-dashboard/revenue-tracking.mdx +++ b/docs/content/docs/analytics-dashboard/revenue-tracking.mdx @@ -338,6 +338,12 @@ If a profile ID is found, the transaction is linked to that visitor's profile in `getProfileId()` and `getSessionId()`. +## Ad campaign ROI + +If you run Google Ads, connect the [Google Ads integration](/integrations/google-ads) to combine campaign spend with your revenue data. The [Ads dashboard](/analytics-dashboard/ads) then shows attributed revenue, ROAS, and CPA per campaign. + +For this to work, pass the **session ID** with your transactions — `sessionId` in the [Revenue API](#passing-it-through-to-swetrix) request body, or `swetrix_session_id` in Stripe/Paddle metadata. That's the link Swetrix uses to trace a purchase back to the browsing session, and from there to the ad campaign that started it. + ## Managing your connection You can disconnect or change your connected provider at any time from **Project Settings > Revenue**. Disconnecting removes the API key (or disables API ingestion) but keeps previously recorded revenue data in your dashboard. diff --git a/docs/content/docs/integration-guides.mdx b/docs/content/docs/integration-guides.mdx index 983da29cf..4f6bcfe1e 100644 --- a/docs/content/docs/integration-guides.mdx +++ b/docs/content/docs/integration-guides.mdx @@ -69,6 +69,7 @@ Swetrix integrates with a wide range of platforms, frameworks, and tools. Choose ## Google +- [Google Ads](/integrations/google-ads) - [Google Search Console](/integrations/google-search-console) - [Google Tag Manager](/gtm-integration) diff --git a/docs/content/docs/integrations/google-ads.mdx b/docs/content/docs/integrations/google-ads.mdx new file mode 100644 index 000000000..936935752 --- /dev/null +++ b/docs/content/docs/integrations/google-ads.mdx @@ -0,0 +1,91 @@ +--- +id: google-ads +title: Google Ads +sidebar_label: Google Ads +--- + +Swetrix integrates with Google Ads, powering the dedicated [Ads dashboard](/analytics-dashboard/ads) within your project. It pulls your campaign metrics — spend, clicks, impressions, and conversions — and combines them with your Swetrix analytics, so you can see not just what a campaign cost, but what it actually brought in. + +## Why connect Google Ads? + +By connecting Google Ads, you unlock a full Ads analytics tab in your project dashboard: + +- **Campaign performance:** Track spend, clicks, impressions, CTR, and CPC per campaign over time. +- **Real outcomes, not just ad metrics:** See how many Swetrix sessions each campaign drove on your site. +- **Ad ROI:** Combined with [Revenue Tracking](/analytics-dashboard/revenue-tracking), Swetrix computes attributed revenue, ROAS (return on ad spend), and CPA (cost per acquisition) per campaign. +- **Context everywhere:** Sessions and profiles acquired via a campaign are badged in their detail views, and the Traffic tab's campaign panel shows spend and CPC next to matched campaigns. +- **Read-only and private:** Swetrix only requests read-only access to your reporting data. We never modify your campaigns. + + + +The Google Ads integration is currently available on Swetrix Cloud only. Community Edition support may come later. + + + +## How to Set Up + + + +You need a Google Ads account with at least one campaign. Manager (MCC) accounts are supported — you'll be able to pick any client account your Google login can access. + + + +1. **Navigate to Project Settings:** + Open your project dashboard and click on the **Settings** tab in the navigation menu. + +2. **Go to Integrations:** + Select the **Integrations** tab from the sidebar menu. + +3. **Connect Google Account:** + Locate the **Google Ads** card and click the **Connect** button. You will be redirected to Google to authorize Swetrix to access your Google Ads data. + + _Note: We only request read-only access to your campaign reporting data._ + +4. **Select Your Ads Account:** + Once authenticated, you will be redirected back to the settings page and see your linked Google email address. + + Select the **Google Ads account** Swetrix should pull campaign metrics from. Accounts managed through an MCC are listed too. + +5. **Wait for the initial import:** + After selecting an account, Swetrix automatically imports the last 90 days of campaign data. This usually completes within a minute. From then on, metrics sync automatically several times a day. + +## Set up campaign attribution + +Google Ads reports what happened _inside_ Google (spend, clicks, impressions). To link that to what happened _on your site_ (sessions, conversions, revenue), Swetrix matches campaigns against the `utm_campaign` parameter of incoming traffic. + +Set the **Final URL suffix** in your Google Ads account so every ad click carries the right tags: + +``` +utm_source=google&utm_medium=cpc&utm_campaign={campaignid} +``` + +In Google Ads, go to **Admin > Account settings > Tracking > Final URL suffix** and paste the value above. Google replaces `{campaignid}` with the numeric campaign ID on every click, which Swetrix matches automatically. + + + +If you prefer human-readable campaign values, you can hand-write `utm_campaign` per campaign instead — Swetrix matches `utm_campaign` against the campaign **ID or name** (case-insensitive). The `{campaignid}` suffix is recommended because it keeps working when campaigns are renamed. + +Without any `utm_campaign`, Google's auto-tagging (`gclid`) still lets Swetrix attribute traffic to `google / cpc` — but not to a specific campaign. + + + +## Viewing Ads Data + +Once connected, your campaign data appears in the dedicated **Ads** tab of your project dashboard. + +1. Open your project. +2. Click the **Ads** tab in the project navigation. + +You will see spend, clicks, CPC, sessions, attributed revenue, ROAS and CPA, a trend chart, and a per-campaign breakdown. + +- [Learn more about the Ads dashboard](/analytics-dashboard/ads) + +## Measuring revenue per campaign + +To get ROAS and CPA, connect [Revenue Tracking](/analytics-dashboard/revenue-tracking) and pass the Swetrix session ID with your transactions (`swetrix_session_id` metadata for Stripe/Paddle, or `sessionId` on the revenue API). Swetrix then links each purchase to the session that made it — and to the campaign that session came from. + +## Managing your connection + +You can disconnect Google Ads at any time from **Project Settings > Integrations**. Disconnecting removes the stored tokens but keeps previously synced campaign data in your dashboard. + +If you revoke Swetrix's access from your Google account, syncing pauses and the settings page will prompt you to reconnect. diff --git a/docs/content/docs/integrations/meta.json b/docs/content/docs/integrations/meta.json index ed9272d1f..bcfee0d01 100644 --- a/docs/content/docs/integrations/meta.json +++ b/docs/content/docs/integrations/meta.json @@ -15,6 +15,7 @@ "framer", "ghost", "gitbook", + "google-ads", "google-search-console", "google-tag-manager", "hexo", diff --git a/docs/content/docs/introduction.mdx b/docs/content/docs/introduction.mdx index 871a4de5c..8bbfa81aa 100644 --- a/docs/content/docs/introduction.mdx +++ b/docs/content/docs/introduction.mdx @@ -75,6 +75,7 @@ Swetrix works with any website or web application. We provide step-by-step integ - **[Email reports](/email-reports)** — Get regular traffic summaries delivered to your inbox - **[SEO dashboard](/analytics-dashboard/seo)** — Track search performance with Google Search Console data +- **[Ads dashboard](/analytics-dashboard/ads)** — Pull in Google Ads campaign spend and measure ROAS per campaign - **[Team collaboration](/teams-api-integrations)** — Invite team members and manage access permissions - **[Custom alerts](/analytics-dashboard/alerts)** — Get notified about traffic spikes or unusual drops - **[Public dashboards](/how-to-embed)** — Share your analytics publicly or embed them in your website diff --git a/docs/content/docs/traffic-sources.mdx b/docs/content/docs/traffic-sources.mdx index 7a9efe5c6..58dd43700 100644 --- a/docs/content/docs/traffic-sources.mdx +++ b/docs/content/docs/traffic-sources.mdx @@ -126,6 +126,13 @@ Add UTMs to: - Every podcast show‑notes link - Every push notification deep link + + Running Google Ads? Set your account's **Final URL suffix** to + `utm_source=google&utm_medium=cpc&utm_campaign={campaignid}` and connect the [Google Ads + integration](/integrations/google-ads) — the [Ads dashboard](/analytics-dashboard/ads) will then + match campaigns to sessions and revenue, showing spend, ROAS, and CPA per campaign. + + ### 2. Serve your site over HTTPS If any part of your site is HTTP, links from HTTPS pages to it will diff --git a/docs/lib/source.ts b/docs/lib/source.ts index 2a3f0c424..3d21725c7 100644 --- a/docs/lib/source.ts +++ b/docs/lib/source.ts @@ -14,6 +14,7 @@ const SLUG_MAP: Record = { // Integration special cases "integrations/google-tag-manager": ["gtm-integration"], "integrations/google-search-console": ["integrations", "google-search-console"], + "integrations/google-ads": ["integrations", "google-ads"], // API files → flat URLs "api/stats": ["statistics-api"], diff --git a/web/app/api/api.server.ts b/web/app/api/api.server.ts index 0f0a05849..a276905eb 100644 --- a/web/app/api/api.server.ts +++ b/web/app/api/api.server.ts @@ -1849,6 +1849,190 @@ export async function processGSCTokenServer( }) } +// ============================================================================ +// MARK: Google Ads API +// ============================================================================ + +export async function processAdsTokenServer( + request: Request, + code: string, + state: string, +): Promise> { + return serverFetch<{ pid: string }>(request, 'v1/project/ads/process-token', { + method: 'POST', + body: { code, state }, + }) +} + +interface AdsStats { + cost: number + clicks: number + impressions: number + conversions: number + ctr: number + cpc: number + sessions: number + revenue: number + purchases: number + roas: number | null + cpa: number | null + previous: { + cost: number + clicks: number + sessions: number + revenue: number + } +} + +export interface AdsChart { + x: string[] + cost: number[] + clicks: number[] + sessions: number[] +} + +export interface AdsCampaign { + campaignId: string + campaignName: string + campaignStatus: string + cost: number + clicks: number + impressions: number + ctr: number + cpc: number + conversions: number + conversionsValue: number + sessions: number + revenue: number + purchases: number + roas: number | null + cpa: number | null +} + +export interface AdsDashboardResponse { + notConnected?: boolean + currency?: string + stats?: AdsStats + chart?: AdsChart +} + +export interface AdsCampaignMapEntry { + campaignId: string + name: string + cost: number + clicks: number + cpc: number +} + +const buildAdsQueryParams = ( + pid: string, + params: { + period?: string + from?: string + to?: string + timezone?: string + timeBucket?: string + }, +) => { + const queryParams = new URLSearchParams() + queryParams.append('pid', pid) + queryParams.append('period', params.period || '7d') + if (params.from) queryParams.append('from', params.from) + if (params.to) queryParams.append('to', params.to) + if (params.timezone) queryParams.append('timezone', params.timezone) + if (params.timeBucket) queryParams.append('timeBucket', params.timeBucket) + return queryParams +} + +export async function getAdsDashboardServer( + request: Request, + pid: string, + params: { + period?: string + from?: string + to?: string + timezone?: string + timeBucket?: string + password?: string + } = {}, +): Promise> { + const queryParams = buildAdsQueryParams(pid, params) + + const headers: Record = {} + if (params.password) { + headers['x-password'] = params.password + } + + return serverFetch( + request, + `log/ads?${queryParams.toString()}`, + { + headers, + timeoutMs: 60000, + }, + ) +} + +export async function getAdsCampaignsServer( + request: Request, + pid: string, + params: { + period?: string + from?: string + to?: string + timezone?: string + password?: string + } = {}, +): Promise> { + const queryParams = buildAdsQueryParams(pid, params) + + const headers: Record = {} + if (params.password) { + headers['x-password'] = params.password + } + + return serverFetch<{ campaigns: AdsCampaign[] }>( + request, + `log/ads/campaigns?${queryParams.toString()}`, + { + headers, + timeoutMs: 60000, + }, + ) +} + +export async function getAdsCampaignMapServer( + request: Request, + pid: string, + params: { + period?: string + from?: string + to?: string + timezone?: string + password?: string + } = {}, +): Promise< + ServerFetchResult<{ + map: Record + currency: string + }> +> { + const queryParams = buildAdsQueryParams(pid, params) + + const headers: Record = {} + if (params.password) { + headers['x-password'] = params.password + } + + return serverFetch<{ + map: Record + currency: string + }>(request, `log/ads/campaign-map?${queryParams.toString()}`, { + headers, + timeoutMs: 60000, + }) +} + // ============================================================================ // MARK: Google Analytics 4 Import API // ============================================================================ diff --git a/web/app/hooks/useAnalyticsProxy.ts b/web/app/hooks/useAnalyticsProxy.ts index dd0b01f25..36aaa137e 100644 --- a/web/app/hooks/useAnalyticsProxy.ts +++ b/web/app/hooks/useAnalyticsProxy.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import type { SessionReplaysResponse, @@ -17,6 +17,9 @@ import type { LiveStats, BotProtectionStats, BotProtectionPeriod, + AdsDashboardResponse, + AdsCampaign, + AdsCampaignMapEntry, JourneysResponse, RevenueStatus, RevenueDataResponse, @@ -657,6 +660,122 @@ export function useJourneysProxy() { return { fetchJourneys, data, error, isLoading } } +export function useAdsDashboardProxy() { + const [data, setData] = useState(null) + const [campaigns, setCampaigns] = useState(null) + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const requestIdRef = useRef(0) + + const fetchDashboard = useCallback( + async (projectId: string, params: ClientAnalyticsParams = {}) => { + const requestId = requestIdRef.current + 1 + requestIdRef.current = requestId + setIsLoading(true) + setError(null) + + try { + const [dashboardResult, campaignsResult] = await Promise.all([ + postAnalytics({ + action: 'getAdsDashboard', + projectId, + params, + }), + postAnalytics<{ campaigns: AdsCampaign[] }>({ + action: 'getAdsCampaigns', + projectId, + params, + }), + ]) + + if (requestId !== requestIdRef.current) { + return dashboardResult.data + } + + if (dashboardResult.data) { + setData(dashboardResult.data) + } + if (campaignsResult.data) { + setCampaigns(campaignsResult.data.campaigns) + } + setError(dashboardResult.error || campaignsResult.error) + return dashboardResult.data + } catch (err) { + if (requestId === requestIdRef.current) { + setError(err instanceof Error ? err.message : 'Unknown error') + } + return null + } finally { + if (requestId === requestIdRef.current) { + setIsLoading(false) + } + } + }, + [], + ) + + const resetData = useCallback(() => { + requestIdRef.current += 1 + setData(null) + setCampaigns(null) + setError(null) + setIsLoading(false) + }, []) + + return { fetchDashboard, data, campaigns, error, isLoading, resetData } +} + +export function useAdsCampaignMapProxy() { + const [map, setMap] = useState | null>( + null, + ) + const [currency, setCurrency] = useState('USD') + const [error, setError] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const requestIdRef = useRef(0) + + const fetchCampaignMap = useCallback( + async (projectId: string, params: ClientAnalyticsParams = {}) => { + const requestId = requestIdRef.current + 1 + requestIdRef.current = requestId + setIsLoading(true) + setError(null) + + try { + const result = await postAnalytics<{ + map: Record + currency: string + }>({ + action: 'getAdsCampaignMap', + projectId, + params, + }) + + if (requestId !== requestIdRef.current) { + return result.data?.map || null + } + + setMap(result.data?.map || null) + setCurrency(result.data?.currency || 'USD') + setError(result.error) + return result.data?.map || null + } catch (err) { + if (requestId === requestIdRef.current) { + setError(err instanceof Error ? err.message : 'Unknown error') + } + return null + } finally { + if (requestId === requestIdRef.current) { + setIsLoading(false) + } + } + }, + [], + ) + + return { fetchCampaignMap, map, currency, error, isLoading } +} + export function useRevenueProxy() { const [statusData, setStatusData] = useState(null) const [revenueData, setRevenueData] = useState( diff --git a/web/app/hooks/useAuthProxy.ts b/web/app/hooks/useAuthProxy.ts index ebefc494a..2d798c009 100644 --- a/web/app/hooks/useAuthProxy.ts +++ b/web/app/hooks/useAuthProxy.ts @@ -135,6 +135,22 @@ export function useAuthProxy() { [], ) + const processAdsToken = useCallback( + async (code: string, state: string): Promise<{ pid: string }> => { + const response = await fetch('/api/auth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'processAdsToken', code, state }), + }) + const result = (await response.json()) as ProxyResponse<{ pid: string }> + if (result.error || !result.data) { + throw new Error(result.error || 'Failed to process Google Ads token') + } + return result.data + }, + [], + ) + const processGA4ImportToken = useCallback( async (code: string, state: string): Promise<{ pid: string }> => { const response = await fetch('/api/auth', { @@ -207,6 +223,7 @@ export function useAuthProxy() { linkBySSOHash, processSSOToken, processGSCToken, + processAdsToken, processGA4ImportToken, authMe, linkSSOWithPassword, diff --git a/web/app/lib/constants/index.ts b/web/app/lib/constants/index.ts index 7bd4ec015..db605d322 100644 --- a/web/app/lib/constants/index.ts +++ b/web/app/lib/constants/index.ts @@ -525,6 +525,7 @@ const PRODUCTION_PROJECT_TABS = { traffic: 'traffic', performance: 'performance', seo: 'seo', + ads: 'ads', profiles: 'profiles', journeys: 'journeys', funnels: 'funnels', diff --git a/web/app/lib/models/Project.ts b/web/app/lib/models/Project.ts index 305e6459a..be0aac992 100644 --- a/web/app/lib/models/Project.ts +++ b/web/app/lib/models/Project.ts @@ -131,6 +131,13 @@ export interface SessionDetails { isLive?: boolean revenue?: number refunds?: number + adCampaign?: AdCampaignRef | null +} + +interface AdCampaignRef { + provider: string + campaignId: string + campaignName: string } export interface Profile { @@ -159,6 +166,11 @@ export interface ProfileDetails extends Profile { activityCalendar: { date: string; pageviews: number; events: number }[] totalRevenue?: number revenueCurrency?: string + acquisition?: { + so: string | null + ca: string | null + adCampaign: AdCampaignRef | null + } } export interface AnalyticsFunnel { diff --git a/web/app/pages/Project/Settings/ProjectSettings.tsx b/web/app/pages/Project/Settings/ProjectSettings.tsx index 55842bd23..29e4bae72 100644 --- a/web/app/pages/Project/Settings/ProjectSettings.tsx +++ b/web/app/pages/Project/Settings/ProjectSettings.tsx @@ -38,7 +38,10 @@ import { useRequiredParams } from '~/hooks/useRequiredParams' import { isSelfhosted, isBrowser } from '~/lib/constants' import { Project } from '~/lib/models/Project' import { useAuth } from '~/providers/AuthProvider' -import type { ProjectSettingsActionData } from '~/routes/projects.settings.$id' +import type { + ProjectSettingsActionData, + AdsAccount, +} from '~/routes/projects.settings.$id' import Button from '~/ui/Button' import GoogleGSVG from '~/ui/icons/GoogleG' import GoogleSearchConsoleSVG from '~/ui/icons/GoogleSearchConsole' @@ -172,6 +175,20 @@ const buildProjectAutosaveFormData = (updates: Partial
) => { return formData } +// Only redirect to Google OAuth URLs we can positively validate - the URL +// comes back through our API and must never become an open redirect +const getSafeGoogleAuthUrl = (url: string): string | null => { + try { + const parsed = new URL(url) + if (parsed.protocol !== 'https:') return null + if (parsed.username || parsed.password) return null + if (parsed.hostname !== 'accounts.google.com') return null + return parsed.toString() + } catch { + return null + } +} + const ProjectSettings = () => { const { user } = useAuth() @@ -185,6 +202,7 @@ const ProjectSettings = () => { const fetcher = useFetcher() const autosaveFetcher = useFetcher() const gscFetcher = useFetcher() + const adsFetcher = useFetcher() const [project, setProject] = useState(initialProject) const [form, setForm] = useState(() => @@ -215,6 +233,8 @@ const ProjectSettings = () => { useDeduplicateFetcherResponse() const shouldHandleGscData = useDeduplicateFetcherResponse() + const shouldHandleAdsData = + useDeduplicateFetcherResponse() const activeAutosave = useRef<{ updates: Partial toastKey: string @@ -403,6 +423,14 @@ const ProjectSettings = () => { const [gscEmail, setGscEmail] = useState(null) const [gscAvailable, setGscAvailable] = useState(true) + // Google Ads integration state + const [adsConnected, setAdsConnected] = useState(null) + const [adsAccounts, setAdsAccounts] = useState([]) + const [adsEmail, setAdsEmail] = useState(null) + const [adsAvailable, setAdsAvailable] = useState(true) + const [adsCustomerId, setAdsCustomerId] = useState(null) + const [adsSyncError, setAdsSyncError] = useState(null) + // CAPTCHA state const [captchaSecretKey, setCaptchaSecretKey] = useState( () => initialProject.captchaSecretKey || null, @@ -514,6 +542,10 @@ const ProjectSettings = () => { const pendingGscPropertyUri = useRef(null) const gscInitialized = useRef(false) + const [adsAccountsPending, setAdsAccountsPending] = useState(false) + const pendingAdsCustomerId = useRef(null) + const adsInitialized = useRef(false) + // Handle GSC fetcher responses useEffect(() => { if (gscFetcher.state !== 'idle' || !gscFetcher.data) return @@ -542,17 +574,7 @@ const ProjectSettings = () => { setGscProperties(properties) setGscPropertiesPending(false) } else if (intent === 'gsc-connect' && gscAuthUrl) { - const safeUrl = (() => { - try { - const parsed = new URL(gscAuthUrl) - if (parsed.protocol !== 'https:') return null - if (parsed.username || parsed.password) return null - if (parsed.hostname !== 'accounts.google.com') return null - return parsed.toString() - } catch { - return null - } - })() + const safeUrl = getSafeGoogleAuthUrl(gscAuthUrl) if (!safeUrl) { toast.error(t('apiNotifications.somethingWentWrong')) @@ -616,6 +638,99 @@ const ProjectSettings = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // Handle Google Ads fetcher responses + useEffect(() => { + if (adsFetcher.state !== 'idle' || !adsFetcher.data) return + if (!shouldHandleAdsData(adsFetcher.data)) return + + const { + intent, + adsStatus, + adsAccounts: accounts, + adsAuthUrl, + error: adsError, + } = adsFetcher.data + + if (adsFetcher.data.success) { + if (intent === 'ads-status' && adsStatus) { + setAdsConnected(adsStatus.connected) + setAdsEmail(adsStatus.email || null) + setAdsAvailable(adsStatus.available !== false) + setAdsCustomerId(adsStatus.customerId || null) + setAdsSyncError(adsStatus.syncError || null) + if (adsStatus.connected) { + setAdsAccountsPending(true) + } else { + setAdsAccounts([]) + } + } else if (intent === 'ads-accounts' && accounts) { + setAdsAccounts(accounts) + setAdsAccountsPending(false) + } else if (intent === 'ads-connect' && adsAuthUrl) { + const safeUrl = getSafeGoogleAuthUrl(adsAuthUrl) + + if (!safeUrl) { + toast.error(t('apiNotifications.somethingWentWrong')) + return + } + + window.location.href = safeUrl + } else if (intent === 'ads-disconnect') { + setAdsConnected(false) + setAdsAccounts([]) + setAdsEmail(null) + setAdsCustomerId(null) + setAdsSyncError(null) + pendingAdsCustomerId.current = null + toast.success(t('project.settings.ads.disconnected')) + } else if (intent === 'ads-set-account') { + const customerId = pendingAdsCustomerId.current + if (customerId) { + setAdsCustomerId(customerId) + pendingAdsCustomerId.current = null + } + setAdsSyncError(null) + toast.success(t('project.settings.ads.accountConnected')) + } else if (intent === 'ads-sync') { + setAdsSyncError(null) + toast.success(t('project.settings.ads.syncTriggered')) + } + } else if (adsError) { + toast.error( + typeof adsError === 'string' + ? adsError + : t('apiNotifications.somethingWentWrong'), + ) + setAdsAccountsPending(false) + + if (intent === 'ads-status') { + setAdsConnected(false) + setAdsEmail(null) + setAdsAccounts([]) + } else if (intent === 'ads-set-account') { + pendingAdsCustomerId.current = null + } + } + }, [adsFetcher.state, adsFetcher.data, t, shouldHandleAdsData]) + + // Fetch Google Ads accounts after status confirms connected + useEffect(() => { + if (adsAccountsPending && adsFetcher.state === 'idle') { + setAdsAccountsPending(false) + adsFetcher.submit({ intent: 'ads-accounts' }, { method: 'post' }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [adsAccountsPending, adsFetcher.state]) + + // Initial Google Ads status fetch (Cloud only - the self-hosted API has no + // Google Ads endpoints) + useEffect(() => { + if (isSelfhosted || adsInitialized.current) return + adsInitialized.current = true + adsFetcher.submit({ intent: 'ads-status' }, { method: 'post' }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + useEffect(() => { if (!fetcher.data) return if (!shouldHandleFetcherData(fetcher.data)) return @@ -1357,6 +1472,160 @@ const ProjectSettings = () => { )} + + {/* Google Ads is a Cloud-only integration - the self-hosted API has no ads endpoints */} + {isSelfhosted ? null : ( + <> +
+ + + + Google Ads + + {adsConnected === null ? ( + + ) : !adsAvailable ? ( +
+ + ), + }} + /> +
+ ) : !adsConnected ? ( +
+ + {t('project.settings.ads.connect')} + + +
+ ) : ( +
+ + + {adsSyncError ? ( +
+ {t('project.settings.ads.syncError')} +
+ ) : null} + + + +