cdp-edge 2.0.2 → 2.0.4

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 +325 -308
  2. package/contracts/agent-versions.json +364 -0
  3. package/dist/commands/install.js +1 -1
  4. package/dist/commands/setup.js +1 -1
  5. package/extracted-skill/tracking-events-generator/agents/browser-tracking.md +2 -2
  6. package/extracted-skill/tracking-events-generator/agents/master-orchestrator.md +14 -20
  7. package/extracted-skill/tracking-events-generator/agents/premium-tracking-intelligence-agent.md +4 -4
  8. package/extracted-skill/tracking-events-generator/agents/server-tracking.md +13 -13
  9. package/extracted-skill/tracking-events-generator/agents/spotify-agent.md +1 -1
  10. package/extracted-skill/tracking-events-generator/agents/webhook-agent.md +4 -4
  11. package/extracted-skill/tracking-events-generator/agents/youtube-agent.md +3 -3
  12. package/package.json +81 -76
  13. package/server-edge-tracker/index.js +780 -0
  14. package/server-edge-tracker/modules/db.js +531 -0
  15. package/server-edge-tracker/modules/dispatch/ga4.js +65 -0
  16. package/server-edge-tracker/modules/dispatch/meta.js +103 -0
  17. package/server-edge-tracker/modules/dispatch/platforms.js +237 -0
  18. package/server-edge-tracker/modules/dispatch/tiktok.js +100 -0
  19. package/server-edge-tracker/modules/dispatch/whatsapp.js +233 -0
  20. package/server-edge-tracker/modules/intelligence.js +204 -0
  21. package/server-edge-tracker/modules/ml/bidding.js +245 -0
  22. package/server-edge-tracker/modules/ml/fraud.js +301 -0
  23. package/server-edge-tracker/modules/ml/ltv.js +320 -0
  24. package/server-edge-tracker/modules/ml/segmentation.js +316 -0
  25. package/server-edge-tracker/modules/utils.js +89 -0
  26. package/server-edge-tracker/schema-indexes.sql +67 -0
  27. package/server-edge-tracker/wrangler.toml +2 -0
