/** 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 }; }