cdp-edge 1.23.1 → 1.23.3

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