feat: Social con gráficas diarias por periodo y token Meta 4V2030

Series IG/FB (alcance, impresiones, clics, engagement, fans) según el rango del header; Meta y Google quedan como tokens separados.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-24 17:30:44 +02:00
parent 224f060e9d
commit 5e5fc87710
4 changed files with 707 additions and 257 deletions

View File

@@ -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<string> {
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<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'),
};
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<string, string> = {}
): Promise<{ ok: boolean; json: Record<string, unknown>; 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<string, unknown> = {};
try {
json = JSON.parse(raw) as Record<string, unknown>;
} 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<string | null> {
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<string, unknown>;
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<string>();
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<string, unknown>[]) || [];
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<string, unknown>[]) || [];
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<string, unknown>[]) || []) {
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<string>,
p: Record<string, unknown>
) {
const id = String(p.id || '');
if (!id || seen.has(id)) return;
const att = (p.attachments as { data?: Record<string, unknown>[] })?.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<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'),
};
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<string, unknown>;
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<string, unknown>[]) || []) {
const mediaType = (m.media_type as string) || 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;
? (m.media_url as string) || (m.thumbnail_url as string) || null
: (m.media_url as string) || (m.thumbnail_url as string) || null;
const thumbnailUrl =
(m.thumbnail_url as string) || (video ? null : (m.media_url as string)) || 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' }
? 'reach,saved,shares,views'
: 'reach,views,saved,shares';
const mi = await graphGet(
`${encodeURIComponent(String(m.id))}/insights`,
agencyToken,
{ metric: metrics }
);
if (mi.ok) {
const ij = await mi.json();
const data = ij.data as {
if (mi.ok && Array.isArray(mi.json.data)) {
const data = mi.json.data as {
name?: string;
values?: { value?: number }[];
}[];
reach = insightValue(data, 'reach');
impressions =
insightValue(data, 'impressions') ??
insightValue(data, 'plays');
shares = insightValue(data, 'shares') ?? 0;
const val = (name: string) => {
const row = data.find((d) => d.name === name);
const v = row?.values?.[0]?.value;
return typeof v === 'number' ? v : null;
};
reach = val('reach');
impressions = val('views') ?? val('impressions');
shares = val('shares') ?? 0;
}
} catch {
/* fall through without per-media insights */
/* skip */
}
posts.push({
id: m.id,
id: String(m.id),
platform: 'instagram',
message: m.caption || '',
message: String(m.caption || ''),
mediaUrl,
thumbnailUrl,
permalink: m.permalink || null,
createdAt: m.timestamp || null,
permalink: (m.permalink as string) || null,
createdAt: (m.timestamp as string) || null,
likes: Number(m.like_count || 0),
comments: Number(m.comments_count || 0),
shares,
@@ -342,14 +509,34 @@ async function fetchIgBundle(token: string, igId: string) {
});
}
} else {
profile.postsError = formatGraphError(await mediaRes.text());
postsError = formatGraphError(mediaRes.raw);
}
return { ...profile, igInsights, posts };
return {
...profile,
igInsights,
series,
posts,
postsError,
insightsError: insightData.length ? undefined : insightsError,
since,
until,
};
}
/** GET — social integrations + analytics + recent posts preview. */
export async function GET() {
function defaultRange(): { since: string; until: string } {
const until = new Date();
until.setUTCDate(until.getUTCDate() - 1);
const since = new Date(until);
since.setUTCDate(since.getUTCDate() - 27);
return {
since: since.toISOString().slice(0, 10),
until: until.toISOString().slice(0, 10),
};
}
/** GET — social integrations + analytics series + posts. ?since=&until= */
export async function GET(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
@@ -368,6 +555,12 @@ export async function GET() {
});
}
const def = defaultRange();
const sinceParam = req.nextUrl.searchParams.get('since') || '';
const untilParam = req.nextUrl.searchParams.get('until') || '';
const since = /^\d{4}-\d{2}-\d{2}$/.test(sinceParam) ? sinceParam : def.since;
const until = /^\d{4}-\d{2}-\d{2}$/.test(untilParam) ? untilParam : def.until;
const agency = isAgencyRole(userRole(user));
const providers = 'instagram,facebook,tiktok,linkedin';
let integrations: Integ[] = [];
@@ -390,35 +583,65 @@ export async function GET() {
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 {
const tokens = await resolveMetaAccessTokens();
if (!tokens.length) {
metrics.warning =
'Sin token Meta de agencia: perfiles enlazados pero sin métricas live.';
'Sin token Meta: configura META_ACCESS_TOKEN (4V2030) o pégalo en Agencia.';
} else {
const fbInteg = integrations.find(
(i) =>
i.provider === 'facebook' &&
i.status === 'connected' &&
i.external_account_id &&
!i.external_account_id.startsWith('http')
);
const igInteg = integrations.find(
(i) =>
i.provider === 'instagram' &&
i.status === 'connected' &&
i.external_account_id &&
!i.external_account_id.startsWith('http')
);
const token =
(await pickWorkingToken(tokens, fbInteg?.external_account_id)) ||
(await pickWorkingToken(tokens, igInteg?.external_account_id)) ||
tokens[0];
try {
if (fbInteg?.external_account_id) {
const fb = await fetchPageBundle(
token,
fbInteg.external_account_id,
since,
until
);
metrics.facebook = fb;
if (Array.isArray(fb.posts)) posts.push(...fb.posts);
}
if (igInteg?.external_account_id) {
const ig = await fetchIgBundle(
token,
igInteg.external_account_id,
since,
until
);
metrics.instagram = ig;
if (Array.isArray(ig.posts)) posts.push(...ig.posts);
}
} catch (e) {
metrics.warning = e instanceof Error ? e.message : 'metrics error';
}
}
posts.sort((a, b) => {
// Filter posts to selected range when dates exist
const ranged = posts.filter((p) => {
if (!p.createdAt) return true;
const d = p.createdAt.slice(0, 10);
return d >= since && d <= until;
});
ranged.sort((a, b) => {
const ta = Date.parse(a.createdAt || '') || 0;
const tb = Date.parse(b.createdAt || '') || 0;
return tb - ta;
@@ -427,7 +650,9 @@ export async function GET() {
return NextResponse.json({
integrations,
metrics,
posts: posts.slice(0, 24),
posts: ranged.slice(0, 24),
since,
until,
label: ctx.label,
});
}