diff --git a/src/app/api/webhooks/clientify/route.ts b/src/app/api/webhooks/clientify/route.ts new file mode 100644 index 0000000..9add863 --- /dev/null +++ b/src/app/api/webhooks/clientify/route.ts @@ -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; + +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 { + 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=', + 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 }); + } +} diff --git a/src/middleware.ts b/src/middleware.ts index df02bc1..18a3124 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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' ||