diff --git a/src/app/api/admin/sync/route.ts b/src/app/api/admin/sync/route.ts new file mode 100644 index 0000000..5661ad6 --- /dev/null +++ b/src/app/api/admin/sync/route.ts @@ -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 = { + 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); +} diff --git a/src/app/api/cron/daily-sync/route.ts b/src/app/api/cron/daily-sync/route.ts index 46e30d2..340f2b0 100644 --- a/src/app/api/cron/daily-sync/route.ts +++ b/src/app/api/cron/daily-sync/route.ts @@ -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 }); diff --git a/src/components/AdTrackerClient.tsx b/src/components/AdTrackerClient.tsx index 9c8c85b..48fb654 100644 --- a/src/components/AdTrackerClient.tsx +++ b/src/components/AdTrackerClient.tsx @@ -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={} /> {/* Tabs */} diff --git a/src/components/AssetsLibrary.tsx b/src/components/AssetsLibrary.tsx index 301f5f7..d61407c 100644 --- a/src/components/AssetsLibrary.tsx +++ b/src/components/AssetsLibrary.tsx @@ -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={} />
diff --git a/src/components/CrmDashboard.tsx b/src/components/CrmDashboard.tsx index 15a2c6f..72fca46 100644 --- a/src/components/CrmDashboard.tsx +++ b/src/components/CrmDashboard.tsx @@ -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={} />
diff --git a/src/components/CrmPortfolioBoard.tsx b/src/components/CrmPortfolioBoard.tsx index 3cd8e5c..7410585 100644 --- a/src/components/CrmPortfolioBoard.tsx +++ b/src/components/CrmPortfolioBoard.tsx @@ -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.

-
+
+
{( diff --git a/src/components/DashboardShell.tsx b/src/components/DashboardShell.tsx index 859595d..486abcf 100644 --- a/src/components/DashboardShell.tsx +++ b/src/components/DashboardShell.tsx @@ -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,14 +349,19 @@ export default function DashboardShell({ />
- +
+
+ +
+ {isAgency ? : null} +
diff --git a/src/components/MetricsDashboard.tsx b/src/components/MetricsDashboard.tsx index 8b4285a..d7154c2 100644 --- a/src/components/MetricsDashboard.tsx +++ b/src/components/MetricsDashboard.tsx @@ -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={} /> {sparse && ( diff --git a/src/components/OrganicoDashboard.tsx b/src/components/OrganicoDashboard.tsx index aa045bc..86e651d 100644 --- a/src/components/OrganicoDashboard.tsx +++ b/src/components/OrganicoDashboard.tsx @@ -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={} />

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={

+ setPillar(p)} diff --git a/src/components/SeguimientoBoard.tsx b/src/components/SeguimientoBoard.tsx index be3eb4e..908bb97 100644 --- a/src/components/SeguimientoBoard.tsx +++ b/src/components/SeguimientoBoard.tsx @@ -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={} />
diff --git a/src/components/SocialDashboard.tsx b/src/components/SocialDashboard.tsx index 05fb18f..a02883c 100644 --- a/src/components/SocialDashboard.tsx +++ b/src/components/SocialDashboard.tsx @@ -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={} /> {err && ( diff --git a/src/components/SyncNowButton.tsx b/src/components/SyncNowButton.tsx new file mode 100644 index 0000000..acdc06c --- /dev/null +++ b/src/components/SyncNowButton.tsx @@ -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 = { + 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(null); + const [err, setErr] = useState(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 ( +
+ + {msg ? ( + + {msg} + + ) : null} + {err ? ( + + {err} + + ) : null} +
+ ); +} diff --git a/src/lib/clientify-sync.ts b/src/lib/clientify-sync.ts new file mode 100644 index 0000000..f231a73 --- /dev/null +++ b/src/lib/clientify-sync.ts @@ -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> { + 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; +} + +async function cfyPaginate( + token: string, + path: string, + maxPages = 40 +): Promise[]> { + const out: Record[] = []; + 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[]) || []; + out.push(...batch); + if (!j.next || !batch.length) break; + } + return out; +} + +function firstEmail(c: Record): string | null { + const emails = c.emails; + if (!Array.isArray(emails) || !emails.length) return null; + const e = emails[0] as Record | string; + if (typeof e === 'object' && e) { + return String(e.email || e.value || '').trim() || null; + } + return String(e).trim() || null; +} + +function firstPhone(c: Record): string | null { + const phones = c.phones; + if (!Array.isArray(phones) || !phones.length) return null; + const p = phones[0] as Record | 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): '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 { + 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(); + 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 }; +}