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>
593 lines
18 KiB
TypeScript
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',
|
|
};
|
|
}
|
|
}
|