cdp-edge 2.5.8 → 2.6.0

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