diff --git a/src/app/api/social/route.ts b/src/app/api/social/route.ts index e77d3f2..1bb06b9 100644 --- a/src/app/api/social/route.ts +++ b/src/app/api/social/route.ts @@ -1,11 +1,11 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; import { resolveActiveTenant, restGet } from '@/lib/tenant-context'; import { isAgencyRole, userRole } from '@/lib/agency'; -import { resolveMetaAccessToken } from '@/lib/ads-discovery'; +import { resolveMetaAccessTokens } from '@/lib/ads-discovery'; export const dynamic = 'force-dynamic'; -export const maxDuration = 60; +export const maxDuration = 90; const META = 'https://graph.facebook.com/v21.0'; @@ -23,7 +23,6 @@ type SocialPost = { platform: 'facebook' | 'instagram'; message?: string; mediaUrl?: string | null; - /** Poster / still for VIDEO / REELS when mediaUrl is a video. */ thumbnailUrl?: string | null; permalink?: string | null; createdAt?: string | null; @@ -35,6 +34,8 @@ type SocialPost = { mediaType?: string | null; }; +export type DayPoint = { day: string; value: number }; + function isVideoType(t?: string | null): boolean { const s = (t || '').toUpperCase(); return ( @@ -61,11 +62,9 @@ function formatGraphError(raw: string): string { 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.' + 'Token Meta inválido para esta Página. Usa el system user de la app 4V2030 ' + + 'con pages_* + instagram_* + ads_*, asígnalo a la Página y pégalo en Agencia ' + + '(o META_ACCESS_TOKEN en Dokploy).' ); } return ( @@ -79,23 +78,18 @@ function formatGraphError(raw: string): string { } } -/** - * 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' }> { +): Promise { 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' }; + if (j.access_token) return j.access_token; } - const accounts = await fetch( `${META}/me/accounts?fields=id,access_token&limit=100&access_token=${encodeURIComponent(userToken)}`, { cache: 'no-store' } @@ -105,234 +99,407 @@ async function resolvePageAccessToken( 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' }; + if (hit?.access_token) return hit.access_token; } - - return { token: userToken, via: 'user' }; + return userToken; } -function insightValue( - data: { name?: string; values?: { value?: number }[] }[] | undefined, +function insightSeries( + data: { name?: string; values?: { value?: number; end_time?: string }[] }[] | undefined, name: string -): number | null { +): DayPoint[] { const row = data?.find((d) => d.name === name); - const v = row?.values?.[0]?.value; - return typeof v === 'number' && Number.isFinite(v) ? v : null; + if (!row?.values?.length) return []; + return row.values + .map((v) => ({ + day: (v.end_time || '').slice(0, 10), + value: typeof v.value === 'number' && Number.isFinite(v.value) ? v.value : 0, + })) + .filter((p) => /^\d{4}-\d{2}-\d{2}$/.test(p.day)) + .sort((a, b) => a.day.localeCompare(b.day)); } -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: formatGraphError(await r.text()) }; - const profile = await r.json(); +function lastValue(series: DayPoint[]): number | null { + if (!series.length) return null; + return series[series.length - 1].value; +} - const insightsRes = await fetch( - `${META}/${encodeURIComponent(pageId)}/insights?metric=page_impressions,page_impressions_unique,page_post_engagements,page_fans,page_views_total&period=day&access_token=${encodeURIComponent(token)}`, - { cache: 'no-store' } - ); - let pageInsights: Record = {}; - if (insightsRes.ok) { - const j = await insightsRes.json(); - const data = j.data as { name?: string; values?: { value?: number }[] }[]; - pageInsights = { - impressions: insightValue(data, 'page_impressions'), - reach: insightValue(data, 'page_impressions_unique'), - postEngagements: insightValue(data, 'page_post_engagements'), - fans: insightValue(data, 'page_fans'), - pageViews: insightValue(data, 'page_views_total'), - }; +function sumSeries(series: DayPoint[]): number { + return series.reduce((a, p) => a + (p.value || 0), 0); +} + +function toUnix(isoDay: string, endOfDay = false): number { + const t = Date.parse(`${isoDay}T${endOfDay ? '23:59:59' : '00:00:00'}Z`); + return Math.floor(t / 1000); +} + +async function graphGet( + path: string, + token: string, + query: Record = {} +): Promise<{ ok: boolean; json: Record; raw: string }> { + const q = new URLSearchParams({ ...query, access_token: token }); + const r = await fetch(`${META}/${path}?${q}`, { cache: 'no-store' }); + const raw = await r.text(); + let json: Record = {}; + try { + json = JSON.parse(raw) as Record; + } catch { + /* keep empty */ + } + return { ok: r.ok, json, raw }; +} + +/** Pick first agency token that can read the page (or just /me). */ +async function pickWorkingToken( + tokens: string[], + pageId?: string | null +): Promise { + for (const t of tokens) { + if (pageId) { + const probe = await graphGet(encodeURIComponent(pageId), t, { + fields: 'id,name', + }); + if (probe.ok) return t; + continue; + } + const me = await graphGet('me', t, { fields: 'id,name' }); + if (me.ok) return t; + } + return tokens[0] || null; +} + +async function fetchPageBundle( + agencyToken: string, + pageId: string, + since: string, + until: string +) { + const token = await resolvePageAccessToken(agencyToken, pageId); + const profileRes = await graphGet(encodeURIComponent(pageId), token, { + fields: 'name,fan_count,followers_count,link,picture{url},engagement,about,website', + }); + if (!profileRes.ok) return { error: formatGraphError(profileRes.raw) }; + const profile = profileRes.json as Record; + + const sinceU = String(toUnix(since)); + const untilU = String(toUnix(until, true)); + + const metricSets = [ + 'page_impressions,page_impressions_unique,page_post_engagements,page_fans,page_views_total,page_follows,page_daily_follows,page_actions_post_reactions_total', + 'page_impressions,page_impressions_unique,page_post_engagements,page_fans,page_views_total', + 'page_impressions,page_impressions_unique,page_post_engagements,page_fans', + ]; + + let insightData: { + name?: string; + values?: { value?: number; end_time?: string }[]; + }[] = []; + let insightsError: string | undefined; + + for (const metrics of metricSets) { + const ins = await graphGet(`${encodeURIComponent(pageId)}/insights`, token, { + metric: metrics, + period: 'day', + since: sinceU, + until: untilU, + }); + if (ins.ok && Array.isArray(ins.json.data)) { + insightData = ins.json.data as typeof insightData; + break; + } + insightsError = formatGraphError(ins.raw); } - const postsRes = await fetch( - `${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 series = { + impressions: insightSeries(insightData, 'page_impressions'), + reach: insightSeries(insightData, 'page_impressions_unique'), + postEngagements: insightSeries(insightData, 'page_post_engagements'), + fans: insightSeries(insightData, 'page_fans'), + pageViews: insightSeries(insightData, 'page_views_total'), + follows: insightSeries(insightData, 'page_follows').length + ? insightSeries(insightData, 'page_follows') + : insightSeries(insightData, 'page_daily_follows'), + reactions: insightSeries(insightData, 'page_actions_post_reactions_total'), + }; + + const pageInsights = { + impressions: sumSeries(series.impressions) || lastValue(series.impressions), + reach: sumSeries(series.reach) || lastValue(series.reach), + postEngagements: + sumSeries(series.postEngagements) || lastValue(series.postEngagements), + fans: + lastValue(series.fans) ?? + (typeof profile.followers_count === 'number' + ? profile.followers_count + : typeof profile.fan_count === 'number' + ? profile.fan_count + : null), + pageViews: sumSeries(series.pageViews) || lastValue(series.pageViews), + follows: sumSeries(series.follows) || lastValue(series.follows), + reactions: sumSeries(series.reactions) || lastValue(series.reactions), + }; + const posts: SocialPost[] = []; const seen = new Set(); + + const postsRes = await graphGet( + `${encodeURIComponent(pageId)}/published_posts`, + token, + { + 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: '20', + } + ); + let postsError: string | undefined; if (postsRes.ok) { - const j = await postsRes.json(); - for (const p of j.data || []) { - const att = p.attachments?.data?.[0]; - const attType = String(att?.media_type || att?.type || '').toLowerCase(); - const videoSrc = - att?.media?.source || - att?.url || - att?.subattachments?.data?.[0]?.media?.source || - null; - const isVideo = - attType.includes('video') || - String(p.status_type || '').includes('video') || - Boolean(videoSrc && /\.mp4|video/i.test(String(videoSrc))); - const thumb = - p.full_picture || - att?.media?.image?.src || - att?.subattachments?.data?.[0]?.media?.image?.src || - null; - posts.push({ - id: p.id, - platform: 'facebook', - message: p.message || '', - mediaUrl: isVideo ? videoSrc || thumb : thumb, - thumbnailUrl: thumb, - 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: isVideo ? 'VIDEO' : thumb ? 'IMAGE' : 'status', - }); - seen.add(p.id); + const data = (postsRes.json.data as Record[]) || []; + for (const p of data) { + pushFbPost(posts, seen, p); } } else { - // 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' } - ); + const posts2 = await graphGet(`${encodeURIComponent(pageId)}/posts`, token, { + 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: '20', + }); 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); - } + const data = (posts2.json.data as Record[]) || []; + for (const p of data) pushFbPost(posts, seen, p); } else { - profile.postsError = formatGraphError(await postsRes.text()); + postsError = formatGraphError(postsRes.raw); } } - 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' } - ); + const videosRes = await graphGet(`${encodeURIComponent(pageId)}/videos`, token, { + fields: 'id,description,created_time,source,permalink_url,picture,length,title', + limit: '10', + }); if (videosRes.ok) { - const j = await videosRes.json(); - for (const v of j.data || []) { - if (!v?.id || seen.has(v.id)) continue; + for (const v of (videosRes.json.data as Record[]) || []) { + if (!v?.id || seen.has(String(v.id))) continue; posts.push({ - id: v.id, + id: String(v.id), platform: 'facebook', - message: v.description || v.title || '', - mediaUrl: v.source || v.picture || null, - thumbnailUrl: v.picture || null, - permalink: v.permalink_url || null, - createdAt: v.created_time || null, + message: String(v.description || v.title || ''), + mediaUrl: (v.source as string) || (v.picture as string) || null, + thumbnailUrl: (v.picture as string) || null, + permalink: (v.permalink_url as string) || null, + createdAt: (v.created_time as string) || null, likes: 0, comments: 0, shares: 0, mediaType: 'VIDEO', }); - seen.add(v.id); + seen.add(String(v.id)); } } - return { ...profile, pageInsights, posts }; + return { + ...profile, + pageInsights, + series, + posts, + postsError, + insightsError: insightData.length ? undefined : insightsError, + since, + until, + }; } -async function fetchIgBundle(token: string, igId: string) { - const r = await fetch( - `${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: formatGraphError(await r.text()) }; - const profile = await r.json(); +function pushFbPost( + posts: SocialPost[], + seen: Set, + p: Record +) { + const id = String(p.id || ''); + if (!id || seen.has(id)) return; + const att = (p.attachments as { data?: Record[] })?.data?.[0]; + const attType = String(att?.media_type || att?.type || '').toLowerCase(); + const media = att?.media as { source?: string; image?: { src?: string } } | undefined; + const videoSrc = + media?.source || + (att?.url as string) || + ( + att?.subattachments as { + data?: { media?: { source?: string } }[]; + } + )?.data?.[0]?.media?.source || + null; + const isVideo = + attType.includes('video') || + String(p.status_type || '').includes('video') || + Boolean(videoSrc && /\.mp4|video/i.test(String(videoSrc))); + const thumb = + (p.full_picture as string) || + media?.image?.src || + ( + att?.subattachments as { + data?: { media?: { image?: { src?: string } } }[]; + } + )?.data?.[0]?.media?.image?.src || + null; + const likes = p.likes as { summary?: { total_count?: number } } | undefined; + const comments = p.comments as { summary?: { total_count?: number } } | undefined; + const shares = p.shares as { count?: number } | undefined; + posts.push({ + id, + platform: 'facebook', + message: String(p.message || ''), + mediaUrl: isVideo ? videoSrc || thumb : thumb, + thumbnailUrl: thumb, + permalink: (p.permalink_url as string) || null, + createdAt: (p.created_time as string) || null, + likes: Number(likes?.summary?.total_count || 0), + comments: Number(comments?.summary?.total_count || 0), + shares: Number(shares?.count || 0), + mediaType: isVideo ? 'VIDEO' : thumb ? 'IMAGE' : 'status', + }); + seen.add(id); +} - const ins = await fetch( - `${META}/${encodeURIComponent(igId)}/insights?metric=reach,impressions,profile_views,website_clicks,accounts_engaged&period=day&access_token=${encodeURIComponent(token)}`, - { cache: 'no-store' } - ); - let igInsights: Record = {}; - if (ins.ok) { - const j = await ins.json(); - const data = j.data as { name?: string; values?: { value?: number }[] }[]; - igInsights = { - reach: insightValue(data, 'reach'), - impressions: insightValue(data, 'impressions'), - profileViews: insightValue(data, 'profile_views'), - websiteClicks: insightValue(data, 'website_clicks'), - accountsEngaged: insightValue(data, 'accounts_engaged'), - }; - } else { - // Fallback subset (some metrics need different periods on newer API) - const ins2 = await fetch( - `${META}/${encodeURIComponent(igId)}/insights?metric=reach,follower_count&period=day&access_token=${encodeURIComponent(token)}`, - { cache: 'no-store' } - ); - if (ins2.ok) { - const j = await ins2.json(); - const data = j.data as { name?: string; values?: { value?: number }[] }[]; - igInsights = { - reach: insightValue(data, 'reach'), - followerDelta: insightValue(data, 'follower_count'), - }; +async function fetchIgBundle( + agencyToken: string, + igId: string, + since: string, + until: string +) { + const profileRes = await graphGet(encodeURIComponent(igId), agencyToken, { + fields: + 'username,name,followers_count,follows_count,media_count,profile_picture_url,biography,website', + }); + if (!profileRes.ok) return { error: formatGraphError(profileRes.raw) }; + const profile = profileRes.json as Record; + + const sinceU = String(toUnix(since)); + const untilU = String(toUnix(until, true)); + + const metricSets = [ + 'reach,views,profile_views,website_clicks,accounts_engaged,follower_count,total_interactions', + 'reach,profile_views,website_clicks,accounts_engaged,follower_count', + 'reach,follower_count,profile_views', + 'reach,follower_count', + ]; + + let insightData: { + name?: string; + values?: { value?: number; end_time?: string }[]; + }[] = []; + let insightsError: string | undefined; + + for (const metrics of metricSets) { + const ins = await graphGet(`${encodeURIComponent(igId)}/insights`, agencyToken, { + metric: metrics, + period: 'day', + since: sinceU, + until: untilU, + }); + if (ins.ok && Array.isArray(ins.json.data)) { + insightData = ins.json.data as typeof insightData; + break; } + // Some metrics reject `since`; retry without range (last ~30d default) + const ins2 = await graphGet( + `${encodeURIComponent(igId)}/insights`, + agencyToken, + { metric: metrics, period: 'day' } + ); + if (ins2.ok && Array.isArray(ins2.json.data)) { + insightData = (ins2.json.data as typeof insightData).filter((row) => { + // keep points inside range when end_time present + const vals = (row.values || []).filter((v) => { + const d = (v.end_time || '').slice(0, 10); + return d >= since && d <= until; + }); + row.values = vals; + return vals.length > 0; + }); + if (insightData.length) break; + } + insightsError = formatGraphError(ins.raw); } - const mediaRes = await fetch( - `${META}/${encodeURIComponent(igId)}/media?fields=id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count&limit=16&access_token=${encodeURIComponent(token)}`, - { cache: 'no-store' } - ); + const series = { + reach: insightSeries(insightData, 'reach'), + impressions: + insightSeries(insightData, 'views').length + ? insightSeries(insightData, 'views') + : insightSeries(insightData, 'impressions'), + profileViews: insightSeries(insightData, 'profile_views'), + websiteClicks: insightSeries(insightData, 'website_clicks'), + accountsEngaged: insightSeries(insightData, 'accounts_engaged'), + followerDelta: insightSeries(insightData, 'follower_count'), + interactions: insightSeries(insightData, 'total_interactions'), + }; + + const igInsights = { + reach: sumSeries(series.reach) || lastValue(series.reach), + impressions: sumSeries(series.impressions) || lastValue(series.impressions), + profileViews: sumSeries(series.profileViews) || lastValue(series.profileViews), + websiteClicks: + sumSeries(series.websiteClicks) || lastValue(series.websiteClicks), + accountsEngaged: + sumSeries(series.accountsEngaged) || lastValue(series.accountsEngaged), + followerDelta: + sumSeries(series.followerDelta) || lastValue(series.followerDelta), + interactions: + sumSeries(series.interactions) || lastValue(series.interactions), + }; + + const mediaRes = await graphGet(`${encodeURIComponent(igId)}/media`, agencyToken, { + fields: + 'id,caption,media_type,media_url,thumbnail_url,permalink,timestamp,like_count,comments_count', + limit: '20', + }); const posts: SocialPost[] = []; + let postsError: string | undefined; if (mediaRes.ok) { - const j = await mediaRes.json(); - for (const m of j.data || []) { - const mediaType = m.media_type || null; + for (const m of (mediaRes.json.data as Record[]) || []) { + const mediaType = (m.media_type as string) || null; const video = isVideoType(mediaType); - // VIDEO/REELS: keep media_url for