@@ -0,0 +1,780 @@
1
+ /**
2
+ * CDP Edge — index.js (ES Module Entry Point)
3
+ *
4
+ * Este arquivo é o novo entry point modular do Worker.
5
+ * Para usá-lo, altere em wrangler.toml:
6
+ * main = "worker.js" → main = "index.js"
7
+ *
8
+ * O worker.js original permanece intacto como fallback.
9
+ * Todos os módulos ficam em ./modules/
10
+ */
11
+
12
+ // ── Utilitários base ──────────────────────────────────────────────────────────
13
+ import {
14
+ isAllowedOrigin,
15
+ corsHeaders,
16
+ sha256,
17
+ META_TO_GA4,
18
+ VALID_EVENT_NAMES,
19
+ } from './modules/utils.js';
20
+
21
+ // ── Banco de dados (D1) ───────────────────────────────────────────────────────
22
+ import {
23
+ saveLead,
24
+ upsertProfile,
25
+ resolveDeviceGraph,
26
+ fireAutomation,
27
+ getProfileByEmail,
28
+ enrichGeoFromEdge,
29
+ writeAuditLog,
30
+ generateEdgeFingerprint,
31
+ saveEdgeFingerprint,
32
+ resurrectUTM,
33
+ upsertLtvProfile,
34
+ } from './modules/db.js';
35
+
36
+ // ── Dispatch — plataformas de ads ─────────────────────────────────────────────
37
+ import { sendMetaCapi } from './modules/dispatch/meta.js';
38
+ import { sendGA4Mp } from './modules/dispatch/ga4.js';
39
+ import { sendTikTokApi } from './modules/dispatch/tiktok.js';
40
+ import {
41
+ sendPinterestCapi,
42
+ sendRedditCapi,
43
+ sendLinkedInCapi,
44
+ sendSpotifyCapi,
45
+ } from './modules/dispatch/platforms.js';
46
+ import {
47
+ sendWhatsApp,
48
+ processWhatsAppWebhook,
49
+ verifyHmac,
50
+ } from './modules/dispatch/whatsapp.js';
51
+
52
+ // ── ML — LTV + A/B Testing ────────────────────────────────────────────────────
53
+ import {
54
+ predictLtv,
55
+ getLtvAbVariation,
56
+ recordAbAssignment,
57
+ handleLtvAbTestCreate,
58
+ handleLtvAbTestList,
59
+ handleLtvAbTestResults,
60
+ handleLtvAbTestWinner,
61
+ } from './modules/ml/ltv.js';
62
+
63
+ // ── ML — Segmentação ──────────────────────────────────────────────────────────
64
+ import {
65
+ handleSegmentationCluster,
66
+ handleSegmentationList,
67
+ handleSegmentationOutliers,
68
+ handleSegmentationUpdate,
69
+ } from './modules/ml/segmentation.js';
70
+
71
+ // ── ML — Bidding ──────────────────────────────────────────────────────────────
72
+ import {
73
+ handleBiddingRecommend,
74
+ handleBiddingHistory,
75
+ handleBiddingStatus,
76
+ } from './modules/ml/bidding.js';
77
+
78
+ // ── ML — Fraud Detection ──────────────────────────────────────────────────────
79
+ import {
80
+ checkFraudGate,
81
+ logFraudSignal,
82
+ handleFraudAlerts,
83
+ handleFraudBlocklist,
84
+ handleFraudBlocklistAdd,
85
+ handleFraudBlocklistRemove,
86
+ handleFraudStats,
87
+ } from './modules/ml/fraud.js';
88
+
89
+ // ── Intelligence Agent (Cron) ─────────────────────────────────────────────────
90
+ import {
91
+ runIntelligenceAgent,
92
+ buildGoogleCustomerMatchExport,
93
+ } from './modules/intelligence.js';
94
+
95
+ // ─────────────────────────────────────────────────────────────────────────────
96
+ // HANDLER PRINCIPAL
97
+ // ─────────────────────────────────────────────────────────────────────────────
98
+ export default {
99
+
100
+ async fetch(request, env, ctx) {
101
+ const origin = request.headers.get('Origin') || '';
102
+ const headers = {
103
+ 'Content-Type': 'application/json',
104
+ ...corsHeaders(origin, env.SITE_DOMAIN),
105
+ };
106
+
107
+ // Preflight CORS
108
+ if (request.method === 'OPTIONS') {
109
+ return new Response(null, { status: 204, headers });
110
+ }
111
+
112
+ const url = new URL(request.url);
113
+
114
+ // ── Fraud Gate — Fase 4 (apenas em /track) ────────────────────────────────
115
+ // Roda ANTES de qualquer processamento de evento
116
+ // Silent drop (200) — bots não sabem que foram detectados
117
+ if (url.pathname === '/track' && request.method === 'POST') {
118
+ let trackBodyForFraud;
119
+ try {
120
+ const cloned = request.clone();
121
+ trackBodyForFraud = await cloned.json().catch(() => ({}));
122
+ } catch { trackBodyForFraud = {}; }
123
+
124
+ const fraudResult = await checkFraudGate(env, request, trackBodyForFraud);
125
+ if (!fraudResult.allowed) {
126
+ ctx.waitUntil(logFraudSignal(env, request, trackBodyForFraud, fraudResult));
127
+ return new Response(JSON.stringify({ status: 'ok', queued: true }), { status: 200, headers });
128
+ }
129
+ if (fraudResult.action === 'flagged') {
130
+ ctx.waitUntil(logFraudSignal(env, request, trackBodyForFraud, fraudResult));
131
+ }
132
+ }
133
+
134
+ // ── GET /export/customer-match ────────────────────────────────────────────
135
+ if (request.method === 'GET' && url.pathname === '/export/customer-match') {
136
+ const authHeader = request.headers.get('Authorization') || '';
137
+ const token = authHeader.replace('Bearer ', '');
138
+ if (!env.META_ACCESS_TOKEN || token !== env.META_ACCESS_TOKEN) {
139
+ return new Response('Unauthorized', { status: 401 });
140
+ }
141
+
142
+ const rows = await buildGoogleCustomerMatchExport(env);
143
+ return new Response(JSON.stringify({ total: rows.length, data: rows }, null, 2), {
144
+ headers: { ...headers, 'Content-Disposition': 'attachment; filename="customer-match.json"' },
145
+ });
146
+ }
147
+
148
+ // ── GET /health ───────────────────────────────────────────────────────────
149
+ if (request.method === 'GET' && url.pathname === '/health') {
150
+ const results = {};
151
+
152
+ try {
153
+ await env.DB.prepare('SELECT 1').run();
154
+ results.d1 = 'ok';
155
+ } catch (err) {
156
+ results.d1 = `FAILED: ${err.message}`;
157
+ }
158
+
159
+ try {
160
+ await env.GEO_CACHE.get('__health_check__');
161
+ results.kv = 'ok';
162
+ } catch (err) {
163
+ results.kv = `FAILED: ${err.message}`;
164
+ }
165
+
166
+ try {
167
+ await env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
168
+ messages: [{ role: 'user', content: 'ping' }],
169
+ max_tokens: 1,
170
+ });
171
+ results.ai = 'ok';
172
+ } catch (err) {
173
+ results.ai = `FAILED: ${err.message}`;
174
+ }
175
+
176
+ const vars = {
177
+ META_PIXEL_ID: env.META_PIXEL_ID ? 'set' : 'MISSING',
178
+ GA4_MEASUREMENT_ID: env.GA4_MEASUREMENT_ID ? 'set' : 'MISSING',
179
+ TIKTOK_PIXEL_ID: env.TIKTOK_PIXEL_ID ? 'set' : 'MISSING',
180
+ SITE_DOMAIN: env.SITE_DOMAIN ? 'set' : 'MISSING',
181
+ };
182
+
183
+ const secrets = {
184
+ META_ACCESS_TOKEN: env.META_ACCESS_TOKEN ? 'set' : 'MISSING',
185
+ GA4_API_SECRET: env.GA4_API_SECRET ? 'set' : 'MISSING',
186
+ WA_WEBHOOK_VERIFY_TOKEN: env.WA_WEBHOOK_VERIFY_TOKEN ? 'set' : 'MISSING',
187
+ WHATSAPP_ACCESS_TOKEN: env.WHATSAPP_ACCESS_TOKEN ? 'set' : 'not set (optional - only for auto-reply)',
188
+ WHATSAPP_PHONE_NUMBER_ID: env.WHATSAPP_PHONE_NUMBER_ID ? 'set' : 'not set (optional - only for auto-reply)',
189
+ WA_NOTIFY_NUMBER: env.WA_NOTIFY_NUMBER ? 'set' : 'not set (optional - only for auto-reply)',
190
+ TIKTOK_ACCESS_TOKEN: env.TIKTOK_ACCESS_TOKEN ? 'set' : 'not set (optional)',
191
+ CALLMEBOT_PHONE: env.CALLMEBOT_PHONE ? 'set' : 'not set (optional)',
192
+ };
193
+
194
+ const hasMissing =
195
+ Object.values(vars).includes('MISSING') ||
196
+ Object.values(secrets).includes('MISSING') ||
197
+ results.d1 !== 'ok';
198
+
199
+ return new Response(JSON.stringify({
200
+ status: hasMissing ? 'degraded' : 'ok',
201
+ timestamp: new Date().toISOString(),
202
+ bindings: results,
203
+ vars,
204
+ secrets,
205
+ }, null, 2), { headers });
206
+ }
207
+
208
+ // ── POST /track ───────────────────────────────────────────────────────────
209
+ if (request.method === 'POST' && url.pathname === '/track') {
210
+ // Reject oversized payloads before reading body (64 KB limit)
211
+ const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
212
+ if (contentLength > 65536) {
213
+ return new Response(JSON.stringify({ error: 'Payload muito grande' }), { status: 413, headers });
214
+ }
215
+
216
+ let body;
217
+ try {
218
+ body = await request.json();
219
+ } catch {
220
+ return new Response(JSON.stringify({ error: 'JSON inválido' }), { status: 400, headers });
221
+ }
222
+
223
+ if (typeof body !== 'object' || Array.isArray(body) || body === null) {
224
+ return new Response(JSON.stringify({ error: 'Payload inválido' }), { status: 400, headers });
225
+ }
226
+
227
+ const STR_FIELDS = ['email','phone','firstName','lastName','city','state','zip','userId',
228
+ 'utmSource','utmMedium','utmCampaign','utmContent','utmTerm',
229
+ 'fbclid','ttclid','gclid','transactionId','productName','currency'];
230
+
231
+ const { eventName, behavioral_data, ...payload } = body;
232
+
233
+ if (!eventName) {
234
+ return new Response(JSON.stringify({ error: 'eventName é obrigatório' }), { status: 400, headers });
235
+ }
236
+
237
+ if (typeof eventName !== 'string' || eventName.length > 64 || !VALID_EVENT_NAMES.has(eventName)) {
238
+ return new Response(JSON.stringify({ error: `eventName desconhecido: ${eventName.slice(0, 64)}` }), { status: 400, headers });
239
+ }
240
+
241
+ for (const field of STR_FIELDS) {
242
+ if (payload[field] !== undefined && payload[field] !== null) {
243
+ if (typeof payload[field] !== 'string' || payload[field].length > 512) {
244
+ return new Response(JSON.stringify({ error: `Campo inválido: ${field}` }), { status: 400, headers });
245
+ }
246
+ }
247
+ }
248
+
249
+ if (payload.value !== undefined && payload.value !== null) {
250
+ const v = Number(payload.value);
251
+ if (isNaN(v) || v < 0 || v > 9_999_999) {
252
+ return new Response(JSON.stringify({ error: 'value fora do intervalo permitido' }), { status: 400, headers });
253
+ }
254
+ payload.value = v;
255
+ }
256
+
257
+ // ── Extrair dados comportamentais do browser ──────────────────────────
258
+ if (behavioral_data) {
259
+ payload.engagementScore = behavioral_data.engagement_score ?? behavioral_data.totalScore ?? null;
260
+ payload.intentionLevel = behavioral_data.intention_level ?? null;
261
+ payload.userScore = behavioral_data.user_score ?? null;
262
+ payload.email = payload.email || behavioral_data.email || null;
263
+ payload.phone = payload.phone || behavioral_data.phone || null;
264
+ payload.firstName = payload.firstName || behavioral_data.first_name || behavioral_data.firstName || null;
265
+ payload.lastName = payload.lastName || behavioral_data.last_name || behavioral_data.lastName || null;
266
+ payload.city = payload.city || behavioral_data.city || null;
267
+ payload.state = payload.state || behavioral_data.state || null;
268
+ payload.zip = payload.zip || behavioral_data.zip || null;
269
+ payload.dob = payload.dob || behavioral_data.dob || null;
270
+ }
271
+
272
+ // ── Edge Fingerprint + UTM Resurrection ──────────────────────────────
273
+ const fingerprint = await generateEdgeFingerprint(request);
274
+ payload.utmRestored = false;
275
+
276
+ if (fingerprint) {
277
+ if (payload.utmSource) {
278
+ ctx.waitUntil(saveEdgeFingerprint(env.DB, fingerprint, payload.userId, payload));
279
+ } else {
280
+ const recovered = await resurrectUTM(env.DB, fingerprint);
281
+ if (recovered) {
282
+ payload.utmSource = payload.utmSource || recovered.utm_source;
283
+ payload.utmMedium = payload.utmMedium || recovered.utm_medium;
284
+ payload.utmCampaign = payload.utmCampaign || recovered.utm_campaign;
285
+ payload.utmContent = payload.utmContent || recovered.utm_content;
286
+ payload.utmTerm = payload.utmTerm || recovered.utm_term;
287
+ payload.utmRestored = true;
288
+ console.log(`[UTM Resurrection] Recovered: ${recovered.utm_source}/${recovered.utm_medium}/${recovered.utm_campaign}`);
289
+ }
290
+ }
291
+ }
292
+
293
+ // ── Bot Mitigation ────────────────────────────────────────────────────
294
+ const botScoreStr = request.cf?.botManagement?.score;
295
+ const cfBotScore = botScoreStr !== undefined ? parseInt(botScoreStr) : 100;
296
+ const ua = (request.headers.get('User-Agent') || '').toLowerCase();
297
+ const isBotPattern = /bot|crawler|spider|lighthouse|postman|curl|inspect|headless/i.test(ua);
298
+
299
+ const isBot = cfBotScore < 30 || isBotPattern;
300
+ payload.botScore = isBot ? 2 : (cfBotScore < 60 ? 1 : 0);
301
+
302
+ if (isBot && !['Contact', 'Lead', 'Purchase'].includes(eventName)) {
303
+ return new Response(JSON.stringify({ ok: true, skipped_due_to_bot: true }), { status: 200, headers });
304
+ }
305
+
306
+ // ── Edge Geo Enrichment ───────────────────────────────────────────────
307
+ const geoData = await enrichGeoFromEdge(request, env, payload);
308
+
309
+ // ── First-Party Cookie (Identity Resolution) ──────────────────────────
310
+ const cookieHeader = request.headers.get('Cookie') || '';
311
+ const cdpUidMatch = cookieHeader.match(/_cdp_uid=([^;]+)/);
312
+ const finalUserId = payload.userId || (cdpUidMatch ? cdpUidMatch[1] : crypto.randomUUID());
313
+ payload.userId = finalUserId;
314
+
315
+ const ga4Name = META_TO_GA4[eventName] || eventName.toLowerCase();
316
+
317
+ // ── LTV Prediction (+ A/B Testing de Prompts) ─────────────────────────
318
+ const LTV_EVENTS = ['Lead', 'Contact', 'Schedule', 'CompleteRegistration'];
319
+ if (LTV_EVENTS.includes(eventName) && !payload.value) {
320
+ const abVariation = await getLtvAbVariation(env);
321
+ const ltv = await predictLtv(env, payload, request, abVariation?.system_prompt || null);
322
+ payload.value = ltv.value;
323
+ payload.currency = payload.currency || 'BRL';
324
+ payload.ltvClass = ltv.class;
325
+ payload.ltvScore = ltv.score;
326
+ ctx.waitUntil(upsertLtvProfile(env, payload.userId, ltv));
327
+ if (abVariation) {
328
+ const emailHash = payload.email
329
+ ? await sha256(payload.email.trim().toLowerCase())
330
+ : null;
331
+ ctx.waitUntil(
332
+ recordAbAssignment(
333
+ env,
334
+ payload.userId,
335
+ abVariation.variation_id,
336
+ abVariation.test_id,
337
+ ltv.value,
338
+ ltv.class,
339
+ emailHash,
340
+ )
341
+ );
342
+ }
343
+ }
344
+
345
+ // Cross-Device Graph — background
346
+ if (env.DB && payload.userId && (payload.email || payload.phone)) {
347
+ ctx.waitUntil(resolveDeviceGraph(env.DB, payload.userId, payload.email, payload.phone));
348
+ }
349
+
350
+ // R2 Audit Log — background
351
+ ctx.waitUntil(writeAuditLog(env, eventName, payload, geoData));
352
+
353
+ // Disparar tudo em paralelo
354
+ const WHATSAPP_NOTIFY_EVENTS = ['Lead', 'Purchase', 'CompleteRegistration'];
355
+ const [metaRes, ga4Res, ttRes] = await Promise.allSettled([
356
+ sendMetaCapi(env, eventName, payload, request, ctx),
357
+ sendGA4Mp(env, ga4Name, payload, ctx),
358
+ sendTikTokApi(env, eventName, payload, request, ctx),
359
+ saveLead(env, eventName, payload, request, 'website'),
360
+ upsertProfile(env, eventName, payload, request),
361
+ ...(WHATSAPP_NOTIFY_EVENTS.includes(eventName)
362
+ ? [sendWhatsApp(env, eventName, payload)]
363
+ : []),
364
+ ]);
365
+
366
+ // Automação de mensagens
367
+ const AUTOMATION_EVENTS = ['Lead', 'Purchase', 'InitiateCheckout'];
368
+ if (AUTOMATION_EVENTS.includes(eventName) && env.DB) {
369
+ ctx.waitUntil(
370
+ (async () => {
371
+ try {
372
+ const lastLead = await env.DB
373
+ .prepare(`SELECT id FROM leads WHERE event_id = ?1 LIMIT 1`)
374
+ .bind(payload.eventId || payload.event_id || '')
375
+ .first();
376
+ const leadId = lastLead?.id ?? null;
377
+ if (leadId) await fireAutomation(env, eventName, leadId, payload);
378
+ } catch (e) { console.error('[Automation] lead lookup error:', e.message); }
379
+ })()
380
+ );
381
+ }
382
+
383
+ // Edge Personalization
384
+ let currentScore = 0;
385
+ if (env.DB && payload.userId) {
386
+ try {
387
+ const profileRow = await env.DB.prepare('SELECT score FROM user_profiles WHERE user_id = ?').bind(payload.userId).first();
388
+ if (profileRow) currentScore = profileRow.score;
389
+ } catch {}
390
+ }
391
+
392
+ const resHeaders = new Headers(headers);
393
+ resHeaders.set('Set-Cookie', `_cdp_uid=${finalUserId}; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000; Path=/; Domain=.${env.SITE_DOMAIN}`);
394
+
395
+ return new Response(JSON.stringify({
396
+ ok: true,
397
+ userProfile: { score: currentScore, user_id: finalUserId },
398
+ meta: metaRes.value ?? { error: metaRes.reason?.message },
399
+ ga4: ga4Res.value ?? { error: ga4Res.reason?.message },
400
+ tiktok: ttRes.value ?? { error: ttRes.reason?.message },
401
+ }), { status: 200, headers: resHeaders });
402
+ }
403
+
404
+ // ── POST /webhook/hotmart ─────────────────────────────────────────────────
405
+ if (request.method === 'POST' && url.pathname === '/webhook/hotmart') {
406
+ if (env.WEBHOOK_SECRET_HOTMART) {
407
+ const token = request.headers.get('X-Hotmart-Webhook-Token') || '';
408
+ if (token !== env.WEBHOOK_SECRET_HOTMART) {
409
+ return new Response('Unauthorized', { status: 401 });
410
+ }
411
+ }
412
+
413
+ let wh;
414
+ try { wh = await request.json(); } catch {
415
+ return new Response('JSON inválido', { status: 400 });
416
+ }
417
+
418
+ const data = wh.data || wh;
419
+ const buyer = data.buyer || {};
420
+ const purchase = data.purchase || {};
421
+ const product = data.product || {};
422
+
423
+ if (!['APPROVED', 'COMPLETE', 'approved', 'complete'].includes(purchase.status)) {
424
+ return new Response(JSON.stringify({ skipped: `status ${purchase.status}` }), { status: 200, headers });
425
+ }
426
+
427
+ const hmTxId = String(purchase.transaction || '');
428
+ if (hmTxId && env.DB) {
429
+ try {
430
+ const dup = await env.DB.prepare(
431
+ 'SELECT id FROM webhook_events WHERE transaction_id = ? AND status = ?'
432
+ ).bind(hmTxId, 'processed').first();
433
+ if (dup) {
434
+ return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
435
+ }
436
+ } catch {}
437
+ }
438
+
439
+ const profile = await getProfileByEmail(env, buyer.email);
440
+
441
+ const payload = {
442
+ email: buyer.email,
443
+ phone: buyer.phone,
444
+ firstName: buyer.name?.split(' ')[0],
445
+ lastName: buyer.name?.split(' ').slice(1).join(' ') || undefined,
446
+ fbp: profile?.fbp,
447
+ fbc: profile?.fbc,
448
+ userId: profile?.user_id,
449
+ gaClientId: profile?.ga_client_id,
450
+ value: purchase.price?.value,
451
+ currency: purchase.price?.currency_value || 'BRL',
452
+ contentIds: [String(product.id || product.ucode || '')],
453
+ contentName: product.name,
454
+ contentType: 'product',
455
+ pageUrl: profile?.page_url || `https://${env.SITE_DOMAIN || 'seu-dominio.com.br'}`,
456
+ orderId: purchase.transaction,
457
+ eventId: `hotmart_${purchase.transaction}`,
458
+ city: profile?.city,
459
+ state: profile?.state,
460
+ country: profile?.country,
461
+ };
462
+
463
+ if (hmTxId && env.DB) {
464
+ try {
465
+ await env.DB.prepare(
466
+ 'INSERT OR IGNORE INTO webhook_events (platform, transaction_id, email, status, raw_payload) VALUES (?,?,?,?,?)'
467
+ ).bind('hotmart', hmTxId, buyer.email || null, 'processed', JSON.stringify(wh)).run();
468
+ } catch {}
469
+ }
470
+
471
+ ctx.waitUntil(Promise.allSettled([
472
+ sendMetaCapi(env, 'Purchase', payload, request, ctx),
473
+ sendGA4Mp(env, 'purchase', payload, ctx),
474
+ sendTikTokApi(env, 'CompletePayment', payload, request, ctx),
475
+ saveLead(env, 'Purchase', payload, request, 'hotmart'),
476
+ sendWhatsApp(env, 'Purchase', payload),
477
+ ]));
478
+
479
+ return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
480
+ }
481
+
482
+ // ── POST /webhook/kiwify ──────────────────────────────────────────────────
483
+ if (request.method === 'POST' && url.pathname === '/webhook/kiwify') {
484
+ if (env.WEBHOOK_SECRET_KIWIFY) {
485
+ const token = request.headers.get('X-Kiwify-Event-Token') || '';
486
+ if (token !== env.WEBHOOK_SECRET_KIWIFY) {
487
+ return new Response('Unauthorized', { status: 401 });
488
+ }
489
+ }
490
+
491
+ let wh;
492
+ try { wh = await request.json(); } catch {
493
+ return new Response('JSON inválido', { status: 400 });
494
+ }
495
+
496
+ if (wh.order_status !== 'paid' && wh.order_status !== 'approved') {
497
+ return new Response(JSON.stringify({ skipped: `status ${wh.order_status}` }), { status: 200, headers });
498
+ }
499
+
500
+ const kwTxId = String(wh.order_id || '');
501
+ if (kwTxId && env.DB) {
502
+ try {
503
+ const dup = await env.DB.prepare(
504
+ 'SELECT id FROM webhook_events WHERE transaction_id = ? AND status = ?'
505
+ ).bind(kwTxId, 'processed').first();
506
+ if (dup) {
507
+ return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
508
+ }
509
+ } catch {}
510
+ }
511
+
512
+ const customer = wh.Customer || {};
513
+ const product = wh.Product || {};
514
+ const profile = await getProfileByEmail(env, customer.email);
515
+
516
+ const payload = {
517
+ email: customer.email,
518
+ phone: customer.mobile,
519
+ firstName: customer.full_name?.split(' ')[0],
520
+ lastName: customer.full_name?.split(' ').slice(1).join(' ') || undefined,
521
+ fbp: profile?.fbp,
522
+ fbc: profile?.fbc,
523
+ userId: profile?.user_id,
524
+ gaClientId: profile?.ga_client_id,
525
+ value: wh.order_value ? parseFloat(wh.order_value) / 100 : undefined,
526
+ currency: 'BRL',
527
+ contentIds: [String(product.product_id || '')],
528
+ contentName: product.product_name,
529
+ contentType: 'product',
530
+ pageUrl: profile?.page_url || `https://${env.SITE_DOMAIN || 'seu-dominio.com.br'}`,
531
+ orderId: wh.order_id,
532
+ eventId: `kiwify_${wh.order_id}`,
533
+ city: profile?.city,
534
+ state: profile?.state,
535
+ country: profile?.country,
536
+ };
537
+
538
+ if (kwTxId && env.DB) {
539
+ try {
540
+ await env.DB.prepare(
541
+ 'INSERT OR IGNORE INTO webhook_events (platform, transaction_id, email, status, raw_payload) VALUES (?,?,?,?,?)'
542
+ ).bind('kiwify', kwTxId, customer.email || null, 'processed', JSON.stringify(wh)).run();
543
+ } catch {}
544
+ }
545
+
546
+ ctx.waitUntil(Promise.allSettled([
547
+ sendMetaCapi(env, 'Purchase', payload, request, ctx),
548
+ sendGA4Mp(env, 'purchase', payload, ctx),
549
+ sendTikTokApi(env, 'CompletePayment', payload, request, ctx),
550
+ sendPinterestCapi(env, 'Purchase', payload, request, ctx),
551
+ sendRedditCapi(env, 'Purchase', payload, request, ctx),
552
+ sendLinkedInCapi(env, 'Purchase', payload, request, ctx),
553
+ sendSpotifyCapi(env, 'Purchase', payload, request, ctx),
554
+ saveLead(env, 'Purchase', payload, request, 'kiwify'),
555
+ sendWhatsApp(env, 'Purchase', payload),
556
+ ]));
557
+
558
+ return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
559
+ }
560
+
561
+ // ── POST /webhook/ticto ───────────────────────────────────────────────────
562
+ if (request.method === 'POST' && url.pathname === '/webhook/ticto') {
563
+ let rawBody;
564
+ try { rawBody = await request.text(); } catch {
565
+ return new Response('Leitura de body falhou', { status: 400 });
566
+ }
567
+ if (env.WEBHOOK_SECRET_TICTO) {
568
+ const sig = request.headers.get('X-Ticto-Signature') || '';
569
+ const valid = await verifyHmac(env.WEBHOOK_SECRET_TICTO, rawBody, sig);
570
+ if (!valid) {
571
+ return new Response('Unauthorized', { status: 401 });
572
+ }
573
+ }
574
+
575
+ let wh;
576
+ try { wh = JSON.parse(rawBody); } catch {
577
+ return new Response('JSON inválido', { status: 400 });
578
+ }
579
+
580
+ const STATUS_PAID = ['paid', 'approved', 'complete', 'completed'];
581
+ if (!STATUS_PAID.includes((wh.status || '').toLowerCase())) {
582
+ return new Response(JSON.stringify({ skipped: `status ${wh.status}` }), { status: 200, headers });
583
+ }
584
+
585
+ const customer = wh.customer || {};
586
+ const order = wh.order || {};
587
+ const item = wh.item || {};
588
+ const tracking = wh.tracking || wh.url_params || {};
589
+
590
+ const valueRaw = order.paid_amount ?? order.total ?? order.amount;
591
+ const value = valueRaw ? parseFloat(valueRaw) / 100 : undefined;
592
+ const transactionId = order.hash || order.transaction_hash || order.id;
593
+ const tcTxId = String(order.hash || order.transaction_hash || order.id || '');
594
+
595
+ if (tcTxId && env.DB) {
596
+ try {
597
+ const dup = await env.DB.prepare(
598
+ 'SELECT id FROM webhook_events WHERE transaction_id = ? AND status = ?'
599
+ ).bind(tcTxId, 'processed').first();
600
+ if (dup) {
601
+ return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
602
+ }
603
+ } catch {}
604
+ }
605
+
606
+ const urlUserId = tracking.user_id || wh.url_params?.user_id;
607
+ let profile = await getProfileByEmail(env, customer.email);
608
+ if (!profile && urlUserId && env.DB) {
609
+ try {
610
+ profile = await env.DB.prepare(
611
+ 'SELECT * FROM user_profiles WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1'
612
+ ).bind(urlUserId).first();
613
+ } catch {}
614
+ }
615
+
616
+ const fbclid = tracking.fbclid || wh.url_params?.fbclid;
617
+ const fbc = profile?.fbc || (fbclid ? `fb.1.${Date.now()}.${fbclid}` : undefined);
618
+
619
+ const payload = {
620
+ email: customer.email,
621
+ phone: customer.phone,
622
+ firstName: customer.name?.split(' ')[0],
623
+ lastName: customer.name?.split(' ').slice(1).join(' ') || undefined,
624
+ fbp: profile?.fbp,
625
+ fbc,
626
+ ttp: profile?.ttp,
627
+ userId: profile?.user_id,
628
+ gaClientId: profile?.ga_client_id,
629
+ value,
630
+ currency: 'BRL',
631
+ contentIds: [String(item.product_id || '')],
632
+ contentName: item.product_name,
633
+ contentType: 'product',
634
+ pageUrl: profile?.page_url || `https://${env.SITE_DOMAIN || 'seu-dominio.com.br'}`,
635
+ orderId: transactionId,
636
+ eventId: `ticto_${transactionId}`,
637
+ city: profile?.city,
638
+ state: profile?.state,
639
+ country: profile?.country || 'br',
640
+ utmSource: tracking.utm_source || tracking.src || '',
641
+ utmMedium: tracking.utm_medium || '',
642
+ utmCampaign: tracking.utm_campaign || '',
643
+ utmContent: tracking.utm_content || '',
644
+ };
645
+
646
+ if (tcTxId && env.DB) {
647
+ try {
648
+ await env.DB.prepare(
649
+ 'INSERT OR IGNORE INTO webhook_events (platform, transaction_id, email, status, raw_payload) VALUES (?,?,?,?,?)'
650
+ ).bind('ticto', tcTxId, customer.email || null, 'processed', JSON.stringify(wh)).run();
651
+ } catch {}
652
+ }
653
+
654
+ ctx.waitUntil(Promise.allSettled([
655
+ sendMetaCapi(env, 'Purchase', payload, request, ctx),
656
+ sendGA4Mp(env, 'purchase', payload, ctx),
657
+ sendTikTokApi(env, 'CompletePayment', payload, request, ctx),
658
+ sendPinterestCapi(env, 'Purchase', payload, request, ctx),
659
+ sendRedditCapi(env, 'Purchase', payload, request, ctx),
660
+ sendLinkedInCapi(env, 'Purchase', payload, request, ctx),
661
+ sendSpotifyCapi(env, 'Purchase', payload, request, ctx),
662
+ saveLead(env, 'Purchase', payload, request, 'ticto'),
663
+ sendWhatsApp(env, 'Purchase', payload),
664
+ ]));
665
+
666
+ return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
667
+ }
668
+
669
+ // ── GET /webhook/whatsapp — verificação do webhook pela Meta ─────────────
670
+ if (request.method === 'GET' && url.pathname === '/webhook/whatsapp') {
671
+ const mode = url.searchParams.get('hub.mode');
672
+ const token = url.searchParams.get('hub.verify_token');
673
+ const challenge = url.searchParams.get('hub.challenge');
674
+
675
+ if (mode === 'subscribe' && env.WA_WEBHOOK_VERIFY_TOKEN && token === env.WA_WEBHOOK_VERIFY_TOKEN) {
676
+ return new Response(challenge, { status: 200, headers: { 'Content-Type': 'text/plain' } });
677
+ }
678
+ return new Response('Forbidden', { status: 403 });
679
+ }
680
+
681
+ // ── POST /webhook/whatsapp — mensagens recebidas (CTWA) ──────────────────
682
+ if (request.method === 'POST' && url.pathname === '/webhook/whatsapp') {
683
+ let body;
684
+ try { body = await request.json(); } catch {
685
+ return new Response('JSON inválido', { status: 400 });
686
+ }
687
+
688
+ const result = await processWhatsAppWebhook(env, body, request, ctx);
689
+ return new Response(JSON.stringify({ ok: true, ...result }), { status: 200, headers });
690
+ }
691
+
692
+ // ── ML — Segmentação Dinâmica ─────────────────────────────────────────────
693
+ if (url.pathname === '/api/segmentation/cluster' && request.method === 'POST') {
694
+ return handleSegmentationCluster(env, request, headers);
695
+ }
696
+ if (url.pathname === '/api/segmentation/list' && request.method === 'GET') {
697
+ return handleSegmentationList(env, request, headers);
698
+ }
699
+ if (url.pathname === '/api/segmentation/outliers' && request.method === 'GET') {
700
+ return handleSegmentationOutliers(env, request, headers);
701
+ }
702
+ if (url.pathname === '/api/segmentation/update' && request.method === 'PUT') {
703
+ return handleSegmentationUpdate(env, request, headers);
704
+ }
705
+
706
+ // ── ML — Bidding Recommendations ──────────────────────────────────────────
707
+ if (url.pathname === '/api/bidding/recommend' && request.method === 'POST') {
708
+ return handleBiddingRecommend(env, request, headers);
709
+ }
710
+ if (url.pathname === '/api/bidding/history' && request.method === 'GET') {
711
+ return handleBiddingHistory(env, request, headers);
712
+ }
713
+ if (url.pathname === '/api/bidding/status' && request.method === 'GET') {
714
+ return handleBiddingStatus(env, request, headers);
715
+ }
716
+
717
+ // ── ML — A/B Testing de Prompts LTV ──────────────────────────────────────
718
+ if (url.pathname === '/api/ltv/ab-test/create' && request.method === 'POST') {
719
+ return handleLtvAbTestCreate(env, request, headers);
720
+ }
721
+ if (url.pathname === '/api/ltv/ab-test/list' && request.method === 'GET') {
722
+ return handleLtvAbTestList(env, request, headers);
723
+ }
724
+ if (url.pathname === '/api/ltv/ab-test/results' && request.method === 'GET') {
725
+ return handleLtvAbTestResults(env, request, headers);
726
+ }
727
+ if (url.pathname === '/api/ltv/ab-test/winner' && request.method === 'POST') {
728
+ return handleLtvAbTestWinner(env, request, headers);
729
+ }
730
+
731
+ // ── Fraud Detection — Fase 4 ──────────────────────────────────────────────
732
+ if (url.pathname === '/api/fraud/alerts' && request.method === 'GET') {
733
+ return handleFraudAlerts(env, request, headers);
734
+ }
735
+ if (url.pathname === '/api/fraud/blocklist' && request.method === 'GET') {
736
+ return handleFraudBlocklist(env, request, headers);
737
+ }
738
+ if (url.pathname === '/api/fraud/blocklist/add' && request.method === 'POST') {
739
+ return handleFraudBlocklistAdd(env, request, headers);
740
+ }
741
+ if (url.pathname === '/api/fraud/blocklist/remove' && request.method === 'DELETE') {
742
+ return handleFraudBlocklistRemove(env, request, headers);
743
+ }
744
+ if (url.pathname === '/api/fraud/stats' && request.method === 'GET') {
745
+ return handleFraudStats(env, request, headers);
746
+ }
747
+
748
+ // 404
749
+ return new Response(JSON.stringify({ error: 'rota não encontrada' }), { status: 404, headers });
750
+ },
751
+
752
+ // ── Cron Handler — Intelligence Agent ────────────────────────────────────────
753
+ async scheduled(event, env, ctx) {
754
+ const cron = event.cron;
755
+ const isMonthly = cron === '0 3 1 * *';
756
+
757
+ console.log(`[Intelligence Agent] Cron executado: ${cron}`);
758
+ ctx.waitUntil(runIntelligenceAgent(env, isMonthly ? 'monthly_audit' : 'weekly_check'));
759
+ },
760
+
761
+ // ── Queue Consumer — Retry de eventos com falha ───────────────────────────────
762
+ async queue(batch, env) {
763
+ for (const message of batch.messages) {
764
+ const { eventType, payload, platform, attempt = 1 } = message.body;
765
+
766
+ console.log(`[Queue] Reprocessando: ${platform}/${eventType} (tentativa ${attempt})`);
767
+
768
+ try {
769
+ if (platform === 'meta') await sendMetaCapi(env, eventType, payload, null, null);
770
+ if (platform === 'ga4') await sendGA4Mp(env, eventType, payload, null);
771
+ if (platform === 'tiktok') await sendTikTokApi(env, eventType, payload, null, null);
772
+
773
+ message.ack();
774
+ } catch (err) {
775
+ console.error(`[Queue] Falha ao reprocessar ${platform}/${eventType}:`, err.message);
776
+ message.retry();
777
+ }
778
+ }
779
+ },
780
+ };