Files
4ventures-client-portal/src/lib/ad-creatives-google.ts
4Ventures 4b6f7f1896 feat: Ads KPIs completos, Social feed+analytics, Orgánico por sección
Meta/Google con las mismas métricas que Resumen y solo campañas/ads con actividad en el periodo; Social con insights FB/IG y preview de posts (likes/comentarios/shares); Orgánico con paneles distintos por ítem del menú y prioridades/notas solo en visión general.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-24 16:30:17 +02:00

593 lines
18 KiB
TypeScript

/** 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<string> {
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<string, string> {
const h: Record<string, string> = {
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<string | null> {
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<GaqlRow[]> {
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<typeof aggregateDailyByAd>[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<string, (typeof dailyAggs)[number]>();
for (const a of dailyAggs) {
campaignSpend.set(a.adId, a);
if (a.campaignName) campaignSpend.set(a.campaignName.toLowerCase(), a);
}
const agMap = new Map<string, AgBucket>();
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',
};
}
}