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:
@@ -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<string, unknown> = {};
|
||||
if (cur.ok) {
|
||||
const rows = (await cur.json()) as { meta?: Record<string, unknown> }[];
|
||||
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 }
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -25,6 +25,7 @@ const PLACEHOLDERS: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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
422
src/lib/gohighlevel-sync.ts
Normal 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 };
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user