From 224f060e9d7f3d47efe6aec4a555e4727b676422 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Thu, 24 Sep 2026 17:26:22 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Social=20FB=20=E2=80=94=20Page=20token?= =?UTF-8?q?=20+=20aviso=20si=20el=20Meta=20token=20no=20tiene=20pages=5F*?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El token de ads no sirve para /posts; intentamos Page Access Token y explicamos los permisos necesarios. Co-authored-by: Cursor --- src/app/api/social/route.ts | 104 ++++++++++++++++++++++++++-- src/components/AdsDiscoverPanel.tsx | 8 ++- src/lib/ads-discovery.ts | 2 +- 3 files changed, 103 insertions(+), 11 deletions(-) diff --git a/src/app/api/social/route.ts b/src/app/api/social/route.ts index 401afaf..e77d3f2 100644 --- a/src/app/api/social/route.ts +++ b/src/app/api/social/route.ts @@ -46,6 +46,71 @@ function isVideoType(t?: string | null): boolean { ); } +function formatGraphError(raw: string): string { + try { + const j = JSON.parse(raw) as { + error?: { + message?: string; + code?: number; + error_subcode?: number; + error_user_title?: string; + error_user_msg?: string; + }; + }; + const e = j.error; + if (!e) return raw.slice(0, 220); + if (e.code === 190 || e.error_subcode === 2069032) { + return ( + 'Token Meta inválido o sin permisos de Página. En Business Manager → ' + + 'Usuarios del sistema → genera un token con pages_show_list, ' + + 'pages_read_engagement, pages_read_user_content, ' + + 'instagram_basic, instagram_manage_insights (además de ads_*) y ' + + 'pégalo en Agencia. Asigna el system user a cada Página.' + ); + } + return ( + e.error_user_title || + e.error_user_msg || + e.message || + raw + ).slice(0, 280); + } catch { + return raw.slice(0, 220); + } +} + +/** + * Posts/insights need a Page Access Token. System-user tokens with ads_* alone + * fail with OAuthException 190 — exchange when Graph allows it. + */ +async function resolvePageAccessToken( + userToken: string, + pageId: string +): Promise<{ token: string; via: 'page' | 'user' }> { + const r = await fetch( + `${META}/${encodeURIComponent(pageId)}?fields=access_token,name&access_token=${encodeURIComponent(userToken)}`, + { cache: 'no-store' } + ); + if (r.ok) { + const j = (await r.json()) as { access_token?: string }; + if (j.access_token) return { token: j.access_token, via: 'page' }; + } + + const accounts = await fetch( + `${META}/me/accounts?fields=id,access_token&limit=100&access_token=${encodeURIComponent(userToken)}`, + { cache: 'no-store' } + ); + if (accounts.ok) { + const j = (await accounts.json()) as { + data?: { id?: string; access_token?: string }[]; + }; + const hit = (j.data || []).find((p) => p.id === pageId && p.access_token); + if (hit?.access_token) return { token: hit.access_token, via: 'page' }; + } + + return { token: userToken, via: 'user' }; +} + function insightValue( data: { name?: string; values?: { value?: number }[] }[] | undefined, name: string @@ -55,14 +120,15 @@ function insightValue( return typeof v === 'number' && Number.isFinite(v) ? v : null; } -async function fetchPageBundle(token: string, pageId: string) { +async function fetchPageBundle(agencyToken: string, pageId: string) { + const { token } = await resolvePageAccessToken(agencyToken, pageId); const fields = 'name,fan_count,followers_count,link,picture{url},engagement,about,website'; const r = await fetch( `${META}/${encodeURIComponent(pageId)}?fields=${fields}&access_token=${encodeURIComponent(token)}`, { cache: 'no-store' } ); - if (!r.ok) return { error: (await r.text()).slice(0, 200) }; + if (!r.ok) return { error: formatGraphError(await r.text()) }; const profile = await r.json(); const insightsRes = await fetch( @@ -83,7 +149,7 @@ async function fetchPageBundle(token: string, pageId: string) { } const postsRes = await fetch( - `${META}/${encodeURIComponent(pageId)}/posts?fields=id,message,created_time,full_picture,permalink_url,status_type,shares,likes.summary(true),comments.summary(true),attachments{media_type,type,url,media,subattachments}&limit=16&access_token=${encodeURIComponent(token)}`, + `${META}/${encodeURIComponent(pageId)}/published_posts?fields=id,message,created_time,full_picture,permalink_url,status_type,shares,likes.summary(true),comments.summary(true),attachments{media_type,type,url,media,subattachments}&limit=16&access_token=${encodeURIComponent(token)}`, { cache: 'no-store' } ); const posts: SocialPost[] = []; @@ -123,10 +189,34 @@ async function fetchPageBundle(token: string, pageId: string) { seen.add(p.id); } } else { - profile.postsError = (await postsRes.text()).slice(0, 160); + // Fallback /posts then surface clear error + const posts2 = await fetch( + `${META}/${encodeURIComponent(pageId)}/posts?fields=id,message,created_time,full_picture,permalink_url,status_type,shares,likes.summary(true),comments.summary(true),attachments{media_type,type,url,media,subattachments}&limit=16&access_token=${encodeURIComponent(token)}`, + { cache: 'no-store' } + ); + if (posts2.ok) { + const j = await posts2.json(); + for (const p of j.data || []) { + posts.push({ + id: p.id, + platform: 'facebook', + message: p.message || '', + mediaUrl: p.full_picture || null, + thumbnailUrl: p.full_picture || null, + permalink: p.permalink_url || null, + createdAt: p.created_time || null, + likes: Number(p.likes?.summary?.total_count || 0), + comments: Number(p.comments?.summary?.total_count || 0), + shares: Number(p.shares?.count || 0), + mediaType: p.full_picture ? 'IMAGE' : 'status', + }); + seen.add(p.id); + } + } else { + profile.postsError = formatGraphError(await postsRes.text()); + } } - // Native page videos / reels often missing from /posts const videosRes = await fetch( `${META}/${encodeURIComponent(pageId)}/videos?fields=id,description,created_time,source,permalink_url,picture,length,title&limit=10&access_token=${encodeURIComponent(token)}`, { cache: 'no-store' } @@ -160,7 +250,7 @@ async function fetchIgBundle(token: string, igId: string) { `${META}/${encodeURIComponent(igId)}?fields=username,name,followers_count,follows_count,media_count,profile_picture_url,biography,website&access_token=${encodeURIComponent(token)}`, { cache: 'no-store' } ); - if (!r.ok) return { error: (await r.text()).slice(0, 200) }; + if (!r.ok) return { error: formatGraphError(await r.text()) }; const profile = await r.json(); const ins = await fetch( @@ -252,7 +342,7 @@ async function fetchIgBundle(token: string, igId: string) { }); } } else { - profile.postsError = (await mediaRes.text()).slice(0, 160); + profile.postsError = formatGraphError(await mediaRes.text()); } return { ...profile, igInsights, posts }; diff --git a/src/components/AdsDiscoverPanel.tsx b/src/components/AdsDiscoverPanel.tsx index 4844e21..cbf963c 100644 --- a/src/components/AdsDiscoverPanel.tsx +++ b/src/components/AdsDiscoverPanel.tsx @@ -183,9 +183,11 @@ export default function AdsDiscoverPanel() { Meta Ads + Google Ads (agencia)

- Lista cuentas accesibles y las enlaza a cada ficha por nombre. - Meta Ads + páginas FB/IG usan el token system user (API 4V2030 / - App Review). Google Ads con el MCC de agencia. + Lista cuentas y las enlaza por nombre. El system user token debe + incluir ads_* y páginas/IG: pages_show_list, + pages_read_engagement, pages_read_user_content, instagram_basic, + instagram_manage_insights. Asigna el system user a cada Página en + Business Manager.

diff --git a/src/lib/ads-discovery.ts b/src/lib/ads-discovery.ts index 40fa40b..a2db529 100644 --- a/src/lib/ads-discovery.ts +++ b/src/lib/ads-discovery.ts @@ -81,7 +81,7 @@ export async function saveMetaAccessToken(token: string): Promise<{ access_token: clean, account_email: me.name || me.id || null, expires_at: null, - scopes: 'ads_read,ads_management,business_management', + scopes: 'ads_read,ads_management,business_management,pages_show_list,pages_read_engagement,pages_read_user_content,instagram_basic,instagram_manage_insights', updated_at: new Date().toISOString(), meta: { graph_id: me.id || null, saved_via: 'paste' }, };