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

@@ -4,7 +4,7 @@ import { serviceHeaders, supabaseUrl } from '@/lib/agency';
import { resolveMetaAccessToken } from '@/lib/ads-discovery';
const META_GRAPH = 'https://graph.facebook.com/v21.0';
const GADS_API = 'https://googleads.googleapis.com/v21';
const GADS_API = 'https://googleads.googleapis.com/v22';
export type SyncTenantResult = {
tenantId: string;
@@ -20,8 +20,25 @@ type IntegrationRow = {
provider: string;
external_account_id: string | null;
status?: string;
meta?: {
extra_ad_accounts?: { account_id?: string; external_account_id?: string }[];
alternate_accounts?: { external_account_id?: string }[];
} | null;
};
function extraAccountIds(row: IntegrationRow | undefined): string[] {
if (!row) return [];
const out: string[] = [];
for (const x of row.meta?.extra_ad_accounts || []) {
const id = x.account_id || x.external_account_id;
if (id) out.push(id);
}
for (const x of row.meta?.alternate_accounts || []) {
if (x.external_account_id) out.push(x.external_account_id);
}
return out;
}
type TenantRow = { id: string; name: string };
type AdDailyInsert = {
@@ -114,22 +131,39 @@ function gadsHeaders(accessToken: string, loginCustomerId?: string) {
return h;
}
async function fetchMetaAdDaily(
accountId: string,
tenantId: string,
days: number
): Promise<AdDailyInsert[]> {
const token = await resolveMetaAccessToken();
if (!token) throw new Error('Sin token Meta');
const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`;
const since = new Date();
since.setUTCDate(since.getUTCDate() - days);
const until = new Date();
const timeRange = JSON.stringify({
since: since.toISOString().slice(0, 10),
until: until.toISOString().slice(0, 10),
});
function isoDayUTC(d: Date): string {
return d.toISOString().slice(0, 10);
}
/** Inclusive day windows of at most `chunkDays` from `since` → today. */
function dayWindows(days: number, chunkDays: number): { since: string; until: string }[] {
const until = new Date();
const start = new Date();
start.setUTCDate(start.getUTCDate() - days);
start.setUTCHours(0, 0, 0, 0);
until.setUTCHours(0, 0, 0, 0);
const out: { since: string; until: string }[] = [];
let cursor = new Date(start);
while (cursor <= until) {
const end = new Date(cursor);
end.setUTCDate(end.getUTCDate() + chunkDays - 1);
if (end > until) end.setTime(until.getTime());
out.push({ since: isoDayUTC(cursor), until: isoDayUTC(end) });
cursor = new Date(end);
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
return out;
}
async function fetchMetaInsightsWindow(
act: string,
token: string,
tenantId: string,
since: string,
until: string
): Promise<AdDailyInsert[]> {
const timeRange = JSON.stringify({ since, until });
const out: AdDailyInsert[] = [];
let url: string | null =
`${META_GRAPH}/${act}/insights` +
@@ -177,19 +211,32 @@ async function fetchMetaAdDaily(
return out;
}
async function fetchGoogleCampaignDaily(
customerId: string,
async function fetchMetaAdDaily(
accountId: string,
tenantId: string,
days: number,
days: number
): Promise<AdDailyInsert[]> {
const token = await resolveMetaAccessToken();
if (!token) throw new Error('Sin token Meta');
const act = accountId.startsWith('act_') ? accountId : `act_${accountId}`;
// Meta rejects huge ad-level ranges — pull in ~30d windows
const windows = dayWindows(days, 30);
const out: AdDailyInsert[] = [];
for (const w of windows) {
out.push(
...(await fetchMetaInsightsWindow(act, token, tenantId, w.since, w.until))
);
}
return out;
}
async function fetchGoogleWindow(
cid: string,
tenantId: string,
sinceStr: string,
untilStr: string,
accessToken: string
): Promise<AdDailyInsert[]> {
const cid = customerId.replace(/-/g, '');
const since = new Date();
since.setUTCDate(since.getUTCDate() - days);
const until = new Date();
const sinceStr = since.toISOString().slice(0, 10);
const untilStr = until.toISOString().slice(0, 10);
const query = `
SELECT
campaign.id,
@@ -304,6 +351,23 @@ async function fetchGoogleCampaignDaily(
return out;
}
async function fetchGoogleCampaignDaily(
customerId: string,
tenantId: string,
days: number,
accessToken: string
): Promise<AdDailyInsert[]> {
const cid = customerId.replace(/-/g, '');
const windows = dayWindows(days, 90);
const out: AdDailyInsert[] = [];
for (const w of windows) {
out.push(
...(await fetchGoogleWindow(cid, tenantId, w.since, w.until, accessToken))
);
}
return out;
}
async function replaceDaily(
tenantId: string,
platforms: string[],
@@ -350,17 +414,23 @@ async function upsertWeeklyFromDaily(tenantId: string, days: number) {
since.setUTCDate(since.getUTCDate() - days);
const sinceStr = since.toISOString().slice(0, 10);
const r = await fetch(
`${base}/rest/v1/ad_daily?tenant_id=eq.${encodeURIComponent(tenantId)}&date=gte.${sinceStr}&select=date,platform,spend,platform_leads`,
{ headers, cache: 'no-store' }
);
if (!r.ok) throw new Error(await r.text());
const daily = (await r.json()) as {
const daily: {
date?: string;
platform?: string;
spend?: number;
platform_leads?: number;
}[];
}[] = [];
const pageSize = 1000;
for (let offset = 0; offset < 200000; offset += pageSize) {
const r = await fetch(
`${base}/rest/v1/ad_daily?tenant_id=eq.${encodeURIComponent(tenantId)}&date=gte.${sinceStr}&select=date,platform,spend,platform_leads&order=date.asc&limit=${pageSize}&offset=${offset}`,
{ headers, cache: 'no-store' }
);
if (!r.ok) throw new Error(await r.text());
const chunk = (await r.json()) as typeof daily;
daily.push(...chunk);
if (chunk.length < pageSize) break;
}
type Agg = { spend: number; leads: number };
const map = new Map<string, Agg>();
@@ -401,27 +471,33 @@ async function upsertWeeklyFromDaily(tenantId: string, days: number) {
if (payload.length) {
// kpi_weekly may be a materialized view — best-effort only
const ins = await fetch(`${base}/rest/v1/kpi_weekly`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'resolution=merge-duplicates,return=minimal',
},
body: JSON.stringify(payload),
});
if (!ins.ok) {
const msg = await ins.text();
if (msg.includes('materialized view')) {
// UI falls back to ad_daily; skip hard failure
return 0;
for (let i = 0; i < payload.length; i += 400) {
const chunk = payload.slice(i, i + 400);
const ins = await fetch(`${base}/rest/v1/kpi_weekly`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'resolution=merge-duplicates,return=minimal',
},
body: JSON.stringify(chunk),
});
if (!ins.ok) {
const msg = await ins.text();
if (msg.includes('materialized view')) {
// UI falls back to ad_daily; skip hard failure
return 0;
}
throw new Error(`insert kpi_weekly: ${msg.slice(0, 220)}`);
}
throw new Error(`insert kpi_weekly: ${msg.slice(0, 220)}`);
}
}
return payload.length;
}
/** Max lookback for intentional backfills (≈ Jan 2024 → now). Cron stays short. */
export const ADS_BACKFILL_MAX_DAYS = 1000;
export async function syncAdsForTenants(opts?: {
tenantId?: string;
days?: number;
@@ -429,20 +505,29 @@ export async function syncAdsForTenants(opts?: {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const base = supabaseUrl();
const days = opts?.days ?? 30;
const days = Math.min(
ADS_BACKFILL_MAX_DAYS,
Math.max(1, opts?.days ?? 30)
);
const tRes = await fetch(
`${base}/rest/v1/tenants?select=id,name&order=name.asc`,
`${base}/rest/v1/tenants?select=id,name&agency_followed=neq.false&order=name.asc`,
{ headers, cache: 'no-store' }
);
if (!tRes.ok) throw new Error(await tRes.text());
let tenants = (await tRes.json()) as TenantRow[];
if (opts?.tenantId) {
tenants = tenants.filter((t) => t.id === opts.tenantId);
// Explicit id may be archived satellite — still allow
const all = await fetch(
`${base}/rest/v1/tenants?select=id,name&order=name.asc`,
{ headers, cache: 'no-store' }
);
const allTenants = all.ok ? ((await all.json()) as TenantRow[]) : tenants;
tenants = allTenants.filter((t) => t.id === opts.tenantId);
}
const iRes = await fetch(
`${base}/rest/v1/integrations?provider=in.(meta_ads,google_ads)&select=tenant_id,provider,external_account_id,status&external_account_id=not.is.null`,
`${base}/rest/v1/integrations?provider=in.(meta_ads,google_ads)&select=tenant_id,provider,external_account_id,status,meta&external_account_id=not.is.null`,
{ headers, cache: 'no-store' }
);
if (!iRes.ok) throw new Error(await iRes.text());
@@ -471,52 +556,77 @@ export async function syncAdsForTenants(opts?: {
const platforms: string[] = [];
const meta = mine.find((i) => i.provider === 'meta_ads' && i.external_account_id);
if (meta?.external_account_id) {
const metaIds = [
...(meta?.external_account_id ? [meta.external_account_id] : []),
...extraAccountIds(meta),
];
for (const accountId of [...new Set(metaIds)]) {
try {
const mrows = await fetchMetaAdDaily(
meta.external_account_id,
tenant.id,
days
);
const mrows = await fetchMetaAdDaily(accountId, tenant.id, days);
rows.push(...mrows);
platforms.push('meta');
result.metaRows = mrows.length;
result.metaRows += mrows.length;
} catch (e) {
result.errors.push(
e instanceof Error ? e.message : 'meta sync error'
);
}
}
if (result.metaRows > 0) platforms.push('meta');
const gads = mine.find(
(i) => i.provider === 'google_ads' && i.external_account_id
);
if (gads?.external_account_id) {
const googleIds = [
...(gads?.external_account_id ? [gads.external_account_id] : []),
...extraAccountIds(gads),
];
if (googleIds.length) {
if (!gToken) {
result.errors.push('Google Ads sin token OAuth');
} else {
try {
const grows = await fetchGoogleCampaignDaily(
gads.external_account_id,
tenant.id,
days,
gToken
);
rows.push(...grows);
platforms.push('google');
result.googleRows = grows.length;
} catch (e) {
result.errors.push(
e instanceof Error ? e.message : 'google sync error'
);
for (const customerId of [...new Set(googleIds)]) {
try {
const grows = await fetchGoogleCampaignDaily(
customerId,
tenant.id,
days,
gToken
);
rows.push(...grows);
result.googleRows += grows.length;
} catch (e) {
result.errors.push(
e instanceof Error ? e.message : 'google sync error'
);
}
}
}
}
if (result.googleRows > 0) platforms.push('google');
if (rows.length && platforms.length) {
try {
await replaceDaily(tenant.id, platforms, rows, days);
result.weeklyRows = await upsertWeeklyFromDaily(tenant.id, days);
const now = new Date().toISOString();
for (const p of platforms) {
const provider = p === 'meta' ? 'meta_ads' : 'google_ads';
await fetch(
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.${provider}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify({
last_sync_at: now,
error_message: null,
}),
}
);
}
} catch (e) {
result.errors.push(
e instanceof Error ? e.message : 'warehouse write error'