cdp-edge 2.2.1 → 2.2.3
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 +2 -2
- package/dist/commands/server.js +4 -4
- package/extracted-skill/tracking-events-generator/agents/page-analyzer.md +6 -2
- package/extracted-skill/tracking-events-generator/models/lancamento-imobiliario.md +344 -0
- package/package.json +3 -2
- package/server-edge-tracker/INSTALAR.md +5 -5
- package/server-edge-tracker/modules/ml/ltv.js +6 -0
- package/server-edge-tracker/modules/utils.js +5 -3
- package/server-edge-tracker/wrangler.toml +10 -9
- package/templates/lancamento-imobiliario.md +344 -0
- package/server-edge-tracker/worker.js +0 -4635
|
@@ -1,4635 +0,0 @@
|
|
|
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
|
-
// IDs de pixel lidos exclusivamente de env.* (wrangler.toml [vars] ou wrangler secret put)
|
|
20
|
-
// CORS — aceita o domínio raiz e qualquer subdomínio (domínio vem de env.SITE_DOMAIN)
|
|
21
|
-
function isAllowedOrigin(origin, siteDomain) {
|
|
22
|
-
if (!origin || !siteDomain) return false;
|
|
23
|
-
return origin === `https://${siteDomain}`
|
|
24
|
-
|| origin.endsWith(`.${siteDomain}`)
|
|
25
|
-
|| origin === 'http://localhost:3000'
|
|
26
|
-
|| origin === 'http://localhost:5173';
|
|
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, siteDomain) {
|
|
64
|
-
const allowed = isAllowedOrigin(origin, siteDomain) ? origin : `https://${siteDomain}`;
|
|
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
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
74
|
-
// LOGISTIC REGRESSION — LTV Model (pure JS, sem deps externas)
|
|
75
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
76
|
-
|
|
77
|
-
const _UTM_SCORES = {
|
|
78
|
-
facebook: 0.90, instagram: 0.90, meta: 0.90,
|
|
79
|
-
google: 0.82, youtube: 0.82, tiktok: 0.75,
|
|
80
|
-
email: 0.68, sms: 0.68, organic: 0.30, direct: 0.20,
|
|
81
|
-
};
|
|
82
|
-
const _INTENTION_SCORES = {
|
|
83
|
-
comprador: 1.00, high_intent: 1.00, interessado: 0.60, nurture: 0.30, curioso: 0.15,
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
function _extractFeatures(row) {
|
|
87
|
-
const src = (row.utm_source || '').toLowerCase().trim();
|
|
88
|
-
const intention = (row.intention_level || '').toLowerCase().trim();
|
|
89
|
-
const daysSince = row.days_since_lead || 0;
|
|
90
|
-
return [
|
|
91
|
-
_UTM_SCORES[src] ?? (src ? 0.10 : 0.05),
|
|
92
|
-
Math.min((row.engagement_score || 0) / 5, 1),
|
|
93
|
-
_INTENTION_SCORES[intention] ?? 0,
|
|
94
|
-
Math.max(0, 1 - daysSince / 90),
|
|
95
|
-
row.has_email ? 1 : 0,
|
|
96
|
-
row.has_phone ? 1 : 0,
|
|
97
|
-
row.is_br ? 1 : 0,
|
|
98
|
-
((row.hour || 12) / 23),
|
|
99
|
-
];
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function _sigmoid(z) {
|
|
103
|
-
if (z > 20) return 1;
|
|
104
|
-
if (z < -20) return 0;
|
|
105
|
-
return 1 / (1 + Math.exp(-z));
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function _dot(weights, features) {
|
|
109
|
-
return features.reduce((sum, f, i) => sum + (weights[i] || 0) * f, 0);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function _trainLogisticRegression(dataset, opts = {}) {
|
|
113
|
-
if (!dataset || dataset.length < 50) return null;
|
|
114
|
-
const iterations = opts.iterations || 200;
|
|
115
|
-
const learningRate = opts.learningRate || 0.1;
|
|
116
|
-
const lambda = opts.lambda || 0.01;
|
|
117
|
-
const nFeatures = dataset[0].features.length;
|
|
118
|
-
let bias = 0;
|
|
119
|
-
let weights = new Array(nFeatures).fill(0);
|
|
120
|
-
const positives = dataset.filter(d => d.label === 1).length;
|
|
121
|
-
const positiveRate = positives / dataset.length;
|
|
122
|
-
if (positiveRate < 0.03) return null;
|
|
123
|
-
for (let iter = 0; iter < iterations; iter++) {
|
|
124
|
-
let dBias = 0;
|
|
125
|
-
const dWeights = new Array(nFeatures).fill(0);
|
|
126
|
-
for (const { features, label } of dataset) {
|
|
127
|
-
const error = _sigmoid(_dot(weights, features) + bias) - label;
|
|
128
|
-
dBias += error;
|
|
129
|
-
for (let j = 0; j < nFeatures; j++) dWeights[j] += error * features[j];
|
|
130
|
-
}
|
|
131
|
-
const n = dataset.length;
|
|
132
|
-
bias -= learningRate * (dBias / n);
|
|
133
|
-
for (let j = 0; j < nFeatures; j++) {
|
|
134
|
-
weights[j] -= learningRate * ((dWeights[j] / n) + lambda * weights[j]);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
let correct = 0;
|
|
138
|
-
const threshold = positiveRate > 0.3 ? 0.5 : Math.max(0.3, positiveRate * 1.5);
|
|
139
|
-
for (const { features, label } of dataset) {
|
|
140
|
-
if ((_sigmoid(_dot(weights, features) + bias) >= threshold ? 1 : 0) === label) correct++;
|
|
141
|
-
}
|
|
142
|
-
return { bias, weights, accuracy: correct / dataset.length, positiveRate, sampleSize: dataset.length, threshold, trainedAt: new Date().toISOString() };
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function _predictWithWeights(model, features) {
|
|
146
|
-
return Math.round(_sigmoid(_dot(model.weights, features) + model.bias) * 100);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const _LTV_WEIGHTS_KV_KEY = 'ltv_weights_active';
|
|
150
|
-
|
|
151
|
-
async function _loadActiveWeights(env) {
|
|
152
|
-
if (env.GEO_CACHE) {
|
|
153
|
-
try {
|
|
154
|
-
const cached = await env.GEO_CACHE.get(_LTV_WEIGHTS_KV_KEY, 'json');
|
|
155
|
-
if (cached?.weights?.length) return cached;
|
|
156
|
-
} catch {}
|
|
157
|
-
}
|
|
158
|
-
if (!env.DB) return null;
|
|
159
|
-
try {
|
|
160
|
-
const row = await env.DB.prepare(
|
|
161
|
-
`SELECT weights_json FROM ltv_model_weights WHERE is_active = 1 ORDER BY trained_at DESC LIMIT 1`
|
|
162
|
-
).first();
|
|
163
|
-
if (!row?.weights_json) return null;
|
|
164
|
-
const model = JSON.parse(row.weights_json);
|
|
165
|
-
if (env.GEO_CACHE && model?.weights?.length) {
|
|
166
|
-
env.GEO_CACHE.put(_LTV_WEIGHTS_KV_KEY, JSON.stringify(model), { expirationTtl: 604800 }).catch(() => {});
|
|
167
|
-
}
|
|
168
|
-
return model;
|
|
169
|
-
} catch { return null; }
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
async function _saveWeights(DB, model) {
|
|
173
|
-
if (!DB || !model) return;
|
|
174
|
-
await DB.prepare(`UPDATE ltv_model_weights SET is_active = 0 WHERE is_active = 1`).run();
|
|
175
|
-
await DB.prepare(`
|
|
176
|
-
INSERT INTO ltv_model_weights (trained_at, is_active, sample_size, positive_rate, accuracy, weights_json)
|
|
177
|
-
VALUES (?, 1, ?, ?, ?, ?)
|
|
178
|
-
`).bind(new Date().toISOString(), model.sampleSize, model.positiveRate, model.accuracy, JSON.stringify(model)).run();
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
182
|
-
// MATCH QUALITY — Tracking de qualidade dos dados enviados ao Meta CAPI
|
|
183
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
184
|
-
|
|
185
|
-
const _MQ_THRESHOLDS = {
|
|
186
|
-
email_rate_min: 0.40, fbp_rate_min: 0.30, composite_min: 0.45, min_events_alert: 10,
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
async function _logMatchQuality(DB, eventName, payload, recovered = {}) {
|
|
190
|
-
if (!DB) return;
|
|
191
|
-
try {
|
|
192
|
-
await DB.prepare(`
|
|
193
|
-
INSERT INTO match_quality_log (
|
|
194
|
-
event_name, has_email, has_phone, has_fbp, has_fbc, has_external_id,
|
|
195
|
-
was_email_recovered, was_utm_restored
|
|
196
|
-
) VALUES (?,?,?,?,?,?,?,?)
|
|
197
|
-
`).bind(
|
|
198
|
-
eventName,
|
|
199
|
-
payload.email ? 1 : 0, payload.phone ? 1 : 0,
|
|
200
|
-
payload.fbp ? 1 : 0, payload.fbc ? 1 : 0,
|
|
201
|
-
payload.userId ? 1 : 0,
|
|
202
|
-
recovered.email ? 1 : 0, recovered.utm ? 1 : 0,
|
|
203
|
-
).run();
|
|
204
|
-
} catch { /* não bloquear dispatch */ }
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
async function _autoEnrichPayload(env, payload) {
|
|
208
|
-
const recovered = { email: false, utm: false };
|
|
209
|
-
if (!env.DB) return { payload, recovered };
|
|
210
|
-
if (!payload.email && payload.userId) {
|
|
211
|
-
try {
|
|
212
|
-
const profile = await env.DB.prepare(
|
|
213
|
-
`SELECT email, fbp, fbc, phone FROM user_profiles WHERE user_id = ? LIMIT 1`
|
|
214
|
-
).bind(payload.userId).first();
|
|
215
|
-
if (profile) {
|
|
216
|
-
if (profile.email && !payload.email) { payload.email = profile.email; recovered.email = true; }
|
|
217
|
-
if (profile.fbp && !payload.fbp) payload.fbp = profile.fbp;
|
|
218
|
-
if (profile.fbc && !payload.fbc) payload.fbc = profile.fbc;
|
|
219
|
-
if (profile.phone && !payload.phone) payload.phone = profile.phone;
|
|
220
|
-
}
|
|
221
|
-
} catch {}
|
|
222
|
-
}
|
|
223
|
-
if (payload.utmRestored) recovered.utm = true;
|
|
224
|
-
return { payload, recovered };
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
async function _analyzeMatchQuality(env) {
|
|
228
|
-
if (!env.DB) return null;
|
|
229
|
-
try {
|
|
230
|
-
const row = await env.DB.prepare(`
|
|
231
|
-
SELECT
|
|
232
|
-
COUNT(*) AS total,
|
|
233
|
-
ROUND(AVG(has_email) * 100, 1) AS email_rate,
|
|
234
|
-
ROUND(AVG(has_phone) * 100, 1) AS phone_rate,
|
|
235
|
-
ROUND(AVG(has_fbp) * 100, 1) AS fbp_rate,
|
|
236
|
-
ROUND(AVG(has_fbc) * 100, 1) AS fbc_rate,
|
|
237
|
-
ROUND(AVG(has_external_id) * 100, 1) AS ext_id_rate,
|
|
238
|
-
ROUND(AVG(was_email_recovered) * 100, 1) AS email_recovered_rate,
|
|
239
|
-
ROUND((AVG(has_email)*0.4 + AVG(has_fbp)*0.3 + AVG(has_phone)*0.2 + AVG(has_fbc)*0.1) * 100, 1) AS composite_score
|
|
240
|
-
FROM match_quality_log
|
|
241
|
-
WHERE logged_at >= datetime('now', '-2 hours')
|
|
242
|
-
`).first();
|
|
243
|
-
if (!row || row.total < _MQ_THRESHOLDS.min_events_alert) return { total: row?.total || 0, alerts: [] };
|
|
244
|
-
const alerts = [];
|
|
245
|
-
if ((row.email_rate || 0) < _MQ_THRESHOLDS.email_rate_min * 100)
|
|
246
|
-
alerts.push({ type: 'email_low', message: `Taxa de email baixa: ${row.email_rate}%` });
|
|
247
|
-
if ((row.fbp_rate || 0) < _MQ_THRESHOLDS.fbp_rate_min * 100)
|
|
248
|
-
alerts.push({ type: 'fbp_low', message: `Cookie fbp ausente em ${100 - row.fbp_rate}% dos eventos` });
|
|
249
|
-
if ((row.composite_score || 0) < _MQ_THRESHOLDS.composite_min * 100)
|
|
250
|
-
alerts.push({ type: 'composite_critical', message: `Score composto crítico: ${row.composite_score}%`, severity: 'critical' });
|
|
251
|
-
return { ...row, alerts };
|
|
252
|
-
} catch (err) { console.error('[MatchQuality] analyze error:', err.message); return null; }
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
async function _alertMatchQuality(env, analysis) {
|
|
256
|
-
if (!analysis || analysis.alerts.length === 0) return;
|
|
257
|
-
const hasCritical = analysis.alerts.some(a => a.severity === 'critical');
|
|
258
|
-
const icon = hasCritical ? '🚨' : '⚠️';
|
|
259
|
-
const lines = [
|
|
260
|
-
`${icon} CDP Edge — Match Quality Alert`,
|
|
261
|
-
``,
|
|
262
|
-
`📊 Últimas 2h (${analysis.total} eventos):`,
|
|
263
|
-
` Email: ${analysis.email_rate ?? 0}% ${(analysis.email_rate ?? 0) < 40 ? '❌' : '✅'}`,
|
|
264
|
-
` fbp: ${analysis.fbp_rate ?? 0}% ${(analysis.fbp_rate ?? 0) < 30 ? '❌' : '✅'}`,
|
|
265
|
-
` Score: ${analysis.composite_score ?? 0}%`,
|
|
266
|
-
``,
|
|
267
|
-
`🔍 Problemas:`,
|
|
268
|
-
...analysis.alerts.map(a => ` · ${a.message}`),
|
|
269
|
-
``,
|
|
270
|
-
`🛠 Identity Graph recovery: ${analysis.email_recovered_rate ?? 0}% emails recuperados`,
|
|
271
|
-
new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' }),
|
|
272
|
-
];
|
|
273
|
-
await sendCallMeBot(env, lines.join('\n'));
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
async function _purgeOldMatchQualityLogs(DB) {
|
|
277
|
-
if (!DB) return;
|
|
278
|
-
try {
|
|
279
|
-
await DB.prepare(`DELETE FROM match_quality_log WHERE logged_at < datetime('now', '-30 days')`).run();
|
|
280
|
-
} catch {}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// ── Meta CAPI v22.0 ───────────────────────────────────────────────────────────
|
|
284
|
-
async function sendMetaCapi(env, eventName, payload, request, ctx) {
|
|
285
|
-
// Auto-enriquecer payload com dados do Identity Graph antes do envio
|
|
286
|
-
let recovered = { email: false, utm: false };
|
|
287
|
-
if (env.DB && payload) {
|
|
288
|
-
const enriched = await _autoEnrichPayload(env, payload);
|
|
289
|
-
payload = enriched.payload;
|
|
290
|
-
recovered = enriched.recovered;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
const {
|
|
294
|
-
email, phone, firstName, lastName,
|
|
295
|
-
city, state, country,
|
|
296
|
-
zip, dob,
|
|
297
|
-
fbp, fbc, userId,
|
|
298
|
-
eventId, pageUrl,
|
|
299
|
-
value, currency,
|
|
300
|
-
contentIds, contentName, contentType, numItems,
|
|
301
|
-
} = payload;
|
|
302
|
-
|
|
303
|
-
const phoneNorm = normalizePhone(phone);
|
|
304
|
-
const countryCode = (country || request.cf?.country || 'br').toLowerCase();
|
|
305
|
-
const stateCode = state ? String(state).toLowerCase() : undefined;
|
|
306
|
-
const cityNorm = normalizeCity(city);
|
|
307
|
-
|
|
308
|
-
// user_data — hashear tudo com SHA-256 antes de enviar
|
|
309
|
-
const userData = {
|
|
310
|
-
...(email && { em: await sha256(email) }),
|
|
311
|
-
...(phoneNorm && { ph: await sha256(phoneNorm) }),
|
|
312
|
-
...(firstName && { fn: await sha256(firstName) }),
|
|
313
|
-
...(lastName && { ln: await sha256(lastName) }),
|
|
314
|
-
...(cityNorm && { ct: await sha256(cityNorm) }),
|
|
315
|
-
...(stateCode && { st: await sha256(stateCode) }),
|
|
316
|
-
...(countryCode && { country: await sha256(countryCode) }),
|
|
317
|
-
...(userId && { external_id: await sha256(String(userId)) }),
|
|
318
|
-
...(zip && { zp: await sha256(zip) }),
|
|
319
|
-
...(dob && { db: await sha256(dob) }),
|
|
320
|
-
...(fbp && { fbp }), // cookies NÃO são hasheados
|
|
321
|
-
...(fbc && { fbc }),
|
|
322
|
-
client_ip_address: request.headers.get('CF-Connecting-IP')
|
|
323
|
-
|| request.headers.get('X-Forwarded-For')
|
|
324
|
-
|| '',
|
|
325
|
-
client_user_agent: request.headers.get('User-Agent') || '',
|
|
326
|
-
};
|
|
327
|
-
|
|
328
|
-
// custom_data — dados do produto/valor
|
|
329
|
-
const customData = {
|
|
330
|
-
...(value !== undefined && { value: parseFloat(value) }),
|
|
331
|
-
...(currency && { currency: String(currency).toUpperCase() }),
|
|
332
|
-
...(contentIds && contentIds.length > 0 && { content_ids: contentIds }),
|
|
333
|
-
...(contentName && { content_name: contentName }),
|
|
334
|
-
...(contentType && { content_type: contentType }),
|
|
335
|
-
...(numItems && { num_items: parseInt(numItems) }),
|
|
336
|
-
};
|
|
337
|
-
|
|
338
|
-
const eventPayload = {
|
|
339
|
-
event_name: eventName,
|
|
340
|
-
event_time: Math.floor(Date.now() / 1000),
|
|
341
|
-
event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
342
|
-
event_source_url: pageUrl || `https://${env.SITE_DOMAIN}`,
|
|
343
|
-
action_source: 'website',
|
|
344
|
-
user_data: userData,
|
|
345
|
-
...(Object.keys(customData).length > 0 && { custom_data: customData }),
|
|
346
|
-
};
|
|
347
|
-
|
|
348
|
-
const requestBody = {
|
|
349
|
-
data: [eventPayload],
|
|
350
|
-
access_token: env.META_ACCESS_TOKEN,
|
|
351
|
-
};
|
|
352
|
-
|
|
353
|
-
// Test Event Code — só em staging, remover em produção
|
|
354
|
-
if (env.META_TEST_CODE) {
|
|
355
|
-
requestBody.test_event_code = env.META_TEST_CODE;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
// Logar match quality em background (não bloqueia dispatch)
|
|
359
|
-
if (env.DB && ctx) {
|
|
360
|
-
ctx.waitUntil(_logMatchQuality(env.DB, eventName, payload, recovered));
|
|
361
|
-
} else if (env.DB) {
|
|
362
|
-
_logMatchQuality(env.DB, eventName, payload, recovered).catch(() => {});
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
const endpoint = `https://graph.facebook.com/v22.0/${env.META_PIXEL_ID}/events`;
|
|
366
|
-
|
|
367
|
-
try {
|
|
368
|
-
const res = await fetch(endpoint, {
|
|
369
|
-
method: 'POST',
|
|
370
|
-
headers: { 'Content-Type': 'application/json' },
|
|
371
|
-
body: JSON.stringify(requestBody),
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
const data = await res.json();
|
|
375
|
-
|
|
376
|
-
if (!res.ok) {
|
|
377
|
-
const errorCode = data.error?.code || String(res.status);
|
|
378
|
-
const errorMessage = data.error?.message || data.error?.error_user_msg || 'Unknown error';
|
|
379
|
-
console.error('Meta CAPI error:', res.status, data.error?.message || data.error?.error_user_msg || 'unknown');
|
|
380
|
-
|
|
381
|
-
// Log de falha para Feedback Loop
|
|
382
|
-
if (env.DB) {
|
|
383
|
-
ctx.waitUntil(logApiFailure(
|
|
384
|
-
env.DB,
|
|
385
|
-
'meta',
|
|
386
|
-
eventName,
|
|
387
|
-
errorCode,
|
|
388
|
-
errorMessage,
|
|
389
|
-
eventPayload.event_id,
|
|
390
|
-
JSON.stringify(requestBody)
|
|
391
|
-
));
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
return data;
|
|
396
|
-
} catch (err) {
|
|
397
|
-
console.error('Meta CAPI fetch failed:', err.message);
|
|
398
|
-
|
|
399
|
-
// Log de falha para Feedback Loop
|
|
400
|
-
if (env.DB) {
|
|
401
|
-
ctx.waitUntil(logApiFailure(
|
|
402
|
-
env.DB,
|
|
403
|
-
'meta',
|
|
404
|
-
eventName,
|
|
405
|
-
'FETCH_ERROR',
|
|
406
|
-
err.message,
|
|
407
|
-
eventPayload.event_id,
|
|
408
|
-
JSON.stringify(requestBody)
|
|
409
|
-
));
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
return { error: err.message };
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// ── GA4 Measurement Protocol ──────────────────────────────────────────────────
|
|
417
|
-
async function sendGA4Mp(env, ga4EventName, payload, ctx) {
|
|
418
|
-
if (!env.GA4_API_SECRET) return { skipped: 'GA4_API_SECRET not set' };
|
|
419
|
-
|
|
420
|
-
const {
|
|
421
|
-
clientId, sessionId,
|
|
422
|
-
value, currency, contentName,
|
|
423
|
-
email, phone, firstName,
|
|
424
|
-
orderId,
|
|
425
|
-
} = payload;
|
|
426
|
-
|
|
427
|
-
// GA4 MP exige client_id (cookie _ga)
|
|
428
|
-
if (!clientId) return { skipped: 'no clientId' };
|
|
429
|
-
|
|
430
|
-
const eventParams = {
|
|
431
|
-
...(value !== undefined && { value: parseFloat(value) }),
|
|
432
|
-
...(currency && { currency: String(currency).toUpperCase() }),
|
|
433
|
-
...(contentName && { content_name: contentName }),
|
|
434
|
-
...(orderId && { transaction_id: orderId }),
|
|
435
|
-
...(email && { user_data_email_address: email.toLowerCase().trim() }),
|
|
436
|
-
...(phone && { user_data_phone_number: normalizePhone(phone) }),
|
|
437
|
-
...(firstName && { user_data_first_name: firstName.toLowerCase().trim() }),
|
|
438
|
-
...(sessionId && { session_id: sessionId }),
|
|
439
|
-
engagement_time_msec: 100,
|
|
440
|
-
};
|
|
441
|
-
|
|
442
|
-
const body = {
|
|
443
|
-
client_id: clientId,
|
|
444
|
-
events: [{ name: ga4EventName, params: eventParams }],
|
|
445
|
-
};
|
|
446
|
-
|
|
447
|
-
const url = `https://www.google-analytics.com/mp/collect`
|
|
448
|
-
+ `?measurement_id=${env.GA4_MEASUREMENT_ID}`
|
|
449
|
-
+ `&api_secret=${env.GA4_API_SECRET}`;
|
|
450
|
-
|
|
451
|
-
try {
|
|
452
|
-
const res = await fetch(url, {
|
|
453
|
-
method: 'POST',
|
|
454
|
-
headers: { 'Content-Type': 'application/json' },
|
|
455
|
-
body: JSON.stringify(body),
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
// GA4 MP retorna 204 em sucesso (sem body)
|
|
459
|
-
if (res.status !== 204) {
|
|
460
|
-
// Log de falha para Feedback Loop
|
|
461
|
-
if (env.DB && ctx) {
|
|
462
|
-
ctx.waitUntil(logApiFailure(
|
|
463
|
-
env.DB,
|
|
464
|
-
'ga4',
|
|
465
|
-
ga4EventName,
|
|
466
|
-
String(res.status),
|
|
467
|
-
'GA4 returned non-204 status',
|
|
468
|
-
null,
|
|
469
|
-
JSON.stringify(body)
|
|
470
|
-
));
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
return res.status === 204 ? { ok: true } : { status: res.status };
|
|
475
|
-
} catch (err) {
|
|
476
|
-
console.error('GA4 MP fetch failed:', err.message);
|
|
477
|
-
|
|
478
|
-
// Log de falha para Feedback Loop
|
|
479
|
-
if (env.DB && ctx) {
|
|
480
|
-
ctx.waitUntil(logApiFailure(
|
|
481
|
-
env.DB,
|
|
482
|
-
'ga4',
|
|
483
|
-
ga4EventName,
|
|
484
|
-
'FETCH_ERROR',
|
|
485
|
-
err.message,
|
|
486
|
-
null,
|
|
487
|
-
JSON.stringify(body)
|
|
488
|
-
));
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
return { error: err.message };
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// ── D1 — salvar lead ──────────────────────────────────────────────────────────
|
|
496
|
-
async function saveLead(env, eventName, payload, request, platform = 'website') {
|
|
497
|
-
if (!env.DB) return;
|
|
498
|
-
try {
|
|
499
|
-
const {
|
|
500
|
-
email, phone, firstName, lastName,
|
|
501
|
-
city, state, country,
|
|
502
|
-
fbp, fbc, userId,
|
|
503
|
-
utmSource, utmMedium, utmCampaign, utmContent, utmTerm,
|
|
504
|
-
pageUrl, value, currency, eventId, botScore,
|
|
505
|
-
engagementScore, intentionLevel, utmRestored,
|
|
506
|
-
} = payload;
|
|
507
|
-
|
|
508
|
-
await env.DB.prepare(`
|
|
509
|
-
INSERT INTO leads (
|
|
510
|
-
event_name, event_id, email, phone, first_name, last_name,
|
|
511
|
-
city, state, country, fbp, fbc, user_id,
|
|
512
|
-
utm_source, utm_medium, utm_campaign, utm_content, utm_term,
|
|
513
|
-
page_url, value, currency, ip_address, platform, bot_score,
|
|
514
|
-
engagement_score, intention_level, utm_restored, created_at
|
|
515
|
-
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
|
|
516
|
-
`).bind(
|
|
517
|
-
eventName,
|
|
518
|
-
eventId || null,
|
|
519
|
-
email || null,
|
|
520
|
-
normalizePhone(phone) || null,
|
|
521
|
-
firstName || null,
|
|
522
|
-
lastName || null,
|
|
523
|
-
city || null,
|
|
524
|
-
state || null,
|
|
525
|
-
(country || request.cf?.country || null),
|
|
526
|
-
fbp || null,
|
|
527
|
-
fbc || null,
|
|
528
|
-
userId || null,
|
|
529
|
-
utmSource || null,
|
|
530
|
-
utmMedium || null,
|
|
531
|
-
utmCampaign || null,
|
|
532
|
-
utmContent || null,
|
|
533
|
-
utmTerm || null,
|
|
534
|
-
pageUrl || null,
|
|
535
|
-
value !== undefined ? parseFloat(value) : null,
|
|
536
|
-
currency || 'BRL',
|
|
537
|
-
request.headers.get('CF-Connecting-IP') || null,
|
|
538
|
-
platform,
|
|
539
|
-
botScore || 0,
|
|
540
|
-
engagementScore !== undefined ? parseFloat(engagementScore) : null,
|
|
541
|
-
intentionLevel || null,
|
|
542
|
-
utmRestored ? 1 : 0,
|
|
543
|
-
).run();
|
|
544
|
-
} catch (err) {
|
|
545
|
-
console.error('D1 saveLead error:', err.message);
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// ── D1 — upsert perfil (acumula cookies entre visitas) ───────────────────────
|
|
550
|
-
// ── Cálculo de Cohort Label baseado no score acumulado ───────────────────────
|
|
551
|
-
function calculateCohortLabel(score, eventName) {
|
|
552
|
-
if (eventName === 'Purchase') return 'buyer_lookalike';
|
|
553
|
-
if (score >= 80) return 'high_intent';
|
|
554
|
-
if (score >= 30) return 'nurture';
|
|
555
|
-
return 'lost';
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
async function upsertProfile(env, eventName, payload, request) {
|
|
559
|
-
if (!env.DB || !payload.userId) return;
|
|
560
|
-
try {
|
|
561
|
-
const {
|
|
562
|
-
userId, email, phone,
|
|
563
|
-
fbp, fbc, ttp, gclid, ttclid, gaClientId,
|
|
564
|
-
city, state, country,
|
|
565
|
-
engagementScore, userScore,
|
|
566
|
-
} = payload;
|
|
567
|
-
|
|
568
|
-
// Score base por evento + bônus de engajamento do browser
|
|
569
|
-
const scoreMap = { PageView: 5, ViewContent: 10, ScrollDepth: 3, TimeOnPage: 5, Lead: 30, InitiateCheckout: 50, Purchase: 100 };
|
|
570
|
-
const eventScore = scoreMap[eventName] || 2;
|
|
571
|
-
|
|
572
|
-
// userScore vem do BehaviorEngine (0-100), engagementScore do engagement-scoring.js (0-5)
|
|
573
|
-
// Normaliza ambos para uma escala de bônus adicional (0-20)
|
|
574
|
-
const behaviorBonus = userScore
|
|
575
|
-
? Math.round((Math.min(userScore, 100) / 100) * 20)
|
|
576
|
-
: (engagementScore ? Math.round((Math.min(engagementScore, 5) / 5) * 10) : 0);
|
|
577
|
-
|
|
578
|
-
const totalDelta = eventScore + behaviorBonus;
|
|
579
|
-
|
|
580
|
-
await env.DB.prepare(`
|
|
581
|
-
INSERT INTO user_profiles
|
|
582
|
-
(user_id, email, phone, fbp, fbc, ttp, gclid, ttclid, ga_client_id,
|
|
583
|
-
city, state, country, score, cohort_label, created_at, updated_at)
|
|
584
|
-
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'),datetime('now'))
|
|
585
|
-
ON CONFLICT(user_id) DO UPDATE SET
|
|
586
|
-
email = COALESCE(excluded.email, user_profiles.email),
|
|
587
|
-
phone = COALESCE(excluded.phone, user_profiles.phone),
|
|
588
|
-
fbp = COALESCE(excluded.fbp, user_profiles.fbp),
|
|
589
|
-
fbc = COALESCE(excluded.fbc, user_profiles.fbc),
|
|
590
|
-
ttp = COALESCE(excluded.ttp, user_profiles.ttp),
|
|
591
|
-
gclid = COALESCE(excluded.gclid, user_profiles.gclid),
|
|
592
|
-
ttclid = COALESCE(excluded.ttclid, user_profiles.ttclid),
|
|
593
|
-
ga_client_id = COALESCE(excluded.ga_client_id, user_profiles.ga_client_id),
|
|
594
|
-
city = COALESCE(excluded.city, user_profiles.city),
|
|
595
|
-
state = COALESCE(excluded.state, user_profiles.state),
|
|
596
|
-
country = COALESCE(excluded.country, user_profiles.country),
|
|
597
|
-
score = user_profiles.score + excluded.score,
|
|
598
|
-
cohort_label = excluded.cohort_label,
|
|
599
|
-
updated_at = datetime('now')
|
|
600
|
-
`).bind(
|
|
601
|
-
userId,
|
|
602
|
-
email || null,
|
|
603
|
-
normalizePhone(phone) || null,
|
|
604
|
-
fbp || null,
|
|
605
|
-
fbc || null,
|
|
606
|
-
ttp || null,
|
|
607
|
-
gclid || null,
|
|
608
|
-
ttclid || null,
|
|
609
|
-
gaClientId || null,
|
|
610
|
-
city || null,
|
|
611
|
-
state || null,
|
|
612
|
-
(country || request.cf?.country || null),
|
|
613
|
-
totalDelta,
|
|
614
|
-
calculateCohortLabel(totalDelta, eventName),
|
|
615
|
-
).run();
|
|
616
|
-
} catch (err) {
|
|
617
|
-
console.error('D1 upsertProfile error:', err.message);
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
// ── D1 — Cross-Device Graph (matching probabilístico) ────────────────────────
|
|
622
|
-
// Quando email ou phone aparecem em outro _cdp_uid, registra o par no device_graph.
|
|
623
|
-
// Não mescla dados — preserva ambos os perfis, apenas cria o link de identidade.
|
|
624
|
-
// O primary_user_id é o perfil mais antigo (mais confiável historicamente).
|
|
625
|
-
async function resolveDeviceGraph(DB, currentUserId, email, phone) {
|
|
626
|
-
if (!DB || !currentUserId) return;
|
|
627
|
-
if (!email && !phone) return;
|
|
628
|
-
|
|
629
|
-
try {
|
|
630
|
-
// Busca perfis com mesmo email OU mesmo phone mas _cdp_uid diferente
|
|
631
|
-
const conditions = [];
|
|
632
|
-
const bindings = [];
|
|
633
|
-
|
|
634
|
-
if (email) {
|
|
635
|
-
conditions.push('email = ?');
|
|
636
|
-
bindings.push(email.toLowerCase().trim());
|
|
637
|
-
}
|
|
638
|
-
if (phone) {
|
|
639
|
-
const digits = String(phone).replace(/\D/g, '');
|
|
640
|
-
if (digits.length >= 10) {
|
|
641
|
-
conditions.push('phone LIKE ?');
|
|
642
|
-
bindings.push(`%${digits.slice(-10)}`); // sufixo dos últimos 10 dígitos
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
if (conditions.length === 0) return;
|
|
647
|
-
|
|
648
|
-
bindings.push(currentUserId);
|
|
649
|
-
const rows = await DB.prepare(`
|
|
650
|
-
SELECT user_id, email, phone, created_at
|
|
651
|
-
FROM user_profiles
|
|
652
|
-
WHERE (${conditions.join(' OR ')})
|
|
653
|
-
AND user_id != ?
|
|
654
|
-
ORDER BY created_at ASC
|
|
655
|
-
LIMIT 5
|
|
656
|
-
`).bind(...bindings).all();
|
|
657
|
-
|
|
658
|
-
if (!rows.results || rows.results.length === 0) return;
|
|
659
|
-
|
|
660
|
-
for (const match of rows.results) {
|
|
661
|
-
// Determinar tipo de match e confiança
|
|
662
|
-
const emailMatch = email && match.email &&
|
|
663
|
-
email.toLowerCase().trim() === match.email.toLowerCase().trim();
|
|
664
|
-
const phoneMatch = phone && match.phone && (() => {
|
|
665
|
-
const a = String(phone).replace(/\D/g, '');
|
|
666
|
-
const b = String(match.phone).replace(/\D/g, '');
|
|
667
|
-
return a.slice(-10) === b.slice(-10) && a.length >= 10;
|
|
668
|
-
})();
|
|
669
|
-
|
|
670
|
-
if (!emailMatch && !phoneMatch) continue;
|
|
671
|
-
|
|
672
|
-
const matchType = emailMatch && phoneMatch ? 'email+phone' : (emailMatch ? 'email' : 'phone');
|
|
673
|
-
const matchConfidence = emailMatch && phoneMatch ? 0.99 : (emailMatch ? 0.95 : 0.85);
|
|
674
|
-
|
|
675
|
-
// primary = mais antigo (âncora de identidade)
|
|
676
|
-
const primary = match.user_id; // veio da query ORDER BY created_at ASC
|
|
677
|
-
const secondary = currentUserId;
|
|
678
|
-
|
|
679
|
-
await DB.prepare(`
|
|
680
|
-
INSERT OR IGNORE INTO device_graph
|
|
681
|
-
(primary_user_id, secondary_user_id, match_type, match_confidence)
|
|
682
|
-
VALUES (?, ?, ?, ?)
|
|
683
|
-
`).bind(primary, secondary, matchType, matchConfidence).run();
|
|
684
|
-
|
|
685
|
-
}
|
|
686
|
-
} catch (err) {
|
|
687
|
-
console.error('resolveDeviceGraph error:', err.message);
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
// ── Automação de Mensagens — avalia regras e dispara WA/Email ────────────────
|
|
692
|
-
// Chamado via ctx.waitUntil após saveLead() para não bloquear o browser.
|
|
693
|
-
// Requer secrets: WHATSAPP_ACCESS_TOKEN, WHATSAPP_PHONE_NUMBER_ID, RESEND_API_KEY, RESEND_FROM_EMAIL
|
|
694
|
-
async function fireAutomation(env, eventName, leadId, payload) {
|
|
695
|
-
if (!env.DB) return;
|
|
696
|
-
|
|
697
|
-
try {
|
|
698
|
-
const { results: rules } = await env.DB
|
|
699
|
-
.prepare(
|
|
700
|
-
`SELECT id, channel, subject_template, message_template
|
|
701
|
-
FROM automation_rules
|
|
702
|
-
WHERE trigger_event = ?1 AND is_active = 1`
|
|
703
|
-
)
|
|
704
|
-
.bind(eventName)
|
|
705
|
-
.all();
|
|
706
|
-
|
|
707
|
-
if (!rules || rules.length === 0) return;
|
|
708
|
-
|
|
709
|
-
const vars = {
|
|
710
|
-
name: String(payload.firstName || payload.name || ''),
|
|
711
|
-
email: String(payload.email || ''),
|
|
712
|
-
phone: String(payload.phone || ''),
|
|
713
|
-
campaign: String(payload.utm_campaign || payload.utmCampaign || ''),
|
|
714
|
-
intention: String(payload.intentionLevel || payload.intention_level || ''),
|
|
715
|
-
};
|
|
716
|
-
|
|
717
|
-
const interpolate = (tpl) =>
|
|
718
|
-
tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? '');
|
|
719
|
-
|
|
720
|
-
for (const rule of rules) {
|
|
721
|
-
const message = interpolate(rule.message_template);
|
|
722
|
-
const subject = rule.subject_template ? interpolate(rule.subject_template) : null;
|
|
723
|
-
|
|
724
|
-
try {
|
|
725
|
-
if (rule.channel === 'whatsapp' && payload.phone && env.WHATSAPP_ACCESS_TOKEN && env.WHATSAPP_PHONE_NUMBER_ID) {
|
|
726
|
-
const digits = String(payload.phone).replace(/\D/g, '');
|
|
727
|
-
const e164 = digits.startsWith('55') ? `+${digits}` : `+55${digits}`;
|
|
728
|
-
const waRes = await fetch(
|
|
729
|
-
`https://graph.facebook.com/v22.0/${env.WHATSAPP_PHONE_NUMBER_ID}/messages`,
|
|
730
|
-
{
|
|
731
|
-
method: 'POST',
|
|
732
|
-
headers: { 'Authorization': `Bearer ${env.WHATSAPP_ACCESS_TOKEN}`, 'Content-Type': 'application/json' },
|
|
733
|
-
body: JSON.stringify({ messaging_product: 'whatsapp', recipient_type: 'individual', to: e164, type: 'text', text: { body: message } }),
|
|
734
|
-
}
|
|
735
|
-
);
|
|
736
|
-
const waData = await waRes.json();
|
|
737
|
-
const status = waRes.ok ? 'sent' : 'failed';
|
|
738
|
-
const meta = waRes.ok ? (waData.messages?.[0]?.id ?? null) : JSON.stringify(waData);
|
|
739
|
-
await env.DB.prepare(
|
|
740
|
-
`INSERT INTO messaging_history (lead_id, channel, recipient, subject, content, status, meta) VALUES (?1,?2,?3,?4,?5,?6,?7)`
|
|
741
|
-
).bind(leadId, 'whatsapp', e164, null, message, status, meta).run();
|
|
742
|
-
|
|
743
|
-
} else if (rule.channel === 'email' && payload.email && env.RESEND_API_KEY) {
|
|
744
|
-
const resendRes = await fetch('https://api.resend.com/emails', {
|
|
745
|
-
method: 'POST',
|
|
746
|
-
headers: { 'Authorization': `Bearer ${env.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
|
|
747
|
-
body: JSON.stringify({
|
|
748
|
-
from: env.RESEND_FROM_EMAIL || 'noreply@cdp-edge.app',
|
|
749
|
-
to: [payload.email],
|
|
750
|
-
subject: subject || `Olá, ${vars.name || 'você'}!`,
|
|
751
|
-
html: `<p>${message.replace(/\n/g, '<br>')}</p>`,
|
|
752
|
-
}),
|
|
753
|
-
});
|
|
754
|
-
const resendData = await resendRes.json();
|
|
755
|
-
const status = resendRes.ok ? 'sent' : 'failed';
|
|
756
|
-
const meta = resendRes.ok ? (resendData.id ?? null) : JSON.stringify(resendData);
|
|
757
|
-
await env.DB.prepare(
|
|
758
|
-
`INSERT INTO messaging_history (lead_id, channel, recipient, subject, content, status, meta) VALUES (?1,?2,?3,?4,?5,?6,?7)`
|
|
759
|
-
).bind(leadId, 'email', payload.email, subject, message, status, meta).run();
|
|
760
|
-
}
|
|
761
|
-
} catch (err) {
|
|
762
|
-
console.error(`[Automation] rule ${rule.id} error:`, err.message);
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
} catch (err) {
|
|
766
|
-
console.error('[Automation] fireAutomation error:', err.message);
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
// ── D1 — buscar perfil por email (para webhooks) ──────────────────────────────
|
|
771
|
-
async function getProfileByEmail(env, email) {
|
|
772
|
-
if (!env.DB || !email) return null;
|
|
773
|
-
try {
|
|
774
|
-
return await env.DB.prepare(
|
|
775
|
-
'SELECT * FROM user_profiles WHERE email = ? ORDER BY updated_at DESC LIMIT 1'
|
|
776
|
-
).bind(email.toLowerCase().trim()).first();
|
|
777
|
-
} catch {
|
|
778
|
-
return null;
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
783
|
-
// EDGE FINGERPRINT — UTM Resurrection
|
|
784
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
785
|
-
|
|
786
|
-
// ── Edge Geo Enrichment — Workers Paid: cidade, estado, CEP, lat/lon ─────────
|
|
787
|
-
// Free tier: cf.country, cf.continent, cf.asn (sempre disponíveis)
|
|
788
|
-
// Workers Paid: cf.city, cf.region, cf.regionCode, cf.postalCode,
|
|
789
|
-
// cf.latitude, cf.longitude, cf.timezone, cf.metroCode
|
|
790
|
-
// Quando Workers Paid não está ativo, os campos Paid retornam undefined
|
|
791
|
-
// e o payload é enriquecido apenas com os dados Free disponíveis.
|
|
792
|
-
// Ao contratar Workers Paid ($5/mês) os dados passam a chegar automaticamente.
|
|
793
|
-
async function enrichGeoFromEdge(request, env, payload) {
|
|
794
|
-
const cf = request.cf || {};
|
|
795
|
-
const ip = request.headers.get('CF-Connecting-IP') || '';
|
|
796
|
-
|
|
797
|
-
// ── Tentar cache KV (TTL 1h) — evita lookup redundante por IP ────────────
|
|
798
|
-
let geoData = null;
|
|
799
|
-
if (env.GEO_CACHE && ip) {
|
|
800
|
-
try {
|
|
801
|
-
const cached = await env.GEO_CACHE.get(`geo:${ip}`, 'json');
|
|
802
|
-
if (cached) geoData = cached;
|
|
803
|
-
} catch {}
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
if (!geoData) {
|
|
807
|
-
geoData = {
|
|
808
|
-
// ── Free tier (sempre disponível) ──────────────────────────────────────
|
|
809
|
-
country: cf.country || null, // 'BR', 'US'
|
|
810
|
-
continent: cf.continent || null, // 'SA', 'NA'
|
|
811
|
-
asn: cf.asn || null, // 7628
|
|
812
|
-
asOrg: cf.asOrganization || null, // 'VIVO (Telefonica Brasil)'
|
|
813
|
-
colo: cf.colo || null, // 'GRU' (datacenter mais próximo)
|
|
814
|
-
// ── Workers Paid ($5/mês) ───────────────────────────────────────────────
|
|
815
|
-
city: cf.city || null, // 'São Paulo'
|
|
816
|
-
region: cf.region || null, // 'São Paulo' (nome do estado)
|
|
817
|
-
regionCode: cf.regionCode || null, // 'SP'
|
|
818
|
-
postalCode: cf.postalCode || null, // '01310-100'
|
|
819
|
-
latitude: cf.latitude || null, // '-23.5505'
|
|
820
|
-
longitude: cf.longitude || null, // '-46.6333'
|
|
821
|
-
timezone: cf.timezone || null, // 'America/Sao_Paulo'
|
|
822
|
-
metroCode: cf.metroCode || null, // código de área metropolitana
|
|
823
|
-
};
|
|
824
|
-
|
|
825
|
-
// Salvar no KV por 1h (geo de IP raramente muda)
|
|
826
|
-
if (env.GEO_CACHE && ip && geoData.country) {
|
|
827
|
-
try {
|
|
828
|
-
await env.GEO_CACHE.put(`geo:${ip}`, JSON.stringify(geoData), { expirationTtl: 3600 });
|
|
829
|
-
} catch {}
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
// ── Enriquecer payload (edge tem prioridade menor que dados do browser) ───
|
|
834
|
-
payload.country = payload.country || geoData.country;
|
|
835
|
-
payload.city = payload.city || geoData.city;
|
|
836
|
-
payload.state = payload.state || geoData.regionCode; // 'SP', 'RJ'
|
|
837
|
-
payload.zip = payload.zip || geoData.postalCode;
|
|
838
|
-
|
|
839
|
-
// Objeto completo disponível para agentes (Analytics, Attribution, LTV)
|
|
840
|
-
payload.geo = geoData;
|
|
841
|
-
|
|
842
|
-
return geoData;
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
// ── R2 Audit Log — grava evento consolidado no bucket cdp-edge-logs ──────────
|
|
846
|
-
// Só executa quando env.AUDIT_LOGS estiver disponível (R2 habilitado no CF Dashboard)
|
|
847
|
-
// Chamado via ctx.waitUntil — não bloqueia resposta ao browser
|
|
848
|
-
async function writeAuditLog(env, eventName, payload, geoData) {
|
|
849
|
-
if (!env.AUDIT_LOGS) return;
|
|
850
|
-
try {
|
|
851
|
-
const now = new Date();
|
|
852
|
-
const y = now.getUTCFullYear();
|
|
853
|
-
const m = String(now.getUTCMonth() + 1).padStart(2, '0');
|
|
854
|
-
const d = String(now.getUTCDate()).padStart(2, '0');
|
|
855
|
-
const key = `logs/${y}/${m}/${d}/${now.getTime()}_${eventName}.json`;
|
|
856
|
-
|
|
857
|
-
const log = {
|
|
858
|
-
timestamp: now.toISOString(),
|
|
859
|
-
event: eventName,
|
|
860
|
-
userId: payload.userId || null,
|
|
861
|
-
eventId: payload.eventId || null,
|
|
862
|
-
value: payload.value || null,
|
|
863
|
-
currency: payload.currency || null,
|
|
864
|
-
ltvClass: payload.ltvClass || null,
|
|
865
|
-
utm: {
|
|
866
|
-
source: payload.utmSource || null,
|
|
867
|
-
medium: payload.utmMedium || null,
|
|
868
|
-
campaign: payload.utmCampaign || null,
|
|
869
|
-
content: payload.utmContent || null,
|
|
870
|
-
term: payload.utmTerm || null,
|
|
871
|
-
restored: payload.utmRestored || false,
|
|
872
|
-
},
|
|
873
|
-
geo: geoData || null,
|
|
874
|
-
};
|
|
875
|
-
|
|
876
|
-
await env.AUDIT_LOGS.put(key, JSON.stringify(log), {
|
|
877
|
-
httpMetadata: { contentType: 'application/json' },
|
|
878
|
-
});
|
|
879
|
-
} catch (err) {
|
|
880
|
-
console.error('[R2 Audit] Error:', err.message);
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
// ── Gerar fingerprint a partir de signals de borda (sem PII) ─────────────────
|
|
885
|
-
// Combina: ASN (rede do usuário) + Accept-Language + base do User-Agent
|
|
886
|
-
// Efêmero por design: identifica o dispositivo/rede, não a pessoa.
|
|
887
|
-
async function generateEdgeFingerprint(request) {
|
|
888
|
-
const asn = String(request.cf?.asn || '0');
|
|
889
|
-
const lang = (request.headers.get('Accept-Language') || 'unknown').split(',')[0].trim();
|
|
890
|
-
const ua = request.headers.get('User-Agent') || '';
|
|
891
|
-
|
|
892
|
-
// Base do UA: apenas plataforma + browser (sem versão — mais estável entre sessões)
|
|
893
|
-
// Ex: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
|
894
|
-
// → "windows applewebkit"
|
|
895
|
-
const uaBase = ua
|
|
896
|
-
.toLowerCase()
|
|
897
|
-
.replace(/[\d.]+/g, '') // remove números de versão
|
|
898
|
-
.replace(/[^a-z\s]/g, ' ') // remove caracteres especiais
|
|
899
|
-
.split(' ')
|
|
900
|
-
.filter(w => w.length > 3) // só palavras com >3 chars (remove ruído)
|
|
901
|
-
.slice(0, 4) // pega até 4 tokens
|
|
902
|
-
.join(' ')
|
|
903
|
-
.trim();
|
|
904
|
-
|
|
905
|
-
const raw = `${asn}|${lang}|${uaBase}`;
|
|
906
|
-
return sha256(raw);
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
// ── Salvar fingerprint + UTM no D1 (quando UTM presente) ─────────────────────
|
|
910
|
-
async function saveEdgeFingerprint(DB, fingerprint, userId, payload) {
|
|
911
|
-
if (!DB || !fingerprint) return;
|
|
912
|
-
const { utmSource, utmMedium, utmCampaign, utmContent, utmTerm } = payload;
|
|
913
|
-
if (!utmSource) return; // só salva quando há UTM real
|
|
914
|
-
|
|
915
|
-
try {
|
|
916
|
-
await DB.prepare(`
|
|
917
|
-
INSERT INTO edge_fingerprints (fingerprint, user_id, utm_source, utm_medium, utm_campaign, utm_content, utm_term)
|
|
918
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
919
|
-
`).bind(
|
|
920
|
-
fingerprint,
|
|
921
|
-
userId || null,
|
|
922
|
-
utmSource || null,
|
|
923
|
-
utmMedium || null,
|
|
924
|
-
utmCampaign || null,
|
|
925
|
-
utmContent || null,
|
|
926
|
-
utmTerm || null,
|
|
927
|
-
).run();
|
|
928
|
-
} catch (err) {
|
|
929
|
-
console.error('saveEdgeFingerprint error:', err.message);
|
|
930
|
-
}
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
// ── Recuperar UTM perdida por fingerprint (últimas 48h) ───────────────────────
|
|
934
|
-
async function resurrectUTM(DB, fingerprint) {
|
|
935
|
-
if (!DB || !fingerprint) return null;
|
|
936
|
-
try {
|
|
937
|
-
return await DB.prepare(`
|
|
938
|
-
SELECT utm_source, utm_medium, utm_campaign, utm_content, utm_term
|
|
939
|
-
FROM edge_fingerprints
|
|
940
|
-
WHERE fingerprint = ?
|
|
941
|
-
AND utm_source IS NOT NULL
|
|
942
|
-
AND created_at > datetime('now', '-48 hours')
|
|
943
|
-
ORDER BY created_at DESC
|
|
944
|
-
LIMIT 1
|
|
945
|
-
`).bind(fingerprint).first();
|
|
946
|
-
} catch {
|
|
947
|
-
return null;
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
|
|
951
|
-
// ── TikTok Events API v1.3 ───────────────────────────────────────────────────
|
|
952
|
-
async function sendTikTokApi(env, eventName, payload, request, ctx) {
|
|
953
|
-
if (!env.TIKTOK_ACCESS_TOKEN) return { skipped: 'TIKTOK_ACCESS_TOKEN not set' };
|
|
954
|
-
|
|
955
|
-
const pixelId = env.TIKTOK_PIXEL_ID || TIKTOK_PIXEL_ID;
|
|
956
|
-
if (!pixelId || pixelId === 'SEU_TIKTOK_PIXEL_ID') return { skipped: 'TIKTOK_PIXEL_ID not configured' };
|
|
957
|
-
|
|
958
|
-
const {
|
|
959
|
-
email, phone, firstName, lastName,
|
|
960
|
-
fbp, fbc, ttp, ttclid, userId,
|
|
961
|
-
eventId, pageUrl,
|
|
962
|
-
value, currency,
|
|
963
|
-
contentIds, contentName, contentType,
|
|
964
|
-
} = payload;
|
|
965
|
-
|
|
966
|
-
const phoneNorm = normalizePhone(phone);
|
|
967
|
-
|
|
968
|
-
// user — hashear com SHA-256
|
|
969
|
-
const user = {
|
|
970
|
-
...(email && { email: await sha256(email) }),
|
|
971
|
-
...(phoneNorm && { phone_number: await sha256(phoneNorm) }),
|
|
972
|
-
...(userId && { external_id: await sha256(String(userId)) }),
|
|
973
|
-
...(ttp && { ttp }), // _ttp cookie — não hashear
|
|
974
|
-
...(ttclid && { ttclid }), // click ID — não hashear
|
|
975
|
-
};
|
|
976
|
-
|
|
977
|
-
// properties — dados do produto
|
|
978
|
-
const properties = {
|
|
979
|
-
...(value !== undefined && { value: parseFloat(value) }),
|
|
980
|
-
...(currency && { currency: String(currency).toUpperCase() }),
|
|
981
|
-
...(contentIds && contentIds.length > 0 && {
|
|
982
|
-
contents: contentIds.map(id => ({
|
|
983
|
-
content_id: String(id),
|
|
984
|
-
content_name: contentName || '',
|
|
985
|
-
content_type: contentType || 'product',
|
|
986
|
-
quantity: 1,
|
|
987
|
-
price: value ? parseFloat(value) : 0,
|
|
988
|
-
})),
|
|
989
|
-
}),
|
|
990
|
-
};
|
|
991
|
-
|
|
992
|
-
const event = {
|
|
993
|
-
event: eventName,
|
|
994
|
-
event_time: Math.floor(Date.now() / 1000),
|
|
995
|
-
event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
996
|
-
user,
|
|
997
|
-
page: {
|
|
998
|
-
url: pageUrl || `https://${env.SITE_DOMAIN}`,
|
|
999
|
-
referrer: request.headers.get('Referer') || '',
|
|
1000
|
-
},
|
|
1001
|
-
...(Object.keys(properties).length > 0 && { properties }),
|
|
1002
|
-
context: {
|
|
1003
|
-
ip: request.headers.get('CF-Connecting-IP') || '',
|
|
1004
|
-
user_agent: request.headers.get('User-Agent') || '',
|
|
1005
|
-
},
|
|
1006
|
-
};
|
|
1007
|
-
|
|
1008
|
-
const body = {
|
|
1009
|
-
event_source: 'web',
|
|
1010
|
-
event_source_id: pixelId,
|
|
1011
|
-
data: [event],
|
|
1012
|
-
};
|
|
1013
|
-
|
|
1014
|
-
// Endpoint obrigatório: sempre /v1.3/event/track/ (nunca /pixel/track/)
|
|
1015
|
-
const endpoint = 'https://business-api.tiktok.com/open_api/v1.3/event/track/';
|
|
1016
|
-
|
|
1017
|
-
try {
|
|
1018
|
-
const res = await fetch(endpoint, {
|
|
1019
|
-
method: 'POST',
|
|
1020
|
-
headers: {
|
|
1021
|
-
'Content-Type': 'application/json',
|
|
1022
|
-
'Access-Token': env.TIKTOK_ACCESS_TOKEN,
|
|
1023
|
-
},
|
|
1024
|
-
body: JSON.stringify(body),
|
|
1025
|
-
});
|
|
1026
|
-
|
|
1027
|
-
const data = await res.json();
|
|
1028
|
-
if (!res.ok || data.code !== 0) {
|
|
1029
|
-
console.error('TikTok Events API error:', res.status, data.message || data.code || 'unknown');
|
|
1030
|
-
|
|
1031
|
-
// Log de falha para Feedback Loop
|
|
1032
|
-
if (env.DB && ctx) {
|
|
1033
|
-
ctx.waitUntil(logApiFailure(
|
|
1034
|
-
env.DB,
|
|
1035
|
-
'tiktok',
|
|
1036
|
-
eventName,
|
|
1037
|
-
String(data.code || res.status),
|
|
1038
|
-
data.message || 'TikTok API error',
|
|
1039
|
-
event.event_id,
|
|
1040
|
-
JSON.stringify(body)
|
|
1041
|
-
));
|
|
1042
|
-
}
|
|
1043
|
-
}
|
|
1044
|
-
return data;
|
|
1045
|
-
} catch (err) {
|
|
1046
|
-
console.error('TikTok Events API fetch failed:', err.message);
|
|
1047
|
-
|
|
1048
|
-
// Log de falha para Feedback Loop
|
|
1049
|
-
if (env.DB && ctx) {
|
|
1050
|
-
ctx.waitUntil(logApiFailure(
|
|
1051
|
-
env.DB,
|
|
1052
|
-
'tiktok',
|
|
1053
|
-
eventName,
|
|
1054
|
-
'FETCH_ERROR',
|
|
1055
|
-
err.message,
|
|
1056
|
-
null,
|
|
1057
|
-
JSON.stringify(body)
|
|
1058
|
-
));
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
return { error: err.message };
|
|
1062
|
-
}
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
// ── Pinterest — Conversions API v5 (server-side) — TEMPLATE (não ativo em prod)
|
|
1067
|
-
// Para ativar: configurar PINTEREST_ACCESS_TOKEN e PINTEREST_AD_ACCOUNT_ID
|
|
1068
|
-
// e descomentar a chamada no Promise.allSettled do /track
|
|
1069
|
-
//
|
|
1070
|
-
async function sendPinterestCapi(env, eventName, payload, request, ctx) {
|
|
1071
|
-
if (!env.PINTEREST_ACCESS_TOKEN || !env.PINTEREST_AD_ACCOUNT_ID) {
|
|
1072
|
-
return { skipped: 'Pinterest credentials not set' };
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
const {
|
|
1076
|
-
email, phone, userId,
|
|
1077
|
-
eventId, pageUrl,
|
|
1078
|
-
value, currency,
|
|
1079
|
-
contentIds, contentName,
|
|
1080
|
-
} = payload;
|
|
1081
|
-
|
|
1082
|
-
const phoneNorm = normalizePhone(phone);
|
|
1083
|
-
|
|
1084
|
-
const pinterestEventMap = {
|
|
1085
|
-
PageView: 'pagevisit',
|
|
1086
|
-
ViewContent: 'pagevisit',
|
|
1087
|
-
Lead: 'lead',
|
|
1088
|
-
Purchase: 'checkout',
|
|
1089
|
-
AddToCart: 'addtocart',
|
|
1090
|
-
InitiateCheckout: 'checkout',
|
|
1091
|
-
CompleteRegistration: 'signup',
|
|
1092
|
-
Search: 'search',
|
|
1093
|
-
Contact: 'lead',
|
|
1094
|
-
};
|
|
1095
|
-
const pEvent = pinterestEventMap[eventName] || 'custom';
|
|
1096
|
-
|
|
1097
|
-
const userData = {
|
|
1098
|
-
...(email && { em: [await sha256(email)] }),
|
|
1099
|
-
...(phoneNorm && { ph: [await sha256(phoneNorm)] }),
|
|
1100
|
-
...(userId && { external_id: [await sha256(String(userId))] }),
|
|
1101
|
-
client_ip_address: request.headers.get('CF-Connecting-IP') || '',
|
|
1102
|
-
client_user_agent: request.headers.get('User-Agent') || '',
|
|
1103
|
-
};
|
|
1104
|
-
|
|
1105
|
-
const body = {
|
|
1106
|
-
data: [{
|
|
1107
|
-
event_name: pEvent,
|
|
1108
|
-
action_source: 'web',
|
|
1109
|
-
event_time: Math.floor(Date.now() / 1000),
|
|
1110
|
-
event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
1111
|
-
event_source_url: pageUrl || '',
|
|
1112
|
-
user_data: userData,
|
|
1113
|
-
custom_data: {
|
|
1114
|
-
currency: (currency || 'BRL').toUpperCase(),
|
|
1115
|
-
value: value ? String(parseFloat(value)) : '0',
|
|
1116
|
-
...(contentIds?.length > 0 && { content_ids: contentIds.map(String) }),
|
|
1117
|
-
...(contentName && { content_name: contentName }),
|
|
1118
|
-
content_type: 'product',
|
|
1119
|
-
},
|
|
1120
|
-
}],
|
|
1121
|
-
};
|
|
1122
|
-
|
|
1123
|
-
try {
|
|
1124
|
-
const res = await fetch(
|
|
1125
|
-
`https://api.pinterest.com/v5/ad_accounts/${env.PINTEREST_AD_ACCOUNT_ID}/events`,
|
|
1126
|
-
{
|
|
1127
|
-
method: 'POST',
|
|
1128
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.PINTEREST_ACCESS_TOKEN}` },
|
|
1129
|
-
body: JSON.stringify(body),
|
|
1130
|
-
}
|
|
1131
|
-
);
|
|
1132
|
-
const data = await res.json();
|
|
1133
|
-
if (!res.ok) {
|
|
1134
|
-
const pinterestErrMsg = data.message || data.code || String(res.status);
|
|
1135
|
-
console.error('Pinterest CAPI error:', res.status, pinterestErrMsg);
|
|
1136
|
-
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'pinterest', eventName, String(res.status), pinterestErrMsg, body.data[0].event_id, JSON.stringify(body)));
|
|
1137
|
-
}
|
|
1138
|
-
return data;
|
|
1139
|
-
} catch (err) {
|
|
1140
|
-
console.error('Pinterest CAPI fetch failed:', err.message);
|
|
1141
|
-
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'pinterest', eventName, 'FETCH_ERROR', err.message, null, JSON.stringify(body)));
|
|
1142
|
-
return { error: err.message };
|
|
1143
|
-
}
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
// ── Reddit — Conversions API v2.0 (server-side) ───────────────────────────────
|
|
1148
|
-
//
|
|
1149
|
-
// Secrets necessários (wrangler secret put):
|
|
1150
|
-
// REDDIT_ACCESS_TOKEN → Bearer token da Reddit Conversions API
|
|
1151
|
-
// REDDIT_AD_ACCOUNT_ID → ID da conta de anúncios (ex: t2_XXXXXXX)
|
|
1152
|
-
//
|
|
1153
|
-
async function sendRedditCapi(env, eventName, payload, request, ctx) {
|
|
1154
|
-
if (!env.REDDIT_ACCESS_TOKEN || !env.REDDIT_AD_ACCOUNT_ID) {
|
|
1155
|
-
return { skipped: 'Reddit credentials not set' };
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
const {
|
|
1159
|
-
email, phone, userId,
|
|
1160
|
-
eventId, pageUrl,
|
|
1161
|
-
value, currency,
|
|
1162
|
-
} = payload;
|
|
1163
|
-
|
|
1164
|
-
const phoneNorm = normalizePhone(phone);
|
|
1165
|
-
|
|
1166
|
-
const redditEventMap = {
|
|
1167
|
-
PageView: 'PageVisit',
|
|
1168
|
-
ViewContent: 'ViewContent',
|
|
1169
|
-
Lead: 'Lead',
|
|
1170
|
-
Purchase: 'Purchase',
|
|
1171
|
-
AddToCart: 'AddToCart',
|
|
1172
|
-
InitiateCheckout: 'Purchase',
|
|
1173
|
-
CompleteRegistration: 'SignUp',
|
|
1174
|
-
Search: 'Search',
|
|
1175
|
-
Contact: 'Lead',
|
|
1176
|
-
};
|
|
1177
|
-
const rEvent = redditEventMap[eventName] || 'Custom';
|
|
1178
|
-
|
|
1179
|
-
const user = {
|
|
1180
|
-
...(email && { email: { value: await sha256(email) } }),
|
|
1181
|
-
...(phoneNorm && { phoneNumber: { value: await sha256(phoneNorm) } }),
|
|
1182
|
-
...(userId && { externalId: { value: await sha256(String(userId)) } }),
|
|
1183
|
-
ipAddress: { value: request.headers.get('CF-Connecting-IP') || '' },
|
|
1184
|
-
userAgent: { value: request.headers.get('User-Agent') || '' },
|
|
1185
|
-
};
|
|
1186
|
-
|
|
1187
|
-
const event = {
|
|
1188
|
-
event_at: new Date().toISOString(),
|
|
1189
|
-
event_type: { tracking_type: rEvent, ...(eventName === 'InitiateCheckout' && { conversion_type: 'BEGIN_CHECKOUT' }) },
|
|
1190
|
-
click_id: payload.rdtClid || '',
|
|
1191
|
-
event_metadata: {
|
|
1192
|
-
currency: (currency || 'BRL').toUpperCase(),
|
|
1193
|
-
value_decimal: String(value || 0),
|
|
1194
|
-
item_count: '1',
|
|
1195
|
-
conversion_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
1196
|
-
},
|
|
1197
|
-
user,
|
|
1198
|
-
};
|
|
1199
|
-
|
|
1200
|
-
const body = { events: [event] };
|
|
1201
|
-
|
|
1202
|
-
try {
|
|
1203
|
-
const res = await fetch(
|
|
1204
|
-
`https://ads-api.reddit.com/api/v2.0/conversions/events/${env.REDDIT_AD_ACCOUNT_ID}`,
|
|
1205
|
-
{
|
|
1206
|
-
method: 'POST',
|
|
1207
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.REDDIT_ACCESS_TOKEN}` },
|
|
1208
|
-
body: JSON.stringify(body),
|
|
1209
|
-
}
|
|
1210
|
-
);
|
|
1211
|
-
if (!res.ok) {
|
|
1212
|
-
const txt = await res.text();
|
|
1213
|
-
console.error('Reddit CAPI error:', txt);
|
|
1214
|
-
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'reddit', eventName, String(res.status), txt, event.event_metadata.conversion_id, JSON.stringify(body)));
|
|
1215
|
-
return { error: `HTTP ${res.status}` };
|
|
1216
|
-
}
|
|
1217
|
-
const data = await res.json();
|
|
1218
|
-
return data;
|
|
1219
|
-
} catch (err) {
|
|
1220
|
-
console.error('Reddit CAPI fetch failed:', err.message);
|
|
1221
|
-
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'reddit', eventName, 'FETCH_ERROR', err.message, null, JSON.stringify(body)));
|
|
1222
|
-
return { error: err.message };
|
|
1223
|
-
}
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
// ── LinkedIn — Conversions API v2 (server-side) ───────────────────────────────
|
|
1228
|
-
//
|
|
1229
|
-
// Secrets necessários (wrangler secret put):
|
|
1230
|
-
// LINKEDIN_ACCESS_TOKEN → OAuth2 Bearer token da LinkedIn Marketing API
|
|
1231
|
-
// LINKEDIN_CONVERSION_ID → ID da conversão (URN: urn:li:conversion:XXXXXXXXX)
|
|
1232
|
-
// LINKEDIN_AD_ACCOUNT_ID → ID da conta de anúncios (ex: 123456789)
|
|
1233
|
-
//
|
|
1234
|
-
async function sendLinkedInCapi(env, eventName, payload, request, ctx) {
|
|
1235
|
-
if (!env.LINKEDIN_ACCESS_TOKEN || !env.LINKEDIN_CONVERSION_ID) {
|
|
1236
|
-
return { skipped: 'LinkedIn credentials not set' };
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
const {
|
|
1240
|
-
email, phone, firstName, lastName, userId,
|
|
1241
|
-
eventId, pageUrl,
|
|
1242
|
-
value, currency,
|
|
1243
|
-
} = payload;
|
|
1244
|
-
|
|
1245
|
-
const phoneNorm = normalizePhone(phone);
|
|
1246
|
-
|
|
1247
|
-
// LinkedIn só suporta conversões (Lead, Purchase, CompleteRegistration)
|
|
1248
|
-
const linkedInEventMap = {
|
|
1249
|
-
Lead: 'LEAD',
|
|
1250
|
-
Purchase: 'PURCHASE',
|
|
1251
|
-
CompleteRegistration: 'REGISTRATION',
|
|
1252
|
-
AddToCart: 'ADD_TO_CART',
|
|
1253
|
-
InitiateCheckout: 'OTHER',
|
|
1254
|
-
ViewContent: 'OTHER',
|
|
1255
|
-
PageView: 'OTHER',
|
|
1256
|
-
Contact: 'LEAD',
|
|
1257
|
-
};
|
|
1258
|
-
const liEvent = linkedInEventMap[eventName] || 'OTHER';
|
|
1259
|
-
|
|
1260
|
-
// user — SHA-256 em campos PII
|
|
1261
|
-
const userInfo = {
|
|
1262
|
-
...(email && { 'SHA256_EMAIL': await sha256(email) }),
|
|
1263
|
-
...(phoneNorm && { 'SHA256_PHONE': await sha256(phoneNorm) }),
|
|
1264
|
-
...(firstName && { 'SHA256_FIRST_NAME': await sha256(firstName.toLowerCase().trim()) }),
|
|
1265
|
-
...(lastName && { 'SHA256_LAST_NAME': await sha256(lastName.toLowerCase().trim()) }),
|
|
1266
|
-
};
|
|
1267
|
-
|
|
1268
|
-
const body = {
|
|
1269
|
-
conversion: `urn:li:conversion:${env.LINKEDIN_CONVERSION_ID}`,
|
|
1270
|
-
conversionHappenedAt: Date.now(),
|
|
1271
|
-
conversionValue: value ? { currencyCode: (currency || 'BRL').toUpperCase(), amount: String(parseFloat(value)) } : undefined,
|
|
1272
|
-
eventId: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
1273
|
-
...(Object.keys(userInfo).length > 0 && { user: { userIds: Object.entries(userInfo).map(([idType, idValue]) => ({ idType, idValue })) } }),
|
|
1274
|
-
};
|
|
1275
|
-
|
|
1276
|
-
try {
|
|
1277
|
-
const res = await fetch(
|
|
1278
|
-
'https://api.linkedin.com/rest/conversionEvents',
|
|
1279
|
-
{
|
|
1280
|
-
method: 'POST',
|
|
1281
|
-
headers: {
|
|
1282
|
-
'Content-Type': 'application/json',
|
|
1283
|
-
Authorization: `Bearer ${env.LINKEDIN_ACCESS_TOKEN}`,
|
|
1284
|
-
'LinkedIn-Version': '202405',
|
|
1285
|
-
'X-Restli-Protocol-Version': '2.0.0',
|
|
1286
|
-
},
|
|
1287
|
-
body: JSON.stringify(body),
|
|
1288
|
-
}
|
|
1289
|
-
);
|
|
1290
|
-
if (!res.ok) {
|
|
1291
|
-
const txt = await res.text();
|
|
1292
|
-
console.error('LinkedIn CAPI error:', txt);
|
|
1293
|
-
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'linkedin', eventName, String(res.status), txt, body.eventId, JSON.stringify(body)));
|
|
1294
|
-
return { error: `HTTP ${res.status}` };
|
|
1295
|
-
}
|
|
1296
|
-
return { ok: true };
|
|
1297
|
-
} catch (err) {
|
|
1298
|
-
console.error('LinkedIn CAPI fetch failed:', err.message);
|
|
1299
|
-
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'linkedin', eventName, 'FETCH_ERROR', err.message, null, JSON.stringify(body)));
|
|
1300
|
-
return { error: err.message };
|
|
1301
|
-
}
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
// ── Spotify — Conversions API v1 (server-side) ────────────────────────────────
|
|
1306
|
-
//
|
|
1307
|
-
// Secrets necessários (wrangler secret put):
|
|
1308
|
-
// SPOTIFY_ACCESS_TOKEN → Bearer token da Spotify Advertising API
|
|
1309
|
-
// SPOTIFY_AD_ACCOUNT_ID → ID da conta de anúncios Spotify Ads
|
|
1310
|
-
//
|
|
1311
|
-
async function sendSpotifyCapi(env, eventName, payload, request, ctx) {
|
|
1312
|
-
if (!env.SPOTIFY_ACCESS_TOKEN || !env.SPOTIFY_AD_ACCOUNT_ID) {
|
|
1313
|
-
return { skipped: 'Spotify credentials not set' };
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
const {
|
|
1317
|
-
email, phone, userId,
|
|
1318
|
-
eventId, pageUrl,
|
|
1319
|
-
value, currency,
|
|
1320
|
-
} = payload;
|
|
1321
|
-
|
|
1322
|
-
const phoneNorm = normalizePhone(phone);
|
|
1323
|
-
|
|
1324
|
-
const spotifyEventMap = {
|
|
1325
|
-
Purchase: 'PURCHASE',
|
|
1326
|
-
Lead: 'LEAD',
|
|
1327
|
-
CompleteRegistration: 'SIGN_UP',
|
|
1328
|
-
AddToCart: 'ADD_TO_CART',
|
|
1329
|
-
InitiateCheckout: 'INITIATE_CHECKOUT',
|
|
1330
|
-
ViewContent: 'VIEW_CONTENT',
|
|
1331
|
-
PageView: 'PAGE_VIEW',
|
|
1332
|
-
Contact: 'LEAD',
|
|
1333
|
-
};
|
|
1334
|
-
const spEvent = spotifyEventMap[eventName] || 'CUSTOM';
|
|
1335
|
-
|
|
1336
|
-
// user — SHA-256 em campos PII
|
|
1337
|
-
const user = {
|
|
1338
|
-
...(email && { hashed_email: await sha256(email) }),
|
|
1339
|
-
...(phoneNorm && { hashed_phone: await sha256(phoneNorm) }),
|
|
1340
|
-
...(userId && { user_id: userId }),
|
|
1341
|
-
ip_address: request.headers.get('CF-Connecting-IP') || '',
|
|
1342
|
-
user_agent: request.headers.get('User-Agent') || '',
|
|
1343
|
-
};
|
|
1344
|
-
|
|
1345
|
-
const body = {
|
|
1346
|
-
data: [{
|
|
1347
|
-
event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
1348
|
-
event_type: spEvent,
|
|
1349
|
-
event_time: Math.floor(Date.now() / 1000),
|
|
1350
|
-
url: pageUrl || '',
|
|
1351
|
-
user,
|
|
1352
|
-
...(value !== undefined && {
|
|
1353
|
-
value: {
|
|
1354
|
-
currency: (currency || 'BRL').toUpperCase(),
|
|
1355
|
-
amount: parseFloat(value),
|
|
1356
|
-
},
|
|
1357
|
-
}),
|
|
1358
|
-
}],
|
|
1359
|
-
};
|
|
1360
|
-
|
|
1361
|
-
try {
|
|
1362
|
-
const res = await fetch(
|
|
1363
|
-
`https://advertising-api.spotify.com/conversion/v1/accounts/${env.SPOTIFY_AD_ACCOUNT_ID}/events`,
|
|
1364
|
-
{
|
|
1365
|
-
method: 'POST',
|
|
1366
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${env.SPOTIFY_ACCESS_TOKEN}` },
|
|
1367
|
-
body: JSON.stringify(body),
|
|
1368
|
-
}
|
|
1369
|
-
);
|
|
1370
|
-
if (!res.ok) {
|
|
1371
|
-
const txt = await res.text();
|
|
1372
|
-
console.error('Spotify CAPI error:', txt);
|
|
1373
|
-
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'spotify', eventName, String(res.status), txt, body.data[0].event_id, JSON.stringify(body)));
|
|
1374
|
-
return { error: `HTTP ${res.status}` };
|
|
1375
|
-
}
|
|
1376
|
-
const data = await res.json();
|
|
1377
|
-
return data;
|
|
1378
|
-
} catch (err) {
|
|
1379
|
-
console.error('Spotify CAPI fetch failed:', err.message);
|
|
1380
|
-
if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'spotify', eventName, 'FETCH_ERROR', err.message, null, JSON.stringify(body)));
|
|
1381
|
-
return { error: err.message };
|
|
1382
|
-
}
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
// ── WhatsApp — Meta Cloud API v22.0 ──────────────────────────────────────────
|
|
1387
|
-
//
|
|
1388
|
-
// Secrets necessários (wrangler secret put):
|
|
1389
|
-
// WHATSAPP_PHONE_NUMBER_ID → Meta: "Phone Number ID" (ex: 123456789012345)
|
|
1390
|
-
// WHATSAPP_ACCESS_TOKEN → Meta: "Access Token" (Token permanente)
|
|
1391
|
-
// WA_NOTIFY_NUMBER → Número do dono para receber notificações (ex: 5511999998888)
|
|
1392
|
-
//
|
|
1393
|
-
// Formatos suportados:
|
|
1394
|
-
// text → Texto livre (só funciona dentro da janela de 24h)
|
|
1395
|
-
// template → Mensagem pré-aprovada pela Meta (inicia conversa proativamente)
|
|
1396
|
-
// image → Imagem com legenda opcional (URL pública)
|
|
1397
|
-
// video → Vídeo com legenda opcional (URL pública)
|
|
1398
|
-
// document → PDF/arquivo com nome e legenda (URL pública)
|
|
1399
|
-
// audio → Áudio (URL pública, OGG/MP4)
|
|
1400
|
-
// interactive → Botões (até 3) ou lista de opções (até 10 itens)
|
|
1401
|
-
//
|
|
1402
|
-
// Regra crítica: para iniciar conversa proativamente (Purchase, Lead, etc.)
|
|
1403
|
-
// é OBRIGATÓRIO usar type: 'template'. Texto livre só funciona em resposta
|
|
1404
|
-
// a uma mensagem do usuário nos últimos 24h.
|
|
1405
|
-
//
|
|
1406
|
-
async function sendWhatsApp(env, tipo, payload, options = {}) {
|
|
1407
|
-
if (!env.WHATSAPP_PHONE_NUMBER_ID || !env.WHATSAPP_ACCESS_TOKEN || !env.WA_NOTIFY_NUMBER) {
|
|
1408
|
-
return { skipped: 'WhatsApp não configurado' };
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
const to = options.to || env.WA_NOTIFY_NUMBER;
|
|
1412
|
-
|
|
1413
|
-
// ── Template (proativo — fora da janela de 24h) ───────────────────────────
|
|
1414
|
-
// Usar quando: Purchase, Lead, ou qualquer disparo iniciado pelo sistema
|
|
1415
|
-
// O template deve estar aprovado no Meta Business Suite → WhatsApp → Templates
|
|
1416
|
-
// Formato dos componentes segue a API de Templates da Meta
|
|
1417
|
-
if (options.template) {
|
|
1418
|
-
const { name, language = 'pt_BR', components = [] } = options.template;
|
|
1419
|
-
const body = {
|
|
1420
|
-
messaging_product: 'whatsapp',
|
|
1421
|
-
to,
|
|
1422
|
-
type: 'template',
|
|
1423
|
-
template: { name, language: { code: language }, components },
|
|
1424
|
-
};
|
|
1425
|
-
return _sendWARequest(env, body);
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
|
-
// ── Mídia — image, video, document, audio ────────────────────────────────
|
|
1429
|
-
// Usar quando: envio de PDF de nota fiscal, vídeo de boas-vindas, etc.
|
|
1430
|
-
// mediaUrl deve ser uma URL pública acessível pela Meta
|
|
1431
|
-
if (options.mediaType && options.mediaUrl) {
|
|
1432
|
-
const mediaPayload = { link: options.mediaUrl };
|
|
1433
|
-
if (options.caption) mediaPayload.caption = options.caption;
|
|
1434
|
-
if (options.filename) mediaPayload.filename = options.filename; // só para document
|
|
1435
|
-
const body = {
|
|
1436
|
-
messaging_product: 'whatsapp',
|
|
1437
|
-
to,
|
|
1438
|
-
type: options.mediaType,
|
|
1439
|
-
[options.mediaType]: mediaPayload,
|
|
1440
|
-
};
|
|
1441
|
-
return _sendWARequest(env, body);
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
// ── Interactive — botões (até 3) ─────────────────────────────────────────
|
|
1445
|
-
// Usar quando: confirmação de compra com botões de ação
|
|
1446
|
-
// buttons: [{ id: 'btn_1', title: 'Ver Pedido' }, ...]
|
|
1447
|
-
if (options.interactive === 'buttons' && options.buttons?.length) {
|
|
1448
|
-
const body = {
|
|
1449
|
-
messaging_product: 'whatsapp',
|
|
1450
|
-
to,
|
|
1451
|
-
type: 'interactive',
|
|
1452
|
-
interactive: {
|
|
1453
|
-
type: 'button',
|
|
1454
|
-
body: { text: options.bodyText || '' },
|
|
1455
|
-
action: {
|
|
1456
|
-
buttons: options.buttons.slice(0, 3).map(b => ({
|
|
1457
|
-
type: 'reply',
|
|
1458
|
-
reply: { id: b.id, title: b.title },
|
|
1459
|
-
})),
|
|
1460
|
-
},
|
|
1461
|
-
},
|
|
1462
|
-
};
|
|
1463
|
-
return _sendWARequest(env, body);
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
// ── Interactive — lista de opções (até 10 itens) ──────────────────────────
|
|
1467
|
-
// Usar quando: menu de suporte, seleção de produto, etc.
|
|
1468
|
-
// rows: [{ id: 'opt_1', title: 'Suporte', description: 'Falar com equipe' }, ...]
|
|
1469
|
-
if (options.interactive === 'list' && options.rows?.length) {
|
|
1470
|
-
const body = {
|
|
1471
|
-
messaging_product: 'whatsapp',
|
|
1472
|
-
to,
|
|
1473
|
-
type: 'interactive',
|
|
1474
|
-
interactive: {
|
|
1475
|
-
type: 'list',
|
|
1476
|
-
body: { text: options.bodyText || '' },
|
|
1477
|
-
action: {
|
|
1478
|
-
button: options.listButton || 'Ver opções',
|
|
1479
|
-
sections: [{ rows: options.rows.slice(0, 10) }],
|
|
1480
|
-
},
|
|
1481
|
-
},
|
|
1482
|
-
};
|
|
1483
|
-
return _sendWARequest(env, body);
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
// ── Text — fallback (dentro da janela de 24h) ─────────────────────────────
|
|
1487
|
-
const nome = [payload.firstName, payload.lastName].filter(Boolean).join(' ') || 'sem nome';
|
|
1488
|
-
const valor = payload.value ? `R$ ${parseFloat(payload.value).toFixed(2)}` : '—';
|
|
1489
|
-
const utm = payload.utmSource || 'direto';
|
|
1490
|
-
const produto = payload.contentName || '';
|
|
1491
|
-
|
|
1492
|
-
let texto = '';
|
|
1493
|
-
if (tipo === 'Purchase') {
|
|
1494
|
-
texto =
|
|
1495
|
-
`🛒 *Nova Venda!*\n\n` +
|
|
1496
|
-
`👤 ${nome}\n` +
|
|
1497
|
-
`📧 ${payload.email || '—'}\n` +
|
|
1498
|
-
`📱 ${payload.phone || '—'}\n` +
|
|
1499
|
-
`💰 ${valor}\n` +
|
|
1500
|
-
(produto ? `📦 ${produto}\n` : '') +
|
|
1501
|
-
`🔗 UTM: ${utm}\n` +
|
|
1502
|
-
`🕐 ${new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' })}`;
|
|
1503
|
-
} else if (tipo === 'Lead') {
|
|
1504
|
-
texto =
|
|
1505
|
-
`📋 *Novo Lead!*\n\n` +
|
|
1506
|
-
`📧 ${payload.email || '—'}\n` +
|
|
1507
|
-
`🔗 UTM: ${utm}\n` +
|
|
1508
|
-
`🌐 ${payload.pageUrl || '—'}\n` +
|
|
1509
|
-
`🕐 ${new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' })}`;
|
|
1510
|
-
} else {
|
|
1511
|
-
return { skipped: `tipo ${tipo} não suportado sem template` };
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
return _sendWARequest(env, {
|
|
1515
|
-
messaging_product: 'whatsapp',
|
|
1516
|
-
to,
|
|
1517
|
-
type: 'text',
|
|
1518
|
-
text: { body: texto },
|
|
1519
|
-
});
|
|
1520
|
-
}
|
|
1521
|
-
|
|
1522
|
-
// ── Executor interno — evita duplicação de fetch entre os formatos ────────────
|
|
1523
|
-
async function _sendWARequest(env, body) {
|
|
1524
|
-
try {
|
|
1525
|
-
const res = await fetch(`https://graph.facebook.com/v22.0/${env.WHATSAPP_PHONE_NUMBER_ID}/messages`, {
|
|
1526
|
-
method: 'POST',
|
|
1527
|
-
headers: {
|
|
1528
|
-
'Content-Type': 'application/json',
|
|
1529
|
-
'Authorization': `Bearer ${env.WHATSAPP_ACCESS_TOKEN}`,
|
|
1530
|
-
},
|
|
1531
|
-
body: JSON.stringify(body),
|
|
1532
|
-
});
|
|
1533
|
-
const data = await res.json();
|
|
1534
|
-
if (!res.ok) console.error('WhatsApp Meta API error:', res.status, data.error?.message || 'unknown');
|
|
1535
|
-
return { ok: res.ok, status: res.status, data };
|
|
1536
|
-
} catch (err) {
|
|
1537
|
-
console.error('WhatsApp Meta API failed:', err.message);
|
|
1538
|
-
return { ok: false, error: err.message };
|
|
1539
|
-
}
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
// ── CallMeBot — Alertas de sistema (falhas Cloudflare, API down, erros críticos)
|
|
1543
|
-
//
|
|
1544
|
-
// Secrets necessários (wrangler secret put):
|
|
1545
|
-
// CALLMEBOT_PHONE → Número do admin no formato internacional (ex: +5511999998888)
|
|
1546
|
-
// CALLMEBOT_APIKEY → API Key gerada pelo CallMeBot (ativar via WhatsApp: wa.me/34638398527)
|
|
1547
|
-
//
|
|
1548
|
-
// Usado para: alertas internos do sistema — Worker com erro, API falhando,
|
|
1549
|
-
// token expirado, D1 com problema. NÃO para mensagens a clientes.
|
|
1550
|
-
//
|
|
1551
|
-
async function sendCallMeBot(env, mensagem) {
|
|
1552
|
-
if (!env.CALLMEBOT_PHONE || !env.CALLMEBOT_APIKEY) {
|
|
1553
|
-
return { skipped: 'CallMeBot não configurado' };
|
|
1554
|
-
}
|
|
1555
|
-
try {
|
|
1556
|
-
const url = `https://api.callmebot.com/whatsapp.php?phone=${encodeURIComponent(env.CALLMEBOT_PHONE)}&text=${encodeURIComponent(mensagem)}&apikey=${env.CALLMEBOT_APIKEY}`;
|
|
1557
|
-
const res = await fetch(url);
|
|
1558
|
-
return { ok: res.ok, status: res.status };
|
|
1559
|
-
} catch (err) {
|
|
1560
|
-
console.error('CallMeBot failed:', err.message);
|
|
1561
|
-
return { ok: false, error: err.message };
|
|
1562
|
-
}
|
|
1563
|
-
}
|
|
1564
|
-
|
|
1565
|
-
// ── WhatsApp CTWA — Processa webhook de mensagem recebida ─────────────────────
|
|
1566
|
-
// Acionado em POST /webhook/whatsapp quando um usuário envia mensagem após
|
|
1567
|
-
// clicar em um anúncio "Click to WhatsApp" (CTWA) no Facebook/Instagram.
|
|
1568
|
-
//
|
|
1569
|
-
// O payload da Meta Cloud API inclui:
|
|
1570
|
-
// message.from → número do usuário (sem "+", ex: "5511999998888")
|
|
1571
|
-
// message.id (wamid) → ID único da mensagem (usado para deduplicação)
|
|
1572
|
-
// message.referral → dados do anúncio que gerou o clique:
|
|
1573
|
-
// .ctwa_clid → identificador do clique (equivalente ao fbclid para CTWA)
|
|
1574
|
-
// .source_id → ID do anúncio
|
|
1575
|
-
// .source_url → URL do anúncio no Facebook/Instagram
|
|
1576
|
-
// .headline → título do anúncio
|
|
1577
|
-
//
|
|
1578
|
-
// O evento enviado à Meta CAPI usa action_source="chat" (obrigatório para CTWA)
|
|
1579
|
-
// e inclui ctwa_clid em user_data (sem hash) junto com ph (phone hasheado).
|
|
1580
|
-
// Isso permite à Meta fechar o loop: clique no anúncio → conversa no WhatsApp.
|
|
1581
|
-
async function processWhatsAppWebhook(env, body, request, ctx) {
|
|
1582
|
-
const entry = body?.entry?.[0];
|
|
1583
|
-
const change = entry?.changes?.find(c => c.field === 'messages');
|
|
1584
|
-
if (!change) return { skipped: 'no messages field' };
|
|
1585
|
-
|
|
1586
|
-
const messages = change.value?.messages;
|
|
1587
|
-
if (!messages || messages.length === 0) return { skipped: 'no messages' };
|
|
1588
|
-
|
|
1589
|
-
const results = [];
|
|
1590
|
-
|
|
1591
|
-
for (const message of messages) {
|
|
1592
|
-
const phone = message.from; // ex: "5511999998888"
|
|
1593
|
-
const wamid = message.id; // ID único da mensagem
|
|
1594
|
-
const referral = message.referral || {};
|
|
1595
|
-
const ctwaClid = referral.ctwa_clid || null; // click ID do anúncio
|
|
1596
|
-
const adId = referral.source_id || null;
|
|
1597
|
-
const sourceUrl = referral.source_url || null;
|
|
1598
|
-
const headline = referral.headline || null;
|
|
1599
|
-
const messageBody = message.text?.body || message.type || '';
|
|
1600
|
-
|
|
1601
|
-
if (!phone) {
|
|
1602
|
-
results.push({ skipped: 'no phone' });
|
|
1603
|
-
continue;
|
|
1604
|
-
}
|
|
1605
|
-
|
|
1606
|
-
const phoneNorm = normalizePhone(phone) || phone;
|
|
1607
|
-
const phoneHash = await sha256(phoneNorm);
|
|
1608
|
-
|
|
1609
|
-
// Deduplicação — mesmo wamid não dispara duas vezes
|
|
1610
|
-
if (env.DB && wamid) {
|
|
1611
|
-
try {
|
|
1612
|
-
const existing = await env.DB.prepare(
|
|
1613
|
-
'SELECT id FROM whatsapp_contacts WHERE wamid = ?'
|
|
1614
|
-
).bind(wamid).first();
|
|
1615
|
-
if (existing) {
|
|
1616
|
-
results.push({ skipped: 'duplicate wamid', wamid });
|
|
1617
|
-
continue;
|
|
1618
|
-
}
|
|
1619
|
-
} catch { /* não bloquear se D1 falhar */ }
|
|
1620
|
-
}
|
|
1621
|
-
|
|
1622
|
-
const eventId = `ctwa_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1623
|
-
|
|
1624
|
-
// Persistir contato no D1 (antes de enviar ao CAPI)
|
|
1625
|
-
if (env.DB) {
|
|
1626
|
-
ctx.waitUntil(
|
|
1627
|
-
env.DB.prepare(
|
|
1628
|
-
`INSERT OR IGNORE INTO whatsapp_contacts
|
|
1629
|
-
(phone_hash, phone_raw, wamid, ctwa_clid, ad_id, source_url, headline, capi_sent, capi_event_id, message_body)
|
|
1630
|
-
VALUES (?,?,?,?,?,?,?,0,?,?)`
|
|
1631
|
-
).bind(phoneHash, phoneNorm, wamid || null, ctwaClid, adId,
|
|
1632
|
-
sourceUrl, headline, eventId, messageBody || null).run()
|
|
1633
|
-
);
|
|
1634
|
-
}
|
|
1635
|
-
|
|
1636
|
-
// Montar evento para Meta CAPI
|
|
1637
|
-
// action_source: "chat" é obrigatório para eventos originados no WhatsApp
|
|
1638
|
-
// ctwa_clid vai em user_data sem hash (a Meta exige assim)
|
|
1639
|
-
const capiEvent = {
|
|
1640
|
-
event_name: 'Contact',
|
|
1641
|
-
event_time: Math.floor(Date.now() / 1000),
|
|
1642
|
-
event_id: eventId,
|
|
1643
|
-
action_source: 'chat',
|
|
1644
|
-
user_data: {
|
|
1645
|
-
ph: phoneHash,
|
|
1646
|
-
...(ctwaClid && { ctwa_clid: ctwaClid }),
|
|
1647
|
-
client_ip_address: request.headers.get('CF-Connecting-IP') || '',
|
|
1648
|
-
client_user_agent: request.headers.get('User-Agent') || '',
|
|
1649
|
-
},
|
|
1650
|
-
...(sourceUrl && { event_source_url: sourceUrl }),
|
|
1651
|
-
};
|
|
1652
|
-
|
|
1653
|
-
const pixelId = env.META_PIXEL_ID || META_PIXEL_ID;
|
|
1654
|
-
|
|
1655
|
-
// Enviar ao Meta CAPI de forma assíncrona (não bloqueia a resposta ao WhatsApp)
|
|
1656
|
-
ctx.waitUntil(
|
|
1657
|
-
(async () => {
|
|
1658
|
-
try {
|
|
1659
|
-
const requestBody = {
|
|
1660
|
-
data: [capiEvent],
|
|
1661
|
-
access_token: env.META_ACCESS_TOKEN,
|
|
1662
|
-
};
|
|
1663
|
-
if (env.META_TEST_CODE) requestBody.test_event_code = env.META_TEST_CODE;
|
|
1664
|
-
|
|
1665
|
-
const res = await fetch(
|
|
1666
|
-
`https://graph.facebook.com/v22.0/${pixelId}/events`,
|
|
1667
|
-
{
|
|
1668
|
-
method: 'POST',
|
|
1669
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1670
|
-
body: JSON.stringify(requestBody),
|
|
1671
|
-
}
|
|
1672
|
-
);
|
|
1673
|
-
const data = await res.json();
|
|
1674
|
-
|
|
1675
|
-
if (res.ok && env.DB && wamid) {
|
|
1676
|
-
await env.DB.prepare(
|
|
1677
|
-
'UPDATE whatsapp_contacts SET capi_sent = 1 WHERE wamid = ?'
|
|
1678
|
-
).bind(wamid).run();
|
|
1679
|
-
} else if (!res.ok) {
|
|
1680
|
-
console.error('[CTWA] Meta CAPI error:', res.status, data.error?.message || 'unknown');
|
|
1681
|
-
if (env.DB) {
|
|
1682
|
-
await logApiFailure(env.DB, 'meta', 'Contact', data.error?.code || res.status,
|
|
1683
|
-
data.error?.message || 'CTWA CAPI error', eventId, JSON.stringify(requestBody));
|
|
1684
|
-
}
|
|
1685
|
-
}
|
|
1686
|
-
} catch (err) {
|
|
1687
|
-
console.error('[CTWA] Meta CAPI fetch failed:', err.message);
|
|
1688
|
-
}
|
|
1689
|
-
})()
|
|
1690
|
-
);
|
|
1691
|
-
|
|
1692
|
-
// Registrar também na tabela leads para aparecer no CRM Dashboard
|
|
1693
|
-
ctx.waitUntil(
|
|
1694
|
-
saveLead(env, 'Contact', {
|
|
1695
|
-
phone: phoneNorm,
|
|
1696
|
-
eventId: eventId,
|
|
1697
|
-
pageUrl: sourceUrl,
|
|
1698
|
-
utmSource: 'whatsapp_ctwa',
|
|
1699
|
-
utmMedium: 'paid_social',
|
|
1700
|
-
}, request, 'whatsapp')
|
|
1701
|
-
);
|
|
1702
|
-
|
|
1703
|
-
results.push({
|
|
1704
|
-
ok: true,
|
|
1705
|
-
phone: phoneNorm.slice(0, 4) + '****',
|
|
1706
|
-
ctwa_clid: ctwaClid ? 'present' : 'absent',
|
|
1707
|
-
event_id: eventId,
|
|
1708
|
-
});
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
return { processed: results.length, results };
|
|
1712
|
-
}
|
|
1713
|
-
|
|
1714
|
-
// ── Verificação de assinatura HMAC-SHA256 (webhooks) ─────────────────────────
|
|
1715
|
-
async function verifyHmac(secret, rawBody, receivedSignature) {
|
|
1716
|
-
if (!secret || !receivedSignature) return false;
|
|
1717
|
-
try {
|
|
1718
|
-
const key = await crypto.subtle.importKey(
|
|
1719
|
-
'raw',
|
|
1720
|
-
new TextEncoder().encode(secret),
|
|
1721
|
-
{ name: 'HMAC', hash: 'SHA-256' },
|
|
1722
|
-
false,
|
|
1723
|
-
['sign']
|
|
1724
|
-
);
|
|
1725
|
-
const sig = await crypto.subtle.sign(
|
|
1726
|
-
'HMAC', key, new TextEncoder().encode(rawBody)
|
|
1727
|
-
);
|
|
1728
|
-
const computed = Array.from(new Uint8Array(sig))
|
|
1729
|
-
.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
1730
|
-
// Comparação constant-time (evita timing attack)
|
|
1731
|
-
if (computed.length !== receivedSignature.length) return false;
|
|
1732
|
-
let diff = 0;
|
|
1733
|
-
for (let i = 0; i < computed.length; i++) {
|
|
1734
|
-
diff |= computed.charCodeAt(i) ^ receivedSignature.charCodeAt(i);
|
|
1735
|
-
}
|
|
1736
|
-
return diff === 0;
|
|
1737
|
-
} catch {
|
|
1738
|
-
return false;
|
|
1739
|
-
}
|
|
1740
|
-
}
|
|
1741
|
-
|
|
1742
|
-
// ── Mapa Meta → GA4 event names ───────────────────────────────────────────────
|
|
1743
|
-
const META_TO_GA4 = {
|
|
1744
|
-
PageView: 'page_view',
|
|
1745
|
-
ViewContent: 'view_item',
|
|
1746
|
-
Lead: 'generate_lead',
|
|
1747
|
-
Contact: 'generate_lead',
|
|
1748
|
-
Schedule: 'generate_lead',
|
|
1749
|
-
InitiateCheckout: 'begin_checkout',
|
|
1750
|
-
AddToCart: 'add_to_cart',
|
|
1751
|
-
AddPaymentInfo: 'add_payment_info',
|
|
1752
|
-
Purchase: 'purchase',
|
|
1753
|
-
CompleteRegistration: 'sign_up',
|
|
1754
|
-
Subscribe: 'subscribe',
|
|
1755
|
-
StartTrial: 'start_trial',
|
|
1756
|
-
Search: 'search',
|
|
1757
|
-
AddToWishlist: 'add_to_wishlist',
|
|
1758
|
-
};
|
|
1759
|
-
|
|
1760
|
-
// ── Persistir LTV no perfil D1 ───────────────────────────────────────────────
|
|
1761
|
-
async function upsertLtvProfile(env, userId, ltv) {
|
|
1762
|
-
if (!env.DB || !userId) return;
|
|
1763
|
-
try {
|
|
1764
|
-
await env.DB.prepare(`
|
|
1765
|
-
UPDATE user_profiles
|
|
1766
|
-
SET predicted_ltv_class = ?,
|
|
1767
|
-
predicted_ltv_value = ?,
|
|
1768
|
-
updated_at = datetime('now')
|
|
1769
|
-
WHERE user_id = ?
|
|
1770
|
-
`).bind(ltv.class, ltv.value, userId).run();
|
|
1771
|
-
} catch (err) {
|
|
1772
|
-
console.error('upsertLtvProfile error:', err.message);
|
|
1773
|
-
}
|
|
1774
|
-
}
|
|
1775
|
-
|
|
1776
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
1777
|
-
// LTV PREDICTION — Valor Preditivo de Lifetime Value
|
|
1778
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
1779
|
-
|
|
1780
|
-
/**
|
|
1781
|
-
* Prediz o LTV (Lifetime Value) de um lead no momento do primeiro contato.
|
|
1782
|
-
*
|
|
1783
|
-
* Modelo heurístico em 5 dimensões (0–100 pontos):
|
|
1784
|
-
* 1. Engajamento browser (0–30 pts) — engagement_score + user_score
|
|
1785
|
-
* 2. Origem de tráfego (0–25 pts) — UTM source (paid > organic > direct)
|
|
1786
|
-
* 3. Contexto de rede (0–15 pts) — ASN corporativo, país, hora do dia
|
|
1787
|
-
* 4. Contexto do evento (0–20 pts) — InitiateCheckout visto antes = +20
|
|
1788
|
-
* 5. Dados PII disponíveis (0–10 pts) — email + phone + nome = melhor match
|
|
1789
|
-
*
|
|
1790
|
-
* Retorna: { score, class, value }
|
|
1791
|
-
* score: 0–100
|
|
1792
|
-
* class: 'High' | 'Medium' | 'Low'
|
|
1793
|
-
* value: valor em BRL (base × multiplicador da classe)
|
|
1794
|
-
*/
|
|
1795
|
-
async function predictLtv(env, payload, request, customSystemPrompt = null) {
|
|
1796
|
-
// 0. Tentar modelo treinado (regressão logística via D1/KV)
|
|
1797
|
-
try {
|
|
1798
|
-
const model = await _loadActiveWeights(env);
|
|
1799
|
-
if (model?.weights?.length) {
|
|
1800
|
-
const hour = new Date().getUTCHours();
|
|
1801
|
-
const country = (payload.country || request.cf?.country || '').toUpperCase();
|
|
1802
|
-
const features = _extractFeatures({
|
|
1803
|
-
utm_source: payload.utmSource,
|
|
1804
|
-
engagement_score: parseFloat(payload.engagementScore || 0),
|
|
1805
|
-
intention_level: payload.intentionLevel,
|
|
1806
|
-
days_since_lead: 0,
|
|
1807
|
-
has_email: !!payload.email,
|
|
1808
|
-
has_phone: !!payload.phone,
|
|
1809
|
-
is_br: country === 'BR',
|
|
1810
|
-
hour,
|
|
1811
|
-
});
|
|
1812
|
-
const score100 = _predictWithWeights(model, features);
|
|
1813
|
-
const ltvClass = score100 >= 70 ? 'High' : score100 >= 40 ? 'Medium' : 'Low';
|
|
1814
|
-
const multiplier = ltvClass === 'High' ? 3.5 : ltvClass === 'Medium' ? 1.8 : 0.8;
|
|
1815
|
-
const base = payload.value ? parseFloat(payload.value) : 197;
|
|
1816
|
-
return { score: score100, class: ltvClass, value: Math.round(base * multiplier * 100) / 100, source: 'model' };
|
|
1817
|
-
}
|
|
1818
|
-
} catch { /* fallback heurístico */ }
|
|
1819
|
-
|
|
1820
|
-
let score = 0;
|
|
1821
|
-
|
|
1822
|
-
// 1. Engajamento browser (0–30)
|
|
1823
|
-
const engScore = parseFloat(payload.engagementScore || 0);
|
|
1824
|
-
const userScore = parseFloat(payload.userScore || 0);
|
|
1825
|
-
// engagement_score é 0-5 → normaliza para 0-15
|
|
1826
|
-
score += Math.min(15, Math.round((engScore / 5) * 15));
|
|
1827
|
-
// user_score é 0-100 → normaliza para 0-15
|
|
1828
|
-
score += Math.min(15, Math.round((userScore / 100) * 15));
|
|
1829
|
-
|
|
1830
|
-
// 2. Origem de tráfego (0–25)
|
|
1831
|
-
const src = (payload.utmSource || '').toLowerCase();
|
|
1832
|
-
const utm_score_map = {
|
|
1833
|
-
facebook: 25, instagram: 25, meta: 25,
|
|
1834
|
-
google: 22, youtube: 22, tiktok: 20, // youtube = mesmo nível do google search (alta intenção)
|
|
1835
|
-
email: 18, sms: 18,
|
|
1836
|
-
organic: 10, direct: 5,
|
|
1837
|
-
};
|
|
1838
|
-
const utmScore = utm_score_map[src] ?? (src ? 8 : 3);
|
|
1839
|
-
score += utmScore;
|
|
1840
|
-
|
|
1841
|
-
// 3. Contexto de rede (0–15)
|
|
1842
|
-
const hour = new Date().getUTCHours();
|
|
1843
|
-
const country = (payload.country || request.cf?.country || '').toUpperCase();
|
|
1844
|
-
const org = String(request.cf?.asOrganization || '').toLowerCase();
|
|
1845
|
-
|
|
1846
|
-
// Horário de alta conversão: 18h-23h BRT (21h-02h UTC) = +8
|
|
1847
|
-
const isHighConvTime = hour >= 21 || hour <= 2;
|
|
1848
|
-
score += isHighConvTime ? 8 : (hour >= 12 && hour <= 20 ? 4 : 1);
|
|
1849
|
-
|
|
1850
|
-
// País Brasil = público-alvo primário = +5; outros LATAM = +3
|
|
1851
|
-
const latam = ['AR', 'CL', 'CO', 'MX', 'PE', 'UY', 'PY', 'BO'];
|
|
1852
|
-
score += country === 'BR' ? 5 : (latam.includes(country) ? 3 : 1);
|
|
1853
|
-
|
|
1854
|
-
// ASN corporativo (empresa/datacenter = B2B = alto LTV) = +2
|
|
1855
|
-
const isCorp = /ltda|s\.a\.|corp|telecom|fibra|claro|vivo|tim|oi/.test(org);
|
|
1856
|
-
score += isCorp ? 2 : 0;
|
|
1857
|
-
|
|
1858
|
-
// 4. Contexto do evento (0–20)
|
|
1859
|
-
// InitiateCheckout visto antes deste Lead = usuário já na jornada de compra
|
|
1860
|
-
const intentionLevel = (payload.intentionLevel || '').toLowerCase();
|
|
1861
|
-
if (intentionLevel === 'comprador' || intentionLevel === 'high_intent') score += 20;
|
|
1862
|
-
else if (intentionLevel === 'interessado') score += 12;
|
|
1863
|
-
else if (intentionLevel === 'nurture') score += 6;
|
|
1864
|
-
|
|
1865
|
-
// 5. Dados PII disponíveis (0–10)
|
|
1866
|
-
if (payload.email) score += 4;
|
|
1867
|
-
if (payload.phone) score += 4;
|
|
1868
|
-
if (payload.firstName) score += 2;
|
|
1869
|
-
|
|
1870
|
-
score = Math.min(100, score);
|
|
1871
|
-
|
|
1872
|
-
// Classificação
|
|
1873
|
-
let ltvClass, ltvMultiplier;
|
|
1874
|
-
if (score >= 70) {
|
|
1875
|
-
ltvClass = 'High'; ltvMultiplier = 3.5;
|
|
1876
|
-
} else if (score >= 40) {
|
|
1877
|
-
ltvClass = 'Medium'; ltvMultiplier = 1.8;
|
|
1878
|
-
} else {
|
|
1879
|
-
ltvClass = 'Low'; ltvMultiplier = 0.8;
|
|
1880
|
-
}
|
|
1881
|
-
|
|
1882
|
-
// Valor base do produto (do payload) ou estimativa por classe
|
|
1883
|
-
const productValue = payload.value ? parseFloat(payload.value) : 0;
|
|
1884
|
-
const baseValue = productValue > 0 ? productValue : 197; // ticket médio padrão BR
|
|
1885
|
-
const predictedValue = Math.round(baseValue * ltvMultiplier * 100) / 100;
|
|
1886
|
-
|
|
1887
|
-
// Enriquecimento opcional via Workers AI (se binding disponível)
|
|
1888
|
-
// Usado apenas para ajuste fino do score — não bloqueia o fluxo principal
|
|
1889
|
-
let aiAdjustment = 0;
|
|
1890
|
-
if (env.AI && score >= 40) {
|
|
1891
|
-
try {
|
|
1892
|
-
const systemContent = customSystemPrompt ||
|
|
1893
|
-
'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.';
|
|
1894
|
-
const prompt = [
|
|
1895
|
-
{ role: 'system', content: systemContent },
|
|
1896
|
-
{ role: 'user', content: JSON.stringify({
|
|
1897
|
-
utm_source: payload.utmSource,
|
|
1898
|
-
intention: intentionLevel,
|
|
1899
|
-
engagement: engScore,
|
|
1900
|
-
hour_utc: hour,
|
|
1901
|
-
country,
|
|
1902
|
-
has_email: !!payload.email,
|
|
1903
|
-
has_phone: !!payload.phone,
|
|
1904
|
-
})},
|
|
1905
|
-
];
|
|
1906
|
-
const aiRes = await env.AI.run('@cf/ibm-granite/granite-4.0-h-micro', { messages: prompt, max_tokens: 32 });
|
|
1907
|
-
const parsed = JSON.parse(aiRes.response.trim());
|
|
1908
|
-
if (typeof parsed.adjustment === 'number') {
|
|
1909
|
-
aiAdjustment = Math.max(-10, Math.min(10, parsed.adjustment));
|
|
1910
|
-
}
|
|
1911
|
-
} catch { /* graceful fallback — AI opcional */ }
|
|
1912
|
-
}
|
|
1913
|
-
|
|
1914
|
-
const finalScore = Math.min(100, Math.max(0, score + aiAdjustment));
|
|
1915
|
-
|
|
1916
|
-
return {
|
|
1917
|
-
score: finalScore,
|
|
1918
|
-
class: ltvClass,
|
|
1919
|
-
value: predictedValue,
|
|
1920
|
-
};
|
|
1921
|
-
}
|
|
1922
|
-
|
|
1923
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
1924
|
-
// FEEDBACK LOOP — Monitoramento de Falhas e Saúde
|
|
1925
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
1926
|
-
|
|
1927
|
-
// ── Log de Falha de API ─────────────────────────────────────────────────────
|
|
1928
|
-
async function logApiFailure(DB, platform, eventName, errorCode, errorMessage, eventId, rawPayload) {
|
|
1929
|
-
try {
|
|
1930
|
-
await DB.prepare(`
|
|
1931
|
-
INSERT INTO api_failures (platform, event_name, error_code, error_message, event_id, raw_payload, retry_count, final_status)
|
|
1932
|
-
VALUES (?, ?, ?, ?, ?, ?, 0, 'failed')
|
|
1933
|
-
`).bind(platform, eventName, String(errorCode), errorMessage, eventId, rawPayload).run();
|
|
1934
|
-
} catch (err) {
|
|
1935
|
-
console.error('Failed to log API failure:', err.message);
|
|
1936
|
-
}
|
|
1937
|
-
}
|
|
1938
|
-
|
|
1939
|
-
// ── Métricas de Saúde (últimas 24h) ─────────────────────────────────────────
|
|
1940
|
-
async function getHealthMetrics(DB, platform, hours = 24) {
|
|
1941
|
-
try {
|
|
1942
|
-
// Total de eventos com falha
|
|
1943
|
-
const failures = await DB.prepare(`
|
|
1944
|
-
SELECT COUNT(*) as count, error_code
|
|
1945
|
-
FROM api_failures
|
|
1946
|
-
WHERE platform = ? AND created_at > datetime('now', '-${hours} hours')
|
|
1947
|
-
GROUP BY error_code
|
|
1948
|
-
`).bind(platform).all();
|
|
1949
|
-
|
|
1950
|
-
// Total de eventos enviados (leads table)
|
|
1951
|
-
const totalSent = await DB.prepare(`
|
|
1952
|
-
SELECT COUNT(*) as count
|
|
1953
|
-
FROM leads
|
|
1954
|
-
WHERE platform = ? AND created_at > datetime('now', '-${hours} hours')
|
|
1955
|
-
`).bind(platform).first();
|
|
1956
|
-
|
|
1957
|
-
const totalFailed = failures.reduce((sum, f) => sum + f.count, 0);
|
|
1958
|
-
const successRate = totalSent?.count > 0
|
|
1959
|
-
? ((totalSent.count - totalFailed) / totalSent.count) * 100
|
|
1960
|
-
: 100;
|
|
1961
|
-
|
|
1962
|
-
return {
|
|
1963
|
-
platform,
|
|
1964
|
-
hours,
|
|
1965
|
-
events_sent: totalSent?.count || 0,
|
|
1966
|
-
events_failed: totalFailed,
|
|
1967
|
-
success_rate: successRate,
|
|
1968
|
-
errors_detected: failures.map(f => ({ code: f.error_code, count: f.count })),
|
|
1969
|
-
issues: totalFailed > (totalSent?.count || 0) * 0.1 ? ['high_error_rate'] : [],
|
|
1970
|
-
};
|
|
1971
|
-
} catch (err) {
|
|
1972
|
-
console.error('Failed to get health metrics:', err.message);
|
|
1973
|
-
return {
|
|
1974
|
-
platform,
|
|
1975
|
-
hours,
|
|
1976
|
-
events_sent: 0,
|
|
1977
|
-
events_failed: 0,
|
|
1978
|
-
success_rate: 0,
|
|
1979
|
-
errors_detected: [],
|
|
1980
|
-
issues: ['metrics_unavailable'],
|
|
1981
|
-
};
|
|
1982
|
-
}
|
|
1983
|
-
}
|
|
1984
|
-
|
|
1985
|
-
// ── Gerar Relatório Diário ────────────────────────────────────────────────────
|
|
1986
|
-
async function generateDailyReport(DB) {
|
|
1987
|
-
const platforms = ['meta', 'ga4', 'tiktok', 'pinterest', 'reddit'];
|
|
1988
|
-
const today = new Date().toISOString().split('T')[0];
|
|
1989
|
-
|
|
1990
|
-
const reports = [];
|
|
1991
|
-
|
|
1992
|
-
for (const platform of platforms) {
|
|
1993
|
-
const metrics = await getHealthMetrics(DB, platform, 24);
|
|
1994
|
-
|
|
1995
|
-
try {
|
|
1996
|
-
await DB.prepare(`
|
|
1997
|
-
INSERT INTO health_reports (report_date, platform, events_sent, events_failed, success_rate, errors_detected, issues_detected)
|
|
1998
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1999
|
-
`).bind(
|
|
2000
|
-
today,
|
|
2001
|
-
platform,
|
|
2002
|
-
metrics.events_sent,
|
|
2003
|
-
metrics.events_failed,
|
|
2004
|
-
metrics.success_rate,
|
|
2005
|
-
JSON.stringify(metrics.errors_detected),
|
|
2006
|
-
JSON.stringify(metrics.issues)
|
|
2007
|
-
).run();
|
|
2008
|
-
reports.push({ platform, status: 'ok' });
|
|
2009
|
-
} catch (err) {
|
|
2010
|
-
console.error(`Failed to generate report for ${platform}:`, err.message);
|
|
2011
|
-
reports.push({ platform, status: 'failed' });
|
|
2012
|
-
}
|
|
2013
|
-
}
|
|
2014
|
-
|
|
2015
|
-
return reports;
|
|
2016
|
-
}
|
|
2017
|
-
|
|
2018
|
-
// ── Verificar Versões de API (scheduled task) ────────────────────────────────
|
|
2019
|
-
async function checkApiVersions() {
|
|
2020
|
-
// Consulta api-versions.json para verificar versões atuais
|
|
2021
|
-
// Em produção, isso poderia fazer fetch para documentação oficial
|
|
2022
|
-
const currentVersions = {
|
|
2023
|
-
meta: 'v22.0',
|
|
2024
|
-
ga4: 'latest',
|
|
2025
|
-
tiktok: 'v1.3',
|
|
2026
|
-
pinterest: 'v5',
|
|
2027
|
-
reddit: 'v2.0',
|
|
2028
|
-
};
|
|
2029
|
-
|
|
2030
|
-
const today = new Date().toISOString().split('T')[0];
|
|
2031
|
-
const nextReview = '2026-04-27'; // Data do próximo review
|
|
2032
|
-
|
|
2033
|
-
return {
|
|
2034
|
-
check_date: today,
|
|
2035
|
-
next_review: nextReview,
|
|
2036
|
-
versions: currentVersions,
|
|
2037
|
-
status: 'ok',
|
|
2038
|
-
};
|
|
2039
|
-
}
|
|
2040
|
-
|
|
2041
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
2042
|
-
// INTELLIGENCE AGENT — Monitoramento Autônomo
|
|
2043
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
2044
|
-
|
|
2045
|
-
// Versões esperadas das APIs (fonte da verdade: contracts/api-versions.json)
|
|
2046
|
-
const EXPECTED_API_VERSIONS = {
|
|
2047
|
-
meta: 'v22.0',
|
|
2048
|
-
ga4: 'latest',
|
|
2049
|
-
tiktok: 'v1.3',
|
|
2050
|
-
pinterest: 'v5',
|
|
2051
|
-
reddit: 'v2.0',
|
|
2052
|
-
};
|
|
2053
|
-
|
|
2054
|
-
// Thresholds de alerta
|
|
2055
|
-
const ALERT_THRESHOLDS = {
|
|
2056
|
-
errorRateCritical: 0.20, // > 20% de falha = crítico
|
|
2057
|
-
errorRateWarning: 0.10, // > 10% de falha = aviso
|
|
2058
|
-
};
|
|
2059
|
-
|
|
2060
|
-
// ── Log de execução do Intelligence Agent no D1 ──────────────────────────────
|
|
2061
|
-
async function logIntelligence(DB, runType, platform, checkType, status, currentValue, expectedValue, message, alertSent = false) {
|
|
2062
|
-
if (!DB) return;
|
|
2063
|
-
try {
|
|
2064
|
-
await DB.prepare(`
|
|
2065
|
-
INSERT INTO intelligence_logs (run_type, platform, check_type, status, current_value, expected_value, message, alert_sent)
|
|
2066
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
2067
|
-
`).bind(runType, platform, checkType, status, String(currentValue ?? ''), String(expectedValue ?? ''), message, alertSent ? 1 : 0).run();
|
|
2068
|
-
} catch (err) {
|
|
2069
|
-
console.error('logIntelligence error:', err.message);
|
|
2070
|
-
}
|
|
2071
|
-
}
|
|
2072
|
-
|
|
2073
|
-
// ── Alerta CallMeBot para o admin (falhas de sistema, API down, erros críticos)
|
|
2074
|
-
async function sendIntelligenceAlert(env, severity, title, details) {
|
|
2075
|
-
const icon = severity === 'critical' ? '🚨' : '⚠️';
|
|
2076
|
-
const texto =
|
|
2077
|
-
`${icon} CDP Edge — ${title}\n\n` +
|
|
2078
|
-
details + '\n\n' +
|
|
2079
|
-
new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' });
|
|
2080
|
-
|
|
2081
|
-
return sendCallMeBot(env, texto);
|
|
2082
|
-
}
|
|
2083
|
-
|
|
2084
|
-
// ── Check de versões de API ───────────────────────────────────────────────────
|
|
2085
|
-
async function checkApiVersionsIntelligence(env, runType) {
|
|
2086
|
-
const results = [];
|
|
2087
|
-
|
|
2088
|
-
for (const [platform, expected] of Object.entries(EXPECTED_API_VERSIONS)) {
|
|
2089
|
-
// Versão atual baseada no código deployado
|
|
2090
|
-
const currentMap = { meta: 'v22.0', tiktok: 'v1.3', ga4: 'latest', pinterest: 'v5', reddit: 'v2.0' };
|
|
2091
|
-
const current = currentMap[platform] || 'unknown';
|
|
2092
|
-
const isOk = current === expected || expected === 'latest';
|
|
2093
|
-
const status = isOk ? 'ok' : 'warning';
|
|
2094
|
-
|
|
2095
|
-
await logIntelligence(env.DB, runType, platform, 'api_version', status, current, expected,
|
|
2096
|
-
isOk ? `${platform} ${current} — versão correta` : `${platform} ${current} desatualizado, esperado ${expected}`
|
|
2097
|
-
);
|
|
2098
|
-
|
|
2099
|
-
results.push({ platform, current, expected, status });
|
|
2100
|
-
}
|
|
2101
|
-
|
|
2102
|
-
return results;
|
|
2103
|
-
}
|
|
2104
|
-
|
|
2105
|
-
// ── Auditoria de taxa de erro por plataforma (últimas 24h) ───────────────────
|
|
2106
|
-
async function auditErrorRates(env, runType) {
|
|
2107
|
-
if (!env.DB) return [];
|
|
2108
|
-
const alerts = [];
|
|
2109
|
-
|
|
2110
|
-
for (const platform of ['meta', 'ga4', 'tiktok']) {
|
|
2111
|
-
const metrics = await getHealthMetrics(env.DB, platform, 24);
|
|
2112
|
-
const errorRate = metrics.events_sent > 0
|
|
2113
|
-
? metrics.events_failed / metrics.events_sent
|
|
2114
|
-
: 0;
|
|
2115
|
-
|
|
2116
|
-
let status = 'ok';
|
|
2117
|
-
if (errorRate >= ALERT_THRESHOLDS.errorRateCritical) status = 'critical';
|
|
2118
|
-
else if (errorRate >= ALERT_THRESHOLDS.errorRateWarning) status = 'warning';
|
|
2119
|
-
|
|
2120
|
-
const message = `${platform}: ${metrics.events_sent} eventos, ${metrics.events_failed} falhas (${(errorRate * 100).toFixed(1)}%)`;
|
|
2121
|
-
const alertSent = status !== 'ok'
|
|
2122
|
-
? await sendIntelligenceAlert(env, status, `Taxa de Erro Alta — ${platform.toUpperCase()}`,
|
|
2123
|
-
`📊 ${message}\n🎯 Taxa: ${(errorRate * 100).toFixed(1)}% (limite: ${ALERT_THRESHOLDS.errorRateWarning * 100}%)`)
|
|
2124
|
-
: false;
|
|
2125
|
-
|
|
2126
|
-
await logIntelligence(env.DB, runType, platform, 'error_rate', status,
|
|
2127
|
-
`${(errorRate * 100).toFixed(1)}%`,
|
|
2128
|
-
`${ALERT_THRESHOLDS.errorRateWarning * 100}%`,
|
|
2129
|
-
message, alertSent
|
|
2130
|
-
);
|
|
2131
|
-
|
|
2132
|
-
if (status !== 'ok') alerts.push({ platform, errorRate, status });
|
|
2133
|
-
}
|
|
2134
|
-
|
|
2135
|
-
return alerts;
|
|
2136
|
-
}
|
|
2137
|
-
|
|
2138
|
-
// ── Treinar modelo LTV com dados reais do D1 ─────────────────────────────────
|
|
2139
|
-
async function _trainLtvModel(env) {
|
|
2140
|
-
if (!env.DB) return { skipped: 'DB não disponível' };
|
|
2141
|
-
try {
|
|
2142
|
-
const rows = await env.DB.prepare(`
|
|
2143
|
-
SELECT
|
|
2144
|
-
l.utm_source, l.engagement_score, l.intention_level,
|
|
2145
|
-
CAST(julianday('now') - julianday(l.created_at) AS INTEGER) AS days_since_lead,
|
|
2146
|
-
CASE WHEN l.email IS NOT NULL AND l.email != '' THEN 1 ELSE 0 END AS has_email,
|
|
2147
|
-
CASE WHEN l.phone IS NOT NULL AND l.phone != '' THEN 1 ELSE 0 END AS has_phone,
|
|
2148
|
-
CASE WHEN (l.country = 'br' OR l.country = 'BR' OR l.country IS NULL) THEN 1 ELSE 0 END AS is_br,
|
|
2149
|
-
CAST(strftime('%H', l.created_at) AS INTEGER) AS hour,
|
|
2150
|
-
CASE WHEN EXISTS (
|
|
2151
|
-
SELECT 1 FROM events e
|
|
2152
|
-
WHERE e.user_id = l.user_id
|
|
2153
|
-
AND e.event_name IN ('Purchase','purchase','PURCHASE')
|
|
2154
|
-
AND e.created_at > l.created_at
|
|
2155
|
-
) THEN 1 ELSE 0 END AS label
|
|
2156
|
-
FROM leads l
|
|
2157
|
-
WHERE l.created_at >= datetime('now', '-90 days')
|
|
2158
|
-
LIMIT 5000
|
|
2159
|
-
`).all();
|
|
2160
|
-
|
|
2161
|
-
const dataset = (rows.results || []).map(row => ({ features: _extractFeatures(row), label: row.label || 0 }));
|
|
2162
|
-
const model = _trainLogisticRegression(dataset);
|
|
2163
|
-
|
|
2164
|
-
if (!model) {
|
|
2165
|
-
return { skipped: 'dados insuficientes', samples: dataset.length };
|
|
2166
|
-
}
|
|
2167
|
-
|
|
2168
|
-
await _saveWeights(env.DB, model);
|
|
2169
|
-
if (env.GEO_CACHE) env.GEO_CACHE.delete(_LTV_WEIGHTS_KV_KEY).catch(() => {});
|
|
2170
|
-
|
|
2171
|
-
return { trained: true, samples: dataset.length, accuracy: model.accuracy, positiveRate: model.positiveRate };
|
|
2172
|
-
} catch (err) {
|
|
2173
|
-
console.error('[LTV Train] Erro:', err.message);
|
|
2174
|
-
return { error: err.message };
|
|
2175
|
-
}
|
|
2176
|
-
}
|
|
2177
|
-
|
|
2178
|
-
// ── Auto-decisão de winner no A/B LTV Test ────────────────────────────────────
|
|
2179
|
-
const _AB_LTV_CACHE_KEY = 'ab_ltv_active_test';
|
|
2180
|
-
|
|
2181
|
-
async function _autoDecideAbWinner(env) {
|
|
2182
|
-
if (!env.DB) return null;
|
|
2183
|
-
try {
|
|
2184
|
-
const test = await env.DB.prepare(`
|
|
2185
|
-
SELECT id, min_sample_size FROM ltv_ab_tests WHERE status = 'running' ORDER BY created_at DESC LIMIT 1
|
|
2186
|
-
`).first();
|
|
2187
|
-
if (!test) return { decided: false };
|
|
2188
|
-
|
|
2189
|
-
const variations = await env.DB.prepare(`
|
|
2190
|
-
SELECT id, name, is_control, sample_count, accuracy_score
|
|
2191
|
-
FROM ltv_ab_variations WHERE test_id = ?
|
|
2192
|
-
`).bind(test.id).all();
|
|
2193
|
-
|
|
2194
|
-
const vars = variations.results || [];
|
|
2195
|
-
if (vars.some(v => (v.sample_count || 0) < (test.min_sample_size || 50))) return { decided: false };
|
|
2196
|
-
|
|
2197
|
-
const control = vars.find(v => v.is_control) || vars[0];
|
|
2198
|
-
const best = vars.reduce((a, b) => (b.accuracy_score || 0) > (a.accuracy_score || 0) ? b : a, control);
|
|
2199
|
-
|
|
2200
|
-
if (best.id === control.id) return { decided: false };
|
|
2201
|
-
|
|
2202
|
-
const improvement = (best.accuracy_score || 0) - (control.accuracy_score || 0);
|
|
2203
|
-
if (improvement < 5) return { decided: false };
|
|
2204
|
-
|
|
2205
|
-
await env.DB.prepare(`
|
|
2206
|
-
UPDATE ltv_ab_tests SET status='completed', winner_id=?, auto_decided_at=datetime('now'), auto_decided_reason=?
|
|
2207
|
-
WHERE id=?
|
|
2208
|
-
`).bind(best.id, `Auto: +${improvement.toFixed(1)}pp vs control`, test.id).run();
|
|
2209
|
-
|
|
2210
|
-
if (env.GEO_CACHE) env.GEO_CACHE.delete(_AB_LTV_CACHE_KEY).catch(() => {});
|
|
2211
|
-
|
|
2212
|
-
const winnerVar = await env.DB.prepare(
|
|
2213
|
-
`SELECT system_prompt FROM ltv_ab_variations WHERE id = ?`
|
|
2214
|
-
).bind(best.id).first();
|
|
2215
|
-
|
|
2216
|
-
return { decided: true, test_id: test.id, winner_name: best.name, improvement, winning_prompt: winnerVar?.system_prompt };
|
|
2217
|
-
} catch (err) {
|
|
2218
|
-
console.error('[AB Auto-Decide] Erro:', err.message);
|
|
2219
|
-
return null;
|
|
2220
|
-
}
|
|
2221
|
-
}
|
|
2222
|
-
|
|
2223
|
-
// ── Runner principal do Intelligence Agent ────────────────────────────────────
|
|
2224
|
-
async function runIntelligenceAgent(env, runType) {
|
|
2225
|
-
|
|
2226
|
-
// 1. Check de versões (sempre)
|
|
2227
|
-
const versionResults = await checkApiVersionsIntelligence(env, runType);
|
|
2228
|
-
|
|
2229
|
-
// 2. Relatório diário de saúde (sempre)
|
|
2230
|
-
if (env.DB) {
|
|
2231
|
-
const reports = await generateDailyReport(env.DB);
|
|
2232
|
-
}
|
|
2233
|
-
|
|
2234
|
-
// 3. Auditoria de taxas de erro (sempre)
|
|
2235
|
-
const errorAlerts = await auditErrorRates(env, runType);
|
|
2236
|
-
if (errorAlerts.length > 0) {
|
|
2237
|
-
console.warn(`[Intelligence Agent] ${errorAlerts.length} alertas de taxa de erro enviados`);
|
|
2238
|
-
}
|
|
2239
|
-
|
|
2240
|
-
// 4. Treinar modelo LTV com dados reais do D1 (toda semana)
|
|
2241
|
-
const ltvTrainResult = await _trainLtvModel(env);
|
|
2242
|
-
if (ltvTrainResult.trained) {
|
|
2243
|
-
if (env.DB) {
|
|
2244
|
-
logIntelligence(env.DB, runType, 'ltv', 'model_training', 'ok',
|
|
2245
|
-
`accuracy=${(ltvTrainResult.accuracy * 100).toFixed(1)}%`, null,
|
|
2246
|
-
`Modelo LTV re-treinado com ${ltvTrainResult.samples} amostras`
|
|
2247
|
-
).catch(() => {});
|
|
2248
|
-
}
|
|
2249
|
-
}
|
|
2250
|
-
|
|
2251
|
-
// 5. Auto-decisão de winner no A/B LTV Test
|
|
2252
|
-
try {
|
|
2253
|
-
const abResult = await _autoDecideAbWinner(env);
|
|
2254
|
-
if (abResult?.decided) {
|
|
2255
|
-
await sendIntelligenceAlert(env, 'info',
|
|
2256
|
-
`A/B LTV Test — Winner Declarado`,
|
|
2257
|
-
`🏆 Vencedor: ${abResult.winner_name}\n📈 Melhoria: +${abResult.improvement?.toFixed(1) ?? '?'}pp vs controle\n🆔 Test ID: ${abResult.test_id}\n\n✅ Prompt vencedor ativado automaticamente`
|
|
2258
|
-
);
|
|
2259
|
-
}
|
|
2260
|
-
} catch (err) {
|
|
2261
|
-
console.error('[Intelligence Agent] A/B auto-decide error:', err.message);
|
|
2262
|
-
}
|
|
2263
|
-
|
|
2264
|
-
// 6. Match Quality — análise + alertas
|
|
2265
|
-
try {
|
|
2266
|
-
const mqAnalysis = await _analyzeMatchQuality(env);
|
|
2267
|
-
if (mqAnalysis) {
|
|
2268
|
-
await _alertMatchQuality(env, mqAnalysis);
|
|
2269
|
-
}
|
|
2270
|
-
} catch (err) {
|
|
2271
|
-
console.error('[Intelligence Agent] Match quality error:', err.message);
|
|
2272
|
-
}
|
|
2273
|
-
|
|
2274
|
-
// 7. Auditoria mensal adicional
|
|
2275
|
-
if (runType === 'monthly_audit') {
|
|
2276
|
-
if (env.DB) {
|
|
2277
|
-
try {
|
|
2278
|
-
const ltvStats = await env.DB.prepare(`
|
|
2279
|
-
SELECT predicted_ltv_class, COUNT(*) as count
|
|
2280
|
-
FROM user_profiles
|
|
2281
|
-
WHERE predicted_ltv_class IS NOT NULL
|
|
2282
|
-
AND updated_at > datetime('now', '-30 days')
|
|
2283
|
-
GROUP BY predicted_ltv_class
|
|
2284
|
-
`).all();
|
|
2285
|
-
|
|
2286
|
-
const summary = ltvStats.results?.map(r => `${r.predicted_ltv_class}: ${r.count}`).join(', ') || 'sem dados';
|
|
2287
|
-
await logIntelligence(env.DB, runType, 'all', 'ltv_distribution', 'ok', summary, null,
|
|
2288
|
-
`Distribuição LTV últimos 30 dias: ${summary}`);
|
|
2289
|
-
} catch (err) {
|
|
2290
|
-
console.error('LTV audit error:', err.message);
|
|
2291
|
-
}
|
|
2292
|
-
|
|
2293
|
-
// Purge de logs antigos de match quality (> 30 dias)
|
|
2294
|
-
await _purgeOldMatchQualityLogs(env.DB);
|
|
2295
|
-
}
|
|
2296
|
-
}
|
|
2297
|
-
|
|
2298
|
-
// 8. Customer Match — sync semanal D1 → Meta Custom Audience
|
|
2299
|
-
const cmResult = await syncMetaCustomAudience(env);
|
|
2300
|
-
|
|
2301
|
-
}
|
|
2302
|
-
|
|
2303
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
2304
|
-
// CUSTOMER MATCH — Sync automático D1 → Meta Custom Audiences
|
|
2305
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
2306
|
-
|
|
2307
|
-
/**
|
|
2308
|
-
* Busca leads high_intent + buyer_lookalike do D1 e envia para Meta Custom Audience.
|
|
2309
|
-
*
|
|
2310
|
-
* Secrets necessários (wrangler secret put):
|
|
2311
|
-
* META_AD_ACCOUNT_ID → ID da conta de anúncios (act_XXXXXXXXX)
|
|
2312
|
-
* META_AUDIENCE_ID → ID da Custom Audience já criada no Meta Ads
|
|
2313
|
-
*
|
|
2314
|
-
* Meta aceita até 10.000 usuários por chamada.
|
|
2315
|
-
* Formato: schema EMAIL_SHA256 + PHONE_SHA256, dados já hasheados.
|
|
2316
|
-
*/
|
|
2317
|
-
async function syncMetaCustomAudience(env) {
|
|
2318
|
-
if (!env.META_ACCESS_TOKEN || !env.META_AD_ACCOUNT_ID || !env.META_AUDIENCE_ID) {
|
|
2319
|
-
return { skipped: 'META_AD_ACCOUNT_ID ou META_AUDIENCE_ID não configurados' };
|
|
2320
|
-
}
|
|
2321
|
-
if (!env.DB) return { skipped: 'DB não disponível' };
|
|
2322
|
-
|
|
2323
|
-
try {
|
|
2324
|
-
// Busca perfis high_intent e buyer_lookalike atualizados nos últimos 30 dias
|
|
2325
|
-
const profiles = await env.DB.prepare(`
|
|
2326
|
-
SELECT email, phone
|
|
2327
|
-
FROM user_profiles
|
|
2328
|
-
WHERE cohort_label IN ('high_intent', 'buyer_lookalike')
|
|
2329
|
-
AND updated_at > datetime('now', '-30 days')
|
|
2330
|
-
AND email IS NOT NULL
|
|
2331
|
-
LIMIT 10000
|
|
2332
|
-
`).all();
|
|
2333
|
-
|
|
2334
|
-
if (!profiles.results || profiles.results.length === 0) {
|
|
2335
|
-
return { sent: 0 };
|
|
2336
|
-
}
|
|
2337
|
-
|
|
2338
|
-
// Hash de cada linha — Meta exige SHA-256 lowercase sem espaços
|
|
2339
|
-
const data = await Promise.all(
|
|
2340
|
-
profiles.results.map(async (p) => {
|
|
2341
|
-
const row = [];
|
|
2342
|
-
row.push(p.email ? await sha256(p.email) : '');
|
|
2343
|
-
row.push(p.phone ? await sha256(p.phone) : '');
|
|
2344
|
-
return row;
|
|
2345
|
-
})
|
|
2346
|
-
);
|
|
2347
|
-
|
|
2348
|
-
const body = {
|
|
2349
|
-
payload: {
|
|
2350
|
-
schema: ['EMAIL_SHA256', 'PHONE_SHA256'],
|
|
2351
|
-
data,
|
|
2352
|
-
},
|
|
2353
|
-
};
|
|
2354
|
-
|
|
2355
|
-
const endpoint = `https://graph.facebook.com/v22.0/${env.META_AUDIENCE_ID}/users`;
|
|
2356
|
-
const res = await fetch(endpoint, {
|
|
2357
|
-
method: 'POST',
|
|
2358
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2359
|
-
body: JSON.stringify({ ...body, access_token: env.META_ACCESS_TOKEN }),
|
|
2360
|
-
});
|
|
2361
|
-
|
|
2362
|
-
const result = await res.json();
|
|
2363
|
-
|
|
2364
|
-
if (!res.ok) {
|
|
2365
|
-
console.error('[CustomerMatch] Meta erro:', res.status, result.error?.message || 'unknown');
|
|
2366
|
-
return { error: result.error?.message, sent: 0 };
|
|
2367
|
-
}
|
|
2368
|
-
|
|
2369
|
-
return { sent: profiles.results.length, num_received: result.num_received };
|
|
2370
|
-
|
|
2371
|
-
} catch (err) {
|
|
2372
|
-
console.error('[CustomerMatch] Meta fetch error:', err.message);
|
|
2373
|
-
return { error: err.message, sent: 0 };
|
|
2374
|
-
}
|
|
2375
|
-
}
|
|
2376
|
-
|
|
2377
|
-
/**
|
|
2378
|
-
* Gera payload formatado para Google Ads Customer Match (upload manual ou via API).
|
|
2379
|
-
* Retorna JSON com emails e phones hasheados prontos para upload.
|
|
2380
|
-
*
|
|
2381
|
-
* Google Ads Customer Match não tem API simples disponível via Workers sem OAuth2.
|
|
2382
|
-
* Esta função gera o arquivo — o upload é feito via endpoint GET /export/customer-match.
|
|
2383
|
-
*/
|
|
2384
|
-
async function buildGoogleCustomerMatchExport(env) {
|
|
2385
|
-
if (!env.DB) return [];
|
|
2386
|
-
|
|
2387
|
-
const profiles = await env.DB.prepare(`
|
|
2388
|
-
SELECT email, phone, first_name, last_name
|
|
2389
|
-
FROM user_profiles
|
|
2390
|
-
WHERE cohort_label IN ('high_intent', 'buyer_lookalike')
|
|
2391
|
-
AND updated_at > datetime('now', '-30 days')
|
|
2392
|
-
AND email IS NOT NULL
|
|
2393
|
-
LIMIT 10000
|
|
2394
|
-
`).all();
|
|
2395
|
-
|
|
2396
|
-
if (!profiles.results?.length) return [];
|
|
2397
|
-
|
|
2398
|
-
return Promise.all(
|
|
2399
|
-
profiles.results.map(async (p) => ({
|
|
2400
|
-
hashed_email: p.email ? await sha256(p.email) : '',
|
|
2401
|
-
hashed_phone: p.phone ? await sha256(p.phone) : '',
|
|
2402
|
-
first_name: p.first_name || '',
|
|
2403
|
-
last_name: p.last_name || '',
|
|
2404
|
-
}))
|
|
2405
|
-
);
|
|
2406
|
-
}
|
|
2407
|
-
|
|
2408
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
2409
|
-
// SEGMENTAÇÃO DINÂMICA ML — Handlers das Rotas de Clustering
|
|
2410
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
2411
|
-
|
|
2412
|
-
// Helper: parse seguro de JSON armazenado como TEXT no D1
|
|
2413
|
-
function tryParseJson(str, fallback) {
|
|
2414
|
-
if (!str) return fallback !== undefined ? fallback : null;
|
|
2415
|
-
try { return JSON.parse(str); } catch { return fallback !== undefined ? fallback : null; }
|
|
2416
|
-
}
|
|
2417
|
-
|
|
2418
|
-
// ── Helpers K-means vetorial (usado pelo clustering com embeddings) ───────────
|
|
2419
|
-
|
|
2420
|
-
function _cosDist(a, b) {
|
|
2421
|
-
let dot = 0, na = 0, nb = 0;
|
|
2422
|
-
for (let i = 0; i < a.length; i++) { dot += a[i]*b[i]; na += a[i]*a[i]; nb += b[i]*b[i]; }
|
|
2423
|
-
return 1 - dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-10);
|
|
2424
|
-
}
|
|
2425
|
-
|
|
2426
|
-
function _kmeansRun(vectors, k, maxIter = 25) {
|
|
2427
|
-
const n = vectors.length;
|
|
2428
|
-
const dim = vectors[0].length;
|
|
2429
|
-
// K-means++ init
|
|
2430
|
-
const centroids = [vectors[Math.floor(Math.random() * n)]];
|
|
2431
|
-
while (centroids.length < k) {
|
|
2432
|
-
const dists = vectors.map(v => Math.min(...centroids.map(c => _cosDist(v, c))));
|
|
2433
|
-
const sum = dists.reduce((a, b) => a + b, 0);
|
|
2434
|
-
let r = Math.random() * sum, cumul = 0;
|
|
2435
|
-
for (let i = 0; i < n; i++) { cumul += dists[i]; if (cumul >= r) { centroids.push(vectors[i]); break; } }
|
|
2436
|
-
if (centroids.length < k) centroids.push(vectors[Math.floor(Math.random() * n)]);
|
|
2437
|
-
}
|
|
2438
|
-
|
|
2439
|
-
let assignments = new Array(n).fill(0);
|
|
2440
|
-
for (let iter = 0; iter < maxIter; iter++) {
|
|
2441
|
-
let changed = false;
|
|
2442
|
-
for (let i = 0; i < n; i++) {
|
|
2443
|
-
let best = 0, bestD = Infinity;
|
|
2444
|
-
for (let c = 0; c < k; c++) { const d = _cosDist(vectors[i], centroids[c]); if (d < bestD) { bestD = d; best = c; } }
|
|
2445
|
-
if (assignments[i] !== best) { assignments[i] = best; changed = true; }
|
|
2446
|
-
}
|
|
2447
|
-
if (!changed) break;
|
|
2448
|
-
// Recompute centroids
|
|
2449
|
-
for (let c = 0; c < k; c++) {
|
|
2450
|
-
const members = vectors.filter((_, i) => assignments[i] === c);
|
|
2451
|
-
if (members.length === 0) continue;
|
|
2452
|
-
for (let d = 0; d < dim; d++) centroids[c][d] = members.reduce((s, v) => s + v[d], 0) / members.length;
|
|
2453
|
-
}
|
|
2454
|
-
}
|
|
2455
|
-
return { assignments, centroids };
|
|
2456
|
-
}
|
|
2457
|
-
|
|
2458
|
-
function _silhouette(vectors, assignments, k) {
|
|
2459
|
-
const n = vectors.length;
|
|
2460
|
-
let total = 0;
|
|
2461
|
-
for (let i = 0; i < n; i++) {
|
|
2462
|
-
const ci = assignments[i];
|
|
2463
|
-
const sameCluster = vectors.filter((_, j) => j !== i && assignments[j] === ci);
|
|
2464
|
-
const a = sameCluster.length ? sameCluster.reduce((s, v) => s + _cosDist(vectors[i], v), 0) / sameCluster.length : 0;
|
|
2465
|
-
let b = Infinity;
|
|
2466
|
-
for (let c = 0; c < k; c++) {
|
|
2467
|
-
if (c === ci) continue;
|
|
2468
|
-
const other = vectors.filter((_, j) => assignments[j] === c);
|
|
2469
|
-
if (other.length) b = Math.min(b, other.reduce((s, v) => s + _cosDist(vectors[i], v), 0) / other.length);
|
|
2470
|
-
}
|
|
2471
|
-
total += b === Infinity ? 0 : (b - a) / Math.max(a, b);
|
|
2472
|
-
}
|
|
2473
|
-
return Math.round((total / n) * 1000) / 1000;
|
|
2474
|
-
}
|
|
2475
|
-
|
|
2476
|
-
function _buildLeadProfile(l) {
|
|
2477
|
-
return [
|
|
2478
|
-
`LTV: ${l.predicted_ltv_class || 'desconhecido'}`,
|
|
2479
|
-
`engajamento: ${Math.round(l.engagement_score || 0)}`,
|
|
2480
|
-
`intenção: ${l.intention_level || 'desconhecida'}`,
|
|
2481
|
-
`origem: ${l.utm_source || 'direto'}`,
|
|
2482
|
-
`canal: ${l.utm_medium || 'desconhecido'}`,
|
|
2483
|
-
`país: ${l.country || 'BR'}`,
|
|
2484
|
-
`estado: ${l.state || ''}`,
|
|
2485
|
-
`hora: ${l.hour_of_day || 12}h`,
|
|
2486
|
-
(l.is_weekend ? 'fim-de-semana' : 'dia-útil'),
|
|
2487
|
-
`recência: ${l.days_since_lead || 0} dias`,
|
|
2488
|
-
].filter(Boolean).join(', ');
|
|
2489
|
-
}
|
|
2490
|
-
|
|
2491
|
-
// ── POST /api/segmentation/cluster ───────────────────────────────────────────
|
|
2492
|
-
// Clustering real com embeddings (embeddinggemma-300m) + K-means vetorial
|
|
2493
|
-
// Granite usado apenas para nomear segmentos
|
|
2494
|
-
// Requer bindings: DB + AI
|
|
2495
|
-
async function handleSegmentationCluster(env, request, headers) {
|
|
2496
|
-
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
2497
|
-
if (!env.AI) return new Response(JSON.stringify({ error: 'Workers AI não configurado (verifique binding AI no wrangler.toml)' }), { status: 503, headers });
|
|
2498
|
-
|
|
2499
|
-
const url = new URL(request.url);
|
|
2500
|
-
const algorithm = url.searchParams.get('algorithm') || 'kmeans';
|
|
2501
|
-
const nClusters = Math.min(10, Math.max(2, parseInt(url.searchParams.get('n_clusters') || '5')));
|
|
2502
|
-
const clientVertical = url.searchParams.get('vertical') || 'general';
|
|
2503
|
-
const forceRecluster = url.searchParams.get('force') === 'true';
|
|
2504
|
-
|
|
2505
|
-
if (!['kmeans', 'dbscan', 'hierarchical'].includes(algorithm)) {
|
|
2506
|
-
return new Response(JSON.stringify({ error: 'algorithm deve ser: kmeans, dbscan ou hierarchical', received: algorithm }), { status: 400, headers });
|
|
2507
|
-
}
|
|
2508
|
-
|
|
2509
|
-
try {
|
|
2510
|
-
// 1. Cluster recente? Evitar re-clustering desnecessário (< 7 dias)
|
|
2511
|
-
if (!forceRecluster) {
|
|
2512
|
-
const existing = await env.DB.prepare(`
|
|
2513
|
-
SELECT id, created_at, cluster_name FROM ml_segments
|
|
2514
|
-
WHERE clustering_algorithm = ? AND is_active = 1 AND client_vertical = ?
|
|
2515
|
-
ORDER BY created_at DESC LIMIT 1
|
|
2516
|
-
`).bind(algorithm, clientVertical).first();
|
|
2517
|
-
|
|
2518
|
-
if (existing) {
|
|
2519
|
-
const ageDays = (Date.now() - new Date(existing.created_at).getTime()) / (1000 * 60 * 60 * 24);
|
|
2520
|
-
if (ageDays < 7) {
|
|
2521
|
-
return new Response(JSON.stringify({
|
|
2522
|
-
success: true,
|
|
2523
|
-
message: 'Cluster existente ainda válido (< 7 dias). Use ?force=true para re-clustering.',
|
|
2524
|
-
cluster_id: existing.id,
|
|
2525
|
-
cluster_name: existing.cluster_name,
|
|
2526
|
-
age_days: Math.round(ageDays * 10) / 10,
|
|
2527
|
-
use_existing: true,
|
|
2528
|
-
}), { status: 200, headers });
|
|
2529
|
-
}
|
|
2530
|
-
}
|
|
2531
|
-
}
|
|
2532
|
-
|
|
2533
|
-
// 2. Extrair leads históricos do D1 (últimos 6 meses, excluindo bots confirmados)
|
|
2534
|
-
const leadsRes = await env.DB.prepare(`
|
|
2535
|
-
SELECT id, predicted_ltv_class, engagement_score, intention_level,
|
|
2536
|
-
country, state, utm_source, utm_medium, bot_score,
|
|
2537
|
-
CAST(strftime('%H', created_at) AS INTEGER) AS hour_of_day,
|
|
2538
|
-
CAST(julianday('now') - julianday(created_at) AS INTEGER) AS days_since_lead,
|
|
2539
|
-
CASE WHEN strftime('%w', created_at) IN ('0','6') THEN 1 ELSE 0 END AS is_weekend
|
|
2540
|
-
FROM leads
|
|
2541
|
-
WHERE created_at >= datetime('now', '-6 months')
|
|
2542
|
-
AND (bot_score IS NULL OR bot_score < 2)
|
|
2543
|
-
ORDER BY RANDOM()
|
|
2544
|
-
LIMIT 2000
|
|
2545
|
-
`).all();
|
|
2546
|
-
|
|
2547
|
-
const leads = leadsRes.results || [];
|
|
2548
|
-
|
|
2549
|
-
if (leads.length < 50) {
|
|
2550
|
-
return new Response(JSON.stringify({
|
|
2551
|
-
error: 'Dados insuficientes para clustering. Mínimo: 50 leads nos últimos 6 meses.',
|
|
2552
|
-
leads_found: leads.length,
|
|
2553
|
-
required: 50,
|
|
2554
|
-
}), { status: 400, headers });
|
|
2555
|
-
}
|
|
2556
|
-
|
|
2557
|
-
const startTime = Date.now();
|
|
2558
|
-
|
|
2559
|
-
// 3. Gerar perfis textuais e embeddings via embeddinggemma-300m
|
|
2560
|
-
const sample = leads.slice(0, 100); // max 100 por batch
|
|
2561
|
-
const profiles = sample.map(_buildLeadProfile);
|
|
2562
|
-
|
|
2563
|
-
const embRes = await env.AI.run('@cf/baai/bge-m3', { text: profiles });
|
|
2564
|
-
const vectors = embRes.data; // float32[][] shape [N, 768]
|
|
2565
|
-
|
|
2566
|
-
if (!vectors || vectors.length < nClusters) {
|
|
2567
|
-
throw new Error(`embeddinggemma retornou ${vectors?.length ?? 0} vetores — insuficiente para ${nClusters} clusters`);
|
|
2568
|
-
}
|
|
2569
|
-
|
|
2570
|
-
// 4. K-means vetorial real (cosine distance)
|
|
2571
|
-
const { assignments } = _kmeansRun(vectors, nClusters);
|
|
2572
|
-
|
|
2573
|
-
// 5. Silhouette score real
|
|
2574
|
-
const silhouetteScore = _silhouette(vectors, assignments, nClusters);
|
|
2575
|
-
|
|
2576
|
-
// 6. Agregar estatísticas por cluster para nomear com Granite
|
|
2577
|
-
const clusterStats = Array.from({ length: nClusters }, (_, c) => {
|
|
2578
|
-
const members = sample.filter((_, i) => assignments[i] === c);
|
|
2579
|
-
if (members.length === 0) return null;
|
|
2580
|
-
const ltvMap = { High: 1, Medium: 0.5, Low: 0 };
|
|
2581
|
-
const avgLtv = members.reduce((s, l) => s + (ltvMap[l.predicted_ltv_class] ?? 0), 0) / members.length;
|
|
2582
|
-
const avgEng = members.reduce((s, l) => s + (l.engagement_score || 0), 0) / members.length;
|
|
2583
|
-
const avgDays = members.reduce((s, l) => s + (l.days_since_lead || 0), 0) / members.length;
|
|
2584
|
-
const sources = members.map(l => l.utm_source).filter(Boolean);
|
|
2585
|
-
const states = members.map(l => l.state).filter(Boolean);
|
|
2586
|
-
const topSource = sources.length ? [...sources.reduce((m, s) => m.set(s, (m.get(s)||0)+1), new Map())].sort((a,b)=>b[1]-a[1])[0]?.[0] : 'direto';
|
|
2587
|
-
const topState = states.length ? [...states.reduce((m, s) => m.set(s, (m.get(s)||0)+1), new Map())].sort((a,b)=>b[1]-a[1])[0]?.[0] : 'BR';
|
|
2588
|
-
const intentions = members.map(l => l.intention_level).filter(Boolean);
|
|
2589
|
-
const topIntent = intentions.length ? [...intentions.reduce((m, s) => m.set(s,(m.get(s)||0)+1), new Map())].sort((a,b)=>b[1]-a[1])[0]?.[0] : 'desconhecida';
|
|
2590
|
-
return { c, size: members.length, pct: Math.round(members.length / sample.length * 100), avgLtv, avgEng, avgDays, topSource, topState, topIntent };
|
|
2591
|
-
}).filter(Boolean);
|
|
2592
|
-
|
|
2593
|
-
// 7. Usar Granite apenas para nomear e recomendar ação por cluster
|
|
2594
|
-
const namingPrompt =
|
|
2595
|
-
`Você é especialista em segmentação de clientes. Dê um nome descritivo em português e uma recomendação de campanha para cada segmento abaixo. Retorne SOMENTE JSON válido:
|
|
2596
|
-
{"segments":[{"cluster_id":0,"name":"...","action":"..."},...]}
|
|
2597
|
-
|
|
2598
|
-
Segmentos:
|
|
2599
|
-
${clusterStats.map(s => `Cluster ${s.c}: LTV médio=${s.avgLtv.toFixed(2)}, engajamento=${s.avgEng.toFixed(0)}, intenção dominante="${s.topIntent}", origem="${s.topSource}", estado="${s.topState}", recência=${s.avgDays.toFixed(0)} dias, tamanho=${s.size} leads`).join('\n')}`;
|
|
2600
|
-
|
|
2601
|
-
const nameRes = await env.AI.run('@cf/ibm-granite/granite-4.0-h-micro', {
|
|
2602
|
-
messages: [{ role: 'user', content: namingPrompt }],
|
|
2603
|
-
max_tokens: 800,
|
|
2604
|
-
});
|
|
2605
|
-
|
|
2606
|
-
let clusterNames = {};
|
|
2607
|
-
try {
|
|
2608
|
-
const m = (nameRes?.response || '').match(/\{[\s\S]*\}/);
|
|
2609
|
-
if (m) {
|
|
2610
|
-
const parsed = JSON.parse(m[0]);
|
|
2611
|
-
(parsed.segments || []).forEach(s => { clusterNames[s.cluster_id] = { name: s.name, action: s.action }; });
|
|
2612
|
-
}
|
|
2613
|
-
} catch { /* usa nomes fallback */ }
|
|
2614
|
-
|
|
2615
|
-
const duration = Date.now() - startTime;
|
|
2616
|
-
|
|
2617
|
-
// 8. Montar resultado final
|
|
2618
|
-
const clusters = clusterStats.map(s => ({
|
|
2619
|
-
cluster_id: s.c,
|
|
2620
|
-
name: clusterNames[s.c]?.name || `Segmento ${s.c + 1}`,
|
|
2621
|
-
size: s.size,
|
|
2622
|
-
percentage: s.pct,
|
|
2623
|
-
action_recommendation: clusterNames[s.c]?.action || '',
|
|
2624
|
-
characteristics: {
|
|
2625
|
-
avg_ltv_class: s.avgLtv,
|
|
2626
|
-
avg_engagement_score: s.avgEng,
|
|
2627
|
-
avg_intention_level: s.avgLtv,
|
|
2628
|
-
avg_days_since_lead: s.avgDays,
|
|
2629
|
-
dominant_countries: ['BR'],
|
|
2630
|
-
dominant_states: [s.topState],
|
|
2631
|
-
dominant_utm_sources: [s.topSource],
|
|
2632
|
-
top_features: ['ltv', 'engagement', 'intention'],
|
|
2633
|
-
},
|
|
2634
|
-
}));
|
|
2635
|
-
|
|
2636
|
-
// 9. Inativar clusters anteriores do mesmo algoritmo/vertical
|
|
2637
|
-
await env.DB.prepare(
|
|
2638
|
-
`UPDATE ml_segments SET is_active = 0 WHERE clustering_algorithm = ? AND client_vertical = ? AND is_active = 1`
|
|
2639
|
-
).bind(algorithm, clientVertical).run();
|
|
2640
|
-
|
|
2641
|
-
// 10. Persistir novos clusters no D1
|
|
2642
|
-
const now = new Date().toISOString();
|
|
2643
|
-
for (const cluster of clusters) {
|
|
2644
|
-
const ch = cluster.characteristics;
|
|
2645
|
-
await env.DB.prepare(`
|
|
2646
|
-
INSERT INTO ml_segments (
|
|
2647
|
-
cluster_id, cluster_name, clustering_algorithm, client_vertical,
|
|
2648
|
-
size, percentage,
|
|
2649
|
-
avg_ltv_class, avg_behavior_score, avg_engagement_score,
|
|
2650
|
-
avg_intention_level, avg_days_since_lead,
|
|
2651
|
-
dominant_countries, dominant_states, dominant_utm_sources, dominant_features,
|
|
2652
|
-
silhouette_score, action_recommendations, bid_recommendations, campaign_recommendations,
|
|
2653
|
-
is_active, created_at, updated_at
|
|
2654
|
-
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)
|
|
2655
|
-
`).bind(
|
|
2656
|
-
cluster.cluster_id,
|
|
2657
|
-
cluster.name,
|
|
2658
|
-
algorithm,
|
|
2659
|
-
clientVertical,
|
|
2660
|
-
cluster.size,
|
|
2661
|
-
cluster.percentage,
|
|
2662
|
-
ch.avg_ltv_class,
|
|
2663
|
-
ch.avg_engagement_score,
|
|
2664
|
-
ch.avg_engagement_score,
|
|
2665
|
-
ch.avg_intention_level,
|
|
2666
|
-
ch.avg_days_since_lead,
|
|
2667
|
-
JSON.stringify(ch.dominant_countries),
|
|
2668
|
-
JSON.stringify(ch.dominant_states),
|
|
2669
|
-
JSON.stringify(ch.dominant_utm_sources),
|
|
2670
|
-
JSON.stringify(ch.top_features),
|
|
2671
|
-
silhouetteScore,
|
|
2672
|
-
JSON.stringify([cluster.action_recommendation]),
|
|
2673
|
-
JSON.stringify([]),
|
|
2674
|
-
JSON.stringify([]),
|
|
2675
|
-
now,
|
|
2676
|
-
now,
|
|
2677
|
-
).run();
|
|
2678
|
-
}
|
|
2679
|
-
|
|
2680
|
-
// 11. Log no histórico de clustering
|
|
2681
|
-
try {
|
|
2682
|
-
await env.DB.prepare(`
|
|
2683
|
-
INSERT INTO ml_clustering_history (
|
|
2684
|
-
clustering_id, started_at, completed_at, algorithm,
|
|
2685
|
-
n_leads_processed, n_clusters_created, total_duration_ms,
|
|
2686
|
-
workers_ai_neurons_used, status, parameters, results_summary
|
|
2687
|
-
) VALUES (0, ?, datetime('now'), ?, ?, ?, ?, ?, 'completed', ?, ?)
|
|
2688
|
-
`).bind(
|
|
2689
|
-
new Date(startTime).toISOString(),
|
|
2690
|
-
algorithm,
|
|
2691
|
-
leads.length,
|
|
2692
|
-
clusters.length,
|
|
2693
|
-
duration,
|
|
2694
|
-
Math.ceil(duration * 0.01),
|
|
2695
|
-
JSON.stringify({ algorithm, n_clusters: nClusters, vertical: clientVertical, engine: 'embeddinggemma-300m+kmeans' }),
|
|
2696
|
-
JSON.stringify({ clusters: clusters.length, silhouette: silhouetteScore }),
|
|
2697
|
-
).run();
|
|
2698
|
-
} catch (e) { console.error('[Segmentation] history log error:', e.message); }
|
|
2699
|
-
|
|
2700
|
-
return new Response(JSON.stringify({
|
|
2701
|
-
success: true,
|
|
2702
|
-
algorithm,
|
|
2703
|
-
engine: 'embeddinggemma-300m + kmeans vetorial',
|
|
2704
|
-
n_clusters: clusters.length,
|
|
2705
|
-
client_vertical: clientVertical,
|
|
2706
|
-
leads_analyzed: leads.length,
|
|
2707
|
-
sample_embedded: sample.length,
|
|
2708
|
-
duration_ms: duration,
|
|
2709
|
-
silhouette_score: silhouetteScore,
|
|
2710
|
-
clusters,
|
|
2711
|
-
generated_at: now,
|
|
2712
|
-
}), { status: 200, headers });
|
|
2713
|
-
|
|
2714
|
-
} catch (err) {
|
|
2715
|
-
console.error('[Segmentation] cluster error:', err.message);
|
|
2716
|
-
try {
|
|
2717
|
-
if (env.DB) {
|
|
2718
|
-
await env.DB.prepare(`
|
|
2719
|
-
INSERT INTO ml_clustering_history
|
|
2720
|
-
(clustering_id, started_at, algorithm, n_leads_processed, n_clusters_created,
|
|
2721
|
-
total_duration_ms, workers_ai_neurons_used, status, error_message, parameters, results_summary)
|
|
2722
|
-
VALUES (0, datetime('now'), ?, 0, 0, 0, 0, 'failed', ?, ?, '{}')
|
|
2723
|
-
`).bind(algorithm, err.message, JSON.stringify({ algorithm, n_clusters: nClusters })).run();
|
|
2724
|
-
}
|
|
2725
|
-
} catch { /* não bloquear a resposta de erro */ }
|
|
2726
|
-
|
|
2727
|
-
return new Response(JSON.stringify({ error: 'Erro ao executar clustering', message: err.message }), { status: 500, headers });
|
|
2728
|
-
}
|
|
2729
|
-
}
|
|
2730
|
-
|
|
2731
|
-
// ── GET /api/segmentation/list ────────────────────────────────────────────────
|
|
2732
|
-
// Lista todos os segmentos ativos com estatísticas
|
|
2733
|
-
async function handleSegmentationList(env, request, headers) {
|
|
2734
|
-
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
2735
|
-
|
|
2736
|
-
const url = new URL(request.url);
|
|
2737
|
-
const algorithm = url.searchParams.get('algorithm') || null;
|
|
2738
|
-
const vertical = url.searchParams.get('vertical') || null;
|
|
2739
|
-
|
|
2740
|
-
try {
|
|
2741
|
-
const conditions = ['is_active = 1'];
|
|
2742
|
-
const bindings = [];
|
|
2743
|
-
if (algorithm) { conditions.push('clustering_algorithm = ?'); bindings.push(algorithm); }
|
|
2744
|
-
if (vertical) { conditions.push('client_vertical = ?'); bindings.push(vertical); }
|
|
2745
|
-
|
|
2746
|
-
const query = `
|
|
2747
|
-
SELECT id, cluster_id, cluster_name, clustering_algorithm, client_vertical,
|
|
2748
|
-
size, percentage, avg_ltv_class, avg_behavior_score, avg_engagement_score,
|
|
2749
|
-
avg_intention_level, avg_days_since_lead, silhouette_score,
|
|
2750
|
-
dominant_countries, dominant_states, dominant_utm_sources, dominant_features,
|
|
2751
|
-
action_recommendations, bid_recommendations, campaign_recommendations,
|
|
2752
|
-
is_active, created_at, updated_at
|
|
2753
|
-
FROM ml_segments
|
|
2754
|
-
WHERE ${conditions.join(' AND ')}
|
|
2755
|
-
ORDER BY created_at DESC
|
|
2756
|
-
LIMIT 50
|
|
2757
|
-
`;
|
|
2758
|
-
|
|
2759
|
-
const result = await env.DB.prepare(query).bind(...bindings).all();
|
|
2760
|
-
const segments = (result.results || []).map(s => ({
|
|
2761
|
-
...s,
|
|
2762
|
-
dominant_countries: tryParseJson(s.dominant_countries, []),
|
|
2763
|
-
dominant_states: tryParseJson(s.dominant_states, []),
|
|
2764
|
-
dominant_utm_sources: tryParseJson(s.dominant_utm_sources, []),
|
|
2765
|
-
dominant_features: tryParseJson(s.dominant_features, []),
|
|
2766
|
-
action_recommendations: tryParseJson(s.action_recommendations, []),
|
|
2767
|
-
bid_recommendations: tryParseJson(s.bid_recommendations, []),
|
|
2768
|
-
campaign_recommendations: tryParseJson(s.campaign_recommendations, []),
|
|
2769
|
-
}));
|
|
2770
|
-
|
|
2771
|
-
return new Response(JSON.stringify({
|
|
2772
|
-
success: true,
|
|
2773
|
-
total: segments.length,
|
|
2774
|
-
segments,
|
|
2775
|
-
}), { status: 200, headers });
|
|
2776
|
-
|
|
2777
|
-
} catch (err) {
|
|
2778
|
-
console.error('[Segmentation] list error:', err.message);
|
|
2779
|
-
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
2780
|
-
}
|
|
2781
|
-
}
|
|
2782
|
-
|
|
2783
|
-
// ── GET /api/segmentation/outliers ───────────────────────────────────────────
|
|
2784
|
-
// Lista leads marcados como outliers no ml_segment_members (DBSCAN)
|
|
2785
|
-
async function handleSegmentationOutliers(env, request, headers) {
|
|
2786
|
-
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
2787
|
-
|
|
2788
|
-
const url = new URL(request.url);
|
|
2789
|
-
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 200);
|
|
2790
|
-
const days = parseInt(url.searchParams.get('days') || '30');
|
|
2791
|
-
|
|
2792
|
-
try {
|
|
2793
|
-
const result = await env.DB.prepare(`
|
|
2794
|
-
SELECT msm.lead_id, msm.cluster_id, msm.confidence, msm.is_outlier,
|
|
2795
|
-
msm.outlier_reason, msm.assigned_at,
|
|
2796
|
-
l.email, l.phone, l.country, l.state, l.city,
|
|
2797
|
-
l.utm_source, l.bot_score, l.engagement_score, l.intention_level,
|
|
2798
|
-
l.created_at AS lead_created_at
|
|
2799
|
-
FROM ml_segment_members msm
|
|
2800
|
-
LEFT JOIN leads l ON CAST(msm.lead_id AS INTEGER) = l.id
|
|
2801
|
-
WHERE msm.is_outlier = 1
|
|
2802
|
-
AND msm.assigned_at >= datetime('now', '-' || ? || ' days')
|
|
2803
|
-
ORDER BY msm.assigned_at DESC
|
|
2804
|
-
LIMIT ?
|
|
2805
|
-
`).bind(days, limit).all();
|
|
2806
|
-
|
|
2807
|
-
return new Response(JSON.stringify({
|
|
2808
|
-
success: true,
|
|
2809
|
-
total: (result.results || []).length,
|
|
2810
|
-
period_days: days,
|
|
2811
|
-
outliers: result.results || [],
|
|
2812
|
-
}), { status: 200, headers });
|
|
2813
|
-
|
|
2814
|
-
} catch (err) {
|
|
2815
|
-
console.error('[Segmentation] outliers error:', err.message);
|
|
2816
|
-
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
2817
|
-
}
|
|
2818
|
-
}
|
|
2819
|
-
|
|
2820
|
-
// ── PUT /api/segmentation/update ─────────────────────────────────────────────
|
|
2821
|
-
// Atualiza recomendações de ação/bid/campanha de um segmento existente
|
|
2822
|
-
async function handleSegmentationUpdate(env, request, headers) {
|
|
2823
|
-
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
2824
|
-
|
|
2825
|
-
let body;
|
|
2826
|
-
try { body = await request.json(); }
|
|
2827
|
-
catch { return new Response(JSON.stringify({ error: 'JSON inválido no body da requisição' }), { status: 400, headers }); }
|
|
2828
|
-
|
|
2829
|
-
const { cluster_id, action_recommendations, bid_recommendations, campaign_recommendations } = body;
|
|
2830
|
-
|
|
2831
|
-
if (cluster_id === undefined || cluster_id === null) {
|
|
2832
|
-
return new Response(JSON.stringify({ error: 'cluster_id é obrigatório' }), { status: 400, headers });
|
|
2833
|
-
}
|
|
2834
|
-
|
|
2835
|
-
try {
|
|
2836
|
-
const sets = [];
|
|
2837
|
-
const bindings = [];
|
|
2838
|
-
|
|
2839
|
-
if (action_recommendations !== undefined) { sets.push('action_recommendations = ?'); bindings.push(JSON.stringify(action_recommendations)); }
|
|
2840
|
-
if (bid_recommendations !== undefined) { sets.push('bid_recommendations = ?'); bindings.push(JSON.stringify(bid_recommendations)); }
|
|
2841
|
-
if (campaign_recommendations !== undefined) { sets.push('campaign_recommendations = ?'); bindings.push(JSON.stringify(campaign_recommendations)); }
|
|
2842
|
-
|
|
2843
|
-
if (sets.length === 0) {
|
|
2844
|
-
return new Response(JSON.stringify({ error: 'Nenhum campo válido para atualizar (action_recommendations, bid_recommendations, campaign_recommendations)' }), { status: 400, headers });
|
|
2845
|
-
}
|
|
2846
|
-
|
|
2847
|
-
sets.push("updated_at = datetime('now')");
|
|
2848
|
-
bindings.push(cluster_id);
|
|
2849
|
-
|
|
2850
|
-
await env.DB.prepare(
|
|
2851
|
-
`UPDATE ml_segments SET ${sets.join(', ')} WHERE id = ?`
|
|
2852
|
-
).bind(...bindings).run();
|
|
2853
|
-
|
|
2854
|
-
return new Response(JSON.stringify({
|
|
2855
|
-
success: true,
|
|
2856
|
-
cluster_id,
|
|
2857
|
-
fields_updated: sets.length - 1, // exclui o updated_at
|
|
2858
|
-
}), { status: 200, headers });
|
|
2859
|
-
|
|
2860
|
-
} catch (err) {
|
|
2861
|
-
console.error('[Segmentation] update error:', err.message);
|
|
2862
|
-
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
2863
|
-
}
|
|
2864
|
-
}
|
|
2865
|
-
|
|
2866
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
2867
|
-
// FRAUD DETECTION ENGINE — Fase 4 Enterprise-Level
|
|
2868
|
-
// Heurístico puro (sem AI) — latência zero no /track
|
|
2869
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
2870
|
-
|
|
2871
|
-
// ASNs conhecidos de datacenters (evitar falsos negativos em ASNs legítimos)
|
|
2872
|
-
const DATACENTER_PATTERNS = /amazon|google|microsoft|digitalocean|linode|ovh|vultr|hetzner|contabo|cloudflare|packet|rackspace|leaseweb/i;
|
|
2873
|
-
|
|
2874
|
-
// ── checkFraudGate — roda sincronamente ANTES de processar o evento ────────────
|
|
2875
|
-
// Retorna { allowed, score, reasons, action }
|
|
2876
|
-
// NUNCA joga erro — qualquer falha = allowed (fail-safe)
|
|
2877
|
-
async function checkFraudGate(env, request, payload) {
|
|
2878
|
-
const result = { allowed: true, score: 0, reasons: [], action: 'allowed' };
|
|
2879
|
-
|
|
2880
|
-
try {
|
|
2881
|
-
const ip = request.headers.get('CF-Connecting-IP') || '';
|
|
2882
|
-
const ua = request.headers.get('User-Agent') || '';
|
|
2883
|
-
const fingerprint = payload.fingerprint || '';
|
|
2884
|
-
const email = payload.email || '';
|
|
2885
|
-
const botScore = parseInt(payload.botScore || payload.bot_score || 0);
|
|
2886
|
-
const asn = String(request.cf?.asOrganization || '').toLowerCase();
|
|
2887
|
-
const country = (request.cf?.country || '').toUpperCase();
|
|
2888
|
-
const acceptLang = request.headers.get('Accept-Language');
|
|
2889
|
-
|
|
2890
|
-
// 1. KV blocklist check — instantâneo (~0ms)
|
|
2891
|
-
if (env.GEO_CACHE && ip) {
|
|
2892
|
-
const ipBlocked = await env.GEO_CACHE.get(`fraud_block:ip:${ip}`);
|
|
2893
|
-
if (ipBlocked) {
|
|
2894
|
-
return { allowed: false, score: 100, reasons: ['ip_blocklisted'], action: 'dropped' };
|
|
2895
|
-
}
|
|
2896
|
-
}
|
|
2897
|
-
if (env.GEO_CACHE && fingerprint) {
|
|
2898
|
-
const fpBlocked = await env.GEO_CACHE.get(`fraud_block:fp:${fingerprint}`);
|
|
2899
|
-
if (fpBlocked) {
|
|
2900
|
-
return { allowed: false, score: 100, reasons: ['fingerprint_blocklisted'], action: 'dropped' };
|
|
2901
|
-
}
|
|
2902
|
-
}
|
|
2903
|
-
|
|
2904
|
-
// 2. Bot score (já calculado pelo Worker)
|
|
2905
|
-
if (botScore >= 3) { result.score += 60; result.reasons.push('bot_score_high'); }
|
|
2906
|
-
else if (botScore === 2) { result.score += 30; result.reasons.push('bot_score_medium'); }
|
|
2907
|
-
|
|
2908
|
-
// 3. User-Agent suspeito
|
|
2909
|
-
if (/headless|phantomjs|selenium|webdriver|curl|python|scrapy|bot|crawler|spider/i.test(ua)) {
|
|
2910
|
-
result.score += 40; result.reasons.push('suspicious_user_agent');
|
|
2911
|
-
}
|
|
2912
|
-
|
|
2913
|
-
// 4. Datacenter IP
|
|
2914
|
-
if (ip && DATACENTER_PATTERNS.test(asn)) {
|
|
2915
|
-
result.score += 35; result.reasons.push('datacenter_ip');
|
|
2916
|
-
}
|
|
2917
|
-
|
|
2918
|
-
// 5. Sem Accept-Language (bots raramente enviam)
|
|
2919
|
-
if (!acceptLang) {
|
|
2920
|
-
result.score += 20; result.reasons.push('no_accept_language');
|
|
2921
|
-
}
|
|
2922
|
-
|
|
2923
|
-
// 6. Velocity check via KV
|
|
2924
|
-
if (env.GEO_CACHE && ip) {
|
|
2925
|
-
const velKey1h = `fraud_velocity:${ip}:h`;
|
|
2926
|
-
const velStr = await env.GEO_CACHE.get(velKey1h);
|
|
2927
|
-
const vel1h = parseInt(velStr || '0') + 1;
|
|
2928
|
-
|
|
2929
|
-
// Atualizar contador (TTL: 3600s = 1h)
|
|
2930
|
-
await env.GEO_CACHE.put(velKey1h, String(vel1h), { expirationTtl: 3600 });
|
|
2931
|
-
|
|
2932
|
-
if (vel1h > 20) { result.score += 50; result.reasons.push('ip_velocity_very_high'); }
|
|
2933
|
-
else if (vel1h > 10) { result.score += 25; result.reasons.push('ip_velocity_high'); }
|
|
2934
|
-
}
|
|
2935
|
-
|
|
2936
|
-
result.score = Math.min(100, result.score);
|
|
2937
|
-
|
|
2938
|
-
// 8. Decisão final
|
|
2939
|
-
if (result.score >= 80) {
|
|
2940
|
-
result.allowed = false;
|
|
2941
|
-
result.action = 'dropped';
|
|
2942
|
-
} else if (result.score >= 40) {
|
|
2943
|
-
result.action = 'flagged';
|
|
2944
|
-
// Ainda permite o evento, mas loga como suspeito
|
|
2945
|
-
}
|
|
2946
|
-
|
|
2947
|
-
return result;
|
|
2948
|
-
|
|
2949
|
-
} catch (err) {
|
|
2950
|
-
console.error('[Fraud] checkFraudGate error:', err.message);
|
|
2951
|
-
return { allowed: true, score: 0, reasons: ['gate_error_fallback'], action: 'allowed' };
|
|
2952
|
-
}
|
|
2953
|
-
}
|
|
2954
|
-
|
|
2955
|
-
// ── logFraudSignal — persiste no D1 em background via ctx.waitUntil ───────────
|
|
2956
|
-
async function logFraudSignal(env, request, payload, fraudResult) {
|
|
2957
|
-
if (!env.DB || fraudResult.action === 'allowed') return; // só loga suspeitos/dropped
|
|
2958
|
-
try {
|
|
2959
|
-
const ip = request.headers.get('CF-Connecting-IP') || '';
|
|
2960
|
-
const ua = request.headers.get('User-Agent') || '';
|
|
2961
|
-
const fingerprint = payload.fingerprint || '';
|
|
2962
|
-
const botScore = parseInt(payload.botScore || payload.bot_score || 0);
|
|
2963
|
-
const asn = String(request.cf?.asOrganization || '');
|
|
2964
|
-
const country = (request.cf?.country || '');
|
|
2965
|
-
const velKey1h = `fraud_velocity:${ip}:h`;
|
|
2966
|
-
const vel1h = env.GEO_CACHE ? parseInt(await env.GEO_CACHE.get(velKey1h) || '0') : 0;
|
|
2967
|
-
|
|
2968
|
-
let emailHash = null;
|
|
2969
|
-
if (payload.email) {
|
|
2970
|
-
try { emailHash = await sha256(payload.email.trim().toLowerCase()); } catch {}
|
|
2971
|
-
}
|
|
2972
|
-
|
|
2973
|
-
await env.DB.prepare(`
|
|
2974
|
-
INSERT INTO fraud_signals (
|
|
2975
|
-
ip_address, fingerprint, user_id, email_hash, event_name, event_id,
|
|
2976
|
-
fraud_score, action_taken, reasons,
|
|
2977
|
-
ip_country, ip_asn, user_agent, bot_score, velocity_1h, detected_at
|
|
2978
|
-
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
|
|
2979
|
-
`).bind(
|
|
2980
|
-
ip, fingerprint || null, payload.userId || null, emailHash,
|
|
2981
|
-
payload.eventName || null, payload.eventId || null,
|
|
2982
|
-
fraudResult.score, fraudResult.action, JSON.stringify(fraudResult.reasons),
|
|
2983
|
-
country, asn, ua.substring(0, 255), botScore, vel1h,
|
|
2984
|
-
).run();
|
|
2985
|
-
|
|
2986
|
-
// Se dropped com score alto → criar/atualizar fraud_alert para este IP
|
|
2987
|
-
if (fraudResult.action === 'dropped' && ip) {
|
|
2988
|
-
await env.DB.prepare(`
|
|
2989
|
-
INSERT INTO fraud_alerts (alert_type, entity_type, entity_value, events_total, events_dropped, peak_score, first_seen, last_seen, top_reasons)
|
|
2990
|
-
VALUES ('ip_attack', 'ip', ?, 1, 1, ?, datetime('now'), datetime('now'), ?)
|
|
2991
|
-
ON CONFLICT(entity_type, entity_value) DO UPDATE SET
|
|
2992
|
-
events_total = events_total + 1,
|
|
2993
|
-
events_dropped = events_dropped + 1,
|
|
2994
|
-
peak_score = MAX(peak_score, excluded.peak_score),
|
|
2995
|
-
last_seen = datetime('now'),
|
|
2996
|
-
updated_at = datetime('now')
|
|
2997
|
-
`).bind(ip, fraudResult.score, JSON.stringify(fraudResult.reasons)).run().catch(() => {
|
|
2998
|
-
// Pode falhar se ON CONFLICT não funcionar (schema sem UNIQUE) — silent
|
|
2999
|
-
});
|
|
3000
|
-
}
|
|
3001
|
-
} catch (err) {
|
|
3002
|
-
console.error('[Fraud] logFraudSignal error:', err.message);
|
|
3003
|
-
}
|
|
3004
|
-
}
|
|
3005
|
-
|
|
3006
|
-
// ── handleFraudAlerts — GET /api/fraud/alerts ─────────────────────────────────
|
|
3007
|
-
async function handleFraudAlerts(env, request, headers) {
|
|
3008
|
-
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
3009
|
-
|
|
3010
|
-
const url = new URL(request.url);
|
|
3011
|
-
const action = url.searchParams.get('action') || null; // 'dropped','flagged'
|
|
3012
|
-
const hours = parseInt(url.searchParams.get('hours') || '24');
|
|
3013
|
-
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 200);
|
|
3014
|
-
|
|
3015
|
-
try {
|
|
3016
|
-
const cond = action ? 'AND action_taken = ?' : '';
|
|
3017
|
-
const bindings = action ? [hours, action, limit] : [hours, limit];
|
|
3018
|
-
|
|
3019
|
-
const result = await env.DB.prepare(`
|
|
3020
|
-
SELECT ip_address, fingerprint, event_name, fraud_score, action_taken,
|
|
3021
|
-
reasons, ip_country, ip_asn, bot_score, velocity_1h, detected_at
|
|
3022
|
-
FROM fraud_signals
|
|
3023
|
-
WHERE detected_at >= datetime('now', '-' || ? || ' hours')
|
|
3024
|
-
${cond}
|
|
3025
|
-
ORDER BY fraud_score DESC, detected_at DESC
|
|
3026
|
-
LIMIT ?
|
|
3027
|
-
`).bind(...bindings).all();
|
|
3028
|
-
|
|
3029
|
-
const signals = (result.results || []).map(s => ({
|
|
3030
|
-
...s,
|
|
3031
|
-
reasons: tryParseJson(s.reasons, []),
|
|
3032
|
-
}));
|
|
3033
|
-
|
|
3034
|
-
// Stats rápidas
|
|
3035
|
-
const stats = await env.DB.prepare(`SELECT * FROM v_fraud_dashboard`).first().catch(() => null);
|
|
3036
|
-
|
|
3037
|
-
return new Response(JSON.stringify({
|
|
3038
|
-
success: true,
|
|
3039
|
-
period_hours: hours,
|
|
3040
|
-
total: signals.length,
|
|
3041
|
-
stats,
|
|
3042
|
-
alerts: signals,
|
|
3043
|
-
}), { status: 200, headers });
|
|
3044
|
-
|
|
3045
|
-
} catch (err) {
|
|
3046
|
-
console.error('[Fraud] alerts error:', err.message);
|
|
3047
|
-
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
3048
|
-
}
|
|
3049
|
-
}
|
|
3050
|
-
|
|
3051
|
-
// ── handleFraudBlocklist — GET /api/fraud/blocklist ──────────────────────────
|
|
3052
|
-
async function handleFraudBlocklist(env, request, headers) {
|
|
3053
|
-
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
3054
|
-
|
|
3055
|
-
try {
|
|
3056
|
-
const result = await env.DB.prepare(`
|
|
3057
|
-
SELECT entity_type, entity_value, events_total, events_dropped,
|
|
3058
|
-
peak_score, first_seen, last_seen, blocked_at, block_expires, top_reasons
|
|
3059
|
-
FROM fraud_alerts
|
|
3060
|
-
WHERE is_blocked = 1
|
|
3061
|
-
ORDER BY events_dropped DESC
|
|
3062
|
-
LIMIT 100
|
|
3063
|
-
`).all();
|
|
3064
|
-
|
|
3065
|
-
const blocklist = (result.results || []).map(r => ({
|
|
3066
|
-
...r,
|
|
3067
|
-
top_reasons: tryParseJson(r.top_reasons, []),
|
|
3068
|
-
}));
|
|
3069
|
-
|
|
3070
|
-
return new Response(JSON.stringify({
|
|
3071
|
-
success: true,
|
|
3072
|
-
total: blocklist.length,
|
|
3073
|
-
blocklist,
|
|
3074
|
-
}), { status: 200, headers });
|
|
3075
|
-
|
|
3076
|
-
} catch (err) {
|
|
3077
|
-
console.error('[Fraud] blocklist error:', err.message);
|
|
3078
|
-
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
3079
|
-
}
|
|
3080
|
-
}
|
|
3081
|
-
|
|
3082
|
-
// ── handleFraudBlocklistAdd — POST /api/fraud/blocklist/add ──────────────────
|
|
3083
|
-
async function handleFraudBlocklistAdd(env, request, headers) {
|
|
3084
|
-
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
3085
|
-
|
|
3086
|
-
let body;
|
|
3087
|
-
try { body = await request.json(); }
|
|
3088
|
-
catch { return new Response(JSON.stringify({ error: 'JSON inválido' }), { status: 400, headers }); }
|
|
3089
|
-
|
|
3090
|
-
const { entity_type, entity_value, ttl_hours = 24, reason = 'manual_block' } = body;
|
|
3091
|
-
if (!entity_type || !entity_value) {
|
|
3092
|
-
return new Response(JSON.stringify({ error: 'entity_type (ip|fingerprint) e entity_value são obrigatórios' }), { status: 400, headers });
|
|
3093
|
-
}
|
|
3094
|
-
if (!['ip', 'fingerprint'].includes(entity_type)) {
|
|
3095
|
-
return new Response(JSON.stringify({ error: 'entity_type deve ser: ip ou fingerprint' }), { status: 400, headers });
|
|
3096
|
-
}
|
|
3097
|
-
|
|
3098
|
-
try {
|
|
3099
|
-
const kvKey = `fraud_block:${entity_type}:${entity_value}`;
|
|
3100
|
-
const ttlSec = Math.min(ttl_hours * 3600, 7 * 24 * 3600); // max 7 dias
|
|
3101
|
-
const expiresAt = new Date(Date.now() + ttlSec * 1000).toISOString();
|
|
3102
|
-
|
|
3103
|
-
// Adicionar no KV (checagem instantânea em /track)
|
|
3104
|
-
if (env.GEO_CACHE) {
|
|
3105
|
-
await env.GEO_CACHE.put(kvKey, JSON.stringify({ reason, blocked_at: new Date().toISOString() }), { expirationTtl: ttlSec });
|
|
3106
|
-
}
|
|
3107
|
-
|
|
3108
|
-
// Registrar no D1 para auditoria
|
|
3109
|
-
await env.DB.prepare(`
|
|
3110
|
-
INSERT INTO fraud_alerts (alert_type, entity_type, entity_value, events_total, events_dropped, peak_score, first_seen, last_seen, is_blocked, blocked_at, block_expires, top_reasons)
|
|
3111
|
-
VALUES ('manual', ?, ?, 0, 0, 100, datetime('now'), datetime('now'), 1, datetime('now'), ?, ?)
|
|
3112
|
-
ON CONFLICT DO UPDATE SET is_blocked = 1, blocked_at = datetime('now'), block_expires = excluded.block_expires, updated_at = datetime('now')
|
|
3113
|
-
`).bind(entity_type, entity_value, expiresAt, JSON.stringify([reason])).run().catch(() => {
|
|
3114
|
-
// fallback se não tiver UNIQUE constraint na fraud_alerts
|
|
3115
|
-
});
|
|
3116
|
-
|
|
3117
|
-
return new Response(JSON.stringify({
|
|
3118
|
-
success: true,
|
|
3119
|
-
entity_type,
|
|
3120
|
-
entity_value,
|
|
3121
|
-
kv_key: kvKey,
|
|
3122
|
-
ttl_hours,
|
|
3123
|
-
expires_at: expiresAt,
|
|
3124
|
-
message: `${entity_type} '${entity_value}' bloqueado por ${ttl_hours}h. Efeito imediato via KV.`,
|
|
3125
|
-
}), { status: 200, headers });
|
|
3126
|
-
|
|
3127
|
-
} catch (err) {
|
|
3128
|
-
console.error('[Fraud] blocklist add error:', err.message);
|
|
3129
|
-
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
3130
|
-
}
|
|
3131
|
-
}
|
|
3132
|
-
|
|
3133
|
-
// ── handleFraudBlocklistRemove — DELETE /api/fraud/blocklist/remove ──────────
|
|
3134
|
-
async function handleFraudBlocklistRemove(env, request, headers) {
|
|
3135
|
-
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
3136
|
-
|
|
3137
|
-
let body;
|
|
3138
|
-
try { body = await request.json(); }
|
|
3139
|
-
catch { return new Response(JSON.stringify({ error: 'JSON inválido' }), { status: 400, headers }); }
|
|
3140
|
-
|
|
3141
|
-
const { entity_type, entity_value } = body;
|
|
3142
|
-
if (!entity_type || !entity_value) {
|
|
3143
|
-
return new Response(JSON.stringify({ error: 'entity_type e entity_value são obrigatórios' }), { status: 400, headers });
|
|
3144
|
-
}
|
|
3145
|
-
|
|
3146
|
-
try {
|
|
3147
|
-
const kvKey = `fraud_block:${entity_type}:${entity_value}`;
|
|
3148
|
-
if (env.GEO_CACHE) await env.GEO_CACHE.delete(kvKey);
|
|
3149
|
-
|
|
3150
|
-
await env.DB.prepare(
|
|
3151
|
-
`UPDATE fraud_alerts SET is_blocked = 0, resolved_at = datetime('now'), resolved_by = 'manual' WHERE entity_type = ? AND entity_value = ?`
|
|
3152
|
-
).bind(entity_type, entity_value).run();
|
|
3153
|
-
|
|
3154
|
-
return new Response(JSON.stringify({
|
|
3155
|
-
success: true,
|
|
3156
|
-
entity_type,
|
|
3157
|
-
entity_value,
|
|
3158
|
-
message: `${entity_type} '${entity_value}' removido do blocklist. Efeito imediato via KV.`,
|
|
3159
|
-
}), { status: 200, headers });
|
|
3160
|
-
|
|
3161
|
-
} catch (err) {
|
|
3162
|
-
console.error('[Fraud] blocklist remove error:', err.message);
|
|
3163
|
-
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
3164
|
-
}
|
|
3165
|
-
}
|
|
3166
|
-
|
|
3167
|
-
// ── handleFraudStats — GET /api/fraud/stats ───────────────────────────────────
|
|
3168
|
-
async function handleFraudStats(env, request, headers) {
|
|
3169
|
-
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
3170
|
-
|
|
3171
|
-
try {
|
|
3172
|
-
const dashboard = await env.DB.prepare(`SELECT * FROM v_fraud_dashboard`).first();
|
|
3173
|
-
const topIps = await env.DB.prepare(`
|
|
3174
|
-
SELECT ip_address, COUNT(*) as events, MAX(fraud_score) as peak_score
|
|
3175
|
-
FROM fraud_signals
|
|
3176
|
-
WHERE detected_at >= datetime('now', '-24 hours') AND action_taken = 'dropped'
|
|
3177
|
-
GROUP BY ip_address ORDER BY events DESC LIMIT 10
|
|
3178
|
-
`).all();
|
|
3179
|
-
const topReasons = await env.DB.prepare(`
|
|
3180
|
-
SELECT action_taken, COUNT(*) as count FROM fraud_signals
|
|
3181
|
-
WHERE detected_at >= datetime('now', '-24 hours')
|
|
3182
|
-
GROUP BY action_taken
|
|
3183
|
-
`).all();
|
|
3184
|
-
|
|
3185
|
-
return new Response(JSON.stringify({
|
|
3186
|
-
success: true,
|
|
3187
|
-
period: '24h',
|
|
3188
|
-
dashboard,
|
|
3189
|
-
top_attacking_ips: topIps.results || [],
|
|
3190
|
-
by_action: topReasons.results || [],
|
|
3191
|
-
}), { status: 200, headers });
|
|
3192
|
-
|
|
3193
|
-
} catch (err) {
|
|
3194
|
-
console.error('[Fraud] stats error:', err.message);
|
|
3195
|
-
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
3196
|
-
}
|
|
3197
|
-
}
|
|
3198
|
-
|
|
3199
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
3200
|
-
// A/B TESTING DE PROMPTS LTV — Fase 3 Enterprise-Level
|
|
3201
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
3202
|
-
|
|
3203
|
-
// Cache key para o teste ativo (KV — evita hit no D1 a cada request /track)
|
|
3204
|
-
const AB_LTV_CACHE_KEY = 'ab_ltv_active_test';
|
|
3205
|
-
|
|
3206
|
-
// ── getLtvAbVariation — busca e sorteia variação do teste ativo ─────────────
|
|
3207
|
-
// Retorna null se não há teste ativo ou DB/KV indisponíveis
|
|
3208
|
-
async function getLtvAbVariation(env) {
|
|
3209
|
-
if (!env.DB) return null;
|
|
3210
|
-
|
|
3211
|
-
try {
|
|
3212
|
-
// 1. Tentar cache KV (TTL: 5 min)
|
|
3213
|
-
let testData = null;
|
|
3214
|
-
if (env.GEO_CACHE) {
|
|
3215
|
-
const cached = await env.GEO_CACHE.get(AB_LTV_CACHE_KEY, 'json');
|
|
3216
|
-
if (cached) testData = cached;
|
|
3217
|
-
}
|
|
3218
|
-
|
|
3219
|
-
// 2. Cache miss ou KV indisponível → buscar do D1
|
|
3220
|
-
if (!testData) {
|
|
3221
|
-
const test = await env.DB.prepare(`
|
|
3222
|
-
SELECT t.id AS test_id, v.id AS variation_id,
|
|
3223
|
-
v.name, v.system_prompt, v.weight, v.is_control
|
|
3224
|
-
FROM ltv_ab_tests t
|
|
3225
|
-
JOIN ltv_ab_variations v ON v.test_id = t.id
|
|
3226
|
-
WHERE t.status = 'running'
|
|
3227
|
-
ORDER BY t.id DESC
|
|
3228
|
-
`).all();
|
|
3229
|
-
|
|
3230
|
-
if (!test.results || test.results.length === 0) {
|
|
3231
|
-
// Sem teste ativo — cachear null por 5 min para não bater no D1
|
|
3232
|
-
if (env.GEO_CACHE) {
|
|
3233
|
-
await env.GEO_CACHE.put(AB_LTV_CACHE_KEY, JSON.stringify(null), { expirationTtl: 300 });
|
|
3234
|
-
}
|
|
3235
|
-
return null;
|
|
3236
|
-
}
|
|
3237
|
-
|
|
3238
|
-
testData = test.results;
|
|
3239
|
-
if (env.GEO_CACHE) {
|
|
3240
|
-
await env.GEO_CACHE.put(AB_LTV_CACHE_KEY, JSON.stringify(testData), { expirationTtl: 300 });
|
|
3241
|
-
}
|
|
3242
|
-
}
|
|
3243
|
-
|
|
3244
|
-
if (!testData || testData.length === 0) return null;
|
|
3245
|
-
|
|
3246
|
-
// 3. Sortear variação por peso ponderado
|
|
3247
|
-
const totalWeight = testData.reduce((s, v) => s + (v.weight || 0.5), 0);
|
|
3248
|
-
let rand = Math.random() * totalWeight;
|
|
3249
|
-
for (const variation of testData) {
|
|
3250
|
-
rand -= (variation.weight || 0.5);
|
|
3251
|
-
if (rand <= 0) return variation;
|
|
3252
|
-
}
|
|
3253
|
-
return testData[testData.length - 1];
|
|
3254
|
-
|
|
3255
|
-
} catch (err) {
|
|
3256
|
-
console.error('[AB-LTV] getLtvAbVariation error:', err.message);
|
|
3257
|
-
return null; // graceful fallback — nunca quebra o fluxo principal
|
|
3258
|
-
}
|
|
3259
|
-
}
|
|
3260
|
-
|
|
3261
|
-
// ── recordAbAssignment — registra a variação usada para um lead ──────────────
|
|
3262
|
-
// Executado via ctx.waitUntil — não bloqueia o /track
|
|
3263
|
-
async function recordAbAssignment(env, userId, variationId, testId, predictedLtv, predictedClass, emailHash) {
|
|
3264
|
-
if (!env.DB) return;
|
|
3265
|
-
try {
|
|
3266
|
-
await env.DB.prepare(`
|
|
3267
|
-
INSERT INTO ltv_ab_assignments
|
|
3268
|
-
(test_id, variation_id, user_id, email_hash, predicted_ltv, predicted_class, assigned_at)
|
|
3269
|
-
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
|
3270
|
-
`).bind(testId, variationId, userId, emailHash || null, predictedLtv || null, predictedClass || null).run();
|
|
3271
|
-
|
|
3272
|
-
// Incrementar contador na variação
|
|
3273
|
-
await env.DB.prepare(
|
|
3274
|
-
`UPDATE ltv_ab_variations SET total_assigned = total_assigned + 1 WHERE id = ?`
|
|
3275
|
-
).bind(variationId).run();
|
|
3276
|
-
} catch (err) {
|
|
3277
|
-
console.error('[AB-LTV] recordAbAssignment error:', err.message);
|
|
3278
|
-
}
|
|
3279
|
-
}
|
|
3280
|
-
|
|
3281
|
-
// ── handleLtvAbTestCreate — POST /api/ltv/ab-test/create ─────────────────────
|
|
3282
|
-
async function handleLtvAbTestCreate(env, request, headers) {
|
|
3283
|
-
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
3284
|
-
|
|
3285
|
-
let body;
|
|
3286
|
-
try { body = await request.json(); }
|
|
3287
|
-
catch { return new Response(JSON.stringify({ error: 'JSON inválido no body' }), { status: 400, headers }); }
|
|
3288
|
-
|
|
3289
|
-
const { name, description, min_sample = 100, variations } = body;
|
|
3290
|
-
|
|
3291
|
-
if (!name) return new Response(JSON.stringify({ error: 'name é obrigatório' }), { status: 400, headers });
|
|
3292
|
-
if (!Array.isArray(variations) || variations.length < 2) {
|
|
3293
|
-
return new Response(JSON.stringify({ error: 'Mínimo 2 variações são necessárias' }), { status: 400, headers });
|
|
3294
|
-
}
|
|
3295
|
-
|
|
3296
|
-
// Verificar se há teste em andamento
|
|
3297
|
-
const running = await env.DB.prepare(`SELECT id FROM ltv_ab_tests WHERE status = 'running' LIMIT 1`).first();
|
|
3298
|
-
if (running) {
|
|
3299
|
-
return new Response(JSON.stringify({
|
|
3300
|
-
error: 'Já existe um teste em andamento. Pause ou conclua o teste atual antes de criar um novo.',
|
|
3301
|
-
running_test_id: running.id,
|
|
3302
|
-
}), { status: 409, headers });
|
|
3303
|
-
}
|
|
3304
|
-
|
|
3305
|
-
try {
|
|
3306
|
-
const now = new Date().toISOString();
|
|
3307
|
-
|
|
3308
|
-
// Validar que pesos somam aproximadamente 1.0
|
|
3309
|
-
const totalWeight = variations.reduce((s, v) => s + (v.weight || 0.5), 0);
|
|
3310
|
-
if (Math.abs(totalWeight - 1.0) > 0.05) {
|
|
3311
|
-
return new Response(JSON.stringify({
|
|
3312
|
-
error: `A soma dos weights deve ser 1.0. Recebido: ${totalWeight.toFixed(3)}`,
|
|
3313
|
-
}), { status: 400, headers });
|
|
3314
|
-
}
|
|
3315
|
-
|
|
3316
|
-
const hasControl = variations.some(v => v.is_control);
|
|
3317
|
-
if (!hasControl) {
|
|
3318
|
-
return new Response(JSON.stringify({ error: 'Pelo menos uma variação deve ter is_control: true (baseline)' }), { status: 400, headers });
|
|
3319
|
-
}
|
|
3320
|
-
|
|
3321
|
-
// Criar teste
|
|
3322
|
-
const testRes = await env.DB.prepare(`
|
|
3323
|
-
INSERT INTO ltv_ab_tests (name, description, status, min_sample, created_at)
|
|
3324
|
-
VALUES (?, ?, 'running', ?, ?)
|
|
3325
|
-
`).bind(name, description || null, min_sample, now).run();
|
|
3326
|
-
|
|
3327
|
-
const testId = testRes.meta?.last_row_id;
|
|
3328
|
-
if (!testId) throw new Error('Falha ao criar o teste no D1');
|
|
3329
|
-
|
|
3330
|
-
// Criar variações
|
|
3331
|
-
const createdVariations = [];
|
|
3332
|
-
for (const v of variations) {
|
|
3333
|
-
const vRes = await env.DB.prepare(`
|
|
3334
|
-
INSERT INTO ltv_ab_variations (test_id, name, system_prompt, weight, is_control, created_at)
|
|
3335
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
3336
|
-
`).bind(testId, v.name, v.system_prompt, v.weight || 0.5, v.is_control ? 1 : 0, now).run();
|
|
3337
|
-
createdVariations.push({ id: vRes.meta?.last_row_id, name: v.name, weight: v.weight, is_control: !!v.is_control });
|
|
3338
|
-
}
|
|
3339
|
-
|
|
3340
|
-
// Invalidar cache KV
|
|
3341
|
-
if (env.GEO_CACHE) await env.GEO_CACHE.delete(AB_LTV_CACHE_KEY);
|
|
3342
|
-
|
|
3343
|
-
return new Response(JSON.stringify({
|
|
3344
|
-
success: true,
|
|
3345
|
-
test_id: testId,
|
|
3346
|
-
name,
|
|
3347
|
-
status: 'running',
|
|
3348
|
-
min_sample,
|
|
3349
|
-
variations: createdVariations,
|
|
3350
|
-
started_at: now,
|
|
3351
|
-
}), { status: 201, headers });
|
|
3352
|
-
|
|
3353
|
-
} catch (err) {
|
|
3354
|
-
console.error('[AB-LTV] create error:', err.message);
|
|
3355
|
-
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
3356
|
-
}
|
|
3357
|
-
}
|
|
3358
|
-
|
|
3359
|
-
// ── handleLtvAbTestList — GET /api/ltv/ab-test/list ──────────────────────────
|
|
3360
|
-
async function handleLtvAbTestList(env, request, headers) {
|
|
3361
|
-
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
3362
|
-
|
|
3363
|
-
const url = new URL(request.url);
|
|
3364
|
-
const status = url.searchParams.get('status') || null;
|
|
3365
|
-
const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 50);
|
|
3366
|
-
|
|
3367
|
-
try {
|
|
3368
|
-
const cond = status ? 'WHERE t.status = ?' : '';
|
|
3369
|
-
const bindings = status ? [status, limit] : [limit];
|
|
3370
|
-
|
|
3371
|
-
const tests = await env.DB.prepare(`
|
|
3372
|
-
SELECT t.id, t.name, t.description, t.status, t.winner_id,
|
|
3373
|
-
t.started_at, t.completed_at, t.created_at, t.min_sample,
|
|
3374
|
-
COUNT(DISTINCT v.id) AS variation_count,
|
|
3375
|
-
SUM(v.total_assigned) AS total_assigned
|
|
3376
|
-
FROM ltv_ab_tests t
|
|
3377
|
-
LEFT JOIN ltv_ab_variations v ON v.test_id = t.id
|
|
3378
|
-
${cond}
|
|
3379
|
-
GROUP BY t.id
|
|
3380
|
-
ORDER BY t.created_at DESC
|
|
3381
|
-
LIMIT ?
|
|
3382
|
-
`).bind(...bindings).all();
|
|
3383
|
-
|
|
3384
|
-
return new Response(JSON.stringify({
|
|
3385
|
-
success: true,
|
|
3386
|
-
total: (tests.results || []).length,
|
|
3387
|
-
tests: tests.results || [],
|
|
3388
|
-
}), { status: 200, headers });
|
|
3389
|
-
|
|
3390
|
-
} catch (err) {
|
|
3391
|
-
console.error('[AB-LTV] list error:', err.message);
|
|
3392
|
-
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
3393
|
-
}
|
|
3394
|
-
}
|
|
3395
|
-
|
|
3396
|
-
// ── handleLtvAbTestResults — GET /api/ltv/ab-test/results ────────────────────
|
|
3397
|
-
async function handleLtvAbTestResults(env, request, headers) {
|
|
3398
|
-
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
3399
|
-
|
|
3400
|
-
const url = new URL(request.url);
|
|
3401
|
-
const testId = url.searchParams.get('test_id') || null;
|
|
3402
|
-
|
|
3403
|
-
try {
|
|
3404
|
-
const cond = testId ? 'WHERE test_id = ?' : 'WHERE status = \'running\'';
|
|
3405
|
-
const testBind = testId ? [parseInt(testId)] : [];
|
|
3406
|
-
|
|
3407
|
-
const testRes = await env.DB.prepare(`
|
|
3408
|
-
SELECT id, name, status, min_sample, winner_id, started_at FROM ltv_ab_tests ${cond} LIMIT 1
|
|
3409
|
-
`).bind(...testBind).first();
|
|
3410
|
-
|
|
3411
|
-
if (!testRes) {
|
|
3412
|
-
return new Response(JSON.stringify({ error: 'Nenhum teste encontrado' }), { status: 404, headers });
|
|
3413
|
-
}
|
|
3414
|
-
|
|
3415
|
-
const perf = await env.DB.prepare(`
|
|
3416
|
-
SELECT * FROM v_ab_test_performance WHERE test_id = ?
|
|
3417
|
-
`).bind(testRes.id).all();
|
|
3418
|
-
|
|
3419
|
-
const variations = perf.results || [];
|
|
3420
|
-
const ready = variations.every(v => (v.total_assigned || 0) >= testRes.min_sample);
|
|
3421
|
-
let recommendation = null;
|
|
3422
|
-
|
|
3423
|
-
if (ready && variations.length > 0) {
|
|
3424
|
-
const best = variations.reduce((a, b) =>
|
|
3425
|
-
(b.accuracy_score || 0) > (a.accuracy_score || 0) ? b : a
|
|
3426
|
-
);
|
|
3427
|
-
const control = variations.find(v => v.is_control);
|
|
3428
|
-
const improvement = control
|
|
3429
|
-
? ((best.accuracy_score || 0) - (control.accuracy_score || 0)) * 100
|
|
3430
|
-
: null;
|
|
3431
|
-
recommendation = {
|
|
3432
|
-
winner_variation_id: best.variation_id,
|
|
3433
|
-
winner_variation_name: best.variation_name,
|
|
3434
|
-
accuracy_score: best.accuracy_score,
|
|
3435
|
-
improvement_vs_control: improvement ? `+${improvement.toFixed(1)}%` : null,
|
|
3436
|
-
ready_to_declare: true,
|
|
3437
|
-
};
|
|
3438
|
-
}
|
|
3439
|
-
|
|
3440
|
-
return new Response(JSON.stringify({
|
|
3441
|
-
success: true,
|
|
3442
|
-
test: {
|
|
3443
|
-
id: testRes.id,
|
|
3444
|
-
name: testRes.name,
|
|
3445
|
-
status: testRes.status,
|
|
3446
|
-
min_sample: testRes.min_sample,
|
|
3447
|
-
started_at: testRes.started_at,
|
|
3448
|
-
is_ready: ready,
|
|
3449
|
-
},
|
|
3450
|
-
variations,
|
|
3451
|
-
recommendation,
|
|
3452
|
-
}), { status: 200, headers });
|
|
3453
|
-
|
|
3454
|
-
} catch (err) {
|
|
3455
|
-
console.error('[AB-LTV] results error:', err.message);
|
|
3456
|
-
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
3457
|
-
}
|
|
3458
|
-
}
|
|
3459
|
-
|
|
3460
|
-
// ── handleLtvAbTestWinner — POST /api/ltv/ab-test/winner ─────────────────────
|
|
3461
|
-
async function handleLtvAbTestWinner(env, request, headers) {
|
|
3462
|
-
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
3463
|
-
|
|
3464
|
-
let body;
|
|
3465
|
-
try { body = await request.json(); }
|
|
3466
|
-
catch { return new Response(JSON.stringify({ error: 'JSON inválido' }), { status: 400, headers }); }
|
|
3467
|
-
|
|
3468
|
-
const { test_id, variation_id } = body;
|
|
3469
|
-
if (!test_id || !variation_id) {
|
|
3470
|
-
return new Response(JSON.stringify({ error: 'test_id e variation_id são obrigatórios' }), { status: 400, headers });
|
|
3471
|
-
}
|
|
3472
|
-
|
|
3473
|
-
try {
|
|
3474
|
-
const variation = await env.DB.prepare(
|
|
3475
|
-
`SELECT id, name, system_prompt, is_control FROM ltv_ab_variations WHERE id = ? AND test_id = ?`
|
|
3476
|
-
).bind(variation_id, test_id).first();
|
|
3477
|
-
|
|
3478
|
-
if (!variation) {
|
|
3479
|
-
return new Response(JSON.stringify({ error: 'Variação não encontrada para este teste' }), { status: 404, headers });
|
|
3480
|
-
}
|
|
3481
|
-
|
|
3482
|
-
// Marcar winner e concluir o teste
|
|
3483
|
-
await env.DB.prepare(
|
|
3484
|
-
`UPDATE ltv_ab_tests SET winner_id = ?, status = 'completed', completed_at = datetime('now') WHERE id = ?`
|
|
3485
|
-
).bind(variation_id, test_id).run();
|
|
3486
|
-
|
|
3487
|
-
// Invalidar cache KV (não há mais teste ativo)
|
|
3488
|
-
if (env.GEO_CACHE) await env.GEO_CACHE.delete(AB_LTV_CACHE_KEY);
|
|
3489
|
-
|
|
3490
|
-
return new Response(JSON.stringify({
|
|
3491
|
-
success: true,
|
|
3492
|
-
test_id,
|
|
3493
|
-
winner_variation_id: variation_id,
|
|
3494
|
-
winner_name: variation.name,
|
|
3495
|
-
is_control: variation.is_control === 1,
|
|
3496
|
-
winning_prompt: variation.system_prompt,
|
|
3497
|
-
message: variation.is_control === 1
|
|
3498
|
-
? 'O prompt original (controle) venceu. Nenhuma alteração necessária.'
|
|
3499
|
-
: 'Novo prompt vencedor identificado. Copie o campo winning_prompt e aplique ao predictLtv() como novo default.',
|
|
3500
|
-
}), { status: 200, headers });
|
|
3501
|
-
|
|
3502
|
-
} catch (err) {
|
|
3503
|
-
console.error('[AB-LTV] winner error:', err.message);
|
|
3504
|
-
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
3505
|
-
}
|
|
3506
|
-
}
|
|
3507
|
-
|
|
3508
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
3509
|
-
// BIDDING RECOMMENDATIONS — Handlers das Rotas de Otimização de Bids
|
|
3510
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
3511
|
-
|
|
3512
|
-
// Fatores de plataforma (conservadores por design — usuário escala gradualmente)
|
|
3513
|
-
const PLATFORM_FACTORS = { meta: 0.85, google: 0.90, tiktok: 0.75 };
|
|
3514
|
-
|
|
3515
|
-
// Multiplicadores por tier de segmento (baseado em avg_ltv_class + avg_behavior_score)
|
|
3516
|
-
function getSegmentMultiplier(avgLtvClass, avgBehaviorScore) {
|
|
3517
|
-
const ltv = parseFloat(avgLtvClass || 0);
|
|
3518
|
-
const eng = parseFloat(avgBehaviorScore || 0);
|
|
3519
|
-
if (ltv >= 0.7 && eng >= 0.7) return 1.4; // Alto Valor + Alto Engajamento
|
|
3520
|
-
if (ltv >= 0.7 && eng >= 0.4) return 1.2; // Alto Valor + Médio Engajamento
|
|
3521
|
-
if (ltv >= 0.4 && eng >= 0.7) return 1.0; // Médio Valor + Alto Engajamento
|
|
3522
|
-
if (ltv >= 0.4 && eng >= 0.4) return 0.8; // Médio Valor + Médio Engajamento
|
|
3523
|
-
return 0.6; // Baixo Valor ou Baixo Engajamento
|
|
3524
|
-
}
|
|
3525
|
-
|
|
3526
|
-
// Ajuste de confiança baseado em volume de conversões
|
|
3527
|
-
function getConfidenceAdjustment(confidence) {
|
|
3528
|
-
if (confidence >= 0.7) return 1.00;
|
|
3529
|
-
if (confidence >= 0.4) return 0.85;
|
|
3530
|
-
return 0.70;
|
|
3531
|
-
}
|
|
3532
|
-
|
|
3533
|
-
// ── POST /api/bidding/recommend ───────────────────────────────────────────────
|
|
3534
|
-
// Gera recomendações de bid para uma plataforma e vertical
|
|
3535
|
-
// Requer binding: DB (AI é opcional — usa fórmula determinística se indisponível)
|
|
3536
|
-
async function handleBiddingRecommend(env, request, headers) {
|
|
3537
|
-
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
3538
|
-
|
|
3539
|
-
let body;
|
|
3540
|
-
try { body = await request.json(); }
|
|
3541
|
-
catch { return new Response(JSON.stringify({ error: 'JSON inválido no body' }), { status: 400, headers }); }
|
|
3542
|
-
|
|
3543
|
-
const {
|
|
3544
|
-
vertical = 'geral',
|
|
3545
|
-
platform = 'meta',
|
|
3546
|
-
target_roi = 3.5,
|
|
3547
|
-
period_days = 30,
|
|
3548
|
-
campaign_id = null,
|
|
3549
|
-
budget = null,
|
|
3550
|
-
} = body;
|
|
3551
|
-
|
|
3552
|
-
const platforms = platform === 'all'
|
|
3553
|
-
? Object.keys(PLATFORM_FACTORS)
|
|
3554
|
-
: [platform].filter(p => PLATFORM_FACTORS[p]);
|
|
3555
|
-
|
|
3556
|
-
if (platforms.length === 0) {
|
|
3557
|
-
return new Response(JSON.stringify({ error: 'platform deve ser: meta, google, tiktok ou all' }), { status: 400, headers });
|
|
3558
|
-
}
|
|
3559
|
-
if (target_roi < 1 || target_roi > 20) {
|
|
3560
|
-
return new Response(JSON.stringify({ error: 'target_roi deve estar entre 1 e 20' }), { status: 400, headers });
|
|
3561
|
-
}
|
|
3562
|
-
|
|
3563
|
-
try {
|
|
3564
|
-
// 1. Checar se há segmentos ML ativos (com dados de LTV real)
|
|
3565
|
-
const segmentsRes = await env.DB.prepare(`
|
|
3566
|
-
SELECT
|
|
3567
|
-
ms.id AS segment_id,
|
|
3568
|
-
ms.cluster_name,
|
|
3569
|
-
ms.avg_ltv_class,
|
|
3570
|
-
ms.avg_behavior_score,
|
|
3571
|
-
ms.avg_engagement_score,
|
|
3572
|
-
ms.silhouette_score,
|
|
3573
|
-
COUNT(msm.id) AS member_count,
|
|
3574
|
-
AVG(l.predicted_ltv) AS real_avg_ltv,
|
|
3575
|
-
SUM(CASE WHEN l.event_name IN ('Purchase','CompletePayment') THEN 1 ELSE 0 END) AS conversions
|
|
3576
|
-
FROM ml_segments ms
|
|
3577
|
-
LEFT JOIN ml_segment_members msm ON msm.cluster_id = ms.id
|
|
3578
|
-
LEFT JOIN leads l ON CAST(msm.lead_id AS INTEGER) = l.id
|
|
3579
|
-
AND l.created_at >= datetime('now', '-' || ? || ' days')
|
|
3580
|
-
WHERE ms.is_active = 1
|
|
3581
|
-
AND ms.client_vertical IN (?, 'general')
|
|
3582
|
-
GROUP BY ms.id
|
|
3583
|
-
HAVING member_count > 0
|
|
3584
|
-
ORDER BY real_avg_ltv DESC
|
|
3585
|
-
LIMIT 10
|
|
3586
|
-
`).bind(period_days, vertical).all();
|
|
3587
|
-
|
|
3588
|
-
const segments = segmentsRes.results || [];
|
|
3589
|
-
|
|
3590
|
-
// 2. Fallback se não houver segmentos: usar LTV global dos leads
|
|
3591
|
-
let globalLtv = 0, globalLeads = 0, globalConversions = 0;
|
|
3592
|
-
if (segments.length === 0) {
|
|
3593
|
-
const globalRes = await env.DB.prepare(`
|
|
3594
|
-
SELECT
|
|
3595
|
-
COUNT(*) AS total_leads,
|
|
3596
|
-
AVG(predicted_ltv) AS avg_ltv,
|
|
3597
|
-
SUM(CASE WHEN event_name IN ('Purchase','CompletePayment') THEN 1 ELSE 0 END) AS conversions
|
|
3598
|
-
FROM leads
|
|
3599
|
-
WHERE created_at >= datetime('now', '-' || ? || ' days')
|
|
3600
|
-
AND (bot_score IS NULL OR bot_score < 2)
|
|
3601
|
-
`).bind(period_days).first();
|
|
3602
|
-
|
|
3603
|
-
globalLeads = globalRes?.total_leads || 0;
|
|
3604
|
-
globalLtv = globalRes?.avg_ltv || 0;
|
|
3605
|
-
globalConversions = globalRes?.conversions || 0;
|
|
3606
|
-
|
|
3607
|
-
if (globalLeads < 10) {
|
|
3608
|
-
return new Response(JSON.stringify({
|
|
3609
|
-
error: `Dados insuficientes. Apenas ${globalLeads} leads no período de ${period_days} dias. Mínimo: 10.`,
|
|
3610
|
-
leads_found: globalLeads,
|
|
3611
|
-
required: 10,
|
|
3612
|
-
}), { status: 400, headers });
|
|
3613
|
-
}
|
|
3614
|
-
}
|
|
3615
|
-
|
|
3616
|
-
// 3. Gerar recomendações por plataforma × segmento
|
|
3617
|
-
const now = new Date().toISOString();
|
|
3618
|
-
const recommendations = [];
|
|
3619
|
-
|
|
3620
|
-
const targetSegments = segments.length > 0
|
|
3621
|
-
? segments
|
|
3622
|
-
: [{ segment_id: null, cluster_name: 'Global (sem segmentação)', avg_ltv_class: 0.5, avg_behavior_score: 0.5, avg_engagement_score: 0.5, member_count: globalLeads, real_avg_ltv: globalLtv, conversions: globalConversions }];
|
|
3623
|
-
|
|
3624
|
-
for (const seg of targetSegments) {
|
|
3625
|
-
const avgLtv = parseFloat(seg.real_avg_ltv || 0);
|
|
3626
|
-
const convs = parseInt(seg.conversions || 0);
|
|
3627
|
-
const confidence = Math.min(1, convs / 100);
|
|
3628
|
-
|
|
3629
|
-
// Sem LTV real? Usar LTV estimado pela classe do segmento
|
|
3630
|
-
const estimatedLtv = avgLtv > 0 ? avgLtv :
|
|
3631
|
-
seg.avg_ltv_class >= 0.7 ? 497 :
|
|
3632
|
-
seg.avg_ltv_class >= 0.4 ? 297 : 97;
|
|
3633
|
-
|
|
3634
|
-
const cpaTarget = estimatedLtv / target_roi;
|
|
3635
|
-
const segMult = getSegmentMultiplier(seg.avg_ltv_class, seg.avg_behavior_score);
|
|
3636
|
-
const confAdj = getConfidenceAdjustment(confidence);
|
|
3637
|
-
|
|
3638
|
-
const alertMsg = convs < 30
|
|
3639
|
-
? `Atenção: apenas ${convs} conversões no período. Bid baseado em estimativa de LTV — aplique com cautela.`
|
|
3640
|
-
: null;
|
|
3641
|
-
|
|
3642
|
-
for (const plat of platforms) {
|
|
3643
|
-
const platFactor = PLATFORM_FACTORS[plat];
|
|
3644
|
-
const recommendedBid = Math.max(5, cpaTarget * platFactor * segMult * confAdj);
|
|
3645
|
-
const expectedRoi = estimatedLtv / (recommendedBid / platFactor);
|
|
3646
|
-
|
|
3647
|
-
// Guardar no D1 em background
|
|
3648
|
-
const reasoning = `Segmento "${seg.cluster_name}": LTV=${estimatedLtv.toFixed(0)} BRL, ` +
|
|
3649
|
-
`CPA_alvo=${cpaTarget.toFixed(0)} BRL, fator_plataforma=${platFactor}, ` +
|
|
3650
|
-
`mult_segmento=${segMult}, ajuste_confiança=${confAdj}, ` +
|
|
3651
|
-
`base: ${convs} conversões em ${period_days} dias.`;
|
|
3652
|
-
|
|
3653
|
-
try {
|
|
3654
|
-
await env.DB.prepare(`
|
|
3655
|
-
INSERT INTO bid_recommendations (
|
|
3656
|
-
generated_at, vertical, platform, period_days, target_roi,
|
|
3657
|
-
segment_id, segment_name, leads_analyzed, conversions_found,
|
|
3658
|
-
avg_ltv, cpa_target, recommended_bid, bid_currency,
|
|
3659
|
-
confidence, expected_roi, reasoning, ai_used, alert_message,
|
|
3660
|
-
platform_factor, confidence_adjustment, segment_multiplier, is_active
|
|
3661
|
-
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,?,?,?,?,1)
|
|
3662
|
-
`).bind(
|
|
3663
|
-
now, vertical, plat, period_days, target_roi,
|
|
3664
|
-
seg.segment_id || null, seg.cluster_name,
|
|
3665
|
-
seg.member_count || globalLeads, convs,
|
|
3666
|
-
estimatedLtv, cpaTarget, recommendedBid, 'BRL',
|
|
3667
|
-
confidence, expectedRoi, reasoning, alertMsg || null,
|
|
3668
|
-
platFactor, confAdj, segMult,
|
|
3669
|
-
).run();
|
|
3670
|
-
} catch (e) { console.error('[Bidding] D1 insert error:', e.message); }
|
|
3671
|
-
|
|
3672
|
-
recommendations.push({
|
|
3673
|
-
platform: plat,
|
|
3674
|
-
segment: seg.cluster_name,
|
|
3675
|
-
segment_id: seg.segment_id || null,
|
|
3676
|
-
avg_ltv: Math.round(estimatedLtv * 100) / 100,
|
|
3677
|
-
avg_ltv_class: seg.avg_ltv_class >= 0.7 ? 'High' : seg.avg_ltv_class >= 0.4 ? 'Medium' : 'Low',
|
|
3678
|
-
cpa_target: Math.round(cpaTarget * 100) / 100,
|
|
3679
|
-
recommended_bid: Math.round(recommendedBid * 100) / 100,
|
|
3680
|
-
bid_currency: 'BRL',
|
|
3681
|
-
confidence: Math.round(confidence * 100) / 100,
|
|
3682
|
-
expected_roi: Math.round(expectedRoi * 100) / 100,
|
|
3683
|
-
reasoning,
|
|
3684
|
-
alert: alertMsg,
|
|
3685
|
-
});
|
|
3686
|
-
}
|
|
3687
|
-
}
|
|
3688
|
-
|
|
3689
|
-
// Desativar recomendações anteriores da mesma vertical/plataforma
|
|
3690
|
-
await env.DB.prepare(
|
|
3691
|
-
`UPDATE bid_recommendations SET is_active = 0 WHERE vertical = ? AND generated_at < ? AND is_active = 1`
|
|
3692
|
-
).bind(vertical, now).run().catch(() => {});
|
|
3693
|
-
|
|
3694
|
-
const avgConfidence = recommendations.length > 0
|
|
3695
|
-
? recommendations.reduce((s, r) => s + r.confidence, 0) / recommendations.length
|
|
3696
|
-
: 0;
|
|
3697
|
-
|
|
3698
|
-
return new Response(JSON.stringify({
|
|
3699
|
-
success: true,
|
|
3700
|
-
generated_at: now,
|
|
3701
|
-
vertical,
|
|
3702
|
-
period_days,
|
|
3703
|
-
target_roi,
|
|
3704
|
-
data_quality: {
|
|
3705
|
-
leads_analyzed: targetSegments.reduce((s, sg) => s + (sg.member_count || 0), 0),
|
|
3706
|
-
conversions_found: targetSegments.reduce((s, sg) => s + (sg.conversions || 0), 0),
|
|
3707
|
-
segments_active: segments.length,
|
|
3708
|
-
confidence: Math.round(avgConfidence * 100) / 100,
|
|
3709
|
-
},
|
|
3710
|
-
recommendations,
|
|
3711
|
-
global_summary: {
|
|
3712
|
-
total_recommendations: recommendations.length,
|
|
3713
|
-
avg_confidence: Math.round(avgConfidence * 100) / 100,
|
|
3714
|
-
expected_cost_reduction: avgConfidence >= 0.7 ? '-20%' : avgConfidence >= 0.4 ? '-10%' : 'indefinido (dados insuficientes)',
|
|
3715
|
-
segments_analyzed: segments.length,
|
|
3716
|
-
},
|
|
3717
|
-
}), { status: 200, headers });
|
|
3718
|
-
|
|
3719
|
-
} catch (err) {
|
|
3720
|
-
console.error('[Bidding] recommend error:', err.message);
|
|
3721
|
-
return new Response(JSON.stringify({ error: 'Erro ao gerar recomendações', message: err.message }), { status: 500, headers });
|
|
3722
|
-
}
|
|
3723
|
-
}
|
|
3724
|
-
|
|
3725
|
-
// ── GET /api/bidding/history ──────────────────────────────────────────────────
|
|
3726
|
-
// Retorna histórico de recomendações de bids geradas
|
|
3727
|
-
async function handleBiddingHistory(env, request, headers) {
|
|
3728
|
-
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
3729
|
-
|
|
3730
|
-
const url = new URL(request.url);
|
|
3731
|
-
const vertical = url.searchParams.get('vertical') || null;
|
|
3732
|
-
const platform = url.searchParams.get('platform') || null;
|
|
3733
|
-
const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 100);
|
|
3734
|
-
|
|
3735
|
-
try {
|
|
3736
|
-
const conditions = [];
|
|
3737
|
-
const bindings = [];
|
|
3738
|
-
|
|
3739
|
-
if (vertical) { conditions.push('vertical = ?'); bindings.push(vertical); }
|
|
3740
|
-
if (platform) { conditions.push('platform = ?'); bindings.push(platform); }
|
|
3741
|
-
|
|
3742
|
-
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
3743
|
-
|
|
3744
|
-
const result = await env.DB.prepare(`
|
|
3745
|
-
SELECT id, generated_at, vertical, platform, period_days, target_roi,
|
|
3746
|
-
segment_name, leads_analyzed, conversions_found, avg_ltv, cpa_target,
|
|
3747
|
-
recommended_bid, bid_currency, confidence, expected_roi,
|
|
3748
|
-
reasoning, alert_message, ai_used, is_active,
|
|
3749
|
-
applied_at, applied_campaign, applied_result
|
|
3750
|
-
FROM bid_recommendations
|
|
3751
|
-
${where}
|
|
3752
|
-
ORDER BY generated_at DESC
|
|
3753
|
-
LIMIT ?
|
|
3754
|
-
`).bind(...bindings, limit).all();
|
|
3755
|
-
|
|
3756
|
-
const items = (result.results || []).map(r => ({
|
|
3757
|
-
...r,
|
|
3758
|
-
applied_result: tryParseJson(r.applied_result, null),
|
|
3759
|
-
}));
|
|
3760
|
-
|
|
3761
|
-
return new Response(JSON.stringify({
|
|
3762
|
-
success: true,
|
|
3763
|
-
total: items.length,
|
|
3764
|
-
history: items,
|
|
3765
|
-
}), { status: 200, headers });
|
|
3766
|
-
|
|
3767
|
-
} catch (err) {
|
|
3768
|
-
console.error('[Bidding] history error:', err.message);
|
|
3769
|
-
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
3770
|
-
}
|
|
3771
|
-
}
|
|
3772
|
-
|
|
3773
|
-
// ── GET /api/bidding/status ───────────────────────────────────────────────────
|
|
3774
|
-
// Status atual das recomendações ativas (última por plataforma por vertical)
|
|
3775
|
-
async function handleBiddingStatus(env, request, headers) {
|
|
3776
|
-
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
3777
|
-
|
|
3778
|
-
const url = new URL(request.url);
|
|
3779
|
-
const vertical = url.searchParams.get('vertical') || null;
|
|
3780
|
-
|
|
3781
|
-
try {
|
|
3782
|
-
let query = `
|
|
3783
|
-
SELECT platform, vertical, MAX(generated_at) as last_generated,
|
|
3784
|
-
AVG(confidence) as avg_confidence, AVG(recommended_bid) as avg_bid,
|
|
3785
|
-
COUNT(*) as recommendations_count,
|
|
3786
|
-
SUM(CASE WHEN alert_message IS NOT NULL THEN 1 ELSE 0 END) as alerts_count
|
|
3787
|
-
FROM bid_recommendations
|
|
3788
|
-
WHERE is_active = 1
|
|
3789
|
-
`;
|
|
3790
|
-
const bindings = [];
|
|
3791
|
-
if (vertical) { query += ' AND vertical = ?'; bindings.push(vertical); }
|
|
3792
|
-
query += ' GROUP BY platform, vertical ORDER BY last_generated DESC';
|
|
3793
|
-
|
|
3794
|
-
const result = await env.DB.prepare(query).bind(...bindings).all();
|
|
3795
|
-
|
|
3796
|
-
return new Response(JSON.stringify({
|
|
3797
|
-
success: true,
|
|
3798
|
-
status: result.results || [],
|
|
3799
|
-
}), { status: 200, headers });
|
|
3800
|
-
|
|
3801
|
-
} catch (err) {
|
|
3802
|
-
console.error('[Bidding] status error:', err.message);
|
|
3803
|
-
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
3804
|
-
}
|
|
3805
|
-
}
|
|
3806
|
-
|
|
3807
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
3808
|
-
// HANDLER PRINCIPAL
|
|
3809
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
3810
|
-
export default {
|
|
3811
|
-
async fetch(request, env, ctx) {
|
|
3812
|
-
const origin = request.headers.get('Origin') || '';
|
|
3813
|
-
const headers = {
|
|
3814
|
-
'Content-Type': 'application/json',
|
|
3815
|
-
...corsHeaders(origin, env.SITE_DOMAIN),
|
|
3816
|
-
};
|
|
3817
|
-
|
|
3818
|
-
// Preflight CORS
|
|
3819
|
-
if (request.method === 'OPTIONS') {
|
|
3820
|
-
return new Response(null, { status: 204, headers });
|
|
3821
|
-
}
|
|
3822
|
-
|
|
3823
|
-
const url = new URL(request.url);
|
|
3824
|
-
|
|
3825
|
-
// ── Rate Limiter — camada 0, antes do Fraud Gate ─────────────────────────
|
|
3826
|
-
// Bloqueia na borda por IP antes de qualquer CPU ser consumida
|
|
3827
|
-
// Silent drop (200) — atacante não sabe que foi bloqueado
|
|
3828
|
-
// Requer binding RATE_LIMITER no wrangler.toml (Workers Paid)
|
|
3829
|
-
// Fail-open: se binding não existir, deixa passar (não quebra o fluxo)
|
|
3830
|
-
if (url.pathname === '/track' && request.method === 'POST' && env.RATE_LIMITER) {
|
|
3831
|
-
const ip = request.headers.get('CF-Connecting-IP')
|
|
3832
|
-
|| request.headers.get('X-Forwarded-For')?.split(',')[0].trim()
|
|
3833
|
-
|| '0.0.0.0';
|
|
3834
|
-
const { success } = await env.RATE_LIMITER.limit({ key: ip });
|
|
3835
|
-
if (!success) {
|
|
3836
|
-
return new Response(JSON.stringify({ status: 'ok', queued: true }), { status: 200, headers });
|
|
3837
|
-
}
|
|
3838
|
-
}
|
|
3839
|
-
|
|
3840
|
-
// ── Fraud Gate — Fase 4 (apenas em /track e /api) ────────────────────────
|
|
3841
|
-
// Roda ANTES de qualquer processamento de evento
|
|
3842
|
-
// Silent drop (200) — bots não sabem que foram detectados
|
|
3843
|
-
if (url.pathname === '/track' && request.method === 'POST') {
|
|
3844
|
-
let trackBodyForFraud;
|
|
3845
|
-
try {
|
|
3846
|
-
const cloned = request.clone();
|
|
3847
|
-
trackBodyForFraud = await cloned.json().catch(() => ({}));
|
|
3848
|
-
} catch { trackBodyForFraud = {}; }
|
|
3849
|
-
|
|
3850
|
-
const fraudResult = await checkFraudGate(env, request, trackBodyForFraud);
|
|
3851
|
-
if (!fraudResult.allowed) {
|
|
3852
|
-
// Log em background — não bloqueia a resposta
|
|
3853
|
-
ctx.waitUntil(logFraudSignal(env, request, trackBodyForFraud, fraudResult));
|
|
3854
|
-
// Silent drop: retorna 200 com payload de sucesso falso
|
|
3855
|
-
return new Response(JSON.stringify({ status: 'ok', queued: true }), { status: 200, headers });
|
|
3856
|
-
}
|
|
3857
|
-
if (fraudResult.action === 'flagged') {
|
|
3858
|
-
// Suspeito mas permitido — loga em background
|
|
3859
|
-
ctx.waitUntil(logFraudSignal(env, request, trackBodyForFraud, fraudResult));
|
|
3860
|
-
}
|
|
3861
|
-
}
|
|
3862
|
-
|
|
3863
|
-
// ── GET /export/customer-match — exporta leads para Google Ads (download) ──
|
|
3864
|
-
if (request.method === 'GET' && url.pathname === '/export/customer-match') {
|
|
3865
|
-
// Proteção simples por token (mesmo META_ACCESS_TOKEN ou secret dedicado)
|
|
3866
|
-
const authHeader = request.headers.get('Authorization') || '';
|
|
3867
|
-
const token = authHeader.replace('Bearer ', '');
|
|
3868
|
-
if (!env.META_ACCESS_TOKEN || token !== env.META_ACCESS_TOKEN) {
|
|
3869
|
-
return new Response('Unauthorized', { status: 401 });
|
|
3870
|
-
}
|
|
3871
|
-
|
|
3872
|
-
const rows = await buildGoogleCustomerMatchExport(env);
|
|
3873
|
-
return new Response(JSON.stringify({ total: rows.length, data: rows }, null, 2), {
|
|
3874
|
-
headers: { ...headers, 'Content-Disposition': 'attachment; filename="customer-match.json"' },
|
|
3875
|
-
});
|
|
3876
|
-
}
|
|
3877
|
-
|
|
3878
|
-
// ── GET /health — Smoke Test completo ────────────────────────────────────
|
|
3879
|
-
if (request.method === 'GET' && url.pathname === '/health') {
|
|
3880
|
-
const results = {};
|
|
3881
|
-
|
|
3882
|
-
// D1 — query real
|
|
3883
|
-
try {
|
|
3884
|
-
await env.DB.prepare('SELECT 1').run();
|
|
3885
|
-
results.d1 = 'ok';
|
|
3886
|
-
} catch (err) {
|
|
3887
|
-
results.d1 = `FAILED: ${err.message}`;
|
|
3888
|
-
}
|
|
3889
|
-
|
|
3890
|
-
// KV — leitura real
|
|
3891
|
-
try {
|
|
3892
|
-
await env.GEO_CACHE.get('__health_check__');
|
|
3893
|
-
results.kv = 'ok';
|
|
3894
|
-
} catch (err) {
|
|
3895
|
-
results.kv = `FAILED: ${err.message}`;
|
|
3896
|
-
}
|
|
3897
|
-
|
|
3898
|
-
// Workers AI — ping
|
|
3899
|
-
try {
|
|
3900
|
-
await env.AI.run('@cf/ibm-granite/granite-4.0-h-micro', {
|
|
3901
|
-
messages: [{ role: 'user', content: 'ping' }],
|
|
3902
|
-
max_tokens: 1,
|
|
3903
|
-
});
|
|
3904
|
-
results.ai = 'ok';
|
|
3905
|
-
} catch (err) {
|
|
3906
|
-
results.ai = `FAILED: ${err.message}`;
|
|
3907
|
-
}
|
|
3908
|
-
|
|
3909
|
-
// Vars obrigatórias
|
|
3910
|
-
const vars = {
|
|
3911
|
-
META_PIXEL_ID: env.META_PIXEL_ID ? 'set' : 'MISSING',
|
|
3912
|
-
GA4_MEASUREMENT_ID: env.GA4_MEASUREMENT_ID ? 'set' : 'MISSING',
|
|
3913
|
-
TIKTOK_PIXEL_ID: env.TIKTOK_PIXEL_ID ? 'set' : 'MISSING',
|
|
3914
|
-
SITE_DOMAIN: env.SITE_DOMAIN ? 'set' : 'MISSING',
|
|
3915
|
-
};
|
|
3916
|
-
|
|
3917
|
-
// Secrets obrigatórios
|
|
3918
|
-
const secrets = {
|
|
3919
|
-
META_ACCESS_TOKEN: env.META_ACCESS_TOKEN ? 'set' : 'MISSING',
|
|
3920
|
-
GA4_API_SECRET: env.GA4_API_SECRET ? 'set' : 'MISSING',
|
|
3921
|
-
WA_WEBHOOK_VERIFY_TOKEN: env.WA_WEBHOOK_VERIFY_TOKEN ? 'set' : 'MISSING',
|
|
3922
|
-
WHATSAPP_ACCESS_TOKEN: env.WHATSAPP_ACCESS_TOKEN ? 'set' : 'not set (optional - only for auto-reply)',
|
|
3923
|
-
WHATSAPP_PHONE_NUMBER_ID: env.WHATSAPP_PHONE_NUMBER_ID ? 'set' : 'not set (optional - only for auto-reply)',
|
|
3924
|
-
WA_NOTIFY_NUMBER: env.WA_NOTIFY_NUMBER ? 'set' : 'not set (optional - only for auto-reply)',
|
|
3925
|
-
TIKTOK_ACCESS_TOKEN: env.TIKTOK_ACCESS_TOKEN ? 'set' : 'not set (optional)',
|
|
3926
|
-
CALLMEBOT_PHONE: env.CALLMEBOT_PHONE ? 'set' : 'not set (optional)',
|
|
3927
|
-
};
|
|
3928
|
-
|
|
3929
|
-
const hasMissing =
|
|
3930
|
-
Object.values(vars).includes('MISSING') ||
|
|
3931
|
-
Object.values(secrets).includes('MISSING') ||
|
|
3932
|
-
results.d1 !== 'ok';
|
|
3933
|
-
|
|
3934
|
-
return new Response(JSON.stringify({
|
|
3935
|
-
status: hasMissing ? 'degraded' : 'ok',
|
|
3936
|
-
timestamp: new Date().toISOString(),
|
|
3937
|
-
bindings: results,
|
|
3938
|
-
vars,
|
|
3939
|
-
secrets,
|
|
3940
|
-
}, null, 2), { headers });
|
|
3941
|
-
}
|
|
3942
|
-
|
|
3943
|
-
// ── POST /track — evento do browser ───────────────────────────────────────
|
|
3944
|
-
if (request.method === 'POST' && url.pathname === '/track') {
|
|
3945
|
-
// Reject oversized payloads before reading body (64 KB limit)
|
|
3946
|
-
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
|
|
3947
|
-
if (contentLength > 65536) {
|
|
3948
|
-
return new Response(JSON.stringify({ error: 'Payload muito grande' }), { status: 413, headers });
|
|
3949
|
-
}
|
|
3950
|
-
|
|
3951
|
-
let body;
|
|
3952
|
-
try {
|
|
3953
|
-
body = await request.json();
|
|
3954
|
-
} catch {
|
|
3955
|
-
return new Response(
|
|
3956
|
-
JSON.stringify({ error: 'JSON inválido' }),
|
|
3957
|
-
{ status: 400, headers }
|
|
3958
|
-
);
|
|
3959
|
-
}
|
|
3960
|
-
|
|
3961
|
-
// ── Payload validation ────────────────────────────────────────────────────
|
|
3962
|
-
// Reject non-object bodies and oversized string fields to prevent injection
|
|
3963
|
-
if (typeof body !== 'object' || Array.isArray(body) || body === null) {
|
|
3964
|
-
return new Response(JSON.stringify({ error: 'Payload inválido' }), { status: 400, headers });
|
|
3965
|
-
}
|
|
3966
|
-
|
|
3967
|
-
const VALID_EVENT_NAMES = new Set([
|
|
3968
|
-
'PageView','ViewContent','Lead','Purchase','InitiateCheckout',
|
|
3969
|
-
'AddToCart','CompleteRegistration','Contact','Schedule',
|
|
3970
|
-
'StartTrial','Subscribe','SubmitApplication','Search',
|
|
3971
|
-
'video_start','video_25','video_50','video_75','video_complete'
|
|
3972
|
-
]);
|
|
3973
|
-
const STR_FIELDS = ['email','phone','firstName','lastName','city','state','zip','userId',
|
|
3974
|
-
'utmSource','utmMedium','utmCampaign','utmContent','utmTerm',
|
|
3975
|
-
'fbclid','ttclid','gclid','transactionId','productName','currency'];
|
|
3976
|
-
|
|
3977
|
-
const { eventName, behavioral_data, ...payload } = body;
|
|
3978
|
-
|
|
3979
|
-
if (!eventName) {
|
|
3980
|
-
return new Response(
|
|
3981
|
-
JSON.stringify({ error: 'eventName é obrigatório' }),
|
|
3982
|
-
{ status: 400, headers }
|
|
3983
|
-
);
|
|
3984
|
-
}
|
|
3985
|
-
|
|
3986
|
-
if (typeof eventName !== 'string' || eventName.length > 64 || !VALID_EVENT_NAMES.has(eventName)) {
|
|
3987
|
-
return new Response(JSON.stringify({ error: `eventName desconhecido: ${eventName.slice(0, 64)}` }), { status: 400, headers });
|
|
3988
|
-
}
|
|
3989
|
-
|
|
3990
|
-
// Enforce max string length on known PII/UTM fields to block injection payloads
|
|
3991
|
-
for (const field of STR_FIELDS) {
|
|
3992
|
-
if (payload[field] !== undefined && payload[field] !== null) {
|
|
3993
|
-
if (typeof payload[field] !== 'string' || payload[field].length > 512) {
|
|
3994
|
-
return new Response(JSON.stringify({ error: `Campo inválido: ${field}` }), { status: 400, headers });
|
|
3995
|
-
}
|
|
3996
|
-
}
|
|
3997
|
-
}
|
|
3998
|
-
|
|
3999
|
-
// value must be a non-negative number when present
|
|
4000
|
-
if (payload.value !== undefined && payload.value !== null) {
|
|
4001
|
-
const v = Number(payload.value);
|
|
4002
|
-
if (isNaN(v) || v < 0 || v > 9_999_999) {
|
|
4003
|
-
return new Response(JSON.stringify({ error: 'value fora do intervalo permitido' }), { status: 400, headers });
|
|
4004
|
-
}
|
|
4005
|
-
payload.value = v;
|
|
4006
|
-
}
|
|
4007
|
-
|
|
4008
|
-
// ── Extrair dados comportamentais do browser ──────────────────────────────
|
|
4009
|
-
// behavioral_data vem do engagement-scoring.js (engagement_score 0-5, intention_level)
|
|
4010
|
-
// e do BehaviorEngine (user_score 0-100)
|
|
4011
|
-
if (behavioral_data) {
|
|
4012
|
-
payload.engagementScore = behavioral_data.engagement_score ?? behavioral_data.totalScore ?? null;
|
|
4013
|
-
payload.intentionLevel = behavioral_data.intention_level ?? null;
|
|
4014
|
-
payload.userScore = behavioral_data.user_score ?? null;
|
|
4015
|
-
// PII extraído pelo advanced-matching.js chega aninhado em behavioral_data
|
|
4016
|
-
// (trackLead passa piiData como `data`, que é spread em behavioral_data)
|
|
4017
|
-
payload.email = payload.email || behavioral_data.email || null;
|
|
4018
|
-
payload.phone = payload.phone || behavioral_data.phone || null;
|
|
4019
|
-
payload.firstName = payload.firstName || behavioral_data.first_name || behavioral_data.firstName || null;
|
|
4020
|
-
payload.lastName = payload.lastName || behavioral_data.last_name || behavioral_data.lastName || null;
|
|
4021
|
-
payload.city = payload.city || behavioral_data.city || null;
|
|
4022
|
-
payload.state = payload.state || behavioral_data.state || null;
|
|
4023
|
-
payload.zip = payload.zip || behavioral_data.zip || null;
|
|
4024
|
-
payload.dob = payload.dob || behavioral_data.dob || null;
|
|
4025
|
-
}
|
|
4026
|
-
|
|
4027
|
-
// ── Edge Fingerprint + UTM Resurrection ───────────────────────────────────
|
|
4028
|
-
const fingerprint = await generateEdgeFingerprint(request);
|
|
4029
|
-
payload.utmRestored = false;
|
|
4030
|
-
|
|
4031
|
-
if (fingerprint) {
|
|
4032
|
-
if (payload.utmSource) {
|
|
4033
|
-
// Tem UTM → salvar fingerprint para uso futuro
|
|
4034
|
-
ctx.waitUntil(saveEdgeFingerprint(env.DB, fingerprint, payload.userId, payload));
|
|
4035
|
-
} else {
|
|
4036
|
-
// Sem UTM → tentar recuperar das últimas 48h
|
|
4037
|
-
const recovered = await resurrectUTM(env.DB, fingerprint);
|
|
4038
|
-
if (recovered) {
|
|
4039
|
-
payload.utmSource = payload.utmSource || recovered.utm_source;
|
|
4040
|
-
payload.utmMedium = payload.utmMedium || recovered.utm_medium;
|
|
4041
|
-
payload.utmCampaign = payload.utmCampaign || recovered.utm_campaign;
|
|
4042
|
-
payload.utmContent = payload.utmContent || recovered.utm_content;
|
|
4043
|
-
payload.utmTerm = payload.utmTerm || recovered.utm_term;
|
|
4044
|
-
payload.utmRestored = true;
|
|
4045
|
-
}
|
|
4046
|
-
}
|
|
4047
|
-
}
|
|
4048
|
-
|
|
4049
|
-
// ── Bot Mitigation ────────────────────────────────────────────────────────
|
|
4050
|
-
const botScoreStr = request.cf?.botManagement?.score;
|
|
4051
|
-
const cfBotScore = botScoreStr !== undefined ? parseInt(botScoreStr) : 100;
|
|
4052
|
-
const ua = (request.headers.get('User-Agent') || '').toLowerCase();
|
|
4053
|
-
const isBotPattern = /bot|crawler|spider|lighthouse|postman|curl|inspect|headless/i.test(ua);
|
|
4054
|
-
|
|
4055
|
-
const isBot = cfBotScore < 30 || isBotPattern;
|
|
4056
|
-
payload.botScore = isBot ? 2 : (cfBotScore < 60 ? 1 : 0);
|
|
4057
|
-
|
|
4058
|
-
// Dropar silenciosamente eventos de lixo (exceto conversões core para evitar falso positivo)
|
|
4059
|
-
if (isBot && !['Contact', 'Lead', 'Purchase'].includes(eventName)) {
|
|
4060
|
-
return new Response(JSON.stringify({ ok: true, skipped_due_to_bot: true }), { status: 200, headers });
|
|
4061
|
-
}
|
|
4062
|
-
|
|
4063
|
-
// ── Edge Geo Enrichment ───────────────────────────────────────────────────
|
|
4064
|
-
// Free: country, continent, asn | Paid: city, state, zip, lat/lon, timezone
|
|
4065
|
-
const geoData = await enrichGeoFromEdge(request, env, payload);
|
|
4066
|
-
|
|
4067
|
-
// ── First-Party Cookie (Identity Resolution) ──────────────────────────────
|
|
4068
|
-
const cookieHeader = request.headers.get('Cookie') || '';
|
|
4069
|
-
const cdpUidMatch = cookieHeader.match(/_cdp_uid=([^;]+)/);
|
|
4070
|
-
const finalUserId = payload.userId || (cdpUidMatch ? cdpUidMatch[1] : crypto.randomUUID());
|
|
4071
|
-
payload.userId = finalUserId;
|
|
4072
|
-
|
|
4073
|
-
const ga4Name = META_TO_GA4[eventName] || eventName.toLowerCase();
|
|
4074
|
-
|
|
4075
|
-
// ── LTV Prediction (+ A/B Testing de Prompts) ────────────────────────────
|
|
4076
|
-
// Lead, Contact, Schedule: sem valor monetário real → injetar LTV preditivo
|
|
4077
|
-
// Isso treina os algoritmos de Meta/TikTok a priorizar leads de alto valor
|
|
4078
|
-
const LTV_EVENTS = ['Lead', 'Contact', 'Schedule', 'CompleteRegistration'];
|
|
4079
|
-
if (LTV_EVENTS.includes(eventName) && !payload.value) {
|
|
4080
|
-
// A/B Testing: busca variação ativa (usa KV cache — ~0ms de latência extra)
|
|
4081
|
-
const abVariation = await getLtvAbVariation(env);
|
|
4082
|
-
const ltv = await predictLtv(env, payload, request, abVariation?.system_prompt || null);
|
|
4083
|
-
payload.value = ltv.value;
|
|
4084
|
-
payload.currency = payload.currency || 'BRL';
|
|
4085
|
-
payload.ltvClass = ltv.class;
|
|
4086
|
-
payload.ltvScore = ltv.score;
|
|
4087
|
-
// Persiste no perfil em background
|
|
4088
|
-
ctx.waitUntil(
|
|
4089
|
-
upsertLtvProfile(env, payload.userId, ltv)
|
|
4090
|
-
);
|
|
4091
|
-
// Registrar assignment do A/B test em background (não bloqueia)
|
|
4092
|
-
if (abVariation) {
|
|
4093
|
-
const emailHash = payload.email
|
|
4094
|
-
? await sha256(payload.email.trim().toLowerCase())
|
|
4095
|
-
: null;
|
|
4096
|
-
ctx.waitUntil(
|
|
4097
|
-
recordAbAssignment(
|
|
4098
|
-
env,
|
|
4099
|
-
payload.userId,
|
|
4100
|
-
abVariation.variation_id,
|
|
4101
|
-
abVariation.test_id,
|
|
4102
|
-
ltv.value,
|
|
4103
|
-
ltv.class,
|
|
4104
|
-
emailHash,
|
|
4105
|
-
)
|
|
4106
|
-
);
|
|
4107
|
-
}
|
|
4108
|
-
}
|
|
4109
|
-
|
|
4110
|
-
// Cross-Device Graph — background (não bloqueia resposta)
|
|
4111
|
-
// Só dispara quando tem PII e um userId confirmado
|
|
4112
|
-
if (env.DB && payload.userId && (payload.email || payload.phone)) {
|
|
4113
|
-
ctx.waitUntil(
|
|
4114
|
-
resolveDeviceGraph(env.DB, payload.userId, payload.email, payload.phone)
|
|
4115
|
-
);
|
|
4116
|
-
}
|
|
4117
|
-
|
|
4118
|
-
// ── R2 Audit Log — background, não bloqueia ──────────────────────────────
|
|
4119
|
-
ctx.waitUntil(writeAuditLog(env, eventName, payload, geoData));
|
|
4120
|
-
|
|
4121
|
-
// Disparar tudo em paralelo — não bloquear o browser
|
|
4122
|
-
// WhatsApp: só notifica Lead e Purchase para não lotar o celular
|
|
4123
|
-
const WHATSAPP_NOTIFY_EVENTS = ['Lead', 'Purchase', 'CompleteRegistration'];
|
|
4124
|
-
const [metaRes, ga4Res, ttRes] = await Promise.allSettled([
|
|
4125
|
-
sendMetaCapi(env, eventName, payload, request, ctx),
|
|
4126
|
-
sendGA4Mp(env, ga4Name, payload, ctx),
|
|
4127
|
-
sendTikTokApi(env, eventName, payload, request, ctx),
|
|
4128
|
-
saveLead(env, eventName, payload, request, 'website'),
|
|
4129
|
-
upsertProfile(env, eventName, payload, request),
|
|
4130
|
-
...(WHATSAPP_NOTIFY_EVENTS.includes(eventName)
|
|
4131
|
-
? [sendWhatsApp(env, eventName, payload)]
|
|
4132
|
-
: []),
|
|
4133
|
-
]);
|
|
4134
|
-
|
|
4135
|
-
// Automação de mensagens — dispara regras ativas para este evento em background
|
|
4136
|
-
// saveLead() já foi chamado acima; usamos o leadId gerado pelo D1 (last_row_id)
|
|
4137
|
-
const AUTOMATION_EVENTS = ['Lead', 'Purchase', 'InitiateCheckout'];
|
|
4138
|
-
if (AUTOMATION_EVENTS.includes(eventName) && env.DB) {
|
|
4139
|
-
ctx.waitUntil(
|
|
4140
|
-
(async () => {
|
|
4141
|
-
try {
|
|
4142
|
-
const lastLead = await env.DB
|
|
4143
|
-
.prepare(`SELECT id FROM leads WHERE event_id = ?1 LIMIT 1`)
|
|
4144
|
-
.bind(payload.eventId || payload.event_id || '')
|
|
4145
|
-
.first();
|
|
4146
|
-
const leadId = lastLead?.id ?? null;
|
|
4147
|
-
if (leadId) await fireAutomation(env, eventName, leadId, payload);
|
|
4148
|
-
} catch (e) { console.error('[Automation] lead lookup error:', e.message); }
|
|
4149
|
-
})()
|
|
4150
|
-
);
|
|
4151
|
-
}
|
|
4152
|
-
|
|
4153
|
-
// ── Edge Personalization (Retornar Score) ───────────────────────────────
|
|
4154
|
-
let currentScore = 0;
|
|
4155
|
-
if (env.DB && payload.userId) {
|
|
4156
|
-
try {
|
|
4157
|
-
const profileRow = await env.DB.prepare('SELECT score FROM user_profiles WHERE user_id = ?').bind(payload.userId).first();
|
|
4158
|
-
if (profileRow) currentScore = profileRow.score;
|
|
4159
|
-
} catch(e) {}
|
|
4160
|
-
}
|
|
4161
|
-
|
|
4162
|
-
const resHeaders = new Headers(headers);
|
|
4163
|
-
resHeaders.set('Set-Cookie', `_cdp_uid=${finalUserId}; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000; Path=/; Domain=.${env.SITE_DOMAIN}`);
|
|
4164
|
-
|
|
4165
|
-
return new Response(JSON.stringify({
|
|
4166
|
-
ok: true,
|
|
4167
|
-
userProfile: { score: currentScore, user_id: finalUserId },
|
|
4168
|
-
meta: metaRes.value ?? { error: metaRes.reason?.message },
|
|
4169
|
-
ga4: ga4Res.value ?? { error: ga4Res.reason?.message },
|
|
4170
|
-
tiktok: ttRes.value ?? { error: ttRes.reason?.message },
|
|
4171
|
-
}), { status: 200, headers: resHeaders });
|
|
4172
|
-
}
|
|
4173
|
-
|
|
4174
|
-
// ── POST /webhook/hotmart ─────────────────────────────────────────────────
|
|
4175
|
-
if (request.method === 'POST' && url.pathname === '/webhook/hotmart') {
|
|
4176
|
-
// Validação de token Hotmart (X-Hotmart-Webhook-Token)
|
|
4177
|
-
// Secret: wrangler secret put WEBHOOK_SECRET_HOTMART
|
|
4178
|
-
if (env.WEBHOOK_SECRET_HOTMART) {
|
|
4179
|
-
const token = request.headers.get('X-Hotmart-Webhook-Token') || '';
|
|
4180
|
-
if (token !== env.WEBHOOK_SECRET_HOTMART) {
|
|
4181
|
-
return new Response('Unauthorized', { status: 401 });
|
|
4182
|
-
}
|
|
4183
|
-
}
|
|
4184
|
-
|
|
4185
|
-
let wh;
|
|
4186
|
-
try { wh = await request.json(); } catch {
|
|
4187
|
-
return new Response('JSON inválido', { status: 400 });
|
|
4188
|
-
}
|
|
4189
|
-
|
|
4190
|
-
const data = wh.data || wh;
|
|
4191
|
-
const buyer = data.buyer || {};
|
|
4192
|
-
const purchase = data.purchase || {};
|
|
4193
|
-
const product = data.product || {};
|
|
4194
|
-
|
|
4195
|
-
// Só processar compras aprovadas
|
|
4196
|
-
if (!['APPROVED', 'COMPLETE', 'approved', 'complete'].includes(purchase.status)) {
|
|
4197
|
-
return new Response(
|
|
4198
|
-
JSON.stringify({ skipped: `status ${purchase.status}` }),
|
|
4199
|
-
{ status: 200, headers }
|
|
4200
|
-
);
|
|
4201
|
-
}
|
|
4202
|
-
|
|
4203
|
-
// Deduplicação — verificar se transação já foi processada
|
|
4204
|
-
const hmTxId = String(purchase.transaction || '');
|
|
4205
|
-
if (hmTxId && env.DB) {
|
|
4206
|
-
try {
|
|
4207
|
-
const dup = await env.DB.prepare(
|
|
4208
|
-
'SELECT id FROM webhook_events WHERE transaction_id = ? AND status = ?'
|
|
4209
|
-
).bind(hmTxId, 'processed').first();
|
|
4210
|
-
if (dup) {
|
|
4211
|
-
return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
|
|
4212
|
-
}
|
|
4213
|
-
} catch { /* continua mesmo se a consulta falhar */ }
|
|
4214
|
-
}
|
|
4215
|
-
|
|
4216
|
-
// Recuperar cookies do comprador (fbp/fbc) pelo email
|
|
4217
|
-
const profile = await getProfileByEmail(env, buyer.email);
|
|
4218
|
-
|
|
4219
|
-
const payload = {
|
|
4220
|
-
email: buyer.email,
|
|
4221
|
-
phone: buyer.phone,
|
|
4222
|
-
firstName: buyer.name?.split(' ')[0],
|
|
4223
|
-
lastName: buyer.name?.split(' ').slice(1).join(' ') || undefined,
|
|
4224
|
-
fbp: profile?.fbp,
|
|
4225
|
-
fbc: profile?.fbc,
|
|
4226
|
-
userId: profile?.user_id,
|
|
4227
|
-
gaClientId: profile?.ga_client_id,
|
|
4228
|
-
value: purchase.price?.value,
|
|
4229
|
-
currency: purchase.price?.currency_value || 'BRL',
|
|
4230
|
-
contentIds: [String(product.id || product.ucode || '')],
|
|
4231
|
-
contentName: product.name,
|
|
4232
|
-
contentType: 'product',
|
|
4233
|
-
pageUrl: profile?.page_url || `https://${env.SITE_DOMAIN || 'seu-dominio.com.br'}`,
|
|
4234
|
-
orderId: purchase.transaction,
|
|
4235
|
-
eventId: `hotmart_${purchase.transaction}`,
|
|
4236
|
-
city: profile?.city,
|
|
4237
|
-
state: profile?.state,
|
|
4238
|
-
country: profile?.country,
|
|
4239
|
-
};
|
|
4240
|
-
|
|
4241
|
-
// Registrar transação no D1 (prevenção de duplicatas em reenvios)
|
|
4242
|
-
if (hmTxId && env.DB) {
|
|
4243
|
-
try {
|
|
4244
|
-
await env.DB.prepare(
|
|
4245
|
-
'INSERT OR IGNORE INTO webhook_events (platform, transaction_id, email, status, raw_payload) VALUES (?,?,?,?,?)'
|
|
4246
|
-
).bind('hotmart', hmTxId, buyer.email || null, 'processed', JSON.stringify(wh)).run();
|
|
4247
|
-
} catch { /* não bloquear envio se D1 falhar */ }
|
|
4248
|
-
}
|
|
4249
|
-
|
|
4250
|
-
ctx.waitUntil(Promise.allSettled([
|
|
4251
|
-
sendMetaCapi(env, 'Purchase', payload, request, ctx),
|
|
4252
|
-
sendGA4Mp(env, 'purchase', payload, ctx),
|
|
4253
|
-
sendTikTokApi(env, 'CompletePayment', payload, request, ctx),
|
|
4254
|
-
saveLead(env, 'Purchase', payload, request, 'hotmart'),
|
|
4255
|
-
sendWhatsApp(env, 'Purchase', payload),
|
|
4256
|
-
]));
|
|
4257
|
-
|
|
4258
|
-
return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
|
|
4259
|
-
}
|
|
4260
|
-
|
|
4261
|
-
// ── POST /webhook/kiwify ──────────────────────────────────────────────────
|
|
4262
|
-
if (request.method === 'POST' && url.pathname === '/webhook/kiwify') {
|
|
4263
|
-
// Validação de token Kiwify (X-Kiwify-Event-Token)
|
|
4264
|
-
// Secret: wrangler secret put WEBHOOK_SECRET_KIWIFY
|
|
4265
|
-
if (env.WEBHOOK_SECRET_KIWIFY) {
|
|
4266
|
-
const token = request.headers.get('X-Kiwify-Event-Token') || '';
|
|
4267
|
-
if (token !== env.WEBHOOK_SECRET_KIWIFY) {
|
|
4268
|
-
return new Response('Unauthorized', { status: 401 });
|
|
4269
|
-
}
|
|
4270
|
-
}
|
|
4271
|
-
|
|
4272
|
-
let wh;
|
|
4273
|
-
try { wh = await request.json(); } catch {
|
|
4274
|
-
return new Response('JSON inválido', { status: 400 });
|
|
4275
|
-
}
|
|
4276
|
-
|
|
4277
|
-
// Só processar compras aprovadas
|
|
4278
|
-
if (wh.order_status !== 'paid' && wh.order_status !== 'approved') {
|
|
4279
|
-
return new Response(
|
|
4280
|
-
JSON.stringify({ skipped: `status ${wh.order_status}` }),
|
|
4281
|
-
{ status: 200, headers }
|
|
4282
|
-
);
|
|
4283
|
-
}
|
|
4284
|
-
|
|
4285
|
-
// Deduplicação — verificar se transação já foi processada
|
|
4286
|
-
const kwTxId = String(wh.order_id || '');
|
|
4287
|
-
if (kwTxId && env.DB) {
|
|
4288
|
-
try {
|
|
4289
|
-
const dup = await env.DB.prepare(
|
|
4290
|
-
'SELECT id FROM webhook_events WHERE transaction_id = ? AND status = ?'
|
|
4291
|
-
).bind(kwTxId, 'processed').first();
|
|
4292
|
-
if (dup) {
|
|
4293
|
-
return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
|
|
4294
|
-
}
|
|
4295
|
-
} catch { /* continua mesmo se a consulta falhar */ }
|
|
4296
|
-
}
|
|
4297
|
-
|
|
4298
|
-
const customer = wh.Customer || {};
|
|
4299
|
-
const product = wh.Product || {};
|
|
4300
|
-
const profile = await getProfileByEmail(env, customer.email);
|
|
4301
|
-
|
|
4302
|
-
const payload = {
|
|
4303
|
-
email: customer.email,
|
|
4304
|
-
phone: customer.mobile,
|
|
4305
|
-
firstName: customer.full_name?.split(' ')[0],
|
|
4306
|
-
lastName: customer.full_name?.split(' ').slice(1).join(' ') || undefined,
|
|
4307
|
-
fbp: profile?.fbp,
|
|
4308
|
-
fbc: profile?.fbc,
|
|
4309
|
-
userId: profile?.user_id,
|
|
4310
|
-
gaClientId: profile?.ga_client_id,
|
|
4311
|
-
value: wh.order_value ? parseFloat(wh.order_value) / 100 : undefined,
|
|
4312
|
-
currency: 'BRL',
|
|
4313
|
-
contentIds: [String(product.product_id || '')],
|
|
4314
|
-
contentName: product.product_name,
|
|
4315
|
-
contentType: 'product',
|
|
4316
|
-
pageUrl: profile?.page_url || `https://${env.SITE_DOMAIN || 'seu-dominio.com.br'}`,
|
|
4317
|
-
orderId: wh.order_id,
|
|
4318
|
-
eventId: `kiwify_${wh.order_id}`,
|
|
4319
|
-
city: profile?.city,
|
|
4320
|
-
state: profile?.state,
|
|
4321
|
-
country: profile?.country,
|
|
4322
|
-
};
|
|
4323
|
-
|
|
4324
|
-
// Registrar transação no D1
|
|
4325
|
-
if (kwTxId && env.DB) {
|
|
4326
|
-
try {
|
|
4327
|
-
await env.DB.prepare(
|
|
4328
|
-
'INSERT OR IGNORE INTO webhook_events (platform, transaction_id, email, status, raw_payload) VALUES (?,?,?,?,?)'
|
|
4329
|
-
).bind('kiwify', kwTxId, customer.email || null, 'processed', JSON.stringify(wh)).run();
|
|
4330
|
-
} catch { /* não bloquear envio se D1 falhar */ }
|
|
4331
|
-
}
|
|
4332
|
-
|
|
4333
|
-
ctx.waitUntil(Promise.allSettled([
|
|
4334
|
-
sendMetaCapi(env, 'Purchase', payload, request, ctx),
|
|
4335
|
-
sendGA4Mp(env, 'purchase', payload, ctx),
|
|
4336
|
-
sendTikTokApi(env, 'CompletePayment', payload, request, ctx),
|
|
4337
|
-
sendPinterestCapi(env, 'Purchase', payload, request, ctx),
|
|
4338
|
-
sendRedditCapi(env, 'Purchase', payload, request, ctx),
|
|
4339
|
-
sendLinkedInCapi(env, 'Purchase', payload, request, ctx),
|
|
4340
|
-
sendSpotifyCapi(env, 'Purchase', payload, request, ctx),
|
|
4341
|
-
saveLead(env, 'Purchase', payload, request, 'kiwify'),
|
|
4342
|
-
sendWhatsApp(env, 'Purchase', payload),
|
|
4343
|
-
]));
|
|
4344
|
-
|
|
4345
|
-
return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
|
|
4346
|
-
}
|
|
4347
|
-
|
|
4348
|
-
// ── POST /webhook/ticto ───────────────────────────────────────────────────
|
|
4349
|
-
// Ticto Webhook v2 (JSON) — configurar em: Produto → Webhooks → Versão 2.0 → JSON
|
|
4350
|
-
// URL a cadastrar na Ticto: https://SEU_DOMINIO/webhook/ticto
|
|
4351
|
-
// Evento a selecionar: "Venda Realizada" (status: paid | approved | complete)
|
|
4352
|
-
if (request.method === 'POST' && url.pathname === '/webhook/ticto') {
|
|
4353
|
-
// Validação HMAC-SHA256 Ticto (X-Ticto-Signature)
|
|
4354
|
-
// Secret: wrangler secret put WEBHOOK_SECRET_TICTO
|
|
4355
|
-
let rawBody;
|
|
4356
|
-
try { rawBody = await request.text(); } catch {
|
|
4357
|
-
return new Response('Leitura de body falhou', { status: 400 });
|
|
4358
|
-
}
|
|
4359
|
-
if (env.WEBHOOK_SECRET_TICTO) {
|
|
4360
|
-
const sig = request.headers.get('X-Ticto-Signature') || '';
|
|
4361
|
-
const valid = await verifyHmac(env.WEBHOOK_SECRET_TICTO, rawBody, sig);
|
|
4362
|
-
if (!valid) {
|
|
4363
|
-
return new Response('Unauthorized', { status: 401 });
|
|
4364
|
-
}
|
|
4365
|
-
}
|
|
4366
|
-
|
|
4367
|
-
let wh;
|
|
4368
|
-
try { wh = JSON.parse(rawBody); } catch {
|
|
4369
|
-
return new Response('JSON inválido', { status: 400 });
|
|
4370
|
-
}
|
|
4371
|
-
|
|
4372
|
-
// ── Estrutura Ticto v2 ────────────────────────────────────────────────
|
|
4373
|
-
// {
|
|
4374
|
-
// "version": "2.0",
|
|
4375
|
-
// "status": "paid", ← paid | approved | complete | refunded | chargeback
|
|
4376
|
-
// "status_date": "...",
|
|
4377
|
-
// "token": "...",
|
|
4378
|
-
// "payment_method": "credit_card | boleto | pix",
|
|
4379
|
-
// "customer": {
|
|
4380
|
-
// "name": "João Silva",
|
|
4381
|
-
// "email": "joao@email.com",
|
|
4382
|
-
// "phone": "11999998888",
|
|
4383
|
-
// "document": "12345678901" ← CPF (não enviamos para plataformas de ads)
|
|
4384
|
-
// },
|
|
4385
|
-
// "order": {
|
|
4386
|
-
// "id": "ORD123",
|
|
4387
|
-
// "hash": "abc123",
|
|
4388
|
-
// "transaction_hash": "xyz456",
|
|
4389
|
-
// "paid_amount": 29700, ← valor em centavos (R$ 297,00)
|
|
4390
|
-
// "installments": 1,
|
|
4391
|
-
// "order_date": "2024-01-01"
|
|
4392
|
-
// },
|
|
4393
|
-
// "item": {
|
|
4394
|
-
// "product_name": "Curso XYZ",
|
|
4395
|
-
// "product_id": "PROD123"
|
|
4396
|
-
// },
|
|
4397
|
-
// "tracking": { ← parâmetros de URL capturados no checkout
|
|
4398
|
-
// "src": "facebook",
|
|
4399
|
-
// "utm_source": "...",
|
|
4400
|
-
// "utm_medium": "...",
|
|
4401
|
-
// "utm_campaign": "...",
|
|
4402
|
-
// "utm_content": "...",
|
|
4403
|
-
// "utm_term": "..."
|
|
4404
|
-
// },
|
|
4405
|
-
// "url_params": { ← fallback de parâmetros extras (fbclid, sck, xcod...)
|
|
4406
|
-
// "fbclid": "...",
|
|
4407
|
-
// "sck": "..."
|
|
4408
|
-
// }
|
|
4409
|
-
// }
|
|
4410
|
-
|
|
4411
|
-
// Aceitar apenas vendas aprovadas/pagas
|
|
4412
|
-
const STATUS_PAID = ['paid', 'approved', 'complete', 'completed'];
|
|
4413
|
-
if (!STATUS_PAID.includes((wh.status || '').toLowerCase())) {
|
|
4414
|
-
return new Response(
|
|
4415
|
-
JSON.stringify({ skipped: `status ${wh.status}` }),
|
|
4416
|
-
{ status: 200, headers }
|
|
4417
|
-
);
|
|
4418
|
-
}
|
|
4419
|
-
|
|
4420
|
-
const customer = wh.customer || {};
|
|
4421
|
-
const order = wh.order || {};
|
|
4422
|
-
const item = wh.item || {};
|
|
4423
|
-
const tracking = wh.tracking || wh.url_params || {};
|
|
4424
|
-
|
|
4425
|
-
// Valor: paid_amount está em centavos → dividir por 100
|
|
4426
|
-
const valueRaw = order.paid_amount ?? order.total ?? order.amount;
|
|
4427
|
-
const value = valueRaw ? parseFloat(valueRaw) / 100 : undefined;
|
|
4428
|
-
|
|
4429
|
-
// Transaction ID: usar hash se disponível (mais estável que id numérico)
|
|
4430
|
-
const transactionId = order.hash || order.transaction_hash || order.id;
|
|
4431
|
-
|
|
4432
|
-
// Deduplicação — verificar se transação já foi processada
|
|
4433
|
-
const tcTxId = String(order.hash || order.transaction_hash || order.id || '');
|
|
4434
|
-
if (tcTxId && env.DB) {
|
|
4435
|
-
try {
|
|
4436
|
-
const dup = await env.DB.prepare(
|
|
4437
|
-
'SELECT id FROM webhook_events WHERE transaction_id = ? AND status = ?'
|
|
4438
|
-
).bind(tcTxId, 'processed').first();
|
|
4439
|
-
if (dup) {
|
|
4440
|
-
return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
|
|
4441
|
-
}
|
|
4442
|
-
} catch { /* continua mesmo se a consulta falhar */ }
|
|
4443
|
-
}
|
|
4444
|
-
|
|
4445
|
-
// Buscar perfil do comprador pelo email; fallback por user_id passado via URL (cdpTrack passCheckoutParams)
|
|
4446
|
-
const urlUserId = tracking.user_id || wh.url_params?.user_id;
|
|
4447
|
-
let profile = await getProfileByEmail(env, customer.email);
|
|
4448
|
-
if (!profile && urlUserId && env.DB) {
|
|
4449
|
-
try {
|
|
4450
|
-
profile = await env.DB.prepare(
|
|
4451
|
-
'SELECT * FROM user_profiles WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1'
|
|
4452
|
-
).bind(urlUserId).first();
|
|
4453
|
-
} catch { /* continua sem perfil */ }
|
|
4454
|
-
}
|
|
4455
|
-
|
|
4456
|
-
// Construir fbc a partir do fbclid se o profile não tiver fbc
|
|
4457
|
-
const fbclid = tracking.fbclid || wh.url_params?.fbclid;
|
|
4458
|
-
const fbc = profile?.fbc || (fbclid ? `fb.1.${Date.now()}.${fbclid}` : undefined);
|
|
4459
|
-
|
|
4460
|
-
const payload = {
|
|
4461
|
-
email: customer.email,
|
|
4462
|
-
phone: customer.phone,
|
|
4463
|
-
firstName: customer.name?.split(' ')[0],
|
|
4464
|
-
lastName: customer.name?.split(' ').slice(1).join(' ') || undefined,
|
|
4465
|
-
fbp: profile?.fbp,
|
|
4466
|
-
fbc,
|
|
4467
|
-
ttp: profile?.ttp,
|
|
4468
|
-
userId: profile?.user_id,
|
|
4469
|
-
gaClientId: profile?.ga_client_id,
|
|
4470
|
-
value,
|
|
4471
|
-
currency: 'BRL',
|
|
4472
|
-
contentIds: [String(item.product_id || '')],
|
|
4473
|
-
contentName: item.product_name,
|
|
4474
|
-
contentType: 'product',
|
|
4475
|
-
pageUrl: profile?.page_url || `https://${env.SITE_DOMAIN || 'seu-dominio.com.br'}`,
|
|
4476
|
-
orderId: transactionId,
|
|
4477
|
-
eventId: `ticto_${transactionId}`,
|
|
4478
|
-
city: profile?.city,
|
|
4479
|
-
state: profile?.state,
|
|
4480
|
-
country: profile?.country || 'br',
|
|
4481
|
-
utmSource: tracking.utm_source || tracking.src || '',
|
|
4482
|
-
utmMedium: tracking.utm_medium || '',
|
|
4483
|
-
utmCampaign: tracking.utm_campaign || '',
|
|
4484
|
-
utmContent: tracking.utm_content || '',
|
|
4485
|
-
};
|
|
4486
|
-
|
|
4487
|
-
// Registrar transação no D1
|
|
4488
|
-
if (tcTxId && env.DB) {
|
|
4489
|
-
try {
|
|
4490
|
-
await env.DB.prepare(
|
|
4491
|
-
'INSERT OR IGNORE INTO webhook_events (platform, transaction_id, email, status, raw_payload) VALUES (?,?,?,?,?)'
|
|
4492
|
-
).bind('ticto', tcTxId, customer.email || null, 'processed', JSON.stringify(wh)).run();
|
|
4493
|
-
} catch { /* não bloquear envio se D1 falhar */ }
|
|
4494
|
-
}
|
|
4495
|
-
|
|
4496
|
-
ctx.waitUntil(Promise.allSettled([
|
|
4497
|
-
sendMetaCapi(env, 'Purchase', payload, request, ctx),
|
|
4498
|
-
sendGA4Mp(env, 'purchase', payload, ctx),
|
|
4499
|
-
sendTikTokApi(env, 'CompletePayment', payload, request, ctx),
|
|
4500
|
-
sendPinterestCapi(env, 'Purchase', payload, request, ctx),
|
|
4501
|
-
sendRedditCapi(env, 'Purchase', payload, request, ctx),
|
|
4502
|
-
sendLinkedInCapi(env, 'Purchase', payload, request, ctx),
|
|
4503
|
-
sendSpotifyCapi(env, 'Purchase', payload, request, ctx),
|
|
4504
|
-
saveLead(env, 'Purchase', payload, request, 'ticto'),
|
|
4505
|
-
sendWhatsApp(env, 'Purchase', payload),
|
|
4506
|
-
]));
|
|
4507
|
-
|
|
4508
|
-
return new Response(JSON.stringify({ ok: true }), { status: 200, headers });
|
|
4509
|
-
}
|
|
4510
|
-
|
|
4511
|
-
// ── GET /webhook/whatsapp — verificação do webhook pela Meta ─────────────────
|
|
4512
|
-
// A Meta faz um GET nessa URL quando você cadastra o webhook no Business Manager.
|
|
4513
|
-
// Ela envia: hub.mode=subscribe, hub.verify_token=<seu token>, hub.challenge=<número>
|
|
4514
|
-
// Você responde com hub.challenge para confirmar que é seu servidor.
|
|
4515
|
-
// Secret: wrangler secret put WA_WEBHOOK_VERIFY_TOKEN
|
|
4516
|
-
if (request.method === 'GET' && url.pathname === '/webhook/whatsapp') {
|
|
4517
|
-
const mode = url.searchParams.get('hub.mode');
|
|
4518
|
-
const token = url.searchParams.get('hub.verify_token');
|
|
4519
|
-
const challenge = url.searchParams.get('hub.challenge');
|
|
4520
|
-
|
|
4521
|
-
if (mode === 'subscribe' && env.WA_WEBHOOK_VERIFY_TOKEN && token === env.WA_WEBHOOK_VERIFY_TOKEN) {
|
|
4522
|
-
return new Response(challenge, { status: 200, headers: { 'Content-Type': 'text/plain' } });
|
|
4523
|
-
}
|
|
4524
|
-
return new Response('Forbidden', { status: 403 });
|
|
4525
|
-
}
|
|
4526
|
-
|
|
4527
|
-
// ── POST /webhook/whatsapp — mensagens recebidas (CTWA) ───────────────────────
|
|
4528
|
-
// Recebe eventos da Meta Cloud API: mensagens de usuários que clicaram em
|
|
4529
|
-
// anúncios "Click to WhatsApp". Extrai phone + ctwa_clid e dispara Contact
|
|
4530
|
-
// no Meta CAPI com action_source="chat".
|
|
4531
|
-
// URL a cadastrar: Meta Business Manager → WhatsApp → Configuration → Webhook URL
|
|
4532
|
-
// Campos a assinar (subscribe): messages
|
|
4533
|
-
if (request.method === 'POST' && url.pathname === '/webhook/whatsapp') {
|
|
4534
|
-
let body;
|
|
4535
|
-
try { body = await request.json(); } catch {
|
|
4536
|
-
return new Response('JSON inválido', { status: 400 });
|
|
4537
|
-
}
|
|
4538
|
-
|
|
4539
|
-
const result = await processWhatsAppWebhook(env, body, request, ctx);
|
|
4540
|
-
|
|
4541
|
-
// A Meta exige resposta 200 em até 20s — mesmo que não haja nada a processar
|
|
4542
|
-
return new Response(JSON.stringify({ ok: true, ...result }), { status: 200, headers });
|
|
4543
|
-
}
|
|
4544
|
-
|
|
4545
|
-
// ── Segmentação Dinâmica ML ──────────────────────────────────────────
|
|
4546
|
-
if (url.pathname === '/api/segmentation/cluster' && request.method === 'POST') {
|
|
4547
|
-
return handleSegmentationCluster(env, request, headers);
|
|
4548
|
-
}
|
|
4549
|
-
if (url.pathname === '/api/segmentation/list' && request.method === 'GET') {
|
|
4550
|
-
return handleSegmentationList(env, request, headers);
|
|
4551
|
-
}
|
|
4552
|
-
if (url.pathname === '/api/segmentation/outliers' && request.method === 'GET') {
|
|
4553
|
-
return handleSegmentationOutliers(env, request, headers);
|
|
4554
|
-
}
|
|
4555
|
-
if (url.pathname === '/api/segmentation/update' && request.method === 'PUT') {
|
|
4556
|
-
return handleSegmentationUpdate(env, request, headers);
|
|
4557
|
-
}
|
|
4558
|
-
|
|
4559
|
-
// ── Bidding Recommendations ML ────────────────────────────────────────────
|
|
4560
|
-
if (url.pathname === '/api/bidding/recommend' && request.method === 'POST') {
|
|
4561
|
-
return handleBiddingRecommend(env, request, headers);
|
|
4562
|
-
}
|
|
4563
|
-
if (url.pathname === '/api/bidding/history' && request.method === 'GET') {
|
|
4564
|
-
return handleBiddingHistory(env, request, headers);
|
|
4565
|
-
}
|
|
4566
|
-
if (url.pathname === '/api/bidding/status' && request.method === 'GET') {
|
|
4567
|
-
return handleBiddingStatus(env, request, headers);
|
|
4568
|
-
}
|
|
4569
|
-
|
|
4570
|
-
// ── A/B Testing de Prompts LTV ────────────────────────────────────────────
|
|
4571
|
-
if (url.pathname === '/api/ltv/ab-test/create' && request.method === 'POST') {
|
|
4572
|
-
return handleLtvAbTestCreate(env, request, headers);
|
|
4573
|
-
}
|
|
4574
|
-
if (url.pathname === '/api/ltv/ab-test/list' && request.method === 'GET') {
|
|
4575
|
-
return handleLtvAbTestList(env, request, headers);
|
|
4576
|
-
}
|
|
4577
|
-
if (url.pathname === '/api/ltv/ab-test/results' && request.method === 'GET') {
|
|
4578
|
-
return handleLtvAbTestResults(env, request, headers);
|
|
4579
|
-
}
|
|
4580
|
-
if (url.pathname === '/api/ltv/ab-test/winner' && request.method === 'POST') {
|
|
4581
|
-
return handleLtvAbTestWinner(env, request, headers);
|
|
4582
|
-
}
|
|
4583
|
-
|
|
4584
|
-
// ── Fraud Detection — Fase 4 ──────────────────────────────────────────────
|
|
4585
|
-
if (url.pathname === '/api/fraud/alerts' && request.method === 'GET') {
|
|
4586
|
-
return handleFraudAlerts(env, request, headers);
|
|
4587
|
-
}
|
|
4588
|
-
if (url.pathname === '/api/fraud/blocklist' && request.method === 'GET') {
|
|
4589
|
-
return handleFraudBlocklist(env, request, headers);
|
|
4590
|
-
}
|
|
4591
|
-
if (url.pathname === '/api/fraud/blocklist/add' && request.method === 'POST') {
|
|
4592
|
-
return handleFraudBlocklistAdd(env, request, headers);
|
|
4593
|
-
}
|
|
4594
|
-
if (url.pathname === '/api/fraud/blocklist/remove' && request.method === 'DELETE') {
|
|
4595
|
-
return handleFraudBlocklistRemove(env, request, headers);
|
|
4596
|
-
}
|
|
4597
|
-
if (url.pathname === '/api/fraud/stats' && request.method === 'GET') {
|
|
4598
|
-
return handleFraudStats(env, request, headers);
|
|
4599
|
-
}
|
|
4600
|
-
|
|
4601
|
-
// 404 para rotas não encontradas
|
|
4602
|
-
return new Response(
|
|
4603
|
-
JSON.stringify({ error: 'rota não encontrada' }),
|
|
4604
|
-
{ status: 404, headers }
|
|
4605
|
-
);
|
|
4606
|
-
},
|
|
4607
|
-
|
|
4608
|
-
// ── Cron Handler — Intelligence Agent ──────────────────────────────────────
|
|
4609
|
-
async scheduled(event, env, ctx) {
|
|
4610
|
-
const cron = event.cron; // '0 2 * * 0' ou '0 3 1 * *'
|
|
4611
|
-
const isMonthly = cron === '0 3 1 * *';
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
ctx.waitUntil(runIntelligenceAgent(env, isMonthly ? 'monthly_audit' : 'weekly_check'));
|
|
4615
|
-
},
|
|
4616
|
-
|
|
4617
|
-
// ── Queue Consumer — Retry de eventos com falha ────────────────────────────
|
|
4618
|
-
async queue(batch, env) {
|
|
4619
|
-
for (const message of batch.messages) {
|
|
4620
|
-
const { eventType, payload, platform, attempt = 1 } = message.body;
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
try {
|
|
4624
|
-
if (platform === 'meta') await sendMetaCapi(env, eventType, payload, null, null);
|
|
4625
|
-
if (platform === 'ga4') await sendGA4Mp(env, eventType, payload, null);
|
|
4626
|
-
if (platform === 'tiktok') await sendTikTokApi(env, eventType, payload, null, null);
|
|
4627
|
-
|
|
4628
|
-
message.ack(); // sucesso — remove da fila
|
|
4629
|
-
} catch (err) {
|
|
4630
|
-
console.error(`[Queue] Falha ao reprocessar ${platform}/${eventType}:`, err.message);
|
|
4631
|
-
message.retry(); // reenfileira até max_retries, depois vai para DLQ
|
|
4632
|
-
}
|
|
4633
|
-
}
|
|
4634
|
-
},
|
|
4635
|
-
};
|