Add GoHighLevel CRM sync for tenant locations.

Warehouse leads/deals from GHL contacts and opportunities so Métricas and CRM can use the same scorecard as Clientify.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-06 11:57:02 +02:00
parent fb8adab162
commit 487e2c7a2b
12 changed files with 603 additions and 17 deletions

View File

@@ -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 },

422
src/lib/gohighlevel-sync.ts Normal file
View File

@@ -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<Record<string, unknown>> {
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<string, unknown>;
}
async function ghlPost(
token: string,
path: string,
body: Record<string, unknown>
): Promise<Record<string, unknown>> {
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<string, unknown>;
}
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<Record<string, unknown>[]> {
const out: Record<string, unknown>[] = [];
for (let page = 1; page <= 50; page++) {
const j = await ghlPost(token, '/contacts/search', {
locationId,
page,
pageLimit: 100,
});
const batch = (j.contacts as Record<string, unknown>[]) || [];
out.push(...batch);
if (batch.length < 100) break;
}
return out;
}
async function listPipelines(
token: string,
locationId: string
): Promise<Map<string, string>> {
const map = new Map<string, string>();
try {
const j = await ghlGet(
token,
`/opportunities/pipelines?locationId=${encodeURIComponent(locationId)}`
);
const pipes = (j.pipelines as Record<string, unknown>[]) || [];
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<Record<string, unknown>[]> {
const out: Record<string, unknown>[] = [];
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<string, unknown>[]) || [];
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<GhlSyncResult> {
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<string, string>();
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 };
}

View File

@@ -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',