cdp-edge 2.5.9 → 2.6.1
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.
- package/README.md +247 -211
- package/bin/cdp-edge.js +1 -0
- package/contracts/agent-versions.json +2 -2
- package/dist/commands/infra.js +1 -1
- package/dist/commands/server.js +38 -33
- package/dist/commands/setup.js +3 -0
- package/dist/commands/validate.js +251 -236
- package/dist/sdk/cdpTrack.js +6 -4
- package/dist/sdk/cdpTrack.min.js +4 -4
- package/dist/sdk/install-snippet.html +1 -1
- package/extracted-skill/tracking-events-generator/INTEGRACAO-COMPLETA.md +4 -4
- package/extracted-skill/tracking-events-generator/Premium-Tracking-Intelligence-Resumo.md +3 -3
- package/extracted-skill/tracking-events-generator/agents/master-orchestrator.md +78 -33
- package/extracted-skill/tracking-events-generator/agents/whatsapp-agent.md +562 -93
- package/extracted-skill/tracking-events-generator/integration-test.js +3 -3
- package/extracted-skill/tracking-events-generator/knowledge-base.md +12 -12
- package/extracted-skill/tracking-events-generator/models/checkout-proprio.md +1 -1
- package/extracted-skill/tracking-events-generator/models/multi-step-checkout.md +4 -4
- package/extracted-skill/tracking-events-generator/models/reddit/conversions-api-template.js +1 -1
- package/extracted-skill/tracking-events-generator/models/scenarios/behavior-engine.js +1 -1
- package/extracted-skill/tracking-events-generator/models/scenarios/sales-page-logic.md +1 -1
- package/extracted-skill/tracking-events-generator/models/trafego-direto.md +7 -7
- package/package.json +2 -2
- package/server-edge-tracker/.client.env.example +5 -0
- package/server-edge-tracker/deploy-client.cjs +47 -31
- package/server-edge-tracker/index.ts +1267 -1204
- package/server-edge-tracker/modules/db.ts +2 -2
- package/server-edge-tracker/modules/dispatch/meta.ts +3 -0
- package/server-edge-tracker/modules/dispatch/tiktok.ts +1 -0
- package/server-edge-tracker/modules/dispatch/whatsapp.ts +5 -2
- package/server-edge-tracker/modules/utils.ts +1 -1
- package/server-edge-tracker/types.ts +3 -0
- package/server-edge-tracker/wrangler.toml +2 -0
- package/templates/checkout-proprio.md +1 -1
- package/templates/install/CLAUDE.md +1 -1
- package/templates/multi-step-checkout.md +4 -4
- package/templates/reddit/conversions-api-template.js +1 -1
- package/templates/scenarios/behavior-engine.js +1 -1
- package/templates/scenarios/sales-page-logic.md +1 -1
- package/templates/trafego-direto.md +7 -7
- package/templates/vsl-page.md +2 -2
- package/extracted-skill/tracking-events-generator/agents/whatsapp-ctwa-setup-agent.md +0 -707
- package/extracted-skill/tracking-events-generator/agents/zapman-agent.md +0 -189
- package/server-edge-tracker/.client.env +0 -5
- package/server-edge-tracker/dist-check/README.md +0 -1
- package/server-edge-tracker/dist-check/index.js +0 -5164
- 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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
176
|
-
if (
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
//
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
//
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
);
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
}
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
const
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
const
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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
|
-
|
|
1157
|
-
|
|
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
|
-
// ──
|
|
1182
|
-
if (url.pathname === '/api/
|
|
1183
|
-
return
|
|
1184
|
-
}
|
|
1185
|
-
if (url.pathname === '/api/
|
|
1186
|
-
return
|
|
1187
|
-
}
|
|
1188
|
-
if (url.pathname === '/api/
|
|
1189
|
-
return
|
|
1190
|
-
}
|
|
1191
|
-
if (url.pathname === '/api/
|
|
1192
|
-
return
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
|
|
1205
|
+
// ── ML — Segmentaçã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
|
+
};
|