cdp-edge 1.23.2 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +82 -21
  2. package/bin/cdp-edge.js +10 -1
  3. package/contracts/agent-versions.json +42 -41
  4. package/contracts/types.ts +81 -0
  5. package/dist/commands/install.js +6 -1
  6. package/dist/commands/server.js +4 -4
  7. package/docs/whatsapp-ctwa.md +3 -2
  8. package/extracted-skill/tracking-events-generator/agents/database-agent.md +5 -4
  9. package/extracted-skill/tracking-events-generator/agents/fraud-detection-agent.md +0 -1
  10. package/extracted-skill/tracking-events-generator/agents/linkedin-agent.md +1 -1
  11. package/extracted-skill/tracking-events-generator/agents/ltv-predictor-agent.md +4 -4
  12. package/extracted-skill/tracking-events-generator/agents/ml-clustering-agent.md +81 -70
  13. package/extracted-skill/tracking-events-generator/agents/page-analyzer.md +6 -2
  14. package/extracted-skill/tracking-events-generator/cdpTrack.js +7 -0
  15. package/extracted-skill/tracking-events-generator/models/lancamento-imobiliario.md +344 -0
  16. package/extracted-skill/tracking-events-generator/route-intent-capture.js +222 -0
  17. package/package.json +9 -5
  18. package/server-edge-tracker/INSTALAR.md +5 -5
  19. package/server-edge-tracker/{index.js → index.ts} +186 -72
  20. package/server-edge-tracker/modules/{db.js → db.ts} +180 -69
  21. package/server-edge-tracker/modules/dispatch/{ga4.js → ga4.ts} +12 -10
  22. package/server-edge-tracker/modules/dispatch/meta.ts +138 -0
  23. package/server-edge-tracker/modules/dispatch/{platforms.js → platforms.ts} +58 -56
  24. package/server-edge-tracker/modules/dispatch/{tiktok.js → tiktok.ts} +22 -20
  25. package/server-edge-tracker/modules/dispatch/{whatsapp.js → whatsapp.ts} +59 -25
  26. package/server-edge-tracker/modules/{intelligence.js → intelligence.ts} +175 -60
  27. package/server-edge-tracker/modules/ml/{bidding.js → bidding.ts} +37 -35
  28. package/server-edge-tracker/modules/ml/{fraud.js → fraud.ts} +49 -56
  29. package/server-edge-tracker/modules/ml/{logistic.js → logistic.ts} +44 -19
  30. package/server-edge-tracker/modules/ml/{ltv.js → ltv.ts} +179 -83
  31. package/server-edge-tracker/modules/ml/{matchquality.js → matchquality.ts} +70 -26
  32. package/server-edge-tracker/modules/ml/segmentation.ts +407 -0
  33. package/server-edge-tracker/modules/utils.ts +186 -0
  34. package/server-edge-tracker/schema-ltv-feedback.sql +11 -0
  35. package/server-edge-tracker/types.ts +251 -0
  36. package/server-edge-tracker/wrangler.toml +24 -6
  37. package/templates/lancamento-imobiliario.md +344 -0
  38. package/docs/PixelBuilder-Documentacao-Completa (2).docx +0 -0
  39. package/server-edge-tracker/modules/dispatch/meta.js +0 -119
  40. package/server-edge-tracker/modules/ml/segmentation.js +0 -316
  41. package/server-edge-tracker/modules/utils.js +0 -89
  42. package/server-edge-tracker/worker.js +0 -4577
