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

272
scripts/sync_all_data.ts Normal file
View File

@@ -0,0 +1,272 @@
/**
* One-shot sync: organic + ads + superhuman + SEO audits.
* Usage:
* npx tsx -r dotenv/config -r tsconfig-paths/register scripts/sync_all_data.ts
* Optional: TENANT_SLUG=bps
*/
import { config } from 'dotenv';
import { resolve } from 'path';
config({ path: resolve(process.cwd(), '.env.local') });
config({ path: resolve(process.cwd(), '.env') });
import { serviceHeaders, supabaseUrl } from '../src/lib/agency';
import { syncAllConnectedOrganic } from '../src/lib/google-organic';
import { syncAdsForTenants } from '../src/lib/ads-sync';
import { syncAllSuperhumanTenants } from '../src/lib/superhuman-docs';
import { runSeoSiteAudit } from '../src/lib/seo-audit';
import { resolveMetaAccessTokens } from '../src/lib/ads-discovery';
const META = 'https://graph.facebook.com/v21.0';
type Tenant = { id: string; name: string; slug: string };
async function sbGet<T>(path: string): Promise<T> {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const r = await fetch(`${supabaseUrl()}/rest/v1/${path}`, {
headers,
cache: 'no-store',
});
if (!r.ok) throw new Error(`${path}: ${(await r.text()).slice(0, 300)}`);
return (await r.json()) as T;
}
async function sbPatch(path: string, body: unknown) {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const r = await fetch(`${supabaseUrl()}/rest/v1/${path}`, {
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`PATCH ${path}: ${(await r.text()).slice(0, 300)}`);
}
async function sbPost(path: string, body: unknown) {
const headers = serviceHeaders();
if (!headers) throw new Error('no service role');
const r = await fetch(`${supabaseUrl()}/rest/v1/${path}`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`POST ${path}: ${(await r.text()).slice(0, 400)}`);
}
async function syncSocial(tenants: Tenant[]) {
const tokens = await resolveMetaAccessTokens();
const token = tokens[0];
if (!token) return { ok: false as const, warning: 'Sin META token', results: [] as unknown[] };
const results: { tenant: string; fb?: string; ig?: string }[] = [];
for (const tenant of tenants) {
const integs = await sbGet<
{
provider: string;
external_account_id?: string | null;
status: string;
meta?: Record<string, unknown> | null;
}[]
>(
`integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=in.(facebook,instagram)&status=eq.connected&select=provider,external_account_id,status,meta`
);
if (!integs.length) continue;
const out: { tenant: string; fb?: string; ig?: string } = {
tenant: tenant.name,
};
for (const i of integs) {
const id = i.external_account_id;
if (!id) continue;
try {
let payload: Record<string, unknown> | null = null;
if (i.provider === 'facebook') {
const r = await fetch(
`${META}/${encodeURIComponent(id)}?fields=name,fan_count,followers_count&access_token=${encodeURIComponent(token)}`,
{ cache: 'no-store' }
);
if (r.ok) {
payload = (await r.json()) as Record<string, unknown>;
out.fb = 'ok';
} else {
out.fb = `err ${(await r.text()).slice(0, 80)}`;
}
} else {
const r = await fetch(
`${META}/${encodeURIComponent(id)}?fields=name,followers_count,media_count&access_token=${encodeURIComponent(token)}`,
{ cache: 'no-store' }
);
if (r.ok) {
payload = (await r.json()) as Record<string, unknown>;
out.ig = 'ok';
} else {
out.ig = `err ${(await r.text()).slice(0, 80)}`;
}
}
if (payload) {
const now = new Date().toISOString();
await sbPatch(
`integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.${i.provider}`,
{
last_sync_at: now,
error_message: null,
meta: {
...(i.meta || {}),
social_cache: payload,
social_cached_at: now,
},
}
);
}
} catch (e) {
const msg = e instanceof Error ? e.message : 'err';
if (i.provider === 'facebook') out.fb = msg;
else out.ig = msg;
}
}
results.push(out);
}
return { ok: true as const, results };
}
async function syncSeoAudits(tenants: Tenant[]) {
const results: {
tenant: string;
score?: number;
grade?: string;
error?: string;
}[] = [];
for (const tenant of tenants) {
const [gsc, ga4] = await Promise.all([
sbGet<
{ external_account_id?: string | null; meta?: { site_url?: string } }[]
>(
`integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.search_console&status=eq.connected&select=external_account_id,meta&limit=1`
),
sbGet<{ id: string }[]>(
`integrations?tenant_id=eq.${encodeURIComponent(tenant.id)}&provider=eq.ga4&status=eq.connected&select=id&limit=1`
),
]);
const site =
gsc[0]?.meta?.site_url || gsc[0]?.external_account_id || null;
if (!site) continue;
try {
const audit = await runSeoSiteAudit({
gscSiteUrl: site,
ga4Connected: ga4.length > 0,
gscConnected: true,
});
await sbPost('seo_audits', {
tenant_id: tenant.id,
site_url: audit.siteUrl,
score: audit.score,
grade: audit.grade,
warning_count: audit.warningCount,
critical_count: audit.criticalCount,
checks: audit.checks,
summary: audit.summary,
});
results.push({
tenant: tenant.name,
score: audit.score,
grade: audit.grade,
});
} catch (e) {
results.push({
tenant: tenant.name,
error: e instanceof Error ? e.message.slice(0, 160) : 'audit error',
});
}
}
return results;
}
async function main() {
if (!serviceHeaders()) throw new Error('Falta SUPABASE_SERVICE_ROLE_KEY');
let tenants = await sbGet<Tenant[]>(
'tenants?select=id,name,slug&order=name.asc'
);
const slug = process.env.TENANT_SLUG?.trim();
const tid = process.env.TENANT_ID?.trim();
if (slug) tenants = tenants.filter((t) => t.slug === slug);
if (tid) tenants = tenants.filter((t) => t.id === tid);
console.log(`\n=== SYNC ALL · ${tenants.length} tenant(s) ===\n`);
console.log('--- ORGANIC ---');
const organic = await syncAllConnectedOrganic({
tenantId: tenants.length === 1 ? tenants[0].id : null,
days: 28,
});
for (const r of organic.results) {
const name = tenants.find((t) => t.id === r.tenantId)?.name || r.tenantId;
console.log(
` ${name}: ${r.daysWritten}d${r.warning ? ` · ⚠ ${r.warning}` : ''}`
);
}
console.log('\n--- ADS ---');
const ads = await syncAdsForTenants({
tenantId: tenants.length === 1 ? tenants[0].id : undefined,
days: 30,
});
for (const r of ads.results) {
console.log(
` ${r.tenantName}: meta=${r.metaRows} google=${r.googleRows}` +
(r.errors.length ? ` · ERR ${r.errors.join('; ').slice(0, 100)}` : '')
);
}
console.log('\n--- SUPERHUMAN ---');
try {
const sh = await syncAllSuperhumanTenants({
tenantId: tenants.length === 1 ? tenants[0].id : null,
maxEntries: 100,
exportBodies: true,
});
for (const r of sh.results) {
console.log(
` ${r.tenantName}: ${r.status}` +
(r.upserted ? ` · ${r.upserted}` : '') +
(r.docName ? ` · ${r.docName}` : '') +
(r.error ? ` · ${r.error.slice(0, 80)}` : '')
);
}
} catch (e) {
console.log(' ', e instanceof Error ? e.message : e);
}
console.log('\n--- SOCIAL ---');
const social = await syncSocial(tenants);
if (!social.ok) console.log(' ', social.warning);
for (const r of social.results as { tenant: string; fb?: string; ig?: string }[]) {
console.log(` ${r.tenant}: fb=${r.fb || '-'} ig=${r.ig || '-'}`);
}
console.log('\n--- SEO AUDIT ---');
for (const r of await syncSeoAudits(tenants)) {
console.log(
` ${r.tenant}:` +
(r.grade != null ? ` ${r.grade} (${r.score})` : '') +
(r.error ? ` ERR ${r.error}` : '')
);
}
console.log('\n=== DONE ===\n');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});