cdp-edge 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +153 -306
  2. package/bin/cdp-edge.js +71 -61
  3. package/contracts/agent-versions.json +682 -0
  4. package/contracts/api-versions.json +372 -368
  5. package/contracts/types.ts +81 -0
  6. package/dist/commands/analyze.js +52 -52
  7. package/dist/commands/infra.js +54 -54
  8. package/dist/commands/install.js +26 -3
  9. package/dist/commands/server.js +174 -174
  10. package/dist/commands/setup.js +332 -100
  11. package/dist/commands/validate.js +248 -84
  12. package/dist/index.js +12 -12
  13. package/dist/sdk/cdpTrack.js +2095 -0
  14. package/dist/sdk/cdpTrack.min.js +64 -0
  15. package/dist/sdk/install-snippet.html +10 -0
  16. package/docs/whatsapp-ctwa.md +5 -4
  17. package/extracted-skill/tracking-events-generator/INTEGRACAO-COMPLETA.md +89 -0
  18. package/extracted-skill/tracking-events-generator/MELHORIAS-IMPLEMENTADAS.md +101 -0
  19. package/extracted-skill/tracking-events-generator/advanced-matching.js +364 -364
  20. package/extracted-skill/tracking-events-generator/agents/ab-ltv-agent.md +196 -0
  21. package/extracted-skill/tracking-events-generator/agents/ab-testing-agent.md +1 -1
  22. package/extracted-skill/tracking-events-generator/agents/attribution-agent.md +41 -41
  23. package/extracted-skill/tracking-events-generator/agents/bidding-agent.md +347 -0
  24. package/extracted-skill/tracking-events-generator/agents/bing-agent.md +40 -50
  25. package/extracted-skill/tracking-events-generator/agents/browser-tracking.md +174 -74
  26. package/extracted-skill/tracking-events-generator/agents/code-guardian-agent.md +1 -1
  27. package/extracted-skill/tracking-events-generator/agents/compliance-agent.md +25 -5
  28. package/extracted-skill/tracking-events-generator/agents/dashboard-agent.md +10 -10
  29. package/extracted-skill/tracking-events-generator/agents/database-agent.md +43 -42
  30. package/extracted-skill/tracking-events-generator/agents/debug-agent.md +22 -22
  31. package/extracted-skill/tracking-events-generator/agents/devops-agent.md +232 -0
  32. package/extracted-skill/tracking-events-generator/agents/domain-setup-agent.md +23 -9
  33. package/extracted-skill/tracking-events-generator/agents/email-agent.md +28 -1
  34. package/extracted-skill/tracking-events-generator/agents/evo-crm-agent.md +244 -0
  35. package/extracted-skill/tracking-events-generator/agents/fingerprint-agent.md +206 -1
  36. package/extracted-skill/tracking-events-generator/agents/fraud-detection-agent.md +143 -0
  37. package/extracted-skill/tracking-events-generator/agents/google-agent.md +128 -2
  38. package/extracted-skill/tracking-events-generator/agents/intelligence-agent.md +191 -31
  39. package/extracted-skill/tracking-events-generator/agents/lead-scoring-agent.md +282 -0
  40. package/extracted-skill/tracking-events-generator/agents/linkedin-agent.md +145 -34
  41. package/extracted-skill/tracking-events-generator/agents/localization-agent.md +1 -1
  42. package/extracted-skill/tracking-events-generator/agents/ltv-predictor-agent.md +5 -5
  43. package/extracted-skill/tracking-events-generator/agents/master-feedback-loop.md +81 -21
  44. package/extracted-skill/tracking-events-generator/agents/master-orchestrator.md +428 -190
  45. package/extracted-skill/tracking-events-generator/agents/match-quality-agent.md +304 -0
  46. package/extracted-skill/tracking-events-generator/agents/memory-agent.json +25 -109
  47. package/extracted-skill/tracking-events-generator/agents/memory-agent.md +190 -15
  48. package/extracted-skill/tracking-events-generator/agents/meta-agent.md +10 -2
  49. package/extracted-skill/tracking-events-generator/agents/ml-clustering-agent.md +749 -0
  50. package/extracted-skill/tracking-events-generator/agents/page-analyzer.md +21 -4
  51. package/extracted-skill/tracking-events-generator/agents/performance-agent.md +41 -31
  52. package/extracted-skill/tracking-events-generator/agents/performance-optimization-agent.md +18 -8
  53. package/extracted-skill/tracking-events-generator/agents/pinterest-agent.md +14 -6
  54. package/extracted-skill/tracking-events-generator/agents/premium-tracking-intelligence-agent.md +7 -7
  55. package/extracted-skill/tracking-events-generator/agents/r2-setup-agent.md +16 -8
  56. package/extracted-skill/tracking-events-generator/agents/reddit-agent.md +15 -7
  57. package/extracted-skill/tracking-events-generator/agents/security-enterprise-agent.md +157 -48
  58. package/extracted-skill/tracking-events-generator/agents/server-tracking.md +35 -35
  59. package/extracted-skill/tracking-events-generator/agents/spotify-agent.md +15 -7
  60. package/extracted-skill/tracking-events-generator/agents/tiktok-agent.md +73 -2
  61. package/extracted-skill/tracking-events-generator/agents/tracking-plan-agent.md +104 -9
  62. package/extracted-skill/tracking-events-generator/agents/utm-agent.md +322 -0
  63. package/extracted-skill/tracking-events-generator/agents/validator-agent.md +13 -9
  64. package/extracted-skill/tracking-events-generator/agents/webhook-agent.md +112 -4
  65. package/extracted-skill/tracking-events-generator/agents/whatsapp-agent.md +58 -5
  66. package/extracted-skill/tracking-events-generator/agents/whatsapp-ctwa-setup-agent.md +26 -18
  67. package/extracted-skill/tracking-events-generator/agents/youtube-agent.md +152 -37
  68. package/extracted-skill/tracking-events-generator/anti-blocking.js +285 -285
  69. package/extracted-skill/tracking-events-generator/cdpTrack.js +642 -641
  70. package/extracted-skill/tracking-events-generator/contracts/api-versions.json +14 -10
  71. package/extracted-skill/tracking-events-generator/engagement-scoring.js +226 -226
  72. package/extracted-skill/tracking-events-generator/evals/evals.json +235 -235
  73. package/extracted-skill/tracking-events-generator/integration-test.js +497 -497
  74. package/extracted-skill/tracking-events-generator/knowledge-base.md +172 -0
  75. package/extracted-skill/tracking-events-generator/micro-events.js +992 -992
  76. package/extracted-skill/tracking-events-generator/models/lancamento-imobiliario.md +344 -0
  77. package/extracted-skill/tracking-events-generator/models/pinterest/conversions-api-template.js +144 -144
  78. package/extracted-skill/tracking-events-generator/models/pinterest/event-mappings.json +48 -48
  79. package/extracted-skill/tracking-events-generator/models/pinterest/tag-template.js +28 -28
  80. package/extracted-skill/tracking-events-generator/models/quiz-funnel.md +83 -19
  81. package/extracted-skill/tracking-events-generator/models/reddit/conversions-api-template.js +205 -205
  82. package/extracted-skill/tracking-events-generator/models/reddit/event-mappings.json +56 -56
  83. package/extracted-skill/tracking-events-generator/models/reddit/pixel-template.js +19 -19
  84. package/extracted-skill/tracking-events-generator/models/scenarios/behavior-engine.js +425 -425
  85. package/extracted-skill/tracking-events-generator/route-intent-capture.js +222 -0
  86. package/extracted-skill/tracking-events-generator/tracking.config.js +3 -3
  87. package/package.json +89 -75
  88. package/scripts/build-sdk.js +106 -0
  89. package/server-edge-tracker/.client.env.example +14 -0
  90. package/server-edge-tracker/INSTALAR.md +222 -23
  91. package/server-edge-tracker/SEGMENTATION-DOCS.md +513 -0
  92. package/server-edge-tracker/config/utm-mapping.json +64 -0
  93. package/server-edge-tracker/deploy-client.cjs +76 -0
  94. package/server-edge-tracker/index.ts +1230 -0
  95. package/server-edge-tracker/migrate-v7.sql +64 -0
  96. package/server-edge-tracker/modules/db.ts +710 -0
  97. package/server-edge-tracker/modules/dispatch/crm.ts +382 -0
  98. package/server-edge-tracker/modules/dispatch/ga4.ts +72 -0
  99. package/server-edge-tracker/modules/dispatch/meta.ts +143 -0
  100. package/server-edge-tracker/modules/dispatch/platforms.ts +255 -0
  101. package/server-edge-tracker/modules/dispatch/tiktok.ts +107 -0
  102. package/server-edge-tracker/modules/dispatch/whatsapp.ts +296 -0
  103. package/server-edge-tracker/modules/intelligence.ts +589 -0
  104. package/server-edge-tracker/modules/ml/bidding.ts +247 -0
  105. package/server-edge-tracker/modules/ml/fraud.ts +302 -0
  106. package/server-edge-tracker/modules/ml/logistic.ts +226 -0
  107. package/server-edge-tracker/modules/ml/ltv.ts +531 -0
  108. package/server-edge-tracker/modules/ml/matchquality.ts +232 -0
  109. package/server-edge-tracker/modules/ml/quiz.ts +343 -0
  110. package/server-edge-tracker/modules/ml/roas.ts +255 -0
  111. package/server-edge-tracker/modules/ml/segmentation.ts +407 -0
  112. package/server-edge-tracker/modules/nurture.ts +257 -0
  113. package/server-edge-tracker/modules/utils.ts +311 -0
  114. package/server-edge-tracker/modules/utm/utm-enricher.ts +231 -0
  115. package/server-edge-tracker/schema-ab-ltv.sql +97 -0
  116. package/server-edge-tracker/schema-bidding.sql +86 -0
  117. package/server-edge-tracker/schema-fraud.sql +90 -0
  118. package/server-edge-tracker/schema-indexes.sql +67 -0
  119. package/server-edge-tracker/schema-ltv-feedback.sql +11 -0
  120. package/server-edge-tracker/schema-quiz.sql +52 -0
  121. package/server-edge-tracker/schema-sales-engine.sql +113 -0
  122. package/server-edge-tracker/schema-segmentation.sql +219 -0
  123. package/server-edge-tracker/schema-utm.sql +82 -0
  124. package/server-edge-tracker/schema.sql +281 -265
  125. package/server-edge-tracker/types.ts +275 -0
  126. package/server-edge-tracker/wrangler.toml +140 -85
  127. package/templates/lancamento-imobiliario.md +344 -0
  128. package/templates/multi-step-checkout.md +3 -4
  129. package/templates/pinterest/conversions-api-template.js +144 -144
  130. package/templates/pinterest/event-mappings.json +48 -48
  131. package/templates/pinterest/tag-template.js +28 -28
  132. package/templates/quiz-funnel.md +83 -19
  133. package/templates/reddit/conversions-api-template.js +205 -205
  134. package/templates/reddit/event-mappings.json +56 -56
  135. package/templates/reddit/pixel-template.js +12 -39
  136. package/templates/scenarios/behavior-engine.js +45 -22
  137. package/docs/PixelBuilder-Documentacao-Completa (2).docx +0 -0
  138. package/docs/installation.md +0 -155
  139. package/docs/quick-start.md +0 -185
  140. package/extracted-skill/tracking-events-generator/agents/crm-integration-agent.md +0 -1419
  141. package/extracted-skill/tracking-events-generator/agents/intelligence-scheduling.md +0 -643
  142. package/server-edge-tracker/worker.js +0 -2574
