From 487e2c7a2bd549b62ea6f785abd0ab31c1f79b62 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Tue, 6 Oct 2026 11:57:02 +0200 Subject: [PATCH] Add GoHighLevel CRM sync for tenant locations. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Warehouse leads/deals from GHL contacts and opportunities so Métricas and CRM can use the same scorecard as Clientify. Co-authored-by: Cursor --- scripts/dokploy_apply_gohighlevel.py | 92 +++++ sql/gohighlevel.sql | 31 ++ src/app/api/admin/integrations/route.ts | 19 +- src/app/api/admin/sync/route.ts | 11 +- src/app/api/cron/daily-sync/route.ts | 13 +- src/app/dashboard/crm/page.tsx | 6 +- src/app/dashboard/kpis/page.tsx | 6 +- src/components/ClientIntegrationsPanel.tsx | 1 + src/components/WindsorAiSection.tsx | 14 +- src/lib/agency.ts | 1 + src/lib/gohighlevel-sync.ts | 422 +++++++++++++++++++++ src/lib/tenant-modules.ts | 4 +- 12 files changed, 603 insertions(+), 17 deletions(-) create mode 100644 scripts/dokploy_apply_gohighlevel.py create mode 100644 sql/gohighlevel.sql create mode 100644 src/lib/gohighlevel-sync.ts diff --git a/scripts/dokploy_apply_gohighlevel.py b/scripts/dokploy_apply_gohighlevel.py new file mode 100644 index 0000000..44cf0ac --- /dev/null +++ b/scripts/dokploy_apply_gohighlevel.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Apply sql/gohighlevel.sql via Dokploy supabase-db terminal.""" +from __future__ import annotations + +import base64 +import os +import re +import sys +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path("/Users/4ventures/4ventures-client-portal") +BPS = Path("/Users/4ventures/Projects/bps-facturacion") +SQL = ROOT / "sql" / "gohighlevel.sql" +BASE = "https://dokploy.4ventures.es" + + +def env(key: str) -> str: + v = os.environ.get(key, "").strip() + if v: + return v + p = BPS / ".env" + if not p.is_file(): + return "" + for line in p.read_text().splitlines(): + if line.startswith(f"{key}="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + return "" + + +def main() -> int: + email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es" + password = env("DOKPLOY_PASSWORD") + b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode() + chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)] + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1400, "height": 1000}) + page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000) + page.wait_for_timeout(800) + if "/dashboard" not in page.url: + page.locator('input[type="email"], input[name="email"]').first.fill(email) + page.locator('input[type="password"]').first.fill(password) + page.locator('button[type="submit"]').first.click() + page.wait_for_url(re.compile(r"/dashboard"), timeout=90000) + print("login ok", flush=True) + page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000) + page.wait_for_timeout(800) + page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill( + "supabase-db" + ) + page.wait_for_timeout(1000) + page.keyboard.press("Escape") + page.wait_for_timeout(200) + page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click() + page.wait_for_timeout(400) + page.get_by_text("Terminal", exact=True).click() + page.wait_for_timeout(1000) + if page.locator("button", has_text="Bash").count(): + page.locator("button", has_text="Bash").first.click() + page.wait_for_timeout(2800) + if page.locator("textarea.xterm-helper-textarea").count(): + page.locator("textarea.xterm-helper-textarea").first.click() + + def run(cmd: str, wait: int = 800) -> None: + page.keyboard.type(cmd, delay=1) + page.keyboard.press("Enter") + page.wait_for_timeout(wait) + + run("rm -f /tmp/ghl.b64 /tmp/ghl.sql; echo READY", 1200) + for i, ch in enumerate(chunks): + op = ">" if i == 0 else ">>" + run(f"printf '%s' '{ch}' {op} /tmp/ghl.b64", 400) + run( + "base64 -d /tmp/ghl.b64 > /tmp/ghl.sql && " + "psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/ghl.sql; echo EXIT:$?", + 14000, + ) + text = page.evaluate( + "() => (document.querySelector('.xterm-rows')||{}).innerText||''" + ) + browser.close() + ok = "EXIT:0" in text + print("SQL_OK", ok, flush=True) + print(text[-600:], flush=True) + return 0 if ok else 6 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sql/gohighlevel.sql b/sql/gohighlevel.sql new file mode 100644 index 0000000..016fb7b --- /dev/null +++ b/sql/gohighlevel.sql @@ -0,0 +1,31 @@ +-- GoHighLevel CRM slot per tenant +-- Idempotent. PIT stays in integrations.meta (agency-only), never in git. + +alter table public.integrations drop constraint if exists integrations_provider_check; +alter table public.integrations + add constraint integrations_provider_check + check (provider in ( + 'meta_ads', + 'google_ads', + 'ga4', + 'search_console', + 'tag_manager', + 'clientify', + 'gohighlevel', + 'klaviyo', + 'mailchimp', + 'google_drive', + 'wordpress', + 'prestashop', + 'shopify', + 'instagram', + 'facebook', + 'tiktok', + 'linkedin', + 'superhuman_docs' + )); + +insert into public.integrations (tenant_id, provider, status, label) +select t.id, 'gohighlevel', 'disconnected', 'GoHighLevel' +from public.tenants t +on conflict (tenant_id, provider) do nothing; diff --git a/src/app/api/admin/integrations/route.ts b/src/app/api/admin/integrations/route.ts index fd3649c..10ce001 100644 --- a/src/app/api/admin/integrations/route.ts +++ b/src/app/api/admin/integrations/route.ts @@ -118,6 +118,7 @@ export async function PATCH(req: NextRequest) { typeof body.errorMessage === 'string' ? body.errorMessage.trim() : undefined; const enabled = typeof body.enabled === 'boolean' ? body.enabled : undefined; + const pit = typeof body.pit === 'string' ? body.pit.trim() : ''; if (!tenantId || !provider) { return NextResponse.json( @@ -193,7 +194,23 @@ export async function PATCH(req: NextRequest) { patch.error_message = null; } - if (!status && enabled === undefined && externalAccountId === undefined) { + if (provider === 'gohighlevel' && (pit || externalAccountId !== undefined)) { + const cur = await fetch( + `${supabaseUrl()}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&provider=eq.gohighlevel&select=meta`, + { headers, cache: 'no-store' } + ); + let meta: Record = {}; + if (cur.ok) { + const rows = (await cur.json()) as { meta?: Record }[]; + if (rows[0]?.meta && typeof rows[0].meta === 'object') meta = { ...rows[0].meta }; + } + if (pit) meta.pit = pit; + if (externalAccountId) meta.location_id = externalAccountId; + patch.meta = meta; + if (!label) patch.label = patch.label || 'GoHighLevel'; + } + + if (!status && enabled === undefined && externalAccountId === undefined && !pit) { return NextResponse.json( { error: 'status, enabled o externalAccountId requeridos' }, { status: 400 } diff --git a/src/app/api/admin/sync/route.ts b/src/app/api/admin/sync/route.ts index 5661ad6..2106257 100644 --- a/src/app/api/admin/sync/route.ts +++ b/src/app/api/admin/sync/route.ts @@ -4,6 +4,7 @@ import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agenc import { resolveActiveTenant } from '@/lib/tenant-context'; import { syncAdsForTenants } from '@/lib/ads-sync'; import { syncAllClientifyTenants } from '@/lib/clientify-sync'; +import { syncAllGhlTenants } from '@/lib/gohighlevel-sync'; import { syncAllConnectedOrganic } from '@/lib/google-organic'; import { getAgencyOAuthStatus, @@ -67,7 +68,15 @@ export async function POST(req: NextRequest) { try { if (want('crm') || want('metrics')) { - out.crm = await syncAllClientifyTenants({ tenantId }); + const [clientify, gohighlevel] = await Promise.all([ + syncAllClientifyTenants({ tenantId }), + syncAllGhlTenants({ tenantId }), + ]); + out.crm = { + results: [...clientify.results, ...gohighlevel.results], + clientify, + gohighlevel, + }; } } catch (e) { out.crm = { diff --git a/src/app/api/cron/daily-sync/route.ts b/src/app/api/cron/daily-sync/route.ts index 340f2b0..d76e53d 100644 --- a/src/app/api/cron/daily-sync/route.ts +++ b/src/app/api/cron/daily-sync/route.ts @@ -8,6 +8,7 @@ import { superhumanConfigured, } from '@/lib/superhuman-docs'; import { syncAllClientifyTenants } from '@/lib/clientify-sync'; +import { syncAllGhlTenants } from '@/lib/gohighlevel-sync'; export const dynamic = 'force-dynamic'; export const maxDuration = 300; @@ -92,12 +93,16 @@ export async function GET(req: NextRequest) { // 4) Clientify CRM → leads/deals try { - const crm = await syncAllClientifyTenants(); + const [clientify, gohighlevel] = await Promise.all([ + syncAllClientifyTenants(), + syncAllGhlTenants(), + ]); + const results = [...clientify.results, ...gohighlevel.results]; out.crm = { ok: true, - synced: crm.results.filter((r) => !r.error).length, - withErrors: crm.results.filter((r) => r.error).length, - results: crm.results, + synced: results.filter((r) => !r.error).length, + withErrors: results.filter((r) => r.error).length, + results, }; } catch (e) { out.crm = { diff --git a/src/app/dashboard/crm/page.tsx b/src/app/dashboard/crm/page.tsx index 2e72524..c3ac50e 100644 --- a/src/app/dashboard/crm/page.tsx +++ b/src/app/dashboard/crm/page.tsx @@ -33,7 +33,7 @@ async function fetchCrmLeads( let useSelect = select; for (let offset = 0; offset < 80000; offset += pageSize) { const r = await fetch( - `${base}/rest/v1/leads?select=${useSelect}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`, + `${base}/rest/v1/leads?select=${useSelect}${tidFilter}&crm=in.(clientify,gohighlevel)&order=created_at.desc&limit=${pageSize}&offset=${offset}`, { headers, cache: 'no-store' } ); if (!r.ok) { @@ -56,7 +56,7 @@ async function fetchCrmLeads( let q = supabase .from('leads') .select(select) - .eq('crm', 'clientify') + .in('crm', ['clientify', 'gohighlevel']) .order('created_at', { ascending: false }) .range(offset, offset + pageSize - 1); let { data, error } = await q; @@ -64,7 +64,7 @@ async function fetchCrmLeads( const retry = await supabase .from('leads') .select(selectFallback) - .eq('crm', 'clientify') + .in('crm', ['clientify', 'gohighlevel']) .order('created_at', { ascending: false }) .range(offset, offset + pageSize - 1); data = retry.data as typeof data; diff --git a/src/app/dashboard/kpis/page.tsx b/src/app/dashboard/kpis/page.tsx index 9edc769..ae90625 100644 --- a/src/app/dashboard/kpis/page.tsx +++ b/src/app/dashboard/kpis/page.tsx @@ -91,7 +91,7 @@ async function fetchCrmLeads( let useSelect = select; for (let offset = 0; offset < 50000; offset += pageSize) { const r = await fetch( - `${base}/rest/v1/leads?select=${useSelect}${tidFilter}&crm=eq.clientify&order=created_at.desc&limit=${pageSize}&offset=${offset}`, + `${base}/rest/v1/leads?select=${useSelect}${tidFilter}&crm=in.(clientify,gohighlevel)&order=created_at.desc&limit=${pageSize}&offset=${offset}`, { headers, cache: 'no-store' } ); if (!r.ok) { @@ -114,14 +114,14 @@ async function fetchCrmLeads( let { data, error } = await supabase .from('leads') .select(select) - .eq('crm', 'clientify') + .in('crm', ['clientify', 'gohighlevel']) .order('created_at', { ascending: false }) .range(offset, offset + pageSize - 1); if (error && offset === 0) { const retry = await supabase .from('leads') .select(selectFallback) - .eq('crm', 'clientify') + .in('crm', ['clientify', 'gohighlevel']) .order('created_at', { ascending: false }) .range(offset, offset + pageSize - 1); data = retry.data as typeof data; diff --git a/src/components/ClientIntegrationsPanel.tsx b/src/components/ClientIntegrationsPanel.tsx index 0c5626d..44ad80c 100644 --- a/src/components/ClientIntegrationsPanel.tsx +++ b/src/components/ClientIntegrationsPanel.tsx @@ -25,6 +25,7 @@ const PLACEHOLDERS: Record = { search_console: 'sc-domain:ejemplo.com', tag_manager: 'GTM-…', clientify: 'ID / workspace Clientify', + gohighlevel: 'Location ID (URL …/location/XXXX/)', klaviyo: 'Account / list ID', mailchimp: 'Audience ID', google_drive: 'URL de carpeta Drive o folder id', diff --git a/src/components/WindsorAiSection.tsx b/src/components/WindsorAiSection.tsx index 56eb7a1..470345d 100644 --- a/src/components/WindsorAiSection.tsx +++ b/src/components/WindsorAiSection.tsx @@ -97,6 +97,13 @@ export default function WindsorAiSection({ const ga4 = findIntegration('ga4'); const gsc = findIntegration('search_console'); const clientify = findIntegration('clientify'); + const gohighlevel = findIntegration('gohighlevel'); + const crmInteg = + gohighlevel?.status === 'connected' + ? gohighlevel + : clientify?.status === 'connected' + ? clientify + : gohighlevel || clientify; const gscSite = String( gsc?.external_account_id || @@ -404,9 +411,10 @@ export default function WindsorAiSection({ type: 'Tráfico & Clics Orgánicos', }, { - name: 'Clientify CRM', - id: clientify?.label || clientify?.external_account_id || '—', - status: clientify?.status === 'connected' ? 'connected' : 'disconnected', + name: + crmInteg?.provider === 'gohighlevel' ? 'GoHighLevel CRM' : 'Clientify CRM', + id: crmInteg?.label || crmInteg?.external_account_id || '—', + status: crmInteg?.status === 'connected' ? 'connected' : 'disconnected', type: 'Leads & Closed-Loop Won', }, ]; diff --git a/src/lib/agency.ts b/src/lib/agency.ts index 34946f3..04688e5 100644 --- a/src/lib/agency.ts +++ b/src/lib/agency.ts @@ -7,6 +7,7 @@ export const INTEGRATION_PROVIDERS = [ { id: 'search_console', label: 'Search Console', group: 'analytics', live: true }, { id: 'tag_manager', label: 'Tag Manager', group: 'analytics', live: false }, { id: 'clientify', label: 'Clientify', group: 'crm', live: true }, + { id: 'gohighlevel', label: 'GoHighLevel', group: 'crm', live: true }, { id: 'klaviyo', label: 'Klaviyo', group: 'email', live: false }, { id: 'mailchimp', label: 'Mailchimp', group: 'email', live: false }, { id: 'google_drive', label: 'Google Drive', group: 'files', live: true }, diff --git a/src/lib/gohighlevel-sync.ts b/src/lib/gohighlevel-sync.ts new file mode 100644 index 0000000..fbf02b6 --- /dev/null +++ b/src/lib/gohighlevel-sync.ts @@ -0,0 +1,422 @@ +/** Pull GoHighLevel contacts/opportunities → warehouse (leads/deals). */ + +import { createHash } from 'crypto'; +import { serviceHeaders, supabaseUrl } from '@/lib/agency'; + +const GHL = 'https://services.leadconnectorhq.com'; +const VERSION = '2021-07-28'; + +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 envKey(slug: string, kind: 'PIT' | 'LOCATION'): string { + return `GHL_${kind}_${slug.toUpperCase().replace(/-/g, '_')}`; +} + +export function ghlPitForSlug(slug: string): string | null { + return process.env[envKey(slug, 'PIT')]?.trim() || null; +} + +export function ghlLocationForSlug(slug: string): string | null { + return process.env[envKey(slug, 'LOCATION')]?.trim() || null; +} + +function ghlHeaders(token: string): HeadersInit { + return { + Authorization: `Bearer ${token}`, + Version: VERSION, + Accept: 'application/json', + 'Content-Type': 'application/json', + }; +} + +async function ghlGet( + token: string, + path: string +): Promise> { + const r = await fetch(`${GHL}${path}`, { + headers: ghlHeaders(token), + cache: 'no-store', + }); + if (!r.ok) { + throw new Error(`GHL GET ${path}: ${(await r.text()).slice(0, 180)}`); + } + return (await r.json()) as Record; +} + +async function ghlPost( + token: string, + path: string, + body: Record +): Promise> { + const r = await fetch(`${GHL}${path}`, { + method: 'POST', + headers: ghlHeaders(token), + body: JSON.stringify(body), + cache: 'no-store', + }); + if (!r.ok) { + throw new Error(`GHL POST ${path}: ${(await r.text()).slice(0, 180)}`); + } + return (await r.json()) as Record; +} + +function parseDate(s: unknown): string | null { + if (!s || typeof s !== 'string') return null; + const d = new Date(s); + return Number.isNaN(d.getTime()) ? null : d.toISOString(); +} + +function mapOppStatus(status: unknown): 'won' | 'lost' | 'open' { + const s = String(status || '') + .trim() + .toLowerCase(); + if (s === 'won') return 'won'; + if (s === 'lost' || s === 'abandoned') return 'lost'; + return 'open'; +} + +async function listContacts( + token: string, + locationId: string +): Promise[]> { + const out: Record[] = []; + for (let page = 1; page <= 50; page++) { + const j = await ghlPost(token, '/contacts/search', { + locationId, + page, + pageLimit: 100, + }); + const batch = (j.contacts as Record[]) || []; + out.push(...batch); + if (batch.length < 100) break; + } + return out; +} + +async function listPipelines( + token: string, + locationId: string +): Promise> { + const map = new Map(); + try { + const j = await ghlGet( + token, + `/opportunities/pipelines?locationId=${encodeURIComponent(locationId)}` + ); + const pipes = (j.pipelines as Record[]) || []; + for (const p of pipes) { + const id = String(p.id || ''); + const name = String(p.name || ''); + if (id) map.set(id, name); + } + } catch { + /* pipelines.readonly may be missing */ + } + return map; +} + +async function listOpportunities( + token: string, + locationId: string +): Promise[]> { + const out: Record[] = []; + for (let skip = 0; skip < 20000; skip += 100) { + const j = await ghlGet( + token, + `/opportunities/search?location_id=${encodeURIComponent(locationId)}&limit=100&skip=${skip}` + ); + const batch = (j.opportunities as Record[]) || []; + out.push(...batch); + if (batch.length < 100) break; + } + return out; +} + +export type GhlSyncResult = { + tenantId: string; + tenantSlug: string; + leads: number; + deals: number; + error?: string; +}; + +export async function syncGhlTenant(opts: { + tenantId: string; + slug: string; + pit?: string | null; + locationId?: 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.pit?.trim() || ghlPitForSlug(opts.slug); + const locationId = + opts.locationId?.trim() || ghlLocationForSlug(opts.slug); + if (!token) { + return { + tenantId: opts.tenantId, + tenantSlug: opts.slug, + leads: 0, + deals: 0, + error: `Falta PIT GHL (GHL_PIT_${opts.slug.toUpperCase().replace(/-/g, '_')})`, + }; + } + if (!locationId) { + return { + tenantId: opts.tenantId, + tenantSlug: opts.slug, + leads: 0, + deals: 0, + error: + 'Falta Location ID: en GHL copia la URL /location/XXXX/ (Ajustes → Negocio)', + }; + } + + const tid = opts.tenantId; + try { + const contacts = await listContacts(token, locationId); + await fetch( + `${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tid)}&crm=eq.gohighlevel`, + { method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } } + ); + + const contactToLead = new Map(); + const leadRows = contacts.map((c) => { + const cid = String(c.id); + const lid = uuid5(`ghl-lead:${tid}:${cid}`); + contactToLead.set(cid, lid); + const source = String( + c.source || + (c.attributionSource as { sessionSource?: string } | undefined) + ?.sessionSource || + 'gohighlevel' + ) + .trim() + .toLowerCase() + .slice(0, 80) || 'gohighlevel'; + const name = [c.firstName, c.lastName] + .map((x) => String(x || '').trim()) + .filter(Boolean) + .join(' '); + return { + id: lid, + tenant_id: tid, + first_name: name || (c.name ? String(c.name) : null), + email: c.email ? String(c.email) : null, + phone: c.phone ? String(c.phone) : null, + source, + medium: null, + crm: 'gohighlevel', + crm_contact_id: cid, + status: + String(c.type || 'lead') + .trim() + .toLowerCase() + .slice(0, 40) || 'lead', + created_at: parseDate(c.dateAdded) || 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 [opps, pipeNames] = await Promise.all([ + listOpportunities(token, locationId), + listPipelines(token, locationId), + ]); + await fetch( + `${base}/rest/v1/deals?tenant_id=eq.${encodeURIComponent(tid)}&crm_deal_id=like.ghl:*`, + { method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } } + ); + + const dealRows = opps.map((d) => { + const did = String(d.id); + const stage = mapOppStatus(d.status); + const cid = d.contactId ? String(d.contactId) : null; + const leadId = cid ? contactToLead.get(cid) || null : null; + const amount = Number(d.monetaryValue || 0) || 0; + const closed = parseDate(d.updatedAt); + const pipeId = d.pipelineId ? String(d.pipelineId) : null; + const rawSrc = String(d.source || '') + .trim() + .toLowerCase() + .slice(0, 80); + return { + id: uuid5(`ghl-deal:${tid}:${did}`), + tenant_id: tid, + lead_id: leadId, + crm_deal_id: `ghl:${did}`, + stage, + amount, + won_at: stage === 'won' ? closed : null, + lost_at: stage === 'lost' ? closed : null, + pipeline_id: pipeId, + pipeline_name: pipeId ? pipeNames.get(pipeId) || null : null, + source: rawSrc || null, + name: d.name ? String(d.name).slice(0, 200) : 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.gohighlevel`, + { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ + status: 'connected', + last_sync_at: now, + error_message: null, + external_account_id: locationId, + label: 'GoHighLevel', + }), + } + ); + + return { + tenantId: tid, + tenantSlug: opts.slug, + leads: leadRows.length, + deals: dealRows.length, + }; + } catch (e) { + await fetch( + `${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tid)}&provider=eq.gohighlevel`, + { + method: 'PATCH', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ + status: 'error', + error_message: e instanceof Error ? e.message.slice(0, 240) : 'ghl sync failed', + }), + } + ).catch(() => null); + return { + tenantId: tid, + tenantSlug: opts.slug, + leads: 0, + deals: 0, + error: e instanceof Error ? e.message : 'ghl sync failed', + }; + } +} + +export async function syncAllGhlTenants(opts?: { + tenantId?: string; +}): Promise<{ results: GhlSyncResult[] }> { + 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.gohighlevel&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?: { pit?: string; location_id?: string } | null; + external_account_id?: string | null; + }[]; + const byTenant = new Map(ints.map((i) => [i.tenant_id, i])); + + const results: GhlSyncResult[] = []; + for (const t of tenants) { + const integ = byTenant.get(t.id); + const pit = + (integ?.meta && typeof integ.meta.pit === 'string' ? integ.meta.pit : null) || + ghlPitForSlug(t.slug); + const locationId = + integ?.external_account_id || + (integ?.meta && typeof integ.meta.location_id === 'string' + ? integ.meta.location_id + : null) || + ghlLocationForSlug(t.slug); + if (!pit && integ?.status !== 'connected' && integ?.status !== 'pending') { + continue; + } + if (!pit) { + results.push({ + tenantId: t.id, + tenantSlug: t.slug, + leads: 0, + deals: 0, + error: `Sin PIT GHL para ${t.slug}`, + }); + continue; + } + results.push( + await syncGhlTenant({ + tenantId: t.id, + slug: t.slug, + pit, + locationId, + }) + ); + } + return { results }; +} diff --git a/src/lib/tenant-modules.ts b/src/lib/tenant-modules.ts index ce0f97c..67c752d 100644 --- a/src/lib/tenant-modules.ts +++ b/src/lib/tenant-modules.ts @@ -34,8 +34,8 @@ export const DASHBOARD_MODULES: DashboardModuleDef[] = [ id: 'crm', href: '/dashboard/crm', label: 'CRM', - providers: ['clientify'], - hint: 'Clientify / leads', + providers: ['clientify', 'gohighlevel'], + hint: 'Clientify / GoHighLevel / leads', }, { id: 'seguimiento',