feat: sync Clientify CRM + botón Sincronizar por pestaña

El CRM solo vivía de webhooks; ahora hay pull Clientify, cron daily-sync incluye CRM, y cada módulo de Agencia tiene sync manual.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-30 15:34:40 +02:00
parent c11ec20f26
commit e3180ceb51
13 changed files with 749 additions and 10 deletions

View File

@@ -0,0 +1,187 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { syncAdsForTenants } from '@/lib/ads-sync';
import { syncAllClientifyTenants } from '@/lib/clientify-sync';
import { syncAllConnectedOrganic } from '@/lib/google-organic';
import {
getAgencyOAuthStatus,
applyMatches,
matchFoldersToTenants,
listClientFoldersUnderClientes,
} from '@/lib/google-drive';
import {
syncAllSuperhumanTenants,
superhumanConfigured,
} from '@/lib/superhuman-docs';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
export type SyncModule =
| 'metrics'
| 'crm'
| 'seguimiento'
| 'ads'
| 'social'
| 'organico'
| 'activos'
| 'all';
/**
* POST { module, days?, tenantId? }
* Agency-triggered refresh for dashboard modules.
*/
export async function POST(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user || !isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await req.json().catch(() => ({}));
const module = (body.module || 'all') as SyncModule;
const days =
typeof body.days === 'number' && body.days > 0 && body.days <= 90
? body.days
: 14;
const ctx = await resolveActiveTenant(user);
const tenantId =
typeof body.tenantId === 'string'
? body.tenantId
: ctx.portfolioWide
? undefined
: ctx.tenantId || undefined;
const out: Record<string, unknown> = {
ok: true,
module,
tenantId: tenantId || null,
at: new Date().toISOString(),
};
const want = (m: SyncModule) => module === 'all' || module === m;
try {
if (want('crm') || want('metrics')) {
out.crm = await syncAllClientifyTenants({ tenantId });
}
} catch (e) {
out.crm = {
ok: false,
error: e instanceof Error ? e.message : 'crm failed',
};
}
try {
if (want('ads') || want('metrics')) {
const ads = await syncAdsForTenants({ tenantId, days });
out.ads = {
ok: true,
synced: ads.results.filter((r) => r.metaRows + r.googleRows > 0).length,
withErrors: ads.results.filter((r) => r.errors.length).length,
results: ads.results.slice(0, 30),
};
}
} catch (e) {
out.ads = {
ok: false,
error: e instanceof Error ? e.message : 'ads failed',
};
}
try {
if (want('organico') || want('metrics')) {
const oauth = await getAgencyOAuthStatus();
if (!oauth.connected) {
out.organico = { ok: false, warning: 'Google agencia no conectado' };
} else {
out.organico = {
ok: true,
...(await syncAllConnectedOrganic({ days, tenantId })),
};
}
}
} catch (e) {
out.organico = {
ok: false,
error: e instanceof Error ? e.message : 'organic failed',
};
}
try {
if (want('seguimiento')) {
if (!superhumanConfigured()) {
out.seguimiento = {
ok: false,
warning: 'SUPERHUMAN_DOCS_API_KEY no configurado',
};
} else if (tenantId) {
out.seguimiento = {
ok: true,
...(await syncAllSuperhumanTenants({
tenantId,
maxEntries: 80,
})),
};
} else {
out.seguimiento = {
ok: true,
...(await syncAllSuperhumanTenants({ maxEntries: 80 })),
};
}
}
} catch (e) {
out.seguimiento = {
ok: false,
error: e instanceof Error ? e.message : 'seguimiento failed',
};
}
try {
if (want('activos')) {
const oauth = await getAgencyOAuthStatus();
if (!oauth.connected) {
out.activos = { ok: false, warning: 'Google Drive no conectado' };
} else {
let tenants: { id: string; name: string; slug: string }[] = [];
const headers = serviceHeaders();
const base = supabaseUrl();
if (headers && base) {
const tr = await fetch(
`${base}/rest/v1/tenants?select=id,name,slug&agency_followed=neq.false`,
{ headers, cache: 'no-store' }
);
if (tr.ok) tenants = (await tr.json()) as typeof tenants;
}
const { folders } = await listClientFoldersUnderClientes();
const matches = await matchFoldersToTenants(tenants, folders);
const { linked } = await applyMatches(matches);
out.activos = {
ok: true,
matched: matches.filter((m) => m.status === 'matched').length,
linked,
hint: 'Carpetas Drive re-emparejadas. Recarga Activos para ver archivos.',
};
}
}
} catch (e) {
out.activos = {
ok: false,
error: e instanceof Error ? e.message : 'activos failed',
};
}
if (want('social')) {
out.social = {
ok: true,
hint: 'Social se refresca al abrir la pestaña (Graph live + cache). Recarga la página.',
reload: true,
};
}
return NextResponse.json(out);
}

View File

@@ -7,6 +7,7 @@ import {
syncAllSuperhumanTenants,
superhumanConfigured,
} from '@/lib/superhuman-docs';
import { syncAllClientifyTenants } from '@/lib/clientify-sync';
export const dynamic = 'force-dynamic';
export const maxDuration = 300;
@@ -89,7 +90,23 @@ export async function GET(req: NextRequest) {
};
}
const failed = ['organic', 'ads', 'superhuman'].some(
// 4) Clientify CRM → leads/deals
try {
const crm = await syncAllClientifyTenants();
out.crm = {
ok: true,
synced: crm.results.filter((r) => !r.error).length,
withErrors: crm.results.filter((r) => r.error).length,
results: crm.results,
};
} catch (e) {
out.crm = {
ok: false,
error: e instanceof Error ? e.message : 'crm failed',
};
}
const failed = ['organic', 'ads', 'superhuman', 'crm'].some(
(k) => (out[k] as { ok?: boolean } | undefined)?.ok === false
);
return NextResponse.json(out, { status: failed ? 207 : 200 });