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 });
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CreativeZoomModal,
|
||||
} from '@/components/AdCreativesGallery';
|
||||
import { PageHeader } from '@/components/ui';
|
||||
import SyncNowButton from '@/components/SyncNowButton';
|
||||
import { useDateRange } from '@/components/DateRangeContext';
|
||||
|
||||
type PlatformKpis = {
|
||||
@@ -688,6 +689,7 @@ export default function AdTrackerClient({
|
||||
eyebrow={`Ads · ${label}`}
|
||||
title={tagline || 'Meta + Google Ads'}
|
||||
description={`Gasto, clics, conversiones y creatividades. Periodo del header: ${rangeLabel}.`}
|
||||
actions={<SyncNowButton forceModule="ads" />}
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '@/components/ui';
|
||||
import SyncNowButton from '@/components/SyncNowButton';
|
||||
|
||||
type Asset = {
|
||||
id: string;
|
||||
@@ -257,6 +258,7 @@ export default function AssetsLibrary({ tenantLabel }: { tenantLabel: string })
|
||||
eyebrow="Contenido"
|
||||
title="Activos"
|
||||
description={`Réplica exacta de Drive · carpeta del cliente «${tenantLabel}» (bajo Clientes). Solo ese árbol; sin mezclar otros clientes.`}
|
||||
actions={<SyncNowButton forceModule="activos" />}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '@/lib/metrics';
|
||||
import { PageHeader } from '@/components/ui';
|
||||
import { useDateRange } from '@/components/DateRangeContext';
|
||||
import SyncNowButton from '@/components/SyncNowButton';
|
||||
|
||||
function rateClass(rate: number): string {
|
||||
if (rate >= 8) return 'text-emerald-400';
|
||||
@@ -106,6 +107,7 @@ export default function CrmDashboard({
|
||||
eyebrow="Rendimiento"
|
||||
title="CRM"
|
||||
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Funnel y % a cliente por canal · periodo ${rangeHint} (header).`}
|
||||
actions={<SyncNowButton forceModule="crm" />}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
formatNum,
|
||||
resolveKpiDayRange,
|
||||
} from '@/lib/metrics';
|
||||
import SyncNowButton from '@/components/SyncNowButton';
|
||||
|
||||
type RangeKey = KpiRangeKey;
|
||||
|
||||
@@ -85,7 +86,8 @@ export default function CrmPortfolioBoard({
|
||||
cliente en Contexto para el detalle completo.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||
<SyncNowButton forceModule="crm" />
|
||||
<CalendarRange className="w-4 h-4 text-gray-500" />
|
||||
<div className="flex gap-1 rounded-lg border border-gray-200 p-0.5 bg-gray-50">
|
||||
{(
|
||||
|
||||
@@ -26,6 +26,7 @@ import TenantSwitcher from '@/components/TenantSwitcher';
|
||||
import type { DashboardModuleId } from '@/lib/tenant-modules';
|
||||
import DateRangePicker from '@/components/DateRangePicker';
|
||||
import { useDateRange } from '@/components/DateRangeContext';
|
||||
import SyncNowButton from '@/components/SyncNowButton';
|
||||
|
||||
type NavItem = {
|
||||
href: string;
|
||||
@@ -348,6 +349,8 @@ export default function DashboardShell({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<DateRangePicker
|
||||
compact
|
||||
preset={dateRange.preset}
|
||||
@@ -356,6 +359,9 @@ export default function DashboardShell({
|
||||
onPreset={dateRange.setPreset}
|
||||
onCustomChange={dateRange.setCustom}
|
||||
/>
|
||||
</div>
|
||||
{isAgency ? <SyncNowButton /> : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
KpiWeeklyRow,
|
||||
} from '@/lib/metrics';
|
||||
import { PageHeader } from '@/components/ui';
|
||||
import SyncNowButton from '@/components/SyncNowButton';
|
||||
import { useDateRange } from '@/components/DateRangeContext';
|
||||
|
||||
type Card = {
|
||||
@@ -386,6 +387,7 @@ export default function MetricsDashboard({
|
||||
eyebrow="Rendimiento"
|
||||
title="Métricas"
|
||||
description={`${label}${portfolioWide ? ' · portafolio' : ''}. Periodo ${rangeHint} (selector global del header).`}
|
||||
actions={<SyncNowButton forceModule="metrics" />}
|
||||
/>
|
||||
|
||||
{sparse && (
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader, SegmentedControl } from '@/components/ui';
|
||||
import SyncNowButton from '@/components/SyncNowButton';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
@@ -406,6 +407,7 @@ export default function OrganicoDashboard({
|
||||
eyebrow="Crecimiento"
|
||||
title="Orgánico"
|
||||
description="SEO + GEO (visibilidad en IA). Elige un cliente en Contexto."
|
||||
actions={<SyncNowButton forceModule="organico" />}
|
||||
/>
|
||||
<p className="text-sm text-gray-600 border border-gray-200 rounded-xl px-4 py-8 text-center bg-gray-50">
|
||||
Selecciona un cliente para abrir su suite SEO / GEO.
|
||||
@@ -430,6 +432,7 @@ export default function OrganicoDashboard({
|
||||
description={`${label} · Observa posicionamiento en búsqueda e IA, competencia y oportunidades de contenido.`}
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SyncNowButton forceModule="organico" />
|
||||
<SegmentedControl
|
||||
value={pillar}
|
||||
onChange={(p) => setPillar(p)}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '@/components/ui';
|
||||
import SyncNowButton from '@/components/SyncNowButton';
|
||||
import NoteRichEditor, { richTextHasContent } from '@/components/NoteRichEditor';
|
||||
|
||||
type Entry = {
|
||||
@@ -205,6 +206,7 @@ export default function SeguimientoBoard() {
|
||||
eyebrow="Operación"
|
||||
title="Seguimiento"
|
||||
description={`${data.label} · histórico de notas (más reciente primero). El periodo del header no filtra este timeline.`}
|
||||
actions={<SyncNowButton forceModule="seguimiento" />}
|
||||
/>
|
||||
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-5 space-y-3">
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Repeat2,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader } from '@/components/ui';
|
||||
import SyncNowButton from '@/components/SyncNowButton';
|
||||
import { useDateRange } from '@/components/DateRangeContext';
|
||||
|
||||
type Integ = {
|
||||
@@ -571,6 +572,7 @@ export default function SocialDashboard({
|
||||
eyebrow={`Social · ${tenantLabel}`}
|
||||
title="Redes sociales"
|
||||
description={`Analytics orgánicos IG/FB · periodo ${rangeLabel}`}
|
||||
actions={<SyncNowButton forceModule="social" />}
|
||||
/>
|
||||
|
||||
{err && (
|
||||
|
||||
153
src/components/SyncNowButton.tsx
Normal file
153
src/components/SyncNowButton.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { Loader2, RefreshCw } from 'lucide-react';
|
||||
|
||||
type SyncModule =
|
||||
| 'metrics'
|
||||
| 'crm'
|
||||
| 'seguimiento'
|
||||
| 'ads'
|
||||
| 'social'
|
||||
| 'organico'
|
||||
| 'activos'
|
||||
| 'all';
|
||||
|
||||
function moduleFromPath(pathname: string): SyncModule | null {
|
||||
if (pathname.startsWith('/dashboard/agencia')) return 'all';
|
||||
if (pathname.startsWith('/dashboard/kpis')) return 'metrics';
|
||||
if (pathname.startsWith('/dashboard/crm')) return 'crm';
|
||||
if (pathname.startsWith('/dashboard/seguimiento')) return 'seguimiento';
|
||||
if (pathname.startsWith('/dashboard/ad-tracker')) return 'ads';
|
||||
if (pathname.startsWith('/dashboard/social')) return 'social';
|
||||
if (pathname.startsWith('/dashboard/organico')) return 'organico';
|
||||
if (pathname.startsWith('/dashboard/activos')) return 'activos';
|
||||
if (pathname.startsWith('/dashboard/estrategia')) return 'metrics';
|
||||
if (pathname.startsWith('/dashboard/calendario')) return 'seguimiento';
|
||||
return null;
|
||||
}
|
||||
|
||||
const LABELS: Record<SyncModule, string> = {
|
||||
metrics: 'Sincronizar métricas',
|
||||
crm: 'Sincronizar CRM',
|
||||
seguimiento: 'Sincronizar seguimiento',
|
||||
ads: 'Sincronizar Ads',
|
||||
social: 'Actualizar Social',
|
||||
organico: 'Sincronizar orgánico',
|
||||
activos: 'Sincronizar Drive',
|
||||
all: 'Sincronizar todo',
|
||||
};
|
||||
|
||||
export default function SyncNowButton({
|
||||
forceModule,
|
||||
className = '',
|
||||
}: {
|
||||
forceModule?: SyncModule;
|
||||
className?: string;
|
||||
}) {
|
||||
const pathname = usePathname() || '';
|
||||
const router = useRouter();
|
||||
const module = forceModule || moduleFromPath(pathname);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const label = useMemo(
|
||||
() => (module ? LABELS[module] : 'Sincronizar'),
|
||||
[module]
|
||||
);
|
||||
|
||||
if (!module) return null;
|
||||
|
||||
async function run() {
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
setErr(null);
|
||||
try {
|
||||
if (module === 'social') {
|
||||
router.refresh();
|
||||
setMsg('Social actualizado');
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
const res = await fetch('/api/admin/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ module, days: 14 }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setErr(data.error || 'Sync falló');
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (data.crm?.results) {
|
||||
const ok = (data.crm.results as { leads?: number; error?: string }[]).filter(
|
||||
(r) => !r.error && (r.leads || 0) >= 0
|
||||
);
|
||||
const bad = (data.crm.results as { error?: string }[]).filter((r) => r.error);
|
||||
parts.push(`CRM ${ok.length} ok` + (bad.length ? ` · ${bad.length} sin key` : ''));
|
||||
}
|
||||
if (data.ads) {
|
||||
parts.push(
|
||||
data.ads.ok === false
|
||||
? `Ads error`
|
||||
: `Ads ${data.ads.synced || 0} tenants`
|
||||
);
|
||||
}
|
||||
if (data.organico) {
|
||||
parts.push(
|
||||
data.organico.ok === false ? 'Orgánico avisó' : 'Orgánico OK'
|
||||
);
|
||||
}
|
||||
if (data.seguimiento) {
|
||||
parts.push(
|
||||
data.seguimiento.ok === false ? 'Seguimiento avisó' : 'Seguimiento OK'
|
||||
);
|
||||
}
|
||||
if (data.activos) {
|
||||
parts.push(
|
||||
data.activos.ok === false
|
||||
? 'Drive avisó'
|
||||
: `Drive ${data.activos.linked ?? data.activos.matched ?? 0}`
|
||||
);
|
||||
}
|
||||
setMsg(parts.join(' · ') || 'Listo');
|
||||
router.refresh();
|
||||
} catch {
|
||||
setErr('Error de red');
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col items-end gap-0.5 ${className}`}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void run()}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-lg border border-gray-200 bg-white text-gray-800 hover:border-brand-400 hover:text-brand-700 disabled:opacity-50"
|
||||
title="Actualiza datos de esta pestaña desde las APIs (agencia)"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{busy ? 'Sincronizando…' : label}
|
||||
</button>
|
||||
{msg ? (
|
||||
<span className="text-[10px] text-emerald-700 max-w-[220px] text-right">
|
||||
{msg}
|
||||
</span>
|
||||
) : null}
|
||||
{err ? (
|
||||
<span className="text-[10px] text-red-700 max-w-[220px] text-right">
|
||||
{err}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
359
src/lib/clientify-sync.ts
Normal file
359
src/lib/clientify-sync.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
/** Pull Clientify contacts/deals → warehouse (leads/deals). */
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
|
||||
const CFY = 'https://api.clientify.com/v1';
|
||||
|
||||
function uuid5(name: string): string {
|
||||
const NS = Buffer.from('6ba7b8119dad11d180b400c04fd430c8', 'hex');
|
||||
const hash = createHash('sha1').update(NS).update(name).digest();
|
||||
hash[6] = (hash[6]! & 0x0f) | 0x50;
|
||||
hash[8] = (hash[8]! & 0x3f) | 0x80;
|
||||
const h = hash.subarray(0, 16).toString('hex');
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
||||
}
|
||||
|
||||
function clientifyKeyForSlug(slug: string): string | null {
|
||||
const upper = slug.toUpperCase().replace(/-/g, '_');
|
||||
return (
|
||||
process.env[`CLIENTIFY_API_KEY_${upper}`]?.trim() ||
|
||||
process.env.CLIENTIFY_API_KEY?.trim() ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
async function cfyGet(token: string, path: string): Promise<Record<string, unknown>> {
|
||||
const r = await fetch(`${CFY}${path}`, {
|
||||
headers: {
|
||||
Authorization: `Token ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(`Clientify ${path}: ${(await r.text()).slice(0, 180)}`);
|
||||
}
|
||||
return (await r.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function cfyPaginate(
|
||||
token: string,
|
||||
path: string,
|
||||
maxPages = 40
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const out: Record<string, unknown>[] = [];
|
||||
const base = path.split('?')[0] || path;
|
||||
const params = new URLSearchParams(path.includes('?') ? path.split('?')[1] : '');
|
||||
params.set('page_size', '100');
|
||||
for (let page = 1; page <= maxPages; page++) {
|
||||
params.set('page', String(page));
|
||||
const j = await cfyGet(token, `${base}?${params}`);
|
||||
const batch = (j.results as Record<string, unknown>[]) || [];
|
||||
out.push(...batch);
|
||||
if (!j.next || !batch.length) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function firstEmail(c: Record<string, unknown>): string | null {
|
||||
const emails = c.emails;
|
||||
if (!Array.isArray(emails) || !emails.length) return null;
|
||||
const e = emails[0] as Record<string, unknown> | string;
|
||||
if (typeof e === 'object' && e) {
|
||||
return String(e.email || e.value || '').trim() || null;
|
||||
}
|
||||
return String(e).trim() || null;
|
||||
}
|
||||
|
||||
function firstPhone(c: Record<string, unknown>): string | null {
|
||||
const phones = c.phones;
|
||||
if (!Array.isArray(phones) || !phones.length) return null;
|
||||
const p = phones[0] as Record<string, unknown> | string;
|
||||
if (typeof p === 'object' && p) {
|
||||
return String(p.phone || p.value || '').trim() || null;
|
||||
}
|
||||
return String(p).trim() || null;
|
||||
}
|
||||
|
||||
function parseDate(s: unknown): string | null {
|
||||
if (!s || typeof s !== 'string') return null;
|
||||
try {
|
||||
if (s.includes('T')) {
|
||||
return new Date(s).toISOString();
|
||||
}
|
||||
return new Date(`${s}T12:00:00Z`).toISOString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mapStage(deal: Record<string, unknown>): 'won' | 'lost' | 'open' {
|
||||
const desc = String(deal.status_desc || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (desc === 'won') return 'won';
|
||||
if (desc === 'lost') return 'lost';
|
||||
if (deal.status === 3) return 'won';
|
||||
if (deal.status === 4) return 'lost';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
function contactIdFromUrl(url: unknown): string | null {
|
||||
if (typeof url !== 'string' || !url) return null;
|
||||
const parts = url.replace(/\/$/, '').split('/');
|
||||
return parts[parts.length - 1] || null;
|
||||
}
|
||||
|
||||
export type ClientifySyncResult = {
|
||||
tenantId: string;
|
||||
tenantSlug: string;
|
||||
leads: number;
|
||||
deals: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export async function syncClientifyTenant(opts: {
|
||||
tenantId: string;
|
||||
slug: string;
|
||||
apiKey?: string | null;
|
||||
}): Promise<ClientifySyncResult> {
|
||||
const headers = serviceHeaders();
|
||||
const base = supabaseUrl();
|
||||
if (!headers || !base) {
|
||||
return {
|
||||
tenantId: opts.tenantId,
|
||||
tenantSlug: opts.slug,
|
||||
leads: 0,
|
||||
deals: 0,
|
||||
error: 'no service role',
|
||||
};
|
||||
}
|
||||
|
||||
const token = opts.apiKey?.trim() || clientifyKeyForSlug(opts.slug);
|
||||
if (!token) {
|
||||
return {
|
||||
tenantId: opts.tenantId,
|
||||
tenantSlug: opts.slug,
|
||||
leads: 0,
|
||||
deals: 0,
|
||||
error: `Falta CLIENTIFY_API_KEY (o CLIENTIFY_API_KEY_${opts.slug.toUpperCase().replace(/-/g, '_')})`,
|
||||
};
|
||||
}
|
||||
|
||||
const tid = opts.tenantId;
|
||||
try {
|
||||
const contacts = await cfyPaginate(token, '/contacts/');
|
||||
await fetch(
|
||||
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tid)}&crm=eq.clientify`,
|
||||
{ method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } }
|
||||
);
|
||||
|
||||
const contactToLead = new Map<string, string>();
|
||||
const leadRows = contacts.map((c) => {
|
||||
const cid = String(c.id);
|
||||
const lid = uuid5(`clientify-lead:${tid}:${cid}`);
|
||||
contactToLead.set(cid, lid);
|
||||
const source = String(
|
||||
c.contact_source || c.channel || c.medium || 'clientify'
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.slice(0, 80) || 'clientify';
|
||||
const status =
|
||||
String(c.status || 'new')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.slice(0, 40) || 'new';
|
||||
const name = [c.first_name, c.last_name]
|
||||
.map((x) => String(x || '').trim())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return {
|
||||
id: lid,
|
||||
tenant_id: tid,
|
||||
first_name: name || null,
|
||||
email: firstEmail(c),
|
||||
phone: firstPhone(c),
|
||||
source,
|
||||
medium: c.medium ? String(c.medium) : null,
|
||||
crm: 'clientify',
|
||||
crm_contact_id: cid,
|
||||
status,
|
||||
created_at: parseDate(c.created) || new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
for (let i = 0; i < leadRows.length; i += 200) {
|
||||
const chunk = leadRows.slice(i, i + 200);
|
||||
const r = await fetch(`${base}/rest/v1/leads`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify(chunk),
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(`insert leads: ${(await r.text()).slice(0, 180)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const deals = await cfyPaginate(token, '/deals/');
|
||||
await fetch(
|
||||
`${base}/rest/v1/deals?tenant_id=eq.${encodeURIComponent(tid)}&crm_deal_id=like.clientify:*`,
|
||||
{ method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } }
|
||||
);
|
||||
|
||||
const dealRows = deals.map((d) => {
|
||||
const did = String(d.id);
|
||||
const stage = mapStage(d);
|
||||
const cid = contactIdFromUrl(d.contact);
|
||||
const leadId = cid ? contactToLead.get(cid) || null : null;
|
||||
const amount = Number(d.amount || d.amount_user || 0) || 0;
|
||||
const closed = parseDate(d.actual_closed_date);
|
||||
const pipeUrl = String(d.pipeline || '');
|
||||
const pipeId = pipeUrl.includes('/pipelines/')
|
||||
? pipeUrl.replace(/\/$/, '').split('/').pop() || null
|
||||
: null;
|
||||
let rawSrc = String(d.deal_source || d.contact_source || '')
|
||||
.trim()
|
||||
.slice(0, 80);
|
||||
const tags = Array.isArray(d.tags) ? d.tags.map(String).join(' ').toLowerCase() : '';
|
||||
const name = String(d.name || '').slice(0, 200);
|
||||
if (!rawSrc) {
|
||||
if (/facebook|instagram|meta|\bfb\b/.test(tags)) rawSrc = 'meta';
|
||||
else if (/google/.test(tags)) rawSrc = 'google';
|
||||
else if (/woocommerce/.test(tags) || /^order /i.test(name))
|
||||
rawSrc = 'ecommerce';
|
||||
}
|
||||
return {
|
||||
id: uuid5(`clientify-deal:${tid}:${did}`),
|
||||
tenant_id: tid,
|
||||
lead_id: leadId,
|
||||
crm_deal_id: `clientify:${did}`,
|
||||
stage,
|
||||
amount,
|
||||
won_at: stage === 'won' ? closed : null,
|
||||
lost_at: stage === 'lost' ? closed : null,
|
||||
pipeline_id: pipeId,
|
||||
pipeline_name: d.pipeline_desc ? String(d.pipeline_desc) : null,
|
||||
source: rawSrc ? rawSrc.toLowerCase() : null,
|
||||
name: name || null,
|
||||
};
|
||||
});
|
||||
|
||||
for (let i = 0; i < dealRows.length; i += 200) {
|
||||
const chunk = dealRows.slice(i, i + 200);
|
||||
const r = await fetch(`${base}/rest/v1/deals`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify(chunk),
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(`insert deals: ${(await r.text()).slice(0, 180)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
await fetch(
|
||||
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tid)}&provider=eq.clientify`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=minimal',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: 'connected',
|
||||
last_sync_at: now,
|
||||
error_message: null,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
tenantId: tid,
|
||||
tenantSlug: opts.slug,
|
||||
leads: leadRows.length,
|
||||
deals: dealRows.length,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
tenantId: tid,
|
||||
tenantSlug: opts.slug,
|
||||
leads: 0,
|
||||
deals: 0,
|
||||
error: e instanceof Error ? e.message : 'clientify sync failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync all tenants with clientify integration (+ API key available). */
|
||||
export async function syncAllClientifyTenants(opts?: {
|
||||
tenantId?: string;
|
||||
}): Promise<{ results: ClientifySyncResult[] }> {
|
||||
const headers = serviceHeaders();
|
||||
const base = supabaseUrl();
|
||||
if (!headers || !base) return { results: [] };
|
||||
|
||||
const tRes = await fetch(
|
||||
`${base}/rest/v1/tenants?select=id,slug,name&agency_followed=neq.false`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!tRes.ok) throw new Error(await tRes.text());
|
||||
let tenants = (await tRes.json()) as { id: string; slug: string; name: string }[];
|
||||
if (opts?.tenantId) {
|
||||
tenants = tenants.filter((t) => t.id === opts.tenantId);
|
||||
}
|
||||
|
||||
const iRes = await fetch(
|
||||
`${base}/rest/v1/integrations?provider=eq.clientify&select=tenant_id,status,meta,external_account_id`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!iRes.ok) throw new Error(await iRes.text());
|
||||
const ints = (await iRes.json()) as {
|
||||
tenant_id: string;
|
||||
status?: string;
|
||||
meta?: { api_key?: string } | null;
|
||||
external_account_id?: string | null;
|
||||
}[];
|
||||
const byTenant = new Map(ints.map((i) => [i.tenant_id, i]));
|
||||
|
||||
const results: ClientifySyncResult[] = [];
|
||||
for (const t of tenants) {
|
||||
const integ = byTenant.get(t.id);
|
||||
// Sync if marked connected/pending OR we have a key for this slug
|
||||
const key =
|
||||
(integ?.meta && typeof integ.meta.api_key === 'string'
|
||||
? integ.meta.api_key
|
||||
: null) || clientifyKeyForSlug(t.slug);
|
||||
if (!key && integ?.status !== 'connected' && integ?.status !== 'pending') {
|
||||
continue;
|
||||
}
|
||||
if (!key) {
|
||||
results.push({
|
||||
tenantId: t.id,
|
||||
tenantSlug: t.slug,
|
||||
leads: 0,
|
||||
deals: 0,
|
||||
error: `Sin API key Clientify para ${t.slug}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
results.push(
|
||||
await syncClientifyTenant({
|
||||
tenantId: t.id,
|
||||
slug: t.slug,
|
||||
apiKey: key,
|
||||
})
|
||||
);
|
||||
}
|
||||
return { results };
|
||||
}
|
||||
Reference in New Issue
Block a user