From 87237530660a13d18d3735d7345dc7b7dfb55b48 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Fri, 9 Oct 2026 10:20:00 +0200 Subject: [PATCH] =?UTF-8?q?Associate=20Detuatuformacion=20Meta=20ad=20acco?= =?UTF-8?q?unt=20with=20De=20T=C3=BA=20A=20T=C3=BA.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Support multiple Meta ad accounts per tenant in creatives/sync UI so ECP and Detuatuformacion can both be linked. Co-authored-by: Cursor --- src/components/ClientIntegrationsPanel.tsx | 22 +++- src/components/WindsorAiSection.tsx | 13 +- src/lib/ad-creatives-meta.ts | 140 +++++++++++++-------- 3 files changed, 123 insertions(+), 52 deletions(-) diff --git a/src/components/ClientIntegrationsPanel.tsx b/src/components/ClientIntegrationsPanel.tsx index 553f83a..7fea54c 100644 --- a/src/components/ClientIntegrationsPanel.tsx +++ b/src/components/ClientIntegrationsPanel.tsx @@ -13,7 +13,15 @@ type Integ = { error_message?: string | null; last_sync_at?: string | null; enabled?: boolean; - meta?: { folder_name?: string; match_score?: number } | null; + meta?: { + folder_name?: string; + match_score?: number; + extra_ad_accounts?: { + account_id?: string; + external_account_id?: string; + name?: string; + }[]; + } | null; }; type Provider = { id: string; label: string; group: string; live?: boolean }; @@ -354,6 +362,18 @@ export default function ClientIntegrationsPanel({ TikTok Ads. OAuth live y publicación: siguiente fase.

)} + {p.id === 'meta_ads' && + (row?.meta?.extra_ad_accounts || []).length ? ( +

+ Cuentas extra:{' '} + {(row?.meta?.extra_ad_accounts || []) + .map( + (x) => + `${x.name || 'cuenta'} (${x.account_id || x.external_account_id})` + ) + .join(' · ')} +

+ ) : null} ); })} diff --git a/src/components/WindsorAiSection.tsx b/src/components/WindsorAiSection.tsx index 1a926ab..4b21504 100644 --- a/src/components/WindsorAiSection.tsx +++ b/src/components/WindsorAiSection.tsx @@ -394,7 +394,18 @@ export default function WindsorAiSection({ }, { name: 'Meta Ads', - id: meta?.external_account_id || meta?.label || '—', + id: (() => { + const primary = meta?.external_account_id || ''; + const extras = ( + (meta?.meta as { extra_ad_accounts?: { account_id?: string; external_account_id?: string; name?: string }[] } | null) + ?.extra_ad_accounts || [] + ) + .map((x) => x.account_id || x.external_account_id) + .filter(Boolean); + const ids = [primary, ...extras].filter(Boolean); + if (ids.length) return ids.join(' · '); + return meta?.label || '—'; + })(), status: meta?.status === 'connected' ? 'connected' : 'disconnected', type: 'Instagram & Facebook Ads', }, diff --git a/src/lib/ad-creatives-meta.ts b/src/lib/ad-creatives-meta.ts index f0b5673..b4619ed 100644 --- a/src/lib/ad-creatives-meta.ts +++ b/src/lib/ad-creatives-meta.ts @@ -411,18 +411,42 @@ function metaCardsFromDaily( }); } -async function resolveMetaAccountId(tenantId: string): Promise { +async function resolveMetaAccountIds(tenantId: string): Promise { const headers = serviceHeaders(); const base = supabaseUrl(); - if (!headers || !base) return null; + if (!headers || !base) return []; const r = await fetch( - `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.meta_ads&select=external_account_id,status&limit=1`, + `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent( + tenantId + )}&provider=eq.meta_ads&select=external_account_id,status,meta&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; + if (!r.ok) return []; + const rows = (await r.json()) as { + external_account_id?: string | null; + meta?: { + extra_ad_accounts?: { account_id?: string; external_account_id?: string }[]; + alternate_accounts?: { external_account_id?: string }[]; + } | null; + }[]; + const row = rows[0]; + const out: string[] = []; + const primary = row?.external_account_id?.trim(); + if (primary) out.push(primary); + for (const x of row?.meta?.extra_ad_accounts || []) { + const id = (x.account_id || x.external_account_id || '').trim(); + if (id) out.push(id); + } + for (const x of row?.meta?.alternate_accounts || []) { + const id = (x.external_account_id || '').trim(); + if (id) out.push(id); + } + return [...new Set(out)]; +} + +async function resolveMetaAccountId(tenantId: string): Promise { + const ids = await resolveMetaAccountIds(tenantId); + return ids[0] || null; } async function resolveImageHashes( @@ -771,13 +795,14 @@ async function fetchMetaCreativeCardsInner( : { ads: [], warning: 'Sin token Meta (META_ACCESS_TOKEN / agency_oauth)' }; } - const account = await resolveMetaAccountId(opts.tenantId); - if (!account) { + const accounts = await resolveMetaAccountIds(opts.tenantId); + if (!accounts.length) { const ads = warehouse(); return ads.length ? { ads } : { ads: [], warning: 'Cliente sin cuenta Meta Ads enlazada' }; } + const account = accounts[0]; const limit = opts.limit ?? 16; let targets: { @@ -790,16 +815,20 @@ async function fetchMetaCreativeCardsInner( conversions: number; }[] = []; - try { - targets = await fetchMetaAdLevelInsights( - account, - token, - opts.since, - opts.until, - limit - ); - } catch { - /* fall through — never surface ad-level Graph errors */ + for (const actId of accounts) { + if (targets.length >= limit) break; + try { + const part = await fetchMetaAdLevelInsights( + actId, + token, + opts.since, + opts.until, + limit - targets.length + ); + targets.push(...part); + } catch { + /* fall through — never surface ad-level Graph errors */ + } } // Fallback: expand campaigns from ad_daily → ads under each campaign @@ -914,37 +943,48 @@ export async function fetchMetaAccountClickSplit(opts: { }): 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 accounts = await resolveMetaAccountIds(opts.tenantId); + if (!accounts.length) return null; 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; + let clicks = 0; + let linkClicks = 0; + let spend = 0; + let any = false; + for (const account of accounts) { + const act = account.startsWith('act_') ? account : `act_${account}`; + 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) continue; + any = true; + const c = n(row.clicks); + clicks += c; + linkClicks += linkClicksFromMetaActions(row.actions, c); + spend += n(row.spend); + } catch { + /* skip inaccessible account */ + } } + if (!any) return null; + return { + clicks, + linkClicks, + spend: Math.round(spend * 100) / 100, + }; }