From 0ad89e88e5c43184f3cd8f7c40cb5fdffcab73f8 Mon Sep 17 00:00:00 2001 From: 4Ventures <4ventures@MacBook-Air-de-4Ventures.local> Date: Tue, 6 Oct 2026 12:06:27 +0200 Subject: [PATCH] Fix GoHighLevel opportunity pagination and contact dedupe. GHL search rejects skip; page+location_id is required so nightly CRM sync can complete. Co-authored-by: Cursor --- scripts/sync_ghl.ts | 37 +++++++++++++++++++++++++++++++++++++ src/lib/gohighlevel-sync.ts | 31 ++++++++++++++++++++----------- 2 files changed, 57 insertions(+), 11 deletions(-) create mode 100644 scripts/sync_ghl.ts diff --git a/scripts/sync_ghl.ts b/scripts/sync_ghl.ts new file mode 100644 index 0000000..15d4767 --- /dev/null +++ b/scripts/sync_ghl.ts @@ -0,0 +1,37 @@ +/** + * Sync GoHighLevel contacts/opportunities → warehouse. + * npx tsx scripts/sync_ghl.ts + * TENANT_SLUG=delgado-abogados npx tsx scripts/sync_ghl.ts + */ +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(process.cwd(), '.env.local') }); + +import { syncAllGhlTenants } from '../src/lib/gohighlevel-sync'; +import { serviceHeaders, supabaseUrl } from '../src/lib/agency'; + +async function main() { + const slug = process.env.TENANT_SLUG?.trim(); + let tenantId: string | undefined; + if (slug) { + const headers = serviceHeaders(); + const base = supabaseUrl(); + if (!headers || !base) throw new Error('no service role'); + const r = await fetch( + `${base}/rest/v1/tenants?slug=eq.${encodeURIComponent(slug)}&select=id,slug`, + { headers, cache: 'no-store' } + ); + const rows = (await r.json()) as { id: string; slug: string }[]; + if (!rows[0]) throw new Error(`tenant ${slug} not found`); + tenantId = rows[0].id; + } + const out = await syncAllGhlTenants({ tenantId }); + console.log(JSON.stringify(out, null, 2)); + if (out.results.some((x) => x.error)) process.exit(2); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/lib/gohighlevel-sync.ts b/src/lib/gohighlevel-sync.ts index fbf02b6..3bd4301 100644 --- a/src/lib/gohighlevel-sync.ts +++ b/src/lib/gohighlevel-sync.ts @@ -127,11 +127,14 @@ async function listOpportunities( 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}` - ); + for (let page = 1; page <= 200; page++) { + const q = new URLSearchParams({ + location_id: locationId, + limit: '100', + page: String(page), + status: 'all', + }); + const j = await ghlGet(token, `/opportunities/search?${q}`); const batch = (j.opportunities as Record[]) || []; out.push(...batch); if (batch.length < 100) break; @@ -191,13 +194,16 @@ export async function syncGhlTenant(opts: { const tid = opts.tenantId; try { const contacts = await listContacts(token, locationId); + const uniqueContacts = [ + ...new Map(contacts.map((c) => [String(c.id), c])).values(), + ]; 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 leadRows = uniqueContacts.map((c) => { const cid = String(c.id); const lid = uuid5(`ghl-lead:${tid}:${cid}`); contactToLead.set(cid, lid); @@ -240,7 +246,7 @@ export async function syncGhlTenant(opts: { headers: { ...headers, 'Content-Type': 'application/json', - Prefer: 'return=minimal', + Prefer: 'return=minimal,resolution=merge-duplicates', }, body: JSON.stringify(chunk), }); @@ -253,12 +259,15 @@ export async function syncGhlTenant(opts: { listOpportunities(token, locationId), listPipelines(token, locationId), ]); + const uniqueOpps = [ + ...new Map(opps.map((d) => [String(d.id), d])).values(), + ]; 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 dealRows = uniqueOpps.map((d) => { const did = String(d.id); const stage = mapOppStatus(d.status); const cid = d.contactId ? String(d.contactId) : null; @@ -293,7 +302,7 @@ export async function syncGhlTenant(opts: { headers: { ...headers, 'Content-Type': 'application/json', - Prefer: 'return=minimal', + Prefer: 'return=minimal,resolution=merge-duplicates', }, body: JSON.stringify(chunk), }); @@ -310,7 +319,7 @@ export async function syncGhlTenant(opts: { headers: { ...headers, 'Content-Type': 'application/json', - Prefer: 'return=minimal', + Prefer: 'return=minimal,resolution=merge-duplicates', }, body: JSON.stringify({ status: 'connected', @@ -336,7 +345,7 @@ export async function syncGhlTenant(opts: { headers: { ...headers, 'Content-Type': 'application/json', - Prefer: 'return=minimal', + Prefer: 'return=minimal,resolution=merge-duplicates', }, body: JSON.stringify({ status: 'error',