El CRM solo vivía de webhooks; ahora hay pull Clientify, cron daily-sync incluye CRM, y cada módulo de Agencia tiene sync manual. Co-authored-by: Cursor <cursoragent@cursor.com>
360 lines
11 KiB
TypeScript
360 lines
11 KiB
TypeScript
/** Pull Clientify contacts/deals → warehouse (leads/deals). */
|
|
|
|
import { createHash } from 'crypto';
|
|
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
|
|
|
const CFY = 'https://api.clientify.com/v1';
|
|
|
|
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 clientifyKeyForSlug(slug: string): string | null {
|
|
const upper = slug.toUpperCase().replace(/-/g, '_');
|
|
return (
|
|
process.env[`CLIENTIFY_API_KEY_${upper}`]?.trim() ||
|
|
process.env.CLIENTIFY_API_KEY?.trim() ||
|
|
null
|
|
);
|
|
}
|
|
|
|
async function cfyGet(token: string, path: string): Promise<Record<string, unknown>> {
|
|
const r = await fetch(`${CFY}${path}`, {
|
|
headers: {
|
|
Authorization: `Token ${token}`,
|
|
Accept: 'application/json',
|
|
},
|
|
cache: 'no-store',
|
|
});
|
|
if (!r.ok) {
|
|
throw new Error(`Clientify ${path}: ${(await r.text()).slice(0, 180)}`);
|
|
}
|
|
return (await r.json()) as Record<string, unknown>;
|
|
}
|
|
|
|
async function cfyPaginate(
|
|
token: string,
|
|
path: string,
|
|
maxPages = 40
|
|
): Promise<Record<string, unknown>[]> {
|
|
const out: Record<string, unknown>[] = [];
|
|
const base = path.split('?')[0] || path;
|
|
const params = new URLSearchParams(path.includes('?') ? path.split('?')[1] : '');
|
|
params.set('page_size', '100');
|
|
for (let page = 1; page <= maxPages; page++) {
|
|
params.set('page', String(page));
|
|
const j = await cfyGet(token, `${base}?${params}`);
|
|
const batch = (j.results as Record<string, unknown>[]) || [];
|
|
out.push(...batch);
|
|
if (!j.next || !batch.length) break;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function firstEmail(c: Record<string, unknown>): string | null {
|
|
const emails = c.emails;
|
|
if (!Array.isArray(emails) || !emails.length) return null;
|
|
const e = emails[0] as Record<string, unknown> | string;
|
|
if (typeof e === 'object' && e) {
|
|
return String(e.email || e.value || '').trim() || null;
|
|
}
|
|
return String(e).trim() || null;
|
|
}
|
|
|
|
function firstPhone(c: Record<string, unknown>): string | null {
|
|
const phones = c.phones;
|
|
if (!Array.isArray(phones) || !phones.length) return null;
|
|
const p = phones[0] as Record<string, unknown> | string;
|
|
if (typeof p === 'object' && p) {
|
|
return String(p.phone || p.value || '').trim() || null;
|
|
}
|
|
return String(p).trim() || null;
|
|
}
|
|
|
|
function parseDate(s: unknown): string | null {
|
|
if (!s || typeof s !== 'string') return null;
|
|
try {
|
|
if (s.includes('T')) {
|
|
return new Date(s).toISOString();
|
|
}
|
|
return new Date(`${s}T12:00:00Z`).toISOString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function mapStage(deal: Record<string, unknown>): 'won' | 'lost' | 'open' {
|
|
const desc = String(deal.status_desc || '')
|
|
.trim()
|
|
.toLowerCase();
|
|
if (desc === 'won') return 'won';
|
|
if (desc === 'lost') return 'lost';
|
|
if (deal.status === 3) return 'won';
|
|
if (deal.status === 4) return 'lost';
|
|
return 'open';
|
|
}
|
|
|
|
function contactIdFromUrl(url: unknown): string | null {
|
|
if (typeof url !== 'string' || !url) return null;
|
|
const parts = url.replace(/\/$/, '').split('/');
|
|
return parts[parts.length - 1] || null;
|
|
}
|
|
|
|
export type ClientifySyncResult = {
|
|
tenantId: string;
|
|
tenantSlug: string;
|
|
leads: number;
|
|
deals: number;
|
|
error?: string;
|
|
};
|
|
|
|
export async function syncClientifyTenant(opts: {
|
|
tenantId: string;
|
|
slug: string;
|
|
apiKey?: string | null;
|
|
}): Promise<ClientifySyncResult> {
|
|
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.apiKey?.trim() || clientifyKeyForSlug(opts.slug);
|
|
if (!token) {
|
|
return {
|
|
tenantId: opts.tenantId,
|
|
tenantSlug: opts.slug,
|
|
leads: 0,
|
|
deals: 0,
|
|
error: `Falta CLIENTIFY_API_KEY (o CLIENTIFY_API_KEY_${opts.slug.toUpperCase().replace(/-/g, '_')})`,
|
|
};
|
|
}
|
|
|
|
const tid = opts.tenantId;
|
|
try {
|
|
const contacts = await cfyPaginate(token, '/contacts/');
|
|
await fetch(
|
|
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tid)}&crm=eq.clientify`,
|
|
{ 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(`clientify-lead:${tid}:${cid}`);
|
|
contactToLead.set(cid, lid);
|
|
const source = String(
|
|
c.contact_source || c.channel || c.medium || 'clientify'
|
|
)
|
|
.trim()
|
|
.toLowerCase()
|
|
.slice(0, 80) || 'clientify';
|
|
const status =
|
|
String(c.status || 'new')
|
|
.trim()
|
|
.toLowerCase()
|
|
.slice(0, 40) || 'new';
|
|
const name = [c.first_name, c.last_name]
|
|
.map((x) => String(x || '').trim())
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
return {
|
|
id: lid,
|
|
tenant_id: tid,
|
|
first_name: name || null,
|
|
email: firstEmail(c),
|
|
phone: firstPhone(c),
|
|
source,
|
|
medium: c.medium ? String(c.medium) : null,
|
|
crm: 'clientify',
|
|
crm_contact_id: cid,
|
|
status,
|
|
created_at: parseDate(c.created) || 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 deals = await cfyPaginate(token, '/deals/');
|
|
await fetch(
|
|
`${base}/rest/v1/deals?tenant_id=eq.${encodeURIComponent(tid)}&crm_deal_id=like.clientify:*`,
|
|
{ method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } }
|
|
);
|
|
|
|
const dealRows = deals.map((d) => {
|
|
const did = String(d.id);
|
|
const stage = mapStage(d);
|
|
const cid = contactIdFromUrl(d.contact);
|
|
const leadId = cid ? contactToLead.get(cid) || null : null;
|
|
const amount = Number(d.amount || d.amount_user || 0) || 0;
|
|
const closed = parseDate(d.actual_closed_date);
|
|
const pipeUrl = String(d.pipeline || '');
|
|
const pipeId = pipeUrl.includes('/pipelines/')
|
|
? pipeUrl.replace(/\/$/, '').split('/').pop() || null
|
|
: null;
|
|
let rawSrc = String(d.deal_source || d.contact_source || '')
|
|
.trim()
|
|
.slice(0, 80);
|
|
const tags = Array.isArray(d.tags) ? d.tags.map(String).join(' ').toLowerCase() : '';
|
|
const name = String(d.name || '').slice(0, 200);
|
|
if (!rawSrc) {
|
|
if (/facebook|instagram|meta|\bfb\b/.test(tags)) rawSrc = 'meta';
|
|
else if (/google/.test(tags)) rawSrc = 'google';
|
|
else if (/woocommerce/.test(tags) || /^order /i.test(name))
|
|
rawSrc = 'ecommerce';
|
|
}
|
|
return {
|
|
id: uuid5(`clientify-deal:${tid}:${did}`),
|
|
tenant_id: tid,
|
|
lead_id: leadId,
|
|
crm_deal_id: `clientify:${did}`,
|
|
stage,
|
|
amount,
|
|
won_at: stage === 'won' ? closed : null,
|
|
lost_at: stage === 'lost' ? closed : null,
|
|
pipeline_id: pipeId,
|
|
pipeline_name: d.pipeline_desc ? String(d.pipeline_desc) : null,
|
|
source: rawSrc ? rawSrc.toLowerCase() : null,
|
|
name: name || 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.clientify`,
|
|
{
|
|
method: 'PATCH',
|
|
headers: {
|
|
...headers,
|
|
'Content-Type': 'application/json',
|
|
Prefer: 'return=minimal',
|
|
},
|
|
body: JSON.stringify({
|
|
status: 'connected',
|
|
last_sync_at: now,
|
|
error_message: null,
|
|
}),
|
|
}
|
|
);
|
|
|
|
return {
|
|
tenantId: tid,
|
|
tenantSlug: opts.slug,
|
|
leads: leadRows.length,
|
|
deals: dealRows.length,
|
|
};
|
|
} catch (e) {
|
|
return {
|
|
tenantId: tid,
|
|
tenantSlug: opts.slug,
|
|
leads: 0,
|
|
deals: 0,
|
|
error: e instanceof Error ? e.message : 'clientify sync failed',
|
|
};
|
|
}
|
|
}
|
|
|
|
/** Sync all tenants with clientify integration (+ API key available). */
|
|
export async function syncAllClientifyTenants(opts?: {
|
|
tenantId?: string;
|
|
}): Promise<{ results: ClientifySyncResult[] }> {
|
|
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.clientify&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?: { api_key?: string } | null;
|
|
external_account_id?: string | null;
|
|
}[];
|
|
const byTenant = new Map(ints.map((i) => [i.tenant_id, i]));
|
|
|
|
const results: ClientifySyncResult[] = [];
|
|
for (const t of tenants) {
|
|
const integ = byTenant.get(t.id);
|
|
// Sync if marked connected/pending OR we have a key for this slug
|
|
const key =
|
|
(integ?.meta && typeof integ.meta.api_key === 'string'
|
|
? integ.meta.api_key
|
|
: null) || clientifyKeyForSlug(t.slug);
|
|
if (!key && integ?.status !== 'connected' && integ?.status !== 'pending') {
|
|
continue;
|
|
}
|
|
if (!key) {
|
|
results.push({
|
|
tenantId: t.id,
|
|
tenantSlug: t.slug,
|
|
leads: 0,
|
|
deals: 0,
|
|
error: `Sin API key Clientify para ${t.slug}`,
|
|
});
|
|
continue;
|
|
}
|
|
results.push(
|
|
await syncClientifyTenant({
|
|
tenantId: t.id,
|
|
slug: t.slug,
|
|
apiKey: key,
|
|
})
|
|
);
|
|
}
|
|
return { results };
|
|
}
|