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 <cursoragent@cursor.com>
This commit is contained in:
37
scripts/sync_ghl.ts
Normal file
37
scripts/sync_ghl.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
@@ -127,11 +127,14 @@ async function listOpportunities(
|
|||||||
locationId: string
|
locationId: string
|
||||||
): Promise<Record<string, unknown>[]> {
|
): Promise<Record<string, unknown>[]> {
|
||||||
const out: Record<string, unknown>[] = [];
|
const out: Record<string, unknown>[] = [];
|
||||||
for (let skip = 0; skip < 20000; skip += 100) {
|
for (let page = 1; page <= 200; page++) {
|
||||||
const j = await ghlGet(
|
const q = new URLSearchParams({
|
||||||
token,
|
location_id: locationId,
|
||||||
`/opportunities/search?location_id=${encodeURIComponent(locationId)}&limit=100&skip=${skip}`
|
limit: '100',
|
||||||
);
|
page: String(page),
|
||||||
|
status: 'all',
|
||||||
|
});
|
||||||
|
const j = await ghlGet(token, `/opportunities/search?${q}`);
|
||||||
const batch = (j.opportunities as Record<string, unknown>[]) || [];
|
const batch = (j.opportunities as Record<string, unknown>[]) || [];
|
||||||
out.push(...batch);
|
out.push(...batch);
|
||||||
if (batch.length < 100) break;
|
if (batch.length < 100) break;
|
||||||
@@ -191,13 +194,16 @@ export async function syncGhlTenant(opts: {
|
|||||||
const tid = opts.tenantId;
|
const tid = opts.tenantId;
|
||||||
try {
|
try {
|
||||||
const contacts = await listContacts(token, locationId);
|
const contacts = await listContacts(token, locationId);
|
||||||
|
const uniqueContacts = [
|
||||||
|
...new Map(contacts.map((c) => [String(c.id), c])).values(),
|
||||||
|
];
|
||||||
await fetch(
|
await fetch(
|
||||||
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tid)}&crm=eq.gohighlevel`,
|
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tid)}&crm=eq.gohighlevel`,
|
||||||
{ method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } }
|
{ method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } }
|
||||||
);
|
);
|
||||||
|
|
||||||
const contactToLead = new Map<string, string>();
|
const contactToLead = new Map<string, string>();
|
||||||
const leadRows = contacts.map((c) => {
|
const leadRows = uniqueContacts.map((c) => {
|
||||||
const cid = String(c.id);
|
const cid = String(c.id);
|
||||||
const lid = uuid5(`ghl-lead:${tid}:${cid}`);
|
const lid = uuid5(`ghl-lead:${tid}:${cid}`);
|
||||||
contactToLead.set(cid, lid);
|
contactToLead.set(cid, lid);
|
||||||
@@ -240,7 +246,7 @@ export async function syncGhlTenant(opts: {
|
|||||||
headers: {
|
headers: {
|
||||||
...headers,
|
...headers,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Prefer: 'return=minimal',
|
Prefer: 'return=minimal,resolution=merge-duplicates',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(chunk),
|
body: JSON.stringify(chunk),
|
||||||
});
|
});
|
||||||
@@ -253,12 +259,15 @@ export async function syncGhlTenant(opts: {
|
|||||||
listOpportunities(token, locationId),
|
listOpportunities(token, locationId),
|
||||||
listPipelines(token, locationId),
|
listPipelines(token, locationId),
|
||||||
]);
|
]);
|
||||||
|
const uniqueOpps = [
|
||||||
|
...new Map(opps.map((d) => [String(d.id), d])).values(),
|
||||||
|
];
|
||||||
await fetch(
|
await fetch(
|
||||||
`${base}/rest/v1/deals?tenant_id=eq.${encodeURIComponent(tid)}&crm_deal_id=like.ghl:*`,
|
`${base}/rest/v1/deals?tenant_id=eq.${encodeURIComponent(tid)}&crm_deal_id=like.ghl:*`,
|
||||||
{ method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } }
|
{ method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } }
|
||||||
);
|
);
|
||||||
|
|
||||||
const dealRows = opps.map((d) => {
|
const dealRows = uniqueOpps.map((d) => {
|
||||||
const did = String(d.id);
|
const did = String(d.id);
|
||||||
const stage = mapOppStatus(d.status);
|
const stage = mapOppStatus(d.status);
|
||||||
const cid = d.contactId ? String(d.contactId) : null;
|
const cid = d.contactId ? String(d.contactId) : null;
|
||||||
@@ -293,7 +302,7 @@ export async function syncGhlTenant(opts: {
|
|||||||
headers: {
|
headers: {
|
||||||
...headers,
|
...headers,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Prefer: 'return=minimal',
|
Prefer: 'return=minimal,resolution=merge-duplicates',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(chunk),
|
body: JSON.stringify(chunk),
|
||||||
});
|
});
|
||||||
@@ -310,7 +319,7 @@ export async function syncGhlTenant(opts: {
|
|||||||
headers: {
|
headers: {
|
||||||
...headers,
|
...headers,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Prefer: 'return=minimal',
|
Prefer: 'return=minimal,resolution=merge-duplicates',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
status: 'connected',
|
status: 'connected',
|
||||||
@@ -336,7 +345,7 @@ export async function syncGhlTenant(opts: {
|
|||||||
headers: {
|
headers: {
|
||||||
...headers,
|
...headers,
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Prefer: 'return=minimal',
|
Prefer: 'return=minimal,resolution=merge-duplicates',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
|
|||||||
Reference in New Issue
Block a user