feat: KPIs con totales absolutos, backfill ads y ventas en cruce canal
Comparativas WoW/MoM/YoY muestran prev/Δ además del %; sync ads admite histórico desde 2025; tabla ads↔CRM añade cierres, ventas y ROAS. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
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 { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
|
||||
import { resolveMetaAccessTokens } from '@/lib/ads-discovery';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -18,6 +18,73 @@ type Integ = {
|
||||
meta?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
async function cacheSocialSnapshot(
|
||||
tenantId: string,
|
||||
provider: 'facebook' | 'instagram',
|
||||
payload: unknown
|
||||
) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) return;
|
||||
const now = new Date().toISOString();
|
||||
// Merge into meta without wiping other keys: read-modify-write
|
||||
const r = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${provider}&select=meta&limit=1`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
let meta: Record<string, unknown> = {};
|
||||
if (r.ok) {
|
||||
const rows = (await r.json()) as { meta?: Record<string, unknown> }[];
|
||||
meta = { ...(rows[0]?.meta || {}) };
|
||||
}
|
||||
// Keep a compact snapshot (metrics + up to 12 posts)
|
||||
const snap =
|
||||
payload && typeof payload === 'object'
|
||||
? {
|
||||
...(payload as object),
|
||||
posts: Array.isArray((payload as { posts?: unknown }).posts)
|
||||
? (payload as { posts: unknown[] }).posts.slice(0, 12)
|
||||
: undefined,
|
||||
}
|
||||
: payload;
|
||||
meta.social_cache = snap;
|
||||
meta.social_cached_at = now;
|
||||
await fetch(
|
||||
`${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.${provider}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
meta,
|
||||
last_sync_at: now,
|
||||
error_message: null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function readSocialCache(integrations: Integ[]): {
|
||||
facebook?: unknown;
|
||||
instagram?: unknown;
|
||||
at?: string;
|
||||
} {
|
||||
const out: { facebook?: unknown; instagram?: unknown; at?: string } = {};
|
||||
for (const i of integrations) {
|
||||
const cache = i.meta?.social_cache;
|
||||
const at = typeof i.meta?.social_cached_at === 'string'
|
||||
? i.meta.social_cached_at
|
||||
: i.last_sync_at || undefined;
|
||||
if (!cache) continue;
|
||||
if (i.provider === 'facebook') out.facebook = cache;
|
||||
if (i.provider === 'instagram') out.instagram = cache;
|
||||
if (at && (!out.at || at > out.at)) out.at = at;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
type SocialPost = {
|
||||
id: string;
|
||||
platform: 'facebook' | 'instagram';
|
||||
@@ -585,25 +652,34 @@ export async function GET(req: NextRequest) {
|
||||
const posts: SocialPost[] = [];
|
||||
const tokens = await resolveMetaAccessTokens();
|
||||
|
||||
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')
|
||||
);
|
||||
|
||||
if (!tokens.length) {
|
||||
metrics.warning =
|
||||
'Sin token Meta: configura META_ACCESS_TOKEN (4V2030) o pégalo en Agencia.';
|
||||
const cached = readSocialCache(integrations);
|
||||
if (cached.facebook) metrics.facebook = cached.facebook;
|
||||
if (cached.instagram) metrics.instagram = cached.instagram;
|
||||
if (cached.facebook || cached.instagram) {
|
||||
metrics.cached = true;
|
||||
metrics.cachedAt = cached.at;
|
||||
metrics.warning +=
|
||||
' Mostrando último snapshot cacheado.';
|
||||
}
|
||||
} 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)) ||
|
||||
@@ -621,6 +697,7 @@ export async function GET(req: NextRequest) {
|
||||
if ('posts' in fb && Array.isArray(fb.posts)) {
|
||||
posts.push(...(fb.posts as SocialPost[]));
|
||||
}
|
||||
await cacheSocialSnapshot(ctx.tenantId, 'facebook', fb);
|
||||
}
|
||||
if (igInteg?.external_account_id) {
|
||||
const ig = await fetchIgBundle(
|
||||
@@ -633,9 +710,18 @@ export async function GET(req: NextRequest) {
|
||||
if ('posts' in ig && Array.isArray(ig.posts)) {
|
||||
posts.push(...(ig.posts as SocialPost[]));
|
||||
}
|
||||
await cacheSocialSnapshot(ctx.tenantId, 'instagram', ig);
|
||||
}
|
||||
} catch (e) {
|
||||
metrics.warning = e instanceof Error ? e.message : 'metrics error';
|
||||
const cached = readSocialCache(integrations);
|
||||
if (cached.facebook && !metrics.facebook) metrics.facebook = cached.facebook;
|
||||
if (cached.instagram && !metrics.instagram)
|
||||
metrics.instagram = cached.instagram;
|
||||
if (cached.facebook || cached.instagram) {
|
||||
metrics.cached = true;
|
||||
metrics.cachedAt = cached.at;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user