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:
4Ventures
2026-09-23 15:40:41 +02:00
parent 76c26c2e69
commit f436276c3e
2 changed files with 283 additions and 1 deletions

View 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 });
}
}

View File

@@ -52,10 +52,11 @@ export async function middleware(req: NextRequest) {
const url = req.nextUrl.clone(); const url = req.nextUrl.clone();
const path = url.pathname; const path = url.pathname;
// Public: login, attribution script, health // Public: login, attribution script, health, inbound webhooks (CRM)
const isPublic = const isPublic =
path === '/login' || path === '/login' ||
path === '/api/health' || path === '/api/health' ||
path.startsWith('/api/webhooks/') ||
path === '/_4v_attr.js' || path === '/_4v_attr.js' ||
path.startsWith('/_next') || path.startsWith('/_next') ||
path === '/favicon.ico' || path === '/favicon.ico' ||