@@ -0,0 +1,1230 @@
1
+ /**
2
+ * CDP Edge — index.ts (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.ts"
7
+ *
8
+ * O worker.js original permanece intacto como fallback.
9
+ * Todos os módulos ficam em ./modules/
10
+ */
11
+
12
+ import { ExecutionContext } from '@cloudflare/workers-types';
13
+ import { Env, TrackPayload, BehavioralData, HotmartWebhook, KiwifyWebhook, TictoWebhook } from './types';
14
+
15
+ // ── Utilitários base ──────────────────────────────────────────────────────────
16
+ import {
17
+ corsHeaders,
18
+ sha256,
19
+ META_TO_GA4,
20
+ VALID_EVENT_NAMES,
21
+ resolveFunnelStage,
22
+ resolveIntentScore,
23
+ distanceBucketWeight,
24
+ computeMetaSignalWeights,
25
+ metaSignalBucket,
26
+ isValidEmail,
27
+ sanitizeString,
28
+ isValidUrl,
29
+ isValidValue,
30
+ isValidCurrency,
31
+ isValidUTM,
32
+ } from './modules/utils';
33
+
34
+ // ── Banco de dados (D1) ───────────────────────────────────────────────────────
35
+ import {
36
+ saveLead,
37
+ upsertProfile,
38
+ resolveDeviceGraph,
39
+ fireAutomation,
40
+ getProfileByEmail,
41
+ enrichGeoFromEdge,
42
+ writeAuditLog,
43
+ generateEdgeFingerprint,
44
+ saveEdgeFingerprint,
45
+ resurrectUTM,
46
+ upsertLtvProfile,
47
+ recordLtvFeedback,
48
+ processWebhookDuplicateCheck,
49
+ } from './modules/db';
50
+
51
+ // ── Dispatch — plataformas de ads ─────────────────────────────────────────────
52
+ import { sendMetaCapi } from './modules/dispatch/meta';
53
+ import { sendGA4Mp } from './modules/dispatch/ga4';
54
+ import { sendTikTokApi } from './modules/dispatch/tiktok';
55
+ import {
56
+ sendPinterestCapi,
57
+ sendRedditCapi,
58
+ sendLinkedInCapi,
59
+ sendSpotifyCapi,
60
+ } from './modules/dispatch/platforms';
61
+ import {
62
+ sendWhatsApp,
63
+ processWhatsAppWebhook,
64
+ verifyHmac,
65
+ } from './modules/dispatch/whatsapp';
66
+ import {
67
+ pushLeadToCrm,
68
+ } from './modules/dispatch/crm';
69
+
70
+ // ── ML — LTV + A/B Testing ────────────────────────────────────────────────────
71
+ import {
72
+ predictLtv,
73
+ getLtvAbVariation,
74
+ recordAbAssignment,
75
+ handleLtvAbTestCreate,
76
+ handleLtvAbTestList,
77
+ handleLtvAbTestResults,
78
+ handleLtvAbTestWinner,
79
+ } from './modules/ml/ltv';
80
+
81
+ // ── ML — Segmentação ──────────────────────────────────────────────────────────
82
+ import {
83
+ handleSegmentationCluster,
84
+ handleSegmentationList,
85
+ handleSegmentationOutliers,
86
+ handleSegmentationUpdate,
87
+ } from './modules/ml/segmentation';
88
+
89
+ // ── ML — Bidding ──────────────────────────────────────────────────────────────
90
+ import {
91
+ handleBiddingRecommend,
92
+ handleBiddingHistory,
93
+ handleBiddingStatus,
94
+ } from './modules/ml/bidding';
95
+
96
+ // ── ML — Fraud Detection ──────────────────────────────────────────────────────
97
+ import {
98
+ checkFraudGate,
99
+ logFraudSignal,
100
+ handleFraudAlerts,
101
+ handleFraudBlocklist,
102
+ handleFraudBlocklistAdd,
103
+ handleFraudBlocklistRemove,
104
+ handleFraudStats,
105
+ } from './modules/ml/fraud';
106
+
107
+ // ── Quiz Scoring Engine (Fase 6) ──────────────────────────────────────────────
108
+ import {
109
+ scoreQuizAnswers,
110
+ saveQuizSession,
111
+ } from './modules/ml/quiz';
112
+
113
+ // ── Nurture Engine (Fase 7) ───────────────────────────────────────────────────
114
+ import { scheduleNurture } from './modules/nurture';
115
+
116
+ // ── Intelligence Agent (Cron) ─────────────────────────────────────────────────
117
+ import {
118
+ runIntelligenceAgent,
119
+ buildGoogleCustomerMatchExport,
120
+ } from './modules/intelligence';
121
+
122
+ // ── Haversine distance (km) — sem dependência externa ────────────────────────
123
+ function haversineKm(lat1: number | string | null | undefined, lon1: number | string | null | undefined, lat2: number | string | null | undefined, lon2: number | string | null | undefined): number {
124
+ const R = 6371;
125
+ const lat1Num = parseFloat(String(lat1 ?? '0'));
126
+ const lon1Num = parseFloat(String(lon1 ?? '0'));
127
+ const lat2Num = parseFloat(String(lat2 ?? '0'));
128
+ const lon2Num = parseFloat(String(lon2 ?? '0'));
129
+ const dLat = (lat2Num - lat1Num) * Math.PI / 180;
130
+ const dLon = (lon2Num - lon1Num) * Math.PI / 180;
131
+ const a = Math.sin(dLat / 2) ** 2 +
132
+ Math.cos(lat1Num * Math.PI / 180) * Math.cos(lat2Num * Math.PI / 180) * Math.sin(dLon / 2) ** 2;
133
+ return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
134
+ }
135
+
136
+ // ─────────────────────────────────────────────────────────────────────────────
137
+ // HANDLER PRINCIPAL
138
+ // ─────────────────────────────────────────────────────────────────────────────
139
+ export default {
140
+
141
+ async fetch(request: Request, env: Env, ctx: ExecutionContext) {
142
+ const origin = request.headers.get('Origin') || '';
143
+ const headersObj = {
144
+ 'Content-Type': 'application/json',
145
+ ...corsHeaders(origin, env.SITE_DOMAIN || null),
146
+ };
147
+ const headers = new Headers(headersObj);
148
+
149
+ // Preflight CORS
150
+ if (request.method === 'OPTIONS') {
151
+ return new Response(null, { status: 204, headers });
152
+ }
153
+
154
+ const url = new URL(request.url);
155
+
156
+ // ── Rate Limiter — camada 0, antes do Fraud Gate ─────────────────────────
157
+ // Usa apenas CF-Connecting-IP (injetado pela Cloudflare, não pode ser spoofado)
158
+ // X-Forwarded-For pode ser falsificado por atacantes para bypass rate limiter
159
+ if (url.pathname === '/track' && request.method === 'POST' && env.RATE_LIMITER) {
160
+ const ip = request.headers.get('CF-Connecting-IP') || 'unknown';
161
+ const { success } = await env.RATE_LIMITER.limit({ key: ip });
162
+ if (!success) {
163
+ return new Response(JSON.stringify({ status: 'ok', queued: true }), { status: 200, headers });
164
+ }
165
+ }
166
+
167
+ // ── Fraud Gate — Fase 4 (apenas em /track) ────────────────────────────────
168
+ // Roda ANTES de qualquer processamento de evento
169
+ // Silent drop (200) — bots não sabem que foram detectados
170
+ if (url.pathname === '/track' && request.method === 'POST') {
171
+ let trackBodyForFraud: TrackPayload = {};
172
+ try {
173
+ const cloned = request.clone();
174
+ trackBodyForFraud = await cloned.json().catch(() => ({})) as TrackPayload;
175
+ } catch { trackBodyForFraud = {}; }
176
+
177
+ const fraudResult = await checkFraudGate(env, request, trackBodyForFraud);
178
+ if (!fraudResult.allowed) {
179
+ ctx.waitUntil(logFraudSignal(env, request, trackBodyForFraud, fraudResult));
180
+ return new Response(JSON.stringify({ status: 'ok', queued: true }), { status: 200, headers });
181
+ }
182
+ if (fraudResult.action === 'flagged') {
183
+ ctx.waitUntil(logFraudSignal(env, request, trackBodyForFraud, fraudResult));
184
+ }
185
+ }
186
+
187
+ // ── GET /export/customer-match ────────────────────────────────────────────
188
+ if (request.method === 'GET' && url.pathname === '/export/customer-match') {
189
+ const authHeader = request.headers.get('Authorization') || '';
190
+ const token = authHeader.replace('Bearer ', '');
191
+ if (!env.META_ACCESS_TOKEN || token !== env.META_ACCESS_TOKEN) {
192
+ return new Response('Unauthorized', { status: 401 });
193
+ }
194
+
195
+ const rows = await buildGoogleCustomerMatchExport(env);
196
+ return new Response(JSON.stringify({ total: rows.length, data: rows }, null, 2), {
197
+ headers: { ...headers, 'Content-Disposition': 'attachment; filename="customer-match.json"' },
198
+ });
199
+ }
200
+
201
+ // ── GET /health ───────────────────────────────────────────────────────────
202
+ if (request.method === 'GET' && url.pathname === '/health') {
203
+ const results: Record<string, string> = {};
204
+
205
+ try {
206
+ await env.DB?.prepare('SELECT 1').run();
207
+ results.d1 = 'ok';
208
+ } catch (err: any) {
209
+ results.d1 = `FAILED: ${err?.message || String(err)}`;
210
+ }
211
+
212
+ try {
213
+ await env.GEO_CACHE?.get('__health_check__');
214
+ results.kv = 'ok';
215
+ } catch (err: any) {
216
+ results.kv = `FAILED: ${err?.message || String(err)}`;
217
+ }
218
+
219
+ try {
220
+ await env.AI?.run('@cf/ibm-granite/granite-4.0-h-micro', {
221
+ messages: [{ role: 'user', content: 'ping' }],
222
+ max_tokens: 1,
223
+ });
224
+ results.ai = 'ok';
225
+ } catch (err: any) {
226
+ results.ai = `FAILED: ${err?.message || String(err)}`;
227
+ }
228
+
229
+ const vars = {
230
+ META_PIXEL_ID: env.META_PIXEL_ID ? 'set' : 'MISSING',
231
+ GA4_MEASUREMENT_ID: env.GA4_MEASUREMENT_ID ? 'set' : 'MISSING',
232
+ TIKTOK_PIXEL_ID: env.TIKTOK_PIXEL_ID ? 'set' : 'MISSING',
233
+ SITE_DOMAIN: env.SITE_DOMAIN ? 'set' : 'MISSING',
234
+ };
235
+
236
+ const secrets = {
237
+ META_ACCESS_TOKEN: env.META_ACCESS_TOKEN ? 'set' : 'MISSING',
238
+ GA4_API_SECRET: env.GA4_API_SECRET ? 'set' : 'MISSING',
239
+ WA_WEBHOOK_VERIFY_TOKEN: env.WA_WEBHOOK_VERIFY_TOKEN ? 'set' : 'MISSING',
240
+ WHATSAPP_ACCESS_TOKEN: (env.WHATSAPP_ACCESS_TOKEN || env.WA_ACCESS_TOKEN) ? 'set' : 'not set (optional - only for auto-reply)',
241
+ WHATSAPP_PHONE_NUMBER_ID: (env.WHATSAPP_PHONE_NUMBER_ID || env.WA_PHONE_ID) ? 'set' : 'not set (optional - only for auto-reply)',
242
+ WA_NOTIFY_NUMBER: env.WA_NOTIFY_NUMBER ? 'set' : 'not set (optional - only for auto-reply)',
243
+ TIKTOK_ACCESS_TOKEN: env.TIKTOK_ACCESS_TOKEN ? 'set' : 'not set (optional)',
244
+ CALLMEBOT_PHONE: env.CALLMEBOT_PHONE ? 'set' : 'not set (optional)',
245
+ EVO_CRM_BASE_URL: env.EVO_CRM_BASE_URL ? 'set' : 'not set (optional - EVO CRM)',
246
+ EVO_CRM_CLIENT_ID: env.EVO_CRM_CLIENT_ID ? 'set' : 'not set (optional - EVO CRM)',
247
+ EVO_CRM_INBOX_ID: env.EVO_CRM_INBOX_ID ? 'set' : 'not set (optional - EVO CRM)',
248
+ };
249
+
250
+ const hasMissing =
251
+ Object.values(vars).includes('MISSING') ||
252
+ Object.values(secrets).includes('MISSING') ||
253
+ results.d1 !== 'ok';
254
+
255
+ return new Response(JSON.stringify({
256
+ status: hasMissing ? 'degraded' : 'ok',
257
+ timestamp: new Date().toISOString(),
258
+ bindings: results,
259
+ vars,
260
+ secrets,
261
+ }, null, 2), { headers });
262
+ }
263
+
264
+ // ── GET /validate-install ─────────────────────────────────────────────────
265
+ // Endpoint de diagnóstico pós-deploy. Chamado por `cdp-edge validate <url>`.
266
+ // Testa D1 write/read, KV, AI e retorna relatório estruturado.
267
+ // Protegido: só aceita requisições com header CDP-Validate: 1
268
+ if (request.method === 'GET' && url.pathname === '/validate-install') {
269
+ if (request.headers.get('CDP-Validate') !== '1') {
270
+ return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers });
271
+ }
272
+
273
+ const report: Record<string, { ok: boolean; detail: string }> = {};
274
+
275
+ // 1. D1 write + read
276
+ try {
277
+ const testId = `__cdp_validate_${Date.now()}__`;
278
+ await env.DB?.prepare(
279
+ `INSERT OR REPLACE INTO events (event_id, event_name, user_id, created_at)
280
+ VALUES (?, '__validate__', '__validate__', datetime('now'))`
281
+ ).bind(testId).run();
282
+ const row = await env.DB?.prepare(
283
+ `SELECT event_id FROM events WHERE event_id = ?`
284
+ ).bind(testId).first();
285
+ await env.DB?.prepare(`DELETE FROM events WHERE event_id = ?`).bind(testId).run();
286
+ report.d1 = { ok: !!row, detail: row ? 'write+read+delete OK' : 'row not found after insert' };
287
+ } catch (err: any) {
288
+ report.d1 = { ok: false, detail: err?.message || String(err) };
289
+ }
290
+
291
+ // 2. KV read/write
292
+ try {
293
+ await env.GEO_CACHE?.put('__cdp_validate__', '1', { expirationTtl: 60 });
294
+ const val = await env.GEO_CACHE?.get('__cdp_validate__');
295
+ report.kv = { ok: val === '1', detail: val === '1' ? 'write+read OK' : 'value mismatch' };
296
+ } catch (err: any) {
297
+ report.kv = { ok: false, detail: err?.message || String(err) };
298
+ }
299
+
300
+ // 3. Workers AI
301
+ try {
302
+ await env.AI?.run('@cf/ibm-granite/granite-4.0-h-micro', {
303
+ messages: [{ role: 'user', content: 'ping' }],
304
+ max_tokens: 1,
305
+ });
306
+ report.ai = { ok: true, detail: 'Granite 4.0 Micro respondeu' };
307
+ } catch (err: any) {
308
+ report.ai = { ok: false, detail: err?.message || String(err) };
309
+ }
310
+
311
+ // 4. Secrets críticos
312
+ const missing: string[] = [];
313
+ if (!env.META_PIXEL_ID) missing.push('META_PIXEL_ID');
314
+ if (!env.META_ACCESS_TOKEN) missing.push('META_ACCESS_TOKEN');
315
+ if (!env.SITE_DOMAIN) missing.push('SITE_DOMAIN');
316
+ report.secrets = {
317
+ ok: missing.length === 0,
318
+ detail: missing.length === 0 ? 'todos os secrets críticos configurados' : `MISSING: ${missing.join(', ')}`,
319
+ };
320
+
321
+ // 5. /track endpoint (auto-teste)
322
+ const trackTest = { ok: false, detail: '' };
323
+ try {
324
+ const testPayload = {
325
+ eventName: 'PageView',
326
+ userId: '__cdp_validate__',
327
+ pageUrl: `https://${env.SITE_DOMAIN || 'validate.test'}/`,
328
+ userAgent: request.headers.get('User-Agent') || '',
329
+ ip: request.headers.get('CF-Connecting-IP') || '',
330
+ _validate: true,
331
+ };
332
+ const internalReq = new Request(`https://${env.SITE_DOMAIN || 'localhost'}/track`, {
333
+ method: 'POST',
334
+ headers: { 'Content-Type': 'application/json', 'CDP-Validate': '1' },
335
+ body: JSON.stringify(testPayload),
336
+ });
337
+ // Não chama fetch externo — apenas verifica que o payload seria aceito
338
+ const hasRequired = testPayload.eventName && testPayload.userId;
339
+ trackTest.ok = !!hasRequired;
340
+ trackTest.detail = hasRequired ? 'payload de teste válido (eventName + userId presentes)' : 'payload inválido';
341
+ } catch (err: any) {
342
+ trackTest.detail = err?.message || String(err);
343
+ }
344
+ report.track_endpoint = trackTest;
345
+
346
+ const allOk = Object.values(report).every(r => r.ok);
347
+ return new Response(JSON.stringify({
348
+ status: allOk ? 'ok' : 'degraded',
349
+ timestamp: new Date().toISOString(),
350
+ checks: report,
351
+ }, null, 2), {
352
+ status: allOk ? 200 : 207,
353
+ headers,
354
+ });
355
+ }
356
+
357
+ // ── POST /track ───────────────────────────────────────────────────────────
358
+ if (request.method === 'POST' && url.pathname === '/track') {
359
+ // Reject oversized payloads before reading body (64 KB limit)
360
+ const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
361
+ if (contentLength > 65536) {
362
+ return new Response(JSON.stringify({ error: 'Payload muito grande' }), { status: 413, headers });
363
+ }
364
+
365
+ let body;
366
+ try {
367
+ body = await request.json();
368
+ } catch {
369
+ return new Response(JSON.stringify({ error: 'JSON inválido' }), { status: 400, headers });
370
+ }
371
+
372
+ if (typeof body !== 'object' || Array.isArray(body) || body === null) {
373
+ return new Response(JSON.stringify({ error: 'Payload inválido' }), { status: 400, headers });
374
+ }
375
+
376
+ const STR_FIELDS = ['email','phone','firstName','lastName','city','state','zip','userId',
377
+ 'utmSource','utmMedium','utmCampaign','utmContent','utmTerm',
378
+ 'fbclid','ttclid','gclid','transactionId','productName','currency'];
379
+
380
+ const { eventName: _bodyEventName, behavioral_data, ...payload } = body as { eventName?: string; behavioral_data?: BehavioralData; [key: string]: any };
381
+ const trackPayload: TrackPayload = payload;
382
+
383
+ // Aceita eventName (camelCase) ou event_name (snake_case — formato cdpTrack.js SDK)
384
+ const eventName = _bodyEventName || (payload.event_name as string | undefined);
385
+
386
+ // ── Extrair click_ids e utms de sub-objetos (formato cdpTrack.js) ────────
387
+ // cdpTrack.js envia fbp/fbc/fbclid dentro de click_ids{} e UTMs dentro de utms{}
388
+ // O Worker trabalha com campos no nível raiz — esta extração resolve o mismatch.
389
+ if (payload.click_ids && typeof payload.click_ids === 'object') {
390
+ const c = payload.click_ids as Record<string, string>;
391
+ if (!trackPayload.fbp && c.fbp) trackPayload.fbp = c.fbp;
392
+ if (!trackPayload.fbc && c.fbc) trackPayload.fbc = c.fbc;
393
+ if (!trackPayload.fbclid && c.fbclid) trackPayload.fbclid = c.fbclid;
394
+ if (!trackPayload.gclid && c.gclid) trackPayload.gclid = c.gclid;
395
+ if (!trackPayload.wbraid && c.wbraid) trackPayload.wbraid = c.wbraid;
396
+ if (!trackPayload.gbraid && c.gbraid) trackPayload.gbraid = c.gbraid;
397
+ if (!trackPayload.ttclid && c.ttclid) trackPayload.ttclid = c.ttclid;
398
+ if (!trackPayload.ttp && c.ttp) trackPayload.ttp = c.ttp;
399
+ if (!trackPayload.msclkid && c.msclkid) trackPayload.msclkid = c.msclkid;
400
+ }
401
+ if (payload.utms && typeof payload.utms === 'object') {
402
+ const u = payload.utms as Record<string, string>;
403
+ if (!trackPayload.utmSource && u.utm_source) trackPayload.utmSource = u.utm_source;
404
+ if (!trackPayload.utmMedium && u.utm_medium) trackPayload.utmMedium = u.utm_medium;
405
+ if (!trackPayload.utmCampaign && u.utm_campaign) trackPayload.utmCampaign = u.utm_campaign;
406
+ if (!trackPayload.utmContent && u.utm_content) trackPayload.utmContent = u.utm_content;
407
+ if (!trackPayload.utmTerm && u.utm_term) trackPayload.utmTerm = u.utm_term;
408
+ }
409
+
410
+ // ── Normalizar campos snake_case → camelCase (formato cdpTrack.js SDK) ──
411
+ if (!trackPayload.userId && payload.user_id) trackPayload.userId = payload.user_id;
412
+ if (!trackPayload.eventId && payload.event_id) trackPayload.eventId = payload.event_id;
413
+ if (!trackPayload.pageUrl && payload.page_url) trackPayload.pageUrl = payload.page_url;
414
+ if (!trackPayload.sessionId && payload.session_id) trackPayload.sessionId = payload.session_id;
415
+
416
+ // ── Validação de eventName ────────────────────────────────────────
417
+ if (!eventName) {
418
+ return new Response(JSON.stringify({ error: 'eventName é obrigatório' }), { status: 400, headers });
419
+ }
420
+
421
+ if (typeof eventName !== 'string' || eventName.length > 64 || !VALID_EVENT_NAMES.has(eventName)) {
422
+ return new Response(JSON.stringify({ error: `eventName desconhecido: ${eventName.slice(0, 64)}` }), { status: 400, headers });
423
+ }
424
+
425
+ // ── Sanitização e Validação de Campos String ──────────────────────
426
+ type SanitizeResult = { error?: string; sanitized: string | null };
427
+
428
+ const SANITIZE_FIELDS: Record<string, (val: string) => SanitizeResult> = {
429
+ email: (val: string) => {
430
+ if (!isValidEmail(val)) return { error: 'email inválido (formato incorreto)', sanitized: null };
431
+ return { sanitized: val.toLowerCase().trim() };
432
+ },
433
+ firstName: (val: string) => ({ sanitized: sanitizeString(val, 100) }),
434
+ lastName: (val: string) => ({ sanitized: sanitizeString(val, 100) }),
435
+ city: (val: string) => ({ sanitized: sanitizeString(val, 100) }),
436
+ state: (val: string) => ({ sanitized: sanitizeString(val, 100) }),
437
+ zip: (val: string) => ({ sanitized: sanitizeString(val, 20) }),
438
+ dob: (val: string) => ({ sanitized: sanitizeString(val, 20) }),
439
+ productName: (val: string) => ({ sanitized: sanitizeString(val, 200) }),
440
+ pageUrl: (val: string) => {
441
+ if (!isValidUrl(val)) return { error: 'pageUrl inválido (formato incorreto)', sanitized: null };
442
+ return { sanitized: val.trim() };
443
+ },
444
+ currency: (val: string) => {
445
+ if (!isValidCurrency(val)) return { error: 'currency inválido (deve ser código ISO 4217)', sanitized: null };
446
+ return { sanitized: val.trim().toUpperCase() };
447
+ },
448
+ };
449
+
450
+ // Sanitiza e valida campos específicos
451
+ for (const [field, validator] of Object.entries(SANITIZE_FIELDS)) {
452
+ const value = trackPayload[field as keyof TrackPayload];
453
+ if (value !== undefined && value !== null) {
454
+ if (typeof value !== 'string') {
455
+ return new Response(JSON.stringify({ error: `Campo ${field} deve ser string` }), { status: 400, headers });
456
+ }
457
+ const result = validator(value);
458
+ if (result.error) {
459
+ return new Response(JSON.stringify({ error: result.error }), { status: 400, headers });
460
+ }
461
+ if (result.sanitized !== null) {
462
+ trackPayload[field as keyof TrackPayload] = result.sanitized as any;
463
+ }
464
+ }
465
+ }
466
+
467
+ // Sanitiza campos de string genéricos
468
+ const GENERIC_SANITIZE_FIELDS = ['utmSource', 'utmMedium', 'utmCampaign', 'utmContent', 'utmTerm'];
469
+ for (const field of GENERIC_SANITIZE_FIELDS) {
470
+ const value = trackPayload[field as keyof TrackPayload];
471
+ if (value !== undefined && value !== null) {
472
+ if (typeof value !== 'string') {
473
+ return new Response(JSON.stringify({ error: `Campo ${field} deve ser string` }), { status: 400, headers });
474
+ }
475
+ const utmType = `utm_${field.replace('utm', '').toLowerCase()}`;
476
+ if (!isValidUTM(value, utmType)) {
477
+ return new Response(JSON.stringify({ error: `Campo ${field} contém caracteres perigosos` }), { status: 400, headers });
478
+ }
479
+ const sanitized = sanitizeString(value, 200);
480
+ if (sanitized === null) {
481
+ return new Response(JSON.stringify({ error: `Campo ${field} inválido após sanitização` }), { status: 400, headers });
482
+ }
483
+ trackPayload[field as keyof TrackPayload] = sanitized as any;
484
+ }
485
+ }
486
+
487
+ // Sanitiza campos de string restantes
488
+ const TRACKING_ID_FIELDS = ['transactionId', 'fbclid', 'ttclid', 'gclid'];
489
+
490
+ for (const field of TRACKING_ID_FIELDS) {
491
+ const value = trackPayload[field as keyof TrackPayload];
492
+ if (value !== undefined && value !== null) {
493
+ if (typeof value !== 'string') {
494
+ return new Response(JSON.stringify({ error: `Campo ${field} deve ser string` }), { status: 400, headers });
495
+ }
496
+ const sanitized = sanitizeString(value, 512);
497
+ if (sanitized === null) {
498
+ return new Response(JSON.stringify({ error: `Campo ${field} inválido após sanitização` }), { status: 400, headers });
499
+ }
500
+ trackPayload[field as keyof TrackPayload] = sanitized as any;
501
+ }
502
+ }
503
+
504
+ // ── fbc derivado de fbclid ───────────────────────────────────────────
505
+ // Quando o usuário vem de um clique Meta, fbclid chega na URL e o cdpTrack.js
506
+ // o envia como campo. O CAPI espera fbc no formato fb.1.{ms}.{fbclid}.
507
+ // Sem essa conversão, o sinal de clique some — EMQ cai e deduplicação piora.
508
+ if (trackPayload.fbclid && !trackPayload.fbc) {
509
+ trackPayload.fbc = `fb.1.${Date.now()}.${trackPayload.fbclid}`;
510
+ }
511
+
512
+ // ── Validação de Valor Numérico ───────────────────────────────────
513
+ if (trackPayload.value !== undefined && trackPayload.value !== null) {
514
+ if (!isValidValue(trackPayload.value)) {
515
+ return new Response(JSON.stringify({ error: 'value fora do intervalo permitido (0-9,999,999)' }), { status: 400, headers });
516
+ }
517
+ trackPayload.value = Number(trackPayload.value);
518
+ }
519
+
520
+ // ── Extrair dados comportamentais do browser ──────────────────────────
521
+ if (behavioral_data) {
522
+ payload.engagementScore = behavioral_data.engagement_score ?? behavioral_data.totalScore ?? null;
523
+ payload.intentionLevel = behavioral_data.intention_level ?? null;
524
+ payload.userScore = behavioral_data.user_score ?? null;
525
+ // Sinais de engajamento profundo — usados no anti-falso-positivo e no LTV
526
+ payload.scrollScore = behavioral_data.scroll_score ?? null;
527
+ payload.timeLevel = behavioral_data.time_level ?? null;
528
+
529
+ // ── Sanitiza dados do behavioral_data ────────────────────────
530
+ // Os dados do behavioral_data podem vir do browser e ser manipulados
531
+ const sanitizedBehavioralEmail = behavioral_data.email && isValidEmail(behavioral_data.email)
532
+ ? behavioral_data.email.toLowerCase().trim()
533
+ : null;
534
+ const sanitizedBehavioralPhone = behavioral_data.phone
535
+ ? sanitizeString(behavioral_data.phone, 50)
536
+ : null;
537
+ const sanitizedBehavioralFirstName = behavioral_data.first_name || behavioral_data.firstName
538
+ ? sanitizeString(behavioral_data.first_name || behavioral_data.firstName, 100)
539
+ : null;
540
+ const sanitizedBehavioralLastName = behavioral_data.last_name || behavioral_data.lastName
541
+ ? sanitizeString(behavioral_data.last_name || behavioral_data.lastName, 100)
542
+ : null;
543
+ const sanitizedBehavioralCity = behavioral_data.city
544
+ ? sanitizeString(behavioral_data.city, 100)
545
+ : null;
546
+
547
+ // Usa dados sanitizados do behavioral_data se não existirem no payload principal
548
+ payload.email = payload.email || sanitizedBehavioralEmail;
549
+ payload.phone = payload.phone || sanitizedBehavioralPhone;
550
+ payload.firstName = payload.firstName || sanitizedBehavioralFirstName;
551
+ payload.lastName = payload.lastName || sanitizedBehavioralLastName;
552
+ payload.city = payload.city || sanitizedBehavioralCity;
553
+
554
+ // Sanitiza campos restantes do behavioral_data
555
+ const sanitizedBehavioralState = behavioral_data.state
556
+ ? sanitizeString(behavioral_data.state, 100)
557
+ : null;
558
+ const sanitizedBehavioralZip = behavioral_data.zip
559
+ ? sanitizeString(behavioral_data.zip, 20)
560
+ : null;
561
+ const sanitizedBehavioralDob = behavioral_data.dob
562
+ ? sanitizeString(behavioral_data.dob, 20)
563
+ : null;
564
+
565
+ payload.state = payload.state || sanitizedBehavioralState;
566
+ payload.zip = payload.zip || sanitizedBehavioralZip;
567
+ payload.dob = payload.dob || sanitizedBehavioralDob;
568
+ }
569
+
570
+ // ── Normalização de intent_score → 0.0–1.0 ──────────────────────────
571
+ // Aceita string ('high'/'medium'/'low'), 0-1 ou 0-100 enviados pelo front.
572
+ // intent_bucket mantém a label legível para D1 e logs.
573
+ const intentScoreNum = resolveIntentScore(payload.intent_score);
574
+ if (intentScoreNum !== null) {
575
+ payload.intent_score = intentScoreNum;
576
+ payload.intentScoreNum = intentScoreNum;
577
+ payload.intent_bucket = intentScoreNum >= 0.8 ? 'high'
578
+ : intentScoreNum >= 0.5 ? 'medium' : 'low';
579
+ } else {
580
+ payload.intentScoreNum = null;
581
+ }
582
+
583
+ // ── Anti-falso-positivo ───────────────────────────────────────────────
584
+ // Penaliza intent se engajamento insuficiente: scroll raso E tempo curto.
585
+ // scroll_score < 2.0 ≈ não passou de 50% da página.
586
+ // time_level 'curioso' = menos de 60 segundos na página.
587
+ if (payload.intentScoreNum !== null) {
588
+ const isShallowScroll = payload.scrollScore !== null && payload.scrollScore < 2.0;
589
+ const isShallowTime = payload.timeLevel === 'curioso';
590
+ if (isShallowScroll && isShallowTime) {
591
+ const penalized = Math.round(payload.intentScoreNum * 0.7 * 100) / 100;
592
+ payload.intentScoreNum = penalized;
593
+ payload.intent_score = penalized;
594
+ payload.intent_bucket = penalized >= 0.8 ? 'high' : penalized >= 0.5 ? 'medium' : 'low';
595
+ payload.intent_penalized = true; // flag auditável — visível no D1 e logs
596
+ }
597
+ }
598
+
599
+ // ── Edge Fingerprint + UTM Resurrection ──────────────────────────────
600
+ const fingerprint = await generateEdgeFingerprint(request);
601
+ payload.utmRestored = false;
602
+
603
+ if (fingerprint && env.DB) {
604
+ if (payload.utmSource) {
605
+ ctx.waitUntil(saveEdgeFingerprint(env.DB, fingerprint, payload.userId, payload));
606
+ } else {
607
+ const recovered = await resurrectUTM(env.DB, fingerprint);
608
+ if (recovered) {
609
+ payload.utmSource = payload.utmSource || recovered.utm_source;
610
+ payload.utmMedium = payload.utmMedium || recovered.utm_medium;
611
+ payload.utmCampaign = payload.utmCampaign || recovered.utm_campaign;
612
+ payload.utmContent = payload.utmContent || recovered.utm_content;
613
+ payload.utmTerm = payload.utmTerm || recovered.utm_term;
614
+ payload.utmRestored = true;
615
+ }
616
+ }
617
+ }
618
+
619
+ // ── Bot Mitigation ────────────────────────────────────────────────────
620
+ const botScoreStr = (request as any).cf?.botManagement?.score;
621
+ const cfBotScore = botScoreStr !== undefined ? parseInt(String(botScoreStr)) : 100;
622
+ const ua = (request.headers.get('User-Agent') || '').toLowerCase();
623
+ const isBotPattern = /bot|crawler|spider|lighthouse|postman|curl|inspect|headless/i.test(ua);
624
+
625
+ const isBot = cfBotScore < 30 || isBotPattern;
626
+ trackPayload.botScore = isBot ? 2 : (cfBotScore < 60 ? 1 : 0);
627
+
628
+ if (isBot && !['Contact', 'Lead', 'Purchase'].includes(eventName)) {
629
+ return new Response(JSON.stringify({ ok: true, skipped_due_to_bot: true }), { status: 200, headers });
630
+ }
631
+
632
+ // ── Edge Geo Enrichment ───────────────────────────────────────────────
633
+ const geoData = await enrichGeoFromEdge(request, env, payload);
634
+
635
+ // ── First-Party Cookie (Identity Resolution) ──────────────────────────
636
+ const cookieHeader = request.headers.get('Cookie') || '';
637
+ const cdpUidMatch = cookieHeader.match(/_cdp_uid=([^;]+)/);
638
+ const finalUserId = payload.userId || (cdpUidMatch ? cdpUidMatch[1] : crypto.randomUUID());
639
+ payload.userId = finalUserId;
640
+
641
+ const ga4Name = META_TO_GA4[eventName] || eventName.toLowerCase();
642
+
643
+ // ── Dual-layer semantics ─────────────────────────────────────────────
644
+ // Meta sempre recebe o nome canônico (Schedule, Lead, etc.).
645
+ // Internamente o CDP usa semântica de funil precisa via FUNNEL_TAXONOMY.
646
+ if (payload.funnel_stage) {
647
+ const { depth, funnelDepth } = resolveFunnelStage(payload.funnel_stage);
648
+ payload.funnelDepth = funnelDepth; // ex: 'bottom_intent', 'bottom_conversion'
649
+ payload.funnelLevel = depth; // ex: 'bottom', 'conversion', 'mid'
650
+ }
651
+ if (eventName === 'Schedule' && payload.funnel_stage === 'route_click') {
652
+ payload.internalEvent = 'IntentToVisit';
653
+ }
654
+
655
+ // ── Real Estate Distance Enrichment ──────────────────────────────────
656
+ // Calcula distância entre o IP do usuário (via Cloudflare) e o imóvel.
657
+ // Ativa quando o evento carrega property_lat + property_lng (ex: Schedule de rota).
658
+ const propLat = parseFloat(String(trackPayload.property_lat ?? trackPayload.propertyLat));
659
+ const propLng = parseFloat(String(trackPayload.property_lng ?? trackPayload.propertyLng));
660
+ const userLat = parseFloat(String(request.cf?.latitude ?? '0'));
661
+ const userLng = parseFloat(String(request.cf?.longitude ?? '0'));
662
+ if (!isNaN(propLat) && !isNaN(propLng) && !isNaN(userLat) && !isNaN(userLng)) {
663
+ const distKm = haversineKm(userLat, userLng, propLat, propLng);
664
+ trackPayload.distanceKm = Math.round(distKm * 10) / 10;
665
+ trackPayload.distanceBucket = distKm < 5 ? 'very_close' :
666
+ distKm < 15 ? 'close' :
667
+ distKm < 30 ? 'nearby' :
668
+ distKm < 60 ? 'moderate' : 'far';
669
+ }
670
+
671
+ // ── Quiz Scoring Engine (Fase 6) ─────────────────────────────────────
672
+ // Roda antes do LTV para que intentionLevel qualificado alimente a predição.
673
+ // O front envia quiz_answers: [{question, answer, step}] junto com QuizComplete.
674
+ if (eventName === 'QuizComplete' && Array.isArray(payload.quiz_answers) && payload.quiz_answers.length > 0) {
675
+ try {
676
+ const quizResult = await scoreQuizAnswers(env, payload.quiz_answers, payload.quiz_name ?? null);
677
+
678
+ // Injeta qualificação no payload — flui para LTV, Meta Signal, D1 e CAPI
679
+ payload.intentionLevel = quizResult.qualification;
680
+ payload.intent_score = quizResult.intent_score;
681
+ payload.intentScoreNum = quizResult.intent_score;
682
+ payload.intent_bucket = quizResult.intent_score >= 0.8 ? 'high'
683
+ : quizResult.intent_score >= 0.5 ? 'medium' : 'low';
684
+
685
+ // Campos extras para auditoria e dashboard
686
+ (payload as any).quiz_qualification = quizResult.qualification;
687
+ (payload as any).quiz_confidence = quizResult.confidence;
688
+ (payload as any).quiz_weighted_score = quizResult.weighted_score;
689
+ (payload as any).quiz_dominant_dimension = quizResult.dominant_dimension;
690
+ (payload as any).quiz_signals = quizResult.dimensions.map((d: any) => d.signal).filter(Boolean);
691
+ (payload as any).quiz_source = quizResult.source;
692
+
693
+ // utm_term — injetado pelo Worker após qualificação (nunca configurado nos anúncios)
694
+ // Flui para Meta CAPI custom_data e D1 user_profiles para cruzar com ROAS Feedback
695
+ payload.utmTerm = quizResult.qualification; // comprador | interessado | curioso | perdido
696
+
697
+ // Persiste sessão no D1 em background
698
+ if (env.DB) ctx.waitUntil(saveQuizSession(env, payload.userId, payload, quizResult));
699
+
700
+ // Agenda nurture sequence baseada na qualificação (background)
701
+ ctx.waitUntil(scheduleNurture(env, payload, quizResult.qualification));
702
+
703
+ } catch (err: any) {
704
+ console.error('[QuizScoring] erro ao qualificar respostas:', err?.message || String(err));
705
+ // Fail-safe: continua sem qualificação
706
+ }
707
+ }
708
+
709
+ // ── LTV Prediction (+ A/B Testing de Prompts) ─────────────────────────
710
+ const LTV_EVENTS = ['Lead', 'Contact', 'Schedule', 'CompleteRegistration', 'QuizComplete'];
711
+ if (LTV_EVENTS.includes(eventName) && !payload.value) {
712
+ const abVariation = await getLtvAbVariation(env);
713
+ const ltv = await predictLtv(env, payload, request, abVariation?.system_prompt || null);
714
+ payload.value = ltv.value;
715
+ payload.currency = payload.currency || 'BRL';
716
+ payload.ltvClass = ltv.class;
717
+ payload.ltvScore = ltv.score;
718
+ ctx.waitUntil(upsertLtvProfile(env, payload.userId, ltv));
719
+ if (abVariation) {
720
+ const emailHash = payload.email
721
+ ? await sha256(payload.email.trim().toLowerCase())
722
+ : null;
723
+ ctx.waitUntil(
724
+ recordAbAssignment(
725
+ env,
726
+ payload.userId,
727
+ abVariation.id,
728
+ abVariation.test_id,
729
+ ltv.value,
730
+ ltv.class,
731
+ emailHash ?? null,
732
+ )
733
+ );
734
+ }
735
+ }
736
+
737
+ // ── LTV Feedback Loop — fecha o ciclo preditivo ──────────────────────
738
+ // Quando uma compra real acontece, registra o valor real e recalcula accuracy.
739
+ // Alimenta ltv_ab_variations.accuracy_score → autoDecideAbWinner usa isso.
740
+ if (eventName === 'Purchase' && payload.value > 0) {
741
+ ctx.waitUntil(recordLtvFeedback(env, payload.userId, payload.value));
742
+ }
743
+
744
+ // ── Meta Signal Score (composite, pesos dinâmicos) ───────────────────
745
+ // Pesos variam por profundidade de funil: fundo = comportamento pesa mais.
746
+ {
747
+ const w = computeMetaSignalWeights(payload.funnelLevel);
748
+ const iW = payload.intentScoreNum ?? 0.5;
749
+ const lW = payload.ltvScore ? payload.ltvScore / 100 : 0.5;
750
+ const dW = distanceBucketWeight(payload.distanceBucket);
751
+ payload.metaSignal = Math.round(((iW * w.intent) + (lW * w.ltv) + (dW * w.dist)) * 100) / 100;
752
+ payload.metaSignalBucket = metaSignalBucket(payload.metaSignal); // 'hot'/'warm'/'cold'
753
+ }
754
+
755
+ // ── Hot Lead Trigger (timing + sinal) ────────────────────────────────
756
+ // Reage em tempo real: WhatsApp apenas quando comportamento + sinal justificam.
757
+ // Critérios: rota + muito próximo + (intent alto OU meta_signal alto)
758
+ // + (janela de decisão BRT 18–22h OU sinal excepcional ≥ 0.9)
759
+ const hourBRT = (new Date().getUTCHours() - 3 + 24) % 24;
760
+ const inWindow = hourBRT >= 18 && hourBRT <= 22;
761
+ const isHotLead = payload.funnel_stage === 'route_click'
762
+ && payload.distanceBucket === 'very_close'
763
+ && ((payload.intentScoreNum ?? 0) >= 0.8 || payload.metaSignal >= 0.85)
764
+ && (inWindow || payload.metaSignal >= 0.9);
765
+
766
+ // Cross-Device Graph — background
767
+ if (env.DB && payload.userId && (payload.email || payload.phone)) {
768
+ ctx.waitUntil(resolveDeviceGraph(env.DB, payload.userId, payload.email, payload.phone));
769
+ }
770
+
771
+ // Deduplicação server-side — INSERT OR IGNORE retorna changes=0 para duplicatas
772
+ if (env.DB && payload.eventId) {
773
+ try {
774
+ const dedup = await env.DB.prepare(
775
+ `INSERT OR IGNORE INTO events (event_id, event_name, user_id, created_at)
776
+ VALUES (?, ?, ?, datetime('now'))`
777
+ ).bind(payload.eventId, eventName, payload.userId || null).run();
778
+ if (dedup.meta.changes === 0) {
779
+ return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
780
+ }
781
+ } catch {
782
+ // Tabela ausente ou erro de DB — não bloqueia o pipeline
783
+ }
784
+ }
785
+
786
+ // R2 Audit Log — background
787
+ ctx.waitUntil(writeAuditLog(env, eventName, payload, geoData));
788
+
789
+ // Disparar tudo em paralelo
790
+ const WHATSAPP_NOTIFY_EVENTS = ['Lead', 'Purchase', 'CompleteRegistration'];
791
+ const [metaRes, ga4Res, ttRes] = await Promise.allSettled([
792
+ sendMetaCapi(env, eventName, payload, request, ctx),
793
+ sendGA4Mp(env, ga4Name, payload, ctx),
794
+ sendTikTokApi(env, eventName, payload, request, ctx),
795
+ saveLead(env, payload.internalEvent || eventName, payload, request, 'website'),
796
+ upsertProfile(env, eventName, payload, request),
797
+ ...(WHATSAPP_NOTIFY_EVENTS.includes(eventName) || isHotLead
798
+ ? [sendWhatsApp(env, isHotLead ? 'hot_lead_intent_to_visit' : eventName, payload)]
799
+ : []),
800
+ ]);
801
+
802
+ // ── EVO CRM — cria contato + conversa + nota interna ─────────────────
803
+ // Silencioso se EVO_CRM_BASE_URL / EVO_CRM_CLIENT_ID não estiverem configurados.
804
+ const CRM_EVENTS = ['Lead', 'Contact', 'CompleteRegistration'];
805
+ if (CRM_EVENTS.includes(eventName) && trackPayload.phone) {
806
+ ctx.waitUntil(
807
+ pushLeadToCrm(env, {
808
+ phone: trackPayload.phone,
809
+ name: [trackPayload.firstName, trackPayload.lastName].filter(Boolean).join(' ') || null,
810
+ email: trackPayload.email || null,
811
+ fbclid: trackPayload.fbclid || null,
812
+ fbc: trackPayload.fbc || null,
813
+ fbp: trackPayload.fbp || null,
814
+ utmSource: trackPayload.utmSource || null,
815
+ utmMedium: trackPayload.utmMedium || null,
816
+ utmCampaign: trackPayload.utmCampaign || null,
817
+ utmContent: trackPayload.utmContent || null,
818
+ utmTerm: trackPayload.utmTerm || null,
819
+ pageUrl: trackPayload.pageUrl || null,
820
+ formName: trackPayload.contentName || trackPayload.productName || eventName,
821
+ eventName,
822
+ intentScore: typeof trackPayload.intent_score === 'number'
823
+ ? trackPayload.intent_score
824
+ : typeof trackPayload.intentScoreNum === 'number'
825
+ ? trackPayload.intentScoreNum
826
+ : null,
827
+ ltvClass: trackPayload.ltvClass || null,
828
+ funnelStage: trackPayload.funnel_stage || trackPayload.funnelDepth || null,
829
+ value: trackPayload.value ?? null,
830
+ currency: trackPayload.currency || null,
831
+ })
832
+ );
833
+ }
834
+
835
+ // Automação de mensagens
836
+ const AUTOMATION_EVENTS = ['Lead', 'Purchase', 'InitiateCheckout'];
837
+ if (AUTOMATION_EVENTS.includes(eventName) && env.DB) {
838
+ const db = env.DB; // Captura em variável local
839
+ ctx.waitUntil(
840
+ (async () => {
841
+ try {
842
+ const lastLead = await db
843
+ .prepare(`SELECT id FROM leads WHERE event_id = ?1 LIMIT 1`)
844
+ .bind(trackPayload.eventId || trackPayload.event_id || '')
845
+ .first() as any;
846
+ const leadId = lastLead?.id ? Number(lastLead.id) : null;
847
+ if (leadId) await fireAutomation(env, eventName, leadId, trackPayload);
848
+ } catch (e: any) { console.error('[Automation] lead lookup error:', e?.message || String(e)); }
849
+ })()
850
+ );
851
+ }
852
+
853
+ // Edge Personalization
854
+ let currentScore = 0;
855
+ if (env.DB && trackPayload.userId) {
856
+ try {
857
+ const profileRow = await env.DB.prepare('SELECT score FROM user_profiles WHERE user_id = ?').bind(trackPayload.userId).first();
858
+ if (profileRow) currentScore = Number(profileRow.score) || 0;
859
+ } catch (err: any) {
860
+ console.error('[POST /track] Error fetching user profile score:', {
861
+ userId: trackPayload.userId,
862
+ error: err?.message || String(err),
863
+ stack: err?.stack,
864
+ });
865
+ }
866
+ }
867
+
868
+ const resHeaders = new Headers(headers);
869
+ resHeaders.set('Set-Cookie', `_cdp_uid=${finalUserId}; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000; Path=/; Domain=.${env.SITE_DOMAIN}`);
870
+
871
+ return new Response(JSON.stringify({
872
+ ok: true,
873
+ userProfile: { score: currentScore, user_id: finalUserId },
874
+ meta: metaRes.status === 'fulfilled' ? metaRes.value : { error: metaRes.reason?.message },
875
+ ga4: ga4Res.status === 'fulfilled' ? ga4Res.value : { error: ga4Res.reason?.message },
876
+ tiktok: ttRes.status === 'fulfilled' ? ttRes.value : { error: ttRes.reason?.message },
877
+ }), { status: 200, headers: resHeaders });
878
+ }
879
+
880
+ // ── POST /webhook/hotmart ─────────────────────────────────────────────────
881
+ if (request.method === 'POST' && url.pathname === '/webhook/hotmart') {
882
+ if (env.WEBHOOK_SECRET_HOTMART) {
883
+ const token = request.headers.get('X-Hotmart-Webhook-Token') || '';
884
+ if (token !== env.WEBHOOK_SECRET_HOTMART) {
885
+ return new Response('Unauthorized', { status: 401 });
886
+ }
887
+ }
888
+
889
+ let wh: HotmartWebhook;
890
+ try { wh = await request.json() as HotmartWebhook; } catch {
891
+ return new Response('JSON inválido', { status: 400 });
892
+ }
893
+
894
+ const data = wh.data || wh;
895
+ const buyer = data.buyer || {};
896
+ const purchase = data.purchase || {};
897
+ const product = data.product || {};
898
+
899
+ if (!['APPROVED', 'COMPLETE', 'approved', 'complete'].includes(purchase.status)) {
900
+ return new Response(JSON.stringify({ skipped: `status ${purchase.status}` }), { status: 200, headers });
901
+ }
902
+
903
+ const hmTxId = String(purchase.transaction || '');
904
+ const dupCheck = await processWebhookDuplicateCheck(env, 'hotmart', hmTxId, JSON.stringify(wh), {
905
+ email: buyer.email,
906
+ });
907
+
908
+ if (dupCheck.duplicate) {
909
+ return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
910
+ }
911
+
912
+ const profile = await getProfileByEmail(env, buyer.email);
913
+
914
+ const payload = {
915
+ email: buyer.email,
916
+ phone: buyer.phone,
917
+ firstName: buyer.name?.split(' ')[0],
918
+ lastName: buyer.name?.split(' ').slice(1).join(' ') || undefined,
919
+ fbp: profile?.fbp,
920
+ fbc: profile?.fbc,
921
+ userId: profile?.user_id,
922
+ gaClientId: profile?.ga_client_id,
923
+ value: purchase.price?.value,
924
+ currency: purchase.price?.currency_value || 'BRL',
925
+ contentIds: [String(product.id || product.ucode || '')],
926
+ contentName: product.name,
927
+ contentType: 'product',
928
+ pageUrl: profile?.page_url || `https://${env.SITE_DOMAIN || 'seu-dominio.com.br'}`,
929
+ orderId: purchase.transaction,
930
+ eventId: `hotmart_${purchase.transaction}`,
931
+ city: profile?.city,
932
+ state: profile?.state,
933
+ country: profile?.country,
934
+ };
935
+
936
+ ctx.waitUntil(Promise.allSettled([
937
+ sendMetaCapi(env, 'Purchase', payload, request, ctx),
938
+ sendGA4Mp(env, 'purchase', payload, ctx),
939
+ sendTikTokApi(env, 'CompletePayment', payload, request, ctx),
940
+ saveLead(env, 'Purchase', payload, request, 'hotmart'),
941
+ sendWhatsApp(env, 'Purchase', payload),
942
+ ]));
943
+
944
+ return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
945
+ }
946
+
947
+ // ── POST /webhook/kiwify ──────────────────────────────────────────────────
948
+ if (request.method === 'POST' && url.pathname === '/webhook/kiwify') {
949
+ if (env.WEBHOOK_SECRET_KIWIFY) {
950
+ const token = request.headers.get('X-Kiwify-Event-Token') || '';
951
+ if (token !== env.WEBHOOK_SECRET_KIWIFY) {
952
+ return new Response('Unauthorized', { status: 401 });
953
+ }
954
+ }
955
+
956
+ let wh: KiwifyWebhook;
957
+ try { wh = await request.json() as KiwifyWebhook; } catch {
958
+ return new Response('JSON inválido', { status: 400 });
959
+ }
960
+
961
+ if (wh.order_status !== 'paid' && wh.order_status !== 'approved') {
962
+ return new Response(JSON.stringify({ skipped: `status ${wh.order_status}` }), { status: 200, headers });
963
+ }
964
+
965
+ const customer = wh.Customer || {};
966
+ const kwTxId = String(wh.order_id || '');
967
+ const dupCheck = await processWebhookDuplicateCheck(env, 'kiwify', kwTxId, JSON.stringify(wh), {
968
+ email: customer.email,
969
+ });
970
+
971
+ if (dupCheck.duplicate) {
972
+ return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
973
+ }
974
+ const product = wh.Product || {};
975
+ const profile = await getProfileByEmail(env, customer.email || '');
976
+
977
+ const payload = {
978
+ email: customer.email,
979
+ phone: customer.mobile,
980
+ firstName: customer.full_name?.split(' ')[0],
981
+ lastName: customer.full_name?.split(' ').slice(1).join(' ') || undefined,
982
+ fbp: profile?.fbp,
983
+ fbc: profile?.fbc,
984
+ userId: profile?.user_id,
985
+ gaClientId: profile?.ga_client_id,
986
+ value: wh.order_value ? parseFloat(wh.order_value) / 100 : undefined,
987
+ currency: 'BRL',
988
+ contentIds: [String(product.product_id || '')],
989
+ contentName: product.product_name,
990
+ contentType: 'product',
991
+ pageUrl: profile?.page_url || `https://${env.SITE_DOMAIN || 'seu-dominio.com.br'}`,
992
+ orderId: wh.order_id,
993
+ eventId: `kiwify_${wh.order_id}`,
994
+ city: profile?.city,
995
+ state: profile?.state,
996
+ country: profile?.country,
997
+ };
998
+
999
+ ctx.waitUntil(Promise.allSettled([
1000
+ sendMetaCapi(env, 'Purchase', payload, request, ctx),
1001
+ sendGA4Mp(env, 'purchase', payload, ctx),
1002
+ sendTikTokApi(env, 'CompletePayment', payload, request, ctx),
1003
+ sendPinterestCapi(env, 'Purchase', payload, request, ctx),
1004
+ sendRedditCapi(env, 'Purchase', payload, request, ctx),
1005
+ sendLinkedInCapi(env, 'Purchase', payload, request, ctx),
1006
+ sendSpotifyCapi(env, 'Purchase', payload, request, ctx),
1007
+ saveLead(env, 'Purchase', payload, request, 'kiwify'),
1008
+ sendWhatsApp(env, 'Purchase', payload),
1009
+ ]));
1010
+
1011
+ return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
1012
+ }
1013
+
1014
+ // ── POST /webhook/ticto ───────────────────────────────────────────────────
1015
+ if (request.method === 'POST' && url.pathname === '/webhook/ticto') {
1016
+ let rawBody;
1017
+ try { rawBody = await request.text(); } catch {
1018
+ return new Response('Leitura de body falhou', { status: 400 });
1019
+ }
1020
+ if (env.WEBHOOK_SECRET_TICTO) {
1021
+ const sig = request.headers.get('X-Ticto-Signature') || '';
1022
+ const valid = await verifyHmac(env.WEBHOOK_SECRET_TICTO, rawBody, sig);
1023
+ if (!valid) {
1024
+ return new Response('Unauthorized', { status: 401 });
1025
+ }
1026
+ }
1027
+
1028
+ let wh: TictoWebhook;
1029
+ try { wh = JSON.parse(rawBody) as TictoWebhook; } catch {
1030
+ return new Response('JSON inválido', { status: 400 });
1031
+ }
1032
+
1033
+ const STATUS_PAID = ['paid', 'approved', 'complete', 'completed'];
1034
+ if (!STATUS_PAID.includes((wh.status || '').toLowerCase())) {
1035
+ return new Response(JSON.stringify({ skipped: `status ${wh.status}` }), { status: 200, headers });
1036
+ }
1037
+
1038
+ const customer = wh.customer || {};
1039
+ const order = wh.order || {};
1040
+ const item = wh.item || {};
1041
+ const tracking = wh.tracking || wh.url_params || {};
1042
+
1043
+ const valueRaw = order.paid_amount ?? order.total ?? order.amount;
1044
+ const value = valueRaw ? parseFloat(String(valueRaw)) / 100 : undefined;
1045
+ const transactionId = order.hash || order.transaction_hash || order.id;
1046
+ const tcTxId = String(order.hash || order.transaction_hash || order.id || '');
1047
+
1048
+ const dupCheck = await processWebhookDuplicateCheck(env, 'ticto', tcTxId, rawBody, {
1049
+ email: customer.email,
1050
+ });
1051
+
1052
+ if (dupCheck.duplicate) {
1053
+ return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
1054
+ }
1055
+
1056
+ const urlUserId = tracking.user_id || wh.url_params?.user_id;
1057
+ let profile = await getProfileByEmail(env, customer.email || '');
1058
+ if (!profile && urlUserId && env.DB) {
1059
+ try {
1060
+ profile = await env.DB.prepare(
1061
+ 'SELECT * FROM user_profiles WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1'
1062
+ ).bind(urlUserId).first();
1063
+ } catch (err: any) {
1064
+ console.error('[POST /webhook/ticto] Error fetching user profile by userId:', {
1065
+ userId: urlUserId,
1066
+ email: customer.email,
1067
+ error: err?.message || String(err),
1068
+ stack: err?.stack,
1069
+ });
1070
+ }
1071
+ }
1072
+
1073
+ const fbclid = tracking.fbclid || wh.url_params?.fbclid;
1074
+ const fbc = profile?.fbc || (fbclid ? `fb.1.${Date.now()}.${fbclid}` : undefined);
1075
+
1076
+ const payload = {
1077
+ email: customer.email,
1078
+ phone: customer.phone,
1079
+ firstName: customer.name?.split(' ')[0],
1080
+ lastName: customer.name?.split(' ').slice(1).join(' ') || undefined,
1081
+ fbp: profile?.fbp,
1082
+ fbc,
1083
+ ttp: profile?.ttp,
1084
+ userId: profile?.user_id,
1085
+ gaClientId: profile?.ga_client_id,
1086
+ value,
1087
+ currency: 'BRL',
1088
+ contentIds: [String(item.product_id || '')],
1089
+ contentName: item.product_name,
1090
+ contentType: 'product',
1091
+ pageUrl: profile?.page_url || `https://${env.SITE_DOMAIN || 'seu-dominio.com.br'}`,
1092
+ orderId: transactionId,
1093
+ eventId: `ticto_${transactionId}`,
1094
+ city: profile?.city,
1095
+ state: profile?.state,
1096
+ country: profile?.country || 'br',
1097
+ utmSource: tracking.utm_source || tracking.src || '',
1098
+ utmMedium: tracking.utm_medium || '',
1099
+ utmCampaign: tracking.utm_campaign || '',
1100
+ utmContent: tracking.utm_content || '',
1101
+ };
1102
+
1103
+ ctx.waitUntil(Promise.allSettled([
1104
+ sendMetaCapi(env, 'Purchase', payload, request, ctx),
1105
+ sendGA4Mp(env, 'purchase', payload, ctx),
1106
+ sendTikTokApi(env, 'CompletePayment', payload, request, ctx),
1107
+ sendPinterestCapi(env, 'Purchase', payload, request, ctx),
1108
+ sendRedditCapi(env, 'Purchase', payload, request, ctx),
1109
+ sendLinkedInCapi(env, 'Purchase', payload, request, ctx),
1110
+ sendSpotifyCapi(env, 'Purchase', payload, request, ctx),
1111
+ saveLead(env, 'Purchase', payload, request, 'ticto'),
1112
+ sendWhatsApp(env, 'Purchase', payload),
1113
+ ]));
1114
+
1115
+ return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
1116
+ }
1117
+
1118
+ // ── GET /webhook/whatsapp — verificação do webhook pela Meta ─────────────
1119
+ if (request.method === 'GET' && url.pathname === '/webhook/whatsapp') {
1120
+ const mode = url.searchParams.get('hub.mode');
1121
+ const token = url.searchParams.get('hub.verify_token');
1122
+ const challenge = url.searchParams.get('hub.challenge');
1123
+
1124
+ if (mode === 'subscribe' && env.WA_WEBHOOK_VERIFY_TOKEN && token === env.WA_WEBHOOK_VERIFY_TOKEN) {
1125
+ return new Response(challenge, { status: 200, headers: { 'Content-Type': 'text/plain' } });
1126
+ }
1127
+ return new Response('Forbidden', { status: 403 });
1128
+ }
1129
+
1130
+ // ── POST /webhook/whatsapp — mensagens recebidas (CTWA) ──────────────────
1131
+ if (request.method === 'POST' && url.pathname === '/webhook/whatsapp') {
1132
+ let body;
1133
+ try { body = await request.json(); } catch {
1134
+ return new Response('JSON inválido', { status: 400 });
1135
+ }
1136
+
1137
+ const result = await processWhatsAppWebhook(env, body, request, ctx);
1138
+ return new Response(JSON.stringify({ ok: true, ...result }), { status: 200, headers });
1139
+ }
1140
+
1141
+ // ── ML — Segmentação Dinâmica ─────────────────────────────────────────────
1142
+ if (url.pathname === '/api/segmentation/cluster' && request.method === 'POST') {
1143
+ return handleSegmentationCluster(env, request, headers);
1144
+ }
1145
+ if (url.pathname === '/api/segmentation/list' && request.method === 'GET') {
1146
+ return handleSegmentationList(env, request, headers);
1147
+ }
1148
+ if (url.pathname === '/api/segmentation/outliers' && request.method === 'GET') {
1149
+ return handleSegmentationOutliers(env, request, headers);
1150
+ }
1151
+ if (url.pathname === '/api/segmentation/update' && request.method === 'PUT') {
1152
+ return handleSegmentationUpdate(env, request, headers);
1153
+ }
1154
+
1155
+ // ── ML — Bidding Recommendations ──────────────────────────────────────────
1156
+ if (url.pathname === '/api/bidding/recommend' && request.method === 'POST') {
1157
+ return handleBiddingRecommend(env, request, headers);
1158
+ }
1159
+ if (url.pathname === '/api/bidding/history' && request.method === 'GET') {
1160
+ return handleBiddingHistory(env, request, headers);
1161
+ }
1162
+ if (url.pathname === '/api/bidding/status' && request.method === 'GET') {
1163
+ return handleBiddingStatus(env, request, headers);
1164
+ }
1165
+
1166
+ // ── ML — A/B Testing de Prompts LTV ──────────────────────────────────────
1167
+ if (url.pathname === '/api/ltv/ab-test/create' && request.method === 'POST') {
1168
+ return handleLtvAbTestCreate(env, request, headers);
1169
+ }
1170
+ if (url.pathname === '/api/ltv/ab-test/list' && request.method === 'GET') {
1171
+ return handleLtvAbTestList(env, request, headers);
1172
+ }
1173
+ if (url.pathname === '/api/ltv/ab-test/results' && request.method === 'GET') {
1174
+ return handleLtvAbTestResults(env, request, headers);
1175
+ }
1176
+ if (url.pathname === '/api/ltv/ab-test/winner' && request.method === 'POST') {
1177
+ return handleLtvAbTestWinner(env, request, headers);
1178
+ }
1179
+
1180
+ // ── Fraud Detection — Fase 4 ──────────────────────────────────────────────
1181
+ if (url.pathname === '/api/fraud/alerts' && request.method === 'GET') {
1182
+ return handleFraudAlerts(env, request, headers);
1183
+ }
1184
+ if (url.pathname === '/api/fraud/blocklist' && request.method === 'GET') {
1185
+ return handleFraudBlocklist(env, request, headers);
1186
+ }
1187
+ if (url.pathname === '/api/fraud/blocklist/add' && request.method === 'POST') {
1188
+ return handleFraudBlocklistAdd(env, request, headers);
1189
+ }
1190
+ if (url.pathname === '/api/fraud/blocklist/remove' && request.method === 'DELETE') {
1191
+ return handleFraudBlocklistRemove(env, request, headers);
1192
+ }
1193
+ if (url.pathname === '/api/fraud/stats' && request.method === 'GET') {
1194
+ return handleFraudStats(env, request, headers);
1195
+ }
1196
+
1197
+ // 404
1198
+ return new Response(JSON.stringify({ error: 'rota não encontrada' }), { status: 404, headers });
1199
+ },
1200
+
1201
+ // ── Cron Handler — Intelligence Agent ────────────────────────────────────────
1202
+ async scheduled(event: any, env: Env, ctx: ExecutionContext) {
1203
+ const cron = event.cron;
1204
+ const isMonthly = cron === '0 3 1 * *';
1205
+
1206
+ ctx.waitUntil(runIntelligenceAgent(env, isMonthly ? 'monthly_audit' : 'weekly_check'));
1207
+ },
1208
+
1209
+ // ── Queue Consumer — Retry de eventos com falha ───────────────────────────────
1210
+ async queue(batch: any, env: Env, ctx: ExecutionContext) {
1211
+ for (const message of batch.messages) {
1212
+ const { eventType, payload, platform } = message.body as { eventType: string; payload: TrackPayload; platform: string; attempt?: number };
1213
+
1214
+ try {
1215
+ if (platform === 'meta') await sendMetaCapi(env, eventType, payload, null, ctx);
1216
+ if (platform === 'ga4') await sendGA4Mp(env, eventType, payload, ctx);
1217
+ if (platform === 'tiktok') await sendTikTokApi(env, eventType, payload, null, ctx);
1218
+ if (platform === 'pinterest') await sendPinterestCapi(env, eventType, payload, null, ctx);
1219
+ if (platform === 'reddit') await sendRedditCapi(env, eventType, payload, null, ctx);
1220
+ if (platform === 'linkedin') await sendLinkedInCapi(env, eventType, payload, null, ctx);
1221
+ if (platform === 'spotify') await sendSpotifyCapi(env, eventType, payload, null, ctx);
1222
+
1223
+ message.ack();
1224
+ } catch (err: any) {
1225
+ console.error(`[Queue] Falha ao reprocessar ${platform}/${eventType}:`, err?.message || String(err));
1226
+ message.retry();
1227
+ }
1228
+ }
1229
+ },
1230
+ };