cdp-edge 1.2.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 (128) hide show
  1. package/README.md +367 -0
  2. package/bin/cdp-edge.js +61 -0
  3. package/contracts/api-versions.json +368 -0
  4. package/dist/commands/analyze.js +52 -0
  5. package/dist/commands/infra.js +54 -0
  6. package/dist/commands/install.js +168 -0
  7. package/dist/commands/server.js +174 -0
  8. package/dist/commands/setup.js +123 -0
  9. package/dist/commands/validate.js +84 -0
  10. package/dist/index.js +12 -0
  11. package/docs/CI-CD-SETUP.md +217 -0
  12. package/docs/PixelBuilder-Documentacao-Completa (2).docx +0 -0
  13. package/docs/events-reference.md +359 -0
  14. package/docs/installation.md +155 -0
  15. package/docs/quick-start.md +185 -0
  16. package/docs/sdk-reference.md +371 -0
  17. package/docs/whatsapp-ctwa.md +209 -0
  18. package/extracted-skill/tracking-events-generator/INDEX.md +94 -0
  19. package/extracted-skill/tracking-events-generator/INSTALACAO-CDPEDGE.md +58 -0
  20. package/extracted-skill/tracking-events-generator/INTEGRACAO-COMPLETA.md +594 -0
  21. package/extracted-skill/tracking-events-generator/MELHORIAS-IMPLEMENTADAS.md +412 -0
  22. package/extracted-skill/tracking-events-generator/Premium-Tracking-Intelligence-Resumo.md +333 -0
  23. package/extracted-skill/tracking-events-generator/SKILL.md +257 -0
  24. package/extracted-skill/tracking-events-generator/advanced-matching.js +364 -0
  25. package/extracted-skill/tracking-events-generator/agents/ab-testing-agent.md +54 -0
  26. package/extracted-skill/tracking-events-generator/agents/attribution-agent.md +1304 -0
  27. package/extracted-skill/tracking-events-generator/agents/bing-agent.md +76 -0
  28. package/extracted-skill/tracking-events-generator/agents/browser-tracking.md +264 -0
  29. package/extracted-skill/tracking-events-generator/agents/code-guardian-agent.md +149 -0
  30. package/extracted-skill/tracking-events-generator/agents/compliance-agent.md +2077 -0
  31. package/extracted-skill/tracking-events-generator/agents/crm-integration-agent.md +1419 -0
  32. package/extracted-skill/tracking-events-generator/agents/dashboard-agent.md +456 -0
  33. package/extracted-skill/tracking-events-generator/agents/database-agent.md +667 -0
  34. package/extracted-skill/tracking-events-generator/agents/debug-agent.md +1455 -0
  35. package/extracted-skill/tracking-events-generator/agents/domain-setup-agent.md +224 -0
  36. package/extracted-skill/tracking-events-generator/agents/email-agent.md +61 -0
  37. package/extracted-skill/tracking-events-generator/agents/fingerprint-agent.md +52 -0
  38. package/extracted-skill/tracking-events-generator/agents/google-agent.md +109 -0
  39. package/extracted-skill/tracking-events-generator/agents/intelligence-agent.md +365 -0
  40. package/extracted-skill/tracking-events-generator/agents/intelligence-scheduling.md +643 -0
  41. package/extracted-skill/tracking-events-generator/agents/linkedin-agent.md +62 -0
  42. package/extracted-skill/tracking-events-generator/agents/localization-agent.md +55 -0
  43. package/extracted-skill/tracking-events-generator/agents/ltv-predictor-agent.md +59 -0
  44. package/extracted-skill/tracking-events-generator/agents/master-feedback-loop.md +900 -0
  45. package/extracted-skill/tracking-events-generator/agents/master-orchestrator.md +1922 -0
  46. package/extracted-skill/tracking-events-generator/agents/memory-agent.json +109 -0
  47. package/extracted-skill/tracking-events-generator/agents/memory-agent.md +703 -0
  48. package/extracted-skill/tracking-events-generator/agents/meta-agent.md +110 -0
  49. package/extracted-skill/tracking-events-generator/agents/page-analyzer.md +255 -0
  50. package/extracted-skill/tracking-events-generator/agents/performance-agent.md +1157 -0
  51. package/extracted-skill/tracking-events-generator/agents/performance-optimization-agent.md +1432 -0
  52. package/extracted-skill/tracking-events-generator/agents/pinterest-agent.md +310 -0
  53. package/extracted-skill/tracking-events-generator/agents/premium-tracking-intelligence-agent.md +849 -0
  54. package/extracted-skill/tracking-events-generator/agents/r2-setup-agent.md +250 -0
  55. package/extracted-skill/tracking-events-generator/agents/reddit-agent.md +313 -0
  56. package/extracted-skill/tracking-events-generator/agents/security-enterprise-agent.md +1752 -0
  57. package/extracted-skill/tracking-events-generator/agents/server-tracking.md +1188 -0
  58. package/extracted-skill/tracking-events-generator/agents/spotify-agent.md +383 -0
  59. package/extracted-skill/tracking-events-generator/agents/tiktok-agent.md +111 -0
  60. package/extracted-skill/tracking-events-generator/agents/tracking-plan-agent.md +364 -0
  61. package/extracted-skill/tracking-events-generator/agents/validator-agent.md +267 -0
  62. package/extracted-skill/tracking-events-generator/agents/webhook-agent.md +69 -0
  63. package/extracted-skill/tracking-events-generator/agents/whatsapp-agent.md +76 -0
  64. package/extracted-skill/tracking-events-generator/agents/whatsapp-ctwa-setup-agent.md +699 -0
  65. package/extracted-skill/tracking-events-generator/agents/youtube-agent.md +422 -0
  66. package/extracted-skill/tracking-events-generator/anti-blocking.js +285 -0
  67. package/extracted-skill/tracking-events-generator/cdpTrack.js +641 -0
  68. package/extracted-skill/tracking-events-generator/contracts/api-versions.json +368 -0
  69. package/extracted-skill/tracking-events-generator/docs/guia-cloudflare-iniciante.md +107 -0
  70. package/extracted-skill/tracking-events-generator/engagement-scoring.js +226 -0
  71. package/extracted-skill/tracking-events-generator/evals/evals.json +235 -0
  72. package/extracted-skill/tracking-events-generator/integration-test.js +497 -0
  73. package/extracted-skill/tracking-events-generator/knowledge-base.md +2894 -0
  74. package/extracted-skill/tracking-events-generator/micro-events.js +992 -0
  75. package/extracted-skill/tracking-events-generator/models/captura-de-lead.md +78 -0
  76. package/extracted-skill/tracking-events-generator/models/captura-lead-evento-externo.md +99 -0
  77. package/extracted-skill/tracking-events-generator/models/checkout-proprio.md +111 -0
  78. package/extracted-skill/tracking-events-generator/models/multi-step-checkout.md +672 -0
  79. package/extracted-skill/tracking-events-generator/models/pagina-obrigado.md +55 -0
  80. package/extracted-skill/tracking-events-generator/models/pinterest/conversions-api-template.js +144 -0
  81. package/extracted-skill/tracking-events-generator/models/pinterest/event-mappings.json +48 -0
  82. package/extracted-skill/tracking-events-generator/models/pinterest/tag-template.js +28 -0
  83. package/extracted-skill/tracking-events-generator/models/quiz-funnel.md +68 -0
  84. package/extracted-skill/tracking-events-generator/models/reddit/conversions-api-template.js +205 -0
  85. package/extracted-skill/tracking-events-generator/models/reddit/event-mappings.json +56 -0
  86. package/extracted-skill/tracking-events-generator/models/reddit/pixel-template.js +19 -0
  87. package/extracted-skill/tracking-events-generator/models/scenarios/behavior-engine.js +425 -0
  88. package/extracted-skill/tracking-events-generator/models/scenarios/real-estate-logic.md +50 -0
  89. package/extracted-skill/tracking-events-generator/models/scenarios/sales-page-logic.md +50 -0
  90. package/extracted-skill/tracking-events-generator/models/trafego-direto.md +582 -0
  91. package/extracted-skill/tracking-events-generator/models/webinar-registration.md +63 -0
  92. package/extracted-skill/tracking-events-generator/tracking.config.js +46 -0
  93. package/extracted-skill/tracking-events-generator/walkthrough.md +26 -0
  94. package/package.json +75 -0
  95. package/server-edge-tracker/INSTALAR.md +328 -0
  96. package/server-edge-tracker/migrate-new-db.sql +137 -0
  97. package/server-edge-tracker/migrate-v2.sql +16 -0
  98. package/server-edge-tracker/migrate-v3.sql +6 -0
  99. package/server-edge-tracker/migrate-v4.sql +18 -0
  100. package/server-edge-tracker/migrate-v5.sql +17 -0
  101. package/server-edge-tracker/migrate-v6.sql +24 -0
  102. package/server-edge-tracker/migrate.sql +111 -0
  103. package/server-edge-tracker/schema.sql +265 -0
  104. package/server-edge-tracker/worker.js +2574 -0
  105. package/server-edge-tracker/wrangler.toml +85 -0
  106. package/templates/afiliado-sem-landing.md +312 -0
  107. package/templates/captura-de-lead.md +78 -0
  108. package/templates/captura-lead-evento-externo.md +99 -0
  109. package/templates/checkout-proprio.md +111 -0
  110. package/templates/install/.claude/commands/cdp.md +1 -0
  111. package/templates/install/CLAUDE.md +65 -0
  112. package/templates/linkedin/tag-template.js +46 -0
  113. package/templates/multi-step-checkout.md +673 -0
  114. package/templates/pagina-obrigado.md +55 -0
  115. package/templates/pinterest/conversions-api-template.js +144 -0
  116. package/templates/pinterest/event-mappings.json +48 -0
  117. package/templates/pinterest/tag-template.js +28 -0
  118. package/templates/quiz-funnel.md +68 -0
  119. package/templates/reddit/conversions-api-template.js +205 -0
  120. package/templates/reddit/event-mappings.json +56 -0
  121. package/templates/reddit/pixel-template.js +46 -0
  122. package/templates/scenarios/behavior-engine.js +402 -0
  123. package/templates/scenarios/real-estate-logic.md +50 -0
  124. package/templates/scenarios/sales-page-logic.md +50 -0
  125. package/templates/spotify/pixel-template.js +46 -0
  126. package/templates/trafego-direto.md +582 -0
  127. package/templates/vsl-page.md +292 -0
  128. package/templates/webinar-registration.md +63 -0