@@ -1,4577 +0,0 @@
1
- /**
2
- * CDP Edge Server — server-edge-tracker.seu-usuario.workers.dev
3
- * Meta CAPI v22.0 + GA4 Measurement Protocol + TikTok Events API + D1
4
- * Cloudflare Workers (plano gratuito)
5
- *
6
- * Endpoints:
7
- * POST /track → evento do browser (PageView, Lead, Purchase…)
8
- * POST /webhook/ticto → compra confirmada pela Ticto (v2 JSON)
9
- * GET /health → status do Worker
10
- *
11
- * Secrets (configurar via: wrangler secret put NOME):
12
- * META_ACCESS_TOKEN → token da Conversions API Meta
13
- * GA4_API_SECRET → secret do Measurement Protocol (GA4 → Admin → Data Streams → API secrets)
14
- * TIKTOK_ACCESS_TOKEN → token da TikTok Events API (TikTok for Business → Assets → Events → Web Events → Manage → API)
15
- * META_TEST_CODE → só em testes (ex: TEST12345) — remover em produção
16
- */
17
-
18
- // ── Constantes ────────────────────────────────────────────────────────────────
19
- // IDs de pixel lidos exclusivamente de env.* (wrangler.toml [vars] ou wrangler secret put)
20
- // CORS — aceita o domínio raiz e qualquer subdomínio (domínio vem de env.SITE_DOMAIN)
21
- function isAllowedOrigin(origin, siteDomain) {
22
- if (!origin || !siteDomain) return false;
23
- return origin === `https://${siteDomain}`
24
- || origin.endsWith(`.${siteDomain}`)
25
- || origin === 'http://localhost:3000'
26
- || origin === 'http://localhost:5173';
27
- }
28
-
29
- // ── SHA-256 via WebCrypto (obrigatório no Cloudflare Workers) ─────────────────
30
- async function sha256(value) {
31
- if (!value) return undefined;
32
- const clean = String(value).toLowerCase().trim();
33
- if (!clean) return undefined;
34
- const buf = await crypto.subtle.digest(
35
- 'SHA-256',
36
- new TextEncoder().encode(clean)
37
- );
38
- return Array.from(new Uint8Array(buf))
39
- .map(b => b.toString(16).padStart(2, '0'))
40
- .join('');
41
- }
42
-
43
- // ── Normalização de telefone → somente dígitos + DDI 55 ──────────────────────
44
- function normalizePhone(phone) {
45
- if (!phone) return undefined;
46
- let digits = String(phone).replace(/\D/g, '');
47
- if (digits.length === 11 && !digits.startsWith('55')) digits = '55' + digits;
48
- if (digits.length === 10 && !digits.startsWith('55')) digits = '55' + digits;
49
- return digits.length >= 10 ? digits : undefined;
50
- }
51
-
52
- // ── Normalização de cidade → lowercase sem acentos ────────────────────────────
53
- function normalizeCity(city) {
54
- if (!city) return undefined;
55
- return String(city)
56
- .toLowerCase()
57
- .normalize('NFD')
58
- .replace(/[\u0300-\u036f]/g, '')
59
- .replace(/[^a-z0-9]/g, '');
60
- }
61
-
62
- // ── CORS ──────────────────────────────────────────────────────────────────────
63
- function corsHeaders(origin, siteDomain) {
64
- const allowed = isAllowedOrigin(origin, siteDomain) ? origin : `https://${siteDomain}`;
65
- return {
66
- 'Access-Control-Allow-Origin': allowed,
67
- 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
68
- 'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With',
69
- 'Access-Control-Max-Age': '86400',
70
- };
71
- }
72
-
73
- // ─────────────────────────────────────────────────────────────────────────────
74
- // LOGISTIC REGRESSION — LTV Model (pure JS, sem deps externas)
75
- // ─────────────────────────────────────────────────────────────────────────────
76
-
77
- const _UTM_SCORES = {
78
- facebook: 0.90, instagram: 0.90, meta: 0.90,
79
- google: 0.82, youtube: 0.82, tiktok: 0.75,
80
- email: 0.68, sms: 0.68, organic: 0.30, direct: 0.20,
81
- };
82
- const _INTENTION_SCORES = {
83
- comprador: 1.00, high_intent: 1.00, interessado: 0.60, nurture: 0.30, curioso: 0.15,
84
- };
85
-
86
- function _extractFeatures(row) {
87
- const src = (row.utm_source || '').toLowerCase().trim();
88
- const intention = (row.intention_level || '').toLowerCase().trim();
89
- const daysSince = row.days_since_lead || 0;
90
- return [
91
- _UTM_SCORES[src] ?? (src ? 0.10 : 0.05),
92
- Math.min((row.engagement_score || 0) / 5, 1),
93
- _INTENTION_SCORES[intention] ?? 0,
94
- Math.max(0, 1 - daysSince / 90),
95
- row.has_email ? 1 : 0,
96
- row.has_phone ? 1 : 0,
97
- row.is_br ? 1 : 0,
98
- ((row.hour || 12) / 23),
99
- ];
100
- }
101
-
102
- function _sigmoid(z) {
103
- if (z > 20) return 1;
104
- if (z < -20) return 0;
105
- return 1 / (1 + Math.exp(-z));
106
- }
107
-
108
- function _dot(weights, features) {
109
- return features.reduce((sum, f, i) => sum + (weights[i] || 0) * f, 0);
110
- }
111
-
112
- function _trainLogisticRegression(dataset, opts = {}) {
113
- if (!dataset || dataset.length < 50) return null;
114
- const iterations = opts.iterations || 200;
115
- const learningRate = opts.learningRate || 0.1;
116
- const lambda = opts.lambda || 0.01;
117
- const nFeatures = dataset[0].features.length;
118
- let bias = 0;
119
- let weights = new Array(nFeatures).fill(0);
120
- const positives = dataset.filter(d => d.label === 1).length;
121
- const positiveRate = positives / dataset.length;
122
- if (positiveRate < 0.03) return null;
123
- for (let iter = 0; iter < iterations; iter++) {
124
- let dBias = 0;
125
- const dWeights = new Array(nFeatures).fill(0);
126
- for (const { features, label } of dataset) {
127
- const error = _sigmoid(_dot(weights, features) + bias) - label;
128
- dBias += error;
129
- for (let j = 0; j < nFeatures; j++) dWeights[j] += error * features[j];
130
- }
131
- const n = dataset.length;
132
- bias -= learningRate * (dBias / n);
133
- for (let j = 0; j < nFeatures; j++) {
134
- weights[j] -= learningRate * ((dWeights[j] / n) + lambda * weights[j]);
135
- }
136
- }
137
- let correct = 0;
138
- const threshold = positiveRate > 0.3 ? 0.5 : Math.max(0.3, positiveRate * 1.5);
139
- for (const { features, label } of dataset) {
140
- if ((_sigmoid(_dot(weights, features) + bias) >= threshold ? 1 : 0) === label) correct++;
141
- }
142
- return { bias, weights, accuracy: correct / dataset.length, positiveRate, sampleSize: dataset.length, threshold, trainedAt: new Date().toISOString() };
143
- }
144
-
145
- function _predictWithWeights(model, features) {
146
- return Math.round(_sigmoid(_dot(model.weights, features) + model.bias) * 100);
147
- }
148
-
149
- const _LTV_WEIGHTS_KV_KEY = 'ltv_weights_active';
150
-
151
- async function _loadActiveWeights(env) {
152
- if (env.GEO_CACHE) {
153
- try {
154
- const cached = await env.GEO_CACHE.get(_LTV_WEIGHTS_KV_KEY, 'json');
155
- if (cached?.weights?.length) return cached;
156
- } catch {}
157
- }
158
- if (!env.DB) return null;
159
- try {
160
- const row = await env.DB.prepare(
161
- `SELECT weights_json FROM ltv_model_weights WHERE is_active = 1 ORDER BY trained_at DESC LIMIT 1`
162
- ).first();
163
- if (!row?.weights_json) return null;
164
- const model = JSON.parse(row.weights_json);
165
- if (env.GEO_CACHE && model?.weights?.length) {
166
- env.GEO_CACHE.put(_LTV_WEIGHTS_KV_KEY, JSON.stringify(model), { expirationTtl: 604800 }).catch(() => {});
167
- }
168
- return model;
169
- } catch { return null; }
170
- }
171
-
172
- async function _saveWeights(DB, model) {
173
- if (!DB || !model) return;
174
- await DB.prepare(`UPDATE ltv_model_weights SET is_active = 0 WHERE is_active = 1`).run();
175
- await DB.prepare(`
176
- INSERT INTO ltv_model_weights (trained_at, is_active, sample_size, positive_rate, accuracy, weights_json)
177
- VALUES (?, 1, ?, ?, ?, ?)
178
- `).bind(new Date().toISOString(), model.sampleSize, model.positiveRate, model.accuracy, JSON.stringify(model)).run();
179
- }
180
-
181
- // ─────────────────────────────────────────────────────────────────────────────
182
- // MATCH QUALITY — Tracking de qualidade dos dados enviados ao Meta CAPI
183
- // ─────────────────────────────────────────────────────────────────────────────
184
-
185
- const _MQ_THRESHOLDS = {
186
- email_rate_min: 0.40, fbp_rate_min: 0.30, composite_min: 0.45, min_events_alert: 10,
187
- };
188
-
189
- async function _logMatchQuality(DB, eventName, payload, recovered = {}) {
190
- if (!DB) return;
191
- try {
192
- await DB.prepare(`
193
- INSERT INTO match_quality_log (
194
- event_name, has_email, has_phone, has_fbp, has_fbc, has_external_id,
195
- was_email_recovered, was_utm_restored
196
- ) VALUES (?,?,?,?,?,?,?,?)
197
- `).bind(
198
- eventName,
199
- payload.email ? 1 : 0, payload.phone ? 1 : 0,
200
- payload.fbp ? 1 : 0, payload.fbc ? 1 : 0,
201
- payload.userId ? 1 : 0,
202
- recovered.email ? 1 : 0, recovered.utm ? 1 : 0,
203
- ).run();
204
- } catch { /* não bloquear dispatch */ }
205
- }
206
-
207
- async function _autoEnrichPayload(env, payload) {
208
- const recovered = { email: false, utm: false };
209
- if (!env.DB) return { payload, recovered };
210
- if (!payload.email && payload.userId) {
211
- try {
212
- const profile = await env.DB.prepare(
213
- `SELECT email, fbp, fbc, phone FROM user_profiles WHERE user_id = ? LIMIT 1`
214
- ).bind(payload.userId).first();
215
- if (profile) {
216
- if (profile.email && !payload.email) { payload.email = profile.email; recovered.email = true; }
217
- if (profile.fbp && !payload.fbp) payload.fbp = profile.fbp;
218
- if (profile.fbc && !payload.fbc) payload.fbc = profile.fbc;
219
- if (profile.phone && !payload.phone) payload.phone = profile.phone;
220
- }
221
- } catch {}
222
- }
223
- if (payload.utmRestored) recovered.utm = true;
224
- return { payload, recovered };
225
- }
226
-
227
- async function _analyzeMatchQuality(env) {
228
- if (!env.DB) return null;
229
- try {
230
- const row = await env.DB.prepare(`
231
- SELECT
232
- COUNT(*) AS total,
233
- ROUND(AVG(has_email) * 100, 1) AS email_rate,
234
- ROUND(AVG(has_phone) * 100, 1) AS phone_rate,
235
- ROUND(AVG(has_fbp) * 100, 1) AS fbp_rate,
236
- ROUND(AVG(has_fbc) * 100, 1) AS fbc_rate,
237
- ROUND(AVG(has_external_id) * 100, 1) AS ext_id_rate,
238
- ROUND(AVG(was_email_recovered) * 100, 1) AS email_recovered_rate,
239
- ROUND((AVG(has_email)*0.4 + AVG(has_fbp)*0.3 + AVG(has_phone)*0.2 + AVG(has_fbc)*0.1) * 100, 1) AS composite_score
240
- FROM match_quality_log
241
- WHERE logged_at >= datetime('now', '-2 hours')
242
- `).first();
243
- if (!row || row.total < _MQ_THRESHOLDS.min_events_alert) return { total: row?.total || 0, alerts: [] };
244
- const alerts = [];
245
- if ((row.email_rate || 0) < _MQ_THRESHOLDS.email_rate_min * 100)
246
- alerts.push({ type: 'email_low', message: `Taxa de email baixa: ${row.email_rate}%` });
247
- if ((row.fbp_rate || 0) < _MQ_THRESHOLDS.fbp_rate_min * 100)
248
- alerts.push({ type: 'fbp_low', message: `Cookie fbp ausente em ${100 - row.fbp_rate}% dos eventos` });
249
- if ((row.composite_score || 0) < _MQ_THRESHOLDS.composite_min * 100)
250
- alerts.push({ type: 'composite_critical', message: `Score composto crítico: ${row.composite_score}%`, severity: 'critical' });
251
- return { ...row, alerts };
252
- } catch (err) { console.error('[MatchQuality] analyze error:', err.message); return null; }
253
- }
254
-
255
- async function _alertMatchQuality(env, analysis) {
256
- if (!analysis || analysis.alerts.length === 0) return;
257
- const hasCritical = analysis.alerts.some(a => a.severity === 'critical');
258
- const icon = hasCritical ? '🚨' : '⚠️';
259
- const lines = [
260
- `${icon} CDP Edge — Match Quality Alert`,
261
- ``,
262
- `📊 Últimas 2h (${analysis.total} eventos):`,
263
- ` Email: ${analysis.email_rate ?? 0}% ${(analysis.email_rate ?? 0) < 40 ? '❌' : '✅'}`,
264
- ` fbp: ${analysis.fbp_rate ?? 0}% ${(analysis.fbp_rate ?? 0) < 30 ? '❌' : '✅'}`,
265
- ` Score: ${analysis.composite_score ?? 0}%`,
266
- ``,
267
- `🔍 Problemas:`,
268
- ...analysis.alerts.map(a => ` · ${a.message}`),
269
- ``,
270
- `🛠 Identity Graph recovery: ${analysis.email_recovered_rate ?? 0}% emails recuperados`,
271
- new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' }),
272
- ];
273
- await sendCallMeBot(env, lines.join('\n'));
274
- }
275
-
276
- async function _purgeOldMatchQualityLogs(DB) {
277
- if (!DB) return;
278
- try {
279
- await DB.prepare(`DELETE FROM match_quality_log WHERE logged_at < datetime('now', '-30 days')`).run();
280
- } catch {}
281
- }
282
-
283
- // ── Meta CAPI v22.0 ───────────────────────────────────────────────────────────
284
- async function sendMetaCapi(env, eventName, payload, request, ctx) {
285
- // Auto-enriquecer payload com dados do Identity Graph antes do envio
286
- let recovered = { email: false, utm: false };
287
- if (env.DB && payload) {
288
- const enriched = await _autoEnrichPayload(env, payload);
289
- payload = enriched.payload;
290
- recovered = enriched.recovered;
291
- }
292
-
293
- const {
294
- email, phone, firstName, lastName,
295
- city, state, country,
296
- zip, dob,
297
- fbp, fbc, userId,
298
- eventId, pageUrl,
299
- value, currency,
300
- contentIds, contentName, contentType, numItems,
301
- } = payload;
302
-
303
- const phoneNorm = normalizePhone(phone);
304
- const countryCode = (country || request.cf?.country || 'br').toLowerCase();
305
- const stateCode = state ? String(state).toLowerCase() : undefined;
306
- const cityNorm = normalizeCity(city);
307
-
308
- // user_data — hashear tudo com SHA-256 antes de enviar
309
- const userData = {
310
- ...(email && { em: await sha256(email) }),
311
- ...(phoneNorm && { ph: await sha256(phoneNorm) }),
312
- ...(firstName && { fn: await sha256(firstName) }),
313
- ...(lastName && { ln: await sha256(lastName) }),
314
- ...(cityNorm && { ct: await sha256(cityNorm) }),
315
- ...(stateCode && { st: await sha256(stateCode) }),
316
- ...(countryCode && { country: await sha256(countryCode) }),
317
- ...(userId && { external_id: await sha256(String(userId)) }),
318
- ...(zip && { zp: await sha256(zip) }),
319
- ...(dob && { db: await sha256(dob) }),
320
- ...(fbp && { fbp }), // cookies NÃO são hasheados
321
- ...(fbc && { fbc }),
322
- client_ip_address: request.headers.get('CF-Connecting-IP')
323
- || request.headers.get('X-Forwarded-For')
324
- || '',
325
- client_user_agent: request.headers.get('User-Agent') || '',
326
- };
327
-
328
- // custom_data — dados do produto/valor
329
- const customData = {
330
- ...(value !== undefined && { value: parseFloat(value) }),
331
- ...(currency && { currency: String(currency).toUpperCase() }),
332
- ...(contentIds && contentIds.length > 0 && { content_ids: contentIds }),
333
- ...(contentName && { content_name: contentName }),
334
- ...(contentType && { content_type: contentType }),
335
- ...(numItems && { num_items: parseInt(numItems) }),
336
- };
337
-
338
- const eventPayload = {
339
- event_name: eventName,
340
- event_time: Math.floor(Date.now() / 1000),
341
- event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
342
- event_source_url: pageUrl || `https://${env.SITE_DOMAIN}`,
343
- action_source: 'website',
344
- user_data: userData,
345
- ...(Object.keys(customData).length > 0 && { custom_data: customData }),
346
- };
347
-
348
- const requestBody = {
349
- data: [eventPayload],
350
- access_token: env.META_ACCESS_TOKEN,
351
- };
352
-
353
- // Test Event Code — só em staging, remover em produção
354
- if (env.META_TEST_CODE) {
355
- requestBody.test_event_code = env.META_TEST_CODE;
356
- }
357
-
358
- // Logar match quality em background (não bloqueia dispatch)
359
- if (env.DB && ctx) {
360
- ctx.waitUntil(_logMatchQuality(env.DB, eventName, payload, recovered));
361
- } else if (env.DB) {
362
- _logMatchQuality(env.DB, eventName, payload, recovered).catch(() => {});
363
- }
364
-
365
- const endpoint = `https://graph.facebook.com/v22.0/${env.META_PIXEL_ID}/events`;
366
-
367
- try {
368
- const res = await fetch(endpoint, {
369
- method: 'POST',
370
- headers: { 'Content-Type': 'application/json' },
371
- body: JSON.stringify(requestBody),
372
- });
373
-
374
- const data = await res.json();
375
-
376
- if (!res.ok) {
377
- const errorCode = data.error?.code || String(res.status);
378
- const errorMessage = data.error?.message || data.error?.error_user_msg || 'Unknown error';
379
- console.error('Meta CAPI error:', res.status, data.error?.message || data.error?.error_user_msg || 'unknown');
380
-
381
- // Log de falha para Feedback Loop
382
- if (env.DB) {
383
- ctx.waitUntil(logApiFailure(
384
- env.DB,
385
- 'meta',
386
- eventName,
387
- errorCode,
388
- errorMessage,
389
- eventPayload.event_id,
390
- JSON.stringify(requestBody)
391
- ));
392
- }
393
- }
394
-
395
- return data;
396
- } catch (err) {
397
- console.error('Meta CAPI fetch failed:', err.message);
398
-
399
- // Log de falha para Feedback Loop
400
- if (env.DB) {
401
- ctx.waitUntil(logApiFailure(
402
- env.DB,
403
- 'meta',
404
- eventName,
405
- 'FETCH_ERROR',
406
- err.message,
407
- eventPayload.event_id,
408
- JSON.stringify(requestBody)
409
- ));
410
- }
411
-
412
- return { error: err.message };
413
- }
414
- }
415
-
416
- // ── GA4 Measurement Protocol ──────────────────────────────────────────────────
417
- async function sendGA4Mp(env, ga4EventName, payload, ctx) {
418
- if (!env.GA4_API_SECRET) return { skipped: 'GA4_API_SECRET not set' };
419
-
420
- const {
421
- clientId, sessionId,
422
- value, currency, contentName,
423
- email, phone, firstName,
424
- orderId,
425
- } = payload;
426
-
427
- // GA4 MP exige client_id (cookie _ga)
428
- if (!clientId) return { skipped: 'no clientId' };
429
-
430
- const eventParams = {
431
- ...(value !== undefined && { value: parseFloat(value) }),
432
- ...(currency && { currency: String(currency).toUpperCase() }),
433
- ...(contentName && { content_name: contentName }),
434
- ...(orderId && { transaction_id: orderId }),
435
- ...(email && { user_data_email_address: email.toLowerCase().trim() }),
436
- ...(phone && { user_data_phone_number: normalizePhone(phone) }),
437
- ...(firstName && { user_data_first_name: firstName.toLowerCase().trim() }),
438
- ...(sessionId && { session_id: sessionId }),
439
- engagement_time_msec: 100,
440
- };
441
-
442
- const body = {
443
- client_id: clientId,
444
- events: [{ name: ga4EventName, params: eventParams }],
445
- };
446
-
447
- const url = `https://www.google-analytics.com/mp/collect`
448
- + `?measurement_id=${env.GA4_MEASUREMENT_ID}`
449
- + `&api_secret=${env.GA4_API_SECRET}`;
450
-
451
- try {
452
- const res = await fetch(url, {
453
- method: 'POST',
454
- headers: { 'Content-Type': 'application/json' },
455
- body: JSON.stringify(body),
456
- });
457
-
458
- // GA4 MP retorna 204 em sucesso (sem body)
459
- if (res.status !== 204) {
460
- // Log de falha para Feedback Loop
461
- if (env.DB && ctx) {
462
- ctx.waitUntil(logApiFailure(
463
- env.DB,
464
- 'ga4',
465
- ga4EventName,
466
- String(res.status),
467
- 'GA4 returned non-204 status',
468
- null,
469
- JSON.stringify(body)
470
- ));
471
- }
472
- }
473
-
474
- return res.status === 204 ? { ok: true } : { status: res.status };
475
- } catch (err) {
476
- console.error('GA4 MP fetch failed:', err.message);
477
-
478
- // Log de falha para Feedback Loop
479
- if (env.DB && ctx) {
480
- ctx.waitUntil(logApiFailure(
481
- env.DB,
482
- 'ga4',
483
- ga4EventName,
484
- 'FETCH_ERROR',
485
- err.message,
486
- null,
487
- JSON.stringify(body)
488
- ));
489
- }
490
-
491
- return { error: err.message };
492
- }
493
- }
494
-
495
- // ── D1 — salvar lead ──────────────────────────────────────────────────────────
496
- async function saveLead(env, eventName, payload, request, platform = 'website') {
497
- if (!env.DB) return;
498
- try {
499
- const {
500
- email, phone, firstName, lastName,
501
- city, state, country,
502
- fbp, fbc, userId,
503
- utmSource, utmMedium, utmCampaign, utmContent, utmTerm,
504
- pageUrl, value, currency, eventId, botScore,
505
- engagementScore, intentionLevel, utmRestored,
506
- } = payload;
507
-
508
- await env.DB.prepare(`
509
- INSERT INTO leads (
510
- event_name, event_id, email, phone, first_name, last_name,
511
- city, state, country, fbp, fbc, user_id,
512
- utm_source, utm_medium, utm_campaign, utm_content, utm_term,
513
- page_url, value, currency, ip_address, platform, bot_score,
514
- engagement_score, intention_level, utm_restored, created_at
515
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
516
- `).bind(
517
- eventName,
518
- eventId || null,
519
- email || null,
520
- normalizePhone(phone) || null,
521
- firstName || null,
522
- lastName || null,
523
- city || null,
524
- state || null,
525
- (country || request.cf?.country || null),
526
- fbp || null,
527
- fbc || null,
528
- userId || null,
529
- utmSource || null,
530
- utmMedium || null,
531
- utmCampaign || null,
532
- utmContent || null,
533
- utmTerm || null,
534
- pageUrl || null,
535
- value !== undefined ? parseFloat(value) : null,
536
- currency || 'BRL',
537
- request.headers.get('CF-Connecting-IP') || null,
538
- platform,
539
- botScore || 0,
540
- engagementScore !== undefined ? parseFloat(engagementScore) : null,
541
- intentionLevel || null,
542
- utmRestored ? 1 : 0,
543
- ).run();
544
- } catch (err) {
545
- console.error('D1 saveLead error:', err.message);
546
- }
547
- }
548
-
549
- // ── D1 — upsert perfil (acumula cookies entre visitas) ───────────────────────
550
- // ── Cálculo de Cohort Label baseado no score acumulado ───────────────────────
551
- function calculateCohortLabel(score, eventName) {
552
- if (eventName === 'Purchase') return 'buyer_lookalike';
553
- if (score >= 80) return 'high_intent';
554
- if (score >= 30) return 'nurture';
555
- return 'lost';
556
- }
557
-
558
- async function upsertProfile(env, eventName, payload, request) {
559
- if (!env.DB || !payload.userId) return;
560
- try {
561
- const {
562
- userId, email, phone,
563
- fbp, fbc, ttp, gclid, ttclid, gaClientId,
564
- city, state, country,
565
- engagementScore, userScore,
566
- } = payload;
567
-
568
- // Score base por evento + bônus de engajamento do browser
569
- const scoreMap = { PageView: 5, ViewContent: 10, ScrollDepth: 3, TimeOnPage: 5, Lead: 30, InitiateCheckout: 50, Purchase: 100 };
570
- const eventScore = scoreMap[eventName] || 2;
571
-
572
- // userScore vem do BehaviorEngine (0-100), engagementScore do engagement-scoring.js (0-5)
573
- // Normaliza ambos para uma escala de bônus adicional (0-20)
574
- const behaviorBonus = userScore
575
- ? Math.round((Math.min(userScore, 100) / 100) * 20)
576
- : (engagementScore ? Math.round((Math.min(engagementScore, 5) / 5) * 10) : 0);
577
-
578
- const totalDelta = eventScore + behaviorBonus;
579
-
580
- await env.DB.prepare(`
581
- INSERT INTO user_profiles
582
- (user_id, email, phone, fbp, fbc, ttp, gclid, ttclid, ga_client_id,
583
- city, state, country, score, cohort_label, created_at, updated_at)
584
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'),datetime('now'))
585
- ON CONFLICT(user_id) DO UPDATE SET
586
- email = COALESCE(excluded.email, user_profiles.email),
587
- phone = COALESCE(excluded.phone, user_profiles.phone),
588
- fbp = COALESCE(excluded.fbp, user_profiles.fbp),
589
- fbc = COALESCE(excluded.fbc, user_profiles.fbc),
590
- ttp = COALESCE(excluded.ttp, user_profiles.ttp),
591
- gclid = COALESCE(excluded.gclid, user_profiles.gclid),
592
- ttclid = COALESCE(excluded.ttclid, user_profiles.ttclid),
593
- ga_client_id = COALESCE(excluded.ga_client_id, user_profiles.ga_client_id),
594
- city = COALESCE(excluded.city, user_profiles.city),
595
- state = COALESCE(excluded.state, user_profiles.state),
596
- country = COALESCE(excluded.country, user_profiles.country),
597
- score = user_profiles.score + excluded.score,
598
- cohort_label = excluded.cohort_label,
599
- updated_at = datetime('now')
600
- `).bind(
601
- userId,
602
- email || null,
603
- normalizePhone(phone) || null,
604
- fbp || null,
605
- fbc || null,
606
- ttp || null,
607
- gclid || null,
608
- ttclid || null,
609
- gaClientId || null,
610
- city || null,
611
- state || null,
612
- (country || request.cf?.country || null),
613
- totalDelta,
614
- calculateCohortLabel(totalDelta, eventName),
615
- ).run();
616
- } catch (err) {
617
- console.error('D1 upsertProfile error:', err.message);
618
- }
619
- }
620
-
621
- // ── D1 — Cross-Device Graph (matching probabilístico) ────────────────────────
622
- // Quando email ou phone aparecem em outro _cdp_uid, registra o par no device_graph.
623
- // Não mescla dados — preserva ambos os perfis, apenas cria o link de identidade.
624
- // O primary_user_id é o perfil mais antigo (mais confiável historicamente).
625
- async function resolveDeviceGraph(DB, currentUserId, email, phone) {
626
- if (!DB || !currentUserId) return;
627
- if (!email && !phone) return;
628
-
629
- try {
630
- // Busca perfis com mesmo email OU mesmo phone mas _cdp_uid diferente
631
- const conditions = [];
632
- const bindings = [];
633
-
634
- if (email) {
635
- conditions.push('email = ?');
636
- bindings.push(email.toLowerCase().trim());
637
- }
638
- if (phone) {
639
- const digits = String(phone).replace(/\D/g, '');
640
- if (digits.length >= 10) {
641
- conditions.push('phone LIKE ?');
642
- bindings.push(`%${digits.slice(-10)}`); // sufixo dos últimos 10 dígitos
643
- }
644
- }
645
-
646
- if (conditions.length === 0) return;
647
-
648
- bindings.push(currentUserId);
649
- const rows = await DB.prepare(`
650
- SELECT user_id, email, phone, created_at
651
- FROM user_profiles
652
- WHERE (${conditions.join(' OR ')})
653
- AND user_id != ?
654
- ORDER BY created_at ASC
655
- LIMIT 5
656
- `).bind(...bindings).all();
657
-
658
- if (!rows.results || rows.results.length === 0) return;
659
-
660
- for (const match of rows.results) {
661
- // Determinar tipo de match e confiança
662
- const emailMatch = email && match.email &&
663
- email.toLowerCase().trim() === match.email.toLowerCase().trim();
664
- const phoneMatch = phone && match.phone && (() => {
665
- const a = String(phone).replace(/\D/g, '');
666
- const b = String(match.phone).replace(/\D/g, '');
667
- return a.slice(-10) === b.slice(-10) && a.length >= 10;
668
- })();
669
-
670
- if (!emailMatch && !phoneMatch) continue;
671
-
672
- const matchType = emailMatch && phoneMatch ? 'email+phone' : (emailMatch ? 'email' : 'phone');
673
- const matchConfidence = emailMatch && phoneMatch ? 0.99 : (emailMatch ? 0.95 : 0.85);
674
-
675
- // primary = mais antigo (âncora de identidade)
676
- const primary = match.user_id; // veio da query ORDER BY created_at ASC
677
- const secondary = currentUserId;
678
-
679
- await DB.prepare(`
680
- INSERT OR IGNORE INTO device_graph
681
- (primary_user_id, secondary_user_id, match_type, match_confidence)
682
- VALUES (?, ?, ?, ?)
683
- `).bind(primary, secondary, matchType, matchConfidence).run();
684
-
685
- }
686
- } catch (err) {
687
- console.error('resolveDeviceGraph error:', err.message);
688
- }
689
- }
690
-
691
- // ── Automação de Mensagens — avalia regras e dispara WA/Email ────────────────
692
- // Chamado via ctx.waitUntil após saveLead() para não bloquear o browser.
693
- // Requer secrets: WHATSAPP_ACCESS_TOKEN, WHATSAPP_PHONE_NUMBER_ID, RESEND_API_KEY, RESEND_FROM_EMAIL
694
- async function fireAutomation(env, eventName, leadId, payload) {
695
- if (!env.DB) return;
696
-
697
- try {
698
- const { results: rules } = await env.DB
699
- .prepare(
700
- `SELECT id, channel, subject_template, message_template
701
- FROM automation_rules
702
- WHERE trigger_event = ?1 AND is_active = 1`
703
- )
704
- .bind(eventName)
705
- .all();
706
-
707
- if (!rules || rules.length === 0) return;
708
-
709
- const vars = {
710
- name: String(payload.firstName || payload.name || ''),
711
- email: String(payload.email || ''),
712
- phone: String(payload.phone || ''),
713
- campaign: String(payload.utm_campaign || payload.utmCampaign || ''),
714
- intention: String(payload.intentionLevel || payload.intention_level || ''),
715
- };
716
-
717
- const interpolate = (tpl) =>
718
- tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? '');
719
-
720
- for (const rule of rules) {
721
- const message = interpolate(rule.message_template);
722
- const subject = rule.subject_template ? interpolate(rule.subject_template) : null;
723
-
724
- try {
725
- if (rule.channel === 'whatsapp' && payload.phone && env.WHATSAPP_ACCESS_TOKEN && env.WHATSAPP_PHONE_NUMBER_ID) {
726
- const digits = String(payload.phone).replace(/\D/g, '');
727
- const e164 = digits.startsWith('55') ? `+${digits}` : `+55${digits}`;
728
- const waRes = await fetch(
729
- `https://graph.facebook.com/v22.0/${env.WHATSAPP_PHONE_NUMBER_ID}/messages`,
730
- {
731
- method: 'POST',
732
- headers: { 'Authorization': `Bearer ${env.WHATSAPP_ACCESS_TOKEN}`, 'Content-Type': 'application/json' },
733
- body: JSON.stringify({ messaging_product: 'whatsapp', recipient_type: 'individual', to: e164, type: 'text', text: { body: message } }),
734
- }
735
- );
736
- const waData = await waRes.json();
737
- const status = waRes.ok ? 'sent' : 'failed';
738
- const meta = waRes.ok ? (waData.messages?.[0]?.id ?? null) : JSON.stringify(waData);
739
- await env.DB.prepare(
740
- `INSERT INTO messaging_history (lead_id, channel, recipient, subject, content, status, meta) VALUES (?1,?2,?3,?4,?5,?6,?7)`
741
- ).bind(leadId, 'whatsapp', e164, null, message, status, meta).run();
742
-
743
- } else if (rule.channel === 'email' && payload.email && env.RESEND_API_KEY) {
744
- const resendRes = await fetch('https://api.resend.com/emails', {
745
- method: 'POST',
746
- headers: { 'Authorization': `Bearer ${env.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
747
- body: JSON.stringify({
748
- from: env.RESEND_FROM_EMAIL || 'noreply@cdp-edge.app',
749
- to: [payload.email],
750
- subject: subject || `Olá, ${vars.name || 'você'}!`,
751
- html: `<p>${message.replace(/\n/g, '<br>')}</p>`,
752
- }),
753
- });
754
- const resendData = await resendRes.json();
755
- const status = resendRes.ok ? 'sent' : 'failed';
756
- const meta = resendRes.ok ? (resendData.id ?? null) : JSON.stringify(resendData);
757
- await env.DB.prepare(
758
- `INSERT INTO messaging_history (lead_id, channel, recipient, subject, content, status, meta) VALUES (?1,?2,?3,?4,?5,?6,?7)`
759
- ).bind(leadId, 'email', payload.email, subject, message, status, meta).run();
760
- }
761
- } catch (err) {
762
- console.error(`[Automation] rule ${rule.id} error:`, err.message);
763
- }
764
- }
765
- } catch (err) {
766
- console.error('[Automation] fireAutomation error:', err.message);
767
- }
768
- }
769
-
770
- // ── D1 — buscar perfil por email (para webhooks) ──────────────────────────────
771
- async function getProfileByEmail(env, email) {
772
- if (!env.DB || !email) return null;
773
- try {
774
- return await env.DB.prepare(
775
- 'SELECT * FROM user_profiles WHERE email = ? ORDER BY updated_at DESC LIMIT 1'
776
- ).bind(email.toLowerCase().trim()).first();
777
- } catch {
778
- return null;
779
- }
780
- }
781
-
782
- // ─────────────────────────────────────────────────────────────────────────────
783
- // EDGE FINGERPRINT — UTM Resurrection
784
- // ─────────────────────────────────────────────────────────────────────────────
785
-
786
- // ── Edge Geo Enrichment — Workers Paid: cidade, estado, CEP, lat/lon ─────────
787
- // Free tier: cf.country, cf.continent, cf.asn (sempre disponíveis)
788
- // Workers Paid: cf.city, cf.region, cf.regionCode, cf.postalCode,
789
- // cf.latitude, cf.longitude, cf.timezone, cf.metroCode
790
- // Quando Workers Paid não está ativo, os campos Paid retornam undefined
791
- // e o payload é enriquecido apenas com os dados Free disponíveis.
792
- // Ao contratar Workers Paid ($5/mês) os dados passam a chegar automaticamente.
793
- async function enrichGeoFromEdge(request, env, payload) {
794
- const cf = request.cf || {};
795
- const ip = request.headers.get('CF-Connecting-IP') || '';
796
-
797
- // ── Tentar cache KV (TTL 1h) — evita lookup redundante por IP ────────────
798
- let geoData = null;
799
- if (env.GEO_CACHE && ip) {
800
- try {
801
- const cached = await env.GEO_CACHE.get(`geo:${ip}`, 'json');
802
- if (cached) geoData = cached;
803
- } catch {}
804
- }
805
-
806
- if (!geoData) {
807
- geoData = {
808
- // ── Free tier (sempre disponível) ──────────────────────────────────────
809
- country: cf.country || null, // 'BR', 'US'
810
- continent: cf.continent || null, // 'SA', 'NA'
811
- asn: cf.asn || null, // 7628
812
- asOrg: cf.asOrganization || null, // 'VIVO (Telefonica Brasil)'
813
- colo: cf.colo || null, // 'GRU' (datacenter mais próximo)
814
- // ── Workers Paid ($5/mês) ───────────────────────────────────────────────
815
- city: cf.city || null, // 'São Paulo'
816
- region: cf.region || null, // 'São Paulo' (nome do estado)
817
- regionCode: cf.regionCode || null, // 'SP'
818
- postalCode: cf.postalCode || null, // '01310-100'
819
- latitude: cf.latitude || null, // '-23.5505'
820
- longitude: cf.longitude || null, // '-46.6333'
821
- timezone: cf.timezone || null, // 'America/Sao_Paulo'
822
- metroCode: cf.metroCode || null, // código de área metropolitana
823
- };
824
-
825
- // Salvar no KV por 1h (geo de IP raramente muda)
826
- if (env.GEO_CACHE && ip && geoData.country) {
827
- try {
828
- await env.GEO_CACHE.put(`geo:${ip}`, JSON.stringify(geoData), { expirationTtl: 3600 });
829
- } catch {}
830
- }
831
- }
832
-
833
- // ── Enriquecer payload (edge tem prioridade menor que dados do browser) ───
834
- payload.country = payload.country || geoData.country;
835
- payload.city = payload.city || geoData.city;
836
- payload.state = payload.state || geoData.regionCode; // 'SP', 'RJ'
837
- payload.zip = payload.zip || geoData.postalCode;
838
-
839
- // Objeto completo disponível para agentes (Analytics, Attribution, LTV)
840
- payload.geo = geoData;
841
-
842
- return geoData;
843
- }
844
-
845
- // ── R2 Audit Log — grava evento consolidado no bucket cdp-edge-logs ──────────
846
- // Só executa quando env.AUDIT_LOGS estiver disponível (R2 habilitado no CF Dashboard)
847
- // Chamado via ctx.waitUntil — não bloqueia resposta ao browser
848
- async function writeAuditLog(env, eventName, payload, geoData) {
849
- if (!env.AUDIT_LOGS) return;
850
- try {
851
- const now = new Date();
852
- const y = now.getUTCFullYear();
853
- const m = String(now.getUTCMonth() + 1).padStart(2, '0');
854
- const d = String(now.getUTCDate()).padStart(2, '0');
855
- const key = `logs/${y}/${m}/${d}/${now.getTime()}_${eventName}.json`;
856
-
857
- const log = {
858
- timestamp: now.toISOString(),
859
- event: eventName,
860
- userId: payload.userId || null,
861
- eventId: payload.eventId || null,
862
- value: payload.value || null,
863
- currency: payload.currency || null,
864
- ltvClass: payload.ltvClass || null,
865
- utm: {
866
- source: payload.utmSource || null,
867
- medium: payload.utmMedium || null,
868
- campaign: payload.utmCampaign || null,
869
- content: payload.utmContent || null,
870
- term: payload.utmTerm || null,
871
- restored: payload.utmRestored || false,
872
- },
873
- geo: geoData || null,
874
- };
875
-
876
- await env.AUDIT_LOGS.put(key, JSON.stringify(log), {
877
- httpMetadata: { contentType: 'application/json' },
878
- });
879
- } catch (err) {
880
- console.error('[R2 Audit] Error:', err.message);
881
- }
882
- }
883
-
884
- // ── Gerar fingerprint a partir de signals de borda (sem PII) ─────────────────
885
- // Combina: ASN (rede do usuário) + Accept-Language + base do User-Agent
886
- // Efêmero por design: identifica o dispositivo/rede, não a pessoa.
887
- async function generateEdgeFingerprint(request) {
888
- const asn = String(request.cf?.asn || '0');
889
- const lang = (request.headers.get('Accept-Language') || 'unknown').split(',')[0].trim();
890
- const ua = request.headers.get('User-Agent') || '';
891
-
892
- // Base do UA: apenas plataforma + browser (sem versão — mais estável entre sessões)
893
- // Ex: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
894
- // → "windows applewebkit"
895
- const uaBase = ua
896
- .toLowerCase()
897
- .replace(/[\d.]+/g, '') // remove números de versão
898
- .replace(/[^a-z\s]/g, ' ') // remove caracteres especiais
899
- .split(' ')
900
- .filter(w => w.length > 3) // só palavras com >3 chars (remove ruído)
901
- .slice(0, 4) // pega até 4 tokens
902
- .join(' ')
903
- .trim();
904
-
905
- const raw = `${asn}|${lang}|${uaBase}`;
906
- return sha256(raw);
907
- }
908
-
909
- // ── Salvar fingerprint + UTM no D1 (quando UTM presente) ─────────────────────
910
- async function saveEdgeFingerprint(DB, fingerprint, userId, payload) {
911
- if (!DB || !fingerprint) return;
912
- const { utmSource, utmMedium, utmCampaign, utmContent, utmTerm } = payload;
913
- if (!utmSource) return; // só salva quando há UTM real
914
-
915
- try {
916
- await DB.prepare(`
917
- INSERT INTO edge_fingerprints (fingerprint, user_id, utm_source, utm_medium, utm_campaign, utm_content, utm_term)
918
- VALUES (?, ?, ?, ?, ?, ?, ?)
919
- `).bind(
920
- fingerprint,
921
- userId || null,
922
- utmSource || null,
923
- utmMedium || null,
924
- utmCampaign || null,
925
- utmContent || null,
926
- utmTerm || null,
927
- ).run();
928
- } catch (err) {
929
- console.error('saveEdgeFingerprint error:', err.message);
930
- }
931
- }
932
-
933
- // ── Recuperar UTM perdida por fingerprint (últimas 48h) ───────────────────────
934
- async function resurrectUTM(DB, fingerprint) {
935
- if (!DB || !fingerprint) return null;
936
- try {
937
- return await DB.prepare(`
938
- SELECT utm_source, utm_medium, utm_campaign, utm_content, utm_term
939
- FROM edge_fingerprints
940
- WHERE fingerprint = ?
941
- AND utm_source IS NOT NULL
942
- AND created_at > datetime('now', '-48 hours')
943
- ORDER BY created_at DESC
944
- LIMIT 1
945
- `).bind(fingerprint).first();
946
- } catch {
947
- return null;
948
- }
949
- }
950
-
951
- // ── TikTok Events API v1.3 ───────────────────────────────────────────────────
952
- async function sendTikTokApi(env, eventName, payload, request, ctx) {
953
- if (!env.TIKTOK_ACCESS_TOKEN) return { skipped: 'TIKTOK_ACCESS_TOKEN not set' };
954
-
955
- const pixelId = env.TIKTOK_PIXEL_ID || TIKTOK_PIXEL_ID;
956
- if (!pixelId || pixelId === 'SEU_TIKTOK_PIXEL_ID') return { skipped: 'TIKTOK_PIXEL_ID not configured' };
957
-
958
- const {
959
- email, phone, firstName, lastName,
960
- fbp, fbc, ttp, ttclid, userId,
961
- eventId, pageUrl,
962
- value, currency,
963
- contentIds, contentName, contentType,
964
- } = payload;
965
-
966
- const phoneNorm = normalizePhone(phone);
967
-
968
- // user — hashear com SHA-256
969
- const user = {
970
- ...(email && { email: await sha256(email) }),
971
- ...(phoneNorm && { phone_number: await sha256(phoneNorm) }),
972
- ...(userId && { external_id: await sha256(String(userId)) }),
973
- ...(ttp && { ttp }), // _ttp cookie — não hashear
974
- ...(ttclid && { ttclid }), // click ID — não hashear
975
- };
976
-
977
- // properties — dados do produto
978
- const properties = {
979
- ...(value !== undefined && { value: parseFloat(value) }),
980
- ...(currency && { currency: String(currency).toUpperCase() }),
981
- ...(contentIds && contentIds.length > 0 && {
982
- contents: contentIds.map(id => ({
983
- content_id: String(id),
984
- content_name: contentName || '',
985
- content_type: contentType || 'product',
986
- quantity: 1,
987
- price: value ? parseFloat(value) : 0,
988
- })),
989
- }),
990
- };
991
-
992
- const event = {
993
- event: eventName,
994
- event_time: Math.floor(Date.now() / 1000),
995
- event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
996
- user,
997
- page: {
998
- url: pageUrl || `https://${env.SITE_DOMAIN}`,
999
- referrer: request.headers.get('Referer') || '',
1000
- },
1001
- ...(Object.keys(properties).length > 0 && { properties }),
1002
- context: {
1003
- ip: request.headers.get('CF-Connecting-IP') || '',
1004
- user_agent: request.headers.get('User-Agent') || '',
1005
- },
1006
- };
1007
-
1008
- const body = {
1009
- event_source: 'web',
1010
- event_source_id: pixelId,
1011
- data: [event],
1012
- };
1013
-
1014
- // Endpoint obrigatório: sempre /v1.3/event/track/ (nunca /pixel/track/)
1015
- const endpoint = 'https://business-api.tiktok.com/open_api/v1.3/event/track/';
1016
-
1017
- try {
1018
- const res = await fetch(endpoint, {
1019
- method: 'POST',
1020
- headers: {
1021
- 'Content-Type': 'application/json',
1022
- 'Access-Token': env.TIKTOK_ACCESS_TOKEN,
1023
- },
1024
- body: JSON.stringify(body),
1025
- });
1026
-
1027
- const data = await res.json();
1028
- if (!res.ok || data.code !== 0) {
1029
- console.error('TikTok Events API error:', res.status, data.message || data.code || 'unknown');
1030
-
1031
- // Log de falha para Feedback Loop
1032
- if (env.DB && ctx) {
1033
- ctx.waitUntil(logApiFailure(
1034
- env.DB,
1035
- 'tiktok',
1036
- eventName,
1037
- String(data.code || res.status),
1038
- data.message || 'TikTok API error',
1039
- event.event_id,
1040
- JSON.stringify(body)
1041
- ));
1042
- }
1043
- }
1044
- return data;
1045
- } catch (err) {
1046
- console.error('TikTok Events API fetch failed:', err.message);
1047
-
1048
- // Log de falha para Feedback Loop
1049
- if (env.DB && ctx) {
1050
- ctx.waitUntil(logApiFailure(
1051
- env.DB,
1052
- 'tiktok',
1053
- eventName,
1054
- 'FETCH_ERROR',
1055
- err.message,
1056
- null,
1057
- JSON.stringify(body)
1058
- ));
1059
- }
1060
-
1061
- return { error: err.message };
1062
- }
1063
- }
1064
-
1065
-
1066
- // ── Pinterest — Conversions API v5 (server-side) — TEMPLATE (não ativo em prod)
1067
- // Para ativar: configurar PINTEREST_ACCESS_TOKEN e PINTEREST_AD_ACCOUNT_ID
1068
- // e descomentar a chamada no Promise.allSettled do /track
1069
- //
1070
- async function sendPinterestCapi(env, eventName, payload, request, ctx) {
1071
- if (!env.PINTEREST_ACCESS_TOKEN || !env.PINTEREST_AD_ACCOUNT_ID) {
1072
- return { skipped: 'Pinterest credentials not set' };
1073
- }
1074
-
1075
- const {
1076
- email, phone, userId,
1077
- eventId, pageUrl,
1078
- value, currency,
1079
- contentIds, contentName,
1080
- } = payload;
1081
-
1082
- const phoneNorm = normalizePhone(phone);
1083
-
1084
- const pinterestEventMap = {
1085
- PageView: 'pagevisit',
1086
- ViewContent: 'pagevisit',
1087
- Lead: 'lead',
1088
- Purchase: 'checkout',
1089
- AddToCart: 'addtocart',
1090
- InitiateCheckout: 'checkout',
1091
- CompleteRegistration: 'signup',
1092
- Search: 'search',
1093
- Contact: 'lead',
1094
- };
1095
- const pEvent = pinterestEventMap[eventName] || 'custom';
1096
-
1097
- const userData = {
1098
- ...(email && { em: [await sha256(email)] }),
1099
- ...(phoneNorm && { ph: [await sha256(phoneNorm)] }),
1100
- ...(userId && { external_id: [await sha256(String(userId))] }),
1101
- client_ip_address: request.headers.get('CF-Connecting-IP') || '',
1102
- client_user_agent: request.headers.get('User-Agent') || '',
1103
- };
1104
-
1105
- const body = {
1106
- data: [{
1107
- event_name: pEvent,
1108
- action_source: 'web',
1109
- event_time: Math.floor(Date.now() / 1000),
1110
- event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
1111
- event_source_url: pageUrl || '',
1112
- user_data: userData,
1113
- custom_data: {
1114
- currency: (currency || 'BRL').toUpperCase(),
1115
- value: value ? String(parseFloat(value)) : '0',
1116
- ...(contentIds?.length > 0 && { content_ids: contentIds.map(String) }),
1117
- ...(contentName && { content_name: contentName }),
1118
- content_type: 'product',
1119
- },
1120
- }],
1121
- };
1122
-
1123
- try {
1124
- const res = await fetch(
1125
- `https://api.pinterest.com/v5/ad_accounts/${env.PINTEREST_AD_ACCOUNT_ID}/events`,
1126
- {
1127
- method: 'POST',
1128
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.PINTEREST_ACCESS_TOKEN}` },
1129
- body: JSON.stringify(body),
1130
- }
1131
- );
1132
- const data = await res.json();
1133
- if (!res.ok) {
1134
- const pinterestErrMsg = data.message || data.code || String(res.status);
1135
- console.error('Pinterest CAPI error:', res.status, pinterestErrMsg);
1136
- if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'pinterest', eventName, String(res.status), pinterestErrMsg, body.data[0].event_id, JSON.stringify(body)));
1137
- }
1138
- return data;
1139
- } catch (err) {
1140
- console.error('Pinterest CAPI fetch failed:', err.message);
1141
- if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'pinterest', eventName, 'FETCH_ERROR', err.message, null, JSON.stringify(body)));
1142
- return { error: err.message };
1143
- }
1144
- }
1145
-
1146
-
1147
- // ── Reddit — Conversions API v2.0 (server-side) ───────────────────────────────
1148
- //
1149
- // Secrets necessários (wrangler secret put):
1150
- // REDDIT_ACCESS_TOKEN → Bearer token da Reddit Conversions API
1151
- // REDDIT_AD_ACCOUNT_ID → ID da conta de anúncios (ex: t2_XXXXXXX)
1152
- //
1153
- async function sendRedditCapi(env, eventName, payload, request, ctx) {
1154
- if (!env.REDDIT_ACCESS_TOKEN || !env.REDDIT_AD_ACCOUNT_ID) {
1155
- return { skipped: 'Reddit credentials not set' };
1156
- }
1157
-
1158
- const {
1159
- email, phone, userId,
1160
- eventId, pageUrl,
1161
- value, currency,
1162
- } = payload;
1163
-
1164
- const phoneNorm = normalizePhone(phone);
1165
-
1166
- const redditEventMap = {
1167
- PageView: 'PageVisit',
1168
- ViewContent: 'ViewContent',
1169
- Lead: 'Lead',
1170
- Purchase: 'Purchase',
1171
- AddToCart: 'AddToCart',
1172
- InitiateCheckout: 'Purchase',
1173
- CompleteRegistration: 'SignUp',
1174
- Search: 'Search',
1175
- Contact: 'Lead',
1176
- };
1177
- const rEvent = redditEventMap[eventName] || 'Custom';
1178
-
1179
- const user = {
1180
- ...(email && { email: { value: await sha256(email) } }),
1181
- ...(phoneNorm && { phoneNumber: { value: await sha256(phoneNorm) } }),
1182
- ...(userId && { externalId: { value: await sha256(String(userId)) } }),
1183
- ipAddress: { value: request.headers.get('CF-Connecting-IP') || '' },
1184
- userAgent: { value: request.headers.get('User-Agent') || '' },
1185
- };
1186
-
1187
- const event = {
1188
- event_at: new Date().toISOString(),
1189
- event_type: { tracking_type: rEvent, ...(eventName === 'InitiateCheckout' && { conversion_type: 'BEGIN_CHECKOUT' }) },
1190
- click_id: payload.rdtClid || '',
1191
- event_metadata: {
1192
- currency: (currency || 'BRL').toUpperCase(),
1193
- value_decimal: String(value || 0),
1194
- item_count: '1',
1195
- conversion_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
1196
- },
1197
- user,
1198
- };
1199
-
1200
- const body = { events: [event] };
1201
-
1202
- try {
1203
- const res = await fetch(
1204
- `https://ads-api.reddit.com/api/v2.0/conversions/events/${env.REDDIT_AD_ACCOUNT_ID}`,
1205
- {
1206
- method: 'POST',
1207
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.REDDIT_ACCESS_TOKEN}` },
1208
- body: JSON.stringify(body),
1209
- }
1210
- );
1211
- if (!res.ok) {
1212
- const txt = await res.text();
1213
- console.error('Reddit CAPI error:', txt);
1214
- if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'reddit', eventName, String(res.status), txt, event.event_metadata.conversion_id, JSON.stringify(body)));
1215
- return { error: `HTTP ${res.status}` };
1216
- }
1217
- const data = await res.json();
1218
- return data;
1219
- } catch (err) {
1220
- console.error('Reddit CAPI fetch failed:', err.message);
1221
- if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'reddit', eventName, 'FETCH_ERROR', err.message, null, JSON.stringify(body)));
1222
- return { error: err.message };
1223
- }
1224
- }
1225
-
1226
-
1227
- // ── LinkedIn — Conversions API v2 (server-side) ───────────────────────────────
1228
- //
1229
- // Secrets necessários (wrangler secret put):
1230
- // LINKEDIN_ACCESS_TOKEN → OAuth2 Bearer token da LinkedIn Marketing API
1231
- // LINKEDIN_CONVERSION_ID → ID da conversão (URN: urn:li:conversion:XXXXXXXXX)
1232
- // LINKEDIN_AD_ACCOUNT_ID → ID da conta de anúncios (ex: 123456789)
1233
- //
1234
- async function sendLinkedInCapi(env, eventName, payload, request, ctx) {
1235
- if (!env.LINKEDIN_ACCESS_TOKEN || !env.LINKEDIN_CONVERSION_ID) {
1236
- return { skipped: 'LinkedIn credentials not set' };
1237
- }
1238
-
1239
- const {
1240
- email, phone, firstName, lastName, userId,
1241
- eventId, pageUrl,
1242
- value, currency,
1243
- } = payload;
1244
-
1245
- const phoneNorm = normalizePhone(phone);
1246
-
1247
- // LinkedIn só suporta conversões (Lead, Purchase, CompleteRegistration)
1248
- const linkedInEventMap = {
1249
- Lead: 'LEAD',
1250
- Purchase: 'PURCHASE',
1251
- CompleteRegistration: 'REGISTRATION',
1252
- AddToCart: 'ADD_TO_CART',
1253
- InitiateCheckout: 'OTHER',
1254
- ViewContent: 'OTHER',
1255
- PageView: 'OTHER',
1256
- Contact: 'LEAD',
1257
- };
1258
- const liEvent = linkedInEventMap[eventName] || 'OTHER';
1259
-
1260
- // user — SHA-256 em campos PII
1261
- const userInfo = {
1262
- ...(email && { 'SHA256_EMAIL': await sha256(email) }),
1263
- ...(phoneNorm && { 'SHA256_PHONE': await sha256(phoneNorm) }),
1264
- ...(firstName && { 'SHA256_FIRST_NAME': await sha256(firstName.toLowerCase().trim()) }),
1265
- ...(lastName && { 'SHA256_LAST_NAME': await sha256(lastName.toLowerCase().trim()) }),
1266
- };
1267
-
1268
- const body = {
1269
- conversion: `urn:li:conversion:${env.LINKEDIN_CONVERSION_ID}`,
1270
- conversionHappenedAt: Date.now(),
1271
- conversionValue: value ? { currencyCode: (currency || 'BRL').toUpperCase(), amount: String(parseFloat(value)) } : undefined,
1272
- eventId: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
1273
- ...(Object.keys(userInfo).length > 0 && { user: { userIds: Object.entries(userInfo).map(([idType, idValue]) => ({ idType, idValue })) } }),
1274
- };
1275
-
1276
- try {
1277
- const res = await fetch(
1278
- 'https://api.linkedin.com/rest/conversionEvents',
1279
- {
1280
- method: 'POST',
1281
- headers: {
1282
- 'Content-Type': 'application/json',
1283
- Authorization: `Bearer ${env.LINKEDIN_ACCESS_TOKEN}`,
1284
- 'LinkedIn-Version': '202405',
1285
- 'X-Restli-Protocol-Version': '2.0.0',
1286
- },
1287
- body: JSON.stringify(body),
1288
- }
1289
- );
1290
- if (!res.ok) {
1291
- const txt = await res.text();
1292
- console.error('LinkedIn CAPI error:', txt);
1293
- if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'linkedin', eventName, String(res.status), txt, body.eventId, JSON.stringify(body)));
1294
- return { error: `HTTP ${res.status}` };
1295
- }
1296
- return { ok: true };
1297
- } catch (err) {
1298
- console.error('LinkedIn CAPI fetch failed:', err.message);
1299
- if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'linkedin', eventName, 'FETCH_ERROR', err.message, null, JSON.stringify(body)));
1300
- return { error: err.message };
1301
- }
1302
- }
1303
-
1304
-
1305
- // ── Spotify — Conversions API v1 (server-side) ────────────────────────────────
1306
- //
1307
- // Secrets necessários (wrangler secret put):
1308
- // SPOTIFY_ACCESS_TOKEN → Bearer token da Spotify Advertising API
1309
- // SPOTIFY_AD_ACCOUNT_ID → ID da conta de anúncios Spotify Ads
1310
- //
1311
- async function sendSpotifyCapi(env, eventName, payload, request, ctx) {
1312
- if (!env.SPOTIFY_ACCESS_TOKEN || !env.SPOTIFY_AD_ACCOUNT_ID) {
1313
- return { skipped: 'Spotify credentials not set' };
1314
- }
1315
-
1316
- const {
1317
- email, phone, userId,
1318
- eventId, pageUrl,
1319
- value, currency,
1320
- } = payload;
1321
-
1322
- const phoneNorm = normalizePhone(phone);
1323
-
1324
- const spotifyEventMap = {
1325
- Purchase: 'PURCHASE',
1326
- Lead: 'LEAD',
1327
- CompleteRegistration: 'SIGN_UP',
1328
- AddToCart: 'ADD_TO_CART',
1329
- InitiateCheckout: 'INITIATE_CHECKOUT',
1330
- ViewContent: 'VIEW_CONTENT',
1331
- PageView: 'PAGE_VIEW',
1332
- Contact: 'LEAD',
1333
- };
1334
- const spEvent = spotifyEventMap[eventName] || 'CUSTOM';
1335
-
1336
- // user — SHA-256 em campos PII
1337
- const user = {
1338
- ...(email && { hashed_email: await sha256(email) }),
1339
- ...(phoneNorm && { hashed_phone: await sha256(phoneNorm) }),
1340
- ...(userId && { user_id: userId }),
1341
- ip_address: request.headers.get('CF-Connecting-IP') || '',
1342
- user_agent: request.headers.get('User-Agent') || '',
1343
- };
1344
-
1345
- const body = {
1346
- data: [{
1347
- event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
1348
- event_type: spEvent,
1349
- event_time: Math.floor(Date.now() / 1000),
1350
- url: pageUrl || '',
1351
- user,
1352
- ...(value !== undefined && {
1353
- value: {
1354
- currency: (currency || 'BRL').toUpperCase(),
1355
- amount: parseFloat(value),
1356
- },
1357
- }),
1358
- }],
1359
- };
1360
-
1361
- try {
1362
- const res = await fetch(
1363
- `https://advertising-api.spotify.com/conversion/v1/accounts/${env.SPOTIFY_AD_ACCOUNT_ID}/events`,
1364
- {
1365
- method: 'POST',
1366
- headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.SPOTIFY_ACCESS_TOKEN}` },
1367
- body: JSON.stringify(body),
1368
- }
1369
- );
1370
- if (!res.ok) {
1371
- const txt = await res.text();
1372
- console.error('Spotify CAPI error:', txt);
1373
- if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'spotify', eventName, String(res.status), txt, body.data[0].event_id, JSON.stringify(body)));
1374
- return { error: `HTTP ${res.status}` };
1375
- }
1376
- const data = await res.json();
1377
- return data;
1378
- } catch (err) {
1379
- console.error('Spotify CAPI fetch failed:', err.message);
1380
- if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'spotify', eventName, 'FETCH_ERROR', err.message, null, JSON.stringify(body)));
1381
- return { error: err.message };
1382
- }
1383
- }
1384
-
1385
-
1386
- // ── WhatsApp — Meta Cloud API v22.0 ──────────────────────────────────────────
1387
- //
1388
- // Secrets necessários (wrangler secret put):
1389
- // WHATSAPP_PHONE_NUMBER_ID → Meta: "Phone Number ID" (ex: 123456789012345)
1390
- // WHATSAPP_ACCESS_TOKEN → Meta: "Access Token" (Token permanente)
1391
- // WA_NOTIFY_NUMBER → Número do dono para receber notificações (ex: 5511999998888)
1392
- //
1393
- // Formatos suportados:
1394
- // text → Texto livre (só funciona dentro da janela de 24h)
1395
- // template → Mensagem pré-aprovada pela Meta (inicia conversa proativamente)
1396
- // image → Imagem com legenda opcional (URL pública)
1397
- // video → Vídeo com legenda opcional (URL pública)
1398
- // document → PDF/arquivo com nome e legenda (URL pública)
1399
- // audio → Áudio (URL pública, OGG/MP4)
1400
- // interactive → Botões (até 3) ou lista de opções (até 10 itens)
1401
- //
1402
- // Regra crítica: para iniciar conversa proativamente (Purchase, Lead, etc.)
1403
- // é OBRIGATÓRIO usar type: 'template'. Texto livre só funciona em resposta
1404
- // a uma mensagem do usuário nos últimos 24h.
1405
- //
1406
- async function sendWhatsApp(env, tipo, payload, options = {}) {
1407
- if (!env.WHATSAPP_PHONE_NUMBER_ID || !env.WHATSAPP_ACCESS_TOKEN || !env.WA_NOTIFY_NUMBER) {
1408
- return { skipped: 'WhatsApp não configurado' };
1409
- }
1410
-
1411
- const to = options.to || env.WA_NOTIFY_NUMBER;
1412
-
1413
- // ── Template (proativo — fora da janela de 24h) ───────────────────────────
1414
- // Usar quando: Purchase, Lead, ou qualquer disparo iniciado pelo sistema
1415
- // O template deve estar aprovado no Meta Business Suite → WhatsApp → Templates
1416
- // Formato dos componentes segue a API de Templates da Meta
1417
- if (options.template) {
1418
- const { name, language = 'pt_BR', components = [] } = options.template;
1419
- const body = {
1420
- messaging_product: 'whatsapp',
1421
- to,
1422
- type: 'template',
1423
- template: { name, language: { code: language }, components },
1424
- };
1425
- return _sendWARequest(env, body);
1426
- }
1427
-
1428
- // ── Mídia — image, video, document, audio ────────────────────────────────
1429
- // Usar quando: envio de PDF de nota fiscal, vídeo de boas-vindas, etc.
1430
- // mediaUrl deve ser uma URL pública acessível pela Meta
1431
- if (options.mediaType && options.mediaUrl) {
1432
- const mediaPayload = { link: options.mediaUrl };
1433
- if (options.caption) mediaPayload.caption = options.caption;
1434
- if (options.filename) mediaPayload.filename = options.filename; // só para document
1435
- const body = {
1436
- messaging_product: 'whatsapp',
1437
- to,
1438
- type: options.mediaType,
1439
- [options.mediaType]: mediaPayload,
1440
- };
1441
- return _sendWARequest(env, body);
1442
- }
1443
-
1444
- // ── Interactive — botões (até 3) ─────────────────────────────────────────
1445
- // Usar quando: confirmação de compra com botões de ação
1446
- // buttons: [{ id: 'btn_1', title: 'Ver Pedido' }, ...]
1447
- if (options.interactive === 'buttons' && options.buttons?.length) {
1448
- const body = {
1449
- messaging_product: 'whatsapp',
1450
- to,
1451
- type: 'interactive',
1452
- interactive: {
1453
- type: 'button',
1454
- body: { text: options.bodyText || '' },
1455
- action: {
1456
- buttons: options.buttons.slice(0, 3).map(b => ({
1457
- type: 'reply',
1458
- reply: { id: b.id, title: b.title },
1459
- })),
1460
- },
1461
- },
1462
- };
1463
- return _sendWARequest(env, body);
1464
- }
1465
-
1466
- // ── Interactive — lista de opções (até 10 itens) ──────────────────────────
1467
- // Usar quando: menu de suporte, seleção de produto, etc.
1468
- // rows: [{ id: 'opt_1', title: 'Suporte', description: 'Falar com equipe' }, ...]
1469
- if (options.interactive === 'list' && options.rows?.length) {
1470
- const body = {
1471
- messaging_product: 'whatsapp',
1472
- to,
1473
- type: 'interactive',
1474
- interactive: {
1475
- type: 'list',
1476
- body: { text: options.bodyText || '' },
1477
- action: {
1478
- button: options.listButton || 'Ver opções',
1479
- sections: [{ rows: options.rows.slice(0, 10) }],
1480
- },
1481
- },
1482
- };
1483
- return _sendWARequest(env, body);
1484
- }
1485
-
1486
- // ── Text — fallback (dentro da janela de 24h) ─────────────────────────────
1487
- const nome = [payload.firstName, payload.lastName].filter(Boolean).join(' ') || 'sem nome';
1488
- const valor = payload.value ? `R$ ${parseFloat(payload.value).toFixed(2)}` : '—';
1489
- const utm = payload.utmSource || 'direto';
1490
- const produto = payload.contentName || '';
1491
-
1492
- let texto = '';
1493
- if (tipo === 'Purchase') {
1494
- texto =
1495
- `🛒 *Nova Venda!*\n\n` +
1496
- `👤 ${nome}\n` +
1497
- `📧 ${payload.email || '—'}\n` +
1498
- `📱 ${payload.phone || '—'}\n` +
1499
- `💰 ${valor}\n` +
1500
- (produto ? `📦 ${produto}\n` : '') +
1501
- `🔗 UTM: ${utm}\n` +
1502
- `🕐 ${new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' })}`;
1503
- } else if (tipo === 'Lead') {
1504
- texto =
1505
- `📋 *Novo Lead!*\n\n` +
1506
- `📧 ${payload.email || '—'}\n` +
1507
- `🔗 UTM: ${utm}\n` +
1508
- `🌐 ${payload.pageUrl || '—'}\n` +
1509
- `🕐 ${new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' })}`;
1510
- } else {
1511
- return { skipped: `tipo ${tipo} não suportado sem template` };
1512
- }
1513
-
1514
- return _sendWARequest(env, {
1515
- messaging_product: 'whatsapp',
1516
- to,
1517
- type: 'text',
1518
- text: { body: texto },
1519
- });
1520
- }
1521
-
1522
- // ── Executor interno — evita duplicação de fetch entre os formatos ────────────
1523
- async function _sendWARequest(env, body) {
1524
- try {
1525
- const res = await fetch(`https://graph.facebook.com/v22.0/${env.WHATSAPP_PHONE_NUMBER_ID}/messages`, {
1526
- method: 'POST',
1527
- headers: {
1528
- 'Content-Type': 'application/json',
1529
- 'Authorization': `Bearer ${env.WHATSAPP_ACCESS_TOKEN}`,
1530
- },
1531
- body: JSON.stringify(body),
1532
- });
1533
- const data = await res.json();
1534
- if (!res.ok) console.error('WhatsApp Meta API error:', res.status, data.error?.message || 'unknown');
1535
- return { ok: res.ok, status: res.status, data };
1536
- } catch (err) {
1537
- console.error('WhatsApp Meta API failed:', err.message);
1538
- return { ok: false, error: err.message };
1539
- }
1540
- }
1541
-
1542
- // ── CallMeBot — Alertas de sistema (falhas Cloudflare, API down, erros críticos)
1543
- //
1544
- // Secrets necessários (wrangler secret put):
1545
- // CALLMEBOT_PHONE → Número do admin no formato internacional (ex: +5511999998888)
1546
- // CALLMEBOT_APIKEY → API Key gerada pelo CallMeBot (ativar via WhatsApp: wa.me/34638398527)
1547
- //
1548
- // Usado para: alertas internos do sistema — Worker com erro, API falhando,
1549
- // token expirado, D1 com problema. NÃO para mensagens a clientes.
1550
- //
1551
- async function sendCallMeBot(env, mensagem) {
1552
- if (!env.CALLMEBOT_PHONE || !env.CALLMEBOT_APIKEY) {
1553
- return { skipped: 'CallMeBot não configurado' };
1554
- }
1555
- try {
1556
- const url = `https://api.callmebot.com/whatsapp.php?phone=${encodeURIComponent(env.CALLMEBOT_PHONE)}&text=${encodeURIComponent(mensagem)}&apikey=${env.CALLMEBOT_APIKEY}`;
1557
- const res = await fetch(url);
1558
- return { ok: res.ok, status: res.status };
1559
- } catch (err) {
1560
- console.error('CallMeBot failed:', err.message);
1561
- return { ok: false, error: err.message };
1562
- }
1563
- }
1564
-
1565
- // ── WhatsApp CTWA — Processa webhook de mensagem recebida ─────────────────────
1566
- // Acionado em POST /webhook/whatsapp quando um usuário envia mensagem após
1567
- // clicar em um anúncio "Click to WhatsApp" (CTWA) no Facebook/Instagram.
1568
- //
1569
- // O payload da Meta Cloud API inclui:
1570
- // message.from → número do usuário (sem "+", ex: "5511999998888")
1571
- // message.id (wamid) → ID único da mensagem (usado para deduplicação)
1572
- // message.referral → dados do anúncio que gerou o clique:
1573
- // .ctwa_clid → identificador do clique (equivalente ao fbclid para CTWA)
1574
- // .source_id → ID do anúncio
1575
- // .source_url → URL do anúncio no Facebook/Instagram
1576
- // .headline → título do anúncio
1577
- //
1578
- // O evento enviado à Meta CAPI usa action_source="chat" (obrigatório para CTWA)
1579
- // e inclui ctwa_clid em user_data (sem hash) junto com ph (phone hasheado).
1580
- // Isso permite à Meta fechar o loop: clique no anúncio → conversa no WhatsApp.
1581
- async function processWhatsAppWebhook(env, body, request, ctx) {
1582
- const entry = body?.entry?.[0];
1583
- const change = entry?.changes?.find(c => c.field === 'messages');
1584
- if (!change) return { skipped: 'no messages field' };
1585
-
1586
- const messages = change.value?.messages;
1587
- if (!messages || messages.length === 0) return { skipped: 'no messages' };
1588
-
1589
- const results = [];
1590
-
1591
- for (const message of messages) {
1592
- const phone = message.from; // ex: "5511999998888"
1593
- const wamid = message.id; // ID único da mensagem
1594
- const referral = message.referral || {};
1595
- const ctwaClid = referral.ctwa_clid || null; // click ID do anúncio
1596
- const adId = referral.source_id || null;
1597
- const sourceUrl = referral.source_url || null;
1598
- const headline = referral.headline || null;
1599
- const messageBody = message.text?.body || message.type || '';
1600
-
1601
- if (!phone) {
1602
- results.push({ skipped: 'no phone' });
1603
- continue;
1604
- }
1605
-
1606
- const phoneNorm = normalizePhone(phone) || phone;
1607
- const phoneHash = await sha256(phoneNorm);
1608
-
1609
- // Deduplicação — mesmo wamid não dispara duas vezes
1610
- if (env.DB && wamid) {
1611
- try {
1612
- const existing = await env.DB.prepare(
1613
- 'SELECT id FROM whatsapp_contacts WHERE wamid = ?'
1614
- ).bind(wamid).first();
1615
- if (existing) {
1616
- results.push({ skipped: 'duplicate wamid', wamid });
1617
- continue;
1618
- }
1619
- } catch { /* não bloquear se D1 falhar */ }
1620
- }
1621
-
1622
- const eventId = `ctwa_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
1623
-
1624
- // Persistir contato no D1 (antes de enviar ao CAPI)
1625
- if (env.DB) {
1626
- ctx.waitUntil(
1627
- env.DB.prepare(
1628
- `INSERT OR IGNORE INTO whatsapp_contacts
1629
- (phone_hash, phone_raw, wamid, ctwa_clid, ad_id, source_url, headline, capi_sent, capi_event_id, message_body)
1630
- VALUES (?,?,?,?,?,?,?,0,?,?)`
1631
- ).bind(phoneHash, phoneNorm, wamid || null, ctwaClid, adId,
1632
- sourceUrl, headline, eventId, messageBody || null).run()
1633
- );
1634
- }
1635
-
1636
- // Montar evento para Meta CAPI
1637
- // action_source: "chat" é obrigatório para eventos originados no WhatsApp
1638
- // ctwa_clid vai em user_data sem hash (a Meta exige assim)
1639
- const capiEvent = {
1640
- event_name: 'Contact',
1641
- event_time: Math.floor(Date.now() / 1000),
1642
- event_id: eventId,
1643
- action_source: 'chat',
1644
- user_data: {
1645
- ph: phoneHash,
1646
- ...(ctwaClid && { ctwa_clid: ctwaClid }),
1647
- client_ip_address: request.headers.get('CF-Connecting-IP') || '',
1648
- client_user_agent: request.headers.get('User-Agent') || '',
1649
- },
1650
- ...(sourceUrl && { event_source_url: sourceUrl }),
1651
- };
1652
-
1653
- const pixelId = env.META_PIXEL_ID || META_PIXEL_ID;
1654
-
1655
- // Enviar ao Meta CAPI de forma assíncrona (não bloqueia a resposta ao WhatsApp)
1656
- ctx.waitUntil(
1657
- (async () => {
1658
- try {
1659
- const requestBody = {
1660
- data: [capiEvent],
1661
- access_token: env.META_ACCESS_TOKEN,
1662
- };
1663
- if (env.META_TEST_CODE) requestBody.test_event_code = env.META_TEST_CODE;
1664
-
1665
- const res = await fetch(
1666
- `https://graph.facebook.com/v22.0/${pixelId}/events`,
1667
- {
1668
- method: 'POST',
1669
- headers: { 'Content-Type': 'application/json' },
1670
- body: JSON.stringify(requestBody),
1671
- }
1672
- );
1673
- const data = await res.json();
1674
-
1675
- if (res.ok && env.DB && wamid) {
1676
- await env.DB.prepare(
1677
- 'UPDATE whatsapp_contacts SET capi_sent = 1 WHERE wamid = ?'
1678
- ).bind(wamid).run();
1679
- } else if (!res.ok) {
1680
- console.error('[CTWA] Meta CAPI error:', res.status, data.error?.message || 'unknown');
1681
- if (env.DB) {
1682
- await logApiFailure(env.DB, 'meta', 'Contact', data.error?.code || res.status,
1683
- data.error?.message || 'CTWA CAPI error', eventId, JSON.stringify(requestBody));
1684
- }
1685
- }
1686
- } catch (err) {
1687
- console.error('[CTWA] Meta CAPI fetch failed:', err.message);
1688
- }
1689
- })()
1690
- );
1691
-
1692
- // Registrar também na tabela leads para aparecer no CRM Dashboard
1693
- ctx.waitUntil(
1694
- saveLead(env, 'Contact', {
1695
- phone: phoneNorm,
1696
- eventId: eventId,
1697
- pageUrl: sourceUrl,
1698
- utmSource: 'whatsapp_ctwa',
1699
- utmMedium: 'paid_social',
1700
- }, request, 'whatsapp')
1701
- );
1702
-
1703
- results.push({
1704
- ok: true,
1705
- phone: phoneNorm.slice(0, 4) + '****',
1706
- ctwa_clid: ctwaClid ? 'present' : 'absent',
1707
- event_id: eventId,
1708
- });
1709
- }
1710
-
1711
- return { processed: results.length, results };
1712
- }
1713
-
1714
- // ── Verificação de assinatura HMAC-SHA256 (webhooks) ─────────────────────────
1715
- async function verifyHmac(secret, rawBody, receivedSignature) {
1716
- if (!secret || !receivedSignature) return false;
1717
- try {
1718
- const key = await crypto.subtle.importKey(
1719
- 'raw',
1720
- new TextEncoder().encode(secret),
1721
- { name: 'HMAC', hash: 'SHA-256' },
1722
- false,
1723
- ['sign']
1724
- );
1725
- const sig = await crypto.subtle.sign(
1726
- 'HMAC', key, new TextEncoder().encode(rawBody)
1727
- );
1728
- const computed = Array.from(new Uint8Array(sig))
1729
- .map(b => b.toString(16).padStart(2, '0')).join('');
1730
- // Comparação constant-time (evita timing attack)
1731
- if (computed.length !== receivedSignature.length) return false;
1732
- let diff = 0;
1733
- for (let i = 0; i < computed.length; i++) {
1734
- diff |= computed.charCodeAt(i) ^ receivedSignature.charCodeAt(i);
1735
- }
1736
- return diff === 0;
1737
- } catch {
1738
- return false;
1739
- }
1740
- }
1741
-
1742
- // ── Mapa Meta → GA4 event names ───────────────────────────────────────────────
1743
- const META_TO_GA4 = {
1744
- PageView: 'page_view',
1745
- ViewContent: 'view_item',
1746
- Lead: 'generate_lead',
1747
- Contact: 'generate_lead',
1748
- Schedule: 'generate_lead',
1749
- InitiateCheckout: 'begin_checkout',
1750
- AddToCart: 'add_to_cart',
1751
- AddPaymentInfo: 'add_payment_info',
1752
- Purchase: 'purchase',
1753
- CompleteRegistration: 'sign_up',
1754
- Subscribe: 'subscribe',
1755
- StartTrial: 'start_trial',
1756
- Search: 'search',
1757
- AddToWishlist: 'add_to_wishlist',
1758
- };
1759
-
1760
- // ── Persistir LTV no perfil D1 ───────────────────────────────────────────────
1761
- async function upsertLtvProfile(env, userId, ltv) {
1762
- if (!env.DB || !userId) return;
1763
- try {
1764
- await env.DB.prepare(`
1765
- UPDATE user_profiles
1766
- SET predicted_ltv_class = ?,
1767
- predicted_ltv_value = ?,
1768
- updated_at = datetime('now')
1769
- WHERE user_id = ?
1770
- `).bind(ltv.class, ltv.value, userId).run();
1771
- } catch (err) {
1772
- console.error('upsertLtvProfile error:', err.message);
1773
- }
1774
- }
1775
-
1776
- // ─────────────────────────────────────────────────────────────────────────────
1777
- // LTV PREDICTION — Valor Preditivo de Lifetime Value
1778
- // ─────────────────────────────────────────────────────────────────────────────
1779
-
1780
- /**
1781
- * Prediz o LTV (Lifetime Value) de um lead no momento do primeiro contato.
1782
- *
1783
- * Modelo heurístico em 5 dimensões (0–100 pontos):
1784
- * 1. Engajamento browser (0–30 pts) — engagement_score + user_score
1785
- * 2. Origem de tráfego (0–25 pts) — UTM source (paid > organic > direct)
1786
- * 3. Contexto de rede (0–15 pts) — ASN corporativo, país, hora do dia
1787
- * 4. Contexto do evento (0–20 pts) — InitiateCheckout visto antes = +20
1788
- * 5. Dados PII disponíveis (0–10 pts) — email + phone + nome = melhor match
1789
- *
1790
- * Retorna: { score, class, value }
1791
- * score: 0–100
1792
- * class: 'High' | 'Medium' | 'Low'
1793
- * value: valor em BRL (base × multiplicador da classe)
1794
- */
1795
- async function predictLtv(env, payload, request, customSystemPrompt = null) {
1796
- // 0. Tentar modelo treinado (regressão logística via D1/KV)
1797
- try {
1798
- const model = await _loadActiveWeights(env);
1799
- if (model?.weights?.length) {
1800
- const hour = new Date().getUTCHours();
1801
- const country = (payload.country || request.cf?.country || '').toUpperCase();
1802
- const features = _extractFeatures({
1803
- utm_source: payload.utmSource,
1804
- engagement_score: parseFloat(payload.engagementScore || 0),
1805
- intention_level: payload.intentionLevel,
1806
- days_since_lead: 0,
1807
- has_email: !!payload.email,
1808
- has_phone: !!payload.phone,
1809
- is_br: country === 'BR',
1810
- hour,
1811
- });
1812
- const score100 = _predictWithWeights(model, features);
1813
- const ltvClass = score100 >= 70 ? 'High' : score100 >= 40 ? 'Medium' : 'Low';
1814
- const multiplier = ltvClass === 'High' ? 3.5 : ltvClass === 'Medium' ? 1.8 : 0.8;
1815
- const base = payload.value ? parseFloat(payload.value) : 197;
1816
- return { score: score100, class: ltvClass, value: Math.round(base * multiplier * 100) / 100, source: 'model' };
1817
- }
1818
- } catch { /* fallback heurístico */ }
1819
-
1820
- let score = 0;
1821
-
1822
- // 1. Engajamento browser (0–30)
1823
- const engScore = parseFloat(payload.engagementScore || 0);
1824
- const userScore = parseFloat(payload.userScore || 0);
1825
- // engagement_score é 0-5 → normaliza para 0-15
1826
- score += Math.min(15, Math.round((engScore / 5) * 15));
1827
- // user_score é 0-100 → normaliza para 0-15
1828
- score += Math.min(15, Math.round((userScore / 100) * 15));
1829
-
1830
- // 2. Origem de tráfego (0–25)
1831
- const src = (payload.utmSource || '').toLowerCase();
1832
- const utm_score_map = {
1833
- facebook: 25, instagram: 25, meta: 25,
1834
- google: 22, youtube: 22, tiktok: 20, // youtube = mesmo nível do google search (alta intenção)
1835
- email: 18, sms: 18,
1836
- organic: 10, direct: 5,
1837
- };
1838
- const utmScore = utm_score_map[src] ?? (src ? 8 : 3);
1839
- score += utmScore;
1840
-
1841
- // 3. Contexto de rede (0–15)
1842
- const hour = new Date().getUTCHours();
1843
- const country = (payload.country || request.cf?.country || '').toUpperCase();
1844
- const org = String(request.cf?.asOrganization || '').toLowerCase();
1845
-
1846
- // Horário de alta conversão: 18h-23h BRT (21h-02h UTC) = +8
1847
- const isHighConvTime = hour >= 21 || hour <= 2;
1848
- score += isHighConvTime ? 8 : (hour >= 12 && hour <= 20 ? 4 : 1);
1849
-
1850
- // País Brasil = público-alvo primário = +5; outros LATAM = +3
1851
- const latam = ['AR', 'CL', 'CO', 'MX', 'PE', 'UY', 'PY', 'BO'];
1852
- score += country === 'BR' ? 5 : (latam.includes(country) ? 3 : 1);
1853
-
1854
- // ASN corporativo (empresa/datacenter = B2B = alto LTV) = +2
1855
- const isCorp = /ltda|s\.a\.|corp|telecom|fibra|claro|vivo|tim|oi/.test(org);
1856
- score += isCorp ? 2 : 0;
1857
-
1858
- // 4. Contexto do evento (0–20)
1859
- // InitiateCheckout visto antes deste Lead = usuário já na jornada de compra
1860
- const intentionLevel = (payload.intentionLevel || '').toLowerCase();
1861
- if (intentionLevel === 'comprador' || intentionLevel === 'high_intent') score += 20;
1862
- else if (intentionLevel === 'interessado') score += 12;
1863
- else if (intentionLevel === 'nurture') score += 6;
1864
-
1865
- // 5. Dados PII disponíveis (0–10)
1866
- if (payload.email) score += 4;
1867
- if (payload.phone) score += 4;
1868
- if (payload.firstName) score += 2;
1869
-
1870
- score = Math.min(100, score);
1871
-
1872
- // Classificação
1873
- let ltvClass, ltvMultiplier;
1874
- if (score >= 70) {
1875
- ltvClass = 'High'; ltvMultiplier = 3.5;
1876
- } else if (score >= 40) {
1877
- ltvClass = 'Medium'; ltvMultiplier = 1.8;
1878
- } else {
1879
- ltvClass = 'Low'; ltvMultiplier = 0.8;
1880
- }
1881
-
1882
- // Valor base do produto (do payload) ou estimativa por classe
1883
- const productValue = payload.value ? parseFloat(payload.value) : 0;
1884
- const baseValue = productValue > 0 ? productValue : 197; // ticket médio padrão BR
1885
- const predictedValue = Math.round(baseValue * ltvMultiplier * 100) / 100;
1886
-
1887
- // Enriquecimento opcional via Workers AI (se binding disponível)
1888
- // Usado apenas para ajuste fino do score — não bloqueia o fluxo principal
1889
- let aiAdjustment = 0;
1890
- if (env.AI && score >= 40) {
1891
- try {
1892
- const systemContent = customSystemPrompt ||
1893
- 'You are a conversion rate expert. Reply ONLY with a JSON object {"adjustment": <number between -10 and 10>} based on the lead data provided. No explanation.';
1894
- const prompt = [
1895
- { role: 'system', content: systemContent },
1896
- { role: 'user', content: JSON.stringify({
1897
- utm_source: payload.utmSource,
1898
- intention: intentionLevel,
1899
- engagement: engScore,
1900
- hour_utc: hour,
1901
- country,
1902
- has_email: !!payload.email,
1903
- has_phone: !!payload.phone,
1904
- })},
1905
- ];
1906
- const aiRes = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', { messages: prompt, max_tokens: 32 });
1907
- const parsed = JSON.parse(aiRes.response.trim());
1908
- if (typeof parsed.adjustment === 'number') {
1909
- aiAdjustment = Math.max(-10, Math.min(10, parsed.adjustment));
1910
- }
1911
- } catch { /* graceful fallback — AI opcional */ }
1912
- }
1913
-
1914
- const finalScore = Math.min(100, Math.max(0, score + aiAdjustment));
1915
-
1916
- return {
1917
- score: finalScore,
1918
- class: ltvClass,
1919
- value: predictedValue,
1920
- };
1921
- }
1922
-
1923
- // ─────────────────────────────────────────────────────────────────────────────
1924
- // FEEDBACK LOOP — Monitoramento de Falhas e Saúde
1925
- // ─────────────────────────────────────────────────────────────────────────────
1926
-
1927
- // ── Log de Falha de API ─────────────────────────────────────────────────────
1928
- async function logApiFailure(DB, platform, eventName, errorCode, errorMessage, eventId, rawPayload) {
1929
- try {
1930
- await DB.prepare(`
1931
- INSERT INTO api_failures (platform, event_name, error_code, error_message, event_id, raw_payload, retry_count, final_status)
1932
- VALUES (?, ?, ?, ?, ?, ?, 0, 'failed')
1933
- `).bind(platform, eventName, String(errorCode), errorMessage, eventId, rawPayload).run();
1934
- } catch (err) {
1935
- console.error('Failed to log API failure:', err.message);
1936
- }
1937
- }
1938
-
1939
- // ── Métricas de Saúde (últimas 24h) ─────────────────────────────────────────
1940
- async function getHealthMetrics(DB, platform, hours = 24) {
1941
- try {
1942
- // Total de eventos com falha
1943
- const failures = await DB.prepare(`
1944
- SELECT COUNT(*) as count, error_code
1945
- FROM api_failures
1946
- WHERE platform = ? AND created_at > datetime('now', '-${hours} hours')
1947
- GROUP BY error_code
1948
- `).bind(platform).all();
1949
-
1950
- // Total de eventos enviados (leads table)
1951
- const totalSent = await DB.prepare(`
1952
- SELECT COUNT(*) as count
1953
- FROM leads
1954
- WHERE platform = ? AND created_at > datetime('now', '-${hours} hours')
1955
- `).bind(platform).first();
1956
-
1957
- const totalFailed = failures.reduce((sum, f) => sum + f.count, 0);
1958
- const successRate = totalSent?.count > 0
1959
- ? ((totalSent.count - totalFailed) / totalSent.count) * 100
1960
- : 100;
1961
-
1962
- return {
1963
- platform,
1964
- hours,
1965
- events_sent: totalSent?.count || 0,
1966
- events_failed: totalFailed,
1967
- success_rate: successRate,
1968
- errors_detected: failures.map(f => ({ code: f.error_code, count: f.count })),
1969
- issues: totalFailed > (totalSent?.count || 0) * 0.1 ? ['high_error_rate'] : [],
1970
- };
1971
- } catch (err) {
1972
- console.error('Failed to get health metrics:', err.message);
1973
- return {
1974
- platform,
1975
- hours,
1976
- events_sent: 0,
1977
- events_failed: 0,
1978
- success_rate: 0,
1979
- errors_detected: [],
1980
- issues: ['metrics_unavailable'],
1981
- };
1982
- }
1983
- }
1984
-
1985
- // ── Gerar Relatório Diário ────────────────────────────────────────────────────
1986
- async function generateDailyReport(DB) {
1987
- const platforms = ['meta', 'ga4', 'tiktok', 'pinterest', 'reddit'];
1988
- const today = new Date().toISOString().split('T')[0];
1989
-
1990
- const reports = [];
1991
-
1992
- for (const platform of platforms) {
1993
- const metrics = await getHealthMetrics(DB, platform, 24);
1994
-
1995
- try {
1996
- await DB.prepare(`
1997
- INSERT INTO health_reports (report_date, platform, events_sent, events_failed, success_rate, errors_detected, issues_detected)
1998
- VALUES (?, ?, ?, ?, ?, ?, ?)
1999
- `).bind(
2000
- today,
2001
- platform,
2002
- metrics.events_sent,
2003
- metrics.events_failed,
2004
- metrics.success_rate,
2005
- JSON.stringify(metrics.errors_detected),
2006
- JSON.stringify(metrics.issues)
2007
- ).run();
2008
- reports.push({ platform, status: 'ok' });
2009
- } catch (err) {
2010
- console.error(`Failed to generate report for ${platform}:`, err.message);
2011
- reports.push({ platform, status: 'failed' });
2012
- }
2013
- }
2014
-
2015
- return reports;
2016
- }
2017
-
2018
- // ── Verificar Versões de API (scheduled task) ────────────────────────────────
2019
- async function checkApiVersions() {
2020
- // Consulta api-versions.json para verificar versões atuais
2021
- // Em produção, isso poderia fazer fetch para documentação oficial
2022
- const currentVersions = {
2023
- meta: 'v22.0',
2024
- ga4: 'latest',
2025
- tiktok: 'v1.3',
2026
- pinterest: 'v5',
2027
- reddit: 'v2.0',
2028
- };
2029
-
2030
- const today = new Date().toISOString().split('T')[0];
2031
- const nextReview = '2026-04-27'; // Data do próximo review
2032
-
2033
- return {
2034
- check_date: today,
2035
- next_review: nextReview,
2036
- versions: currentVersions,
2037
- status: 'ok',
2038
- };
2039
- }
2040
-
2041
- // ─────────────────────────────────────────────────────────────────────────────
2042
- // INTELLIGENCE AGENT — Monitoramento Autônomo
2043
- // ─────────────────────────────────────────────────────────────────────────────
2044
-
2045
- // Versões esperadas das APIs (fonte da verdade: contracts/api-versions.json)
2046
- const EXPECTED_API_VERSIONS = {
2047
- meta: 'v22.0',
2048
- ga4: 'latest',
2049
- tiktok: 'v1.3',
2050
- pinterest: 'v5',
2051
- reddit: 'v2.0',
2052
- };
2053
-
2054
- // Thresholds de alerta
2055
- const ALERT_THRESHOLDS = {
2056
- errorRateCritical: 0.20, // > 20% de falha = crítico
2057
- errorRateWarning: 0.10, // > 10% de falha = aviso
2058
- };
2059
-
2060
- // ── Log de execução do Intelligence Agent no D1 ──────────────────────────────
2061
- async function logIntelligence(DB, runType, platform, checkType, status, currentValue, expectedValue, message, alertSent = false) {
2062
- if (!DB) return;
2063
- try {
2064
- await DB.prepare(`
2065
- INSERT INTO intelligence_logs (run_type, platform, check_type, status, current_value, expected_value, message, alert_sent)
2066
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2067
- `).bind(runType, platform, checkType, status, String(currentValue ?? ''), String(expectedValue ?? ''), message, alertSent ? 1 : 0).run();
2068
- } catch (err) {
2069
- console.error('logIntelligence error:', err.message);
2070
- }
2071
- }
2072
-
2073
- // ── Alerta CallMeBot para o admin (falhas de sistema, API down, erros críticos)
2074
- async function sendIntelligenceAlert(env, severity, title, details) {
2075
- const icon = severity === 'critical' ? '🚨' : '⚠️';
2076
- const texto =
2077
- `${icon} CDP Edge — ${title}\n\n` +
2078
- details + '\n\n' +
2079
- new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' });
2080
-
2081
- return sendCallMeBot(env, texto);
2082
- }
2083
-
2084
- // ── Check de versões de API ───────────────────────────────────────────────────
2085
- async function checkApiVersionsIntelligence(env, runType) {
2086
- const results = [];
2087
-
2088
- for (const [platform, expected] of Object.entries(EXPECTED_API_VERSIONS)) {
2089
- // Versão atual baseada no código deployado
2090
- const currentMap = { meta: 'v22.0', tiktok: 'v1.3', ga4: 'latest', pinterest: 'v5', reddit: 'v2.0' };
2091
- const current = currentMap[platform] || 'unknown';
2092
- const isOk = current === expected || expected === 'latest';
2093
- const status = isOk ? 'ok' : 'warning';
2094
-
2095
- await logIntelligence(env.DB, runType, platform, 'api_version', status, current, expected,
2096
- isOk ? `${platform} ${current} — versão correta` : `${platform} ${current} desatualizado, esperado ${expected}`
2097
- );
2098
-
2099
- results.push({ platform, current, expected, status });
2100
- }
2101
-
2102
- return results;
2103
- }
2104
-
2105
- // ── Auditoria de taxa de erro por plataforma (últimas 24h) ───────────────────
2106
- async function auditErrorRates(env, runType) {
2107
- if (!env.DB) return [];
2108
- const alerts = [];
2109
-
2110
- for (const platform of ['meta', 'ga4', 'tiktok']) {
2111
- const metrics = await getHealthMetrics(env.DB, platform, 24);
2112
- const errorRate = metrics.events_sent > 0
2113
- ? metrics.events_failed / metrics.events_sent
2114
- : 0;
2115
-
2116
- let status = 'ok';
2117
- if (errorRate >= ALERT_THRESHOLDS.errorRateCritical) status = 'critical';
2118
- else if (errorRate >= ALERT_THRESHOLDS.errorRateWarning) status = 'warning';
2119
-
2120
- const message = `${platform}: ${metrics.events_sent} eventos, ${metrics.events_failed} falhas (${(errorRate * 100).toFixed(1)}%)`;
2121
- const alertSent = status !== 'ok'
2122
- ? await sendIntelligenceAlert(env, status, `Taxa de Erro Alta — ${platform.toUpperCase()}`,
2123
- `📊 ${message}\n🎯 Taxa: ${(errorRate * 100).toFixed(1)}% (limite: ${ALERT_THRESHOLDS.errorRateWarning * 100}%)`)
2124
- : false;
2125
-
2126
- await logIntelligence(env.DB, runType, platform, 'error_rate', status,
2127
- `${(errorRate * 100).toFixed(1)}%`,
2128
- `${ALERT_THRESHOLDS.errorRateWarning * 100}%`,
2129
- message, alertSent
2130
- );
2131
-
2132
- if (status !== 'ok') alerts.push({ platform, errorRate, status });
2133
- }
2134
-
2135
- return alerts;
2136
- }
2137
-
2138
- // ── Treinar modelo LTV com dados reais do D1 ─────────────────────────────────
2139
- async function _trainLtvModel(env) {
2140
- if (!env.DB) return { skipped: 'DB não disponível' };
2141
- try {
2142
- const rows = await env.DB.prepare(`
2143
- SELECT
2144
- l.utm_source, l.engagement_score, l.intention_level,
2145
- CAST(julianday('now') - julianday(l.created_at) AS INTEGER) AS days_since_lead,
2146
- CASE WHEN l.email IS NOT NULL AND l.email != '' THEN 1 ELSE 0 END AS has_email,
2147
- CASE WHEN l.phone IS NOT NULL AND l.phone != '' THEN 1 ELSE 0 END AS has_phone,
2148
- CASE WHEN (l.country = 'br' OR l.country = 'BR' OR l.country IS NULL) THEN 1 ELSE 0 END AS is_br,
2149
- CAST(strftime('%H', l.created_at) AS INTEGER) AS hour,
2150
- CASE WHEN EXISTS (
2151
- SELECT 1 FROM events e
2152
- WHERE e.user_id = l.user_id
2153
- AND e.event_name IN ('Purchase','purchase','PURCHASE')
2154
- AND e.created_at > l.created_at
2155
- ) THEN 1 ELSE 0 END AS label
2156
- FROM leads l
2157
- WHERE l.created_at >= datetime('now', '-90 days')
2158
- LIMIT 5000
2159
- `).all();
2160
-
2161
- const dataset = (rows.results || []).map(row => ({ features: _extractFeatures(row), label: row.label || 0 }));
2162
- const model = _trainLogisticRegression(dataset);
2163
-
2164
- if (!model) {
2165
- return { skipped: 'dados insuficientes', samples: dataset.length };
2166
- }
2167
-
2168
- await _saveWeights(env.DB, model);
2169
- if (env.GEO_CACHE) env.GEO_CACHE.delete(_LTV_WEIGHTS_KV_KEY).catch(() => {});
2170
-
2171
- return { trained: true, samples: dataset.length, accuracy: model.accuracy, positiveRate: model.positiveRate };
2172
- } catch (err) {
2173
- console.error('[LTV Train] Erro:', err.message);
2174
- return { error: err.message };
2175
- }
2176
- }
2177
-
2178
- // ── Auto-decisão de winner no A/B LTV Test ────────────────────────────────────
2179
- const _AB_LTV_CACHE_KEY = 'ab_ltv_active_test';
2180
-
2181
- async function _autoDecideAbWinner(env) {
2182
- if (!env.DB) return null;
2183
- try {
2184
- const test = await env.DB.prepare(`
2185
- SELECT id, min_sample_size FROM ltv_ab_tests WHERE status = 'running' ORDER BY created_at DESC LIMIT 1
2186
- `).first();
2187
- if (!test) return { decided: false };
2188
-
2189
- const variations = await env.DB.prepare(`
2190
- SELECT id, name, is_control, sample_count, accuracy_score
2191
- FROM ltv_ab_variations WHERE test_id = ?
2192
- `).bind(test.id).all();
2193
-
2194
- const vars = variations.results || [];
2195
- if (vars.some(v => (v.sample_count || 0) < (test.min_sample_size || 50))) return { decided: false };
2196
-
2197
- const control = vars.find(v => v.is_control) || vars[0];
2198
- const best = vars.reduce((a, b) => (b.accuracy_score || 0) > (a.accuracy_score || 0) ? b : a, control);
2199
-
2200
- if (best.id === control.id) return { decided: false };
2201
-
2202
- const improvement = (best.accuracy_score || 0) - (control.accuracy_score || 0);
2203
- if (improvement < 5) return { decided: false };
2204
-
2205
- await env.DB.prepare(`
2206
- UPDATE ltv_ab_tests SET status='completed', winner_id=?, auto_decided_at=datetime('now'), auto_decided_reason=?
2207
- WHERE id=?
2208
- `).bind(best.id, `Auto: +${improvement.toFixed(1)}pp vs control`, test.id).run();
2209
-
2210
- if (env.GEO_CACHE) env.GEO_CACHE.delete(_AB_LTV_CACHE_KEY).catch(() => {});
2211
-
2212
- const winnerVar = await env.DB.prepare(
2213
- `SELECT system_prompt FROM ltv_ab_variations WHERE id = ?`
2214
- ).bind(best.id).first();
2215
-
2216
- return { decided: true, test_id: test.id, winner_name: best.name, improvement, winning_prompt: winnerVar?.system_prompt };
2217
- } catch (err) {
2218
- console.error('[AB Auto-Decide] Erro:', err.message);
2219
- return null;
2220
- }
2221
- }
2222
-
2223
- // ── Runner principal do Intelligence Agent ────────────────────────────────────
2224
- async function runIntelligenceAgent(env, runType) {
2225
-
2226
- // 1. Check de versões (sempre)
2227
- const versionResults = await checkApiVersionsIntelligence(env, runType);
2228
-
2229
- // 2. Relatório diário de saúde (sempre)
2230
- if (env.DB) {
2231
- const reports = await generateDailyReport(env.DB);
2232
- }
2233
-
2234
- // 3. Auditoria de taxas de erro (sempre)
2235
- const errorAlerts = await auditErrorRates(env, runType);
2236
- if (errorAlerts.length > 0) {
2237
- console.warn(`[Intelligence Agent] ${errorAlerts.length} alertas de taxa de erro enviados`);
2238
- }
2239
-
2240
- // 4. Treinar modelo LTV com dados reais do D1 (toda semana)
2241
- const ltvTrainResult = await _trainLtvModel(env);
2242
- if (ltvTrainResult.trained) {
2243
- if (env.DB) {
2244
- logIntelligence(env.DB, runType, 'ltv', 'model_training', 'ok',
2245
- `accuracy=${(ltvTrainResult.accuracy * 100).toFixed(1)}%`, null,
2246
- `Modelo LTV re-treinado com ${ltvTrainResult.samples} amostras`
2247
- ).catch(() => {});
2248
- }
2249
- }
2250
-
2251
- // 5. Auto-decisão de winner no A/B LTV Test
2252
- try {
2253
- const abResult = await _autoDecideAbWinner(env);
2254
- if (abResult?.decided) {
2255
- await sendIntelligenceAlert(env, 'info',
2256
- `A/B LTV Test — Winner Declarado`,
2257
- `🏆 Vencedor: ${abResult.winner_name}\n📈 Melhoria: +${abResult.improvement?.toFixed(1) ?? '?'}pp vs controle\n🆔 Test ID: ${abResult.test_id}\n\n✅ Prompt vencedor ativado automaticamente`
2258
- );
2259
- }
2260
- } catch (err) {
2261
- console.error('[Intelligence Agent] A/B auto-decide error:', err.message);
2262
- }
2263
-
2264
- // 6. Match Quality — análise + alertas
2265
- try {
2266
- const mqAnalysis = await _analyzeMatchQuality(env);
2267
- if (mqAnalysis) {
2268
- await _alertMatchQuality(env, mqAnalysis);
2269
- }
2270
- } catch (err) {
2271
- console.error('[Intelligence Agent] Match quality error:', err.message);
2272
- }
2273
-
2274
- // 7. Auditoria mensal adicional
2275
- if (runType === 'monthly_audit') {
2276
- if (env.DB) {
2277
- try {
2278
- const ltvStats = await env.DB.prepare(`
2279
- SELECT predicted_ltv_class, COUNT(*) as count
2280
- FROM user_profiles
2281
- WHERE predicted_ltv_class IS NOT NULL
2282
- AND updated_at > datetime('now', '-30 days')
2283
- GROUP BY predicted_ltv_class
2284
- `).all();
2285
-
2286
- const summary = ltvStats.results?.map(r => `${r.predicted_ltv_class}: ${r.count}`).join(', ') || 'sem dados';
2287
- await logIntelligence(env.DB, runType, 'all', 'ltv_distribution', 'ok', summary, null,
2288
- `Distribuição LTV últimos 30 dias: ${summary}`);
2289
- } catch (err) {
2290
- console.error('LTV audit error:', err.message);
2291
- }
2292
-
2293
- // Purge de logs antigos de match quality (> 30 dias)
2294
- await _purgeOldMatchQualityLogs(env.DB);
2295
- }
2296
- }
2297
-
2298
- // 8. Customer Match — sync semanal D1 → Meta Custom Audience
2299
- const cmResult = await syncMetaCustomAudience(env);
2300
-
2301
- }
2302
-
2303
- // ─────────────────────────────────────────────────────────────────────────────
2304
- // CUSTOMER MATCH — Sync automático D1 → Meta Custom Audiences
2305
- // ─────────────────────────────────────────────────────────────────────────────
2306
-
2307
- /**
2308
- * Busca leads high_intent + buyer_lookalike do D1 e envia para Meta Custom Audience.
2309
- *
2310
- * Secrets necessários (wrangler secret put):
2311
- * META_AD_ACCOUNT_ID → ID da conta de anúncios (act_XXXXXXXXX)
2312
- * META_AUDIENCE_ID → ID da Custom Audience já criada no Meta Ads
2313
- *
2314
- * Meta aceita até 10.000 usuários por chamada.
2315
- * Formato: schema EMAIL_SHA256 + PHONE_SHA256, dados já hasheados.
2316
- */
2317
- async function syncMetaCustomAudience(env) {
2318
- if (!env.META_ACCESS_TOKEN || !env.META_AD_ACCOUNT_ID || !env.META_AUDIENCE_ID) {
2319
- return { skipped: 'META_AD_ACCOUNT_ID ou META_AUDIENCE_ID não configurados' };
2320
- }
2321
- if (!env.DB) return { skipped: 'DB não disponível' };
2322
-
2323
- try {
2324
- // Busca perfis high_intent e buyer_lookalike atualizados nos últimos 30 dias
2325
- const profiles = await env.DB.prepare(`
2326
- SELECT email, phone
2327
- FROM user_profiles
2328
- WHERE cohort_label IN ('high_intent', 'buyer_lookalike')
2329
- AND updated_at > datetime('now', '-30 days')
2330
- AND email IS NOT NULL
2331
- LIMIT 10000
2332
- `).all();
2333
-
2334
- if (!profiles.results || profiles.results.length === 0) {
2335
- return { sent: 0 };
2336
- }
2337
-
2338
- // Hash de cada linha — Meta exige SHA-256 lowercase sem espaços
2339
- const data = await Promise.all(
2340
- profiles.results.map(async (p) => {
2341
- const row = [];
2342
- row.push(p.email ? await sha256(p.email) : '');
2343
- row.push(p.phone ? await sha256(p.phone) : '');
2344
- return row;
2345
- })
2346
- );
2347
-
2348
- const body = {
2349
- payload: {
2350
- schema: ['EMAIL_SHA256', 'PHONE_SHA256'],
2351
- data,
2352
- },
2353
- };
2354
-
2355
- const endpoint = `https://graph.facebook.com/v22.0/${env.META_AUDIENCE_ID}/users`;
2356
- const res = await fetch(endpoint, {
2357
- method: 'POST',
2358
- headers: { 'Content-Type': 'application/json' },
2359
- body: JSON.stringify({ ...body, access_token: env.META_ACCESS_TOKEN }),
2360
- });
2361
-
2362
- const result = await res.json();
2363
-
2364
- if (!res.ok) {
2365
- console.error('[CustomerMatch] Meta erro:', res.status, result.error?.message || 'unknown');
2366
- return { error: result.error?.message, sent: 0 };
2367
- }
2368
-
2369
- return { sent: profiles.results.length, num_received: result.num_received };
2370
-
2371
- } catch (err) {
2372
- console.error('[CustomerMatch] Meta fetch error:', err.message);
2373
- return { error: err.message, sent: 0 };
2374
- }
2375
- }
2376
-
2377
- /**
2378
- * Gera payload formatado para Google Ads Customer Match (upload manual ou via API).
2379
- * Retorna JSON com emails e phones hasheados prontos para upload.
2380
- *
2381
- * Google Ads Customer Match não tem API simples disponível via Workers sem OAuth2.
2382
- * Esta função gera o arquivo — o upload é feito via endpoint GET /export/customer-match.
2383
- */
2384
- async function buildGoogleCustomerMatchExport(env) {
2385
- if (!env.DB) return [];
2386
-
2387
- const profiles = await env.DB.prepare(`
2388
- SELECT email, phone, first_name, last_name
2389
- FROM user_profiles
2390
- WHERE cohort_label IN ('high_intent', 'buyer_lookalike')
2391
- AND updated_at > datetime('now', '-30 days')
2392
- AND email IS NOT NULL
2393
- LIMIT 10000
2394
- `).all();
2395
-
2396
- if (!profiles.results?.length) return [];
2397
-
2398
- return Promise.all(
2399
- profiles.results.map(async (p) => ({
2400
- hashed_email: p.email ? await sha256(p.email) : '',
2401
- hashed_phone: p.phone ? await sha256(p.phone) : '',
2402
- first_name: p.first_name || '',
2403
- last_name: p.last_name || '',
2404
- }))
2405
- );
2406
- }
2407
-
2408
- // ─────────────────────────────────────────────────────────────────────────────
2409
- // SEGMENTAÇÃO DINÂMICA ML — Handlers das Rotas de Clustering
2410
- // ─────────────────────────────────────────────────────────────────────────────
2411
-
2412
- // Helper: parse seguro de JSON armazenado como TEXT no D1
2413
- function tryParseJson(str, fallback) {
2414
- if (!str) return fallback !== undefined ? fallback : null;
2415
- try { return JSON.parse(str); } catch { return fallback !== undefined ? fallback : null; }
2416
- }
2417
-
2418
- // ── POST /api/segmentation/cluster ───────────────────────────────────────────
2419
- // Executa clustering K-means/DBSCAN/Hierarchical via Workers AI
2420
- // Requer bindings: DB + AI
2421
- async function handleSegmentationCluster(env, request, headers) {
2422
- if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
2423
- if (!env.AI) return new Response(JSON.stringify({ error: 'Workers AI não configurado (verifique binding AI no wrangler.toml)' }), { status: 503, headers });
2424
-
2425
- const url = new URL(request.url);
2426
- const algorithm = url.searchParams.get('algorithm') || 'kmeans';
2427
- const nClusters = Math.min(10, Math.max(3, parseInt(url.searchParams.get('n_clusters') || '5')));
2428
- const clientVertical = url.searchParams.get('vertical') || 'general';
2429
- const forceRecluster = url.searchParams.get('force') === 'true';
2430
-
2431
- if (!['kmeans', 'dbscan', 'hierarchical'].includes(algorithm)) {
2432
- return new Response(JSON.stringify({ error: 'algorithm deve ser: kmeans, dbscan ou hierarchical', received: algorithm }), { status: 400, headers });
2433
- }
2434
-
2435
- try {
2436
- // 1. Cluster recente? Evitar re-clustering desnecessário (< 7 dias)
2437
- if (!forceRecluster) {
2438
- const existing = await env.DB.prepare(`
2439
- SELECT id, created_at, cluster_name FROM ml_segments
2440
- WHERE clustering_algorithm = ? AND is_active = 1 AND client_vertical = ?
2441
- ORDER BY created_at DESC LIMIT 1
2442
- `).bind(algorithm, clientVertical).first();
2443
-
2444
- if (existing) {
2445
- const ageDays = (Date.now() - new Date(existing.created_at).getTime()) / (1000 * 60 * 60 * 24);
2446
- if (ageDays < 7) {
2447
- return new Response(JSON.stringify({
2448
- success: true,
2449
- message: 'Cluster existente ainda válido (< 7 dias). Use ?force=true para re-clustering.',
2450
- cluster_id: existing.id,
2451
- cluster_name: existing.cluster_name,
2452
- age_days: Math.round(ageDays * 10) / 10,
2453
- use_existing: true,
2454
- }), { status: 200, headers });
2455
- }
2456
- }
2457
- }
2458
-
2459
- // 2. Extrair leads históricos do D1 (últimos 6 meses, excluindo bots confirmados)
2460
- const leadsRes = await env.DB.prepare(`
2461
- SELECT id, predicted_ltv_class, engagement_score, intention_level,
2462
- country, state, utm_source, utm_medium, bot_score,
2463
- CAST(strftime('%H', created_at) AS INTEGER) AS hour_of_day,
2464
- CAST(julianday('now') - julianday(created_at) AS INTEGER) AS days_since_lead,
2465
- CASE WHEN strftime('%w', created_at) IN ('0','6') THEN 1 ELSE 0 END AS is_weekend
2466
- FROM leads
2467
- WHERE created_at >= datetime('now', '-6 months')
2468
- AND (bot_score IS NULL OR bot_score < 2)
2469
- ORDER BY RANDOM()
2470
- LIMIT 2000
2471
- `).all();
2472
-
2473
- const leads = leadsRes.results || [];
2474
-
2475
- if (leads.length < 50) {
2476
- return new Response(JSON.stringify({
2477
- error: 'Dados insuficientes para clustering. Mínimo: 50 leads nos últimos 6 meses.',
2478
- leads_found: leads.length,
2479
- required: 50,
2480
- }), { status: 400, headers });
2481
- }
2482
-
2483
- // 3. Feature Engineering — normalização 0–1
2484
- const features = leads.map(l => ({
2485
- id: l.id,
2486
- ltv: l.predicted_ltv_class === 'High' ? 1 : (l.predicted_ltv_class === 'Medium' ? 0.5 : 0),
2487
- engagement: Math.min((l.engagement_score || 0) / 100, 1),
2488
- intention: l.intention_level === 'comprador' || l.intention_level === 'high_intent' ? 1
2489
- : l.intention_level === 'interessado' ? 0.6
2490
- : l.intention_level === 'curioso' ? 0.3 : 0,
2491
- recency: Math.max(0, 1 - (l.days_since_lead || 0) / 180),
2492
- hour: (l.hour_of_day || 12) / 23,
2493
- is_weekend: l.is_weekend || 0,
2494
- is_br: l.country === 'BR' ? 1 : 0,
2495
- is_paid: ['facebook','google','tiktok','instagram','youtube'].includes(
2496
- (l.utm_source || '').toLowerCase()) ? 1 : 0,
2497
- }));
2498
-
2499
- // 4. Prompt para Workers AI
2500
- const sampleSize = Math.min(features.length, 100);
2501
- const sample = features.slice(0, sampleSize);
2502
-
2503
- const clusteringPrompt =
2504
- `You are a customer segmentation ML expert. Perform ${algorithm} clustering on ${sampleSize} customers into ${nClusters} segments.
2505
-
2506
- Customer features (all normalized 0-1):
2507
- - ltv: predicted lifetime value (0=Low, 0.5=Medium, 1=High)
2508
- - engagement: browser engagement score
2509
- - intention: purchase intention (0=none, 0.3=curious, 0.6=interested, 1=buyer)
2510
- - recency: lead recency (1=today, 0=6 months ago)
2511
- - hour: conversion hour of day
2512
- - is_weekend: converted on weekend (0/1)
2513
- - is_br: lead from Brazil (0/1)
2514
- - is_paid: from paid traffic channel (0/1)
2515
-
2516
- Data (${sampleSize} customers): ${JSON.stringify(sample.slice(0, 50))}
2517
-
2518
- Return ONLY valid JSON, zero explanation:
2519
- {
2520
- "clusters": [
2521
- {
2522
- "cluster_id": 0,
2523
- "name": "[Nome Descritivo em Português]",
2524
- "size": ${Math.round(sampleSize / nClusters)},
2525
- "percentage": ${Math.round(100 / nClusters)},
2526
- "characteristics": {
2527
- "avg_ltv_class": 0.5,
2528
- "avg_behavior_score": 0.5,
2529
- "avg_engagement_score": 0.5,
2530
- "avg_intention_level": 0.5,
2531
- "avg_days_since_lead": 30,
2532
- "dominant_countries": ["BR"],
2533
- "dominant_states": ["SP", "RJ"],
2534
- "dominant_utm_sources": ["facebook"],
2535
- "top_features": ["ltv", "engagement"]
2536
- },
2537
- "centroid": { "ltv": 0.5, "engagement": 0.5, "intention": 0.5 },
2538
- "action_recommendation": "[Recomendação de campanha específica para este segmento]"
2539
- }
2540
- ],
2541
- "silhouette_score": 0.65,
2542
- "total_processed": ${sampleSize}
2543
- }`;
2544
-
2545
- // 5. Executar via Workers AI
2546
- const startTime = Date.now();
2547
- const aiRes = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
2548
- messages: [{ role: 'user', content: clusteringPrompt }],
2549
- max_tokens: 2000,
2550
- });
2551
- const duration = Date.now() - startTime;
2552
-
2553
- if (!aiRes?.response) throw new Error('Workers AI não retornou resposta');
2554
-
2555
- // 6. Parse do resultado
2556
- const jsonMatch = aiRes.response.trim().match(/\{[\s\S]*\}/);
2557
- if (!jsonMatch) throw new Error('Resposta do Workers AI não contém JSON válido');
2558
- const mlResult = JSON.parse(jsonMatch[0]);
2559
-
2560
- if (!Array.isArray(mlResult.clusters) || mlResult.clusters.length === 0) {
2561
- throw new Error('Workers AI não retornou clusters válidos');
2562
- }
2563
-
2564
- // 7. Inativar clusters anteriores do mesmo algoritmo/vertical
2565
- await env.DB.prepare(
2566
- `UPDATE ml_segments SET is_active = 0 WHERE clustering_algorithm = ? AND client_vertical = ? AND is_active = 1`
2567
- ).bind(algorithm, clientVertical).run();
2568
-
2569
- // 8. Persistir novos clusters no D1
2570
- const now = new Date().toISOString();
2571
- for (const cluster of mlResult.clusters) {
2572
- const ch = cluster.characteristics || {};
2573
- await env.DB.prepare(`
2574
- INSERT INTO ml_segments (
2575
- cluster_id, cluster_name, clustering_algorithm, client_vertical,
2576
- size, percentage,
2577
- avg_ltv_class, avg_behavior_score, avg_engagement_score,
2578
- avg_intention_level, avg_days_since_lead,
2579
- dominant_countries, dominant_states, dominant_utm_sources, dominant_features,
2580
- silhouette_score, action_recommendations, bid_recommendations, campaign_recommendations,
2581
- is_active, created_at, updated_at
2582
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)
2583
- `).bind(
2584
- cluster.cluster_id || 0,
2585
- cluster.name || `Segmento ${cluster.cluster_id}`,
2586
- algorithm,
2587
- clientVertical,
2588
- cluster.size || 0,
2589
- cluster.percentage || 0,
2590
- ch.avg_ltv_class || 0,
2591
- ch.avg_behavior_score || 0,
2592
- ch.avg_engagement_score || 0,
2593
- ch.avg_intention_level || 0,
2594
- ch.avg_days_since_lead || 0,
2595
- JSON.stringify(ch.dominant_countries || ['BR']),
2596
- JSON.stringify(ch.dominant_states || []),
2597
- JSON.stringify(ch.dominant_utm_sources || []),
2598
- JSON.stringify(ch.top_features || []),
2599
- mlResult.silhouette_score || 0,
2600
- JSON.stringify([cluster.action_recommendation || '']),
2601
- JSON.stringify([]),
2602
- JSON.stringify([]),
2603
- now,
2604
- now,
2605
- ).run();
2606
- }
2607
-
2608
- // 9. Log no histórico de clustering
2609
- try {
2610
- await env.DB.prepare(`
2611
- INSERT INTO ml_clustering_history (
2612
- clustering_id, started_at, completed_at, algorithm,
2613
- n_leads_processed, n_clusters_created, total_duration_ms,
2614
- workers_ai_neurons_used, status, parameters, results_summary
2615
- ) VALUES (0, ?, datetime('now'), ?, ?, ?, ?, ?, 'completed', ?, ?)
2616
- `).bind(
2617
- new Date(startTime).toISOString(),
2618
- algorithm,
2619
- leads.length,
2620
- mlResult.clusters.length,
2621
- duration,
2622
- Math.ceil(duration * 0.01),
2623
- JSON.stringify({ algorithm, n_clusters: nClusters, vertical: clientVertical }),
2624
- JSON.stringify({ clusters: mlResult.clusters.length, silhouette: mlResult.silhouette_score }),
2625
- ).run();
2626
- } catch (e) { console.error('[Segmentation] history log error:', e.message); }
2627
-
2628
- return new Response(JSON.stringify({
2629
- success: true,
2630
- algorithm,
2631
- n_clusters: mlResult.clusters.length,
2632
- client_vertical: clientVertical,
2633
- leads_analyzed: leads.length,
2634
- duration_ms: duration,
2635
- silhouette_score: mlResult.silhouette_score || null,
2636
- clusters: mlResult.clusters,
2637
- generated_at: now,
2638
- }), { status: 200, headers });
2639
-
2640
- } catch (err) {
2641
- console.error('[Segmentation] cluster error:', err.message);
2642
- try {
2643
- if (env.DB) {
2644
- await env.DB.prepare(`
2645
- INSERT INTO ml_clustering_history
2646
- (clustering_id, started_at, algorithm, n_leads_processed, n_clusters_created,
2647
- total_duration_ms, workers_ai_neurons_used, status, error_message, parameters, results_summary)
2648
- VALUES (0, datetime('now'), ?, 0, 0, 0, 0, 'failed', ?, ?, '{}')
2649
- `).bind(algorithm, err.message, JSON.stringify({ algorithm, n_clusters: nClusters })).run();
2650
- }
2651
- } catch { /* não bloquear a resposta de erro */ }
2652
-
2653
- return new Response(JSON.stringify({ error: 'Erro ao executar clustering', message: err.message }), { status: 500, headers });
2654
- }
2655
- }
2656
-
2657
- // ── GET /api/segmentation/list ────────────────────────────────────────────────
2658
- // Lista todos os segmentos ativos com estatísticas
2659
- async function handleSegmentationList(env, request, headers) {
2660
- if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
2661
-
2662
- const url = new URL(request.url);
2663
- const algorithm = url.searchParams.get('algorithm') || null;
2664
- const vertical = url.searchParams.get('vertical') || null;
2665
-
2666
- try {
2667
- const conditions = ['is_active = 1'];
2668
- const bindings = [];
2669
- if (algorithm) { conditions.push('clustering_algorithm = ?'); bindings.push(algorithm); }
2670
- if (vertical) { conditions.push('client_vertical = ?'); bindings.push(vertical); }
2671
-
2672
- const query = `
2673
- SELECT id, cluster_id, cluster_name, clustering_algorithm, client_vertical,
2674
- size, percentage, avg_ltv_class, avg_behavior_score, avg_engagement_score,
2675
- avg_intention_level, avg_days_since_lead, silhouette_score,
2676
- dominant_countries, dominant_states, dominant_utm_sources, dominant_features,
2677
- action_recommendations, bid_recommendations, campaign_recommendations,
2678
- is_active, created_at, updated_at
2679
- FROM ml_segments
2680
- WHERE ${conditions.join(' AND ')}
2681
- ORDER BY created_at DESC
2682
- LIMIT 50
2683
- `;
2684
-
2685
- const result = await env.DB.prepare(query).bind(...bindings).all();
2686
- const segments = (result.results || []).map(s => ({
2687
- ...s,
2688
- dominant_countries: tryParseJson(s.dominant_countries, []),
2689
- dominant_states: tryParseJson(s.dominant_states, []),
2690
- dominant_utm_sources: tryParseJson(s.dominant_utm_sources, []),
2691
- dominant_features: tryParseJson(s.dominant_features, []),
2692
- action_recommendations: tryParseJson(s.action_recommendations, []),
2693
- bid_recommendations: tryParseJson(s.bid_recommendations, []),
2694
- campaign_recommendations: tryParseJson(s.campaign_recommendations, []),
2695
- }));
2696
-
2697
- return new Response(JSON.stringify({
2698
- success: true,
2699
- total: segments.length,
2700
- segments,
2701
- }), { status: 200, headers });
2702
-
2703
- } catch (err) {
2704
- console.error('[Segmentation] list error:', err.message);
2705
- return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
2706
- }
2707
- }
2708
-
2709
- // ── GET /api/segmentation/outliers ───────────────────────────────────────────
2710
- // Lista leads marcados como outliers no ml_segment_members (DBSCAN)
2711
- async function handleSegmentationOutliers(env, request, headers) {
2712
- if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
2713
-
2714
- const url = new URL(request.url);
2715
- const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 200);
2716
- const days = parseInt(url.searchParams.get('days') || '30');
2717
-
2718
- try {
2719
- const result = await env.DB.prepare(`
2720
- SELECT msm.lead_id, msm.cluster_id, msm.confidence, msm.is_outlier,
2721
- msm.outlier_reason, msm.assigned_at,
2722
- l.email, l.phone, l.country, l.state, l.city,
2723
- l.utm_source, l.bot_score, l.engagement_score, l.intention_level,
2724
- l.created_at AS lead_created_at
2725
- FROM ml_segment_members msm
2726
- LEFT JOIN leads l ON CAST(msm.lead_id AS INTEGER) = l.id
2727
- WHERE msm.is_outlier = 1
2728
- AND msm.assigned_at >= datetime('now', '-' || ? || ' days')
2729
- ORDER BY msm.assigned_at DESC
2730
- LIMIT ?
2731
- `).bind(days, limit).all();
2732
-
2733
- return new Response(JSON.stringify({
2734
- success: true,
2735
- total: (result.results || []).length,
2736
- period_days: days,
2737
- outliers: result.results || [],
2738
- }), { status: 200, headers });
2739
-
2740
- } catch (err) {
2741
- console.error('[Segmentation] outliers error:', err.message);
2742
- return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
2743
- }
2744
- }
2745
-
2746
- // ── PUT /api/segmentation/update ─────────────────────────────────────────────
2747
- // Atualiza recomendações de ação/bid/campanha de um segmento existente
2748
- async function handleSegmentationUpdate(env, request, headers) {
2749
- if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
2750
-
2751
- let body;
2752
- try { body = await request.json(); }
2753
- catch { return new Response(JSON.stringify({ error: 'JSON inválido no body da requisição' }), { status: 400, headers }); }
2754
-
2755
- const { cluster_id, action_recommendations, bid_recommendations, campaign_recommendations } = body;
2756
-
2757
- if (cluster_id === undefined || cluster_id === null) {
2758
- return new Response(JSON.stringify({ error: 'cluster_id é obrigatório' }), { status: 400, headers });
2759
- }
2760
-
2761
- try {
2762
- const sets = [];
2763
- const bindings = [];
2764
-
2765
- if (action_recommendations !== undefined) { sets.push('action_recommendations = ?'); bindings.push(JSON.stringify(action_recommendations)); }
2766
- if (bid_recommendations !== undefined) { sets.push('bid_recommendations = ?'); bindings.push(JSON.stringify(bid_recommendations)); }
2767
- if (campaign_recommendations !== undefined) { sets.push('campaign_recommendations = ?'); bindings.push(JSON.stringify(campaign_recommendations)); }
2768
-
2769
- if (sets.length === 0) {
2770
- return new Response(JSON.stringify({ error: 'Nenhum campo válido para atualizar (action_recommendations, bid_recommendations, campaign_recommendations)' }), { status: 400, headers });
2771
- }
2772
-
2773
- sets.push("updated_at = datetime('now')");
2774
- bindings.push(cluster_id);
2775
-
2776
- await env.DB.prepare(
2777
- `UPDATE ml_segments SET ${sets.join(', ')} WHERE id = ?`
2778
- ).bind(...bindings).run();
2779
-
2780
- return new Response(JSON.stringify({
2781
- success: true,
2782
- cluster_id,
2783
- fields_updated: sets.length - 1, // exclui o updated_at
2784
- }), { status: 200, headers });
2785
-
2786
- } catch (err) {
2787
- console.error('[Segmentation] update error:', err.message);
2788
- return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
2789
- }
2790
- }
2791
-
2792
- // ─────────────────────────────────────────────────────────────────────────────
2793
- // FRAUD DETECTION ENGINE — Fase 4 Enterprise-Level
2794
- // Heurístico puro (sem AI) — latência zero no /track
2795
- // ─────────────────────────────────────────────────────────────────────────────
2796
-
2797
- // Domínios de email descartáveis
2798
- const DISPOSABLE_EMAIL_DOMAINS = new Set([
2799
- 'mailinator.com','guerrillamail.com','tempmail.com','throwaway.email',
2800
- 'yopmail.com','sharklasers.com','guerrillamailblock.com','spam4.me',
2801
- '10minutemail.com','trashmail.com','maildrop.cc','fakeinbox.com',
2802
- 'dispostable.com','mailnull.com','tempr.email','getnada.com',
2803
- ]);
2804
-
2805
- // ASNs conhecidos de datacenters (evitar falsos negativos em ASNs legítimos)
2806
- const DATACENTER_PATTERNS = /amazon|google|microsoft|digitalocean|linode|ovh|vultr|hetzner|contabo|cloudflare|packet|rackspace|leaseweb/i;
2807
-
2808
- // ── checkFraudGate — roda sincronamente ANTES de processar o evento ────────────
2809
- // Retorna { allowed, score, reasons, action }
2810
- // NUNCA joga erro — qualquer falha = allowed (fail-safe)
2811
- async function checkFraudGate(env, request, payload) {
2812
- const result = { allowed: true, score: 0, reasons: [], action: 'allowed' };
2813
-
2814
- try {
2815
- const ip = request.headers.get('CF-Connecting-IP') || '';
2816
- const ua = request.headers.get('User-Agent') || '';
2817
- const fingerprint = payload.fingerprint || '';
2818
- const email = payload.email || '';
2819
- const botScore = parseInt(payload.botScore || payload.bot_score || 0);
2820
- const asn = String(request.cf?.asOrganization || '').toLowerCase();
2821
- const country = (request.cf?.country || '').toUpperCase();
2822
- const acceptLang = request.headers.get('Accept-Language');
2823
-
2824
- // 1. KV blocklist check — instantâneo (~0ms)
2825
- if (env.GEO_CACHE && ip) {
2826
- const ipBlocked = await env.GEO_CACHE.get(`fraud_block:ip:${ip}`);
2827
- if (ipBlocked) {
2828
- return { allowed: false, score: 100, reasons: ['ip_blocklisted'], action: 'dropped' };
2829
- }
2830
- }
2831
- if (env.GEO_CACHE && fingerprint) {
2832
- const fpBlocked = await env.GEO_CACHE.get(`fraud_block:fp:${fingerprint}`);
2833
- if (fpBlocked) {
2834
- return { allowed: false, score: 100, reasons: ['fingerprint_blocklisted'], action: 'dropped' };
2835
- }
2836
- }
2837
-
2838
- // 2. Bot score (já calculado pelo Worker)
2839
- if (botScore >= 3) { result.score += 60; result.reasons.push('bot_score_high'); }
2840
- else if (botScore === 2) { result.score += 30; result.reasons.push('bot_score_medium'); }
2841
-
2842
- // 3. User-Agent suspeito
2843
- if (/headless|phantomjs|selenium|webdriver|curl|python|scrapy|bot|crawler|spider/i.test(ua)) {
2844
- result.score += 40; result.reasons.push('suspicious_user_agent');
2845
- }
2846
-
2847
- // 4. Datacenter IP
2848
- if (ip && DATACENTER_PATTERNS.test(asn)) {
2849
- result.score += 35; result.reasons.push('datacenter_ip');
2850
- }
2851
-
2852
- // 5. Sem Accept-Language (bots raramente enviam)
2853
- if (!acceptLang) {
2854
- result.score += 20; result.reasons.push('no_accept_language');
2855
- }
2856
-
2857
- // 6. Email descartável
2858
- if (email) {
2859
- const domain = email.split('@')[1]?.toLowerCase();
2860
- if (domain && DISPOSABLE_EMAIL_DOMAINS.has(domain)) {
2861
- result.score += 25; result.reasons.push('disposable_email');
2862
- }
2863
- }
2864
-
2865
- // 7. Velocity check via KV
2866
- if (env.GEO_CACHE && ip) {
2867
- const velKey1h = `fraud_velocity:${ip}:h`;
2868
- const velStr = await env.GEO_CACHE.get(velKey1h);
2869
- const vel1h = parseInt(velStr || '0') + 1;
2870
-
2871
- // Atualizar contador (TTL: 3600s = 1h)
2872
- await env.GEO_CACHE.put(velKey1h, String(vel1h), { expirationTtl: 3600 });
2873
-
2874
- if (vel1h > 20) { result.score += 50; result.reasons.push('ip_velocity_very_high'); }
2875
- else if (vel1h > 10) { result.score += 25; result.reasons.push('ip_velocity_high'); }
2876
- }
2877
-
2878
- result.score = Math.min(100, result.score);
2879
-
2880
- // 8. Decisão final
2881
- if (result.score >= 80) {
2882
- result.allowed = false;
2883
- result.action = 'dropped';
2884
- } else if (result.score >= 40) {
2885
- result.action = 'flagged';
2886
- // Ainda permite o evento, mas loga como suspeito
2887
- }
2888
-
2889
- return result;
2890
-
2891
- } catch (err) {
2892
- console.error('[Fraud] checkFraudGate error:', err.message);
2893
- return { allowed: true, score: 0, reasons: ['gate_error_fallback'], action: 'allowed' };
2894
- }
2895
- }
2896
-
2897
- // ── logFraudSignal — persiste no D1 em background via ctx.waitUntil ───────────
2898
- async function logFraudSignal(env, request, payload, fraudResult) {
2899
- if (!env.DB || fraudResult.action === 'allowed') return; // só loga suspeitos/dropped
2900
- try {
2901
- const ip = request.headers.get('CF-Connecting-IP') || '';
2902
- const ua = request.headers.get('User-Agent') || '';
2903
- const fingerprint = payload.fingerprint || '';
2904
- const botScore = parseInt(payload.botScore || payload.bot_score || 0);
2905
- const asn = String(request.cf?.asOrganization || '');
2906
- const country = (request.cf?.country || '');
2907
- const velKey1h = `fraud_velocity:${ip}:h`;
2908
- const vel1h = env.GEO_CACHE ? parseInt(await env.GEO_CACHE.get(velKey1h) || '0') : 0;
2909
-
2910
- let emailHash = null;
2911
- if (payload.email) {
2912
- try { emailHash = await sha256(payload.email.trim().toLowerCase()); } catch {}
2913
- }
2914
-
2915
- await env.DB.prepare(`
2916
- INSERT INTO fraud_signals (
2917
- ip_address, fingerprint, user_id, email_hash, event_name, event_id,
2918
- fraud_score, action_taken, reasons,
2919
- ip_country, ip_asn, user_agent, bot_score, velocity_1h, detected_at
2920
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
2921
- `).bind(
2922
- ip, fingerprint || null, payload.userId || null, emailHash,
2923
- payload.eventName || null, payload.eventId || null,
2924
- fraudResult.score, fraudResult.action, JSON.stringify(fraudResult.reasons),
2925
- country, asn, ua.substring(0, 255), botScore, vel1h,
2926
- ).run();
2927
-
2928
- // Se dropped com score alto → criar/atualizar fraud_alert para este IP
2929
- if (fraudResult.action === 'dropped' && ip) {
2930
- await env.DB.prepare(`
2931
- INSERT INTO fraud_alerts (alert_type, entity_type, entity_value, events_total, events_dropped, peak_score, first_seen, last_seen, top_reasons)
2932
- VALUES ('ip_attack', 'ip', ?, 1, 1, ?, datetime('now'), datetime('now'), ?)
2933
- ON CONFLICT(entity_type, entity_value) DO UPDATE SET
2934
- events_total = events_total + 1,
2935
- events_dropped = events_dropped + 1,
2936
- peak_score = MAX(peak_score, excluded.peak_score),
2937
- last_seen = datetime('now'),
2938
- updated_at = datetime('now')
2939
- `).bind(ip, fraudResult.score, JSON.stringify(fraudResult.reasons)).run().catch(() => {
2940
- // Pode falhar se ON CONFLICT não funcionar (schema sem UNIQUE) — silent
2941
- });
2942
- }
2943
- } catch (err) {
2944
- console.error('[Fraud] logFraudSignal error:', err.message);
2945
- }
2946
- }
2947
-
2948
- // ── handleFraudAlerts — GET /api/fraud/alerts ─────────────────────────────────
2949
- async function handleFraudAlerts(env, request, headers) {
2950
- if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
2951
-
2952
- const url = new URL(request.url);
2953
- const action = url.searchParams.get('action') || null; // 'dropped','flagged'
2954
- const hours = parseInt(url.searchParams.get('hours') || '24');
2955
- const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 200);
2956
-
2957
- try {
2958
- const cond = action ? 'AND action_taken = ?' : '';
2959
- const bindings = action ? [hours, action, limit] : [hours, limit];
2960
-
2961
- const result = await env.DB.prepare(`
2962
- SELECT ip_address, fingerprint, event_name, fraud_score, action_taken,
2963
- reasons, ip_country, ip_asn, bot_score, velocity_1h, detected_at
2964
- FROM fraud_signals
2965
- WHERE detected_at >= datetime('now', '-' || ? || ' hours')
2966
- ${cond}
2967
- ORDER BY fraud_score DESC, detected_at DESC
2968
- LIMIT ?
2969
- `).bind(...bindings).all();
2970
-
2971
- const signals = (result.results || []).map(s => ({
2972
- ...s,
2973
- reasons: tryParseJson(s.reasons, []),
2974
- }));
2975
-
2976
- // Stats rápidas
2977
- const stats = await env.DB.prepare(`SELECT * FROM v_fraud_dashboard`).first().catch(() => null);
2978
-
2979
- return new Response(JSON.stringify({
2980
- success: true,
2981
- period_hours: hours,
2982
- total: signals.length,
2983
- stats,
2984
- alerts: signals,
2985
- }), { status: 200, headers });
2986
-
2987
- } catch (err) {
2988
- console.error('[Fraud] alerts error:', err.message);
2989
- return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
2990
- }
2991
- }
2992
-
2993
- // ── handleFraudBlocklist — GET /api/fraud/blocklist ──────────────────────────
2994
- async function handleFraudBlocklist(env, request, headers) {
2995
- if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
2996
-
2997
- try {
2998
- const result = await env.DB.prepare(`
2999
- SELECT entity_type, entity_value, events_total, events_dropped,
3000
- peak_score, first_seen, last_seen, blocked_at, block_expires, top_reasons
3001
- FROM fraud_alerts
3002
- WHERE is_blocked = 1
3003
- ORDER BY events_dropped DESC
3004
- LIMIT 100
3005
- `).all();
3006
-
3007
- const blocklist = (result.results || []).map(r => ({
3008
- ...r,
3009
- top_reasons: tryParseJson(r.top_reasons, []),
3010
- }));
3011
-
3012
- return new Response(JSON.stringify({
3013
- success: true,
3014
- total: blocklist.length,
3015
- blocklist,
3016
- }), { status: 200, headers });
3017
-
3018
- } catch (err) {
3019
- console.error('[Fraud] blocklist error:', err.message);
3020
- return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
3021
- }
3022
- }
3023
-
3024
- // ── handleFraudBlocklistAdd — POST /api/fraud/blocklist/add ──────────────────
3025
- async function handleFraudBlocklistAdd(env, request, headers) {
3026
- if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
3027
-
3028
- let body;
3029
- try { body = await request.json(); }
3030
- catch { return new Response(JSON.stringify({ error: 'JSON inválido' }), { status: 400, headers }); }
3031
-
3032
- const { entity_type, entity_value, ttl_hours = 24, reason = 'manual_block' } = body;
3033
- if (!entity_type || !entity_value) {
3034
- return new Response(JSON.stringify({ error: 'entity_type (ip|fingerprint) e entity_value são obrigatórios' }), { status: 400, headers });
3035
- }
3036
- if (!['ip', 'fingerprint'].includes(entity_type)) {
3037
- return new Response(JSON.stringify({ error: 'entity_type deve ser: ip ou fingerprint' }), { status: 400, headers });
3038
- }
3039
-
3040
- try {
3041
- const kvKey = `fraud_block:${entity_type}:${entity_value}`;
3042
- const ttlSec = Math.min(ttl_hours * 3600, 7 * 24 * 3600); // max 7 dias
3043
- const expiresAt = new Date(Date.now() + ttlSec * 1000).toISOString();
3044
-
3045
- // Adicionar no KV (checagem instantânea em /track)
3046
- if (env.GEO_CACHE) {
3047
- await env.GEO_CACHE.put(kvKey, JSON.stringify({ reason, blocked_at: new Date().toISOString() }), { expirationTtl: ttlSec });
3048
- }
3049
-
3050
- // Registrar no D1 para auditoria
3051
- await env.DB.prepare(`
3052
- INSERT INTO fraud_alerts (alert_type, entity_type, entity_value, events_total, events_dropped, peak_score, first_seen, last_seen, is_blocked, blocked_at, block_expires, top_reasons)
3053
- VALUES ('manual', ?, ?, 0, 0, 100, datetime('now'), datetime('now'), 1, datetime('now'), ?, ?)
3054
- ON CONFLICT DO UPDATE SET is_blocked = 1, blocked_at = datetime('now'), block_expires = excluded.block_expires, updated_at = datetime('now')
3055
- `).bind(entity_type, entity_value, expiresAt, JSON.stringify([reason])).run().catch(() => {
3056
- // fallback se não tiver UNIQUE constraint na fraud_alerts
3057
- });
3058
-
3059
- return new Response(JSON.stringify({
3060
- success: true,
3061
- entity_type,
3062
- entity_value,
3063
- kv_key: kvKey,
3064
- ttl_hours,
3065
- expires_at: expiresAt,
3066
- message: `${entity_type} '${entity_value}' bloqueado por ${ttl_hours}h. Efeito imediato via KV.`,
3067
- }), { status: 200, headers });
3068
-
3069
- } catch (err) {
3070
- console.error('[Fraud] blocklist add error:', err.message);
3071
- return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
3072
- }
3073
- }
3074
-
3075
- // ── handleFraudBlocklistRemove — DELETE /api/fraud/blocklist/remove ──────────
3076
- async function handleFraudBlocklistRemove(env, request, headers) {
3077
- if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
3078
-
3079
- let body;
3080
- try { body = await request.json(); }
3081
- catch { return new Response(JSON.stringify({ error: 'JSON inválido' }), { status: 400, headers }); }
3082
-
3083
- const { entity_type, entity_value } = body;
3084
- if (!entity_type || !entity_value) {
3085
- return new Response(JSON.stringify({ error: 'entity_type e entity_value são obrigatórios' }), { status: 400, headers });
3086
- }
3087
-
3088
- try {
3089
- const kvKey = `fraud_block:${entity_type}:${entity_value}`;
3090
- if (env.GEO_CACHE) await env.GEO_CACHE.delete(kvKey);
3091
-
3092
- await env.DB.prepare(
3093
- `UPDATE fraud_alerts SET is_blocked = 0, resolved_at = datetime('now'), resolved_by = 'manual' WHERE entity_type = ? AND entity_value = ?`
3094
- ).bind(entity_type, entity_value).run();
3095
-
3096
- return new Response(JSON.stringify({
3097
- success: true,
3098
- entity_type,
3099
- entity_value,
3100
- message: `${entity_type} '${entity_value}' removido do blocklist. Efeito imediato via KV.`,
3101
- }), { status: 200, headers });
3102
-
3103
- } catch (err) {
3104
- console.error('[Fraud] blocklist remove error:', err.message);
3105
- return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
3106
- }
3107
- }
3108
-
3109
- // ── handleFraudStats — GET /api/fraud/stats ───────────────────────────────────
3110
- async function handleFraudStats(env, request, headers) {
3111
- if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
3112
-
3113
- try {
3114
- const dashboard = await env.DB.prepare(`SELECT * FROM v_fraud_dashboard`).first();
3115
- const topIps = await env.DB.prepare(`
3116
- SELECT ip_address, COUNT(*) as events, MAX(fraud_score) as peak_score
3117
- FROM fraud_signals
3118
- WHERE detected_at >= datetime('now', '-24 hours') AND action_taken = 'dropped'
3119
- GROUP BY ip_address ORDER BY events DESC LIMIT 10
3120
- `).all();
3121
- const topReasons = await env.DB.prepare(`
3122
- SELECT action_taken, COUNT(*) as count FROM fraud_signals
3123
- WHERE detected_at >= datetime('now', '-24 hours')
3124
- GROUP BY action_taken
3125
- `).all();
3126
-
3127
- return new Response(JSON.stringify({
3128
- success: true,
3129
- period: '24h',
3130
- dashboard,
3131
- top_attacking_ips: topIps.results || [],
3132
- by_action: topReasons.results || [],
3133
- }), { status: 200, headers });
3134
-
3135
- } catch (err) {
3136
- console.error('[Fraud] stats error:', err.message);
3137
- return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
3138
- }
3139
- }
3140
-
3141
- // ─────────────────────────────────────────────────────────────────────────────
3142
- // A/B TESTING DE PROMPTS LTV — Fase 3 Enterprise-Level
3143
- // ─────────────────────────────────────────────────────────────────────────────
3144
-
3145
- // Cache key para o teste ativo (KV — evita hit no D1 a cada request /track)
3146
- const AB_LTV_CACHE_KEY = 'ab_ltv_active_test';
3147
-
3148
- // ── getLtvAbVariation — busca e sorteia variação do teste ativo ─────────────
3149
- // Retorna null se não há teste ativo ou DB/KV indisponíveis
3150
- async function getLtvAbVariation(env) {
3151
- if (!env.DB) return null;
3152
-
3153
- try {
3154
- // 1. Tentar cache KV (TTL: 5 min)
3155
- let testData = null;
3156
- if (env.GEO_CACHE) {
3157
- const cached = await env.GEO_CACHE.get(AB_LTV_CACHE_KEY, 'json');
3158
- if (cached) testData = cached;
3159
- }
3160
-
3161
- // 2. Cache miss ou KV indisponível → buscar do D1
3162
- if (!testData) {
3163
- const test = await env.DB.prepare(`
3164
- SELECT t.id AS test_id, v.id AS variation_id,
3165
- v.name, v.system_prompt, v.weight, v.is_control
3166
- FROM ltv_ab_tests t
3167
- JOIN ltv_ab_variations v ON v.test_id = t.id
3168
- WHERE t.status = 'running'
3169
- ORDER BY t.id DESC
3170
- `).all();
3171
-
3172
- if (!test.results || test.results.length === 0) {
3173
- // Sem teste ativo — cachear null por 5 min para não bater no D1
3174
- if (env.GEO_CACHE) {
3175
- await env.GEO_CACHE.put(AB_LTV_CACHE_KEY, JSON.stringify(null), { expirationTtl: 300 });
3176
- }
3177
- return null;
3178
- }
3179
-
3180
- testData = test.results;
3181
- if (env.GEO_CACHE) {
3182
- await env.GEO_CACHE.put(AB_LTV_CACHE_KEY, JSON.stringify(testData), { expirationTtl: 300 });
3183
- }
3184
- }
3185
-
3186
- if (!testData || testData.length === 0) return null;
3187
-
3188
- // 3. Sortear variação por peso ponderado
3189
- const totalWeight = testData.reduce((s, v) => s + (v.weight || 0.5), 0);
3190
- let rand = Math.random() * totalWeight;
3191
- for (const variation of testData) {
3192
- rand -= (variation.weight || 0.5);
3193
- if (rand <= 0) return variation;
3194
- }
3195
- return testData[testData.length - 1];
3196
-
3197
- } catch (err) {
3198
- console.error('[AB-LTV] getLtvAbVariation error:', err.message);
3199
- return null; // graceful fallback — nunca quebra o fluxo principal
3200
- }
3201
- }
3202
-
3203
- // ── recordAbAssignment — registra a variação usada para um lead ──────────────
3204
- // Executado via ctx.waitUntil — não bloqueia o /track
3205
- async function recordAbAssignment(env, userId, variationId, testId, predictedLtv, predictedClass, emailHash) {
3206
- if (!env.DB) return;
3207
- try {
3208
- await env.DB.prepare(`
3209
- INSERT INTO ltv_ab_assignments
3210
- (test_id, variation_id, user_id, email_hash, predicted_ltv, predicted_class, assigned_at)
3211
- VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
3212
- `).bind(testId, variationId, userId, emailHash || null, predictedLtv || null, predictedClass || null).run();
3213
-
3214
- // Incrementar contador na variação
3215
- await env.DB.prepare(
3216
- `UPDATE ltv_ab_variations SET total_assigned = total_assigned + 1 WHERE id = ?`
3217
- ).bind(variationId).run();
3218
- } catch (err) {
3219
- console.error('[AB-LTV] recordAbAssignment error:', err.message);
3220
- }
3221
- }
3222
-
3223
- // ── handleLtvAbTestCreate — POST /api/ltv/ab-test/create ─────────────────────
3224
- async function handleLtvAbTestCreate(env, request, headers) {
3225
- if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
3226
-
3227
- let body;
3228
- try { body = await request.json(); }
3229
- catch { return new Response(JSON.stringify({ error: 'JSON inválido no body' }), { status: 400, headers }); }
3230
-
3231
- const { name, description, min_sample = 100, variations } = body;
3232
-
3233
- if (!name) return new Response(JSON.stringify({ error: 'name é obrigatório' }), { status: 400, headers });
3234
- if (!Array.isArray(variations) || variations.length < 2) {
3235
- return new Response(JSON.stringify({ error: 'Mínimo 2 variações são necessárias' }), { status: 400, headers });
3236
- }
3237
-
3238
- // Verificar se há teste em andamento
3239
- const running = await env.DB.prepare(`SELECT id FROM ltv_ab_tests WHERE status = 'running' LIMIT 1`).first();
3240
- if (running) {
3241
- return new Response(JSON.stringify({
3242
- error: 'Já existe um teste em andamento. Pause ou conclua o teste atual antes de criar um novo.',
3243
- running_test_id: running.id,
3244
- }), { status: 409, headers });
3245
- }
3246
-
3247
- try {
3248
- const now = new Date().toISOString();
3249
-
3250
- // Validar que pesos somam aproximadamente 1.0
3251
- const totalWeight = variations.reduce((s, v) => s + (v.weight || 0.5), 0);
3252
- if (Math.abs(totalWeight - 1.0) > 0.05) {
3253
- return new Response(JSON.stringify({
3254
- error: `A soma dos weights deve ser 1.0. Recebido: ${totalWeight.toFixed(3)}`,
3255
- }), { status: 400, headers });
3256
- }
3257
-
3258
- const hasControl = variations.some(v => v.is_control);
3259
- if (!hasControl) {
3260
- return new Response(JSON.stringify({ error: 'Pelo menos uma variação deve ter is_control: true (baseline)' }), { status: 400, headers });
3261
- }
3262
-
3263
- // Criar teste
3264
- const testRes = await env.DB.prepare(`
3265
- INSERT INTO ltv_ab_tests (name, description, status, min_sample, created_at)
3266
- VALUES (?, ?, 'running', ?, ?)
3267
- `).bind(name, description || null, min_sample, now).run();
3268
-
3269
- const testId = testRes.meta?.last_row_id;
3270
- if (!testId) throw new Error('Falha ao criar o teste no D1');
3271
-
3272
- // Criar variações
3273
- const createdVariations = [];
3274
- for (const v of variations) {
3275
- const vRes = await env.DB.prepare(`
3276
- INSERT INTO ltv_ab_variations (test_id, name, system_prompt, weight, is_control, created_at)
3277
- VALUES (?, ?, ?, ?, ?, ?)
3278
- `).bind(testId, v.name, v.system_prompt, v.weight || 0.5, v.is_control ? 1 : 0, now).run();
3279
- createdVariations.push({ id: vRes.meta?.last_row_id, name: v.name, weight: v.weight, is_control: !!v.is_control });
3280
- }
3281
-
3282
- // Invalidar cache KV
3283
- if (env.GEO_CACHE) await env.GEO_CACHE.delete(AB_LTV_CACHE_KEY);
3284
-
3285
- return new Response(JSON.stringify({
3286
- success: true,
3287
- test_id: testId,
3288
- name,
3289
- status: 'running',
3290
- min_sample,
3291
- variations: createdVariations,
3292
- started_at: now,
3293
- }), { status: 201, headers });
3294
-
3295
- } catch (err) {
3296
- console.error('[AB-LTV] create error:', err.message);
3297
- return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
3298
- }
3299
- }
3300
-
3301
- // ── handleLtvAbTestList — GET /api/ltv/ab-test/list ──────────────────────────
3302
- async function handleLtvAbTestList(env, request, headers) {
3303
- if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
3304
-
3305
- const url = new URL(request.url);
3306
- const status = url.searchParams.get('status') || null;
3307
- const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 50);
3308
-
3309
- try {
3310
- const cond = status ? 'WHERE t.status = ?' : '';
3311
- const bindings = status ? [status, limit] : [limit];
3312
-
3313
- const tests = await env.DB.prepare(`
3314
- SELECT t.id, t.name, t.description, t.status, t.winner_id,
3315
- t.started_at, t.completed_at, t.created_at, t.min_sample,
3316
- COUNT(DISTINCT v.id) AS variation_count,
3317
- SUM(v.total_assigned) AS total_assigned
3318
- FROM ltv_ab_tests t
3319
- LEFT JOIN ltv_ab_variations v ON v.test_id = t.id
3320
- ${cond}
3321
- GROUP BY t.id
3322
- ORDER BY t.created_at DESC
3323
- LIMIT ?
3324
- `).bind(...bindings).all();
3325
-
3326
- return new Response(JSON.stringify({
3327
- success: true,
3328
- total: (tests.results || []).length,
3329
- tests: tests.results || [],
3330
- }), { status: 200, headers });
3331
-
3332
- } catch (err) {
3333
- console.error('[AB-LTV] list error:', err.message);
3334
- return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
3335
- }
3336
- }
3337
-
3338
- // ── handleLtvAbTestResults — GET /api/ltv/ab-test/results ────────────────────
3339
- async function handleLtvAbTestResults(env, request, headers) {
3340
- if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
3341
-
3342
- const url = new URL(request.url);
3343
- const testId = url.searchParams.get('test_id') || null;
3344
-
3345
- try {
3346
- const cond = testId ? 'WHERE test_id = ?' : 'WHERE status = \'running\'';
3347
- const testBind = testId ? [parseInt(testId)] : [];
3348
-
3349
- const testRes = await env.DB.prepare(`
3350
- SELECT id, name, status, min_sample, winner_id, started_at FROM ltv_ab_tests ${cond} LIMIT 1
3351
- `).bind(...testBind).first();
3352
-
3353
- if (!testRes) {
3354
- return new Response(JSON.stringify({ error: 'Nenhum teste encontrado' }), { status: 404, headers });
3355
- }
3356
-
3357
- const perf = await env.DB.prepare(`
3358
- SELECT * FROM v_ab_test_performance WHERE test_id = ?
3359
- `).bind(testRes.id).all();
3360
-
3361
- const variations = perf.results || [];
3362
- const ready = variations.every(v => (v.total_assigned || 0) >= testRes.min_sample);
3363
- let recommendation = null;
3364
-
3365
- if (ready && variations.length > 0) {
3366
- const best = variations.reduce((a, b) =>
3367
- (b.accuracy_score || 0) > (a.accuracy_score || 0) ? b : a
3368
- );
3369
- const control = variations.find(v => v.is_control);
3370
- const improvement = control
3371
- ? ((best.accuracy_score || 0) - (control.accuracy_score || 0)) * 100
3372
- : null;
3373
- recommendation = {
3374
- winner_variation_id: best.variation_id,
3375
- winner_variation_name: best.variation_name,
3376
- accuracy_score: best.accuracy_score,
3377
- improvement_vs_control: improvement ? `+${improvement.toFixed(1)}%` : null,
3378
- ready_to_declare: true,
3379
- };
3380
- }
3381
-
3382
- return new Response(JSON.stringify({
3383
- success: true,
3384
- test: {
3385
- id: testRes.id,
3386
- name: testRes.name,
3387
- status: testRes.status,
3388
- min_sample: testRes.min_sample,
3389
- started_at: testRes.started_at,
3390
- is_ready: ready,
3391
- },
3392
- variations,
3393
- recommendation,
3394
- }), { status: 200, headers });
3395
-
3396
- } catch (err) {
3397
- console.error('[AB-LTV] results error:', err.message);
3398
- return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
3399
- }
3400
- }
3401
-
3402
- // ── handleLtvAbTestWinner — POST /api/ltv/ab-test/winner ─────────────────────
3403
- async function handleLtvAbTestWinner(env, request, headers) {
3404
- if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
3405
-
3406
- let body;
3407
- try { body = await request.json(); }
3408
- catch { return new Response(JSON.stringify({ error: 'JSON inválido' }), { status: 400, headers }); }
3409
-
3410
- const { test_id, variation_id } = body;
3411
- if (!test_id || !variation_id) {
3412
- return new Response(JSON.stringify({ error: 'test_id e variation_id são obrigatórios' }), { status: 400, headers });
3413
- }
3414
-
3415
- try {
3416
- const variation = await env.DB.prepare(
3417
- `SELECT id, name, system_prompt, is_control FROM ltv_ab_variations WHERE id = ? AND test_id = ?`
3418
- ).bind(variation_id, test_id).first();
3419
-
3420
- if (!variation) {
3421
- return new Response(JSON.stringify({ error: 'Variação não encontrada para este teste' }), { status: 404, headers });
3422
- }
3423
-
3424
- // Marcar winner e concluir o teste
3425
- await env.DB.prepare(
3426
- `UPDATE ltv_ab_tests SET winner_id = ?, status = 'completed', completed_at = datetime('now') WHERE id = ?`
3427
- ).bind(variation_id, test_id).run();
3428
-
3429
- // Invalidar cache KV (não há mais teste ativo)
3430
- if (env.GEO_CACHE) await env.GEO_CACHE.delete(AB_LTV_CACHE_KEY);
3431
-
3432
- return new Response(JSON.stringify({
3433
- success: true,
3434
- test_id,
3435
- winner_variation_id: variation_id,
3436
- winner_name: variation.name,
3437
- is_control: variation.is_control === 1,
3438
- winning_prompt: variation.system_prompt,
3439
- message: variation.is_control === 1
3440
- ? 'O prompt original (controle) venceu. Nenhuma alteração necessária.'
3441
- : 'Novo prompt vencedor identificado. Copie o campo winning_prompt e aplique ao predictLtv() como novo default.',
3442
- }), { status: 200, headers });
3443
-
3444
- } catch (err) {
3445
- console.error('[AB-LTV] winner error:', err.message);
3446
- return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
3447
- }
3448
- }
3449
-
3450
- // ─────────────────────────────────────────────────────────────────────────────
3451
- // BIDDING RECOMMENDATIONS — Handlers das Rotas de Otimização de Bids
3452
- // ─────────────────────────────────────────────────────────────────────────────
3453
-
3454
- // Fatores de plataforma (conservadores por design — usuário escala gradualmente)
3455
- const PLATFORM_FACTORS = { meta: 0.85, google: 0.90, tiktok: 0.75 };
3456
-
3457
- // Multiplicadores por tier de segmento (baseado em avg_ltv_class + avg_behavior_score)
3458
- function getSegmentMultiplier(avgLtvClass, avgBehaviorScore) {
3459
- const ltv = parseFloat(avgLtvClass || 0);
3460
- const eng = parseFloat(avgBehaviorScore || 0);
3461
- if (ltv >= 0.7 && eng >= 0.7) return 1.4; // Alto Valor + Alto Engajamento
3462
- if (ltv >= 0.7 && eng >= 0.4) return 1.2; // Alto Valor + Médio Engajamento
3463
- if (ltv >= 0.4 && eng >= 0.7) return 1.0; // Médio Valor + Alto Engajamento
3464
- if (ltv >= 0.4 && eng >= 0.4) return 0.8; // Médio Valor + Médio Engajamento
3465
- return 0.6; // Baixo Valor ou Baixo Engajamento
3466
- }
3467
-
3468
- // Ajuste de confiança baseado em volume de conversões
3469
- function getConfidenceAdjustment(confidence) {
3470
- if (confidence >= 0.7) return 1.00;
3471
- if (confidence >= 0.4) return 0.85;
3472
- return 0.70;
3473
- }
3474
-
3475
- // ── POST /api/bidding/recommend ───────────────────────────────────────────────
3476
- // Gera recomendações de bid para uma plataforma e vertical
3477
- // Requer binding: DB (AI é opcional — usa fórmula determinística se indisponível)
3478
- async function handleBiddingRecommend(env, request, headers) {
3479
- if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
3480
-
3481
- let body;
3482
- try { body = await request.json(); }
3483
- catch { return new Response(JSON.stringify({ error: 'JSON inválido no body' }), { status: 400, headers }); }
3484
-
3485
- const {
3486
- vertical = 'geral',
3487
- platform = 'meta',
3488
- target_roi = 3.5,
3489
- period_days = 30,
3490
- campaign_id = null,
3491
- budget = null,
3492
- } = body;
3493
-
3494
- const platforms = platform === 'all'
3495
- ? Object.keys(PLATFORM_FACTORS)
3496
- : [platform].filter(p => PLATFORM_FACTORS[p]);
3497
-
3498
- if (platforms.length === 0) {
3499
- return new Response(JSON.stringify({ error: 'platform deve ser: meta, google, tiktok ou all' }), { status: 400, headers });
3500
- }
3501
- if (target_roi < 1 || target_roi > 20) {
3502
- return new Response(JSON.stringify({ error: 'target_roi deve estar entre 1 e 20' }), { status: 400, headers });
3503
- }
3504
-
3505
- try {
3506
- // 1. Checar se há segmentos ML ativos (com dados de LTV real)
3507
- const segmentsRes = await env.DB.prepare(`
3508
- SELECT
3509
- ms.id AS segment_id,
3510
- ms.cluster_name,
3511
- ms.avg_ltv_class,
3512
- ms.avg_behavior_score,
3513
- ms.avg_engagement_score,
3514
- ms.silhouette_score,
3515
- COUNT(msm.id) AS member_count,
3516
- AVG(l.predicted_ltv) AS real_avg_ltv,
3517
- SUM(CASE WHEN l.event_name IN ('Purchase','CompletePayment') THEN 1 ELSE 0 END) AS conversions
3518
- FROM ml_segments ms
3519
- LEFT JOIN ml_segment_members msm ON msm.cluster_id = ms.id
3520
- LEFT JOIN leads l ON CAST(msm.lead_id AS INTEGER) = l.id
3521
- AND l.created_at >= datetime('now', '-' || ? || ' days')
3522
- WHERE ms.is_active = 1
3523
- AND ms.client_vertical IN (?, 'general')
3524
- GROUP BY ms.id
3525
- HAVING member_count > 0
3526
- ORDER BY real_avg_ltv DESC
3527
- LIMIT 10
3528
- `).bind(period_days, vertical).all();
3529
-
3530
- const segments = segmentsRes.results || [];
3531
-
3532
- // 2. Fallback se não houver segmentos: usar LTV global dos leads
3533
- let globalLtv = 0, globalLeads = 0, globalConversions = 0;
3534
- if (segments.length === 0) {
3535
- const globalRes = await env.DB.prepare(`
3536
- SELECT
3537
- COUNT(*) AS total_leads,
3538
- AVG(predicted_ltv) AS avg_ltv,
3539
- SUM(CASE WHEN event_name IN ('Purchase','CompletePayment') THEN 1 ELSE 0 END) AS conversions
3540
- FROM leads
3541
- WHERE created_at >= datetime('now', '-' || ? || ' days')
3542
- AND (bot_score IS NULL OR bot_score < 2)
3543
- `).bind(period_days).first();
3544
-
3545
- globalLeads = globalRes?.total_leads || 0;
3546
- globalLtv = globalRes?.avg_ltv || 0;
3547
- globalConversions = globalRes?.conversions || 0;
3548
-
3549
- if (globalLeads < 10) {
3550
- return new Response(JSON.stringify({
3551
- error: `Dados insuficientes. Apenas ${globalLeads} leads no período de ${period_days} dias. Mínimo: 10.`,
3552
- leads_found: globalLeads,
3553
- required: 10,
3554
- }), { status: 400, headers });
3555
- }
3556
- }
3557
-
3558
- // 3. Gerar recomendações por plataforma × segmento
3559
- const now = new Date().toISOString();
3560
- const recommendations = [];
3561
-
3562
- const targetSegments = segments.length > 0
3563
- ? segments
3564
- : [{ segment_id: null, cluster_name: 'Global (sem segmentação)', avg_ltv_class: 0.5, avg_behavior_score: 0.5, avg_engagement_score: 0.5, member_count: globalLeads, real_avg_ltv: globalLtv, conversions: globalConversions }];
3565
-
3566
- for (const seg of targetSegments) {
3567
- const avgLtv = parseFloat(seg.real_avg_ltv || 0);
3568
- const convs = parseInt(seg.conversions || 0);
3569
- const confidence = Math.min(1, convs / 100);
3570
-
3571
- // Sem LTV real? Usar LTV estimado pela classe do segmento
3572
- const estimatedLtv = avgLtv > 0 ? avgLtv :
3573
- seg.avg_ltv_class >= 0.7 ? 497 :
3574
- seg.avg_ltv_class >= 0.4 ? 297 : 97;
3575
-
3576
- const cpaTarget = estimatedLtv / target_roi;
3577
- const segMult = getSegmentMultiplier(seg.avg_ltv_class, seg.avg_behavior_score);
3578
- const confAdj = getConfidenceAdjustment(confidence);
3579
-
3580
- const alertMsg = convs < 30
3581
- ? `Atenção: apenas ${convs} conversões no período. Bid baseado em estimativa de LTV — aplique com cautela.`
3582
- : null;
3583
-
3584
- for (const plat of platforms) {
3585
- const platFactor = PLATFORM_FACTORS[plat];
3586
- const recommendedBid = Math.max(5, cpaTarget * platFactor * segMult * confAdj);
3587
- const expectedRoi = estimatedLtv / (recommendedBid / platFactor);
3588
-
3589
- // Guardar no D1 em background
3590
- const reasoning = `Segmento "${seg.cluster_name}": LTV=${estimatedLtv.toFixed(0)} BRL, ` +
3591
- `CPA_alvo=${cpaTarget.toFixed(0)} BRL, fator_plataforma=${platFactor}, ` +
3592
- `mult_segmento=${segMult}, ajuste_confiança=${confAdj}, ` +
3593
- `base: ${convs} conversões em ${period_days} dias.`;
3594
-
3595
- try {
3596
- await env.DB.prepare(`
3597
- INSERT INTO bid_recommendations (
3598
- generated_at, vertical, platform, period_days, target_roi,
3599
- segment_id, segment_name, leads_analyzed, conversions_found,
3600
- avg_ltv, cpa_target, recommended_bid, bid_currency,
3601
- confidence, expected_roi, reasoning, ai_used, alert_message,
3602
- platform_factor, confidence_adjustment, segment_multiplier, is_active
3603
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,?,?,?,?,1)
3604
- `).bind(
3605
- now, vertical, plat, period_days, target_roi,
3606
- seg.segment_id || null, seg.cluster_name,
3607
- seg.member_count || globalLeads, convs,
3608
- estimatedLtv, cpaTarget, recommendedBid, 'BRL',
3609
- confidence, expectedRoi, reasoning, alertMsg || null,
3610
- platFactor, confAdj, segMult,
3611
- ).run();
3612
- } catch (e) { console.error('[Bidding] D1 insert error:', e.message); }
3613
-
3614
- recommendations.push({
3615
- platform: plat,
3616
- segment: seg.cluster_name,
3617
- segment_id: seg.segment_id || null,
3618
- avg_ltv: Math.round(estimatedLtv * 100) / 100,
3619
- avg_ltv_class: seg.avg_ltv_class >= 0.7 ? 'High' : seg.avg_ltv_class >= 0.4 ? 'Medium' : 'Low',
3620
- cpa_target: Math.round(cpaTarget * 100) / 100,
3621
- recommended_bid: Math.round(recommendedBid * 100) / 100,
3622
- bid_currency: 'BRL',
3623
- confidence: Math.round(confidence * 100) / 100,
3624
- expected_roi: Math.round(expectedRoi * 100) / 100,
3625
- reasoning,
3626
- alert: alertMsg,
3627
- });
3628
- }
3629
- }
3630
-
3631
- // Desativar recomendações anteriores da mesma vertical/plataforma
3632
- await env.DB.prepare(
3633
- `UPDATE bid_recommendations SET is_active = 0 WHERE vertical = ? AND generated_at < ? AND is_active = 1`
3634
- ).bind(vertical, now).run().catch(() => {});
3635
-
3636
- const avgConfidence = recommendations.length > 0
3637
- ? recommendations.reduce((s, r) => s + r.confidence, 0) / recommendations.length
3638
- : 0;
3639
-
3640
- return new Response(JSON.stringify({
3641
- success: true,
3642
- generated_at: now,
3643
- vertical,
3644
- period_days,
3645
- target_roi,
3646
- data_quality: {
3647
- leads_analyzed: targetSegments.reduce((s, sg) => s + (sg.member_count || 0), 0),
3648
- conversions_found: targetSegments.reduce((s, sg) => s + (sg.conversions || 0), 0),
3649
- segments_active: segments.length,
3650
- confidence: Math.round(avgConfidence * 100) / 100,
3651
- },
3652
- recommendations,
3653
- global_summary: {
3654
- total_recommendations: recommendations.length,
3655
- avg_confidence: Math.round(avgConfidence * 100) / 100,
3656
- expected_cost_reduction: avgConfidence >= 0.7 ? '-20%' : avgConfidence >= 0.4 ? '-10%' : 'indefinido (dados insuficientes)',
3657
- segments_analyzed: segments.length,
3658
- },
3659
- }), { status: 200, headers });
3660
-
3661
- } catch (err) {
3662
- console.error('[Bidding] recommend error:', err.message);
3663
- return new Response(JSON.stringify({ error: 'Erro ao gerar recomendações', message: err.message }), { status: 500, headers });
3664
- }
3665
- }
3666
-
3667
- // ── GET /api/bidding/history ──────────────────────────────────────────────────
3668
- // Retorna histórico de recomendações de bids geradas
3669
- async function handleBiddingHistory(env, request, headers) {
3670
- if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
3671
-
3672
- const url = new URL(request.url);
3673
- const vertical = url.searchParams.get('vertical') || null;
3674
- const platform = url.searchParams.get('platform') || null;
3675
- const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 100);
3676
-
3677
- try {
3678
- const conditions = [];
3679
- const bindings = [];
3680
-
3681
- if (vertical) { conditions.push('vertical = ?'); bindings.push(vertical); }
3682
- if (platform) { conditions.push('platform = ?'); bindings.push(platform); }
3683
-
3684
- const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
3685
-
3686
- const result = await env.DB.prepare(`
3687
- SELECT id, generated_at, vertical, platform, period_days, target_roi,
3688
- segment_name, leads_analyzed, conversions_found, avg_ltv, cpa_target,
3689
- recommended_bid, bid_currency, confidence, expected_roi,
3690
- reasoning, alert_message, ai_used, is_active,
3691
- applied_at, applied_campaign, applied_result
3692
- FROM bid_recommendations
3693
- ${where}
3694
- ORDER BY generated_at DESC
3695
- LIMIT ?
3696
- `).bind(...bindings, limit).all();
3697
-
3698
- const items = (result.results || []).map(r => ({
3699
- ...r,
3700
- applied_result: tryParseJson(r.applied_result, null),
3701
- }));
3702
-
3703
- return new Response(JSON.stringify({
3704
- success: true,
3705
- total: items.length,
3706
- history: items,
3707
- }), { status: 200, headers });
3708
-
3709
- } catch (err) {
3710
- console.error('[Bidding] history error:', err.message);
3711
- return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
3712
- }
3713
- }
3714
-
3715
- // ── GET /api/bidding/status ───────────────────────────────────────────────────
3716
- // Status atual das recomendações ativas (última por plataforma por vertical)
3717
- async function handleBiddingStatus(env, request, headers) {
3718
- if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
3719
-
3720
- const url = new URL(request.url);
3721
- const vertical = url.searchParams.get('vertical') || null;
3722
-
3723
- try {
3724
- let query = `
3725
- SELECT platform, vertical, MAX(generated_at) as last_generated,
3726
- AVG(confidence) as avg_confidence, AVG(recommended_bid) as avg_bid,
3727
- COUNT(*) as recommendations_count,
3728
- SUM(CASE WHEN alert_message IS NOT NULL THEN 1 ELSE 0 END) as alerts_count
3729
- FROM bid_recommendations
3730
- WHERE is_active = 1
3731
- `;
3732
- const bindings = [];
3733
- if (vertical) { query += ' AND vertical = ?'; bindings.push(vertical); }
3734
- query += ' GROUP BY platform, vertical ORDER BY last_generated DESC';
3735
-
3736
- const result = await env.DB.prepare(query).bind(...bindings).all();
3737
-
3738
- return new Response(JSON.stringify({
3739
- success: true,
3740
- status: result.results || [],
3741
- }), { status: 200, headers });
3742
-
3743
- } catch (err) {
3744
- console.error('[Bidding] status error:', err.message);
3745
- return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
3746
- }
3747
- }
3748
-
3749
- // ─────────────────────────────────────────────────────────────────────────────
3750
- // HANDLER PRINCIPAL
3751
- // ─────────────────────────────────────────────────────────────────────────────
3752
- export default {
3753
- async fetch(request, env, ctx) {
3754
- const origin = request.headers.get('Origin') || '';
3755
- const headers = {
3756
- 'Content-Type': 'application/json',
3757
- ...corsHeaders(origin, env.SITE_DOMAIN),
3758
- };
3759
-
3760
- // Preflight CORS
3761
- if (request.method === 'OPTIONS') {
3762
- return new Response(null, { status: 204, headers });
3763
- }
3764
-
3765
- const url = new URL(request.url);
3766
-
3767
- // ── Rate Limiter — camada 0, antes do Fraud Gate ─────────────────────────
3768
- // Bloqueia na borda por IP antes de qualquer CPU ser consumida
3769
- // Silent drop (200) — atacante não sabe que foi bloqueado
3770
- // Requer binding RATE_LIMITER no wrangler.toml (Workers Paid)
3771
- // Fail-open: se binding não existir, deixa passar (não quebra o fluxo)
3772
- if (url.pathname === '/track' && request.method === 'POST' && env.RATE_LIMITER) {
3773
- const ip = request.headers.get('CF-Connecting-IP')
3774
- || request.headers.get('X-Forwarded-For')?.split(',')[0].trim()
3775
- || '0.0.0.0';
3776
- const { success } = await env.RATE_LIMITER.limit({ key: ip });
3777
- if (!success) {
3778
- return new Response(JSON.stringify({ status: 'ok', queued: true }), { status: 200, headers });
3779
- }
3780
- }
3781
-
3782
- // ── Fraud Gate — Fase 4 (apenas em /track e /api) ────────────────────────
3783
- // Roda ANTES de qualquer processamento de evento
3784
- // Silent drop (200) — bots não sabem que foram detectados
3785
- if (url.pathname === '/track' && request.method === 'POST') {
3786
- let trackBodyForFraud;
3787
- try {
3788
- const cloned = request.clone();
3789
- trackBodyForFraud = await cloned.json().catch(() => ({}));
3790
- } catch { trackBodyForFraud = {}; }
3791
-
3792
- const fraudResult = await checkFraudGate(env, request, trackBodyForFraud);
3793
- if (!fraudResult.allowed) {
3794
- // Log em background — não bloqueia a resposta
3795
- ctx.waitUntil(logFraudSignal(env, request, trackBodyForFraud, fraudResult));
3796
- // Silent drop: retorna 200 com payload de sucesso falso
3797
- return new Response(JSON.stringify({ status: 'ok', queued: true }), { status: 200, headers });
3798
- }
3799
- if (fraudResult.action === 'flagged') {
3800
- // Suspeito mas permitido — loga em background
3801
- ctx.waitUntil(logFraudSignal(env, request, trackBodyForFraud, fraudResult));
3802
- }
3803
- }
3804
-
3805
- // ── GET /export/customer-match — exporta leads para Google Ads (download) ──
3806
- if (request.method === 'GET' && url.pathname === '/export/customer-match') {
3807
- // Proteção simples por token (mesmo META_ACCESS_TOKEN ou secret dedicado)
3808
- const authHeader = request.headers.get('Authorization') || '';
3809
- const token = authHeader.replace('Bearer ', '');
3810
- if (!env.META_ACCESS_TOKEN || token !== env.META_ACCESS_TOKEN) {
3811
- return new Response('Unauthorized', { status: 401 });
3812
- }
3813
-
3814
- const rows = await buildGoogleCustomerMatchExport(env);
3815
- return new Response(JSON.stringify({ total: rows.length, data: rows }, null, 2), {
3816
- headers: { ...headers, 'Content-Disposition': 'attachment; filename="customer-match.json"' },
3817
- });
3818
- }
3819
-
3820
- // ── GET /health — Smoke Test completo ────────────────────────────────────
3821
- if (request.method === 'GET' && url.pathname === '/health') {
3822
- const results = {};
3823
-
3824
- // D1 — query real
3825
- try {
3826
- await env.DB.prepare('SELECT 1').run();
3827
- results.d1 = 'ok';
3828
- } catch (err) {
3829
- results.d1 = `FAILED: ${err.message}`;
3830
- }
3831
-
3832
- // KV — leitura real
3833
- try {
3834
- await env.GEO_CACHE.get('__health_check__');
3835
- results.kv = 'ok';
3836
- } catch (err) {
3837
- results.kv = `FAILED: ${err.message}`;
3838
- }
3839
-
3840
- // Workers AI — ping
3841
- try {
3842
- await env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
3843
- messages: [{ role: 'user', content: 'ping' }],
3844
- max_tokens: 1,
3845
- });
3846
- results.ai = 'ok';
3847
- } catch (err) {
3848
- results.ai = `FAILED: ${err.message}`;
3849
- }
3850
-
3851
- // Vars obrigatórias
3852
- const vars = {
3853
- META_PIXEL_ID: env.META_PIXEL_ID ? 'set' : 'MISSING',
3854
- GA4_MEASUREMENT_ID: env.GA4_MEASUREMENT_ID ? 'set' : 'MISSING',
3855
- TIKTOK_PIXEL_ID: env.TIKTOK_PIXEL_ID ? 'set' : 'MISSING',
3856
- SITE_DOMAIN: env.SITE_DOMAIN ? 'set' : 'MISSING',
3857
- };
3858
-
3859
- // Secrets obrigatórios
3860
- const secrets = {
3861
- META_ACCESS_TOKEN: env.META_ACCESS_TOKEN ? 'set' : 'MISSING',
3862
- GA4_API_SECRET: env.GA4_API_SECRET ? 'set' : 'MISSING',
3863
- WA_WEBHOOK_VERIFY_TOKEN: env.WA_WEBHOOK_VERIFY_TOKEN ? 'set' : 'MISSING',
3864
- WHATSAPP_ACCESS_TOKEN: env.WHATSAPP_ACCESS_TOKEN ? 'set' : 'not set (optional - only for auto-reply)',
3865
- WHATSAPP_PHONE_NUMBER_ID: env.WHATSAPP_PHONE_NUMBER_ID ? 'set' : 'not set (optional - only for auto-reply)',
3866
- WA_NOTIFY_NUMBER: env.WA_NOTIFY_NUMBER ? 'set' : 'not set (optional - only for auto-reply)',
3867
- TIKTOK_ACCESS_TOKEN: env.TIKTOK_ACCESS_TOKEN ? 'set' : 'not set (optional)',
3868
- CALLMEBOT_PHONE: env.CALLMEBOT_PHONE ? 'set' : 'not set (optional)',
3869
- };
3870
-
3871
- const hasMissing =
3872
- Object.values(vars).includes('MISSING') ||
3873
- Object.values(secrets).includes('MISSING') ||
3874
- results.d1 !== 'ok';
3875
-
3876
- return new Response(JSON.stringify({
3877
- status: hasMissing ? 'degraded' : 'ok',
3878
- timestamp: new Date().toISOString(),
3879
- bindings: results,
3880
- vars,
3881
- secrets,
3882
- }, null, 2), { headers });
3883
- }
3884
-
3885
- // ── POST /track — evento do browser ───────────────────────────────────────
3886
- if (request.method === 'POST' && url.pathname === '/track') {
3887
- // Reject oversized payloads before reading body (64 KB limit)
3888
- const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
3889
- if (contentLength > 65536) {
3890
- return new Response(JSON.stringify({ error: 'Payload muito grande' }), { status: 413, headers });
3891
- }
3892
-
3893
- let body;
3894
- try {
3895
- body = await request.json();
3896
- } catch {
3897
- return new Response(
3898
- JSON.stringify({ error: 'JSON inválido' }),
3899
- { status: 400, headers }
3900
- );
3901
- }
3902
-
3903
- // ── Payload validation ────────────────────────────────────────────────────
3904
- // Reject non-object bodies and oversized string fields to prevent injection
3905
- if (typeof body !== 'object' || Array.isArray(body) || body === null) {
3906
- return new Response(JSON.stringify({ error: 'Payload inválido' }), { status: 400, headers });
3907
- }
3908
-
3909
- const VALID_EVENT_NAMES = new Set([
3910
- 'PageView','ViewContent','Lead','Purchase','InitiateCheckout',
3911
- 'AddToCart','CompleteRegistration','Contact','Schedule',
3912
- 'StartTrial','Subscribe','SubmitApplication','Search',
3913
- 'video_start','video_25','video_50','video_75','video_complete'
3914
- ]);
3915
- const STR_FIELDS = ['email','phone','firstName','lastName','city','state','zip','userId',
3916
- 'utmSource','utmMedium','utmCampaign','utmContent','utmTerm',
3917
- 'fbclid','ttclid','gclid','transactionId','productName','currency'];
3918
-
3919
- const { eventName, behavioral_data, ...payload } = body;
3920
-
3921
- if (!eventName) {
3922
- return new Response(
3923
- JSON.stringify({ error: 'eventName é obrigatório' }),
3924
- { status: 400, headers }
3925
- );
3926
- }
3927
-
3928
- if (typeof eventName !== 'string' || eventName.length > 64 || !VALID_EVENT_NAMES.has(eventName)) {
3929
- return new Response(JSON.stringify({ error: `eventName desconhecido: ${eventName.slice(0, 64)}` }), { status: 400, headers });
3930
- }
3931
-
3932
- // Enforce max string length on known PII/UTM fields to block injection payloads
3933
- for (const field of STR_FIELDS) {
3934
- if (payload[field] !== undefined && payload[field] !== null) {
3935
- if (typeof payload[field] !== 'string' || payload[field].length > 512) {
3936
- return new Response(JSON.stringify({ error: `Campo inválido: ${field}` }), { status: 400, headers });
3937
- }
3938
- }
3939
- }
3940
-
3941
- // value must be a non-negative number when present
3942
- if (payload.value !== undefined && payload.value !== null) {
3943
- const v = Number(payload.value);
3944
- if (isNaN(v) || v < 0 || v > 9_999_999) {
3945
- return new Response(JSON.stringify({ error: 'value fora do intervalo permitido' }), { status: 400, headers });
3946
- }
3947
- payload.value = v;
3948
- }
3949
-
3950
- // ── Extrair dados comportamentais do browser ──────────────────────────────
3951
- // behavioral_data vem do engagement-scoring.js (engagement_score 0-5, intention_level)
3952
- // e do BehaviorEngine (user_score 0-100)
3953
- if (behavioral_data) {
3954
- payload.engagementScore = behavioral_data.engagement_score ?? behavioral_data.totalScore ?? null;
3955
- payload.intentionLevel = behavioral_data.intention_level ?? null;
3956
- payload.userScore = behavioral_data.user_score ?? null;
3957
- // PII extraído pelo advanced-matching.js chega aninhado em behavioral_data
3958
- // (trackLead passa piiData como `data`, que é spread em behavioral_data)
3959
- payload.email = payload.email || behavioral_data.email || null;
3960
- payload.phone = payload.phone || behavioral_data.phone || null;
3961
- payload.firstName = payload.firstName || behavioral_data.first_name || behavioral_data.firstName || null;
3962
- payload.lastName = payload.lastName || behavioral_data.last_name || behavioral_data.lastName || null;
3963
- payload.city = payload.city || behavioral_data.city || null;
3964
- payload.state = payload.state || behavioral_data.state || null;
3965
- payload.zip = payload.zip || behavioral_data.zip || null;
3966
- payload.dob = payload.dob || behavioral_data.dob || null;
3967
- }
3968
-
3969
- // ── Edge Fingerprint + UTM Resurrection ───────────────────────────────────
3970
- const fingerprint = await generateEdgeFingerprint(request);
3971
- payload.utmRestored = false;
3972
-
3973
- if (fingerprint) {
3974
- if (payload.utmSource) {
3975
- // Tem UTM → salvar fingerprint para uso futuro
3976
- ctx.waitUntil(saveEdgeFingerprint(env.DB, fingerprint, payload.userId, payload));
3977
- } else {
3978
- // Sem UTM → tentar recuperar das últimas 48h
3979
- const recovered = await resurrectUTM(env.DB, fingerprint);
3980
- if (recovered) {
3981
- payload.utmSource = payload.utmSource || recovered.utm_source;
3982
- payload.utmMedium = payload.utmMedium || recovered.utm_medium;
3983
- payload.utmCampaign = payload.utmCampaign || recovered.utm_campaign;
3984
- payload.utmContent = payload.utmContent || recovered.utm_content;
3985
- payload.utmTerm = payload.utmTerm || recovered.utm_term;
3986
- payload.utmRestored = true;
3987
- }
3988
- }
3989
- }
3990
-
3991
- // ── Bot Mitigation ────────────────────────────────────────────────────────
3992
- const botScoreStr = request.cf?.botManagement?.score;
3993
- const cfBotScore = botScoreStr !== undefined ? parseInt(botScoreStr) : 100;
3994
- const ua = (request.headers.get('User-Agent') || '').toLowerCase();
3995
- const isBotPattern = /bot|crawler|spider|lighthouse|postman|curl|inspect|headless/i.test(ua);
3996
-
3997
- const isBot = cfBotScore < 30 || isBotPattern;
3998
- payload.botScore = isBot ? 2 : (cfBotScore < 60 ? 1 : 0);
3999
-
4000
- // Dropar silenciosamente eventos de lixo (exceto conversões core para evitar falso positivo)
4001
- if (isBot && !['Contact', 'Lead', 'Purchase'].includes(eventName)) {
4002
- return new Response(JSON.stringify({ ok: true, skipped_due_to_bot: true }), { status: 200, headers });
4003
- }
4004
-
4005
- // ── Edge Geo Enrichment ───────────────────────────────────────────────────
4006
- // Free: country, continent, asn | Paid: city, state, zip, lat/lon, timezone
4007
- const geoData = await enrichGeoFromEdge(request, env, payload);
4008
-
4009
- // ── First-Party Cookie (Identity Resolution) ──────────────────────────────
4010
- const cookieHeader = request.headers.get('Cookie') || '';
4011
- const cdpUidMatch = cookieHeader.match(/_cdp_uid=([^;]+)/);
4012
- const finalUserId = payload.userId || (cdpUidMatch ? cdpUidMatch[1] : crypto.randomUUID());
4013
- payload.userId = finalUserId;
4014
-
4015
- const ga4Name = META_TO_GA4[eventName] || eventName.toLowerCase();
4016
-
4017
- // ── LTV Prediction (+ A/B Testing de Prompts) ────────────────────────────
4018
- // Lead, Contact, Schedule: sem valor monetário real → injetar LTV preditivo
4019
- // Isso treina os algoritmos de Meta/TikTok a priorizar leads de alto valor
4020
- const LTV_EVENTS = ['Lead', 'Contact', 'Schedule', 'CompleteRegistration'];
4021
- if (LTV_EVENTS.includes(eventName) && !payload.value) {
4022
- // A/B Testing: busca variação ativa (usa KV cache — ~0ms de latência extra)
4023
- const abVariation = await getLtvAbVariation(env);
4024
- const ltv = await predictLtv(env, payload, request, abVariation?.system_prompt || null);
4025
- payload.value = ltv.value;
4026
- payload.currency = payload.currency || 'BRL';
4027
- payload.ltvClass = ltv.class;
4028
- payload.ltvScore = ltv.score;
4029
- // Persiste no perfil em background
4030
- ctx.waitUntil(
4031
- upsertLtvProfile(env, payload.userId, ltv)
4032
- );
4033
- // Registrar assignment do A/B test em background (não bloqueia)
4034
- if (abVariation) {
4035
- const emailHash = payload.email
4036
- ? await sha256(payload.email.trim().toLowerCase())
4037
- : null;
4038
- ctx.waitUntil(
4039
- recordAbAssignment(
4040
- env,
4041
- payload.userId,
4042
- abVariation.variation_id,
4043
- abVariation.test_id,
4044
- ltv.value,
4045
- ltv.class,
4046
- emailHash,
4047
- )
4048
- );
4049
- }
4050
- }
4051
-
4052
- // Cross-Device Graph — background (não bloqueia resposta)
4053
- // Só dispara quando tem PII e um userId confirmado
4054
- if (env.DB && payload.userId && (payload.email || payload.phone)) {
4055
- ctx.waitUntil(
4056
- resolveDeviceGraph(env.DB, payload.userId, payload.email, payload.phone)
4057
- );
4058
- }
4059
-
4060
- // ── R2 Audit Log — background, não bloqueia ──────────────────────────────
4061
- ctx.waitUntil(writeAuditLog(env, eventName, payload, geoData));
4062
-
4063
- // Disparar tudo em paralelo — não bloquear o browser
4064
- // WhatsApp: só notifica Lead e Purchase para não lotar o celular
4065
- const WHATSAPP_NOTIFY_EVENTS = ['Lead', 'Purchase', 'CompleteRegistration'];
4066
- const [metaRes, ga4Res, ttRes] = await Promise.allSettled([
4067
- sendMetaCapi(env, eventName, payload, request, ctx),
4068
- sendGA4Mp(env, ga4Name, payload, ctx),
4069
- sendTikTokApi(env, eventName, payload, request, ctx),
4070
- saveLead(env, eventName, payload, request, 'website'),
4071
- upsertProfile(env, eventName, payload, request),
4072
- ...(WHATSAPP_NOTIFY_EVENTS.includes(eventName)
4073
- ? [sendWhatsApp(env, eventName, payload)]
4074
- : []),
4075
- ]);
4076
-
4077
- // Automação de mensagens — dispara regras ativas para este evento em background
4078
- // saveLead() já foi chamado acima; usamos o leadId gerado pelo D1 (last_row_id)
4079
- const AUTOMATION_EVENTS = ['Lead', 'Purchase', 'InitiateCheckout'];
4080
- if (AUTOMATION_EVENTS.includes(eventName) && env.DB) {
4081
- ctx.waitUntil(
4082
- (async () => {
4083
- try {
4084
- const lastLead = await env.DB
4085
- .prepare(`SELECT id FROM leads WHERE event_id = ?1 LIMIT 1`)
4086
- .bind(payload.eventId || payload.event_id || '')
4087
- .first();
4088
- const leadId = lastLead?.id ?? null;
4089
- if (leadId) await fireAutomation(env, eventName, leadId, payload);
4090
- } catch (e) { console.error('[Automation] lead lookup error:', e.message); }
4091
- })()
4092
- );
4093
- }
4094
-
4095
- // ── Edge Personalization (Retornar Score) ───────────────────────────────
4096
- let currentScore = 0;
4097
- if (env.DB && payload.userId) {
4098
- try {
4099
- const profileRow = await env.DB.prepare('SELECT score FROM user_profiles WHERE user_id = ?').bind(payload.userId).first();
4100
- if (profileRow) currentScore = profileRow.score;
4101
- } catch(e) {}
4102
- }
4103
-
4104
- const resHeaders = new Headers(headers);
4105
- resHeaders.set('Set-Cookie', `_cdp_uid=${finalUserId}; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000; Path=/; Domain=.${env.SITE_DOMAIN}`);
4106
-
4107
- return new Response(JSON.stringify({
4108
- ok: true,
4109
- userProfile: { score: currentScore, user_id: finalUserId },
4110
- meta: metaRes.value ?? { error: metaRes.reason?.message },
4111
- ga4: ga4Res.value ?? { error: ga4Res.reason?.message },
4112
- tiktok: ttRes.value ?? { error: ttRes.reason?.message },
4113
- }), { status: 200, headers: resHeaders });
4114
- }
4115
-
4116
- // ── POST /webhook/hotmart ─────────────────────────────────────────────────
4117
- if (request.method === 'POST' && url.pathname === '/webhook/hotmart') {
4118
- // Validação de token Hotmart (X-Hotmart-Webhook-Token)
4119
- // Secret: wrangler secret put WEBHOOK_SECRET_HOTMART
4120
- if (env.WEBHOOK_SECRET_HOTMART) {
4121
- const token = request.headers.get('X-Hotmart-Webhook-Token') || '';
4122
- if (token !== env.WEBHOOK_SECRET_HOTMART) {
4123
- return new Response('Unauthorized', { status: 401 });
4124
- }
4125
- }
4126
-
4127
- let wh;
4128
- try { wh = await request.json(); } catch {
4129
- return new Response('JSON inválido', { status: 400 });
4130
- }
4131
-
4132
- const data = wh.data || wh;
4133
- const buyer = data.buyer || {};
4134
- const purchase = data.purchase || {};
4135
- const product = data.product || {};
4136
-
4137
- // Só processar compras aprovadas
4138
- if (!['APPROVED', 'COMPLETE', 'approved', 'complete'].includes(purchase.status)) {
4139
- return new Response(
4140
- JSON.stringify({ skipped: `status ${purchase.status}` }),
4141
- { status: 200, headers }
4142
- );
4143
- }
4144
-
4145
- // Deduplicação — verificar se transação já foi processada
4146
- const hmTxId = String(purchase.transaction || '');
4147
- if (hmTxId && env.DB) {
4148
- try {
4149
- const dup = await env.DB.prepare(
4150
- 'SELECT id FROM webhook_events WHERE transaction_id = ? AND status = ?'
4151
- ).bind(hmTxId, 'processed').first();
4152
- if (dup) {
4153
- return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
4154
- }
4155
- } catch { /* continua mesmo se a consulta falhar */ }
4156
- }
4157
-
4158
- // Recuperar cookies do comprador (fbp/fbc) pelo email
4159
- const profile = await getProfileByEmail(env, buyer.email);
4160
-
4161
- const payload = {
4162
- email: buyer.email,
4163
- phone: buyer.phone,
4164
- firstName: buyer.name?.split(' ')[0],
4165
- lastName: buyer.name?.split(' ').slice(1).join(' ') || undefined,
4166
- fbp: profile?.fbp,
4167
- fbc: profile?.fbc,
4168
- userId: profile?.user_id,
4169
- gaClientId: profile?.ga_client_id,
4170
- value: purchase.price?.value,
4171
- currency: purchase.price?.currency_value || 'BRL',
4172
- contentIds: [String(product.id || product.ucode || '')],
4173
- contentName: product.name,
4174
- contentType: 'product',
4175
- pageUrl: profile?.page_url || `https://${env.SITE_DOMAIN || 'seu-dominio.com.br'}`,
4176
- orderId: purchase.transaction,
4177
- eventId: `hotmart_${purchase.transaction}`,
4178
- city: profile?.city,
4179
- state: profile?.state,
4180
- country: profile?.country,
4181
- };
4182
-
4183
- // Registrar transação no D1 (prevenção de duplicatas em reenvios)
4184
- if (hmTxId && env.DB) {
4185
- try {
4186
- await env.DB.prepare(
4187
- 'INSERT OR IGNORE INTO webhook_events (platform, transaction_id, email, status, raw_payload) VALUES (?,?,?,?,?)'
4188
- ).bind('hotmart', hmTxId, buyer.email || null, 'processed', JSON.stringify(wh)).run();
4189
- } catch { /* não bloquear envio se D1 falhar */ }
4190
- }
4191
-
4192
- ctx.waitUntil(Promise.allSettled([
4193
- sendMetaCapi(env, 'Purchase', payload, request, ctx),
4194
- sendGA4Mp(env, 'purchase', payload, ctx),
4195
- sendTikTokApi(env, 'CompletePayment', payload, request, ctx),
4196
- saveLead(env, 'Purchase', payload, request, 'hotmart'),
4197
- sendWhatsApp(env, 'Purchase', payload),
4198
- ]));
4199
-
4200
- return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
4201
- }
4202
-
4203
- // ── POST /webhook/kiwify ──────────────────────────────────────────────────
4204
- if (request.method === 'POST' && url.pathname === '/webhook/kiwify') {
4205
- // Validação de token Kiwify (X-Kiwify-Event-Token)
4206
- // Secret: wrangler secret put WEBHOOK_SECRET_KIWIFY
4207
- if (env.WEBHOOK_SECRET_KIWIFY) {
4208
- const token = request.headers.get('X-Kiwify-Event-Token') || '';
4209
- if (token !== env.WEBHOOK_SECRET_KIWIFY) {
4210
- return new Response('Unauthorized', { status: 401 });
4211
- }
4212
- }
4213
-
4214
- let wh;
4215
- try { wh = await request.json(); } catch {
4216
- return new Response('JSON inválido', { status: 400 });
4217
- }
4218
-
4219
- // Só processar compras aprovadas
4220
- if (wh.order_status !== 'paid' && wh.order_status !== 'approved') {
4221
- return new Response(
4222
- JSON.stringify({ skipped: `status ${wh.order_status}` }),
4223
- { status: 200, headers }
4224
- );
4225
- }
4226
-
4227
- // Deduplicação — verificar se transação já foi processada
4228
- const kwTxId = String(wh.order_id || '');
4229
- if (kwTxId && env.DB) {
4230
- try {
4231
- const dup = await env.DB.prepare(
4232
- 'SELECT id FROM webhook_events WHERE transaction_id = ? AND status = ?'
4233
- ).bind(kwTxId, 'processed').first();
4234
- if (dup) {
4235
- return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
4236
- }
4237
- } catch { /* continua mesmo se a consulta falhar */ }
4238
- }
4239
-
4240
- const customer = wh.Customer || {};
4241
- const product = wh.Product || {};
4242
- const profile = await getProfileByEmail(env, customer.email);
4243
-
4244
- const payload = {
4245
- email: customer.email,
4246
- phone: customer.mobile,
4247
- firstName: customer.full_name?.split(' ')[0],
4248
- lastName: customer.full_name?.split(' ').slice(1).join(' ') || undefined,
4249
- fbp: profile?.fbp,
4250
- fbc: profile?.fbc,
4251
- userId: profile?.user_id,
4252
- gaClientId: profile?.ga_client_id,
4253
- value: wh.order_value ? parseFloat(wh.order_value) / 100 : undefined,
4254
- currency: 'BRL',
4255
- contentIds: [String(product.product_id || '')],
4256
- contentName: product.product_name,
4257
- contentType: 'product',
4258
- pageUrl: profile?.page_url || `https://${env.SITE_DOMAIN || 'seu-dominio.com.br'}`,
4259
- orderId: wh.order_id,
4260
- eventId: `kiwify_${wh.order_id}`,
4261
- city: profile?.city,
4262
- state: profile?.state,
4263
- country: profile?.country,
4264
- };
4265
-
4266
- // Registrar transação no D1
4267
- if (kwTxId && env.DB) {
4268
- try {
4269
- await env.DB.prepare(
4270
- 'INSERT OR IGNORE INTO webhook_events (platform, transaction_id, email, status, raw_payload) VALUES (?,?,?,?,?)'
4271
- ).bind('kiwify', kwTxId, customer.email || null, 'processed', JSON.stringify(wh)).run();
4272
- } catch { /* não bloquear envio se D1 falhar */ }
4273
- }
4274
-
4275
- ctx.waitUntil(Promise.allSettled([
4276
- sendMetaCapi(env, 'Purchase', payload, request, ctx),
4277
- sendGA4Mp(env, 'purchase', payload, ctx),
4278
- sendTikTokApi(env, 'CompletePayment', payload, request, ctx),
4279
- sendPinterestCapi(env, 'Purchase', payload, request, ctx),
4280
- sendRedditCapi(env, 'Purchase', payload, request, ctx),
4281
- sendLinkedInCapi(env, 'Purchase', payload, request, ctx),
4282
- sendSpotifyCapi(env, 'Purchase', payload, request, ctx),
4283
- saveLead(env, 'Purchase', payload, request, 'kiwify'),
4284
- sendWhatsApp(env, 'Purchase', payload),
4285
- ]));
4286
-
4287
- return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
4288
- }
4289
-
4290
- // ── POST /webhook/ticto ───────────────────────────────────────────────────
4291
- // Ticto Webhook v2 (JSON) — configurar em: Produto → Webhooks → Versão 2.0 → JSON
4292
- // URL a cadastrar na Ticto: https://SEU_DOMINIO/webhook/ticto
4293
- // Evento a selecionar: "Venda Realizada" (status: paid | approved | complete)
4294
- if (request.method === 'POST' && url.pathname === '/webhook/ticto') {
4295
- // Validação HMAC-SHA256 Ticto (X-Ticto-Signature)
4296
- // Secret: wrangler secret put WEBHOOK_SECRET_TICTO
4297
- let rawBody;
4298
- try { rawBody = await request.text(); } catch {
4299
- return new Response('Leitura de body falhou', { status: 400 });
4300
- }
4301
- if (env.WEBHOOK_SECRET_TICTO) {
4302
- const sig = request.headers.get('X-Ticto-Signature') || '';
4303
- const valid = await verifyHmac(env.WEBHOOK_SECRET_TICTO, rawBody, sig);
4304
- if (!valid) {
4305
- return new Response('Unauthorized', { status: 401 });
4306
- }
4307
- }
4308
-
4309
- let wh;
4310
- try { wh = JSON.parse(rawBody); } catch {
4311
- return new Response('JSON inválido', { status: 400 });
4312
- }
4313
-
4314
- // ── Estrutura Ticto v2 ────────────────────────────────────────────────
4315
- // {
4316
- // "version": "2.0",
4317
- // "status": "paid", ← paid | approved | complete | refunded | chargeback
4318
- // "status_date": "...",
4319
- // "token": "...",
4320
- // "payment_method": "credit_card | boleto | pix",
4321
- // "customer": {
4322
- // "name": "João Silva",
4323
- // "email": "joao@email.com",
4324
- // "phone": "11999998888",
4325
- // "document": "12345678901" ← CPF (não enviamos para plataformas de ads)
4326
- // },
4327
- // "order": {
4328
- // "id": "ORD123",
4329
- // "hash": "abc123",
4330
- // "transaction_hash": "xyz456",
4331
- // "paid_amount": 29700, ← valor em centavos (R$ 297,00)
4332
- // "installments": 1,
4333
- // "order_date": "2024-01-01"
4334
- // },
4335
- // "item": {
4336
- // "product_name": "Curso XYZ",
4337
- // "product_id": "PROD123"
4338
- // },
4339
- // "tracking": { ← parâmetros de URL capturados no checkout
4340
- // "src": "facebook",
4341
- // "utm_source": "...",
4342
- // "utm_medium": "...",
4343
- // "utm_campaign": "...",
4344
- // "utm_content": "...",
4345
- // "utm_term": "..."
4346
- // },
4347
- // "url_params": { ← fallback de parâmetros extras (fbclid, sck, xcod...)
4348
- // "fbclid": "...",
4349
- // "sck": "..."
4350
- // }
4351
- // }
4352
-
4353
- // Aceitar apenas vendas aprovadas/pagas
4354
- const STATUS_PAID = ['paid', 'approved', 'complete', 'completed'];
4355
- if (!STATUS_PAID.includes((wh.status || '').toLowerCase())) {
4356
- return new Response(
4357
- JSON.stringify({ skipped: `status ${wh.status}` }),
4358
- { status: 200, headers }
4359
- );
4360
- }
4361
-
4362
- const customer = wh.customer || {};
4363
- const order = wh.order || {};
4364
- const item = wh.item || {};
4365
- const tracking = wh.tracking || wh.url_params || {};
4366
-
4367
- // Valor: paid_amount está em centavos → dividir por 100
4368
- const valueRaw = order.paid_amount ?? order.total ?? order.amount;
4369
- const value = valueRaw ? parseFloat(valueRaw) / 100 : undefined;
4370
-
4371
- // Transaction ID: usar hash se disponível (mais estável que id numérico)
4372
- const transactionId = order.hash || order.transaction_hash || order.id;
4373
-
4374
- // Deduplicação — verificar se transação já foi processada
4375
- const tcTxId = String(order.hash || order.transaction_hash || order.id || '');
4376
- if (tcTxId && env.DB) {
4377
- try {
4378
- const dup = await env.DB.prepare(
4379
- 'SELECT id FROM webhook_events WHERE transaction_id = ? AND status = ?'
4380
- ).bind(tcTxId, 'processed').first();
4381
- if (dup) {
4382
- return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
4383
- }
4384
- } catch { /* continua mesmo se a consulta falhar */ }
4385
- }
4386
-
4387
- // Buscar perfil do comprador pelo email; fallback por user_id passado via URL (cdpTrack passCheckoutParams)
4388
- const urlUserId = tracking.user_id || wh.url_params?.user_id;
4389
- let profile = await getProfileByEmail(env, customer.email);
4390
- if (!profile && urlUserId && env.DB) {
4391
- try {
4392
- profile = await env.DB.prepare(
4393
- 'SELECT * FROM user_profiles WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1'
4394
- ).bind(urlUserId).first();
4395
- } catch { /* continua sem perfil */ }
4396
- }
4397
-
4398
- // Construir fbc a partir do fbclid se o profile não tiver fbc
4399
- const fbclid = tracking.fbclid || wh.url_params?.fbclid;
4400
- const fbc = profile?.fbc || (fbclid ? `fb.1.${Date.now()}.${fbclid}` : undefined);
4401
-
4402
- const payload = {
4403
- email: customer.email,
4404
- phone: customer.phone,
4405
- firstName: customer.name?.split(' ')[0],
4406
- lastName: customer.name?.split(' ').slice(1).join(' ') || undefined,
4407
- fbp: profile?.fbp,
4408
- fbc,
4409
- ttp: profile?.ttp,
4410
- userId: profile?.user_id,
4411
- gaClientId: profile?.ga_client_id,
4412
- value,
4413
- currency: 'BRL',
4414
- contentIds: [String(item.product_id || '')],
4415
- contentName: item.product_name,
4416
- contentType: 'product',
4417
- pageUrl: profile?.page_url || `https://${env.SITE_DOMAIN || 'seu-dominio.com.br'}`,
4418
- orderId: transactionId,
4419
- eventId: `ticto_${transactionId}`,
4420
- city: profile?.city,
4421
- state: profile?.state,
4422
- country: profile?.country || 'br',
4423
- utmSource: tracking.utm_source || tracking.src || '',
4424
- utmMedium: tracking.utm_medium || '',
4425
- utmCampaign: tracking.utm_campaign || '',
4426
- utmContent: tracking.utm_content || '',
4427
- };
4428
-
4429
- // Registrar transação no D1
4430
- if (tcTxId && env.DB) {
4431
- try {
4432
- await env.DB.prepare(
4433
- 'INSERT OR IGNORE INTO webhook_events (platform, transaction_id, email, status, raw_payload) VALUES (?,?,?,?,?)'
4434
- ).bind('ticto', tcTxId, customer.email || null, 'processed', JSON.stringify(wh)).run();
4435
- } catch { /* não bloquear envio se D1 falhar */ }
4436
- }
4437
-
4438
- ctx.waitUntil(Promise.allSettled([
4439
- sendMetaCapi(env, 'Purchase', payload, request, ctx),
4440
- sendGA4Mp(env, 'purchase', payload, ctx),
4441
- sendTikTokApi(env, 'CompletePayment', payload, request, ctx),
4442
- sendPinterestCapi(env, 'Purchase', payload, request, ctx),
4443
- sendRedditCapi(env, 'Purchase', payload, request, ctx),
4444
- sendLinkedInCapi(env, 'Purchase', payload, request, ctx),
4445
- sendSpotifyCapi(env, 'Purchase', payload, request, ctx),
4446
- saveLead(env, 'Purchase', payload, request, 'ticto'),
4447
- sendWhatsApp(env, 'Purchase', payload),
4448
- ]));
4449
-
4450
- return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
4451
- }
4452
-
4453
- // ── GET /webhook/whatsapp — verificação do webhook pela Meta ─────────────────
4454
- // A Meta faz um GET nessa URL quando você cadastra o webhook no Business Manager.
4455
- // Ela envia: hub.mode=subscribe, hub.verify_token=<seu token>, hub.challenge=<número>
4456
- // Você responde com hub.challenge para confirmar que é seu servidor.
4457
- // Secret: wrangler secret put WA_WEBHOOK_VERIFY_TOKEN
4458
- if (request.method === 'GET' && url.pathname === '/webhook/whatsapp') {
4459
- const mode = url.searchParams.get('hub.mode');
4460
- const token = url.searchParams.get('hub.verify_token');
4461
- const challenge = url.searchParams.get('hub.challenge');
4462
-
4463
- if (mode === 'subscribe' && env.WA_WEBHOOK_VERIFY_TOKEN && token === env.WA_WEBHOOK_VERIFY_TOKEN) {
4464
- return new Response(challenge, { status: 200, headers: { 'Content-Type': 'text/plain' } });
4465
- }
4466
- return new Response('Forbidden', { status: 403 });
4467
- }
4468
-
4469
- // ── POST /webhook/whatsapp — mensagens recebidas (CTWA) ───────────────────────
4470
- // Recebe eventos da Meta Cloud API: mensagens de usuários que clicaram em
4471
- // anúncios "Click to WhatsApp". Extrai phone + ctwa_clid e dispara Contact
4472
- // no Meta CAPI com action_source="chat".
4473
- // URL a cadastrar: Meta Business Manager → WhatsApp → Configuration → Webhook URL
4474
- // Campos a assinar (subscribe): messages
4475
- if (request.method === 'POST' && url.pathname === '/webhook/whatsapp') {
4476
- let body;
4477
- try { body = await request.json(); } catch {
4478
- return new Response('JSON inválido', { status: 400 });
4479
- }
4480
-
4481
- const result = await processWhatsAppWebhook(env, body, request, ctx);
4482
-
4483
- // A Meta exige resposta 200 em até 20s — mesmo que não haja nada a processar
4484
- return new Response(JSON.stringify({ ok: true, ...result }), { status: 200, headers });
4485
- }
4486
-
4487
- // ── Segmentação Dinâmica ML ──────────────────────────────────────────
4488
- if (url.pathname === '/api/segmentation/cluster' && request.method === 'POST') {
4489
- return handleSegmentationCluster(env, request, headers);
4490
- }
4491
- if (url.pathname === '/api/segmentation/list' && request.method === 'GET') {
4492
- return handleSegmentationList(env, request, headers);
4493
- }
4494
- if (url.pathname === '/api/segmentation/outliers' && request.method === 'GET') {
4495
- return handleSegmentationOutliers(env, request, headers);
4496
- }
4497
- if (url.pathname === '/api/segmentation/update' && request.method === 'PUT') {
4498
- return handleSegmentationUpdate(env, request, headers);
4499
- }
4500
-
4501
- // ── Bidding Recommendations ML ────────────────────────────────────────────
4502
- if (url.pathname === '/api/bidding/recommend' && request.method === 'POST') {
4503
- return handleBiddingRecommend(env, request, headers);
4504
- }
4505
- if (url.pathname === '/api/bidding/history' && request.method === 'GET') {
4506
- return handleBiddingHistory(env, request, headers);
4507
- }
4508
- if (url.pathname === '/api/bidding/status' && request.method === 'GET') {
4509
- return handleBiddingStatus(env, request, headers);
4510
- }
4511
-
4512
- // ── A/B Testing de Prompts LTV ────────────────────────────────────────────
4513
- if (url.pathname === '/api/ltv/ab-test/create' && request.method === 'POST') {
4514
- return handleLtvAbTestCreate(env, request, headers);
4515
- }
4516
- if (url.pathname === '/api/ltv/ab-test/list' && request.method === 'GET') {
4517
- return handleLtvAbTestList(env, request, headers);
4518
- }
4519
- if (url.pathname === '/api/ltv/ab-test/results' && request.method === 'GET') {
4520
- return handleLtvAbTestResults(env, request, headers);
4521
- }
4522
- if (url.pathname === '/api/ltv/ab-test/winner' && request.method === 'POST') {
4523
- return handleLtvAbTestWinner(env, request, headers);
4524
- }
4525
-
4526
- // ── Fraud Detection — Fase 4 ──────────────────────────────────────────────
4527
- if (url.pathname === '/api/fraud/alerts' && request.method === 'GET') {
4528
- return handleFraudAlerts(env, request, headers);
4529
- }
4530
- if (url.pathname === '/api/fraud/blocklist' && request.method === 'GET') {
4531
- return handleFraudBlocklist(env, request, headers);
4532
- }
4533
- if (url.pathname === '/api/fraud/blocklist/add' && request.method === 'POST') {
4534
- return handleFraudBlocklistAdd(env, request, headers);
4535
- }
4536
- if (url.pathname === '/api/fraud/blocklist/remove' && request.method === 'DELETE') {
4537
- return handleFraudBlocklistRemove(env, request, headers);
4538
- }
4539
- if (url.pathname === '/api/fraud/stats' && request.method === 'GET') {
4540
- return handleFraudStats(env, request, headers);
4541
- }
4542
-
4543
- // 404 para rotas não encontradas
4544
- return new Response(
4545
- JSON.stringify({ error: 'rota não encontrada' }),
4546
- { status: 404, headers }
4547
- );
4548
- },
4549
-
4550
- // ── Cron Handler — Intelligence Agent ──────────────────────────────────────
4551
- async scheduled(event, env, ctx) {
4552
- const cron = event.cron; // '0 2 * * 0' ou '0 3 1 * *'
4553
- const isMonthly = cron === '0 3 1 * *';
4554
-
4555
-
4556
- ctx.waitUntil(runIntelligenceAgent(env, isMonthly ? 'monthly_audit' : 'weekly_check'));
4557
- },
4558
-
4559
- // ── Queue Consumer — Retry de eventos com falha ────────────────────────────
4560
- async queue(batch, env) {
4561
- for (const message of batch.messages) {
4562
- const { eventType, payload, platform, attempt = 1 } = message.body;
4563
-
4564
-
4565
- try {
4566
- if (platform === 'meta') await sendMetaCapi(env, eventType, payload, null, null);
4567
- if (platform === 'ga4') await sendGA4Mp(env, eventType, payload, null);
4568
- if (platform === 'tiktok') await sendTikTokApi(env, eventType, payload, null, null);
4569
-
4570
- message.ack(); // sucesso — remove da fila
4571
- } catch (err) {
4572
- console.error(`[Queue] Falha ao reprocessar ${platform}/${eventType}:`, err.message);
4573
- message.retry(); // reenfileira até max_retries, depois vai para DLQ
4574
- }
4575
- }
4576
- },
4577
- };