feat: endpoint webhook Clientify para sync en tiempo real
URL pública por tenant (?tenant=slug) para contactos y oportunidades; middleware permite POST sin sesión. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
281
src/app/api/webhooks/clientify/route.ts
Normal file
281
src/app/api/webhooks/clientify/route.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
import { serviceHeaders, supabaseUrl } from '@/lib/agency';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Clientify outbound webhooks.
|
||||
*
|
||||
* Paste in Clientify → Integraciones → Webhooks (per subcuenta):
|
||||
* https://app.4ventures.es/api/webhooks/clientify?tenant=agua-miia
|
||||
*
|
||||
* Optional secret (same for all entities):
|
||||
* &secret=YOUR_SECRET (must match CLIENTIFY_WEBHOOK_SECRET)
|
||||
*
|
||||
* Entity-specific (optional, inferred if omitted):
|
||||
* &entity=contact|company|deal|activity|budget|product
|
||||
*/
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
|
||||
function n(v: unknown): number {
|
||||
const x = Number(v);
|
||||
return Number.isFinite(x) ? x : 0;
|
||||
}
|
||||
|
||||
function str(v: unknown, max = 200): string | null {
|
||||
if (v == null) return null;
|
||||
if (typeof v === 'object' && v !== null && 'name' in (v as object)) {
|
||||
return String((v as { name?: unknown }).name || '').slice(0, max) || null;
|
||||
}
|
||||
const s = String(v).trim();
|
||||
return s ? s.slice(0, max) : null;
|
||||
}
|
||||
|
||||
function uuid5(name: string): string {
|
||||
// RFC 4122 UUID v5 with URL namespace (same as Python uuid.uuid5(NAMESPACE_URL, …))
|
||||
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 firstEmail(c: Json): string | null {
|
||||
const emails = c.emails;
|
||||
if (Array.isArray(emails) && emails.length) {
|
||||
const e = emails[0] as Json;
|
||||
return str(typeof e === 'object' ? e.email || e.value : e, 200);
|
||||
}
|
||||
return str(c.email, 200);
|
||||
}
|
||||
|
||||
function firstPhone(c: Json): string | null {
|
||||
const phones = c.phones;
|
||||
if (Array.isArray(phones) && phones.length) {
|
||||
const p = phones[0] as Json;
|
||||
return str(typeof p === 'object' ? p.phone || p.value : p, 60);
|
||||
}
|
||||
return str(c.phone, 60);
|
||||
}
|
||||
|
||||
function contactIdFromUrl(url: unknown): string | null {
|
||||
const s = str(url, 300);
|
||||
if (!s) return null;
|
||||
const parts = s.replace(/\/$/, '').split('/');
|
||||
return parts[parts.length - 1] || null;
|
||||
}
|
||||
|
||||
function mapDealStage(d: Json): string {
|
||||
const desc = (str(d.status_desc, 40) || '').toLowerCase();
|
||||
if (desc === 'won') return 'won';
|
||||
if (desc === 'lost') return 'lost';
|
||||
if (desc === 'open') return 'open';
|
||||
const st = n(d.status);
|
||||
if (st === 3) return 'won';
|
||||
if (st === 4) return 'lost';
|
||||
return 'open';
|
||||
}
|
||||
|
||||
function unwrapPayload(body: unknown): Json {
|
||||
if (!body || typeof body !== 'object') return {};
|
||||
const b = body as Json;
|
||||
// Common wrappers
|
||||
for (const k of ['contact', 'deal', 'company', 'data', 'object', 'payload']) {
|
||||
const inner = b[k];
|
||||
if (inner && typeof inner === 'object' && !Array.isArray(inner)) {
|
||||
return inner as Json;
|
||||
}
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
function guessEntity(q: string | null, payload: Json): string {
|
||||
if (q) return q.toLowerCase();
|
||||
if (payload.pipeline != null || payload.pipeline_stage != null || payload.amount != null)
|
||||
return 'deal';
|
||||
if (payload.first_name != null || payload.emails != null || payload.phones != null)
|
||||
return 'contact';
|
||||
if (payload.company_sector != null || (payload.name != null && payload.domain != null))
|
||||
return 'company';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
async function resolveTenantId(slugOrId: string | null): Promise<string | null> {
|
||||
if (!slugOrId) return null;
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) return null;
|
||||
const base = supabaseUrl();
|
||||
// UUID?
|
||||
if (/^[0-9a-f-]{36}$/i.test(slugOrId)) return slugOrId;
|
||||
const r = await fetch(
|
||||
`${base}/rest/v1/tenants?slug=eq.${encodeURIComponent(slugOrId)}&select=id&limit=1`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (!r.ok) return null;
|
||||
const rows = (await r.json()) as { id: string }[];
|
||||
return rows[0]?.id || null;
|
||||
}
|
||||
|
||||
async function upsertLead(tenantId: string, c: Json) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) throw new Error('no service role');
|
||||
const cid = str(c.id, 40);
|
||||
if (!cid) throw new Error('contact missing id');
|
||||
const id = uuid5(`clientify-lead:${tenantId}:${cid}`);
|
||||
const source =
|
||||
str(c.contact_source, 80) ||
|
||||
str(c.channel, 80) ||
|
||||
str(c.medium, 80) ||
|
||||
'clientify';
|
||||
const row = {
|
||||
id,
|
||||
tenant_id: tenantId,
|
||||
first_name:
|
||||
[str(c.first_name, 80), str(c.last_name, 80)].filter(Boolean).join(' ') ||
|
||||
null,
|
||||
email: firstEmail(c),
|
||||
phone: firstPhone(c),
|
||||
source: source.toLowerCase(),
|
||||
medium: str(c.medium, 80),
|
||||
crm: 'clientify',
|
||||
crm_contact_id: cid,
|
||||
status: (str(c.status, 40) || 'new').toLowerCase(),
|
||||
};
|
||||
// Prefer upsert on crm_contact_id if unique; else delete+insert
|
||||
const base = supabaseUrl();
|
||||
await fetch(
|
||||
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}`,
|
||||
{ method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } }
|
||||
);
|
||||
const ins = await fetch(`${base}/rest/v1/leads`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, Prefer: 'return=minimal', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(row),
|
||||
});
|
||||
if (!ins.ok) {
|
||||
throw new Error(`lead insert ${ins.status}: ${(await ins.text()).slice(0, 200)}`);
|
||||
}
|
||||
return { id, crm_contact_id: cid };
|
||||
}
|
||||
|
||||
async function upsertDeal(tenantId: string, d: Json) {
|
||||
const headers = serviceHeaders();
|
||||
if (!headers) throw new Error('no service role');
|
||||
const did = str(d.id, 40);
|
||||
if (!did) throw new Error('deal missing id');
|
||||
const id = uuid5(`clientify-deal:${tenantId}:${did}`);
|
||||
const stage = mapDealStage(d);
|
||||
const cid = contactIdFromUrl(d.contact);
|
||||
let leadId: string | null = null;
|
||||
if (cid) {
|
||||
const base = supabaseUrl();
|
||||
const lr = await fetch(
|
||||
`${base}/rest/v1/leads?tenant_id=eq.${encodeURIComponent(tenantId)}&crm=eq.clientify&crm_contact_id=eq.${encodeURIComponent(cid)}&select=id&limit=1`,
|
||||
{ headers, cache: 'no-store' }
|
||||
);
|
||||
if (lr.ok) {
|
||||
const rows = (await lr.json()) as { id: string }[];
|
||||
leadId = rows[0]?.id || null;
|
||||
}
|
||||
if (!leadId) {
|
||||
leadId = uuid5(`clientify-lead:${tenantId}:${cid}`);
|
||||
}
|
||||
}
|
||||
const closed = str(d.actual_closed_date, 40);
|
||||
const row = {
|
||||
id,
|
||||
tenant_id: tenantId,
|
||||
lead_id: leadId,
|
||||
crm_deal_id: `clientify:${did}`,
|
||||
stage,
|
||||
amount: n(d.amount ?? d.amount_user),
|
||||
won_at: stage === 'won' && closed ? closed : null,
|
||||
lost_at: stage === 'lost' && closed ? closed : null,
|
||||
};
|
||||
const base = supabaseUrl();
|
||||
await fetch(
|
||||
`${base}/rest/v1/deals?tenant_id=eq.${encodeURIComponent(tenantId)}&crm_deal_id=eq.${encodeURIComponent(`clientify:${did}`)}`,
|
||||
{ method: 'DELETE', headers: { ...headers, Prefer: 'return=minimal' } }
|
||||
);
|
||||
const ins = await fetch(`${base}/rest/v1/deals`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, Prefer: 'return=minimal', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(row),
|
||||
});
|
||||
if (!ins.ok) {
|
||||
throw new Error(`deal insert ${ins.status}: ${(await ins.text()).slice(0, 200)}`);
|
||||
}
|
||||
return { id, crm_deal_id: row.crm_deal_id, stage };
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const tenant = req.nextUrl.searchParams.get('tenant');
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
service: 'clientify-webhook',
|
||||
tenant: tenant || null,
|
||||
hint: 'POST events from Clientify. Use ?tenant=<slug>',
|
||||
example: 'https://app.4ventures.es/api/webhooks/clientify?tenant=agua-miia',
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const secretEnv = process.env.CLIENTIFY_WEBHOOK_SECRET?.trim();
|
||||
const secretQ = req.nextUrl.searchParams.get('secret');
|
||||
const secretH = req.headers.get('x-clientify-secret');
|
||||
if (secretEnv && secretQ !== secretEnv && secretH !== secretEnv) {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tenantParam =
|
||||
req.nextUrl.searchParams.get('tenant') ||
|
||||
req.nextUrl.searchParams.get('tenant_id');
|
||||
const tenantId = await resolveTenantId(tenantParam);
|
||||
if (!tenantId) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'missing_or_unknown_tenant',
|
||||
hint: 'Add ?tenant=agua-miia (slug) to the webhook URL',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'invalid_json' }, { status: 400 });
|
||||
}
|
||||
|
||||
const payload = unwrapPayload(body);
|
||||
const entity = guessEntity(
|
||||
req.nextUrl.searchParams.get('entity'),
|
||||
payload
|
||||
);
|
||||
|
||||
try {
|
||||
if (entity === 'contact') {
|
||||
const r = await upsertLead(tenantId, payload);
|
||||
return NextResponse.json({ ok: true, entity, ...r });
|
||||
}
|
||||
if (entity === 'deal') {
|
||||
const r = await upsertDeal(tenantId, payload);
|
||||
return NextResponse.json({ ok: true, entity, ...r });
|
||||
}
|
||||
// company/activity/budget/product: acknowledge for now
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
entity,
|
||||
ignored: true,
|
||||
id: payload.id ?? randomUUID(),
|
||||
hint: 'contact/deal only persisted in v1',
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'error';
|
||||
return NextResponse.json({ error: msg }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -52,10 +52,11 @@ export async function middleware(req: NextRequest) {
|
||||
const url = req.nextUrl.clone();
|
||||
const path = url.pathname;
|
||||
|
||||
// Public: login, attribution script, health
|
||||
// Public: login, attribution script, health, inbound webhooks (CRM)
|
||||
const isPublic =
|
||||
path === '/login' ||
|
||||
path === '/api/health' ||
|
||||
path.startsWith('/api/webhooks/') ||
|
||||
path === '/_4v_attr.js' ||
|
||||
path.startsWith('/_next') ||
|
||||
path === '/favicon.ico' ||
|
||||
|
||||
Reference in New Issue
Block a user