diff --git a/src/app/api/ad-tracker/creatives/route.ts b/src/app/api/ad-tracker/creatives/route.ts index d620049..f12c2d4 100644 --- a/src/app/api/ad-tracker/creatives/route.ts +++ b/src/app/api/ad-tracker/creatives/route.ts @@ -74,23 +74,33 @@ export async function GET(req: NextRequest) { } | null = null; if (platform === 'meta' || platform === 'all') { - const [meta, clickSplit] = await Promise.all([ - fetchMetaCreativeCards({ - tenantId: ctx.tenantId, - dailyRows: daily, - since, - until, - limit: 16, - }), - fetchMetaAccountClickSplit({ - tenantId: ctx.tenantId, - since, - until, - }), - ]); - metaAds = meta.ads; - metaClickSplit = clickSplit; - if (meta.warning) warnings.push(meta.warning); + try { + const [meta, clickSplit] = await Promise.all([ + fetchMetaCreativeCards({ + tenantId: ctx.tenantId, + dailyRows: daily, + since, + until, + limit: 16, + }), + fetchMetaAccountClickSplit({ + tenantId: ctx.tenantId, + since, + until, + }).catch(() => null), + ]); + metaAds = meta.ads; + metaClickSplit = clickSplit; + // Only keep hard configuration warnings (no Graph soft-fail noise) + if ( + meta.warning && + /sin token meta|sin cuenta meta|sin actividad meta/i.test(meta.warning) + ) { + warnings.push(meta.warning); + } + } catch { + metaAds = []; + } } if (platform === 'google' || platform === 'all') { diff --git a/src/components/AdCreativesGallery.tsx b/src/components/AdCreativesGallery.tsx index a7b647d..f2335a6 100644 --- a/src/components/AdCreativesGallery.tsx +++ b/src/components/AdCreativesGallery.tsx @@ -133,13 +133,17 @@ export function CreativesGallery({ const platformWarning = warning ?.split(' · ') - .find((w) => - platform === 'meta' - ? /meta|token meta|sin ads meta/i.test(w) + .find((w) => { + // Never surface soft Graph/insights failures when we still render cards + if (/insights ad-level|fetch failed|métricas repartidas|sin imagen/i.test(w)) { + return false; + } + return platform === 'meta' + ? /meta|token meta|sin ads meta|sin actividad meta|sin cuenta meta/i.test(w) : /google|pmax|rsa|sin ads google|sin assets|oauth|warehouse|reconecta/i.test( w - ) - ) || + ); + }) || (platform === 'google' && warning && /google|oauth|token/i.test(warning) ? warning : null); diff --git a/src/lib/ad-creatives-meta.ts b/src/lib/ad-creatives-meta.ts index ad1e8aa..e1efb06 100644 --- a/src/lib/ad-creatives-meta.ts +++ b/src/lib/ad-creatives-meta.ts @@ -277,17 +277,79 @@ function pickVerticalPreview(assets: CreativeAsset[], fallback: string): string return images[0]?.url || fallback; } +async function sleep(ms: number) { + await new Promise((r) => setTimeout(r, ms)); +} + async function graphGet( path: string, params: Record, token: string ): Promise { const q = new URLSearchParams({ ...params, access_token: token }); - const r = await fetch(`${META_GRAPH}/${path}?${q}`, { cache: 'no-store' }); - if (!r.ok) { - throw new Error(`Meta ${path}: ${(await r.text()).slice(0, 220)}`); + const url = `${META_GRAPH}/${path}?${q}`; + let lastMsg = 'fetch failed'; + for (let attempt = 0; attempt < 3; attempt++) { + try { + const r = await fetch(url, { + cache: 'no-store', + signal: AbortSignal.timeout(28_000), + }); + if (r.ok) return (await r.json()) as T; + lastMsg = (await r.text()).slice(0, 220); + // Rate limit / transient + if (r.status === 429 || r.status >= 500) { + await sleep(500 * (attempt + 1)); + continue; + } + break; + } catch (e) { + lastMsg = e instanceof Error ? e.message : String(e); + await sleep(400 * (attempt + 1)); + } } - return (await r.json()) as T; + throw new Error(`Meta ${path}: ${lastMsg}`); +} + +function cleanMetaAdName(name: string): string { + if (/^__lc:/.test(name)) return ''; + return name; +} + +/** Campaign-level cards from warehouse — always available, never throws. */ +function metaCardsFromDaily( + dailyRows: Parameters[0], + since: string, + until: string, + limit: number +): AdCreativeCard[] { + return aggregateDailyByAd(dailyRows, 'meta', since, until) + .slice(0, limit) + .map((agg) => { + const adName = + cleanMetaAdName(agg.adName) || agg.campaignName || agg.adId; + return { + platform: 'meta' as const, + adId: agg.adId, + adName, + campaignName: agg.campaignName, + spend: Math.round(agg.spend * 100) / 100, + impressions: agg.impressions, + clicks: agg.clicks, + conversions: Math.round(agg.conversions * 100) / 100, + cpl: agg.conversions > 0 ? agg.spend / agg.conversions : null, + mediaType: 'campaign', + thumbnailUrl: '', + imageUrl: '', + headline: adName, + primaryText: agg.campaignName, + headlines: adName ? [adName] : [], + bodies: [], + descriptions: agg.campaignName ? [agg.campaignName] : [], + assets: [] as CreativeAsset[], + status: undefined, + }; + }); } async function resolveMetaAccountId(tenantId: string): Promise { @@ -460,6 +522,7 @@ type MetaAdInsight = { /** * ad_daily is campaign-level for Meta (ad_id === campaign_id). * Pull real ad-level insights for the range so creatives resolve correctly. + * Splits long ranges into ~14d windows (Meta often fails on large ad-level pulls). */ async function fetchMetaAdLevelInsights( accountId: string, @@ -469,54 +532,80 @@ async function fetchMetaAdLevelInsights( limit: number ): Promise { const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`; - const out: MetaAdInsight[] = []; - let after: string | undefined; - for (let page = 0; page < 8; page++) { - const params: Record = { - level: 'ad', - time_increment: 'all_days', - time_range: JSON.stringify({ since, until }), - fields: - 'ad_id,ad_name,campaign_id,campaign_name,impressions,clicks,spend,actions', - limit: '100', - }; - if (after) params.after = after; - const raw = await graphGet<{ - data?: { - ad_id?: string; - ad_name?: string; - campaign_id?: string; - campaign_name?: string; - impressions?: string; - clicks?: string; - spend?: string; - actions?: { action_type?: string; value?: string }[]; - }[]; - paging?: { cursors?: { after?: string }; next?: string }; - }>(`${act}/insights`, params, token); - - for (const row of raw.data || []) { - const adId = str(row.ad_id); - if (!adId) continue; - // Same metric as ad_daily / Ads Manager «Clientes potenciales» - // (do not sum lead + lead_grouped + registration — they overlap ~3×). - const conversions = leadFromMetaActions(row.actions); - out.push({ - adId, - adName: str(row.ad_name), - campaignId: str(row.campaign_id), - campaignName: str(row.campaign_name), - spend: Math.round(n(row.spend) * 100) / 100, - impressions: n(row.impressions), - clicks: n(row.clicks), - conversions, + const windows: { since: string; until: string }[] = []; + { + const start = new Date(since + 'T00:00:00Z'); + const end = new Date(until + 'T00:00:00Z'); + let cur = new Date(start); + while (cur <= end) { + const wEnd = new Date(cur); + wEnd.setUTCDate(wEnd.getUTCDate() + 13); + if (wEnd > end) wEnd.setTime(end.getTime()); + windows.push({ + since: cur.toISOString().slice(0, 10), + until: wEnd.toISOString().slice(0, 10), }); + cur = new Date(wEnd); + cur.setUTCDate(cur.getUTCDate() + 1); } - after = raw.paging?.cursors?.after; - if (!raw.paging?.next || !after) break; } - return out + const byAd = new Map(); + + for (const w of windows) { + let after: string | undefined; + for (let page = 0; page < 8; page++) { + const params: Record = { + level: 'ad', + time_increment: 'all_days', + time_range: JSON.stringify({ since: w.since, until: w.until }), + fields: + 'ad_id,ad_name,campaign_id,campaign_name,impressions,clicks,spend,actions', + limit: '100', + }; + if (after) params.after = after; + const raw = await graphGet<{ + data?: { + ad_id?: string; + ad_name?: string; + campaign_id?: string; + campaign_name?: string; + impressions?: string; + clicks?: string; + spend?: string; + actions?: { action_type?: string; value?: string }[]; + }[]; + paging?: { cursors?: { after?: string }; next?: string }; + }>(`${act}/insights`, params, token); + + for (const row of raw.data || []) { + const adId = str(row.ad_id); + if (!adId) continue; + const conversions = leadFromMetaActions(row.actions); + const cur = byAd.get(adId) || { + adId, + adName: str(row.ad_name), + campaignId: str(row.campaign_id), + campaignName: str(row.campaign_name), + spend: 0, + impressions: 0, + clicks: 0, + conversions: 0, + }; + cur.adName = str(row.ad_name) || cur.adName; + cur.campaignName = str(row.campaign_name) || cur.campaignName; + cur.spend += Math.round(n(row.spend) * 100) / 100; + cur.impressions += n(row.impressions); + cur.clicks += n(row.clicks); + cur.conversions += conversions; + byAd.set(adId, cur); + } + after = raw.paging?.cursors?.after; + if (!raw.paging?.next || !after) break; + } + } + + return [...byAd.values()] .filter( (a) => a.spend > 0 || a.impressions > 0 || a.clicks > 0 || a.conversions > 0 @@ -581,7 +670,8 @@ function cardFromBundle( /** * Build Meta creative cards: live ad-level insights + creative assets. - * Falls back to campaign-level ad_daily only if insights fail. + * Never fails the UI: on any Graph error → campaign ads → warehouse cards. + * Soft-fail warnings are omitted when we still return ads. */ export async function fetchMetaCreativeCards(opts: { tenantId: string; @@ -590,18 +680,46 @@ export async function fetchMetaCreativeCards(opts: { until: string; limit?: number; }): Promise<{ ads: AdCreativeCard[]; warning?: string }> { + const warehouse = () => + metaCardsFromDaily(opts.dailyRows, opts.since, opts.until, opts.limit ?? 16); + + try { + return await fetchMetaCreativeCardsInner(opts, warehouse); + } catch { + const ads = warehouse(); + return ads.length + ? { ads } + : { ads: [], warning: 'Sin actividad Meta en este periodo' }; + } +} + +async function fetchMetaCreativeCardsInner( + opts: { + tenantId: string; + dailyRows: Parameters[0]; + since: string; + until: string; + limit?: number; + }, + warehouse: () => AdCreativeCard[] +): Promise<{ ads: AdCreativeCard[]; warning?: string }> { const token = await resolveMetaAccessToken(); if (!token) { - return { ads: [], warning: 'Sin token Meta (META_ACCESS_TOKEN / agency_oauth)' }; + const ads = warehouse(); + return ads.length + ? { ads } + : { ads: [], warning: 'Sin token Meta (META_ACCESS_TOKEN / agency_oauth)' }; } const account = await resolveMetaAccountId(opts.tenantId); if (!account) { - return { ads: [], warning: 'Cliente sin cuenta Meta Ads enlazada' }; + const ads = warehouse(); + return ads.length + ? { ads } + : { ads: [], warning: 'Cliente sin cuenta Meta Ads enlazada' }; } const limit = opts.limit ?? 16; - let warning: string | undefined; let targets: { adId: string; adName: string; @@ -620,11 +738,8 @@ export async function fetchMetaCreativeCards(opts: { opts.until, limit ); - } catch (e) { - warning = - e instanceof Error - ? `Meta insights ad-level: ${e.message.slice(0, 120)}` - : 'Meta insights ad-level falló'; + } catch { + /* fall through — never surface ad-level Graph errors */ } // Fallback: expand campaigns from ad_daily → ads under each campaign @@ -635,12 +750,6 @@ export async function fetchMetaCreativeCards(opts: { opts.since, opts.until ).slice(0, 8); - if (!campaigns.length) { - return { - ads: [], - warning: warning || 'Sin ads Meta en ad_daily para este periodo', - }; - } for (const camp of campaigns) { try { const raw = await graphGet<{ @@ -667,7 +776,10 @@ export async function fetchMetaCreativeCards(opts: { if (!id) continue; targets.push({ adId: id, - adName: str(ad.name) || camp.adName, + adName: + str(ad.name) || + cleanMetaAdName(camp.adName) || + camp.campaignName, campaignName: camp.campaignName, spend: camp.spend / share, impressions: Math.round(camp.impressions / share), @@ -681,28 +793,29 @@ export async function fetchMetaCreativeCards(opts: { if (targets.length >= limit) break; } targets = targets.slice(0, limit); - if (!targets.length) { - return { - ads: [], - warning: - warning || - 'No se pudieron resolver anuncios Meta (ad_daily es a nivel campaña)', - }; - } - if (!warning) { - warning = - 'Previews desde anuncios de campaña (métricas repartidas; ad_daily es campaign-level)'; - } + } + + // Last resort: warehouse campaign cards (always succeeds if there is spend) + if (!targets.length) { + const ads = warehouse(); + return ads.length + ? { ads } + : { ads: [], warning: 'Sin actividad Meta en este periodo' }; } const bundles: (Awaited>)[] = []; for (let i = 0; i < targets.length; i += 6) { const chunk = targets.slice(i, i + 6); - bundles.push( - ...(await Promise.all( - chunk.map((a) => fetchMetaCreativeBundle(a.adId, token)) - )) + const part = await Promise.all( + chunk.map(async (a) => { + try { + return await fetchMetaCreativeBundle(a.adId, token); + } catch { + return null; + } + }) ); + bundles.push(...part); } const allHashes: string[] = []; @@ -711,23 +824,22 @@ export async function fetchMetaCreativeCards(opts: { allHashes.push(...pack.hashes); return pack; }); - const hashMap = await resolveImageHashes(account, allHashes, token); + let hashMap = new Map(); + try { + hashMap = await resolveImageHashes(account, allHashes, token); + } catch { + /* previews may still work from creative URLs */ + } const out: AdCreativeCard[] = targets.map((agg, idx) => cardFromBundle(agg, bundles[idx], packs[idx], hashMap) ); - const withPreview = out.filter((a) => a.thumbnailUrl || a.assets.length); - if (!withPreview.length && out.length) { - warning = [ - warning, - 'Anuncios sin imagen/vídeo resoluble en la API creativa', - ] - .filter(Boolean) - .join(' · '); + if (!out.length) { + return { ads: warehouse() }; } - return { ads: out, warning }; + return { ads: out }; } /** Google cards from ad_daily only (text fallback). */