fix: previews Meta vía proxy y creatividades Google PMax/RSA

Las imágenes Meta dejan de romperse en el navegador; Google trae assets en vivo.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-23 18:25:48 +02:00
parent 86225a701e
commit d05df8c371
5 changed files with 697 additions and 57 deletions

View File

@@ -0,0 +1,509 @@
/** Google Ads creatives for Ad Tracker (PMax asset groups + RSA/RDA). */
import { googleAdsConfigured } from '@/lib/ads-discovery';
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import type { AdCreativeCard } 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 }[];
};
responsiveDisplayAd?: {
headlines?: { text?: string }[];
longHeadline?: { text?: string };
descriptions?: { text?: string }[];
businessName?: string;
marketingImages?: { asset?: string }[];
squareMarketingImages?: { asset?: 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;
};
};
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);
}
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 r = await fetch(`${GADS_API}/customers/${cid}/googleAds:search`, {
method: 'POST',
headers,
body: JSON.stringify({ query }),
cache: 'no-store',
});
if (r.ok) {
const j = (await r.json()) as { results?: GaqlRow[] };
return j.results || [];
}
lastErr = await r.text();
}
throw new Error(`Google Ads ${cid}: ${lastErr.slice(0, 220)}`);
}
function ytThumb(youtubeId: string): string {
return `https://i.ytimg.com/vi/${youtubeId}/hqdefault.jpg`;
}
type AgBucket = {
adId: string;
adName: string;
campaignId: string;
campaignName: string;
status: string;
headlines: string[];
descriptions: string[];
images: string[];
youtubeIds: string[];
};
/**
* Live Google creatives: Performance Max asset groups + RSA/RDA ads.
* Metrics: prefer asset_group / ad metrics in range; else fall back to campaign ad_daily.
*/
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
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_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.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);
}
// Campaign-level spend from ad_daily as fallback for PMax
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); // portal stores campaign id as ad_id
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',
headlines: [],
descriptions: [],
images: [],
youtubeIds: [],
};
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);
if (ft === 'HEADLINE' && text) entry.headlines.push(text);
else if (ft === 'LONG_HEADLINE' && text) entry.headlines.push(text);
else if (ft === 'DESCRIPTION' && text) entry.descriptions.push(text);
else if (
(ft.includes('MARKETING_IMAGE') || ft === 'LOGO' || ft === 'LANDSCAPE_LOGO') &&
img
) {
if (!entry.images.includes(img)) entry.images.push(img);
} else if (ft === 'YOUTUBE_VIDEO' && yt) {
if (!entry.youtubeIds.includes(yt)) entry.youtubeIds.push(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;
// Skip zero-activity asset groups when we have spend elsewhere, keep if any spend
if (spend <= 0 && impressions <= 0 && clicks <= 0 && !m) continue;
const yt = entry.youtubeIds[0] || '';
const imageUrl = entry.images[0] || (yt ? ytThumb(yt) : '');
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: yt && !entry.images[0] ? 'video' : entry.images[0] ? 'pmax' : 'pmax',
thumbnailUrl: imageUrl,
imageUrl,
headline: [...new Set(entry.headlines)].slice(0, 5).join('\n'),
primaryText: [...new Set(entry.descriptions)].slice(0, 3).join('\n'),
status: entry.status,
youtubeId: yt || undefined,
});
}
for (const row of adRows) {
const ad = row.adGroupAd?.ad;
const adId = str(ad?.id);
if (!adId) continue;
const m = adMetricMap.get(adId);
// Prefer ads with activity in range; keep a few without if needed
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 = [
...(rsa?.headlines || []).map((h) => str(h.text)),
...(rda?.headlines || []).map((h) => str(h.text)),
str(rda?.longHeadline?.text),
].filter(Boolean);
const descriptions = [
...(rsa?.descriptions || []).map((d) => str(d.text)),
...(rda?.descriptions || []).map((d) => str(d.text)),
].filter(Boolean);
const imageUrl = str(ad?.imageAd?.imageUrl);
const adType = enumName(ad?.type).toLowerCase() || 'rsa';
const mediaType = rsa
? 'rsa'
: rda
? 'rda'
: imageUrl
? 'image'
: adType;
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: [...new Set(headlines)].slice(0, 5).join('\n'),
primaryText: [...new Set(descriptions)].slice(0, 3).join('\n'),
status: enumName(row.adGroupAd?.status),
});
}
cards.sort((a, b) => b.spend - a.spend);
const sliced = cards.slice(0, limit);
if (!sliced.length) {
// Fallback: campaign cards from ad_daily (text only)
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',
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',
};
}
}

View File

@@ -36,6 +36,7 @@ export type AdCreativeCard = {
headline: string;
primaryText: string;
status?: string;
youtubeId?: string;
};
type DailyAgg = {
@@ -193,8 +194,9 @@ async function fetchMetaCreativeBundle(
}, token);
let creative = (raw.creative || {}) as Record<string, unknown>;
// Only hydrate creative detail when thumbnail/image missing
const cid = str(creative.id);
if (cid) {
if (cid && !str(creative.thumbnail_url) && !str(creative.image_url)) {
try {
const detail = await graphGet<Record<string, unknown>>(
cid,
@@ -248,16 +250,16 @@ export async function fetchMetaCreativeCards(opts: {
'meta',
opts.since,
opts.until
).slice(0, opts.limit ?? 40);
).slice(0, opts.limit ?? 16);
if (!aggs.length) {
return { ads: [], warning: 'Sin ads Meta en ad_daily para este periodo' };
}
const out: AdCreativeCard[] = [];
// Sequential batches of 5 to respect rate limits
for (let i = 0; i < aggs.length; i += 5) {
const chunk = aggs.slice(i, i + 5);
// Parallel batches of 8 — single Graph call per ad (thumbnail already in creative{})
for (let i = 0; i < aggs.length; i += 8) {
const chunk = aggs.slice(i, i + 8);
const bundles = await Promise.all(
chunk.map((a) => fetchMetaCreativeBundle(a.adId, token))
);