@@ -0,0 +1,2574 @@
1
+ /**
2
+ * CDP Edge Server — server-edge-tracker.seu-usuario.workers.dev
3
+ * Meta CAPI v22.0 + GA4 Measurement Protocol + TikTok Events API + D1
4
+ * Cloudflare Workers (plano gratuito)
5
+ *
6
+ * Endpoints:
7
+ * POST /track → evento do browser (PageView, Lead, Purchase…)
8
+ * POST /webhook/ticto → compra confirmada pela Ticto (v2 JSON)
9
+ * GET /health → status do Worker
10
+ *
11
+ * Secrets (configurar via: wrangler secret put NOME):
12
+ * META_ACCESS_TOKEN → token da Conversions API Meta
13
+ * GA4_API_SECRET → secret do Measurement Protocol (GA4 → Admin → Data Streams → API secrets)
14
+ * TIKTOK_ACCESS_TOKEN → token da TikTok Events API (TikTok for Business → Assets → Events → Web Events → Manage → API)
15
+ * META_TEST_CODE → só em testes (ex: TEST12345) — remover em produção
16
+ */
17
+
18
+ // ── Constantes ────────────────────────────────────────────────────────────────
19
+ const META_PIXEL_ID = '1234567890123456'; // Substitua pelo seu Pixel ID
20
+ const GA4_MEASUREMENT_ID = 'G-XXXXXXXXXX'; // Substitua pelo seu ID GA4
21
+ const TIKTOK_PIXEL_ID = 'CXXXXXXXXXXXXXXX'; // Substitua pelo seu Pixel TikTok
22
+ const ALLOWED_ORIGINS = [
23
+ 'https://seu-dominio.com.br', // Substitua pelo URL absoluto de origem do seu funil
24
+ 'https://seu-worker-tracking.seu-usuario.workers.dev', // URL real do Cloudflare Worker para CORS
25
+ 'http://localhost:3000', // Desenvolvimento local Next
26
+ 'http://localhost:5173', // Desenvolvimento local Vite
27
+ ];
28
+
29
+ // ── SHA-256 via WebCrypto (obrigatório no Cloudflare Workers) ─────────────────
30
+ async function sha256(value) {
31
+ if (!value) return undefined;
32
+ const clean = String(value).toLowerCase().trim();
33
+ if (!clean) return undefined;
34
+ const buf = await crypto.subtle.digest(
35
+ 'SHA-256',
36
+ new TextEncoder().encode(clean)
37
+ );
38
+ return Array.from(new Uint8Array(buf))
39
+ .map(b => b.toString(16).padStart(2, '0'))
40
+ .join('');
41
+ }
42
+
43
+ // ── Normalização de telefone → somente dígitos + DDI 55 ──────────────────────
44
+ function normalizePhone(phone) {
45
+ if (!phone) return undefined;
46
+ let digits = String(phone).replace(/\D/g, '');
47
+ if (digits.length === 11 && !digits.startsWith('55')) digits = '55' + digits;
48
+ if (digits.length === 10 && !digits.startsWith('55')) digits = '55' + digits;
49
+ return digits.length >= 10 ? digits : undefined;
50
+ }
51
+
52
+ // ── Normalização de cidade → lowercase sem acentos ────────────────────────────
53
+ function normalizeCity(city) {
54
+ if (!city) return undefined;
55
+ return String(city)
56
+ .toLowerCase()
57
+ .normalize('NFD')
58
+ .replace(/[\u0300-\u036f]/g, '')
59
+ .replace(/[^a-z0-9]/g, '');
60
+ }
61
+
62
+ // ── CORS ──────────────────────────────────────────────────────────────────────
63
+ function corsHeaders(origin) {
64
+ const allowed = ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0];
65
+ return {
66
+ 'Access-Control-Allow-Origin': allowed,
67
+ 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
68
+ 'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With',
69
+ 'Access-Control-Max-Age': '86400',
70
+ };
71
+ }
72
+
73
+ // ── Meta CAPI v22.0 ───────────────────────────────────────────────────────────
74
+ async function sendMetaCapi(env, eventName, payload, request, ctx) {
75
+ const {
76
+ email, phone, firstName, lastName,
77
+ city, state, country,
78
+ zip, dob,
79
+ fbp, fbc, userId,
80
+ eventId, pageUrl,
81
+ value, currency,
82
+ contentIds, contentName, contentType, numItems,
83
+ } = payload;
84
+
85
+ const phoneNorm = normalizePhone(phone);
86
+ const countryCode = (country || request.cf?.country || 'br').toLowerCase();
87
+ const stateCode = state ? String(state).toLowerCase() : undefined;
88
+ const cityNorm = normalizeCity(city);
89
+
90
+ // user_data — hashear tudo com SHA-256 antes de enviar
91
+ const userData = {
92
+ ...(email && { em: await sha256(email) }),
93
+ ...(phoneNorm && { ph: await sha256(phoneNorm) }),
94
+ ...(firstName && { fn: await sha256(firstName) }),
95
+ ...(lastName && { ln: await sha256(lastName) }),
96
+ ...(cityNorm && { ct: await sha256(cityNorm) }),
97
+ ...(stateCode && { st: await sha256(stateCode) }),
98
+ ...(countryCode && { country: await sha256(countryCode) }),
99
+ ...(userId && { external_id: await sha256(String(userId)) }),
100
+ ...(zip && { zp: await sha256(zip) }),
101
+ ...(dob && { db: await sha256(dob) }),
102
+ ...(fbp && { fbp }), // cookies NÃO são hasheados
103
+ ...(fbc && { fbc }),
104
+ client_ip_address: request.headers.get('CF-Connecting-IP')
105
+ || request.headers.get('X-Forwarded-For')
106
+ || '',
107
+ client_user_agent: request.headers.get('User-Agent') || '',
108
+ };
109
+
110
+ // custom_data — dados do produto/valor
111
+ const customData = {
112
+ ...(value !== undefined && { value: parseFloat(value) }),
113
+ ...(currency && { currency: String(currency).toUpperCase() }),
114
+ ...(contentIds && contentIds.length > 0 && { content_ids: contentIds }),
115
+ ...(contentName && { content_name: contentName }),
116
+ ...(contentType && { content_type: contentType }),
117
+ ...(numItems && { num_items: parseInt(numItems) }),
118
+ };
119
+
120
+ const eventPayload = {
121
+ event_name: eventName,
122
+ event_time: Math.floor(Date.now() / 1000),
123
+ event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
124
+ event_source_url: pageUrl || `https://server-edge-tracker.suporte-ed9.workers.dev`,
125
+ action_source: 'website',
126
+ user_data: userData,
127
+ ...(Object.keys(customData).length > 0 && { custom_data: customData }),
128
+ };
129
+
130
+ const requestBody = {
131
+ data: [eventPayload],
132
+ access_token: env.META_ACCESS_TOKEN,
133
+ };
134
+
135
+ // Test Event Code — só em staging, remover em produção
136
+ if (env.META_TEST_CODE) {
137
+ requestBody.test_event_code = env.META_TEST_CODE;
138
+ }
139
+
140
+ const endpoint = `https://graph.facebook.com/v22.0/${META_PIXEL_ID}/events`;
141
+
142
+ try {
143
+ const res = await fetch(endpoint, {
144
+ method: 'POST',
145
+ headers: { 'Content-Type': 'application/json' },
146
+ body: JSON.stringify(requestBody),
147
+ });
148
+
149
+ const data = await res.json();
150
+
151
+ if (!res.ok) {
152
+ const errorCode = data.error?.code || String(res.status);
153
+ const errorMessage = data.error?.message || data.error?.error_user_msg || 'Unknown error';
154
+ console.error('Meta CAPI error:', JSON.stringify(data));
155
+
156
+ // Log de falha para Feedback Loop
157
+ if (env.DB) {
158
+ ctx.waitUntil(logApiFailure(
159
+ env.DB,
160
+ 'meta',
161
+ eventName,
162
+ errorCode,
163
+ errorMessage,
164
+ eventPayload.event_id,
165
+ JSON.stringify(requestBody)
166
+ ));
167
+ }
168
+ }
169
+
170
+ return data;
171
+ } catch (err) {
172
+ console.error('Meta CAPI fetch failed:', err.message);
173
+
174
+ // Log de falha para Feedback Loop
175
+ if (env.DB) {
176
+ ctx.waitUntil(logApiFailure(
177
+ env.DB,
178
+ 'meta',
179
+ eventName,
180
+ 'FETCH_ERROR',
181
+ err.message,
182
+ eventPayload.event_id,
183
+ JSON.stringify(requestBody)
184
+ ));
185
+ }
186
+
187
+ return { error: err.message };
188
+ }
189
+ }
190
+
191
+ // ── GA4 Measurement Protocol ──────────────────────────────────────────────────
192
+ async function sendGA4Mp(env, ga4EventName, payload, ctx) {
193
+ if (!env.GA4_API_SECRET) return { skipped: 'GA4_API_SECRET not set' };
194
+
195
+ const {
196
+ clientId, sessionId,
197
+ value, currency, contentName,
198
+ email, phone, firstName,
199
+ orderId,
200
+ } = payload;
201
+
202
+ // GA4 MP exige client_id (cookie _ga)
203
+ if (!clientId) return { skipped: 'no clientId' };
204
+
205
+ const eventParams = {
206
+ ...(value !== undefined && { value: parseFloat(value) }),
207
+ ...(currency && { currency: String(currency).toUpperCase() }),
208
+ ...(contentName && { content_name: contentName }),
209
+ ...(orderId && { transaction_id: orderId }),
210
+ ...(email && { user_data_email_address: email.toLowerCase().trim() }),
211
+ ...(phone && { user_data_phone_number: normalizePhone(phone) }),
212
+ ...(firstName && { user_data_first_name: firstName.toLowerCase().trim() }),
213
+ ...(sessionId && { session_id: sessionId }),
214
+ engagement_time_msec: 100,
215
+ };
216
+
217
+ const body = {
218
+ client_id: clientId,
219
+ events: [{ name: ga4EventName, params: eventParams }],
220
+ };
221
+
222
+ const url = `https://www.google-analytics.com/mp/collect`
223
+ + `?measurement_id=${GA4_MEASUREMENT_ID}`
224
+ + `&api_secret=${env.GA4_API_SECRET}`;
225
+
226
+ try {
227
+ const res = await fetch(url, {
228
+ method: 'POST',
229
+ headers: { 'Content-Type': 'application/json' },
230
+ body: JSON.stringify(body),
231
+ });
232
+
233
+ // GA4 MP retorna 204 em sucesso (sem body)
234
+ if (res.status !== 204) {
235
+ // Log de falha para Feedback Loop
236
+ if (env.DB && ctx) {
237
+ ctx.waitUntil(logApiFailure(
238
+ env.DB,
239
+ 'ga4',
240
+ ga4EventName,
241
+ String(res.status),
242
+ 'GA4 returned non-204 status',
243
+ null,
244
+ JSON.stringify(body)
245
+ ));
246
+ }
247
+ }
248
+
249
+ return res.status === 204 ? { ok: true } : { status: res.status };
250
+ } catch (err) {
251
+ console.error('GA4 MP fetch failed:', err.message);
252
+
253
+ // Log de falha para Feedback Loop
254
+ if (env.DB && ctx) {
255
+ ctx.waitUntil(logApiFailure(
256
+ env.DB,
257
+ 'ga4',
258
+ ga4EventName,
259
+ 'FETCH_ERROR',
260
+ err.message,
261
+ null,
262
+ JSON.stringify(body)
263
+ ));
264
+ }
265
+
266
+ return { error: err.message };
267
+ }
268
+ }
269
+
270
+ // ── D1 — salvar lead ──────────────────────────────────────────────────────────
271
+ async function saveLead(env, eventName, payload, request, platform = 'website') {
272
+ if (!env.DB) return;
273
+ try {
274
+ const {
275
+ email, phone, firstName, lastName,
276
+ city, state, country,
277
+ fbp, fbc, userId,
278
+ utmSource, utmMedium, utmCampaign, utmContent, utmTerm,
279
+ pageUrl, value, currency, eventId, botScore,
280
+ engagementScore, intentionLevel, utmRestored,
281
+ } = payload;
282
+
283
+ await env.DB.prepare(`
284
+ INSERT INTO leads (
285
+ event_name, event_id, email, phone, first_name, last_name,
286
+ city, state, country, fbp, fbc, user_id,
287
+ utm_source, utm_medium, utm_campaign, utm_content, utm_term,
288
+ page_url, value, currency, ip_address, platform, bot_score,
289
+ engagement_score, intention_level, utm_restored, created_at
290
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
291
+ `).bind(
292
+ eventName,
293
+ eventId || null,
294
+ email || null,
295
+ normalizePhone(phone) || null,
296
+ firstName || null,
297
+ lastName || null,
298
+ city || null,
299
+ state || null,
300
+ (country || request.cf?.country || null),
301
+ fbp || null,
302
+ fbc || null,
303
+ userId || null,
304
+ utmSource || null,
305
+ utmMedium || null,
306
+ utmCampaign || null,
307
+ utmContent || null,
308
+ utmTerm || null,
309
+ pageUrl || null,
310
+ value !== undefined ? parseFloat(value) : null,
311
+ currency || 'BRL',
312
+ request.headers.get('CF-Connecting-IP') || null,
313
+ platform,
314
+ botScore || 0,
315
+ engagementScore !== undefined ? parseFloat(engagementScore) : null,
316
+ intentionLevel || null,
317
+ utmRestored ? 1 : 0,
318
+ ).run();
319
+ } catch (err) {
320
+ console.error('D1 saveLead error:', err.message);
321
+ }
322
+ }
323
+
324
+ // ── D1 — upsert perfil (acumula cookies entre visitas) ───────────────────────
325
+ // ── Cálculo de Cohort Label baseado no score acumulado ───────────────────────
326
+ function calculateCohortLabel(score, eventName) {
327
+ if (eventName === 'Purchase') return 'buyer_lookalike';
328
+ if (score >= 80) return 'high_intent';
329
+ if (score >= 30) return 'nurture';
330
+ return 'lost';
331
+ }
332
+
333
+ async function upsertProfile(env, eventName, payload, request) {
334
+ if (!env.DB || !payload.userId) return;
335
+ try {
336
+ const {
337
+ userId, email, phone,
338
+ fbp, fbc, ttp, gclid, ttclid, gaClientId,
339
+ city, state, country,
340
+ engagementScore, userScore,
341
+ } = payload;
342
+
343
+ // Score base por evento + bônus de engajamento do browser
344
+ const scoreMap = { PageView: 5, ViewContent: 10, ScrollDepth: 3, TimeOnPage: 5, Lead: 30, InitiateCheckout: 50, Purchase: 100 };
345
+ const eventScore = scoreMap[eventName] || 2;
346
+
347
+ // userScore vem do BehaviorEngine (0-100), engagementScore do engagement-scoring.js (0-5)
348
+ // Normaliza ambos para uma escala de bônus adicional (0-20)
349
+ const behaviorBonus = userScore
350
+ ? Math.round((Math.min(userScore, 100) / 100) * 20)
351
+ : (engagementScore ? Math.round((Math.min(engagementScore, 5) / 5) * 10) : 0);
352
+
353
+ const totalDelta = eventScore + behaviorBonus;
354
+
355
+ await env.DB.prepare(`
356
+ INSERT INTO user_profiles
357
+ (user_id, email, phone, fbp, fbc, ttp, gclid, ttclid, ga_client_id,
358
+ city, state, country, score, cohort_label, created_at, updated_at)
359
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'),datetime('now'))
360
+ ON CONFLICT(user_id) DO UPDATE SET
361
+ email = COALESCE(excluded.email, user_profiles.email),
362
+ phone = COALESCE(excluded.phone, user_profiles.phone),
363
+ fbp = COALESCE(excluded.fbp, user_profiles.fbp),
364
+ fbc = COALESCE(excluded.fbc, user_profiles.fbc),
365
+ ttp = COALESCE(excluded.ttp, user_profiles.ttp),
366
+ gclid = COALESCE(excluded.gclid, user_profiles.gclid),
367
+ ttclid = COALESCE(excluded.ttclid, user_profiles.ttclid),
368
+ ga_client_id = COALESCE(excluded.ga_client_id, user_profiles.ga_client_id),
369
+ city = COALESCE(excluded.city, user_profiles.city),
370
+ state = COALESCE(excluded.state, user_profiles.state),
371
+ country = COALESCE(excluded.country, user_profiles.country),
372
+ score = user_profiles.score + excluded.score,
373
+ cohort_label = excluded.cohort_label,
374
+ updated_at = datetime('now')
375
+ `).bind(
376
+ userId,
377
+ email || null,
378
+ normalizePhone(phone) || null,
379
+ fbp || null,
380
+ fbc || null,
381
+ ttp || null,
382
+ gclid || null,
383
+ ttclid || null,
384
+ gaClientId || null,
385
+ city || null,
386
+ state || null,
387
+ (country || request.cf?.country || null),
388
+ totalDelta,
389
+ calculateCohortLabel(totalDelta, eventName),
390
+ ).run();
391
+ } catch (err) {
392
+ console.error('D1 upsertProfile error:', err.message);
393
+ }
394
+ }
395
+
396
+ // ── D1 — Cross-Device Graph (matching probabilístico) ────────────────────────
397
+ // Quando email ou phone aparecem em outro _cdp_uid, registra o par no device_graph.
398
+ // Não mescla dados — preserva ambos os perfis, apenas cria o link de identidade.
399
+ // O primary_user_id é o perfil mais antigo (mais confiável historicamente).
400
+ async function resolveDeviceGraph(DB, currentUserId, email, phone) {
401
+ if (!DB || !currentUserId) return;
402
+ if (!email && !phone) return;
403
+
404
+ try {
405
+ // Busca perfis com mesmo email OU mesmo phone mas _cdp_uid diferente
406
+ const conditions = [];
407
+ const bindings = [];
408
+
409
+ if (email) {
410
+ conditions.push('email = ?');
411
+ bindings.push(email.toLowerCase().trim());
412
+ }
413
+ if (phone) {
414
+ const digits = String(phone).replace(/\D/g, '');
415
+ if (digits.length >= 10) {
416
+ conditions.push('phone LIKE ?');
417
+ bindings.push(`%${digits.slice(-10)}`); // sufixo dos últimos 10 dígitos
418
+ }
419
+ }
420
+
421
+ if (conditions.length === 0) return;
422
+
423
+ bindings.push(currentUserId);
424
+ const rows = await DB.prepare(`
425
+ SELECT user_id, email, phone, created_at
426
+ FROM user_profiles
427
+ WHERE (${conditions.join(' OR ')})
428
+ AND user_id != ?
429
+ ORDER BY created_at ASC
430
+ LIMIT 5
431
+ `).bind(...bindings).all();
432
+
433
+ if (!rows.results || rows.results.length === 0) return;
434
+
435
+ for (const match of rows.results) {
436
+ // Determinar tipo de match e confiança
437
+ const emailMatch = email && match.email &&
438
+ email.toLowerCase().trim() === match.email.toLowerCase().trim();
439
+ const phoneMatch = phone && match.phone && (() => {
440
+ const a = String(phone).replace(/\D/g, '');
441
+ const b = String(match.phone).replace(/\D/g, '');
442
+ return a.slice(-10) === b.slice(-10) && a.length >= 10;
443
+ })();
444
+
445
+ if (!emailMatch && !phoneMatch) continue;
446
+
447
+ const matchType = emailMatch && phoneMatch ? 'email+phone' : (emailMatch ? 'email' : 'phone');
448
+ const matchConfidence = emailMatch && phoneMatch ? 0.99 : (emailMatch ? 0.95 : 0.85);
449
+
450
+ // primary = mais antigo (âncora de identidade)
451
+ const primary = match.user_id; // veio da query ORDER BY created_at ASC
452
+ const secondary = currentUserId;
453
+
454
+ await DB.prepare(`
455
+ INSERT OR IGNORE INTO device_graph
456
+ (primary_user_id, secondary_user_id, match_type, match_confidence)
457
+ VALUES (?, ?, ?, ?)
458
+ `).bind(primary, secondary, matchType, matchConfidence).run();
459
+
460
+ console.log(`[DeviceGraph] Linked ${secondary} → ${primary} via ${matchType} (confidence: ${matchConfidence})`);
461
+ }
462
+ } catch (err) {
463
+ console.error('resolveDeviceGraph error:', err.message);
464
+ }
465
+ }
466
+
467
+ // ── Automação de Mensagens — avalia regras e dispara WA/Email ────────────────
468
+ // Chamado via ctx.waitUntil após saveLead() para não bloquear o browser.
469
+ // Requer secrets: WHATSAPP_TOKEN, WHATSAPP_PHONE_NUMBER_ID, RESEND_API_KEY, RESEND_FROM_EMAIL
470
+ async function fireAutomation(env, eventName, leadId, payload) {
471
+ if (!env.DB) return;
472
+
473
+ try {
474
+ const { results: rules } = await env.DB
475
+ .prepare(
476
+ `SELECT id, channel, subject_template, message_template
477
+ FROM automation_rules
478
+ WHERE trigger_event = ?1 AND is_active = 1`
479
+ )
480
+ .bind(eventName)
481
+ .all();
482
+
483
+ if (!rules || rules.length === 0) return;
484
+
485
+ const vars = {
486
+ name: String(payload.firstName || payload.name || ''),
487
+ email: String(payload.email || ''),
488
+ phone: String(payload.phone || ''),
489
+ campaign: String(payload.utm_campaign || payload.utmCampaign || ''),
490
+ intention: String(payload.intentionLevel || payload.intention_level || ''),
491
+ };
492
+
493
+ const interpolate = (tpl) =>
494
+ tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? '');
495
+
496
+ for (const rule of rules) {
497
+ const message = interpolate(rule.message_template);
498
+ const subject = rule.subject_template ? interpolate(rule.subject_template) : null;
499
+
500
+ try {
501
+ if (rule.channel === 'whatsapp' && payload.phone && env.WHATSAPP_TOKEN && env.WHATSAPP_PHONE_NUMBER_ID) {
502
+ const digits = String(payload.phone).replace(/\D/g, '');
503
+ const e164 = digits.startsWith('55') ? `+${digits}` : `+55${digits}`;
504
+ const waRes = await fetch(
505
+ `https://graph.facebook.com/v22.0/${env.WHATSAPP_PHONE_NUMBER_ID}/messages`,
506
+ {
507
+ method: 'POST',
508
+ headers: { 'Authorization': `Bearer ${env.WHATSAPP_TOKEN}`, 'Content-Type': 'application/json' },
509
+ body: JSON.stringify({ messaging_product: 'whatsapp', recipient_type: 'individual', to: e164, type: 'text', text: { body: message } }),
510
+ }
511
+ );
512
+ const waData = await waRes.json();
513
+ const status = waRes.ok ? 'sent' : 'failed';
514
+ const meta = waRes.ok ? (waData.messages?.[0]?.id ?? null) : JSON.stringify(waData);
515
+ await env.DB.prepare(
516
+ `INSERT INTO messaging_history (lead_id, channel, recipient, subject, content, status, meta) VALUES (?1,?2,?3,?4,?5,?6,?7)`
517
+ ).bind(leadId, 'whatsapp', e164, null, message, status, meta).run();
518
+
519
+ } else if (rule.channel === 'email' && payload.email && env.RESEND_API_KEY) {
520
+ const resendRes = await fetch('https://api.resend.com/emails', {
521
+ method: 'POST',
522
+ headers: { 'Authorization': `Bearer ${env.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
523
+ body: JSON.stringify({
524
+ from: env.RESEND_FROM_EMAIL || 'noreply@cdp-edge.app',
525
+ to: [payload.email],
526
+ subject: subject || `Olá, ${vars.name || 'você'}!`,
527
+ html: `<p>${message.replace(/\n/g, '<br>')}</p>`,
528
+ }),
529
+ });
530
+ const resendData = await resendRes.json();
531
+ const status = resendRes.ok ? 'sent' : 'failed';
532
+ const meta = resendRes.ok ? (resendData.id ?? null) : JSON.stringify(resendData);
533
+ await env.DB.prepare(
534
+ `INSERT INTO messaging_history (lead_id, channel, recipient, subject, content, status, meta) VALUES (?1,?2,?3,?4,?5,?6,?7)`
535
+ ).bind(leadId, 'email', payload.email, subject, message, status, meta).run();
536
+ }
537
+ } catch (err) {
538
+ console.error(`[Automation] rule ${rule.id} error:`, err.message);
539
+ }
540
+ }
541
+ } catch (err) {
542
+ console.error('[Automation] fireAutomation error:', err.message);
543
+ }
544
+ }
545
+
546
+ // ── D1 — buscar perfil por email (para webhooks) ──────────────────────────────
547
+ async function getProfileByEmail(env, email) {
548
+ if (!env.DB || !email) return null;
549
+ try {
550
+ return await env.DB.prepare(
551
+ 'SELECT * FROM user_profiles WHERE email = ? ORDER BY updated_at DESC LIMIT 1'
552
+ ).bind(email.toLowerCase().trim()).first();
553
+ } catch {
554
+ return null;
555
+ }
556
+ }
557
+
558
+ // ─────────────────────────────────────────────────────────────────────────────
559
+ // EDGE FINGERPRINT — UTM Resurrection
560
+ // ─────────────────────────────────────────────────────────────────────────────
561
+
562
+ // ── Edge Geo Enrichment — Workers Paid: cidade, estado, CEP, lat/lon ─────────
563
+ // Free tier: cf.country, cf.continent, cf.asn (sempre disponíveis)
564
+ // Workers Paid: cf.city, cf.region, cf.regionCode, cf.postalCode,
565
+ // cf.latitude, cf.longitude, cf.timezone, cf.metroCode
566
+ // Quando Workers Paid não está ativo, os campos Paid retornam undefined
567
+ // e o payload é enriquecido apenas com os dados Free disponíveis.
568
+ // Ao contratar Workers Paid ($5/mês) os dados passam a chegar automaticamente.
569
+ async function enrichGeoFromEdge(request, env, payload) {
570
+ const cf = request.cf || {};
571
+ const ip = request.headers.get('CF-Connecting-IP') || '';
572
+
573
+ // ── Tentar cache KV (TTL 1h) — evita lookup redundante por IP ────────────
574
+ let geoData = null;
575
+ if (env.GEO_CACHE && ip) {
576
+ try {
577
+ const cached = await env.GEO_CACHE.get(`geo:${ip}`, 'json');
578
+ if (cached) geoData = cached;
579
+ } catch {}
580
+ }
581
+
582
+ if (!geoData) {
583
+ geoData = {
584
+ // ── Free tier (sempre disponível) ──────────────────────────────────────
585
+ country: cf.country || null, // 'BR', 'US'
586
+ continent: cf.continent || null, // 'SA', 'NA'
587
+ asn: cf.asn || null, // 7628
588
+ asOrg: cf.asOrganization || null, // 'VIVO (Telefonica Brasil)'
589
+ colo: cf.colo || null, // 'GRU' (datacenter mais próximo)
590
+ // ── Workers Paid ($5/mês) ───────────────────────────────────────────────
591
+ city: cf.city || null, // 'São Paulo'
592
+ region: cf.region || null, // 'São Paulo' (nome do estado)
593
+ regionCode: cf.regionCode || null, // 'SP'
594
+ postalCode: cf.postalCode || null, // '01310-100'
595
+ latitude: cf.latitude || null, // '-23.5505'
596
+ longitude: cf.longitude || null, // '-46.6333'
597
+ timezone: cf.timezone || null, // 'America/Sao_Paulo'
598
+ metroCode: cf.metroCode || null, // código de área metropolitana
599
+ };
600
+
601
+ // Salvar no KV por 1h (geo de IP raramente muda)
602
+ if (env.GEO_CACHE && ip && geoData.country) {
603
+ try {
604
+ await env.GEO_CACHE.put(`geo:${ip}`, JSON.stringify(geoData), { expirationTtl: 3600 });
605
+ } catch {}
606
+ }
607
+ }
608
+
609
+ // ── Enriquecer payload (edge tem prioridade menor que dados do browser) ───
610
+ payload.country = payload.country || geoData.country;
611
+ payload.city = payload.city || geoData.city;
612
+ payload.state = payload.state || geoData.regionCode; // 'SP', 'RJ'
613
+ payload.zip = payload.zip || geoData.postalCode;
614
+
615
+ // Objeto completo disponível para agentes (Analytics, Attribution, LTV)
616
+ payload.geo = geoData;
617
+
618
+ return geoData;
619
+ }
620
+
621
+ // ── R2 Audit Log — grava evento consolidado no bucket cdp-edge-logs ──────────
622
+ // Só executa quando env.AUDIT_LOGS estiver disponível (R2 habilitado no CF Dashboard)
623
+ // Chamado via ctx.waitUntil — não bloqueia resposta ao browser
624
+ async function writeAuditLog(env, eventName, payload, geoData) {
625
+ if (!env.AUDIT_LOGS) return;
626
+ try {
627
+ const now = new Date();
628
+ const y = now.getUTCFullYear();
629
+ const m = String(now.getUTCMonth() + 1).padStart(2, '0');
630
+ const d = String(now.getUTCDate()).padStart(2, '0');
631
+ const key = `logs/${y}/${m}/${d}/${now.getTime()}_${eventName}.json`;
632
+
633
+ const log = {
634
+ timestamp: now.toISOString(),
635
+ event: eventName,
636
+ userId: payload.userId || null,
637
+ eventId: payload.eventId || null,
638
+ value: payload.value || null,
639
+ currency: payload.currency || null,
640
+ ltvClass: payload.ltvClass || null,
641
+ utm: {
642
+ source: payload.utmSource || null,
643
+ medium: payload.utmMedium || null,
644
+ campaign: payload.utmCampaign || null,
645
+ content: payload.utmContent || null,
646
+ term: payload.utmTerm || null,
647
+ restored: payload.utmRestored || false,
648
+ },
649
+ geo: geoData || null,
650
+ };
651
+
652
+ await env.AUDIT_LOGS.put(key, JSON.stringify(log), {
653
+ httpMetadata: { contentType: 'application/json' },
654
+ });
655
+ } catch (err) {
656
+ console.error('[R2 Audit] Error:', err.message);
657
+ }
658
+ }
659
+
660
+ // ── Gerar fingerprint a partir de signals de borda (sem PII) ─────────────────
661
+ // Combina: ASN (rede do usuário) + Accept-Language + base do User-Agent
662
+ // Efêmero por design: identifica o dispositivo/rede, não a pessoa.
663
+ async function generateEdgeFingerprint(request) {
664
+ const asn = String(request.cf?.asn || '0');
665
+ const lang = (request.headers.get('Accept-Language') || 'unknown').split(',')[0].trim();
666
+ const ua = request.headers.get('User-Agent') || '';
667
+
668
+ // Base do UA: apenas plataforma + browser (sem versão — mais estável entre sessões)
669
+ // Ex: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
670
+ // → "windows applewebkit"
671
+ const uaBase = ua
672
+ .toLowerCase()
673
+ .replace(/[\d.]+/g, '') // remove números de versão
674
+ .replace(/[^a-z\s]/g, ' ') // remove caracteres especiais
675
+ .split(' ')
676
+ .filter(w => w.length > 3) // só palavras com >3 chars (remove ruído)
677
+ .slice(0, 4) // pega até 4 tokens
678
+ .join(' ')
679
+ .trim();
680
+
681
+ const raw = `${asn}|${lang}|${uaBase}`;
682
+ return sha256(raw);
683
+ }
684
+
685
+ // ── Salvar fingerprint + UTM no D1 (quando UTM presente) ─────────────────────
686
+ async function saveEdgeFingerprint(DB, fingerprint, userId, payload) {
687
+ if (!DB || !fingerprint) return;
688
+ const { utmSource, utmMedium, utmCampaign, utmContent, utmTerm } = payload;
689
+ if (!utmSource) return; // só salva quando há UTM real
690
+
691
+ try {
692
+ await DB.prepare(`
693
+ INSERT INTO edge_fingerprints (fingerprint, user_id, utm_source, utm_medium, utm_campaign, utm_content, utm_term)
694
+ VALUES (?, ?, ?, ?, ?, ?, ?)
695
+ `).bind(
696
+ fingerprint,
697
+ userId || null,
698
+ utmSource || null,
699
+ utmMedium || null,
700
+ utmCampaign || null,
701
+ utmContent || null,
702
+ utmTerm || null,
703
+ ).run();
704
+ } catch (err) {
705
+ console.error('saveEdgeFingerprint error:', err.message);
706
+ }
707
+ }
708
+
709
+ // ── Recuperar UTM perdida por fingerprint (últimas 48h) ───────────────────────
710
+ async function resurrectUTM(DB, fingerprint) {
711
+ if (!DB || !fingerprint) return null;
712
+ try {
713
+ return await DB.prepare(`
714
+ SELECT utm_source, utm_medium, utm_campaign, utm_content, utm_term
715
+ FROM edge_fingerprints
716
+ WHERE fingerprint = ?
717
+ AND utm_source IS NOT NULL
718
+ AND created_at > datetime('now', '-48 hours')
719
+ ORDER BY created_at DESC
720
+ LIMIT 1
721
+ `).bind(fingerprint).first();
722
+ } catch {
723
+ return null;
724
+ }
725
+ }
726
+
727
+ // ── TikTok Events API v1.3 ───────────────────────────────────────────────────
728
+ async function sendTikTokApi(env, eventName, payload, request, ctx) {
729
+ if (!env.TIKTOK_ACCESS_TOKEN) return { skipped: 'TIKTOK_ACCESS_TOKEN not set' };
730
+
731
+ const pixelId = env.TIKTOK_PIXEL_ID || TIKTOK_PIXEL_ID;
732
+ if (!pixelId || pixelId === 'SEU_TIKTOK_PIXEL_ID') return { skipped: 'TIKTOK_PIXEL_ID not configured' };
733
+
734
+ const {
735
+ email, phone, firstName, lastName,
736
+ fbp, fbc, ttp, ttclid, userId,
737
+ eventId, pageUrl,
738
+ value, currency,
739
+ contentIds, contentName, contentType,
740
+ } = payload;
741
+
742
+ const phoneNorm = normalizePhone(phone);
743
+
744
+ // user — hashear com SHA-256
745
+ const user = {
746
+ ...(email && { email: await sha256(email) }),
747
+ ...(phoneNorm && { phone_number: await sha256(phoneNorm) }),
748
+ ...(userId && { external_id: await sha256(String(userId)) }),
749
+ ...(ttp && { ttp }), // _ttp cookie — não hashear
750
+ ...(ttclid && { ttclid }), // click ID — não hashear
751
+ };
752
+
753
+ // properties — dados do produto
754
+ const properties = {
755
+ ...(value !== undefined && { value: parseFloat(value) }),
756
+ ...(currency && { currency: String(currency).toUpperCase() }),
757
+ ...(contentIds && contentIds.length > 0 && {
758
+ contents: contentIds.map(id => ({
759
+ content_id: String(id),
760
+ content_name: contentName || '',
761
+ content_type: contentType || 'product',
762
+ quantity: 1,
763
+ price: value ? parseFloat(value) : 0,
764
+ })),
765
+ }),
766
+ };
767
+
768
+ const event = {
769
+ event: eventName,
770
+ event_time: Math.floor(Date.now() / 1000),
771
+ event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
772
+ user,
773
+ page: {
774
+ url: pageUrl || 'https://server-edge-tracker.suporte-ed9.workers.dev',
775
+ referrer: request.headers.get('Referer') || '',
776
+ },
777
+ ...(Object.keys(properties).length > 0 && { properties }),
778
+ context: {
779
+ ip: request.headers.get('CF-Connecting-IP') || '',
780
+ user_agent: request.headers.get('User-Agent') || '',
781
+ },
782
+ };
783
+
784
+ const body = {
785
+ event_source: 'web',
786
+ event_source_id: pixelId,
787
+ data: [event],
788
+ };
789
+
790
+ // Endpoint obrigatório: sempre /v1.3/event/track/ (nunca /pixel/track/)
791
+ const endpoint = 'https://business-api.tiktok.com/open_api/v1.3/event/track/';
792
+
793
+ try {
794
+ const res = await fetch(endpoint, {
795
+ method: 'POST',
796
+ headers: {
797
+ 'Content-Type': 'application/json',
798
+ 'Access-Token': env.TIKTOK_ACCESS_TOKEN,
799
+ },
800
+ body: JSON.stringify(body),
801
+ });
802
+
803
+ const data = await res.json();
804
+ if (!res.ok || data.code !== 0) {
805
+ console.error('TikTok Events API error:', JSON.stringify(data));
806
+
807
+ // Log de falha para Feedback Loop
808
+ if (env.DB && ctx) {
809
+ ctx.waitUntil(logApiFailure(
810
+ env.DB,
811
+ 'tiktok',
812
+ eventName,
813
+ String(data.code || res.status),
814
+ data.message || 'TikTok API error',
815
+ event.event_id,
816
+ JSON.stringify(body)
817
+ ));
818
+ }
819
+ }
820
+ return data;
821
+ } catch (err) {
822
+ console.error('TikTok Events API fetch failed:', err.message);
823
+
824
+ // Log de falha para Feedback Loop
825
+ if (env.DB && ctx) {
826
+ ctx.waitUntil(logApiFailure(
827
+ env.DB,
828
+ 'tiktok',
829
+ eventName,
830
+ 'FETCH_ERROR',
831
+ err.message,
832
+ null,
833
+ JSON.stringify(body)
834
+ ));
835
+ }
836
+
837
+ return { error: err.message };
838
+ }
839
+ }
840
+
841
+
842
+ // ── Pinterest — Conversions API v5 (server-side) — TEMPLATE (não ativo em prod)
843
+ // Para ativar: configurar PINTEREST_ACCESS_TOKEN e PINTEREST_AD_ACCOUNT_ID
844
+ // e descomentar a chamada no Promise.allSettled do /track
845
+ //
846
+ async function sendPinterestCapi(env, eventName, payload, request, ctx) {
847
+ if (!env.PINTEREST_ACCESS_TOKEN || !env.PINTEREST_AD_ACCOUNT_ID) {
848
+ return { skipped: 'Pinterest credentials not set' };
849
+ }
850
+
851
+ const {
852
+ email, phone, userId,
853
+ eventId, pageUrl,
854
+ value, currency,
855
+ contentIds, contentName,
856
+ } = payload;
857
+
858
+ const phoneNorm = normalizePhone(phone);
859
+
860
+ const pinterestEventMap = {
861
+ PageView: 'pagevisit',
862
+ ViewContent: 'pagevisit',
863
+ Lead: 'lead',
864
+ Purchase: 'checkout',
865
+ AddToCart: 'addtocart',
866
+ InitiateCheckout: 'checkout',
867
+ CompleteRegistration: 'signup',
868
+ Search: 'search',
869
+ Contact: 'lead',
870
+ };
871
+ const pEvent = pinterestEventMap[eventName] || 'custom';
872
+
873
+ const userData = {
874
+ ...(email && { em: [await sha256(email)] }),
875
+ ...(phoneNorm && { ph: [await sha256(phoneNorm)] }),
876
+ ...(userId && { external_id: [await sha256(String(userId))] }),
877
+ client_ip_address: request.headers.get('CF-Connecting-IP') || '',
878
+ client_user_agent: request.headers.get('User-Agent') || '',
879
+ };
880
+
881
+ const body = {
882
+ data: [{
883
+ event_name: pEvent,
884
+ action_source: 'web',
885
+ event_time: Math.floor(Date.now() / 1000),
886
+ event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
887
+ event_source_url: pageUrl || '',
888
+ user_data: userData,
889
+ custom_data: {
890
+ currency: (currency || 'BRL').toUpperCase(),
891
+ value: value ? String(parseFloat(value)) : '0',
892
+ ...(contentIds?.length > 0 && { content_ids: contentIds.map(String) }),
893
+ ...(contentName && { content_name: contentName }),
894
+ content_type: 'product',
895
+ },
896
+ }],
897
+ };
898
+
899
+ try {
900
+ const res = await fetch(
901
+ `https://api.pinterest.com/v5/ad_accounts/${env.PINTEREST_AD_ACCOUNT_ID}/events`,
902
+ {
903
+ method: 'POST',
904
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.PINTEREST_ACCESS_TOKEN}` },
905
+ body: JSON.stringify(body),
906
+ }
907
+ );
908
+ const data = await res.json();
909
+ if (!res.ok) {
910
+ console.error('Pinterest CAPI error:', JSON.stringify(data));
911
+ if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'pinterest', eventName, String(res.status), JSON.stringify(data), body.data[0].event_id, JSON.stringify(body)));
912
+ }
913
+ return data;
914
+ } catch (err) {
915
+ console.error('Pinterest CAPI fetch failed:', err.message);
916
+ if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'pinterest', eventName, 'FETCH_ERROR', err.message, null, JSON.stringify(body)));
917
+ return { error: err.message };
918
+ }
919
+ }
920
+
921
+
922
+ // ── Reddit — Conversions API v2.0 (server-side) ───────────────────────────────
923
+ //
924
+ // Secrets necessários (wrangler secret put):
925
+ // REDDIT_ACCESS_TOKEN → Bearer token da Reddit Conversions API
926
+ // REDDIT_AD_ACCOUNT_ID → ID da conta de anúncios (ex: t2_XXXXXXX)
927
+ //
928
+ async function sendRedditCapi(env, eventName, payload, request, ctx) {
929
+ if (!env.REDDIT_ACCESS_TOKEN || !env.REDDIT_AD_ACCOUNT_ID) {
930
+ return { skipped: 'Reddit credentials not set' };
931
+ }
932
+
933
+ const {
934
+ email, phone, userId,
935
+ eventId, pageUrl,
936
+ value, currency,
937
+ } = payload;
938
+
939
+ const phoneNorm = normalizePhone(phone);
940
+
941
+ const redditEventMap = {
942
+ PageView: 'PageVisit',
943
+ ViewContent: 'ViewContent',
944
+ Lead: 'Lead',
945
+ Purchase: 'Purchase',
946
+ AddToCart: 'AddToCart',
947
+ InitiateCheckout: 'Purchase',
948
+ CompleteRegistration: 'SignUp',
949
+ Search: 'Search',
950
+ Contact: 'Lead',
951
+ };
952
+ const rEvent = redditEventMap[eventName] || 'Custom';
953
+
954
+ const user = {
955
+ ...(email && { email: { value: await sha256(email) } }),
956
+ ...(phoneNorm && { phoneNumber: { value: await sha256(phoneNorm) } }),
957
+ ...(userId && { externalId: { value: await sha256(String(userId)) } }),
958
+ ipAddress: { value: request.headers.get('CF-Connecting-IP') || '' },
959
+ userAgent: { value: request.headers.get('User-Agent') || '' },
960
+ };
961
+
962
+ const event = {
963
+ event_at: new Date().toISOString(),
964
+ event_type: { tracking_type: rEvent, ...(eventName === 'InitiateCheckout' && { conversion_type: 'BEGIN_CHECKOUT' }) },
965
+ click_id: payload.rdtClid || '',
966
+ event_metadata: {
967
+ currency: (currency || 'BRL').toUpperCase(),
968
+ value_decimal: String(value || 0),
969
+ item_count: '1',
970
+ conversion_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
971
+ },
972
+ user,
973
+ };
974
+
975
+ const body = { events: [event] };
976
+
977
+ try {
978
+ const res = await fetch(
979
+ `https://ads-api.reddit.com/api/v2.0/conversions/events/${env.REDDIT_AD_ACCOUNT_ID}`,
980
+ {
981
+ method: 'POST',
982
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.REDDIT_ACCESS_TOKEN}` },
983
+ body: JSON.stringify(body),
984
+ }
985
+ );
986
+ if (!res.ok) {
987
+ const txt = await res.text();
988
+ console.error('Reddit CAPI error:', txt);
989
+ if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'reddit', eventName, String(res.status), txt, event.event_metadata.conversion_id, JSON.stringify(body)));
990
+ return { error: `HTTP ${res.status}` };
991
+ }
992
+ const data = await res.json();
993
+ return data;
994
+ } catch (err) {
995
+ console.error('Reddit CAPI fetch failed:', err.message);
996
+ if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'reddit', eventName, 'FETCH_ERROR', err.message, null, JSON.stringify(body)));
997
+ return { error: err.message };
998
+ }
999
+ }
1000
+
1001
+
1002
+ // ── LinkedIn — Conversions API v2 (server-side) ───────────────────────────────
1003
+ //
1004
+ // Secrets necessários (wrangler secret put):
1005
+ // LINKEDIN_ACCESS_TOKEN → OAuth2 Bearer token da LinkedIn Marketing API
1006
+ // LINKEDIN_CONVERSION_ID → ID da conversão (URN: urn:li:conversion:XXXXXXXXX)
1007
+ // LINKEDIN_AD_ACCOUNT_ID → ID da conta de anúncios (ex: 123456789)
1008
+ //
1009
+ async function sendLinkedInCapi(env, eventName, payload, request, ctx) {
1010
+ if (!env.LINKEDIN_ACCESS_TOKEN || !env.LINKEDIN_CONVERSION_ID) {
1011
+ return { skipped: 'LinkedIn credentials not set' };
1012
+ }
1013
+
1014
+ const {
1015
+ email, phone, firstName, lastName, userId,
1016
+ eventId, pageUrl,
1017
+ value, currency,
1018
+ } = payload;
1019
+
1020
+ const phoneNorm = normalizePhone(phone);
1021
+
1022
+ // LinkedIn só suporta conversões (Lead, Purchase, CompleteRegistration)
1023
+ const linkedInEventMap = {
1024
+ Lead: 'LEAD',
1025
+ Purchase: 'PURCHASE',
1026
+ CompleteRegistration: 'REGISTRATION',
1027
+ AddToCart: 'ADD_TO_CART',
1028
+ InitiateCheckout: 'OTHER',
1029
+ ViewContent: 'OTHER',
1030
+ PageView: 'OTHER',
1031
+ Contact: 'LEAD',
1032
+ };
1033
+ const liEvent = linkedInEventMap[eventName] || 'OTHER';
1034
+
1035
+ // user — SHA-256 em campos PII
1036
+ const userInfo = {
1037
+ ...(email && { 'SHA256_EMAIL': await sha256(email) }),
1038
+ ...(phoneNorm && { 'SHA256_PHONE': await sha256(phoneNorm) }),
1039
+ ...(firstName && { 'SHA256_FIRST_NAME': await sha256(firstName.toLowerCase().trim()) }),
1040
+ ...(lastName && { 'SHA256_LAST_NAME': await sha256(lastName.toLowerCase().trim()) }),
1041
+ };
1042
+
1043
+ const body = {
1044
+ conversion: `urn:li:conversion:${env.LINKEDIN_CONVERSION_ID}`,
1045
+ conversionHappenedAt: Date.now(),
1046
+ conversionValue: value ? { currencyCode: (currency || 'BRL').toUpperCase(), amount: String(parseFloat(value)) } : undefined,
1047
+ eventId: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
1048
+ ...(Object.keys(userInfo).length > 0 && { user: { userIds: Object.entries(userInfo).map(([idType, idValue]) => ({ idType, idValue })) } }),
1049
+ };
1050
+
1051
+ try {
1052
+ const res = await fetch(
1053
+ 'https://api.linkedin.com/rest/conversionEvents',
1054
+ {
1055
+ method: 'POST',
1056
+ headers: {
1057
+ 'Content-Type': 'application/json',
1058
+ Authorization: `Bearer ${env.LINKEDIN_ACCESS_TOKEN}`,
1059
+ 'LinkedIn-Version': '202405',
1060
+ 'X-Restli-Protocol-Version': '2.0.0',
1061
+ },
1062
+ body: JSON.stringify(body),
1063
+ }
1064
+ );
1065
+ if (!res.ok) {
1066
+ const txt = await res.text();
1067
+ console.error('LinkedIn CAPI error:', txt);
1068
+ if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'linkedin', eventName, String(res.status), txt, body.eventId, JSON.stringify(body)));
1069
+ return { error: `HTTP ${res.status}` };
1070
+ }
1071
+ return { ok: true };
1072
+ } catch (err) {
1073
+ console.error('LinkedIn CAPI fetch failed:', err.message);
1074
+ if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'linkedin', eventName, 'FETCH_ERROR', err.message, null, JSON.stringify(body)));
1075
+ return { error: err.message };
1076
+ }
1077
+ }
1078
+
1079
+
1080
+ // ── Spotify — Conversions API v1 (server-side) ────────────────────────────────
1081
+ //
1082
+ // Secrets necessários (wrangler secret put):
1083
+ // SPOTIFY_ACCESS_TOKEN → Bearer token da Spotify Advertising API
1084
+ // SPOTIFY_AD_ACCOUNT_ID → ID da conta de anúncios Spotify Ads
1085
+ //
1086
+ async function sendSpotifyCapi(env, eventName, payload, request, ctx) {
1087
+ if (!env.SPOTIFY_ACCESS_TOKEN || !env.SPOTIFY_AD_ACCOUNT_ID) {
1088
+ return { skipped: 'Spotify credentials not set' };
1089
+ }
1090
+
1091
+ const {
1092
+ email, phone, userId,
1093
+ eventId, pageUrl,
1094
+ value, currency,
1095
+ } = payload;
1096
+
1097
+ const phoneNorm = normalizePhone(phone);
1098
+
1099
+ const spotifyEventMap = {
1100
+ Purchase: 'PURCHASE',
1101
+ Lead: 'LEAD',
1102
+ CompleteRegistration: 'SIGN_UP',
1103
+ AddToCart: 'ADD_TO_CART',
1104
+ InitiateCheckout: 'INITIATE_CHECKOUT',
1105
+ ViewContent: 'VIEW_CONTENT',
1106
+ PageView: 'PAGE_VIEW',
1107
+ Contact: 'LEAD',
1108
+ };
1109
+ const spEvent = spotifyEventMap[eventName] || 'CUSTOM';
1110
+
1111
+ // user — SHA-256 em campos PII
1112
+ const user = {
1113
+ ...(email && { hashed_email: await sha256(email) }),
1114
+ ...(phoneNorm && { hashed_phone: await sha256(phoneNorm) }),
1115
+ ...(userId && { user_id: userId }),
1116
+ ip_address: request.headers.get('CF-Connecting-IP') || '',
1117
+ user_agent: request.headers.get('User-Agent') || '',
1118
+ };
1119
+
1120
+ const body = {
1121
+ data: [{
1122
+ event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
1123
+ event_type: spEvent,
1124
+ event_time: Math.floor(Date.now() / 1000),
1125
+ url: pageUrl || '',
1126
+ user,
1127
+ ...(value !== undefined && {
1128
+ value: {
1129
+ currency: (currency || 'BRL').toUpperCase(),
1130
+ amount: parseFloat(value),
1131
+ },
1132
+ }),
1133
+ }],
1134
+ };
1135
+
1136
+ try {
1137
+ const res = await fetch(
1138
+ `https://advertising-api.spotify.com/conversion/v1/accounts/${env.SPOTIFY_AD_ACCOUNT_ID}/events`,
1139
+ {
1140
+ method: 'POST',
1141
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.SPOTIFY_ACCESS_TOKEN}` },
1142
+ body: JSON.stringify(body),
1143
+ }
1144
+ );
1145
+ if (!res.ok) {
1146
+ const txt = await res.text();
1147
+ console.error('Spotify CAPI error:', txt);
1148
+ if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'spotify', eventName, String(res.status), txt, body.data[0].event_id, JSON.stringify(body)));
1149
+ return { error: `HTTP ${res.status}` };
1150
+ }
1151
+ const data = await res.json();
1152
+ return data;
1153
+ } catch (err) {
1154
+ console.error('Spotify CAPI fetch failed:', err.message);
1155
+ if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'spotify', eventName, 'FETCH_ERROR', err.message, null, JSON.stringify(body)));
1156
+ return { error: err.message };
1157
+ }
1158
+ }
1159
+
1160
+
1161
+ // ── WhatsApp — Meta Cloud API v22.0 (notificações ao dono) ───────────────────
1162
+ //
1163
+ // Secrets necessários (wrangler secret put):
1164
+ // WA_PHONE_ID → ID do número no WhatsApp Business (ex: 123456789012345)
1165
+ // WA_ACCESS_TOKEN → Token permanente da Meta Cloud API
1166
+ // WA_NOTIFY_NUMBER → Número do dono para receber notificações (ex: 5511999998888)
1167
+ //
1168
+ // Usado para: avisos de Nova Venda e Novo Lead em tempo real.
1169
+ //
1170
+ async function sendWhatsApp(env, tipo, payload) {
1171
+ if (!env.WA_PHONE_ID || !env.WA_ACCESS_TOKEN || !env.WA_NOTIFY_NUMBER) {
1172
+ return { skipped: 'WhatsApp não configurado' };
1173
+ }
1174
+
1175
+ const nome = [payload.firstName, payload.lastName].filter(Boolean).join(' ') || 'sem nome';
1176
+ const valor = payload.value ? `R$ ${parseFloat(payload.value).toFixed(2)}` : '—';
1177
+ const utm = payload.utmSource || 'direto';
1178
+ const produto = payload.contentName || '';
1179
+
1180
+ let texto = '';
1181
+ if (tipo === 'Purchase') {
1182
+ texto =
1183
+ `🛒 *Nova Venda!*\n\n` +
1184
+ `👤 ${nome}\n` +
1185
+ `📧 ${payload.email || '—'}\n` +
1186
+ `📱 ${payload.phone || '—'}\n` +
1187
+ `💰 ${valor}\n` +
1188
+ (produto ? `📦 ${produto}\n` : '') +
1189
+ `🔗 UTM: ${utm}\n` +
1190
+ `🕐 ${new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' })}`;
1191
+ } else if (tipo === 'Lead') {
1192
+ texto =
1193
+ `📋 *Novo Lead!*\n\n` +
1194
+ `📧 ${payload.email || '—'}\n` +
1195
+ `🔗 UTM: ${utm}\n` +
1196
+ `🌐 ${payload.pageUrl || '—'}\n` +
1197
+ `🕐 ${new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' })}`;
1198
+ } else {
1199
+ return { skipped: `tipo ${tipo} não notificado` };
1200
+ }
1201
+
1202
+ try {
1203
+ const res = await fetch(`https://graph.facebook.com/v22.0/${env.WA_PHONE_ID}/messages`, {
1204
+ method: 'POST',
1205
+ headers: {
1206
+ 'Content-Type': 'application/json',
1207
+ 'Authorization': `Bearer ${env.WA_ACCESS_TOKEN}`,
1208
+ },
1209
+ body: JSON.stringify({
1210
+ messaging_product: 'whatsapp',
1211
+ to: env.WA_NOTIFY_NUMBER,
1212
+ type: 'text',
1213
+ text: { body: texto },
1214
+ }),
1215
+ });
1216
+ return { ok: res.ok, status: res.status };
1217
+ } catch (err) {
1218
+ console.error('WhatsApp Meta API failed:', err.message);
1219
+ return { ok: false, error: err.message };
1220
+ }
1221
+ }
1222
+
1223
+ // ── CallMeBot — Alertas de sistema (falhas Cloudflare, API down, erros críticos)
1224
+ //
1225
+ // Secrets necessários (wrangler secret put):
1226
+ // CALLMEBOT_PHONE → Número do admin no formato internacional (ex: +5511999998888)
1227
+ // CALLMEBOT_APIKEY → API Key gerada pelo CallMeBot (ativar via WhatsApp: wa.me/34638398527)
1228
+ //
1229
+ // Usado para: alertas internos do sistema — Worker com erro, API falhando,
1230
+ // token expirado, D1 com problema. NÃO para mensagens a clientes.
1231
+ //
1232
+ async function sendCallMeBot(env, mensagem) {
1233
+ if (!env.CALLMEBOT_PHONE || !env.CALLMEBOT_APIKEY) {
1234
+ return { skipped: 'CallMeBot não configurado' };
1235
+ }
1236
+ try {
1237
+ const url = `https://api.callmebot.com/whatsapp.php?phone=${encodeURIComponent(env.CALLMEBOT_PHONE)}&text=${encodeURIComponent(mensagem)}&apikey=${env.CALLMEBOT_APIKEY}`;
1238
+ const res = await fetch(url);
1239
+ return { ok: res.ok, status: res.status };
1240
+ } catch (err) {
1241
+ console.error('CallMeBot failed:', err.message);
1242
+ return { ok: false, error: err.message };
1243
+ }
1244
+ }
1245
+
1246
+ // ── WhatsApp CTWA — Processa webhook de mensagem recebida ─────────────────────
1247
+ // Acionado em POST /webhook/whatsapp quando um usuário envia mensagem após
1248
+ // clicar em um anúncio "Click to WhatsApp" (CTWA) no Facebook/Instagram.
1249
+ //
1250
+ // O payload da Meta Cloud API inclui:
1251
+ // message.from → número do usuário (sem "+", ex: "5511999998888")
1252
+ // message.id (wamid) → ID único da mensagem (usado para deduplicação)
1253
+ // message.referral → dados do anúncio que gerou o clique:
1254
+ // .ctwa_clid → identificador do clique (equivalente ao fbclid para CTWA)
1255
+ // .source_id → ID do anúncio
1256
+ // .source_url → URL do anúncio no Facebook/Instagram
1257
+ // .headline → título do anúncio
1258
+ //
1259
+ // O evento enviado à Meta CAPI usa action_source="chat" (obrigatório para CTWA)
1260
+ // e inclui ctwa_clid em user_data (sem hash) junto com ph (phone hasheado).
1261
+ // Isso permite à Meta fechar o loop: clique no anúncio → conversa no WhatsApp.
1262
+ async function processWhatsAppWebhook(env, body, request, ctx) {
1263
+ const entry = body?.entry?.[0];
1264
+ const change = entry?.changes?.find(c => c.field === 'messages');
1265
+ if (!change) return { skipped: 'no messages field' };
1266
+
1267
+ const messages = change.value?.messages;
1268
+ if (!messages || messages.length === 0) return { skipped: 'no messages' };
1269
+
1270
+ const results = [];
1271
+
1272
+ for (const message of messages) {
1273
+ const phone = message.from; // ex: "5511999998888"
1274
+ const wamid = message.id; // ID único da mensagem
1275
+ const referral = message.referral || {};
1276
+ const ctwaClid = referral.ctwa_clid || null; // click ID do anúncio
1277
+ const adId = referral.source_id || null;
1278
+ const sourceUrl = referral.source_url || null;
1279
+ const headline = referral.headline || null;
1280
+ const messageBody = message.text?.body || message.type || '';
1281
+
1282
+ if (!phone) {
1283
+ results.push({ skipped: 'no phone' });
1284
+ continue;
1285
+ }
1286
+
1287
+ const phoneNorm = normalizePhone(phone) || phone;
1288
+ const phoneHash = await sha256(phoneNorm);
1289
+
1290
+ // Deduplicação — mesmo wamid não dispara duas vezes
1291
+ if (env.DB && wamid) {
1292
+ try {
1293
+ const existing = await env.DB.prepare(
1294
+ 'SELECT id FROM whatsapp_contacts WHERE wamid = ?'
1295
+ ).bind(wamid).first();
1296
+ if (existing) {
1297
+ results.push({ skipped: 'duplicate wamid', wamid });
1298
+ continue;
1299
+ }
1300
+ } catch { /* não bloquear se D1 falhar */ }
1301
+ }
1302
+
1303
+ const eventId = `ctwa_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
1304
+
1305
+ // Persistir contato no D1 (antes de enviar ao CAPI)
1306
+ if (env.DB) {
1307
+ ctx.waitUntil(
1308
+ env.DB.prepare(
1309
+ `INSERT OR IGNORE INTO whatsapp_contacts
1310
+ (phone_hash, phone_raw, wamid, ctwa_clid, ad_id, source_url, headline, capi_sent, capi_event_id, message_body)
1311
+ VALUES (?,?,?,?,?,?,?,0,?,?)`
1312
+ ).bind(phoneHash, phoneNorm, wamid || null, ctwaClid, adId,
1313
+ sourceUrl, headline, eventId, messageBody || null).run()
1314
+ );
1315
+ }
1316
+
1317
+ // Montar evento para Meta CAPI
1318
+ // action_source: "chat" é obrigatório para eventos originados no WhatsApp
1319
+ // ctwa_clid vai em user_data sem hash (a Meta exige assim)
1320
+ const capiEvent = {
1321
+ event_name: 'Contact',
1322
+ event_time: Math.floor(Date.now() / 1000),
1323
+ event_id: eventId,
1324
+ action_source: 'chat',
1325
+ user_data: {
1326
+ ph: phoneHash,
1327
+ ...(ctwaClid && { ctwa_clid: ctwaClid }),
1328
+ client_ip_address: request.headers.get('CF-Connecting-IP') || '',
1329
+ client_user_agent: request.headers.get('User-Agent') || '',
1330
+ },
1331
+ ...(sourceUrl && { event_source_url: sourceUrl }),
1332
+ };
1333
+
1334
+ const pixelId = env.META_PIXEL_ID || META_PIXEL_ID;
1335
+
1336
+ // Enviar ao Meta CAPI de forma assíncrona (não bloqueia a resposta ao WhatsApp)
1337
+ ctx.waitUntil(
1338
+ (async () => {
1339
+ try {
1340
+ const requestBody = {
1341
+ data: [capiEvent],
1342
+ access_token: env.META_ACCESS_TOKEN,
1343
+ };
1344
+ if (env.META_TEST_CODE) requestBody.test_event_code = env.META_TEST_CODE;
1345
+
1346
+ const res = await fetch(
1347
+ `https://graph.facebook.com/v22.0/${pixelId}/events`,
1348
+ {
1349
+ method: 'POST',
1350
+ headers: { 'Content-Type': 'application/json' },
1351
+ body: JSON.stringify(requestBody),
1352
+ }
1353
+ );
1354
+ const data = await res.json();
1355
+
1356
+ if (res.ok && env.DB && wamid) {
1357
+ await env.DB.prepare(
1358
+ 'UPDATE whatsapp_contacts SET capi_sent = 1 WHERE wamid = ?'
1359
+ ).bind(wamid).run();
1360
+ } else if (!res.ok) {
1361
+ console.error('[CTWA] Meta CAPI error:', JSON.stringify(data));
1362
+ if (env.DB) {
1363
+ await logApiFailure(env.DB, 'meta', 'Contact', data.error?.code || res.status,
1364
+ data.error?.message || 'CTWA CAPI error', eventId, JSON.stringify(requestBody));
1365
+ }
1366
+ }
1367
+ } catch (err) {
1368
+ console.error('[CTWA] Meta CAPI fetch failed:', err.message);
1369
+ }
1370
+ })()
1371
+ );
1372
+
1373
+ // Registrar também na tabela leads para aparecer no CRM Dashboard
1374
+ ctx.waitUntil(
1375
+ saveLead(env, 'Contact', {
1376
+ phone: phoneNorm,
1377
+ eventId: eventId,
1378
+ pageUrl: sourceUrl,
1379
+ utmSource: 'whatsapp_ctwa',
1380
+ utmMedium: 'paid_social',
1381
+ }, request, 'whatsapp')
1382
+ );
1383
+
1384
+ results.push({
1385
+ ok: true,
1386
+ phone: phoneNorm.slice(0, 4) + '****',
1387
+ ctwa_clid: ctwaClid ? 'present' : 'absent',
1388
+ event_id: eventId,
1389
+ });
1390
+ }
1391
+
1392
+ return { processed: results.length, results };
1393
+ }
1394
+
1395
+ // ── Verificação de assinatura HMAC-SHA256 (webhooks) ─────────────────────────
1396
+ async function verifyHmac(secret, rawBody, receivedSignature) {
1397
+ if (!secret || !receivedSignature) return false;
1398
+ try {
1399
+ const key = await crypto.subtle.importKey(
1400
+ 'raw',
1401
+ new TextEncoder().encode(secret),
1402
+ { name: 'HMAC', hash: 'SHA-256' },
1403
+ false,
1404
+ ['sign']
1405
+ );
1406
+ const sig = await crypto.subtle.sign(
1407
+ 'HMAC', key, new TextEncoder().encode(rawBody)
1408
+ );
1409
+ const computed = Array.from(new Uint8Array(sig))
1410
+ .map(b => b.toString(16).padStart(2, '0')).join('');
1411
+ // Comparação constant-time (evita timing attack)
1412
+ if (computed.length !== receivedSignature.length) return false;
1413
+ let diff = 0;
1414
+ for (let i = 0; i < computed.length; i++) {
1415
+ diff |= computed.charCodeAt(i) ^ receivedSignature.charCodeAt(i);
1416
+ }
1417
+ return diff === 0;
1418
+ } catch {
1419
+ return false;
1420
+ }
1421
+ }
1422
+
1423
+ // ── Mapa Meta → GA4 event names ───────────────────────────────────────────────
1424
+ const META_TO_GA4 = {
1425
+ PageView: 'page_view',
1426
+ ViewContent: 'view_item',
1427
+ Lead: 'generate_lead',
1428
+ Contact: 'generate_lead',
1429
+ Schedule: 'generate_lead',
1430
+ InitiateCheckout: 'begin_checkout',
1431
+ AddToCart: 'add_to_cart',
1432
+ AddPaymentInfo: 'add_payment_info',
1433
+ Purchase: 'purchase',
1434
+ CompleteRegistration: 'sign_up',
1435
+ Subscribe: 'subscribe',
1436
+ StartTrial: 'start_trial',
1437
+ Search: 'search',
1438
+ AddToWishlist: 'add_to_wishlist',
1439
+ };
1440
+
1441
+ // ── Persistir LTV no perfil D1 ───────────────────────────────────────────────
1442
+ async function upsertLtvProfile(env, userId, ltv) {
1443
+ if (!env.DB || !userId) return;
1444
+ try {
1445
+ await env.DB.prepare(`
1446
+ UPDATE user_profiles
1447
+ SET predicted_ltv_class = ?,
1448
+ predicted_ltv_value = ?,
1449
+ updated_at = datetime('now')
1450
+ WHERE user_id = ?
1451
+ `).bind(ltv.class, ltv.value, userId).run();
1452
+ } catch (err) {
1453
+ console.error('upsertLtvProfile error:', err.message);
1454
+ }
1455
+ }
1456
+
1457
+ // ─────────────────────────────────────────────────────────────────────────────
1458
+ // LTV PREDICTION — Valor Preditivo de Lifetime Value
1459
+ // ─────────────────────────────────────────────────────────────────────────────
1460
+
1461
+ /**
1462
+ * Prediz o LTV (Lifetime Value) de um lead no momento do primeiro contato.
1463
+ *
1464
+ * Modelo heurístico em 5 dimensões (0–100 pontos):
1465
+ * 1. Engajamento browser (0–30 pts) — engagement_score + user_score
1466
+ * 2. Origem de tráfego (0–25 pts) — UTM source (paid > organic > direct)
1467
+ * 3. Contexto de rede (0–15 pts) — ASN corporativo, país, hora do dia
1468
+ * 4. Contexto do evento (0–20 pts) — InitiateCheckout visto antes = +20
1469
+ * 5. Dados PII disponíveis (0–10 pts) — email + phone + nome = melhor match
1470
+ *
1471
+ * Retorna: { score, class, value }
1472
+ * score: 0–100
1473
+ * class: 'High' | 'Medium' | 'Low'
1474
+ * value: valor em BRL (base × multiplicador da classe)
1475
+ */
1476
+ async function predictLtv(env, payload, request) {
1477
+ let score = 0;
1478
+
1479
+ // 1. Engajamento browser (0–30)
1480
+ const engScore = parseFloat(payload.engagementScore || 0);
1481
+ const userScore = parseFloat(payload.userScore || 0);
1482
+ // engagement_score é 0-5 → normaliza para 0-15
1483
+ score += Math.min(15, Math.round((engScore / 5) * 15));
1484
+ // user_score é 0-100 → normaliza para 0-15
1485
+ score += Math.min(15, Math.round((userScore / 100) * 15));
1486
+
1487
+ // 2. Origem de tráfego (0–25)
1488
+ const src = (payload.utmSource || '').toLowerCase();
1489
+ const utm_score_map = {
1490
+ facebook: 25, instagram: 25, meta: 25,
1491
+ google: 22, youtube: 22, tiktok: 20, // youtube = mesmo nível do google search (alta intenção)
1492
+ email: 18, sms: 18,
1493
+ organic: 10, direct: 5,
1494
+ };
1495
+ const utmScore = utm_score_map[src] ?? (src ? 8 : 3);
1496
+ score += utmScore;
1497
+
1498
+ // 3. Contexto de rede (0–15)
1499
+ const hour = new Date().getUTCHours();
1500
+ const country = (payload.country || request.cf?.country || '').toUpperCase();
1501
+ const org = String(request.cf?.asOrganization || '').toLowerCase();
1502
+
1503
+ // Horário de alta conversão: 18h-23h BRT (21h-02h UTC) = +8
1504
+ const isHighConvTime = hour >= 21 || hour <= 2;
1505
+ score += isHighConvTime ? 8 : (hour >= 12 && hour <= 20 ? 4 : 1);
1506
+
1507
+ // País Brasil = público-alvo primário = +5; outros LATAM = +3
1508
+ const latam = ['AR', 'CL', 'CO', 'MX', 'PE', 'UY', 'PY', 'BO'];
1509
+ score += country === 'BR' ? 5 : (latam.includes(country) ? 3 : 1);
1510
+
1511
+ // ASN corporativo (empresa/datacenter = B2B = alto LTV) = +2
1512
+ const isCorp = /ltda|s\.a\.|corp|telecom|fibra|claro|vivo|tim|oi/.test(org);
1513
+ score += isCorp ? 2 : 0;
1514
+
1515
+ // 4. Contexto do evento (0–20)
1516
+ // InitiateCheckout visto antes deste Lead = usuário já na jornada de compra
1517
+ const intentionLevel = (payload.intentionLevel || '').toLowerCase();
1518
+ if (intentionLevel === 'comprador' || intentionLevel === 'high_intent') score += 20;
1519
+ else if (intentionLevel === 'interessado') score += 12;
1520
+ else if (intentionLevel === 'nurture') score += 6;
1521
+
1522
+ // 5. Dados PII disponíveis (0–10)
1523
+ if (payload.email) score += 4;
1524
+ if (payload.phone) score += 4;
1525
+ if (payload.firstName) score += 2;
1526
+
1527
+ score = Math.min(100, score);
1528
+
1529
+ // Classificação
1530
+ let ltvClass, ltvMultiplier;
1531
+ if (score >= 70) {
1532
+ ltvClass = 'High'; ltvMultiplier = 3.5;
1533
+ } else if (score >= 40) {
1534
+ ltvClass = 'Medium'; ltvMultiplier = 1.8;
1535
+ } else {
1536
+ ltvClass = 'Low'; ltvMultiplier = 0.8;
1537
+ }
1538
+
1539
+ // Valor base do produto (do payload) ou estimativa por classe
1540
+ const productValue = payload.value ? parseFloat(payload.value) : 0;
1541
+ const baseValue = productValue > 0 ? productValue : 197; // ticket médio padrão BR
1542
+ const predictedValue = Math.round(baseValue * ltvMultiplier * 100) / 100;
1543
+
1544
+ // Enriquecimento opcional via Workers AI (se binding disponível)
1545
+ // Usado apenas para ajuste fino do score — não bloqueia o fluxo principal
1546
+ let aiAdjustment = 0;
1547
+ if (env.AI && score >= 40) {
1548
+ try {
1549
+ const prompt = [
1550
+ { role: 'system', content: 'You are a conversion rate expert. Reply ONLY with a JSON object {"adjustment": <number between -10 and 10>} based on the lead data provided. No explanation.' },
1551
+ { role: 'user', content: JSON.stringify({
1552
+ utm_source: payload.utmSource,
1553
+ intention: intentionLevel,
1554
+ engagement: engScore,
1555
+ hour_utc: hour,
1556
+ country,
1557
+ has_email: !!payload.email,
1558
+ has_phone: !!payload.phone,
1559
+ })},
1560
+ ];
1561
+ const aiRes = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', { messages: prompt, max_tokens: 32 });
1562
+ const parsed = JSON.parse(aiRes.response.trim());
1563
+ if (typeof parsed.adjustment === 'number') {
1564
+ aiAdjustment = Math.max(-10, Math.min(10, parsed.adjustment));
1565
+ }
1566
+ } catch { /* graceful fallback — AI opcional */ }
1567
+ }
1568
+
1569
+ const finalScore = Math.min(100, Math.max(0, score + aiAdjustment));
1570
+
1571
+ return {
1572
+ score: finalScore,
1573
+ class: ltvClass,
1574
+ value: predictedValue,
1575
+ };
1576
+ }
1577
+
1578
+ // ─────────────────────────────────────────────────────────────────────────────
1579
+ // FEEDBACK LOOP — Monitoramento de Falhas e Saúde
1580
+ // ─────────────────────────────────────────────────────────────────────────────
1581
+
1582
+ // ── Log de Falha de API ─────────────────────────────────────────────────────
1583
+ async function logApiFailure(DB, platform, eventName, errorCode, errorMessage, eventId, rawPayload) {
1584
+ try {
1585
+ await DB.prepare(`
1586
+ INSERT INTO api_failures (platform, event_name, error_code, error_message, event_id, raw_payload, retry_count, final_status)
1587
+ VALUES (?, ?, ?, ?, ?, ?, 0, 'failed')
1588
+ `).bind(platform, eventName, String(errorCode), errorMessage, eventId, rawPayload).run();
1589
+ } catch (err) {
1590
+ console.error('Failed to log API failure:', err.message);
1591
+ }
1592
+ }
1593
+
1594
+ // ── Métricas de Saúde (últimas 24h) ─────────────────────────────────────────
1595
+ async function getHealthMetrics(DB, platform, hours = 24) {
1596
+ try {
1597
+ // Total de eventos com falha
1598
+ const failures = await DB.prepare(`
1599
+ SELECT COUNT(*) as count, error_code
1600
+ FROM api_failures
1601
+ WHERE platform = ? AND created_at > datetime('now', '-${hours} hours')
1602
+ GROUP BY error_code
1603
+ `).bind(platform).all();
1604
+
1605
+ // Total de eventos enviados (leads table)
1606
+ const totalSent = await DB.prepare(`
1607
+ SELECT COUNT(*) as count
1608
+ FROM leads
1609
+ WHERE platform = ? AND created_at > datetime('now', '-${hours} hours')
1610
+ `).bind(platform).first();
1611
+
1612
+ const totalFailed = failures.reduce((sum, f) => sum + f.count, 0);
1613
+ const successRate = totalSent?.count > 0
1614
+ ? ((totalSent.count - totalFailed) / totalSent.count) * 100
1615
+ : 100;
1616
+
1617
+ return {
1618
+ platform,
1619
+ hours,
1620
+ events_sent: totalSent?.count || 0,
1621
+ events_failed: totalFailed,
1622
+ success_rate: successRate,
1623
+ errors_detected: failures.map(f => ({ code: f.error_code, count: f.count })),
1624
+ issues: totalFailed > (totalSent?.count || 0) * 0.1 ? ['high_error_rate'] : [],
1625
+ };
1626
+ } catch (err) {
1627
+ console.error('Failed to get health metrics:', err.message);
1628
+ return {
1629
+ platform,
1630
+ hours,
1631
+ events_sent: 0,
1632
+ events_failed: 0,
1633
+ success_rate: 0,
1634
+ errors_detected: [],
1635
+ issues: ['metrics_unavailable'],
1636
+ };
1637
+ }
1638
+ }
1639
+
1640
+ // ── Gerar Relatório Diário ────────────────────────────────────────────────────
1641
+ async function generateDailyReport(DB) {
1642
+ const platforms = ['meta', 'ga4', 'tiktok', 'pinterest', 'reddit'];
1643
+ const today = new Date().toISOString().split('T')[0];
1644
+
1645
+ const reports = [];
1646
+
1647
+ for (const platform of platforms) {
1648
+ const metrics = await getHealthMetrics(DB, platform, 24);
1649
+
1650
+ try {
1651
+ await DB.prepare(`
1652
+ INSERT INTO health_reports (report_date, platform, events_sent, events_failed, success_rate, errors_detected, issues_detected)
1653
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1654
+ `).bind(
1655
+ today,
1656
+ platform,
1657
+ metrics.events_sent,
1658
+ metrics.events_failed,
1659
+ metrics.success_rate,
1660
+ JSON.stringify(metrics.errors_detected),
1661
+ JSON.stringify(metrics.issues)
1662
+ ).run();
1663
+ reports.push({ platform, status: 'ok' });
1664
+ } catch (err) {
1665
+ console.error(`Failed to generate report for ${platform}:`, err.message);
1666
+ reports.push({ platform, status: 'failed' });
1667
+ }
1668
+ }
1669
+
1670
+ return reports;
1671
+ }
1672
+
1673
+ // ── Verificar Versões de API (scheduled task) ────────────────────────────────
1674
+ async function checkApiVersions() {
1675
+ // Consulta api-versions.json para verificar versões atuais
1676
+ // Em produção, isso poderia fazer fetch para documentação oficial
1677
+ const currentVersions = {
1678
+ meta: 'v22.0',
1679
+ ga4: 'latest',
1680
+ tiktok: 'v1.3',
1681
+ pinterest: 'v5',
1682
+ reddit: 'v2.0',
1683
+ };
1684
+
1685
+ const today = new Date().toISOString().split('T')[0];
1686
+ const nextReview = '2026-04-27'; // Data do próximo review
1687
+
1688
+ return {
1689
+ check_date: today,
1690
+ next_review: nextReview,
1691
+ versions: currentVersions,
1692
+ status: 'ok',
1693
+ };
1694
+ }
1695
+
1696
+ // ─────────────────────────────────────────────────────────────────────────────
1697
+ // INTELLIGENCE AGENT — Monitoramento Autônomo
1698
+ // ─────────────────────────────────────────────────────────────────────────────
1699
+
1700
+ // Versões esperadas das APIs (fonte da verdade: contracts/api-versions.json)
1701
+ const EXPECTED_API_VERSIONS = {
1702
+ meta: 'v22.0',
1703
+ ga4: 'latest',
1704
+ tiktok: 'v1.3',
1705
+ pinterest: 'v5',
1706
+ reddit: 'v2.0',
1707
+ };
1708
+
1709
+ // Thresholds de alerta
1710
+ const ALERT_THRESHOLDS = {
1711
+ errorRateCritical: 0.20, // > 20% de falha = crítico
1712
+ errorRateWarning: 0.10, // > 10% de falha = aviso
1713
+ };
1714
+
1715
+ // ── Log de execução do Intelligence Agent no D1 ──────────────────────────────
1716
+ async function logIntelligence(DB, runType, platform, checkType, status, currentValue, expectedValue, message, alertSent = false) {
1717
+ if (!DB) return;
1718
+ try {
1719
+ await DB.prepare(`
1720
+ INSERT INTO intelligence_logs (run_type, platform, check_type, status, current_value, expected_value, message, alert_sent)
1721
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1722
+ `).bind(runType, platform, checkType, status, String(currentValue ?? ''), String(expectedValue ?? ''), message, alertSent ? 1 : 0).run();
1723
+ } catch (err) {
1724
+ console.error('logIntelligence error:', err.message);
1725
+ }
1726
+ }
1727
+
1728
+ // ── Alerta CallMeBot para o admin (falhas de sistema, API down, erros críticos)
1729
+ async function sendIntelligenceAlert(env, severity, title, details) {
1730
+ const icon = severity === 'critical' ? '🚨' : '⚠️';
1731
+ const texto =
1732
+ `${icon} CDP Edge — ${title}\n\n` +
1733
+ details + '\n\n' +
1734
+ new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' });
1735
+
1736
+ return sendCallMeBot(env, texto);
1737
+ }
1738
+
1739
+ // ── Check de versões de API ───────────────────────────────────────────────────
1740
+ async function checkApiVersionsIntelligence(env, runType) {
1741
+ const results = [];
1742
+
1743
+ for (const [platform, expected] of Object.entries(EXPECTED_API_VERSIONS)) {
1744
+ // Versão atual baseada no código deployado
1745
+ const currentMap = { meta: 'v22.0', tiktok: 'v1.3', ga4: 'latest', pinterest: 'v5', reddit: 'v2.0' };
1746
+ const current = currentMap[platform] || 'unknown';
1747
+ const isOk = current === expected || expected === 'latest';
1748
+ const status = isOk ? 'ok' : 'warning';
1749
+
1750
+ await logIntelligence(env.DB, runType, platform, 'api_version', status, current, expected,
1751
+ isOk ? `${platform} ${current} — versão correta` : `${platform} ${current} desatualizado, esperado ${expected}`
1752
+ );
1753
+
1754
+ results.push({ platform, current, expected, status });
1755
+ }
1756
+
1757
+ return results;
1758
+ }
1759
+
1760
+ // ── Auditoria de taxa de erro por plataforma (últimas 24h) ───────────────────
1761
+ async function auditErrorRates(env, runType) {
1762
+ if (!env.DB) return [];
1763
+ const alerts = [];
1764
+
1765
+ for (const platform of ['meta', 'ga4', 'tiktok']) {
1766
+ const metrics = await getHealthMetrics(env.DB, platform, 24);
1767
+ const errorRate = metrics.events_sent > 0
1768
+ ? metrics.events_failed / metrics.events_sent
1769
+ : 0;
1770
+
1771
+ let status = 'ok';
1772
+ if (errorRate >= ALERT_THRESHOLDS.errorRateCritical) status = 'critical';
1773
+ else if (errorRate >= ALERT_THRESHOLDS.errorRateWarning) status = 'warning';
1774
+
1775
+ const message = `${platform}: ${metrics.events_sent} eventos, ${metrics.events_failed} falhas (${(errorRate * 100).toFixed(1)}%)`;
1776
+ const alertSent = status !== 'ok'
1777
+ ? await sendIntelligenceAlert(env, status, `Taxa de Erro Alta — ${platform.toUpperCase()}`,
1778
+ `📊 ${message}\n🎯 Taxa: ${(errorRate * 100).toFixed(1)}% (limite: ${ALERT_THRESHOLDS.errorRateWarning * 100}%)`)
1779
+ : false;
1780
+
1781
+ await logIntelligence(env.DB, runType, platform, 'error_rate', status,
1782
+ `${(errorRate * 100).toFixed(1)}%`,
1783
+ `${ALERT_THRESHOLDS.errorRateWarning * 100}%`,
1784
+ message, alertSent
1785
+ );
1786
+
1787
+ if (status !== 'ok') alerts.push({ platform, errorRate, status });
1788
+ }
1789
+
1790
+ return alerts;
1791
+ }
1792
+
1793
+ // ── Runner principal do Intelligence Agent ────────────────────────────────────
1794
+ async function runIntelligenceAgent(env, runType) {
1795
+ console.log(`[Intelligence Agent] Iniciando ${runType}`);
1796
+
1797
+ // 1. Check de versões (sempre)
1798
+ const versionResults = await checkApiVersionsIntelligence(env, runType);
1799
+ console.log(`[Intelligence Agent] Versões verificadas: ${versionResults.length} plataformas`);
1800
+
1801
+ // 2. Relatório diário de saúde (sempre)
1802
+ if (env.DB) {
1803
+ const reports = await generateDailyReport(env.DB);
1804
+ console.log(`[Intelligence Agent] Relatórios gerados: ${reports.length}`);
1805
+ }
1806
+
1807
+ // 3. Auditoria de taxas de erro (sempre)
1808
+ const errorAlerts = await auditErrorRates(env, runType);
1809
+ if (errorAlerts.length > 0) {
1810
+ console.warn(`[Intelligence Agent] ${errorAlerts.length} alertas de taxa de erro enviados`);
1811
+ }
1812
+
1813
+ // 4. Auditoria mensal adicional
1814
+ if (runType === 'monthly_audit') {
1815
+ // Verificar LTV: quantos perfis High vs Low no último mês
1816
+ if (env.DB) {
1817
+ try {
1818
+ const ltvStats = await env.DB.prepare(`
1819
+ SELECT predicted_ltv_class, COUNT(*) as count
1820
+ FROM user_profiles
1821
+ WHERE predicted_ltv_class IS NOT NULL
1822
+ AND updated_at > datetime('now', '-30 days')
1823
+ GROUP BY predicted_ltv_class
1824
+ `).all();
1825
+
1826
+ const summary = ltvStats.results?.map(r => `${r.predicted_ltv_class}: ${r.count}`).join(', ') || 'sem dados';
1827
+ await logIntelligence(env.DB, runType, 'all', 'ltv_distribution', 'ok', summary, null,
1828
+ `Distribuição LTV últimos 30 dias: ${summary}`);
1829
+ console.log(`[Intelligence Agent] LTV distribution: ${summary}`);
1830
+ } catch (err) {
1831
+ console.error('LTV audit error:', err.message);
1832
+ }
1833
+ }
1834
+ }
1835
+
1836
+ // 5. Customer Match — sync semanal D1 → Meta Custom Audience
1837
+ const cmResult = await syncMetaCustomAudience(env);
1838
+ console.log(`[Intelligence Agent] Customer Match Meta: ${JSON.stringify(cmResult)}`);
1839
+
1840
+ console.log(`[Intelligence Agent] ${runType} concluído`);
1841
+ }
1842
+
1843
+ // ─────────────────────────────────────────────────────────────────────────────
1844
+ // CUSTOMER MATCH — Sync automático D1 → Meta Custom Audiences
1845
+ // ─────────────────────────────────────────────────────────────────────────────
1846
+
1847
+ /**
1848
+ * Busca leads high_intent + buyer_lookalike do D1 e envia para Meta Custom Audience.
1849
+ *
1850
+ * Secrets necessários (wrangler secret put):
1851
+ * META_AD_ACCOUNT_ID → ID da conta de anúncios (act_XXXXXXXXX)
1852
+ * META_AUDIENCE_ID → ID da Custom Audience já criada no Meta Ads
1853
+ *
1854
+ * Meta aceita até 10.000 usuários por chamada.
1855
+ * Formato: schema EMAIL_SHA256 + PHONE_SHA256, dados já hasheados.
1856
+ */
1857
+ async function syncMetaCustomAudience(env) {
1858
+ if (!env.META_ACCESS_TOKEN || !env.META_AD_ACCOUNT_ID || !env.META_AUDIENCE_ID) {
1859
+ console.log('[CustomerMatch] Meta: secrets não configurados — pulando sync');
1860
+ return { skipped: 'META_AD_ACCOUNT_ID ou META_AUDIENCE_ID não configurados' };
1861
+ }
1862
+ if (!env.DB) return { skipped: 'DB não disponível' };
1863
+
1864
+ try {
1865
+ // Busca perfis high_intent e buyer_lookalike atualizados nos últimos 30 dias
1866
+ const profiles = await env.DB.prepare(`
1867
+ SELECT email, phone
1868
+ FROM user_profiles
1869
+ WHERE cohort_label IN ('high_intent', 'buyer_lookalike')
1870
+ AND updated_at > datetime('now', '-30 days')
1871
+ AND email IS NOT NULL
1872
+ LIMIT 10000
1873
+ `).all();
1874
+
1875
+ if (!profiles.results || profiles.results.length === 0) {
1876
+ console.log('[CustomerMatch] Meta: nenhum perfil elegível');
1877
+ return { sent: 0 };
1878
+ }
1879
+
1880
+ // Hash de cada linha — Meta exige SHA-256 lowercase sem espaços
1881
+ const data = await Promise.all(
1882
+ profiles.results.map(async (p) => {
1883
+ const row = [];
1884
+ row.push(p.email ? await sha256(p.email) : '');
1885
+ row.push(p.phone ? await sha256(p.phone) : '');
1886
+ return row;
1887
+ })
1888
+ );
1889
+
1890
+ const body = {
1891
+ payload: {
1892
+ schema: ['EMAIL_SHA256', 'PHONE_SHA256'],
1893
+ data,
1894
+ },
1895
+ };
1896
+
1897
+ const endpoint = `https://graph.facebook.com/v22.0/${env.META_AUDIENCE_ID}/users`;
1898
+ const res = await fetch(endpoint, {
1899
+ method: 'POST',
1900
+ headers: { 'Content-Type': 'application/json' },
1901
+ body: JSON.stringify({ ...body, access_token: env.META_ACCESS_TOKEN }),
1902
+ });
1903
+
1904
+ const result = await res.json();
1905
+
1906
+ if (!res.ok) {
1907
+ console.error('[CustomerMatch] Meta erro:', JSON.stringify(result));
1908
+ return { error: result.error?.message, sent: 0 };
1909
+ }
1910
+
1911
+ console.log(`[CustomerMatch] Meta: ${profiles.results.length} perfis sincronizados`);
1912
+ return { sent: profiles.results.length, num_received: result.num_received };
1913
+
1914
+ } catch (err) {
1915
+ console.error('[CustomerMatch] Meta fetch error:', err.message);
1916
+ return { error: err.message, sent: 0 };
1917
+ }
1918
+ }
1919
+
1920
+ /**
1921
+ * Gera payload formatado para Google Ads Customer Match (upload manual ou via API).
1922
+ * Retorna JSON com emails e phones hasheados prontos para upload.
1923
+ *
1924
+ * Google Ads Customer Match não tem API simples disponível via Workers sem OAuth2.
1925
+ * Esta função gera o arquivo — o upload é feito via endpoint GET /export/customer-match.
1926
+ */
1927
+ async function buildGoogleCustomerMatchExport(env) {
1928
+ if (!env.DB) return [];
1929
+
1930
+ const profiles = await env.DB.prepare(`
1931
+ SELECT email, phone, first_name, last_name
1932
+ FROM user_profiles
1933
+ WHERE cohort_label IN ('high_intent', 'buyer_lookalike')
1934
+ AND updated_at > datetime('now', '-30 days')
1935
+ AND email IS NOT NULL
1936
+ LIMIT 10000
1937
+ `).all();
1938
+
1939
+ if (!profiles.results?.length) return [];
1940
+
1941
+ return Promise.all(
1942
+ profiles.results.map(async (p) => ({
1943
+ hashed_email: p.email ? await sha256(p.email) : '',
1944
+ hashed_phone: p.phone ? await sha256(p.phone) : '',
1945
+ first_name: p.first_name || '',
1946
+ last_name: p.last_name || '',
1947
+ }))
1948
+ );
1949
+ }
1950
+
1951
+ // ─────────────────────────────────────────────────────────────────────────────
1952
+ // HANDLER PRINCIPAL
1953
+ // ─────────────────────────────────────────────────────────────────────────────
1954
+ export default {
1955
+ async fetch(request, env, ctx) {
1956
+ const origin = request.headers.get('Origin') || '';
1957
+ const headers = {
1958
+ 'Content-Type': 'application/json',
1959
+ ...corsHeaders(origin),
1960
+ };
1961
+
1962
+ // Preflight CORS
1963
+ if (request.method === 'OPTIONS') {
1964
+ return new Response(null, { status: 204, headers });
1965
+ }
1966
+
1967
+ const url = new URL(request.url);
1968
+
1969
+ // ── GET /export/customer-match — exporta leads para Google Ads (download) ──
1970
+ if (request.method === 'GET' && url.pathname === '/export/customer-match') {
1971
+ // Proteção simples por token (mesmo META_ACCESS_TOKEN ou secret dedicado)
1972
+ const authHeader = request.headers.get('Authorization') || '';
1973
+ const token = authHeader.replace('Bearer ', '');
1974
+ if (!env.META_ACCESS_TOKEN || token !== env.META_ACCESS_TOKEN) {
1975
+ return new Response('Unauthorized', { status: 401 });
1976
+ }
1977
+
1978
+ const rows = await buildGoogleCustomerMatchExport(env);
1979
+ return new Response(JSON.stringify({ total: rows.length, data: rows }, null, 2), {
1980
+ headers: { ...headers, 'Content-Disposition': 'attachment; filename="customer-match.json"' },
1981
+ });
1982
+ }
1983
+
1984
+ // ── GET /health ───────────────────────────────────────────────────────────
1985
+ if (request.method === 'GET' && url.pathname === '/health') {
1986
+ return new Response(JSON.stringify({
1987
+ status: 'ok',
1988
+ meta_pixel: META_PIXEL_ID,
1989
+ ga4: GA4_MEASUREMENT_ID,
1990
+ tiktok_pixel: env.TIKTOK_PIXEL_ID || TIKTOK_PIXEL_ID,
1991
+ db: env.DB ? 'connected' : 'not bound',
1992
+ meta_token: env.META_ACCESS_TOKEN ? 'set' : 'MISSING',
1993
+ ga4_secret: env.GA4_API_SECRET ? 'set' : 'MISSING',
1994
+ tiktok_token: env.TIKTOK_ACCESS_TOKEN ? 'set' : 'not set (optional)',
1995
+ }), { headers });
1996
+ }
1997
+
1998
+ // ── POST /track — evento do browser ───────────────────────────────────────
1999
+ if (request.method === 'POST' && url.pathname === '/track') {
2000
+ let body;
2001
+ try {
2002
+ body = await request.json();
2003
+ } catch {
2004
+ return new Response(
2005
+ JSON.stringify({ error: 'JSON inválido' }),
2006
+ { status: 400, headers }
2007
+ );
2008
+ }
2009
+
2010
+ const { eventName, behavioral_data, ...payload } = body;
2011
+
2012
+ if (!eventName) {
2013
+ return new Response(
2014
+ JSON.stringify({ error: 'eventName é obrigatório' }),
2015
+ { status: 400, headers }
2016
+ );
2017
+ }
2018
+
2019
+ // ── Extrair dados comportamentais do browser ──────────────────────────────
2020
+ // behavioral_data vem do engagement-scoring.js (engagement_score 0-5, intention_level)
2021
+ // e do BehaviorEngine (user_score 0-100)
2022
+ if (behavioral_data) {
2023
+ payload.engagementScore = behavioral_data.engagement_score ?? behavioral_data.totalScore ?? null;
2024
+ payload.intentionLevel = behavioral_data.intention_level ?? null;
2025
+ payload.userScore = behavioral_data.user_score ?? null;
2026
+ // PII extraído pelo advanced-matching.js chega aninhado em behavioral_data
2027
+ // (trackLead passa piiData como `data`, que é spread em behavioral_data)
2028
+ payload.email = payload.email || behavioral_data.email || null;
2029
+ payload.phone = payload.phone || behavioral_data.phone || null;
2030
+ payload.firstName = payload.firstName || behavioral_data.first_name || behavioral_data.firstName || null;
2031
+ payload.lastName = payload.lastName || behavioral_data.last_name || behavioral_data.lastName || null;
2032
+ payload.city = payload.city || behavioral_data.city || null;
2033
+ payload.state = payload.state || behavioral_data.state || null;
2034
+ payload.zip = payload.zip || behavioral_data.zip || null;
2035
+ payload.dob = payload.dob || behavioral_data.dob || null;
2036
+ }
2037
+
2038
+ // ── Edge Fingerprint + UTM Resurrection ───────────────────────────────────
2039
+ const fingerprint = await generateEdgeFingerprint(request);
2040
+ payload.utmRestored = false;
2041
+
2042
+ if (fingerprint) {
2043
+ if (payload.utmSource) {
2044
+ // Tem UTM → salvar fingerprint para uso futuro
2045
+ ctx.waitUntil(saveEdgeFingerprint(env.DB, fingerprint, payload.userId, payload));
2046
+ } else {
2047
+ // Sem UTM → tentar recuperar das últimas 48h
2048
+ const recovered = await resurrectUTM(env.DB, fingerprint);
2049
+ if (recovered) {
2050
+ payload.utmSource = payload.utmSource || recovered.utm_source;
2051
+ payload.utmMedium = payload.utmMedium || recovered.utm_medium;
2052
+ payload.utmCampaign = payload.utmCampaign || recovered.utm_campaign;
2053
+ payload.utmContent = payload.utmContent || recovered.utm_content;
2054
+ payload.utmTerm = payload.utmTerm || recovered.utm_term;
2055
+ payload.utmRestored = true;
2056
+ console.log(`[UTM Resurrection] Recovered: ${recovered.utm_source}/${recovered.utm_medium}/${recovered.utm_campaign}`);
2057
+ }
2058
+ }
2059
+ }
2060
+
2061
+ // ── Bot Mitigation ────────────────────────────────────────────────────────
2062
+ const botScoreStr = request.cf?.botManagement?.score;
2063
+ const cfBotScore = botScoreStr !== undefined ? parseInt(botScoreStr) : 100;
2064
+ const ua = (request.headers.get('User-Agent') || '').toLowerCase();
2065
+ const isBotPattern = /bot|crawler|spider|lighthouse|postman|curl|inspect|headless/i.test(ua);
2066
+
2067
+ const isBot = cfBotScore < 30 || isBotPattern;
2068
+ payload.botScore = isBot ? 2 : (cfBotScore < 60 ? 1 : 0);
2069
+
2070
+ // Dropar silenciosamente eventos de lixo (exceto conversões core para evitar falso positivo)
2071
+ if (isBot && !['Contact', 'Lead', 'Purchase'].includes(eventName)) {
2072
+ return new Response(JSON.stringify({ ok: true, skipped_due_to_bot: true }), { status: 200, headers });
2073
+ }
2074
+
2075
+ // ── Edge Geo Enrichment ───────────────────────────────────────────────────
2076
+ // Free: country, continent, asn | Paid: city, state, zip, lat/lon, timezone
2077
+ const geoData = await enrichGeoFromEdge(request, env, payload);
2078
+
2079
+ // ── First-Party Cookie (Identity Resolution) ──────────────────────────────
2080
+ const cookieHeader = request.headers.get('Cookie') || '';
2081
+ const cdpUidMatch = cookieHeader.match(/_cdp_uid=([^;]+)/);
2082
+ const finalUserId = payload.userId || (cdpUidMatch ? cdpUidMatch[1] : crypto.randomUUID());
2083
+ payload.userId = finalUserId;
2084
+
2085
+ const ga4Name = META_TO_GA4[eventName] || eventName.toLowerCase();
2086
+
2087
+ // ── LTV Prediction — eventos de topo de funil ─────────────────────────────
2088
+ // Lead, Contact, Schedule: sem valor monetário real → injetar LTV preditivo
2089
+ // Isso treina os algoritmos de Meta/TikTok a priorizar leads de alto valor
2090
+ const LTV_EVENTS = ['Lead', 'Contact', 'Schedule', 'CompleteRegistration'];
2091
+ if (LTV_EVENTS.includes(eventName) && !payload.value) {
2092
+ const ltv = await predictLtv(env, payload, request);
2093
+ payload.value = ltv.value;
2094
+ payload.currency = payload.currency || 'BRL';
2095
+ payload.ltvClass = ltv.class;
2096
+ payload.ltvScore = ltv.score;
2097
+ // Persiste no perfil em background
2098
+ ctx.waitUntil(
2099
+ upsertLtvProfile(env, payload.userId, ltv)
2100
+ );
2101
+ }
2102
+
2103
+ // Cross-Device Graph — background (não bloqueia resposta)
2104
+ // Só dispara quando tem PII e um userId confirmado
2105
+ if (env.DB && payload.userId && (payload.email || payload.phone)) {
2106
+ ctx.waitUntil(
2107
+ resolveDeviceGraph(env.DB, payload.userId, payload.email, payload.phone)
2108
+ );
2109
+ }
2110
+
2111
+ // ── R2 Audit Log — background, não bloqueia ──────────────────────────────
2112
+ ctx.waitUntil(writeAuditLog(env, eventName, payload, geoData));
2113
+
2114
+ // Disparar tudo em paralelo — não bloquear o browser
2115
+ // WhatsApp: só notifica Lead e Purchase para não lotar o celular
2116
+ const WHATSAPP_NOTIFY_EVENTS = ['Lead', 'Purchase', 'CompleteRegistration'];
2117
+ const [metaRes, ga4Res, ttRes] = await Promise.allSettled([
2118
+ sendMetaCapi(env, eventName, payload, request, ctx),
2119
+ sendGA4Mp(env, ga4Name, payload, ctx),
2120
+ sendTikTokApi(env, eventName, payload, request, ctx),
2121
+ saveLead(env, eventName, payload, request, 'website'),
2122
+ upsertProfile(env, eventName, payload, request),
2123
+ ...(WHATSAPP_NOTIFY_EVENTS.includes(eventName)
2124
+ ? [sendWhatsApp(env, eventName, payload)]
2125
+ : []),
2126
+ ]);
2127
+
2128
+ // Automação de mensagens — dispara regras ativas para este evento em background
2129
+ // saveLead() já foi chamado acima; usamos o leadId gerado pelo D1 (last_row_id)
2130
+ const AUTOMATION_EVENTS = ['Lead', 'Purchase', 'InitiateCheckout'];
2131
+ if (AUTOMATION_EVENTS.includes(eventName) && env.DB) {
2132
+ ctx.waitUntil(
2133
+ (async () => {
2134
+ try {
2135
+ const lastLead = await env.DB
2136
+ .prepare(`SELECT id FROM leads WHERE event_id = ?1 LIMIT 1`)
2137
+ .bind(payload.eventId || payload.event_id || '')
2138
+ .first();
2139
+ const leadId = lastLead?.id ?? null;
2140
+ if (leadId) await fireAutomation(env, eventName, leadId, payload);
2141
+ } catch (e) { console.error('[Automation] lead lookup error:', e.message); }
2142
+ })()
2143
+ );
2144
+ }
2145
+
2146
+ // ── Edge Personalization (Retornar Score) ───────────────────────────────
2147
+ let currentScore = 0;
2148
+ if (env.DB && payload.userId) {
2149
+ try {
2150
+ const profileRow = await env.DB.prepare('SELECT score FROM user_profiles WHERE user_id = ?').bind(payload.userId).first();
2151
+ if (profileRow) currentScore = profileRow.score;
2152
+ } catch(e) {}
2153
+ }
2154
+
2155
+ const resHeaders = new Headers(headers);
2156
+ resHeaders.set('Set-Cookie', `_cdp_uid=${finalUserId}; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000; Path=/`);
2157
+
2158
+ return new Response(JSON.stringify({
2159
+ ok: true,
2160
+ userProfile: { score: currentScore, user_id: finalUserId },
2161
+ meta: metaRes.value ?? { error: metaRes.reason?.message },
2162
+ ga4: ga4Res.value ?? { error: ga4Res.reason?.message },
2163
+ tiktok: ttRes.value ?? { error: ttRes.reason?.message },
2164
+ }), { status: 200, headers: resHeaders });
2165
+ }
2166
+
2167
+ // ── POST /webhook/hotmart ─────────────────────────────────────────────────
2168
+ if (request.method === 'POST' && url.pathname === '/webhook/hotmart') {
2169
+ // Validação de token Hotmart (X-Hotmart-Webhook-Token)
2170
+ // Secret: wrangler secret put WEBHOOK_SECRET_HOTMART
2171
+ if (env.WEBHOOK_SECRET_HOTMART) {
2172
+ const token = request.headers.get('X-Hotmart-Webhook-Token') || '';
2173
+ if (token !== env.WEBHOOK_SECRET_HOTMART) {
2174
+ return new Response('Unauthorized', { status: 401 });
2175
+ }
2176
+ }
2177
+
2178
+ let wh;
2179
+ try { wh = await request.json(); } catch {
2180
+ return new Response('JSON inválido', { status: 400 });
2181
+ }
2182
+
2183
+ const data = wh.data || wh;
2184
+ const buyer = data.buyer || {};
2185
+ const purchase = data.purchase || {};
2186
+ const product = data.product || {};
2187
+
2188
+ // Só processar compras aprovadas
2189
+ if (!['APPROVED', 'COMPLETE', 'approved', 'complete'].includes(purchase.status)) {
2190
+ return new Response(
2191
+ JSON.stringify({ skipped: `status ${purchase.status}` }),
2192
+ { status: 200, headers }
2193
+ );
2194
+ }
2195
+
2196
+ // Deduplicação — verificar se transação já foi processada
2197
+ const hmTxId = String(purchase.transaction || '');
2198
+ if (hmTxId && env.DB) {
2199
+ try {
2200
+ const dup = await env.DB.prepare(
2201
+ 'SELECT id FROM webhook_events WHERE transaction_id = ? AND status = ?'
2202
+ ).bind(hmTxId, 'processed').first();
2203
+ if (dup) {
2204
+ return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
2205
+ }
2206
+ } catch { /* continua mesmo se a consulta falhar */ }
2207
+ }
2208
+
2209
+ // Recuperar cookies do comprador (fbp/fbc) pelo email
2210
+ const profile = await getProfileByEmail(env, buyer.email);
2211
+
2212
+ const payload = {
2213
+ email: buyer.email,
2214
+ phone: buyer.phone,
2215
+ firstName: buyer.name?.split(' ')[0],
2216
+ lastName: buyer.name?.split(' ').slice(1).join(' ') || undefined,
2217
+ fbp: profile?.fbp,
2218
+ fbc: profile?.fbc,
2219
+ userId: profile?.user_id,
2220
+ gaClientId: profile?.ga_client_id,
2221
+ value: purchase.price?.value,
2222
+ currency: purchase.price?.currency_value || 'BRL',
2223
+ contentIds: [String(product.id || product.ucode || '')],
2224
+ contentName: product.name,
2225
+ contentType: 'product',
2226
+ pageUrl: profile?.page_url || `https://${env.SITE_DOMAIN || 'seu-dominio.com.br'}`,
2227
+ orderId: purchase.transaction,
2228
+ eventId: `hotmart_${purchase.transaction}`,
2229
+ city: profile?.city,
2230
+ state: profile?.state,
2231
+ country: profile?.country,
2232
+ };
2233
+
2234
+ // Registrar transação no D1 (prevenção de duplicatas em reenvios)
2235
+ if (hmTxId && env.DB) {
2236
+ try {
2237
+ await env.DB.prepare(
2238
+ 'INSERT OR IGNORE INTO webhook_events (platform, transaction_id, email, status, raw_payload) VALUES (?,?,?,?,?)'
2239
+ ).bind('hotmart', hmTxId, buyer.email || null, 'processed', JSON.stringify(wh)).run();
2240
+ } catch { /* não bloquear envio se D1 falhar */ }
2241
+ }
2242
+
2243
+ ctx.waitUntil(Promise.allSettled([
2244
+ sendMetaCapi(env, 'Purchase', payload, request, ctx),
2245
+ sendGA4Mp(env, 'purchase', payload, ctx),
2246
+ sendTikTokApi(env, 'CompletePayment', payload, request, ctx),
2247
+ saveLead(env, 'Purchase', payload, request, 'hotmart'),
2248
+ sendWhatsApp(env, 'Purchase', payload),
2249
+ ]));
2250
+
2251
+ return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
2252
+ }
2253
+
2254
+ // ── POST /webhook/kiwify ──────────────────────────────────────────────────
2255
+ if (request.method === 'POST' && url.pathname === '/webhook/kiwify') {
2256
+ // Validação de token Kiwify (X-Kiwify-Event-Token)
2257
+ // Secret: wrangler secret put WEBHOOK_SECRET_KIWIFY
2258
+ if (env.WEBHOOK_SECRET_KIWIFY) {
2259
+ const token = request.headers.get('X-Kiwify-Event-Token') || '';
2260
+ if (token !== env.WEBHOOK_SECRET_KIWIFY) {
2261
+ return new Response('Unauthorized', { status: 401 });
2262
+ }
2263
+ }
2264
+
2265
+ let wh;
2266
+ try { wh = await request.json(); } catch {
2267
+ return new Response('JSON inválido', { status: 400 });
2268
+ }
2269
+
2270
+ // Só processar compras aprovadas
2271
+ if (wh.order_status !== 'paid' && wh.order_status !== 'approved') {
2272
+ return new Response(
2273
+ JSON.stringify({ skipped: `status ${wh.order_status}` }),
2274
+ { status: 200, headers }
2275
+ );
2276
+ }
2277
+
2278
+ // Deduplicação — verificar se transação já foi processada
2279
+ const kwTxId = String(wh.order_id || '');
2280
+ if (kwTxId && env.DB) {
2281
+ try {
2282
+ const dup = await env.DB.prepare(
2283
+ 'SELECT id FROM webhook_events WHERE transaction_id = ? AND status = ?'
2284
+ ).bind(kwTxId, 'processed').first();
2285
+ if (dup) {
2286
+ return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
2287
+ }
2288
+ } catch { /* continua mesmo se a consulta falhar */ }
2289
+ }
2290
+
2291
+ const customer = wh.Customer || {};
2292
+ const product = wh.Product || {};
2293
+ const profile = await getProfileByEmail(env, customer.email);
2294
+
2295
+ const payload = {
2296
+ email: customer.email,
2297
+ phone: customer.mobile,
2298
+ firstName: customer.full_name?.split(' ')[0],
2299
+ lastName: customer.full_name?.split(' ').slice(1).join(' ') || undefined,
2300
+ fbp: profile?.fbp,
2301
+ fbc: profile?.fbc,
2302
+ userId: profile?.user_id,
2303
+ gaClientId: profile?.ga_client_id,
2304
+ value: wh.order_value ? parseFloat(wh.order_value) / 100 : undefined,
2305
+ currency: 'BRL',
2306
+ contentIds: [String(product.product_id || '')],
2307
+ contentName: product.product_name,
2308
+ contentType: 'product',
2309
+ pageUrl: profile?.page_url || `https://${env.SITE_DOMAIN || 'seu-dominio.com.br'}`,
2310
+ orderId: wh.order_id,
2311
+ eventId: `kiwify_${wh.order_id}`,
2312
+ city: profile?.city,
2313
+ state: profile?.state,
2314
+ country: profile?.country,
2315
+ };
2316
+
2317
+ // Registrar transação no D1
2318
+ if (kwTxId && env.DB) {
2319
+ try {
2320
+ await env.DB.prepare(
2321
+ 'INSERT OR IGNORE INTO webhook_events (platform, transaction_id, email, status, raw_payload) VALUES (?,?,?,?,?)'
2322
+ ).bind('kiwify', kwTxId, customer.email || null, 'processed', JSON.stringify(wh)).run();
2323
+ } catch { /* não bloquear envio se D1 falhar */ }
2324
+ }
2325
+
2326
+ ctx.waitUntil(Promise.allSettled([
2327
+ sendMetaCapi(env, 'Purchase', payload, request, ctx),
2328
+ sendGA4Mp(env, 'purchase', payload, ctx),
2329
+ sendTikTokApi(env, 'CompletePayment', payload, request, ctx),
2330
+ sendPinterestCapi(env, 'Purchase', payload, request, ctx),
2331
+ sendRedditCapi(env, 'Purchase', payload, request, ctx),
2332
+ sendLinkedInCapi(env, 'Purchase', payload, request, ctx),
2333
+ sendSpotifyCapi(env, 'Purchase', payload, request, ctx),
2334
+ saveLead(env, 'Purchase', payload, request, 'kiwify'),
2335
+ sendWhatsApp(env, 'Purchase', payload),
2336
+ ]));
2337
+
2338
+ return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
2339
+ }
2340
+
2341
+ // ── POST /webhook/ticto ───────────────────────────────────────────────────
2342
+ // Ticto Webhook v2 (JSON) — configurar em: Produto → Webhooks → Versão 2.0 → JSON
2343
+ // URL a cadastrar na Ticto: https://server-edge-tracker.suporte-ed9.workers.dev/webhook/ticto
2344
+ // Evento a selecionar: "Venda Realizada" (status: paid | approved | complete)
2345
+ if (request.method === 'POST' && url.pathname === '/webhook/ticto') {
2346
+ // Validação HMAC-SHA256 Ticto (X-Ticto-Signature)
2347
+ // Secret: wrangler secret put WEBHOOK_SECRET_TICTO
2348
+ let rawBody;
2349
+ try { rawBody = await request.text(); } catch {
2350
+ return new Response('Leitura de body falhou', { status: 400 });
2351
+ }
2352
+ if (env.WEBHOOK_SECRET_TICTO) {
2353
+ const sig = request.headers.get('X-Ticto-Signature') || '';
2354
+ const valid = await verifyHmac(env.WEBHOOK_SECRET_TICTO, rawBody, sig);
2355
+ if (!valid) {
2356
+ return new Response('Unauthorized', { status: 401 });
2357
+ }
2358
+ }
2359
+
2360
+ let wh;
2361
+ try { wh = JSON.parse(rawBody); } catch {
2362
+ return new Response('JSON inválido', { status: 400 });
2363
+ }
2364
+
2365
+ // ── Estrutura Ticto v2 ────────────────────────────────────────────────
2366
+ // {
2367
+ // "version": "2.0",
2368
+ // "status": "paid", ← paid | approved | complete | refunded | chargeback
2369
+ // "status_date": "...",
2370
+ // "token": "...",
2371
+ // "payment_method": "credit_card | boleto | pix",
2372
+ // "customer": {
2373
+ // "name": "João Silva",
2374
+ // "email": "joao@email.com",
2375
+ // "phone": "11999998888",
2376
+ // "document": "12345678901" ← CPF (não enviamos para plataformas de ads)
2377
+ // },
2378
+ // "order": {
2379
+ // "id": "ORD123",
2380
+ // "hash": "abc123",
2381
+ // "transaction_hash": "xyz456",
2382
+ // "paid_amount": 29700, ← valor em centavos (R$ 297,00)
2383
+ // "installments": 1,
2384
+ // "order_date": "2024-01-01"
2385
+ // },
2386
+ // "item": {
2387
+ // "product_name": "Curso XYZ",
2388
+ // "product_id": "PROD123"
2389
+ // },
2390
+ // "tracking": { ← parâmetros de URL capturados no checkout
2391
+ // "src": "facebook",
2392
+ // "utm_source": "...",
2393
+ // "utm_medium": "...",
2394
+ // "utm_campaign": "...",
2395
+ // "utm_content": "...",
2396
+ // "utm_term": "..."
2397
+ // },
2398
+ // "url_params": { ← fallback de parâmetros extras (fbclid, sck, xcod...)
2399
+ // "fbclid": "...",
2400
+ // "sck": "..."
2401
+ // }
2402
+ // }
2403
+
2404
+ // Aceitar apenas vendas aprovadas/pagas
2405
+ const STATUS_PAID = ['paid', 'approved', 'complete', 'completed'];
2406
+ if (!STATUS_PAID.includes((wh.status || '').toLowerCase())) {
2407
+ return new Response(
2408
+ JSON.stringify({ skipped: `status ${wh.status}` }),
2409
+ { status: 200, headers }
2410
+ );
2411
+ }
2412
+
2413
+ const customer = wh.customer || {};
2414
+ const order = wh.order || {};
2415
+ const item = wh.item || {};
2416
+ const tracking = wh.tracking || wh.url_params || {};
2417
+
2418
+ // Valor: paid_amount está em centavos → dividir por 100
2419
+ const valueRaw = order.paid_amount ?? order.total ?? order.amount;
2420
+ const value = valueRaw ? parseFloat(valueRaw) / 100 : undefined;
2421
+
2422
+ // Transaction ID: usar hash se disponível (mais estável que id numérico)
2423
+ const transactionId = order.hash || order.transaction_hash || order.id;
2424
+
2425
+ // Deduplicação — verificar se transação já foi processada
2426
+ const tcTxId = String(order.hash || order.transaction_hash || order.id || '');
2427
+ if (tcTxId && env.DB) {
2428
+ try {
2429
+ const dup = await env.DB.prepare(
2430
+ 'SELECT id FROM webhook_events WHERE transaction_id = ? AND status = ?'
2431
+ ).bind(tcTxId, 'processed').first();
2432
+ if (dup) {
2433
+ return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
2434
+ }
2435
+ } catch { /* continua mesmo se a consulta falhar */ }
2436
+ }
2437
+
2438
+ // Buscar perfil do comprador pelo email; fallback por user_id passado via URL (cdpTrack passCheckoutParams)
2439
+ const urlUserId = tracking.user_id || wh.url_params?.user_id;
2440
+ let profile = await getProfileByEmail(env, customer.email);
2441
+ if (!profile && urlUserId && env.DB) {
2442
+ try {
2443
+ profile = await env.DB.prepare(
2444
+ 'SELECT * FROM user_profiles WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1'
2445
+ ).bind(urlUserId).first();
2446
+ } catch { /* continua sem perfil */ }
2447
+ }
2448
+
2449
+ // Construir fbc a partir do fbclid se o profile não tiver fbc
2450
+ const fbclid = tracking.fbclid || wh.url_params?.fbclid;
2451
+ const fbc = profile?.fbc || (fbclid ? `fb.1.${Date.now()}.${fbclid}` : undefined);
2452
+
2453
+ const payload = {
2454
+ email: customer.email,
2455
+ phone: customer.phone,
2456
+ firstName: customer.name?.split(' ')[0],
2457
+ lastName: customer.name?.split(' ').slice(1).join(' ') || undefined,
2458
+ fbp: profile?.fbp,
2459
+ fbc,
2460
+ ttp: profile?.ttp,
2461
+ userId: profile?.user_id,
2462
+ gaClientId: profile?.ga_client_id,
2463
+ value,
2464
+ currency: 'BRL',
2465
+ contentIds: [String(item.product_id || '')],
2466
+ contentName: item.product_name,
2467
+ contentType: 'product',
2468
+ pageUrl: profile?.page_url || `https://${env.SITE_DOMAIN || 'seu-dominio.com.br'}`,
2469
+ orderId: transactionId,
2470
+ eventId: `ticto_${transactionId}`,
2471
+ city: profile?.city,
2472
+ state: profile?.state,
2473
+ country: profile?.country || 'br',
2474
+ utmSource: tracking.utm_source || tracking.src || '',
2475
+ utmMedium: tracking.utm_medium || '',
2476
+ utmCampaign: tracking.utm_campaign || '',
2477
+ utmContent: tracking.utm_content || '',
2478
+ };
2479
+
2480
+ // Registrar transação no D1
2481
+ if (tcTxId && env.DB) {
2482
+ try {
2483
+ await env.DB.prepare(
2484
+ 'INSERT OR IGNORE INTO webhook_events (platform, transaction_id, email, status, raw_payload) VALUES (?,?,?,?,?)'
2485
+ ).bind('ticto', tcTxId, customer.email || null, 'processed', JSON.stringify(wh)).run();
2486
+ } catch { /* não bloquear envio se D1 falhar */ }
2487
+ }
2488
+
2489
+ ctx.waitUntil(Promise.allSettled([
2490
+ sendMetaCapi(env, 'Purchase', payload, request, ctx),
2491
+ sendGA4Mp(env, 'purchase', payload, ctx),
2492
+ sendTikTokApi(env, 'CompletePayment', payload, request, ctx),
2493
+ sendPinterestCapi(env, 'Purchase', payload, request, ctx),
2494
+ sendRedditCapi(env, 'Purchase', payload, request, ctx),
2495
+ sendLinkedInCapi(env, 'Purchase', payload, request, ctx),
2496
+ sendSpotifyCapi(env, 'Purchase', payload, request, ctx),
2497
+ saveLead(env, 'Purchase', payload, request, 'ticto'),
2498
+ sendWhatsApp(env, 'Purchase', payload),
2499
+ ]));
2500
+
2501
+ return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
2502
+ }
2503
+
2504
+ // ── GET /webhook/whatsapp — verificação do webhook pela Meta ─────────────────
2505
+ // A Meta faz um GET nessa URL quando você cadastra o webhook no Business Manager.
2506
+ // Ela envia: hub.mode=subscribe, hub.verify_token=<seu token>, hub.challenge=<número>
2507
+ // Você responde com hub.challenge para confirmar que é seu servidor.
2508
+ // Secret: wrangler secret put WA_WEBHOOK_VERIFY_TOKEN
2509
+ if (request.method === 'GET' && url.pathname === '/webhook/whatsapp') {
2510
+ const mode = url.searchParams.get('hub.mode');
2511
+ const token = url.searchParams.get('hub.verify_token');
2512
+ const challenge = url.searchParams.get('hub.challenge');
2513
+
2514
+ if (mode === 'subscribe' && env.WA_WEBHOOK_VERIFY_TOKEN && token === env.WA_WEBHOOK_VERIFY_TOKEN) {
2515
+ return new Response(challenge, { status: 200, headers: { 'Content-Type': 'text/plain' } });
2516
+ }
2517
+ return new Response('Forbidden', { status: 403 });
2518
+ }
2519
+
2520
+ // ── POST /webhook/whatsapp — mensagens recebidas (CTWA) ───────────────────────
2521
+ // Recebe eventos da Meta Cloud API: mensagens de usuários que clicaram em
2522
+ // anúncios "Click to WhatsApp". Extrai phone + ctwa_clid e dispara Contact
2523
+ // no Meta CAPI com action_source="chat".
2524
+ // URL a cadastrar: Meta Business Manager → WhatsApp → Configuration → Webhook URL
2525
+ // Campos a assinar (subscribe): messages
2526
+ if (request.method === 'POST' && url.pathname === '/webhook/whatsapp') {
2527
+ let body;
2528
+ try { body = await request.json(); } catch {
2529
+ return new Response('JSON inválido', { status: 400 });
2530
+ }
2531
+
2532
+ const result = await processWhatsAppWebhook(env, body, request, ctx);
2533
+
2534
+ // A Meta exige resposta 200 em até 20s — mesmo que não haja nada a processar
2535
+ return new Response(JSON.stringify({ ok: true, ...result }), { status: 200, headers });
2536
+ }
2537
+
2538
+ // 404 para rotas não encontradas
2539
+ return new Response(
2540
+ JSON.stringify({ error: 'rota não encontrada' }),
2541
+ { status: 404, headers }
2542
+ );
2543
+ },
2544
+
2545
+ // ── Cron Handler — Intelligence Agent ──────────────────────────────────────
2546
+ async scheduled(event, env, ctx) {
2547
+ const cron = event.cron; // '0 2 * * 0' ou '0 3 1 * *'
2548
+ const isMonthly = cron === '0 3 1 * *';
2549
+
2550
+ console.log(`[Intelligence Agent] Cron executado: ${cron}`);
2551
+
2552
+ ctx.waitUntil(runIntelligenceAgent(env, isMonthly ? 'monthly_audit' : 'weekly_check'));
2553
+ },
2554
+
2555
+ // ── Queue Consumer — Retry de eventos com falha ────────────────────────────
2556
+ async queue(batch, env) {
2557
+ for (const message of batch.messages) {
2558
+ const { eventType, payload, platform, attempt = 1 } = message.body;
2559
+
2560
+ console.log(`[Queue] Reprocessando: ${platform}/${eventType} (tentativa ${attempt})`);
2561
+
2562
+ try {
2563
+ if (platform === 'meta') await sendMetaCapi(env, eventType, payload, null, null);
2564
+ if (platform === 'ga4') await sendGA4Mp(env, eventType, payload, null);
2565
+ if (platform === 'tiktok') await sendTikTokApi(env, eventType, payload, null, null);
2566
+
2567
+ message.ack(); // sucesso — remove da fila
2568
+ } catch (err) {
2569
+ console.error(`[Queue] Falha ao reprocessar ${platform}/${eventType}:`, err.message);
2570
+ message.retry(); // reenfileira até max_retries, depois vai para DLQ
2571
+ }
2572
+ }
2573
+ },
2574
+ };