El token de ads no sirve para /posts; intentamos Page Access Token y explicamos los permisos necesarios. Co-authored-by: Cursor <cursoragent@cursor.com>
434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
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<string, unknown> | 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<string, number | null> = {};
|
|
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<string>();
|
|
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<string, number | null> = {};
|
|
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 <video>; thumbnail for poster
|
|
const mediaUrl = video
|
|
? m.media_url || m.thumbnail_url || null
|
|
: m.media_url || m.thumbnail_url || null;
|
|
const thumbnailUrl = m.thumbnail_url || (video ? null : m.media_url) || null;
|
|
let reach: number | null = null;
|
|
let impressions: number | null = null;
|
|
let shares = 0;
|
|
try {
|
|
const metrics = video
|
|
? 'reach,saved,shares,plays'
|
|
: 'reach,impressions,saved,shares';
|
|
const mi = await fetch(
|
|
`${META}/${encodeURIComponent(m.id)}/insights?metric=${metrics}&access_token=${encodeURIComponent(token)}`,
|
|
{ cache: 'no-store' }
|
|
);
|
|
if (mi.ok) {
|
|
const ij = await mi.json();
|
|
const data = ij.data as {
|
|
name?: string;
|
|
values?: { value?: number }[];
|
|
}[];
|
|
reach = insightValue(data, 'reach');
|
|
impressions =
|
|
insightValue(data, 'impressions') ??
|
|
insightValue(data, 'plays');
|
|
shares = insightValue(data, 'shares') ?? 0;
|
|
}
|
|
} catch {
|
|
/* fall through without per-media insights */
|
|
}
|
|
posts.push({
|
|
id: m.id,
|
|
platform: 'instagram',
|
|
message: m.caption || '',
|
|
mediaUrl,
|
|
thumbnailUrl,
|
|
permalink: m.permalink || null,
|
|
createdAt: m.timestamp || null,
|
|
likes: Number(m.like_count || 0),
|
|
comments: Number(m.comments_count || 0),
|
|
shares,
|
|
reach,
|
|
impressions,
|
|
mediaType,
|
|
});
|
|
}
|
|
} else {
|
|
profile.postsError = formatGraphError(await mediaRes.text());
|
|
}
|
|
|
|
return { ...profile, igInsights, posts };
|
|
}
|
|
|
|
/** GET — social integrations + analytics + recent posts preview. */
|
|
export async function GET() {
|
|
const supabase = await createClient();
|
|
const {
|
|
data: { user },
|
|
} = await supabase.auth.getUser();
|
|
if (!user) {
|
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
}
|
|
|
|
const ctx = await resolveActiveTenant(user);
|
|
if (!ctx.tenantId || ctx.portfolioWide) {
|
|
return NextResponse.json({
|
|
integrations: [],
|
|
metrics: {},
|
|
posts: [],
|
|
warning: 'Selecciona un cliente en Contexto',
|
|
});
|
|
}
|
|
|
|
const agency = isAgencyRole(userRole(user));
|
|
const providers = 'instagram,facebook,tiktok,linkedin';
|
|
let integrations: Integ[] = [];
|
|
|
|
if (agency && ctx.viaView) {
|
|
const tid = encodeURIComponent(ctx.tenantId);
|
|
const r = await restGet(
|
|
`/rest/v1/integrations?tenant_id=eq.${tid}&provider=in.(${providers})&select=provider,status,external_account_id,label,last_sync_at,meta,error_message`
|
|
);
|
|
integrations = (r.data as Integ[]) || [];
|
|
} else {
|
|
const { data } = await supabase
|
|
.from('integrations')
|
|
.select(
|
|
'provider,status,external_account_id,label,last_sync_at,meta,error_message'
|
|
)
|
|
.in('provider', ['instagram', 'facebook', 'tiktok', 'linkedin']);
|
|
integrations = (data as Integ[]) || [];
|
|
}
|
|
|
|
const metrics: Record<string, unknown> = {};
|
|
const posts: SocialPost[] = [];
|
|
const token = await resolveMetaAccessToken();
|
|
if (token) {
|
|
for (const i of integrations) {
|
|
if (i.status !== 'connected' || !i.external_account_id) continue;
|
|
const id = i.external_account_id;
|
|
if (id.startsWith('http')) continue;
|
|
try {
|
|
if (i.provider === 'facebook') {
|
|
const fb = await fetchPageBundle(token, id);
|
|
metrics.facebook = fb;
|
|
if (Array.isArray(fb.posts)) posts.push(...fb.posts);
|
|
}
|
|
if (i.provider === 'instagram') {
|
|
const ig = await fetchIgBundle(token, id);
|
|
metrics.instagram = ig;
|
|
if (Array.isArray(ig.posts)) posts.push(...ig.posts);
|
|
}
|
|
} catch (e) {
|
|
metrics[i.provider] = {
|
|
error: e instanceof Error ? e.message : 'metrics error',
|
|
};
|
|
}
|
|
}
|
|
} else {
|
|
metrics.warning =
|
|
'Sin token Meta de agencia: perfiles enlazados pero sin métricas live.';
|
|
}
|
|
|
|
posts.sort((a, b) => {
|
|
const ta = Date.parse(a.createdAt || '') || 0;
|
|
const tb = Date.parse(b.createdAt || '') || 0;
|
|
return tb - ta;
|
|
});
|
|
|
|
return NextResponse.json({
|
|
integrations,
|
|
metrics,
|
|
posts: posts.slice(0, 24),
|
|
label: ctx.label,
|
|
});
|
|
}
|