/** * CDP Edge — index.ts (ES Module Entry Point) * * Este arquivo é o novo entry point modular do Worker. * Para usá-lo, altere em wrangler.toml: * main = "worker.js" → main = "index.ts" * * O worker.js original permanece intacto como fallback. * Todos os módulos ficam em ./modules/ */ import { ExecutionContext } from '@cloudflare/workers-types'; import { Env, TrackPayload, BehavioralData, HotmartWebhook, KiwifyWebhook, TictoWebhook } from './types'; // ── Utilitários base ────────────────────────────────────────────────────────── import { corsHeaders, sha256, META_TO_GA4, resolveFunnelStage, resolveIntentScore, distanceBucketWeight, computeMetaSignalWeights, metaSignalBucket, isValidEmail, sanitizeString, isValidUrl, isValidValue, isValidCurrency, isValidUTM, } from './modules/utils'; // ── Banco de dados (D1) ─────────────────────────────────────────────────────── import { saveLead, upsertProfile, resolveDeviceGraph, fireAutomation, getProfileByEmail, enrichGeoFromEdge, writeAuditLog, generateEdgeFingerprint, saveEdgeFingerprint, resurrectUTM, upsertLtvProfile, recordLtvFeedback, processWebhookDuplicateCheck, } from './modules/db'; // ── Dispatch — plataformas de ads ───────────────────────────────────────────── import { sendMetaCapi } from './modules/dispatch/meta'; import { consentAllows, isBrowserPurchaseBlocked, deleteSubjectData, purgeExpiredPii, retentionDays, MIN_RETENTION_DAYS } from './modules/privacy'; import { getMetaToken, getMetaTokenStatus, setMetaToken, clearMetaToken, validateMetaToken, refreshMetaTokenIfNeeded } from './modules/meta-api'; import { sendGA4Mp } from './modules/dispatch/ga4'; import { sendTikTokApi } from './modules/dispatch/tiktok'; import { sendPinterestCapi, sendRedditCapi, sendLinkedInCapi, sendSpotifyCapi, } from './modules/dispatch/platforms'; import { sendWhatsApp, processWhatsAppWebhook, verifyHmac, } from './modules/dispatch/whatsapp'; import { hotmartWebhookSchema, kiwifyWebhookSchema, schemaError, tictoWebhookSchema, trackRequestSchema, } from './modules/schemas'; // ── ML — LTV + A/B Testing ──────────────────────────────────────────────────── import { predictLtv, getLtvAbVariation, recordAbAssignment, handleLtvAbTestCreate, handleLtvAbTestList, handleLtvAbTestResults, handleLtvAbTestWinner, } from './modules/ml/ltv'; // ── ML — Segmentação ────────────────────────────────────────────────────────── import { handleSegmentationCluster, handleSegmentationList, handleSegmentationOutliers, handleSegmentationUpdate, } from './modules/ml/segmentation'; // ── ML — Bidding ────────────────────────────────────────────────────────────── import { handleBiddingRecommend, handleBiddingHistory, handleBiddingStatus, } from './modules/ml/bidding'; // ── ML — Fraud Detection ────────────────────────────────────────────────────── import { checkFraudGate, logFraudSignal, handleFraudAlerts, handleFraudBlocklist, handleFraudBlocklistAdd, handleFraudBlocklistRemove, handleFraudStats, } from './modules/ml/fraud'; // ── Quiz Scoring Engine (Fase 6) ────────────────────────────────────────────── import { scoreQuizAnswers, saveQuizSession, } from './modules/ml/quiz'; // ── Nurture Engine (Fase 7) ─────────────────────────────────────────────────── import { scheduleNurture } from './modules/nurture'; // ── Intelligence Agent (Cron) ───────────────────────────────────────────────── import { runIntelligenceAgent, buildGoogleCustomerMatchExport, } from './modules/intelligence'; // ── Haversine distance (km) — sem dependência externa ──────────────────────── function haversineKm(lat1: number | string | null | undefined, lon1: number | string | null | undefined, lat2: number | string | null | undefined, lon2: number | string | null | undefined): number { const R = 6371; const lat1Num = parseFloat(String(lat1 ?? '0')); const lon1Num = parseFloat(String(lon1 ?? '0')); const lat2Num = parseFloat(String(lat2 ?? '0')); const lon2Num = parseFloat(String(lon2 ?? '0')); const dLat = (lat2Num - lat1Num) * Math.PI / 180; const dLon = (lon2Num - lon1Num) * Math.PI / 180; const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1Num * Math.PI / 180) * Math.cos(lat2Num * Math.PI / 180) * Math.sin(dLon / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } // Comparação em tempo constante (evita timing attack no token administrativo) function constantTimeEqual(a: string, b: string): boolean { const enc = new TextEncoder(); const x = enc.encode(a); const y = enc.encode(b); const len = Math.max(x.length, y.length); let diff = x.length ^ y.length; for (let i = 0; i < len; i++) { diff |= (x[i] ?? 0) ^ (y[i] ?? 0); } return diff === 0; } // Resultado neutro para um dispatch não executado (consentimento ou regra de integridade) function skipDispatch(reason: string): Promise<{ skipped: string }> { return Promise.resolve({ skipped: reason }); } function requireAdminAuth(request: Request, env: Env, headers: Headers): Response | null { if (!env.ADMIN_API_TOKEN) { return new Response(JSON.stringify({ error: 'ADMIN_API_TOKEN não configurado' }), { status: 503, headers }); } const authHeader = request.headers.get('Authorization') || ''; const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; if (!token || !constantTimeEqual(token, env.ADMIN_API_TOKEN)) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers }); } return null; } async function buildHealthReport(env: Env) { const results: Record = {}; try { await env.DB?.prepare('SELECT 1').run(); results.d1 = 'ok'; } catch (err: any) { results.d1 = `FAILED: ${err?.message || String(err)}`; } try { await env.GEO_CACHE?.get('__health_check__'); results.kv = 'ok'; } catch (err: any) { results.kv = `FAILED: ${err?.message || String(err)}`; } try { await env.AI?.run('@cf/ibm-granite/granite-4.0-h-micro', { messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, }); results.ai = 'ok'; } catch (err: any) { results.ai = `FAILED: ${err?.message || String(err)}`; } const vars = { META_PIXEL_ID: env.META_PIXEL_ID ? 'set' : 'MISSING', GA4_MEASUREMENT_ID: env.GA4_MEASUREMENT_ID ? 'set' : 'MISSING', TIKTOK_PIXEL_ID: env.TIKTOK_PIXEL_ID ? 'set' : 'MISSING', SITE_DOMAIN: env.SITE_DOMAIN ? 'set' : 'MISSING', }; const secrets = { META_ACCESS_TOKEN: (await getMetaToken(env)) ? 'set' : 'MISSING', GA4_API_SECRET: env.GA4_API_SECRET ? 'set' : 'MISSING', WA_WEBHOOK_VERIFY_TOKEN: env.WA_WEBHOOK_VERIFY_TOKEN ? 'set' : 'MISSING', WHATSAPP_ACCESS_TOKEN: (env.WHATSAPP_ACCESS_TOKEN || env.WA_ACCESS_TOKEN) ? 'set' : 'not set (optional - only for auto-reply)', WHATSAPP_PHONE_NUMBER_ID: (env.WHATSAPP_PHONE_NUMBER_ID || env.WA_PHONE_ID) ? 'set' : 'not set (optional - only for auto-reply)', WA_NOTIFY_NUMBER: env.WA_NOTIFY_NUMBER ? 'set' : 'not set (optional - only for auto-reply)', TIKTOK_ACCESS_TOKEN: env.TIKTOK_ACCESS_TOKEN ? 'set' : 'not set (optional)', CALLMEBOT_PHONE: env.CALLMEBOT_PHONE ? 'set' : 'not set (optional)', }; const hasMissing = Object.values(vars).includes('MISSING') || Object.values(secrets).includes('MISSING') || results.d1 !== 'ok'; return { status: hasMissing ? 'degraded' : 'ok', timestamp: new Date().toISOString(), bindings: results, vars, secrets, }; } // ───────────────────────────────────────────────────────────────────────────── // HANDLER PRINCIPAL // ───────────────────────────────────────────────────────────────────────────── export default { async fetch(request: Request, env: Env, ctx: ExecutionContext) { const origin = request.headers.get('Origin') || ''; const headersObj = { 'Content-Type': 'application/json', ...corsHeaders(origin, env.SITE_DOMAIN || null), }; const headers = new Headers(headersObj); // Preflight CORS if (request.method === 'OPTIONS') { return new Response(null, { status: 204, headers }); } const url = new URL(request.url); // ── Rate Limiter — camada 0, antes do Fraud Gate ───────────────────────── // Usa apenas CF-Connecting-IP (injetado pela Cloudflare, não pode ser spoofado) // X-Forwarded-For pode ser falsificado por atacantes para bypass rate limiter if (url.pathname === '/track' && request.method === 'POST' && env.RATE_LIMITER) { const ip = request.headers.get('CF-Connecting-IP') || 'unknown'; const { success } = await env.RATE_LIMITER.limit({ key: ip }); if (!success) { return new Response(JSON.stringify({ status: 'ok', queued: true }), { status: 200, headers }); } } // ── Fraud Gate — Fase 4 (apenas em /track) ──────────────────────────────── // Roda ANTES de qualquer processamento de evento // Silent drop (200) — bots não sabem que foram detectados if (url.pathname === '/track' && request.method === 'POST') { let trackBodyForFraud: TrackPayload = {}; try { const cloned = request.clone(); trackBodyForFraud = await cloned.json().catch(() => ({})) as TrackPayload; } catch { trackBodyForFraud = {}; } const earlyEventId = String(trackBodyForFraud.eventId || trackBodyForFraud.event_id || '').trim(); if (env.DB && earlyEventId) { try { const existingEvent = await env.DB .prepare('SELECT event_id FROM events WHERE event_id = ? LIMIT 1') .bind(earlyEventId) .first(); const existingLead = await env.DB .prepare('SELECT id FROM leads WHERE event_id = ? LIMIT 1') .bind(earlyEventId) .first(); if (existingEvent || existingLead) { return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers }); } } catch { // Dedup early-check falhou; segue para o Fraud Gate fail-safe. } } const fraudResult = await checkFraudGate(env, request, trackBodyForFraud); if (!fraudResult.allowed) { ctx.waitUntil(logFraudSignal(env, request, trackBodyForFraud, fraudResult)); return new Response(JSON.stringify({ status: 'ok', queued: true }), { status: 200, headers }); } if (fraudResult.action === 'flagged') { ctx.waitUntil(logFraudSignal(env, request, trackBodyForFraud, fraudResult)); } } // ── GET /export/customer-match ──────────────────────────────────────────── if (request.method === 'GET' && url.pathname === '/export/customer-match') { const authError = requireAdminAuth(request, env, headers); if (authError) return authError; const rows = await buildGoogleCustomerMatchExport(env); return new Response(JSON.stringify({ total: rows.length, data: rows }, null, 2), { headers: { ...headers, 'Content-Disposition': 'attachment; filename="customer-match.json"' }, }); } // ── GET /health ─────────────────────────────────────────────────────────── if (request.method === 'GET' && url.pathname === '/health') { return new Response(JSON.stringify({ status: 'ok', timestamp: new Date().toISOString(), }, null, 2), { headers }); } // ── /admin/meta-token — troca/renovação do token Meta sem redeploy ──────── // GET → estado (origem, final do token, validade); nunca o valor completo // POST → { "token": "..." } valida no Pixel e grava no KV // POST /admin/meta-token/refresh → força renovação (exige META_APP_ID/SECRET) // DELETE → remove o token do KV e volta ao secret META_ACCESS_TOKEN if (url.pathname === '/admin/meta-token' || url.pathname === '/admin/meta-token/refresh') { const authError = requireAdminAuth(request, env, headers); if (authError) return authError; const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers }); if (url.pathname === '/admin/meta-token/refresh' && request.method === 'POST') { return json(await refreshMetaTokenIfNeeded(env, true)); } if (request.method === 'GET') return json(await getMetaTokenStatus(env)); if (request.method === 'DELETE') { await clearMetaToken(env); return json({ ok: true, using: 'secret META_ACCESS_TOKEN' }); } if (request.method === 'POST') { let body: any; try { body = await request.json(); } catch { return json({ error: 'JSON inválido' }, 400); } const token = typeof body?.token === 'string' ? body.token.trim() : ''; if (token.length < 20) return json({ error: 'token ausente ou curto demais' }, 400); const check = await validateMetaToken(env, token); if (!check.ok) return json({ error: 'Meta recusou o token', detail: check.detail }, 422); try { await setMetaToken(env, token, 'admin'); } catch (err: any) { return json({ error: err?.message || String(err) }, 500); } return json({ ok: true, detail: check.detail, status: await getMetaTokenStatus(env) }); } return json({ error: 'Method not allowed' }, 405); } // ── /admin/privacy/* — direitos do titular e retenção (LGPD) ───────────── // POST /admin/privacy/delete { "email": "...", "phone": "..." } → apaga os dados do titular // POST /admin/privacy/purge { "days": 365 } → anonimiza PII antiga (padrão: DATA_RETENTION_DAYS) if (url.pathname === '/admin/privacy/delete' || url.pathname === '/admin/privacy/purge') { const authError = requireAdminAuth(request, env, headers); if (authError) return authError; const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers }); if (request.method !== 'POST') return json({ error: 'Method not allowed' }, 405); if (!env.DB) return json({ error: 'D1 não configurado' }, 503); let body: any; try { body = await request.json(); } catch { return json({ error: 'JSON inválido' }, 400); } if (url.pathname === '/admin/privacy/delete') { const email = typeof body?.email === 'string' && body.email.length <= 256 ? body.email : null; const phone = typeof body?.phone === 'string' && body.phone.length <= 32 ? body.phone : null; if (!email && !phone) return json({ error: 'informe email ou phone' }, 400); try { return json({ ok: true, deleted: await deleteSubjectData(env, { email, phone }) }); } catch (err: any) { return json({ error: err?.message || String(err) }, 500); } } const days = body?.days !== undefined ? Number(body.days) : retentionDays(env); if (!days || !Number.isFinite(days) || days < MIN_RETENTION_DAYS) { return json({ error: `defina DATA_RETENTION_DAYS ou envie "days" (mínimo ${MIN_RETENTION_DAYS})` }, 400); } try { return json({ ok: true, days, anonymized: await purgeExpiredPii(env, days) }); } catch (err: any) { return json({ error: err?.message || String(err) }, 500); } } // ── GET /validate-install ───────────────────────────────────────────────── // Endpoint de diagnóstico pós-deploy. Chamado por `cdp-edge validate `. // Testa D1 write/read, KV, AI e retorna relatório estruturado. // Protegido: exige Authorization: Bearer if (request.method === 'GET' && url.pathname === '/validate-install') { const authError = requireAdminAuth(request, env, headers); if (authError) return authError; const report: Record = {}; // 1. D1 write + read try { const testId = `__cdp_validate_${Date.now()}__`; await env.DB?.prepare( `INSERT OR REPLACE INTO events (event_id, event_name, user_id, created_at) VALUES (?, '__validate__', '__validate__', datetime('now'))` ).bind(testId).run(); const row = await env.DB?.prepare( `SELECT event_id FROM events WHERE event_id = ?` ).bind(testId).first(); await env.DB?.prepare(`DELETE FROM events WHERE event_id = ?`).bind(testId).run(); report.d1 = { ok: !!row, detail: row ? 'write+read+delete OK' : 'row not found after insert' }; } catch (err: any) { report.d1 = { ok: false, detail: err?.message || String(err) }; } // 2. KV read/write try { await env.GEO_CACHE?.put('__cdp_validate__', '1', { expirationTtl: 60 }); const val = await env.GEO_CACHE?.get('__cdp_validate__'); report.kv = { ok: val === '1', detail: val === '1' ? 'write+read OK' : 'value mismatch' }; } catch (err: any) { report.kv = { ok: false, detail: err?.message || String(err) }; } // 3. Workers AI try { await env.AI?.run('@cf/ibm-granite/granite-4.0-h-micro', { messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, }); report.ai = { ok: true, detail: 'Granite 4.0 Micro respondeu' }; } catch (err: any) { report.ai = { ok: false, detail: err?.message || String(err) }; } // 4. Secrets críticos const missing: string[] = []; if (!env.META_PIXEL_ID) missing.push('META_PIXEL_ID'); if (!(await getMetaToken(env))) missing.push('META_ACCESS_TOKEN'); if (!env.SITE_DOMAIN) missing.push('SITE_DOMAIN'); report.secrets = { ok: missing.length === 0, detail: missing.length === 0 ? 'todos os secrets críticos configurados' : `MISSING: ${missing.join(', ')}`, }; // 5. /track endpoint (auto-teste) const trackTest = { ok: false, detail: '' }; try { const testPayload = { eventName: 'PageView', userId: '__cdp_validate__', pageUrl: `https://${env.SITE_DOMAIN || 'validate.test'}/`, userAgent: request.headers.get('User-Agent') || '', ip: request.headers.get('CF-Connecting-IP') || '', _validate: true, }; // Não chama fetch externo — apenas verifica que o payload seria aceito const hasRequired = testPayload.eventName && testPayload.userId; trackTest.ok = !!hasRequired; trackTest.detail = hasRequired ? 'payload de teste válido (eventName + userId presentes)' : 'payload inválido'; } catch (err: any) { trackTest.detail = err?.message || String(err); } report.track_endpoint = trackTest; const allOk = Object.values(report).every(r => r.ok); return new Response(JSON.stringify({ status: allOk ? 'ok' : 'degraded', timestamp: new Date().toISOString(), checks: report, }, null, 2), { status: allOk ? 200 : 207, headers, }); } // ── POST /track ─────────────────────────────────────────────────────────── if (request.method === 'POST' && url.pathname === '/track') { // Reject oversized payloads before reading body (64 KB limit) const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10); if (contentLength > 65536) { return new Response(JSON.stringify({ error: 'Payload muito grande' }), { status: 413, headers }); } let body; try { const rawBody = await request.arrayBuffer(); if (rawBody.byteLength > 65536) { return new Response(JSON.stringify({ error: 'Payload muito grande' }), { status: 413, headers }); } body = JSON.parse(new TextDecoder().decode(rawBody)); } catch { return new Response(JSON.stringify({ error: 'JSON inválido' }), { status: 400, headers }); } const parsedBody = trackRequestSchema.safeParse(body); if (!parsedBody.success) { return new Response(JSON.stringify(schemaError(parsedBody.error)), { status: 400, headers }); } const STR_FIELDS = ['email','phone','firstName','lastName','city','state','zip','userId', 'utmSource','utmMedium','utmCampaign','utmContent','utmTerm', 'fbclid','ttclid','gclid','transactionId','productName','currency']; const validatedBody = body as { eventName?: string; event_name?: string; behavioral_data?: BehavioralData; [key: string]: any; }; const { eventName: _bodyEventName, behavioral_data, ...payload } = validatedBody; const trackPayload: TrackPayload = payload; // Aceita eventName (camelCase) ou event_name (snake_case — formato cdpTrack.js SDK) const eventName = (_bodyEventName || payload.event_name) as string; // ── Extrair click_ids e utms de sub-objetos (formato cdpTrack.js) ──────── // cdpTrack.js envia fbp/fbc/fbclid dentro de click_ids{} e UTMs dentro de utms{} // O Worker trabalha com campos no nível raiz — esta extração resolve o mismatch. if (payload.click_ids && typeof payload.click_ids === 'object') { const c = payload.click_ids as Record; if (!trackPayload.fbp && c.fbp) trackPayload.fbp = c.fbp; if (!trackPayload.fbc && c.fbc) trackPayload.fbc = c.fbc; if (!trackPayload.fbclid && c.fbclid) trackPayload.fbclid = c.fbclid; if (!trackPayload.gclid && c.gclid) trackPayload.gclid = c.gclid; if (!trackPayload.wbraid && c.wbraid) trackPayload.wbraid = c.wbraid; if (!trackPayload.gbraid && c.gbraid) trackPayload.gbraid = c.gbraid; if (!trackPayload.ttclid && c.ttclid) trackPayload.ttclid = c.ttclid; if (!trackPayload.ttp && c.ttp) trackPayload.ttp = c.ttp; if (!trackPayload.msclkid && c.msclkid) trackPayload.msclkid = c.msclkid; } if (payload.utms && typeof payload.utms === 'object') { const u = payload.utms as Record; if (!trackPayload.utmSource && u.utm_source) trackPayload.utmSource = u.utm_source; if (!trackPayload.utmMedium && u.utm_medium) trackPayload.utmMedium = u.utm_medium; if (!trackPayload.utmCampaign && u.utm_campaign) trackPayload.utmCampaign = u.utm_campaign; if (!trackPayload.utmContent && u.utm_content) trackPayload.utmContent = u.utm_content; if (!trackPayload.utmTerm && u.utm_term) trackPayload.utmTerm = u.utm_term; } // ── Normalizar campos snake_case → camelCase (formato cdpTrack.js SDK) ── if (!trackPayload.userId && payload.user_id) trackPayload.userId = payload.user_id; if (!trackPayload.eventId && payload.event_id) trackPayload.eventId = payload.event_id; if (!trackPayload.pageUrl && payload.page_url) trackPayload.pageUrl = payload.page_url; if (!trackPayload.sessionId && payload.session_id) trackPayload.sessionId = payload.session_id; // ── Sanitização e Validação de Campos String ────────────────────── type SanitizeResult = { error?: string; sanitized: string | null }; const SANITIZE_FIELDS: Record SanitizeResult> = { email: (val: string) => { if (!isValidEmail(val)) return { error: 'email inválido (formato incorreto)', sanitized: null }; return { sanitized: val.toLowerCase().trim() }; }, firstName: (val: string) => ({ sanitized: sanitizeString(val, 100) }), lastName: (val: string) => ({ sanitized: sanitizeString(val, 100) }), city: (val: string) => ({ sanitized: sanitizeString(val, 100) }), state: (val: string) => ({ sanitized: sanitizeString(val, 100) }), zip: (val: string) => ({ sanitized: sanitizeString(val, 20) }), dob: (val: string) => ({ sanitized: sanitizeString(val, 20) }), productName: (val: string) => ({ sanitized: sanitizeString(val, 200) }), pageUrl: (val: string) => { if (!isValidUrl(val)) return { error: 'pageUrl inválido (formato incorreto)', sanitized: null }; return { sanitized: val.trim() }; }, currency: (val: string) => { if (!isValidCurrency(val)) return { error: 'currency inválido (deve ser código ISO 4217)', sanitized: null }; return { sanitized: val.trim().toUpperCase() }; }, }; // Sanitiza e valida campos específicos for (const [field, validator] of Object.entries(SANITIZE_FIELDS)) { const value = trackPayload[field as keyof TrackPayload]; if (value !== undefined && value !== null) { if (typeof value !== 'string') { return new Response(JSON.stringify({ error: `Campo ${field} deve ser string` }), { status: 400, headers }); } const result = validator(value); if (result.error) { return new Response(JSON.stringify({ error: result.error }), { status: 400, headers }); } if (result.sanitized !== null) { trackPayload[field as keyof TrackPayload] = result.sanitized as any; } } } // Sanitiza campos de string genéricos const GENERIC_SANITIZE_FIELDS = ['utmSource', 'utmMedium', 'utmCampaign', 'utmContent', 'utmTerm']; for (const field of GENERIC_SANITIZE_FIELDS) { const value = trackPayload[field as keyof TrackPayload]; if (value !== undefined && value !== null) { if (typeof value !== 'string') { return new Response(JSON.stringify({ error: `Campo ${field} deve ser string` }), { status: 400, headers }); } const utmType = `utm_${field.replace('utm', '').toLowerCase()}`; if (!isValidUTM(value, utmType)) { return new Response(JSON.stringify({ error: `Campo ${field} contém caracteres perigosos` }), { status: 400, headers }); } const sanitized = sanitizeString(value, 200); if (sanitized === null) { return new Response(JSON.stringify({ error: `Campo ${field} inválido após sanitização` }), { status: 400, headers }); } trackPayload[field as keyof TrackPayload] = sanitized as any; } } // Sanitiza campos de string restantes const TRACKING_ID_FIELDS = ['transactionId', 'fbclid', 'ttclid', 'gclid']; for (const field of TRACKING_ID_FIELDS) { const value = trackPayload[field as keyof TrackPayload]; if (value !== undefined && value !== null) { if (typeof value !== 'string') { return new Response(JSON.stringify({ error: `Campo ${field} deve ser string` }), { status: 400, headers }); } const sanitized = sanitizeString(value, 512); if (sanitized === null) { return new Response(JSON.stringify({ error: `Campo ${field} inválido após sanitização` }), { status: 400, headers }); } trackPayload[field as keyof TrackPayload] = sanitized as any; } } // ── fbc derivado de fbclid ─────────────────────────────────────────── // Quando o usuário vem de um clique Meta, fbclid chega na URL e o cdpTrack.js // o envia como campo. O CAPI espera fbc no formato fb.1.{ms}.{fbclid}. // Sem essa conversão, o sinal de clique some — EMQ cai e deduplicação piora. if (trackPayload.fbclid && !trackPayload.fbc) { trackPayload.fbc = `fb.1.${Date.now()}.${trackPayload.fbclid}`; } // ── Validação de Valor Numérico ─────────────────────────────────── if (trackPayload.value !== undefined && trackPayload.value !== null) { if (!isValidValue(trackPayload.value)) { return new Response(JSON.stringify({ error: 'value fora do intervalo permitido (0-9,999,999)' }), { status: 400, headers }); } trackPayload.value = Number(trackPayload.value); } // ── Extrair dados comportamentais do browser ────────────────────────── if (behavioral_data) { payload.engagementScore = behavioral_data.engagement_score ?? behavioral_data.totalScore ?? null; payload.intentionLevel = behavioral_data.intention_level ?? null; payload.userScore = behavioral_data.user_score ?? null; // Sinais de engajamento profundo — usados no anti-falso-positivo e no LTV payload.scrollScore = behavioral_data.scroll_score ?? null; payload.timeLevel = behavioral_data.time_level ?? null; // ── Sanitiza dados do behavioral_data ──────────────────────── // Os dados do behavioral_data podem vir do browser e ser manipulados const sanitizedBehavioralEmail = behavioral_data.email && isValidEmail(behavioral_data.email) ? behavioral_data.email.toLowerCase().trim() : null; const sanitizedBehavioralPhone = behavioral_data.phone ? sanitizeString(behavioral_data.phone, 50) : null; const sanitizedBehavioralFirstName = behavioral_data.first_name || behavioral_data.firstName ? sanitizeString(behavioral_data.first_name || behavioral_data.firstName, 100) : null; const sanitizedBehavioralLastName = behavioral_data.last_name || behavioral_data.lastName ? sanitizeString(behavioral_data.last_name || behavioral_data.lastName, 100) : null; const sanitizedBehavioralCity = behavioral_data.city ? sanitizeString(behavioral_data.city, 100) : null; // Usa dados sanitizados do behavioral_data se não existirem no payload principal payload.email = payload.email || sanitizedBehavioralEmail; payload.phone = payload.phone || sanitizedBehavioralPhone; payload.firstName = payload.firstName || sanitizedBehavioralFirstName; payload.lastName = payload.lastName || sanitizedBehavioralLastName; payload.city = payload.city || sanitizedBehavioralCity; // Sanitiza campos restantes do behavioral_data const sanitizedBehavioralState = behavioral_data.state ? sanitizeString(behavioral_data.state, 100) : null; const sanitizedBehavioralZip = behavioral_data.zip ? sanitizeString(behavioral_data.zip, 20) : null; const sanitizedBehavioralDob = behavioral_data.dob ? sanitizeString(behavioral_data.dob, 20) : null; payload.state = payload.state || sanitizedBehavioralState; payload.zip = payload.zip || sanitizedBehavioralZip; payload.dob = payload.dob || sanitizedBehavioralDob; } // ── Normalização de intent_score → 0.0–1.0 ────────────────────────── // Aceita string ('high'/'medium'/'low'), 0-1 ou 0-100 enviados pelo front. // intent_bucket mantém a label legível para D1 e logs. const intentScoreNum = resolveIntentScore(payload.intent_score); if (intentScoreNum !== null) { payload.intent_score = intentScoreNum; payload.intentScoreNum = intentScoreNum; payload.intent_bucket = intentScoreNum >= 0.8 ? 'high' : intentScoreNum >= 0.5 ? 'medium' : 'low'; } else { payload.intentScoreNum = null; } // ── Anti-falso-positivo ─────────────────────────────────────────────── // Penaliza intent se engajamento insuficiente: scroll raso E tempo curto. // scroll_score < 2.0 ≈ não passou de 50% da página. // time_level 'curioso' = menos de 60 segundos na página. if (payload.intentScoreNum !== null) { const isShallowScroll = payload.scrollScore !== null && payload.scrollScore < 2.0; const isShallowTime = payload.timeLevel === 'curioso'; if (isShallowScroll && isShallowTime) { const penalized = Math.round(payload.intentScoreNum * 0.7 * 100) / 100; payload.intentScoreNum = penalized; payload.intent_score = penalized; payload.intent_bucket = penalized >= 0.8 ? 'high' : penalized >= 0.5 ? 'medium' : 'low'; payload.intent_penalized = true; // flag auditável — visível no D1 e logs } } // ── Edge Fingerprint + UTM Resurrection ────────────────────────────── const fingerprint = await generateEdgeFingerprint(request); payload.utmRestored = false; if (fingerprint && env.DB) { if (payload.utmSource) { ctx.waitUntil(saveEdgeFingerprint(env.DB, fingerprint, payload.userId, payload)); } else { const recovered = await resurrectUTM(env.DB, fingerprint); if (recovered) { payload.utmSource = payload.utmSource || recovered.utm_source; payload.utmMedium = payload.utmMedium || recovered.utm_medium; payload.utmCampaign = payload.utmCampaign || recovered.utm_campaign; payload.utmContent = payload.utmContent || recovered.utm_content; payload.utmTerm = payload.utmTerm || recovered.utm_term; payload.utmRestored = true; } } } // ── Bot Mitigation ──────────────────────────────────────────────────── const botScoreStr = (request as any).cf?.botManagement?.score; const cfBotScore = botScoreStr !== undefined ? parseInt(String(botScoreStr)) : 100; const ua = (request.headers.get('User-Agent') || '').toLowerCase(); const isBotPattern = /bot|crawler|spider|lighthouse|postman|curl|inspect|headless/i.test(ua); const isBot = cfBotScore < 30 || isBotPattern; trackPayload.botScore = isBot ? 2 : (cfBotScore < 60 ? 1 : 0); if (isBot && !['Contact', 'Lead'].includes(eventName)) { return new Response(JSON.stringify({ ok: true, skipped_due_to_bot: true }), { status: 200, headers }); } // ── Consentimento (LGPD) e Purchase do navegador ───────────────────── // Recusa explícita nunca é enviada às plataformas. Purchase do navegador é // bloqueado quando o webhook do checkout (autenticado) já envia a compra. const adsAllowed = consentAllows(env, payload.consent, 'ads'); const analyticsAllowed = consentAllows(env, payload.consent, 'analytics'); const purchaseBlocked = eventName === 'Purchase' && isBrowserPurchaseBlocked(env); if (purchaseBlocked) payload.internalEvent = 'PurchaseBrowser'; // ── Edge Geo Enrichment ─────────────────────────────────────────────── const geoData = await enrichGeoFromEdge(request, env, payload); // ── First-Party Cookie (Identity Resolution) ────────────────────────── const cookieHeader = request.headers.get('Cookie') || ''; const cdpUidMatch = cookieHeader.match(/_cdp_uid=([^;]+)/); const finalUserId = payload.userId || (cdpUidMatch ? cdpUidMatch[1] : crypto.randomUUID()); payload.userId = finalUserId; // Deduplica antes de enrichments caros e dispatch externo. const dedupEventId = String(payload.eventId || payload.event_id || '').trim(); if (env.DB && dedupEventId) { try { const existingEvent = await env.DB .prepare('SELECT event_id FROM events WHERE event_id = ? LIMIT 1') .bind(dedupEventId) .first(); const existingLead = await env.DB .prepare('SELECT id FROM leads WHERE event_id = ? LIMIT 1') .bind(dedupEventId) .first(); if (existingEvent || existingLead) { return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers }); } await env.DB.prepare( `INSERT INTO events (event_id, event_name, user_id, created_at) VALUES (?, ?, ?, datetime('now'))` ).bind(dedupEventId, eventName, payload.userId || null).run(); } catch { // Tabela ausente ou erro de DB não bloqueia tracking. } } const ga4Name = META_TO_GA4[eventName] || eventName.toLowerCase(); // ── Dual-layer semantics ───────────────────────────────────────────── // Meta sempre recebe o nome canônico (Schedule, Lead, etc.). // Internamente o CDP usa semântica de funil precisa via FUNNEL_TAXONOMY. if (payload.funnel_stage) { const { depth, funnelDepth } = resolveFunnelStage(payload.funnel_stage); payload.funnelDepth = funnelDepth; // ex: 'bottom_intent', 'bottom_conversion' payload.funnelLevel = depth; // ex: 'bottom', 'conversion', 'mid' } if (eventName === 'Schedule' && payload.funnel_stage === 'route_click') { payload.internalEvent = 'IntentToVisit'; } // ── Real Estate Distance Enrichment ────────────────────────────────── // Calcula distância entre o IP do usuário (via Cloudflare) e o imóvel. // Ativa quando o evento carrega property_lat + property_lng (ex: Schedule de rota). const propLat = parseFloat(String(trackPayload.property_lat ?? trackPayload.propertyLat)); const propLng = parseFloat(String(trackPayload.property_lng ?? trackPayload.propertyLng)); const userLat = parseFloat(String(request.cf?.latitude ?? '0')); const userLng = parseFloat(String(request.cf?.longitude ?? '0')); if (!isNaN(propLat) && !isNaN(propLng) && !isNaN(userLat) && !isNaN(userLng)) { const distKm = haversineKm(userLat, userLng, propLat, propLng); trackPayload.distanceKm = Math.round(distKm * 10) / 10; trackPayload.distanceBucket = distKm < 5 ? 'very_close' : distKm < 15 ? 'close' : distKm < 30 ? 'nearby' : distKm < 60 ? 'moderate' : 'far'; } // ── Quiz Scoring Engine (Fase 6) ───────────────────────────────────── // Roda antes do LTV para que intentionLevel qualificado alimente a predição. // O front envia quiz_answers: [{question, answer, step}] junto com QuizComplete. if (eventName === 'QuizComplete' && Array.isArray(payload.quiz_answers) && payload.quiz_answers.length > 0) { try { const quizResult = await scoreQuizAnswers(env, payload.quiz_answers, payload.quiz_name ?? null); // Injeta qualificação no payload — flui para LTV, Meta Signal, D1 e CAPI payload.intentionLevel = quizResult.qualification; payload.intent_score = quizResult.intent_score; payload.intentScoreNum = quizResult.intent_score; payload.intent_bucket = quizResult.intent_score >= 0.8 ? 'high' : quizResult.intent_score >= 0.5 ? 'medium' : 'low'; // Campos extras para auditoria e dashboard (payload as any).quiz_qualification = quizResult.qualification; (payload as any).quiz_confidence = quizResult.confidence; (payload as any).quiz_weighted_score = quizResult.weighted_score; (payload as any).quiz_dominant_dimension = quizResult.dominant_dimension; (payload as any).quiz_signals = quizResult.dimensions.map((d: any) => d.signal).filter(Boolean); (payload as any).quiz_source = quizResult.source; // utm_term — injetado pelo Worker após qualificação (nunca configurado nos anúncios) // Flui para Meta CAPI custom_data e D1 user_profiles para cruzar com ROAS Feedback payload.utmTerm = quizResult.qualification; // comprador | interessado | curioso | perdido // Persiste sessão no D1 em background if (env.DB) ctx.waitUntil(saveQuizSession(env, payload.userId, payload, quizResult)); // Agenda nurture sequence baseada na qualificação (background) ctx.waitUntil(scheduleNurture(env, payload, quizResult.qualification)); } catch (err: any) { console.error('[QuizScoring] erro ao qualificar respostas:', err?.message || String(err)); // Fail-safe: continua sem qualificação } } // ── LTV Prediction (+ A/B Testing de Prompts) ───────────────────────── const LTV_EVENTS = ['Lead', 'Contact', 'Schedule', 'CompleteRegistration', 'QuizComplete']; if (LTV_EVENTS.includes(eventName) && !payload.value) { const abVariation = await getLtvAbVariation(env); const ltv = await predictLtv(env, payload, request, abVariation?.system_prompt || null); payload.value = ltv.value; payload.currency = payload.currency || 'BRL'; payload.ltvClass = ltv.class; payload.ltvScore = ltv.score; ctx.waitUntil(upsertLtvProfile(env, payload.userId, ltv)); if (abVariation) { const emailHash = payload.email ? await sha256(payload.email.trim().toLowerCase()) : null; ctx.waitUntil( recordAbAssignment( env, payload.userId, abVariation.id, abVariation.test_id, ltv.value, ltv.class, emailHash ?? null, ) ); } } // ── LTV Feedback Loop — fecha o ciclo preditivo ────────────────────── // Quando uma compra real acontece, registra o valor real e recalcula accuracy. // Alimenta ltv_ab_variations.accuracy_score → autoDecideAbWinner usa isso. if (eventName === 'Purchase' && payload.value > 0 && !purchaseBlocked) { ctx.waitUntil(recordLtvFeedback(env, payload.userId, payload.value)); } // ── Meta Signal Score (composite, pesos dinâmicos) ─────────────────── // Pesos variam por profundidade de funil: fundo = comportamento pesa mais. { const w = computeMetaSignalWeights(payload.funnelLevel); const iW = payload.intentScoreNum ?? 0.5; const lW = payload.ltvScore ? payload.ltvScore / 100 : 0.5; const dW = distanceBucketWeight(payload.distanceBucket); payload.metaSignal = Math.round(((iW * w.intent) + (lW * w.ltv) + (dW * w.dist)) * 100) / 100; payload.metaSignalBucket = metaSignalBucket(payload.metaSignal); // 'hot'/'warm'/'cold' } // ── Hot Lead Trigger (timing + sinal) ──────────────────────────────── // Reage em tempo real: WhatsApp apenas quando comportamento + sinal justificam. // Critérios: rota + muito próximo + (intent alto OU meta_signal alto) // + (janela de decisão BRT 18–22h OU sinal excepcional ≥ 0.9) const hourBRT = (new Date().getUTCHours() - 3 + 24) % 24; const inWindow = hourBRT >= 18 && hourBRT <= 22; const isHotLead = payload.funnel_stage === 'route_click' && payload.distanceBucket === 'very_close' && ((payload.intentScoreNum ?? 0) >= 0.8 || payload.metaSignal >= 0.85) && (inWindow || payload.metaSignal >= 0.9); // Cross-Device Graph — background if (env.DB && payload.userId && (payload.email || payload.phone)) { ctx.waitUntil(resolveDeviceGraph(env.DB, payload.userId, payload.email, payload.phone)); } // R2 Audit Log — background ctx.waitUntil(writeAuditLog(env, eventName, payload, geoData)); // Disparar tudo em paralelo const WHATSAPP_NOTIFY_EVENTS = ['Lead', 'Purchase', 'CompleteRegistration']; const [metaRes, ga4Res, ttRes] = await Promise.allSettled([ purchaseBlocked ? skipDispatch('browser purchase blocked (webhook ativo)') : adsAllowed ? sendMetaCapi(env, eventName, payload, request, ctx) : skipDispatch('consent denied'), purchaseBlocked ? skipDispatch('browser purchase blocked (webhook ativo)') : analyticsAllowed ? sendGA4Mp(env, ga4Name, payload, ctx) : skipDispatch('consent denied'), purchaseBlocked ? skipDispatch('browser purchase blocked (webhook ativo)') : adsAllowed ? sendTikTokApi(env, eventName, payload, request, ctx) : skipDispatch('consent denied'), saveLead(env, payload.internalEvent || eventName, payload, request, 'website'), upsertProfile(env, eventName, payload, request), ...(WHATSAPP_NOTIFY_EVENTS.includes(eventName) || isHotLead ? [sendWhatsApp(env, isHotLead ? 'hot_lead_intent_to_visit' : eventName, payload)] : []), ]); // Automação de mensagens const AUTOMATION_EVENTS = ['Lead', 'Purchase', 'InitiateCheckout']; if (AUTOMATION_EVENTS.includes(eventName) && env.DB) { const db = env.DB; // Captura em variável local ctx.waitUntil( (async () => { try { const lastLead = await db .prepare(`SELECT id FROM leads WHERE event_id = ?1 LIMIT 1`) .bind(trackPayload.eventId || trackPayload.event_id || '') .first() as any; const leadId = lastLead?.id ? Number(lastLead.id) : null; if (leadId) await fireAutomation(env, eventName, leadId, trackPayload); } catch (e: any) { console.error('[Automation] lead lookup error:', e?.message || String(e)); } })() ); } // Edge Personalization let currentScore = 0; if (env.DB && trackPayload.userId) { try { const profileRow = await env.DB.prepare('SELECT score FROM user_profiles WHERE user_id = ?').bind(trackPayload.userId).first(); if (profileRow) currentScore = Number(profileRow.score) || 0; } catch (err: any) { console.error('[POST /track] Error fetching user profile score:', { userId: trackPayload.userId, error: err?.message || String(err), stack: err?.stack, }); } } const resHeaders = new Headers(headers); resHeaders.set('Set-Cookie', `_cdp_uid=${finalUserId}; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000; Path=/; Domain=.${env.SITE_DOMAIN}`); return new Response(JSON.stringify({ ok: true, userProfile: { score: currentScore, user_id: finalUserId }, meta: metaRes.status === 'fulfilled' ? metaRes.value : { error: metaRes.reason?.message }, ga4: ga4Res.status === 'fulfilled' ? ga4Res.value : { error: ga4Res.reason?.message }, tiktok: ttRes.status === 'fulfilled' ? ttRes.value : { error: ttRes.reason?.message }, }), { status: 200, headers: resHeaders }); } // ── POST /webhook/hotmart ───────────────────────────────────────────────── if (request.method === 'POST' && url.pathname === '/webhook/hotmart') { if (!env.WEBHOOK_SECRET_HOTMART) { return new Response('Webhook secret not configured', { status: 503 }); } const token = request.headers.get('X-Hotmart-Webhook-Token') || ''; if (token !== env.WEBHOOK_SECRET_HOTMART) { return new Response('Unauthorized', { status: 401 }); } let wh: HotmartWebhook; try { const raw = await request.json(); const parsed = hotmartWebhookSchema.safeParse(raw); if (!parsed.success) return Response.json(schemaError(parsed.error), { status: 400 }); wh = raw as HotmartWebhook; } catch { return new Response('JSON inválido', { status: 400 }); } const data = wh.data || wh; const buyer = data.buyer || {}; const purchase = data.purchase || {}; const product = data.product || {}; if (!['APPROVED', 'COMPLETE', 'approved', 'complete'].includes(purchase.status)) { return new Response(JSON.stringify({ skipped: `status ${purchase.status}` }), { status: 200, headers }); } const hmTxId = String(purchase.transaction || ''); const dupCheck = await processWebhookDuplicateCheck(env, 'hotmart', hmTxId, JSON.stringify(wh), { email: buyer.email, }); if (dupCheck.duplicate) { return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers }); } const profile = await getProfileByEmail(env, buyer.email); const payload = { email: buyer.email, phone: buyer.phone, firstName: buyer.name?.split(' ')[0], lastName: buyer.name?.split(' ').slice(1).join(' ') || undefined, fbp: profile?.fbp, fbc: profile?.fbc, userId: profile?.user_id, gaClientId: profile?.ga_client_id, value: purchase.price?.value, currency: purchase.price?.currency_value || 'BRL', contentIds: [String(product.id || product.ucode || '')], contentName: product.name, contentType: 'product', pageUrl: profile?.page_url || `https://${env.SITE_DOMAIN || 'seu-dominio.com.br'}`, orderId: purchase.transaction, eventId: `hotmart_${purchase.transaction}`, city: profile?.city, state: profile?.state, country: profile?.country, }; ctx.waitUntil(Promise.allSettled([ sendMetaCapi(env, 'Purchase', payload, null, ctx), // webhook: IP/UA seriam do checkout, não do comprador sendGA4Mp(env, 'purchase', payload, ctx), sendTikTokApi(env, 'CompletePayment', payload, null, ctx), saveLead(env, 'Purchase', payload, request, 'hotmart'), ...(payload.userId && Number(payload.value) > 0 ? [recordLtvFeedback(env, payload.userId, Number(payload.value))] : []), sendWhatsApp(env, 'Purchase', payload), ])); return new Response(JSON.stringify({ ok: true }), { status: 200, headers }); } // ── POST /webhook/kiwify ────────────────────────────────────────────────── if (request.method === 'POST' && url.pathname === '/webhook/kiwify') { if (!env.WEBHOOK_SECRET_KIWIFY) { return new Response('Webhook secret not configured', { status: 503 }); } const token = request.headers.get('X-Kiwify-Event-Token') || ''; if (token !== env.WEBHOOK_SECRET_KIWIFY) { return new Response('Unauthorized', { status: 401 }); } let wh: KiwifyWebhook; try { const raw = await request.json(); const parsed = kiwifyWebhookSchema.safeParse(raw); if (!parsed.success) return Response.json(schemaError(parsed.error), { status: 400 }); wh = raw as KiwifyWebhook; } catch { return new Response('JSON inválido', { status: 400 }); } if (wh.order_status !== 'paid' && wh.order_status !== 'approved') { return new Response(JSON.stringify({ skipped: `status ${wh.order_status}` }), { status: 200, headers }); } const customer = wh.Customer || {}; const kwTxId = String(wh.order_id || ''); const dupCheck = await processWebhookDuplicateCheck(env, 'kiwify', kwTxId, JSON.stringify(wh), { email: customer.email, }); if (dupCheck.duplicate) { return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers }); } const product = wh.Product || {}; const profile = await getProfileByEmail(env, customer.email || ''); const payload = { email: customer.email, phone: customer.mobile, firstName: customer.full_name?.split(' ')[0], lastName: customer.full_name?.split(' ').slice(1).join(' ') || undefined, fbp: profile?.fbp, fbc: profile?.fbc, userId: profile?.user_id, gaClientId: profile?.ga_client_id, value: wh.order_value ? parseFloat(wh.order_value) / 100 : undefined, currency: 'BRL', contentIds: [String(product.product_id || '')], contentName: product.product_name, contentType: 'product', pageUrl: profile?.page_url || `https://${env.SITE_DOMAIN || 'seu-dominio.com.br'}`, orderId: wh.order_id, eventId: `kiwify_${wh.order_id}`, city: profile?.city, state: profile?.state, country: profile?.country, }; ctx.waitUntil(Promise.allSettled([ sendMetaCapi(env, 'Purchase', payload, null, ctx), // webhook: IP/UA seriam do checkout, não do comprador sendGA4Mp(env, 'purchase', payload, ctx), sendTikTokApi(env, 'CompletePayment', payload, null, ctx), sendPinterestCapi(env, 'Purchase', payload, null, ctx), sendRedditCapi(env, 'Purchase', payload, null, ctx), sendLinkedInCapi(env, 'Purchase', payload, null, ctx), sendSpotifyCapi(env, 'Purchase', payload, null, ctx), saveLead(env, 'Purchase', payload, request, 'kiwify'), ...(payload.userId && Number(payload.value) > 0 ? [recordLtvFeedback(env, payload.userId, Number(payload.value))] : []), sendWhatsApp(env, 'Purchase', payload), ])); return new Response(JSON.stringify({ ok: true }), { status: 200, headers }); } // ── POST /webhook/ticto ─────────────────────────────────────────────────── if (request.method === 'POST' && url.pathname === '/webhook/ticto') { let rawBody; try { rawBody = await request.text(); } catch { return new Response('Leitura de body falhou', { status: 400 }); } if (!env.WEBHOOK_SECRET_TICTO) { return new Response('Webhook secret not configured', { status: 503 }); } const sig = request.headers.get('X-Ticto-Signature') || ''; const valid = await verifyHmac(env.WEBHOOK_SECRET_TICTO, rawBody, sig); if (!valid) { return new Response('Unauthorized', { status: 401 }); } let wh: TictoWebhook; try { const raw = JSON.parse(rawBody); const parsed = tictoWebhookSchema.safeParse(raw); if (!parsed.success) return Response.json(schemaError(parsed.error), { status: 400 }); wh = raw as TictoWebhook; } catch { return new Response('JSON inválido', { status: 400 }); } const STATUS_PAID = ['paid', 'approved', 'complete', 'completed']; if (!STATUS_PAID.includes((wh.status || '').toLowerCase())) { return new Response(JSON.stringify({ skipped: `status ${wh.status}` }), { status: 200, headers }); } const customer = wh.customer || {}; const order = wh.order || {}; const item = wh.item || {}; const tracking = wh.tracking || wh.url_params || {}; const valueRaw = order.paid_amount ?? order.total ?? order.amount; const value = valueRaw ? parseFloat(String(valueRaw)) / 100 : undefined; const transactionId = order.hash || order.transaction_hash || order.id; const tcTxId = String(order.hash || order.transaction_hash || order.id || ''); const dupCheck = await processWebhookDuplicateCheck(env, 'ticto', tcTxId, rawBody, { email: customer.email, }); if (dupCheck.duplicate) { return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers }); } const urlUserId = tracking.user_id || wh.url_params?.user_id; let profile = await getProfileByEmail(env, customer.email || ''); if (!profile && urlUserId && env.DB) { try { profile = await env.DB.prepare( 'SELECT * FROM user_profiles WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1' ).bind(urlUserId).first(); } catch (err: any) { console.error('[POST /webhook/ticto] Error fetching user profile by userId:', { userId: urlUserId, email: customer.email, error: err?.message || String(err), stack: err?.stack, }); } } const fbclid = tracking.fbclid || wh.url_params?.fbclid; const fbc = profile?.fbc || (fbclid ? `fb.1.${Date.now()}.${fbclid}` : undefined); const payload = { email: customer.email, phone: customer.phone, firstName: customer.name?.split(' ')[0], lastName: customer.name?.split(' ').slice(1).join(' ') || undefined, fbp: profile?.fbp, fbc, ttp: profile?.ttp, userId: profile?.user_id, gaClientId: profile?.ga_client_id, value, currency: 'BRL', contentIds: [String(item.product_id || '')], contentName: item.product_name, contentType: 'product', pageUrl: profile?.page_url || `https://${env.SITE_DOMAIN || 'seu-dominio.com.br'}`, orderId: transactionId, eventId: `ticto_${transactionId}`, city: profile?.city, state: profile?.state, country: profile?.country || 'br', utmSource: tracking.utm_source || tracking.src || '', utmMedium: tracking.utm_medium || '', utmCampaign: tracking.utm_campaign || '', utmContent: tracking.utm_content || '', }; ctx.waitUntil(Promise.allSettled([ sendMetaCapi(env, 'Purchase', payload, null, ctx), // webhook: IP/UA seriam do checkout, não do comprador sendGA4Mp(env, 'purchase', payload, ctx), sendTikTokApi(env, 'CompletePayment', payload, null, ctx), sendPinterestCapi(env, 'Purchase', payload, null, ctx), sendRedditCapi(env, 'Purchase', payload, null, ctx), sendLinkedInCapi(env, 'Purchase', payload, null, ctx), sendSpotifyCapi(env, 'Purchase', payload, null, ctx), saveLead(env, 'Purchase', payload, request, 'ticto'), ...(payload.userId && Number(payload.value) > 0 ? [recordLtvFeedback(env, payload.userId, Number(payload.value))] : []), sendWhatsApp(env, 'Purchase', payload), ])); return new Response(JSON.stringify({ ok: true }), { status: 200, headers }); } // ── GET /webhook/whatsapp — verificação do webhook pela Meta ───────────── if (request.method === 'GET' && url.pathname === '/webhook/whatsapp') { const mode = url.searchParams.get('hub.mode'); const token = url.searchParams.get('hub.verify_token'); const challenge = url.searchParams.get('hub.challenge'); if (mode === 'subscribe' && env.WA_WEBHOOK_VERIFY_TOKEN && token === env.WA_WEBHOOK_VERIFY_TOKEN) { return new Response(challenge, { status: 200, headers: { 'Content-Type': 'text/plain' } }); } return new Response('Forbidden', { status: 403 }); } // ── POST /webhook/whatsapp — mensagens recebidas (CTWA) ────────────────── if (request.method === 'POST' && url.pathname === '/webhook/whatsapp') { let rawBody: string; let body: any; try { rawBody = await request.text(); body = JSON.parse(rawBody); } catch { return new Response('JSON inválido', { status: 400 }); } if (!env.META_APP_SECRET) { return new Response('Webhook secret not configured', { status: 503 }); } const sig = request.headers.get('x-hub-signature-256') || ''; const valid = await verifyHmac(env.META_APP_SECRET, rawBody, sig); if (!valid) { return new Response('Unauthorized', { status: 401 }); } const result = await processWhatsAppWebhook(env, body, request, ctx); return new Response(JSON.stringify({ ok: true, ...result }), { status: 200, headers }); } if (url.pathname.startsWith('/api/')) { const authError = requireAdminAuth(request, env, headers); if (authError) return authError; } if (url.pathname === '/api/health' && request.method === 'GET') { return new Response(JSON.stringify(await buildHealthReport(env), null, 2), { headers }); } // ── ML — Segmentação Dinâmica ───────────────────────────────────────────── if (url.pathname === '/api/segmentation/cluster' && request.method === 'POST') { return handleSegmentationCluster(env, request, headers); } if (url.pathname === '/api/segmentation/list' && request.method === 'GET') { return handleSegmentationList(env, request, headers); } if (url.pathname === '/api/segmentation/outliers' && request.method === 'GET') { return handleSegmentationOutliers(env, request, headers); } if (url.pathname === '/api/segmentation/update' && request.method === 'PUT') { return handleSegmentationUpdate(env, request, headers); } // ── ML — Bidding Recommendations ────────────────────────────────────────── if (url.pathname === '/api/bidding/recommend' && request.method === 'POST') { return handleBiddingRecommend(env, request, headers); } if (url.pathname === '/api/bidding/history' && request.method === 'GET') { return handleBiddingHistory(env, request, headers); } if (url.pathname === '/api/bidding/status' && request.method === 'GET') { return handleBiddingStatus(env, request, headers); } // ── ML — A/B Testing de Prompts LTV ────────────────────────────────────── if (url.pathname === '/api/ltv/ab-test/create' && request.method === 'POST') { return handleLtvAbTestCreate(env, request, headers); } if (url.pathname === '/api/ltv/ab-test/list' && request.method === 'GET') { return handleLtvAbTestList(env, request, headers); } if (url.pathname === '/api/ltv/ab-test/results' && request.method === 'GET') { return handleLtvAbTestResults(env, request, headers); } if (url.pathname === '/api/ltv/ab-test/winner' && request.method === 'POST') { return handleLtvAbTestWinner(env, request, headers); } // ── Fraud Detection — Fase 4 ────────────────────────────────────────────── if (url.pathname === '/api/fraud/alerts' && request.method === 'GET') { return handleFraudAlerts(env, request, headers); } if (url.pathname === '/api/fraud/blocklist' && request.method === 'GET') { return handleFraudBlocklist(env, request, headers); } if (url.pathname === '/api/fraud/blocklist/add' && request.method === 'POST') { return handleFraudBlocklistAdd(env, request, headers); } if (url.pathname === '/api/fraud/blocklist/remove' && request.method === 'DELETE') { return handleFraudBlocklistRemove(env, request, headers); } if (url.pathname === '/api/fraud/stats' && request.method === 'GET') { return handleFraudStats(env, request, headers); } // 404 return new Response(JSON.stringify({ error: 'rota não encontrada' }), { status: 404, headers }); }, // ── Cron Handler — Intelligence Agent ──────────────────────────────────────── async scheduled(event: any, env: Env, ctx: ExecutionContext) { const cron = event.cron; const isMonthly = cron === '0 3 1 * *'; const retention = retentionDays(env); if (retention) { ctx.waitUntil(purgeExpiredPii(env, retention) .then(r => console.log('[Privacy] PII anonimizada:', JSON.stringify(r))) .catch(err => console.error('[Privacy] purge falhou:', err?.message || String(err)))); } ctx.waitUntil(refreshMetaTokenIfNeeded(env).then(r => { if (r.refreshed) console.log('[MetaToken] renovado automaticamente'); })); ctx.waitUntil(runIntelligenceAgent(env, isMonthly ? 'monthly_audit' : 'weekly_check')); }, // ── Queue Consumer — Retry de eventos com falha ─────────────────────────────── async queue(batch: any, env: Env, ctx: ExecutionContext) { for (const message of batch.messages) { const { eventType, payload, platform } = message.body as { eventType: string; payload: TrackPayload; platform: string; attempt?: number }; try { if (platform === 'meta') await sendMetaCapi(env, eventType, payload, null, ctx); if (platform === 'ga4') await sendGA4Mp(env, eventType, payload, ctx); if (platform === 'tiktok') await sendTikTokApi(env, eventType, payload, null, ctx); if (platform === 'pinterest') await sendPinterestCapi(env, eventType, payload, null, ctx); if (platform === 'reddit') await sendRedditCapi(env, eventType, payload, null, ctx); if (platform === 'linkedin') await sendLinkedInCapi(env, eventType, payload, null, ctx); if (platform === 'spotify') await sendSpotifyCapi(env, eventType, payload, null, ctx); message.ack(); } catch (err: any) { console.error(`[Queue] Falha ao reprocessar ${platform}/${eventType}:`, err?.message || String(err)); message.retry(); } } }, };