/** Google Ads creatives for Ad Tracker (PMax asset groups + RSA/RDA, full assets). */ import { googleAdsConfigured } from '@/lib/ads-discovery'; import { serviceHeaders, supabaseUrl } from '@/lib/agency'; import type { AdCreativeCard, CreativeAsset } from '@/lib/ad-creatives-meta'; import { aggregateDailyByAd } from '@/lib/ad-creatives-meta'; const GADS_API = 'https://googleads.googleapis.com/v22'; type GaqlRow = { campaign?: { id?: string; name?: string; status?: string }; adGroup?: { id?: string; name?: string }; adGroupAd?: { status?: string; ad?: { id?: string; name?: string; type?: string; finalUrls?: string[]; responsiveSearchAd?: { headlines?: { text?: string }[]; descriptions?: { text?: string }[]; path1?: string; path2?: string; }; responsiveDisplayAd?: { headlines?: { text?: string }[]; longHeadline?: { text?: string }; descriptions?: { text?: string }[]; businessName?: string; callToActionText?: string; }; imageAd?: { imageUrl?: string; name?: string }; }; }; assetGroup?: { id?: string; name?: string; status?: string; finalUrls?: string[]; }; assetGroupAsset?: { fieldType?: string; status?: string }; asset?: { id?: string; type?: string; textAsset?: { text?: string }; imageAsset?: { fullSize?: { url?: string } }; youtubeVideoAsset?: { youtubeVideoId?: string; youtubeVideoTitle?: string; }; callToActionAsset?: { callToAction?: string }; }; metrics?: { costMicros?: string; impressions?: string; clicks?: string; conversions?: number; }; }; function n(v: unknown): number { const x = Number(v); return Number.isFinite(x) ? x : 0; } function str(v: unknown): string { return v == null ? '' : String(v).trim(); } function enumName(v?: string): string { return (v || '').replace(/^[A-Z0-9]+_/, '') || str(v); } function uniq(arr: string[]): string[] { return [...new Set(arr.filter(Boolean))]; } async function googleAccessToken(): Promise { const refresh = process.env.GOOGLE_ADS_REFRESH_TOKEN?.trim() || process.env.GOOGLE_OAUTH_REFRESH_TOKEN?.trim(); const clientId = process.env.GOOGLE_ADS_CLIENT_ID?.trim() || process.env.GOOGLE_OAUTH_CLIENT_ID?.trim(); const clientSecret = process.env.GOOGLE_ADS_CLIENT_SECRET?.trim() || process.env.GOOGLE_OAUTH_CLIENT_SECRET?.trim(); if (!refresh || !clientId || !clientSecret) { throw new Error('Faltan credenciales Google Ads'); } const body = new URLSearchParams({ refresh_token: refresh, client_id: clientId, client_secret: clientSecret, grant_type: 'refresh_token', }); const r = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); if (!r.ok) throw new Error(`Google token: ${(await r.text()).slice(0, 180)}`); return ((await r.json()) as { access_token: string }).access_token; } function gadsHeaders( accessToken: string, loginCustomerId?: string ): Record { const h: Record = { Authorization: `Bearer ${accessToken}`, 'developer-token': process.env.GOOGLE_ADS_DEVELOPER_TOKEN!.trim(), 'Content-Type': 'application/json', }; const login = loginCustomerId || process.env.GOOGLE_ADS_LOGIN_CUSTOMER_ID?.trim()?.replace(/-/g, ''); if (login) h['login-customer-id'] = login; return h; } async function resolveGoogleCustomerId(tenantId: string): Promise { const headers = serviceHeaders(); const base = supabaseUrl(); if (!headers || !base) return null; const r = await fetch( `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.google_ads&select=external_account_id,status&limit=1`, { headers, cache: 'no-store' } ); if (!r.ok) return null; const rows = (await r.json()) as { external_account_id?: string | null }[]; const id = rows[0]?.external_account_id?.trim()?.replace(/-/g, ''); return id || null; } async function searchGoogle( customerId: string, accessToken: string, query: string ): Promise { const cid = customerId.replace(/-/g, ''); const attempts = [ gadsHeaders(accessToken), gadsHeaders(accessToken, cid), ]; let lastErr = ''; for (const headers of attempts) { const out: GaqlRow[] = []; let pageToken: string | undefined; let ok = false; for (let page = 0; page < 20; page++) { const body: { query: string; pageToken?: string } = { query }; if (pageToken) body.pageToken = pageToken; const r = await fetch(`${GADS_API}/customers/${cid}/googleAds:search`, { method: 'POST', headers, body: JSON.stringify(body), cache: 'no-store', }); if (!r.ok) { lastErr = await r.text(); break; } ok = true; const j = (await r.json()) as { results?: GaqlRow[]; nextPageToken?: string; }; out.push(...(j.results || [])); pageToken = j.nextPageToken; if (!pageToken) break; } if (ok) return out; } throw new Error(`Google Ads ${cid}: ${lastErr.slice(0, 220)}`); } function ytThumb(youtubeId: string): string { return `https://i.ytimg.com/vi/${youtubeId}/hqdefault.jpg`; } function pickVerticalGoogle(assets: CreativeAsset[]): string { const images = assets.filter((a) => a.kind === 'image' && a.url); const portrait = images.find((a) => /PORTRAIT|TALL|VERTICAL/i.test(a.label || '') ); if (portrait) return portrait.url; const square = images.find((a) => /SQUARE/i.test(a.label || '')); if (square) return square.url; return images[0]?.url || ''; } type AgBucket = { adId: string; adName: string; campaignId: string; campaignName: string; status: string; finalUrls: string[]; headlines: string[]; longHeadlines: string[]; descriptions: string[]; ctas: string[]; assets: CreativeAsset[]; }; /** * Live Google creatives: Performance Max asset groups + RSA/RDA ads. * Returns full asset packs (images, videos, logos, texts) for zoom UI. */ export async function fetchGoogleCreativeCards(opts: { tenantId: string; dailyRows: Parameters[0]; since: string; until: string; limit?: number; }): Promise<{ ads: AdCreativeCard[]; warning?: string }> { if (!googleAdsConfigured()) { return { ads: [], warning: 'Google Ads API no configurada (GOOGLE_ADS_*)', }; } const customerId = await resolveGoogleCustomerId(opts.tenantId); if (!customerId) { return { ads: [], warning: 'Cliente sin cuenta Google Ads enlazada' }; } let accessToken: string; try { accessToken = await googleAccessToken(); } catch (e) { return { ads: [], warning: e instanceof Error ? e.message : 'Token Google Ads falló', }; } const dateWhere = `segments.date BETWEEN '${opts.since}' AND '${opts.until}'`; const limit = opts.limit ?? 24; try { const [agAssets, agMetrics, adRows, adMetrics] = await Promise.all([ searchGoogle( customerId, accessToken, ` SELECT campaign.id, campaign.name, asset_group.id, asset_group.name, asset_group.status, asset_group.final_urls, asset_group_asset.field_type, asset.id, asset.type, asset.text_asset.text, asset.image_asset.full_size.url, asset.youtube_video_asset.youtube_video_id, asset.youtube_video_asset.youtube_video_title, asset.call_to_action_asset.call_to_action FROM asset_group_asset WHERE asset_group.status != 'REMOVED' AND asset_group_asset.status != 'REMOVED' ` ), searchGoogle( customerId, accessToken, ` SELECT asset_group.id, metrics.cost_micros, metrics.impressions, metrics.clicks, metrics.conversions FROM asset_group WHERE ${dateWhere} ` ), searchGoogle( customerId, accessToken, ` SELECT campaign.id, campaign.name, ad_group.id, ad_group.name, ad_group_ad.status, ad_group_ad.ad.id, ad_group_ad.ad.name, ad_group_ad.ad.type, ad_group_ad.ad.final_urls, ad_group_ad.ad.responsive_search_ad.headlines, ad_group_ad.ad.responsive_search_ad.descriptions, ad_group_ad.ad.responsive_search_ad.path1, ad_group_ad.ad.responsive_search_ad.path2, ad_group_ad.ad.responsive_display_ad.headlines, ad_group_ad.ad.responsive_display_ad.long_headline, ad_group_ad.ad.responsive_display_ad.descriptions, ad_group_ad.ad.responsive_display_ad.business_name, ad_group_ad.ad.responsive_display_ad.call_to_action_text, ad_group_ad.ad.image_ad.image_url, ad_group_ad.ad.image_ad.name FROM ad_group_ad WHERE ad_group_ad.status != 'REMOVED' AND campaign.status != 'REMOVED' LIMIT 80 ` ), searchGoogle( customerId, accessToken, ` SELECT ad_group_ad.ad.id, metrics.cost_micros, metrics.impressions, metrics.clicks, metrics.conversions FROM ad_group_ad WHERE ${dateWhere} ` ), ]); const agMetricMap = new Map< string, { spend: number; impressions: number; clicks: number; conversions: number } >(); for (const row of agMetrics) { const id = str(row.assetGroup?.id); if (!id) continue; const cur = agMetricMap.get(id) || { spend: 0, impressions: 0, clicks: 0, conversions: 0, }; cur.spend += n(row.metrics?.costMicros) / 1_000_000; cur.impressions += n(row.metrics?.impressions); cur.clicks += n(row.metrics?.clicks); cur.conversions += n(row.metrics?.conversions); agMetricMap.set(id, cur); } const adMetricMap = new Map< string, { spend: number; impressions: number; clicks: number; conversions: number } >(); for (const row of adMetrics) { const id = str(row.adGroupAd?.ad?.id); if (!id) continue; const cur = adMetricMap.get(id) || { spend: 0, impressions: 0, clicks: 0, conversions: 0, }; cur.spend += n(row.metrics?.costMicros) / 1_000_000; cur.impressions += n(row.metrics?.impressions); cur.clicks += n(row.metrics?.clicks); cur.conversions += n(row.metrics?.conversions); adMetricMap.set(id, cur); } const dailyAggs = aggregateDailyByAd( opts.dailyRows, 'google', opts.since, opts.until ); const campaignSpend = new Map(); for (const a of dailyAggs) { campaignSpend.set(a.adId, a); if (a.campaignName) campaignSpend.set(a.campaignName.toLowerCase(), a); } const agMap = new Map(); for (const row of agAssets) { const agid = str(row.assetGroup?.id); if (!agid) continue; let entry = agMap.get(agid); if (!entry) { entry = { adId: `ag_${agid}`, adName: str(row.assetGroup?.name) || `Asset group ${agid}`, campaignId: str(row.campaign?.id), campaignName: str(row.campaign?.name), status: enumName(row.assetGroup?.status) || 'ENABLED', finalUrls: (row.assetGroup?.finalUrls || []).map(str).filter(Boolean), headlines: [], longHeadlines: [], descriptions: [], ctas: [], assets: [], }; agMap.set(agid, entry); } const ft = enumName(row.assetGroupAsset?.fieldType).toUpperCase(); const text = str(row.asset?.textAsset?.text); const img = str(row.asset?.imageAsset?.fullSize?.url); const yt = str(row.asset?.youtubeVideoAsset?.youtubeVideoId); const ytTitle = str(row.asset?.youtubeVideoAsset?.youtubeVideoTitle); const cta = enumName(row.asset?.callToActionAsset?.callToAction); if (ft === 'HEADLINE' && text) entry.headlines.push(text); else if (ft === 'LONG_HEADLINE' && text) entry.longHeadlines.push(text); else if (ft === 'DESCRIPTION' && text) entry.descriptions.push(text); else if (ft === 'CALL_TO_ACTION_SELECTION' && cta) entry.ctas.push(cta); else if (ft.includes('MARKETING_IMAGE') && img) { if (!entry.assets.some((a) => a.url === img)) { entry.assets.push({ kind: 'image', url: img, label: ft }); } } else if ((ft === 'LOGO' || ft === 'LANDSCAPE_LOGO') && img) { if (!entry.assets.some((a) => a.url === img)) { entry.assets.push({ kind: 'logo', url: img, label: ft }); } } else if (ft === 'YOUTUBE_VIDEO' && yt) { if (!entry.assets.some((a) => a.youtubeId === yt)) { entry.assets.push({ kind: 'youtube', url: ytThumb(yt), label: 'YOUTUBE_VIDEO', youtubeId: yt, title: ytTitle || yt, }); } } } const cards: AdCreativeCard[] = []; for (const [agid, entry] of agMap) { const m = agMetricMap.get(agid); const camp = campaignSpend.get(entry.campaignId) || campaignSpend.get(entry.campaignName.toLowerCase()); const spend = m?.spend ?? camp?.spend ?? 0; const impressions = m?.impressions ?? camp?.impressions ?? 0; const clicks = m?.clicks ?? camp?.clicks ?? 0; const conversions = m?.conversions ?? camp?.conversions ?? 0; if (spend <= 0 && impressions <= 0 && clicks <= 0 && !m) continue; const headlines = uniq(entry.headlines); const longHeadlines = uniq(entry.longHeadlines); const descriptions = uniq(entry.descriptions); const preview = pickVerticalGoogle(entry.assets) || entry.assets.find((a) => a.kind === 'youtube')?.url || ''; const firstYt = entry.assets.find((a) => a.youtubeId)?.youtubeId; cards.push({ platform: 'google', adId: entry.adId, adName: entry.adName, campaignName: entry.campaignName, adsetName: 'Performance Max', spend: Math.round(spend * 100) / 100, impressions, clicks, conversions: Math.round(conversions * 100) / 100, cpl: conversions > 0 ? spend / conversions : null, mediaType: 'pmax', thumbnailUrl: preview, imageUrl: preview, headline: [...headlines, ...longHeadlines].join('\n'), primaryText: descriptions.join('\n'), headlines, longHeadlines, descriptions, assets: entry.assets, linkUrl: entry.finalUrls[0], cta: uniq(entry.ctas)[0], status: entry.status, youtubeId: firstYt, }); } for (const row of adRows) { const ad = row.adGroupAd?.ad; const adId = str(ad?.id); if (!adId) continue; const m = adMetricMap.get(adId); const spend = m?.spend ?? 0; const impressions = m?.impressions ?? 0; const clicks = m?.clicks ?? 0; const conversions = m?.conversions ?? 0; if (!m && cards.length >= limit) continue; const rsa = ad?.responsiveSearchAd; const rda = ad?.responsiveDisplayAd; const headlines = uniq([ ...(rsa?.headlines || []).map((h) => str(h.text)), ...(rda?.headlines || []).map((h) => str(h.text)), ]); const longHeadlines = uniq([str(rda?.longHeadline?.text)]); const descriptions = uniq([ ...(rsa?.descriptions || []).map((d) => str(d.text)), ...(rda?.descriptions || []).map((d) => str(d.text)), ]); const imageUrl = str(ad?.imageAd?.imageUrl); const assets: CreativeAsset[] = []; if (imageUrl) assets.push({ kind: 'image', url: imageUrl, label: 'IMAGE_AD' }); const adType = enumName(ad?.type).toLowerCase() || 'rsa'; const mediaType = rsa ? 'rsa' : rda ? 'rda' : imageUrl ? 'image' : adType; const paths = [str(rsa?.path1), str(rsa?.path2)].filter(Boolean); cards.push({ platform: 'google', adId, adName: str(ad?.name) || headlines[0] || `Ad ${adId}`, campaignName: str(row.campaign?.name), adsetName: str(row.adGroup?.name), spend: Math.round(spend * 100) / 100, impressions, clicks, conversions: Math.round(conversions * 100) / 100, cpl: conversions > 0 ? spend / conversions : null, mediaType, thumbnailUrl: imageUrl, imageUrl, headline: [...headlines, ...longHeadlines].join('\n'), primaryText: descriptions.join('\n'), headlines, longHeadlines, descriptions, assets, linkUrl: (ad?.finalUrls || []).map(str).filter(Boolean)[0], cta: str(rda?.callToActionText) || undefined, businessName: str(rda?.businessName) || undefined, status: enumName(row.adGroupAd?.status), }); if (paths.length) { // encode paths into descriptions note for zoom cards[cards.length - 1].descriptions = [ ...descriptions, `Path: /${paths.join('/')}`, ]; } } cards.sort((a, b) => b.spend - a.spend); const sliced = cards .filter( (c) => c.spend > 0 || c.impressions > 0 || c.clicks > 0 || c.conversions > 0 ) .slice(0, limit); if (!sliced.length) { const fallback = dailyAggs.slice(0, limit).map((agg) => ({ platform: 'google' as const, adId: agg.adId, adName: agg.adName || agg.campaignName || agg.adId, campaignName: agg.campaignName, spend: Math.round(agg.spend * 100) / 100, impressions: agg.impressions, clicks: agg.clicks, conversions: agg.conversions, cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null, mediaType: 'campaign', thumbnailUrl: '', imageUrl: '', headline: agg.campaignName || agg.adName, primaryText: 'Sin assets creativos en la API', headlines: [agg.campaignName || agg.adName].filter(Boolean), descriptions: ['Sin assets creativos en la API'], assets: [] as CreativeAsset[], status: undefined, })); return { ads: fallback, warning: fallback.length ? 'Google: sin assets PMax/RSA; mostrando campañas de ad_daily' : 'Sin ads Google en este periodo', }; } return { ads: sliced }; } catch (e) { return { ads: [], warning: e instanceof Error ? e.message : 'Error Google creatives', }; } }