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:
187
src/app/api/admin/sync/route.ts
Normal file
187
src/app/api/admin/sync/route.ts
Normal 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);
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user