/** Meta ad creatives for Ad Tracker (vertical previews + full copy/assets). */ import { resolveMetaAccessToken } from '@/lib/ads-discovery'; import { serviceHeaders, supabaseUrl } from '@/lib/agency'; import { leadFromMetaActions, linkClicksFromMetaActions } from '@/lib/meta-leads'; const META_GRAPH = 'https://graph.facebook.com/v21.0'; const CREATIVE_FIELDS = [ 'id', 'name', 'title', 'body', 'call_to_action_type', 'link_url', 'thumbnail_url', 'image_url', 'image_hash', 'video_id', 'object_story_spec', 'asset_feed_spec', 'effective_object_story_id', ].join(','); export type CreativeAspect = '9:16' | '4:5' | '16:9' | '1:1' | 'unknown'; export type CreativeAsset = { kind: 'image' | 'video' | 'logo' | 'youtube'; url: string; label?: string; aspect?: CreativeAspect; youtubeId?: string; title?: string; }; /** Google Ads channel bucket for gallery grouping. */ export type GoogleCreativeChannel = | 'search_brand' | 'search_generic' | 'pmax' | 'display' | 'other'; export type AdCreativeCard = { platform: 'meta' | 'google'; adId: string; adName: string; campaignName: string; adsetName?: string; spend: number; impressions: number; clicks: number; conversions: number; cpl: number | null; mediaType: string; /** Google: search_brand | search_generic | pmax | display | other */ channel?: GoogleCreativeChannel; /** Best preview URL (prefer vertical / portrait). */ thumbnailUrl: string; imageUrl: string; /** Joined for backwards compat; prefer headlines/bodies/descriptions arrays. */ headline: string; primaryText: string; /** Titulares (Meta titles / Google headlines), máx. 5. */ headlines: string[]; /** Textos principales (Meta bodies), máx. 5. */ bodies: string[]; /** Descripción del enlace (Meta descriptions), suele ser 1. */ descriptions: string[]; longHeadlines?: string[]; assets: CreativeAsset[]; linkUrl?: string; cta?: string; businessName?: string; status?: string; youtubeId?: string; }; type DailyAgg = { adId: string; adName: string; campaignName: string; spend: number; impressions: number; clicks: number; 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 uniq(arr: string[]): string[] { return [...new Set(arr.filter(Boolean))]; } export function inferCreativeAspect( label?: string, width?: number, height?: number ): CreativeAspect { const L = (label || '').toLowerCase(); // Dimensiones primero: labels tipo "video" no deben forzar landscape. if (width && height && width > 0 && height > 0) { const r = width / height; if (r <= 0.62) return '9:16'; if (r <= 0.9) return '4:5'; if (r >= 1.5) return '16:9'; if (Math.abs(r - 1) < 0.12) return '1:1'; } if (/9\s*:?\s*16|story|reels|vertical|tall|portrait_9/.test(L)) return '9:16'; if (/4\s*:?\s*5|portrait|feed_portrait/.test(L)) return '4:5'; if (/16\s*:?\s*9|1\.91|landscape|horizontal|right_column|youtube/.test(L)) { return '16:9'; } if (/1\s*:?\s*1|square/.test(L)) return '1:1'; return 'unknown'; } function detectMediaType(creative: Record): string { if (creative.video_id) return 'video'; const feed = creative.asset_feed_spec as Record | undefined; if (feed?.videos || feed?.video_ids) return 'video'; const oss = creative.object_story_spec as Record | undefined; const link = oss?.link_data as Record | undefined; if (Array.isArray(link?.child_attachments) && (link!.child_attachments as unknown[]).length > 1) { return 'carousel'; } if (creative.image_url || creative.image_hash || creative.thumbnail_url) return 'image'; return 'unknown'; } type Extracted = { headlines: string[]; bodies: string[]; descriptions: string[]; assets: CreativeAsset[]; linkUrl: string; cta: string; hashes: string[]; }; function extractCreativePack(creative: Record): Extracted { const headlines: string[] = []; const bodies: string[] = []; const descriptions: string[] = []; const assets: CreativeAsset[] = []; const hashes: string[] = []; const pushHash = (h: unknown) => { const s = str(h); if (/^[a-f0-9]{16,64}$/i.test(s)) hashes.push(s); }; const pushImg = ( url: unknown, label?: string, width?: number, height?: number ) => { const u = str(url); if (!/^https?:\/\//i.test(u)) return; if (assets.some((a) => a.url === u)) return; assets.push({ kind: 'image', url: u, label, aspect: inferCreativeAspect(label, width, height), }); }; const oss = creative.object_story_spec as Record | undefined; const link = oss?.link_data as Record | undefined; const video = oss?.video_data as Record | undefined; const photo = oss?.photo_data as Record | undefined; // Story (anuncio publicado): 1 titular + texto + descripción de enlace. if (link?.name) headlines.push(str(link.name)); if (link?.message) bodies.push(str(link.message)); if (link?.description) descriptions.push(str(link.description)); pushImg(link?.picture, '4:5 story', 1080, 1350); pushImg(link?.image_url, '4:5 story', 1080, 1350); pushHash(link?.image_hash); if (video?.title) headlines.push(str(video.title)); if (video?.message) bodies.push(str(video.message)); if (video?.link_description) descriptions.push(str(video.link_description)); pushImg(video?.image_url, 'video_poster 9:16', 1080, 1920); pushHash(video?.image_hash); pushImg(photo?.url, 'photo 4:5', 1080, 1350); pushHash(photo?.image_hash); if (creative.title) headlines.push(str(creative.title)); if (creative.body) bodies.push(str(creative.body)); pushImg(creative.image_url, 'image 4:5', 1080, 1350); pushImg(creative.thumbnail_url, 'thumbnail 9:16', 1080, 1920); pushHash(creative.image_hash); if (Array.isArray(link?.child_attachments)) { (link!.child_attachments as Record[]).forEach((c, i) => { // Carrusel: preferimos 1:1; si el asset es más alto, se reclasifica por ratio. pushImg(c.picture || c.image_url, `carousel-${i + 1} 1:1`, 1080, 1080); pushHash(c.image_hash); if (c.name) headlines.push(str(c.name)); if (c.message) bodies.push(str(c.message)); if (c.description) descriptions.push(str(c.description)); }); } // Dynamic creative / Advantage+: hasta 5 titles, 5 bodies, descriptions. const feed = creative.asset_feed_spec as Record | undefined; if (feed) { for (const t of (feed.titles as { text?: string }[]) || []) { if (t?.text) headlines.push(str(t.text)); } for (const b of (feed.bodies as { text?: string }[]) || []) { if (b?.text) bodies.push(str(b.text)); } for (const d of (feed.descriptions as { text?: string }[]) || []) { if (d?.text) descriptions.push(str(d.text)); } for (const img of (feed.images as Record[]) || []) { const labels = Array.isArray(img.adlabels) ? (img.adlabels as { name?: string }[]) .map((l) => str(l.name)) .filter(Boolean) .join(' ') : ''; pushImg( img.url || img.permalink_url, labels || 'feed', n(img.width) || undefined, n(img.height) || undefined ); pushHash(img.hash); } for (const v of (feed.videos as Record[]) || []) { pushImg(v.thumbnail_url || v.picture, 'video_feed 9:16', 1080, 1920); if (v.video_id) { assets.push({ kind: 'video', url: str(v.thumbnail_url || v.picture), label: 'video 9:16', title: str(v.video_id), aspect: '9:16', }); } } } const linkUrl = str(creative.link_url) || str(link?.link) || str(video?.link) || ''; const ctaRaw = (link?.call_to_action as { type?: string } | undefined)?.type; const cta = str(creative.call_to_action_type) || str(ctaRaw); return { headlines: uniq(headlines).slice(0, 5), bodies: uniq(bodies).slice(0, 5), descriptions: uniq(descriptions).slice(0, 5), assets, linkUrl, cta, hashes: uniq(hashes), }; } /** * Meta preview picker: * - Default: vertical 9:16 (vídeo/imagen), then 4:5 * - Carousel: 1:1, then 4:5 */ export function pickMetaPreviewAsset( assets: CreativeAsset[], mediaType: string, fallbackUrl = '' ): { url: string; aspect: CreativeAspect } { const withUrl = assets.filter((a) => a.url); const aspectOf = (a: CreativeAsset) => a.aspect && a.aspect !== 'unknown' ? a.aspect : inferCreativeAspect(a.label); if (mediaType === 'carousel') { const square = withUrl.find((a) => aspectOf(a) === '1:1'); if (square) return { url: square.url, aspect: '1:1' }; const fourFive = withUrl.find((a) => aspectOf(a) === '4:5'); if (fourFive) return { url: fourFive.url, aspect: '4:5' }; const carousel = withUrl.find((a) => /carousel/i.test(a.label || '')); if (carousel) return { url: carousel.url, aspect: '1:1' }; if (withUrl[0]) return { url: withUrl[0].url, aspect: '1:1' }; return { url: fallbackUrl, aspect: '1:1' }; } // Prefer video poster/frame labeled 9:16, then any 9:16 image, then 4:5 const videoNine = withUrl.find( (a) => (a.kind === 'video' || /video|reel|story/i.test(a.label || '')) && aspectOf(a) === '9:16' ); if (videoNine) return { url: videoNine.url, aspect: '9:16' }; const nine = withUrl.find((a) => aspectOf(a) === '9:16'); if (nine) return { url: nine.url, aspect: '9:16' }; const fourFive = withUrl.find((a) => aspectOf(a) === '4:5'); if (fourFive) return { url: fourFive.url, aspect: '4:5' }; const thumb = withUrl.find((a) => /thumbnail|video_poster|video_feed/i.test(a.label || '') ); if (thumb) return { url: thumb.url, aspect: '9:16' }; if (withUrl[0]) { return { url: withUrl[0].url, aspect: aspectOf(withUrl[0]) || '9:16' }; } return { url: fallbackUrl, aspect: '9:16' }; } 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 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)); } } 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. */ export function buildMetaWarehouseCards( dailyRows: Parameters[0], since: string, until: string, limit = 16 ): AdCreativeCard[] { return metaCardsFromDaily(dailyRows, since, until, limit); } 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 { 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.meta_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(); return id || null; } async function resolveImageHashes( accountId: string, hashes: string[], token: string ): Promise> { const out = new Map(); const unique = uniq(hashes).slice(0, 40); if (!unique.length) return out; const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`; try { const raw = await graphGet<{ data?: { hash?: string; url?: string; permalink_url?: string }[]; }>( `${act}/adimages`, { hashes: JSON.stringify(unique), fields: 'hash,url,permalink_url', }, token ); for (const row of raw.data || []) { const h = str(row.hash); const u = str(row.url) || str(row.permalink_url); if (h && u) out.set(h, u); } } catch { /* ignore */ } return out; } /** Aggregate ad_daily rows by ad_id for a platform. */ export function aggregateDailyByAd( rows: { platform?: string; ad_id?: string | null; ad_name?: string | null; campaign_name?: string | null; spend?: number; impressions?: number; clicks?: number; platform_leads?: number; conversions?: number; date?: string; }[], platform: 'meta' | 'google', since: string, until: string ): DailyAgg[] { const map = new Map(); for (const r of rows) { if ((r.platform || '').toLowerCase() !== platform) continue; const d = (r.date || '').slice(0, 10); if (d < since || d > until) continue; const adId = str(r.ad_id); if (!adId) continue; const cur = map.get(adId) || { adId, adName: str(r.ad_name) || adId, campaignName: str(r.campaign_name), spend: 0, impressions: 0, clicks: 0, conversions: 0, }; cur.spend += n(r.spend); cur.impressions += n(r.impressions); cur.clicks += n(r.clicks); cur.conversions += n(r.platform_leads ?? r.conversions); if (str(r.ad_name)) cur.adName = str(r.ad_name); if (str(r.campaign_name)) cur.campaignName = str(r.campaign_name); map.set(adId, cur); } return [...map.values()] .filter( (a) => a.spend > 0 || a.impressions > 0 || a.clicks > 0 || a.conversions > 0 ) .sort((a, b) => b.spend - a.spend); } async function fetchMetaCreativeBundle( adId: string, token: string ): Promise<{ adId: string; adName: string; campaignName: string; adsetName: string; status: string; creative: Record; } | null> { try { const raw = await graphGet<{ id?: string; name?: string; effective_status?: string; campaign?: { name?: string }; adset?: { name?: string }; creative?: Record; }>( adId, { fields: `id,name,effective_status,campaign{name},adset{name},creative{${CREATIVE_FIELDS}}`, thumbnail_width: '1080', thumbnail_height: '1920', }, token ); let creative = (raw.creative || {}) as Record; const cid = str(creative.id); if (cid) { try { const detail = await graphGet>( cid, { fields: CREATIVE_FIELDS, thumbnail_width: '1080', thumbnail_height: '1920', }, token ); creative = { ...creative, ...detail }; } catch { /* keep */ } } return { adId: str(raw.id) || adId, adName: str(raw.name), campaignName: str(raw.campaign?.name), adsetName: str(raw.adset?.name), status: str(raw.effective_status), creative, }; } catch { return null; } } type MetaAdInsight = { adId: string; adName: string; campaignId: string; campaignName: string; spend: number; impressions: number; clicks: number; conversions: number; }; /** * 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, token: string, since: string, until: string, limit: number ): Promise { const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`; 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); } } 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 ) .sort((a, b) => b.spend - a.spend) .slice(0, limit); } function cardFromBundle( agg: { adId: string; adName: string; campaignName: string; spend: number; impressions: number; clicks: number; conversions: number; }, b: Awaited>, pack: Extracted, hashMap: Map ): AdCreativeCard { const assets = [...pack.assets]; for (const h of pack.hashes) { const u = hashMap.get(h); if (u && !assets.some((a) => a.url === u)) { assets.push({ kind: 'image', url: u, label: 'hash' }); } } const creative = b?.creative || {}; const fallback = str(creative.image_url) || str(creative.thumbnail_url) || ''; const mediaType = detectMediaType(creative); const picked = pickMetaPreviewAsset(assets, mediaType, fallback); const headlines = pack.headlines; const bodies = pack.bodies; const descriptions = pack.descriptions; return { platform: 'meta', adId: b?.adId || agg.adId, adName: b?.adName || agg.adName, campaignName: b?.campaignName || agg.campaignName, adsetName: b?.adsetName, 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, thumbnailUrl: picked.url, imageUrl: picked.url, headline: headlines.join('\n'), primaryText: bodies.join('\n') || descriptions.join('\n'), headlines, bodies, descriptions, assets, linkUrl: pack.linkUrl || undefined, cta: pack.cta || undefined, status: b?.status, }; } /** * Build Meta creative cards: live ad-level insights + creative assets. * 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; dailyRows: Parameters[0]; since: string; 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) { 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) { const ads = warehouse(); return ads.length ? { ads } : { ads: [], warning: 'Cliente sin cuenta Meta Ads enlazada' }; } const limit = opts.limit ?? 16; let targets: { adId: string; adName: string; campaignName: string; spend: number; impressions: number; clicks: number; conversions: number; }[] = []; try { targets = await fetchMetaAdLevelInsights( account, token, opts.since, opts.until, limit ); } catch { /* fall through — never surface ad-level Graph errors */ } // Fallback: expand campaigns from ad_daily → ads under each campaign if (!targets.length) { const campaigns = aggregateDailyByAd( opts.dailyRows, 'meta', opts.since, opts.until ).slice(0, 8); for (const camp of campaigns) { try { const raw = await graphGet<{ data?: { id?: string; name?: string; effective_status?: string }[]; }>( `${camp.adId}/ads`, { fields: 'id,name,effective_status', limit: '6', filtering: JSON.stringify([ { field: 'effective_status', operator: 'IN', value: ['ACTIVE', 'PAUSED', 'CAMPAIGN_PAUSED', 'ADSET_PAUSED'], }, ]), }, token ); const ads = raw.data || []; const share = ads.length || 1; for (const ad of ads) { const id = str(ad.id); if (!id) continue; targets.push({ adId: id, adName: str(ad.name) || cleanMetaAdName(camp.adName) || camp.campaignName, campaignName: camp.campaignName, spend: camp.spend / share, impressions: Math.round(camp.impressions / share), clicks: Math.round(camp.clicks / share), conversions: camp.conversions / share, }); } } catch { /* skip campaign */ } if (targets.length >= limit) break; } targets = targets.slice(0, limit); } // 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); 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[] = []; const packs = bundles.map((b) => { const pack = extractCreativePack(b?.creative || {}); allHashes.push(...pack.hashes); return pack; }); 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) ); if (!out.length) { return { ads: warehouse() }; } return { ads: out }; } /** * Account-level Meta clicks for the period: * - clicks = todos (Ads Manager) * - linkClicks = clics en el enlace */ export async function fetchMetaAccountClickSplit(opts: { tenantId: string; since: string; until: string; }): Promise<{ clicks: number; linkClicks: number; spend: number } | null> { const token = await resolveMetaAccessToken(); if (!token) return null; const account = await resolveMetaAccountId(opts.tenantId); if (!account) return null; const act = account.startsWith('act_') ? account : `act_${account}`; const timeRange = JSON.stringify({ since: opts.since, until: opts.until }); try { const raw = await graphGet<{ data?: { spend?: string; clicks?: string; actions?: { action_type?: string; value?: string }[]; }[]; }>( `${act}/insights`, { fields: 'spend,clicks,actions', level: 'account', time_increment: 'all_days', time_range: timeRange, limit: '1', }, token ); const row = raw.data?.[0]; if (!row) return null; const clicks = n(row.clicks); return { clicks, linkClicks: linkClicksFromMetaActions(row.actions, clicks), spend: Math.round(n(row.spend) * 100) / 100, }; } catch { return null; } }