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>
273 lines
8.5 KiB
TypeScript
273 lines
8.5 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|