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:
4Ventures
2026-09-28 10:23:36 +02:00
parent dd50251de2
commit cf27eba74d
30 changed files with 2367 additions and 514 deletions

View File

@@ -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;
}
}
}