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