import { 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'; export const dynamic = 'force-dynamic'; export const maxDuration = 60; const META = 'https://graph.facebook.com/v21.0'; type Integ = { provider: string; status: string; external_account_id?: string | null; label?: string | null; last_sync_at?: string | null; meta?: Record | null; }; type SocialPost = { id: string; 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; likes: number; comments: number; shares: number; reach?: number | null; impressions?: number | null; mediaType?: string | null; }; function isVideoType(t?: string | null): boolean { const s = (t || '').toUpperCase(); return ( s === 'VIDEO' || s === 'REELS' || s === 'REEL' || s.includes('VIDEO') || s === 'ANIMATED_IMAGE_VIDEO' ); } 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 ): number | null { const row = data?.find((d) => d.name === name); const v = row?.values?.[0]?.value; return typeof v === 'number' && Number.isFinite(v) ? v : null; } 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(); 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'), }; } 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 posts: SocialPost[] = []; const seen = new Set(); 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); } } 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' } ); 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()); } } 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' } ); if (videosRes.ok) { const j = await videosRes.json(); for (const v of j.data || []) { if (!v?.id || seen.has(v.id)) continue; posts.push({ id: 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, likes: 0, comments: 0, shares: 0, mediaType: 'VIDEO', }); seen.add(v.id); } } return { ...profile, pageInsights, posts }; } 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(); 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'), }; } } 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 posts: SocialPost[] = []; if (mediaRes.ok) { const j = await mediaRes.json(); for (const m of j.data || []) { const mediaType = m.media_type || null; const video = isVideoType(mediaType); // VIDEO/REELS: keep media_url for