cdp-edge 1.12.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +195 -279
- package/docs/whatsapp-ctwa.md +2 -2
- package/extracted-skill/tracking-events-generator/agents/ab-ltv-agent.md +196 -0
- package/extracted-skill/tracking-events-generator/agents/bidding-agent.md +347 -0
- package/extracted-skill/tracking-events-generator/agents/database-agent.md +7 -7
- package/extracted-skill/tracking-events-generator/agents/devops-agent.md +157 -0
- package/extracted-skill/tracking-events-generator/agents/domain-setup-agent.md +10 -4
- package/extracted-skill/tracking-events-generator/agents/fraud-detection-agent.md +142 -0
- package/extracted-skill/tracking-events-generator/agents/master-orchestrator.md +56 -4
- package/extracted-skill/tracking-events-generator/agents/memory-agent.md +49 -0
- package/extracted-skill/tracking-events-generator/agents/ml-clustering-agent.md +738 -0
- package/extracted-skill/tracking-events-generator/agents/page-analyzer.md +14 -2
- package/package.json +1 -1
- package/server-edge-tracker/INSTALAR.md +195 -20
- package/server-edge-tracker/SEGMENTATION-DOCS.md +444 -0
- package/server-edge-tracker/schema-ab-ltv.sql +97 -0
- package/server-edge-tracker/schema-bidding.sql +86 -0
- package/server-edge-tracker/schema-fraud.sql +90 -0
- package/server-edge-tracker/schema-segmentation.sql +219 -0
- package/server-edge-tracker/schema.sql +1 -1
- package/server-edge-tracker/worker.js +1637 -51
- package/server-edge-tracker/wrangler.toml +20 -2
|
@@ -16,15 +16,15 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
// ── Constantes ────────────────────────────────────────────────────────────────
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
28
|
|
|
29
29
|
// ── SHA-256 via WebCrypto (obrigatório no Cloudflare Workers) ─────────────────
|
|
30
30
|
async function sha256(value) {
|
|
@@ -60,8 +60,8 @@ function normalizeCity(city) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
// ── CORS ──────────────────────────────────────────────────────────────────────
|
|
63
|
-
function corsHeaders(origin) {
|
|
64
|
-
const allowed =
|
|
63
|
+
function corsHeaders(origin, siteDomain) {
|
|
64
|
+
const allowed = isAllowedOrigin(origin, siteDomain) ? origin : `https://${siteDomain}`;
|
|
65
65
|
return {
|
|
66
66
|
'Access-Control-Allow-Origin': allowed,
|
|
67
67
|
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
|
@@ -121,7 +121,7 @@ async function sendMetaCapi(env, eventName, payload, request, ctx) {
|
|
|
121
121
|
event_name: eventName,
|
|
122
122
|
event_time: Math.floor(Date.now() / 1000),
|
|
123
123
|
event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
124
|
-
event_source_url: pageUrl || `https
|
|
124
|
+
event_source_url: pageUrl || `https://${env.SITE_DOMAIN}`,
|
|
125
125
|
action_source: 'website',
|
|
126
126
|
user_data: userData,
|
|
127
127
|
...(Object.keys(customData).length > 0 && { custom_data: customData }),
|
|
@@ -137,7 +137,7 @@ async function sendMetaCapi(env, eventName, payload, request, ctx) {
|
|
|
137
137
|
requestBody.test_event_code = env.META_TEST_CODE;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
const endpoint = `https://graph.facebook.com/v22.0/${META_PIXEL_ID}/events`;
|
|
140
|
+
const endpoint = `https://graph.facebook.com/v22.0/${env.META_PIXEL_ID}/events`;
|
|
141
141
|
|
|
142
142
|
try {
|
|
143
143
|
const res = await fetch(endpoint, {
|
|
@@ -220,7 +220,7 @@ async function sendGA4Mp(env, ga4EventName, payload, ctx) {
|
|
|
220
220
|
};
|
|
221
221
|
|
|
222
222
|
const url = `https://www.google-analytics.com/mp/collect`
|
|
223
|
-
+ `?measurement_id=${GA4_MEASUREMENT_ID}`
|
|
223
|
+
+ `?measurement_id=${env.GA4_MEASUREMENT_ID}`
|
|
224
224
|
+ `&api_secret=${env.GA4_API_SECRET}`;
|
|
225
225
|
|
|
226
226
|
try {
|
|
@@ -466,7 +466,7 @@ async function resolveDeviceGraph(DB, currentUserId, email, phone) {
|
|
|
466
466
|
|
|
467
467
|
// ── Automação de Mensagens — avalia regras e dispara WA/Email ────────────────
|
|
468
468
|
// Chamado via ctx.waitUntil após saveLead() para não bloquear o browser.
|
|
469
|
-
// Requer secrets:
|
|
469
|
+
// Requer secrets: WHATSAPP_ACCESS_TOKEN, WHATSAPP_PHONE_NUMBER_ID, RESEND_API_KEY, RESEND_FROM_EMAIL
|
|
470
470
|
async function fireAutomation(env, eventName, leadId, payload) {
|
|
471
471
|
if (!env.DB) return;
|
|
472
472
|
|
|
@@ -498,14 +498,14 @@ async function fireAutomation(env, eventName, leadId, payload) {
|
|
|
498
498
|
const subject = rule.subject_template ? interpolate(rule.subject_template) : null;
|
|
499
499
|
|
|
500
500
|
try {
|
|
501
|
-
if (rule.channel === 'whatsapp' && payload.phone && env.
|
|
501
|
+
if (rule.channel === 'whatsapp' && payload.phone && env.WHATSAPP_ACCESS_TOKEN && env.WHATSAPP_PHONE_NUMBER_ID) {
|
|
502
502
|
const digits = String(payload.phone).replace(/\D/g, '');
|
|
503
503
|
const e164 = digits.startsWith('55') ? `+${digits}` : `+55${digits}`;
|
|
504
504
|
const waRes = await fetch(
|
|
505
505
|
`https://graph.facebook.com/v22.0/${env.WHATSAPP_PHONE_NUMBER_ID}/messages`,
|
|
506
506
|
{
|
|
507
507
|
method: 'POST',
|
|
508
|
-
headers: { 'Authorization': `Bearer ${env.
|
|
508
|
+
headers: { 'Authorization': `Bearer ${env.WHATSAPP_ACCESS_TOKEN}`, 'Content-Type': 'application/json' },
|
|
509
509
|
body: JSON.stringify({ messaging_product: 'whatsapp', recipient_type: 'individual', to: e164, type: 'text', text: { body: message } }),
|
|
510
510
|
}
|
|
511
511
|
);
|
|
@@ -771,7 +771,7 @@ async function sendTikTokApi(env, eventName, payload, request, ctx) {
|
|
|
771
771
|
event_id: eventId || `cdp_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
772
772
|
user,
|
|
773
773
|
page: {
|
|
774
|
-
url: pageUrl ||
|
|
774
|
+
url: pageUrl || `https://${env.SITE_DOMAIN}`,
|
|
775
775
|
referrer: request.headers.get('Referer') || '',
|
|
776
776
|
},
|
|
777
777
|
...(Object.keys(properties).length > 0 && { properties }),
|
|
@@ -1158,20 +1158,107 @@ async function sendSpotifyCapi(env, eventName, payload, request, ctx) {
|
|
|
1158
1158
|
}
|
|
1159
1159
|
|
|
1160
1160
|
|
|
1161
|
-
// ── WhatsApp — Meta Cloud API v22.0
|
|
1161
|
+
// ── WhatsApp — Meta Cloud API v22.0 ──────────────────────────────────────────
|
|
1162
1162
|
//
|
|
1163
1163
|
// Secrets necessários (wrangler secret put):
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
1164
|
+
// WHATSAPP_PHONE_NUMBER_ID → Meta: "Phone Number ID" (ex: 123456789012345)
|
|
1165
|
+
// WHATSAPP_ACCESS_TOKEN → Meta: "Access Token" (Token permanente)
|
|
1166
1166
|
// WA_NOTIFY_NUMBER → Número do dono para receber notificações (ex: 5511999998888)
|
|
1167
1167
|
//
|
|
1168
|
-
//
|
|
1168
|
+
// Formatos suportados:
|
|
1169
|
+
// text → Texto livre (só funciona dentro da janela de 24h)
|
|
1170
|
+
// template → Mensagem pré-aprovada pela Meta (inicia conversa proativamente)
|
|
1171
|
+
// image → Imagem com legenda opcional (URL pública)
|
|
1172
|
+
// video → Vídeo com legenda opcional (URL pública)
|
|
1173
|
+
// document → PDF/arquivo com nome e legenda (URL pública)
|
|
1174
|
+
// audio → Áudio (URL pública, OGG/MP4)
|
|
1175
|
+
// interactive → Botões (até 3) ou lista de opções (até 10 itens)
|
|
1176
|
+
//
|
|
1177
|
+
// Regra crítica: para iniciar conversa proativamente (Purchase, Lead, etc.)
|
|
1178
|
+
// é OBRIGATÓRIO usar type: 'template'. Texto livre só funciona em resposta
|
|
1179
|
+
// a uma mensagem do usuário nos últimos 24h.
|
|
1169
1180
|
//
|
|
1170
|
-
async function sendWhatsApp(env, tipo, payload) {
|
|
1171
|
-
if (!env.
|
|
1181
|
+
async function sendWhatsApp(env, tipo, payload, options = {}) {
|
|
1182
|
+
if (!env.WHATSAPP_PHONE_NUMBER_ID || !env.WHATSAPP_ACCESS_TOKEN || !env.WA_NOTIFY_NUMBER) {
|
|
1172
1183
|
return { skipped: 'WhatsApp não configurado' };
|
|
1173
1184
|
}
|
|
1174
1185
|
|
|
1186
|
+
const to = options.to || env.WA_NOTIFY_NUMBER;
|
|
1187
|
+
|
|
1188
|
+
// ── Template (proativo — fora da janela de 24h) ───────────────────────────
|
|
1189
|
+
// Usar quando: Purchase, Lead, ou qualquer disparo iniciado pelo sistema
|
|
1190
|
+
// O template deve estar aprovado no Meta Business Suite → WhatsApp → Templates
|
|
1191
|
+
// Formato dos componentes segue a API de Templates da Meta
|
|
1192
|
+
if (options.template) {
|
|
1193
|
+
const { name, language = 'pt_BR', components = [] } = options.template;
|
|
1194
|
+
const body = {
|
|
1195
|
+
messaging_product: 'whatsapp',
|
|
1196
|
+
to,
|
|
1197
|
+
type: 'template',
|
|
1198
|
+
template: { name, language: { code: language }, components },
|
|
1199
|
+
};
|
|
1200
|
+
return _sendWARequest(env, body);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// ── Mídia — image, video, document, audio ────────────────────────────────
|
|
1204
|
+
// Usar quando: envio de PDF de nota fiscal, vídeo de boas-vindas, etc.
|
|
1205
|
+
// mediaUrl deve ser uma URL pública acessível pela Meta
|
|
1206
|
+
if (options.mediaType && options.mediaUrl) {
|
|
1207
|
+
const mediaPayload = { link: options.mediaUrl };
|
|
1208
|
+
if (options.caption) mediaPayload.caption = options.caption;
|
|
1209
|
+
if (options.filename) mediaPayload.filename = options.filename; // só para document
|
|
1210
|
+
const body = {
|
|
1211
|
+
messaging_product: 'whatsapp',
|
|
1212
|
+
to,
|
|
1213
|
+
type: options.mediaType,
|
|
1214
|
+
[options.mediaType]: mediaPayload,
|
|
1215
|
+
};
|
|
1216
|
+
return _sendWARequest(env, body);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// ── Interactive — botões (até 3) ─────────────────────────────────────────
|
|
1220
|
+
// Usar quando: confirmação de compra com botões de ação
|
|
1221
|
+
// buttons: [{ id: 'btn_1', title: 'Ver Pedido' }, ...]
|
|
1222
|
+
if (options.interactive === 'buttons' && options.buttons?.length) {
|
|
1223
|
+
const body = {
|
|
1224
|
+
messaging_product: 'whatsapp',
|
|
1225
|
+
to,
|
|
1226
|
+
type: 'interactive',
|
|
1227
|
+
interactive: {
|
|
1228
|
+
type: 'button',
|
|
1229
|
+
body: { text: options.bodyText || '' },
|
|
1230
|
+
action: {
|
|
1231
|
+
buttons: options.buttons.slice(0, 3).map(b => ({
|
|
1232
|
+
type: 'reply',
|
|
1233
|
+
reply: { id: b.id, title: b.title },
|
|
1234
|
+
})),
|
|
1235
|
+
},
|
|
1236
|
+
},
|
|
1237
|
+
};
|
|
1238
|
+
return _sendWARequest(env, body);
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// ── Interactive — lista de opções (até 10 itens) ──────────────────────────
|
|
1242
|
+
// Usar quando: menu de suporte, seleção de produto, etc.
|
|
1243
|
+
// rows: [{ id: 'opt_1', title: 'Suporte', description: 'Falar com equipe' }, ...]
|
|
1244
|
+
if (options.interactive === 'list' && options.rows?.length) {
|
|
1245
|
+
const body = {
|
|
1246
|
+
messaging_product: 'whatsapp',
|
|
1247
|
+
to,
|
|
1248
|
+
type: 'interactive',
|
|
1249
|
+
interactive: {
|
|
1250
|
+
type: 'list',
|
|
1251
|
+
body: { text: options.bodyText || '' },
|
|
1252
|
+
action: {
|
|
1253
|
+
button: options.listButton || 'Ver opções',
|
|
1254
|
+
sections: [{ rows: options.rows.slice(0, 10) }],
|
|
1255
|
+
},
|
|
1256
|
+
},
|
|
1257
|
+
};
|
|
1258
|
+
return _sendWARequest(env, body);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// ── Text — fallback (dentro da janela de 24h) ─────────────────────────────
|
|
1175
1262
|
const nome = [payload.firstName, payload.lastName].filter(Boolean).join(' ') || 'sem nome';
|
|
1176
1263
|
const valor = payload.value ? `R$ ${parseFloat(payload.value).toFixed(2)}` : '—';
|
|
1177
1264
|
const utm = payload.utmSource || 'direto';
|
|
@@ -1196,24 +1283,31 @@ async function sendWhatsApp(env, tipo, payload) {
|
|
|
1196
1283
|
`🌐 ${payload.pageUrl || '—'}\n` +
|
|
1197
1284
|
`🕐 ${new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo' })}`;
|
|
1198
1285
|
} else {
|
|
1199
|
-
return { skipped: `tipo ${tipo} não
|
|
1286
|
+
return { skipped: `tipo ${tipo} não suportado sem template` };
|
|
1200
1287
|
}
|
|
1201
1288
|
|
|
1289
|
+
return _sendWARequest(env, {
|
|
1290
|
+
messaging_product: 'whatsapp',
|
|
1291
|
+
to,
|
|
1292
|
+
type: 'text',
|
|
1293
|
+
text: { body: texto },
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
// ── Executor interno — evita duplicação de fetch entre os formatos ────────────
|
|
1298
|
+
async function _sendWARequest(env, body) {
|
|
1202
1299
|
try {
|
|
1203
|
-
const res = await fetch(`https://graph.facebook.com/v22.0/${env.
|
|
1300
|
+
const res = await fetch(`https://graph.facebook.com/v22.0/${env.WHATSAPP_PHONE_NUMBER_ID}/messages`, {
|
|
1204
1301
|
method: 'POST',
|
|
1205
1302
|
headers: {
|
|
1206
1303
|
'Content-Type': 'application/json',
|
|
1207
|
-
'Authorization': `Bearer ${env.
|
|
1304
|
+
'Authorization': `Bearer ${env.WHATSAPP_ACCESS_TOKEN}`,
|
|
1208
1305
|
},
|
|
1209
|
-
body: JSON.stringify(
|
|
1210
|
-
messaging_product: 'whatsapp',
|
|
1211
|
-
to: env.WA_NOTIFY_NUMBER,
|
|
1212
|
-
type: 'text',
|
|
1213
|
-
text: { body: texto },
|
|
1214
|
-
}),
|
|
1306
|
+
body: JSON.stringify(body),
|
|
1215
1307
|
});
|
|
1216
|
-
|
|
1308
|
+
const data = await res.json();
|
|
1309
|
+
if (!res.ok) console.error('WhatsApp Meta API error:', JSON.stringify(data));
|
|
1310
|
+
return { ok: res.ok, status: res.status, data };
|
|
1217
1311
|
} catch (err) {
|
|
1218
1312
|
console.error('WhatsApp Meta API failed:', err.message);
|
|
1219
1313
|
return { ok: false, error: err.message };
|
|
@@ -1473,7 +1567,7 @@ async function upsertLtvProfile(env, userId, ltv) {
|
|
|
1473
1567
|
* class: 'High' | 'Medium' | 'Low'
|
|
1474
1568
|
* value: valor em BRL (base × multiplicador da classe)
|
|
1475
1569
|
*/
|
|
1476
|
-
async function predictLtv(env, payload, request) {
|
|
1570
|
+
async function predictLtv(env, payload, request, customSystemPrompt = null) {
|
|
1477
1571
|
let score = 0;
|
|
1478
1572
|
|
|
1479
1573
|
// 1. Engajamento browser (0–30)
|
|
@@ -1546,8 +1640,10 @@ async function predictLtv(env, payload, request) {
|
|
|
1546
1640
|
let aiAdjustment = 0;
|
|
1547
1641
|
if (env.AI && score >= 40) {
|
|
1548
1642
|
try {
|
|
1643
|
+
const systemContent = customSystemPrompt ||
|
|
1644
|
+
'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.';
|
|
1549
1645
|
const prompt = [
|
|
1550
|
-
{ role: 'system', content:
|
|
1646
|
+
{ role: 'system', content: systemContent },
|
|
1551
1647
|
{ role: 'user', content: JSON.stringify({
|
|
1552
1648
|
utm_source: payload.utmSource,
|
|
1553
1649
|
intention: intentionLevel,
|
|
@@ -1948,6 +2044,1347 @@ async function buildGoogleCustomerMatchExport(env) {
|
|
|
1948
2044
|
);
|
|
1949
2045
|
}
|
|
1950
2046
|
|
|
2047
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2048
|
+
// SEGMENTAÇÃO DINÂMICA ML — Handlers das Rotas de Clustering
|
|
2049
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2050
|
+
|
|
2051
|
+
// Helper: parse seguro de JSON armazenado como TEXT no D1
|
|
2052
|
+
function tryParseJson(str, fallback) {
|
|
2053
|
+
if (!str) return fallback !== undefined ? fallback : null;
|
|
2054
|
+
try { return JSON.parse(str); } catch { return fallback !== undefined ? fallback : null; }
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
// ── POST /api/segmentation/cluster ───────────────────────────────────────────
|
|
2058
|
+
// Executa clustering K-means/DBSCAN/Hierarchical via Workers AI
|
|
2059
|
+
// Requer bindings: DB + AI
|
|
2060
|
+
async function handleSegmentationCluster(env, request, headers) {
|
|
2061
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
2062
|
+
if (!env.AI) return new Response(JSON.stringify({ error: 'Workers AI não configurado (verifique binding AI no wrangler.toml)' }), { status: 503, headers });
|
|
2063
|
+
|
|
2064
|
+
const url = new URL(request.url);
|
|
2065
|
+
const algorithm = url.searchParams.get('algorithm') || 'kmeans';
|
|
2066
|
+
const nClusters = Math.min(10, Math.max(3, parseInt(url.searchParams.get('n_clusters') || '5')));
|
|
2067
|
+
const clientVertical = url.searchParams.get('vertical') || 'general';
|
|
2068
|
+
const forceRecluster = url.searchParams.get('force') === 'true';
|
|
2069
|
+
|
|
2070
|
+
if (!['kmeans', 'dbscan', 'hierarchical'].includes(algorithm)) {
|
|
2071
|
+
return new Response(JSON.stringify({ error: 'algorithm deve ser: kmeans, dbscan ou hierarchical', received: algorithm }), { status: 400, headers });
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
try {
|
|
2075
|
+
// 1. Cluster recente? Evitar re-clustering desnecessário (< 7 dias)
|
|
2076
|
+
if (!forceRecluster) {
|
|
2077
|
+
const existing = await env.DB.prepare(`
|
|
2078
|
+
SELECT id, created_at, cluster_name FROM ml_segments
|
|
2079
|
+
WHERE clustering_algorithm = ? AND is_active = 1 AND client_vertical = ?
|
|
2080
|
+
ORDER BY created_at DESC LIMIT 1
|
|
2081
|
+
`).bind(algorithm, clientVertical).first();
|
|
2082
|
+
|
|
2083
|
+
if (existing) {
|
|
2084
|
+
const ageDays = (Date.now() - new Date(existing.created_at).getTime()) / (1000 * 60 * 60 * 24);
|
|
2085
|
+
if (ageDays < 7) {
|
|
2086
|
+
return new Response(JSON.stringify({
|
|
2087
|
+
success: true,
|
|
2088
|
+
message: 'Cluster existente ainda válido (< 7 dias). Use ?force=true para re-clustering.',
|
|
2089
|
+
cluster_id: existing.id,
|
|
2090
|
+
cluster_name: existing.cluster_name,
|
|
2091
|
+
age_days: Math.round(ageDays * 10) / 10,
|
|
2092
|
+
use_existing: true,
|
|
2093
|
+
}), { status: 200, headers });
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
// 2. Extrair leads históricos do D1 (últimos 6 meses, excluindo bots confirmados)
|
|
2099
|
+
const leadsRes = await env.DB.prepare(`
|
|
2100
|
+
SELECT id, predicted_ltv_class, engagement_score, intention_level,
|
|
2101
|
+
country, state, utm_source, utm_medium, bot_score,
|
|
2102
|
+
CAST(strftime('%H', created_at) AS INTEGER) AS hour_of_day,
|
|
2103
|
+
CAST(julianday('now') - julianday(created_at) AS INTEGER) AS days_since_lead,
|
|
2104
|
+
CASE WHEN strftime('%w', created_at) IN ('0','6') THEN 1 ELSE 0 END AS is_weekend
|
|
2105
|
+
FROM leads
|
|
2106
|
+
WHERE created_at >= datetime('now', '-6 months')
|
|
2107
|
+
AND (bot_score IS NULL OR bot_score < 2)
|
|
2108
|
+
ORDER BY RANDOM()
|
|
2109
|
+
LIMIT 2000
|
|
2110
|
+
`).all();
|
|
2111
|
+
|
|
2112
|
+
const leads = leadsRes.results || [];
|
|
2113
|
+
|
|
2114
|
+
if (leads.length < 50) {
|
|
2115
|
+
return new Response(JSON.stringify({
|
|
2116
|
+
error: 'Dados insuficientes para clustering. Mínimo: 50 leads nos últimos 6 meses.',
|
|
2117
|
+
leads_found: leads.length,
|
|
2118
|
+
required: 50,
|
|
2119
|
+
}), { status: 400, headers });
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
// 3. Feature Engineering — normalização 0–1
|
|
2123
|
+
const features = leads.map(l => ({
|
|
2124
|
+
id: l.id,
|
|
2125
|
+
ltv: l.predicted_ltv_class === 'High' ? 1 : (l.predicted_ltv_class === 'Medium' ? 0.5 : 0),
|
|
2126
|
+
engagement: Math.min((l.engagement_score || 0) / 100, 1),
|
|
2127
|
+
intention: l.intention_level === 'comprador' || l.intention_level === 'high_intent' ? 1
|
|
2128
|
+
: l.intention_level === 'interessado' ? 0.6
|
|
2129
|
+
: l.intention_level === 'curioso' ? 0.3 : 0,
|
|
2130
|
+
recency: Math.max(0, 1 - (l.days_since_lead || 0) / 180),
|
|
2131
|
+
hour: (l.hour_of_day || 12) / 23,
|
|
2132
|
+
is_weekend: l.is_weekend || 0,
|
|
2133
|
+
is_br: l.country === 'BR' ? 1 : 0,
|
|
2134
|
+
is_paid: ['facebook','google','tiktok','instagram','youtube'].includes(
|
|
2135
|
+
(l.utm_source || '').toLowerCase()) ? 1 : 0,
|
|
2136
|
+
}));
|
|
2137
|
+
|
|
2138
|
+
// 4. Prompt para Workers AI
|
|
2139
|
+
const sampleSize = Math.min(features.length, 100);
|
|
2140
|
+
const sample = features.slice(0, sampleSize);
|
|
2141
|
+
|
|
2142
|
+
const clusteringPrompt =
|
|
2143
|
+
`You are a customer segmentation ML expert. Perform ${algorithm} clustering on ${sampleSize} customers into ${nClusters} segments.
|
|
2144
|
+
|
|
2145
|
+
Customer features (all normalized 0-1):
|
|
2146
|
+
- ltv: predicted lifetime value (0=Low, 0.5=Medium, 1=High)
|
|
2147
|
+
- engagement: browser engagement score
|
|
2148
|
+
- intention: purchase intention (0=none, 0.3=curious, 0.6=interested, 1=buyer)
|
|
2149
|
+
- recency: lead recency (1=today, 0=6 months ago)
|
|
2150
|
+
- hour: conversion hour of day
|
|
2151
|
+
- is_weekend: converted on weekend (0/1)
|
|
2152
|
+
- is_br: lead from Brazil (0/1)
|
|
2153
|
+
- is_paid: from paid traffic channel (0/1)
|
|
2154
|
+
|
|
2155
|
+
Data (${sampleSize} customers): ${JSON.stringify(sample.slice(0, 50))}
|
|
2156
|
+
|
|
2157
|
+
Return ONLY valid JSON, zero explanation:
|
|
2158
|
+
{
|
|
2159
|
+
"clusters": [
|
|
2160
|
+
{
|
|
2161
|
+
"cluster_id": 0,
|
|
2162
|
+
"name": "[Nome Descritivo em Português]",
|
|
2163
|
+
"size": ${Math.round(sampleSize / nClusters)},
|
|
2164
|
+
"percentage": ${Math.round(100 / nClusters)},
|
|
2165
|
+
"characteristics": {
|
|
2166
|
+
"avg_ltv_class": 0.5,
|
|
2167
|
+
"avg_behavior_score": 0.5,
|
|
2168
|
+
"avg_engagement_score": 0.5,
|
|
2169
|
+
"avg_intention_level": 0.5,
|
|
2170
|
+
"avg_days_since_lead": 30,
|
|
2171
|
+
"dominant_countries": ["BR"],
|
|
2172
|
+
"dominant_states": ["SP", "RJ"],
|
|
2173
|
+
"dominant_utm_sources": ["facebook"],
|
|
2174
|
+
"top_features": ["ltv", "engagement"]
|
|
2175
|
+
},
|
|
2176
|
+
"centroid": { "ltv": 0.5, "engagement": 0.5, "intention": 0.5 },
|
|
2177
|
+
"action_recommendation": "[Recomendação de campanha específica para este segmento]"
|
|
2178
|
+
}
|
|
2179
|
+
],
|
|
2180
|
+
"silhouette_score": 0.65,
|
|
2181
|
+
"total_processed": ${sampleSize}
|
|
2182
|
+
}`;
|
|
2183
|
+
|
|
2184
|
+
// 5. Executar via Workers AI
|
|
2185
|
+
const startTime = Date.now();
|
|
2186
|
+
const aiRes = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
|
|
2187
|
+
messages: [{ role: 'user', content: clusteringPrompt }],
|
|
2188
|
+
max_tokens: 2000,
|
|
2189
|
+
});
|
|
2190
|
+
const duration = Date.now() - startTime;
|
|
2191
|
+
|
|
2192
|
+
if (!aiRes?.response) throw new Error('Workers AI não retornou resposta');
|
|
2193
|
+
|
|
2194
|
+
// 6. Parse do resultado
|
|
2195
|
+
const jsonMatch = aiRes.response.trim().match(/\{[\s\S]*\}/);
|
|
2196
|
+
if (!jsonMatch) throw new Error('Resposta do Workers AI não contém JSON válido');
|
|
2197
|
+
const mlResult = JSON.parse(jsonMatch[0]);
|
|
2198
|
+
|
|
2199
|
+
if (!Array.isArray(mlResult.clusters) || mlResult.clusters.length === 0) {
|
|
2200
|
+
throw new Error('Workers AI não retornou clusters válidos');
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
// 7. Inativar clusters anteriores do mesmo algoritmo/vertical
|
|
2204
|
+
await env.DB.prepare(
|
|
2205
|
+
`UPDATE ml_segments SET is_active = 0 WHERE clustering_algorithm = ? AND client_vertical = ? AND is_active = 1`
|
|
2206
|
+
).bind(algorithm, clientVertical).run();
|
|
2207
|
+
|
|
2208
|
+
// 8. Persistir novos clusters no D1
|
|
2209
|
+
const now = new Date().toISOString();
|
|
2210
|
+
for (const cluster of mlResult.clusters) {
|
|
2211
|
+
const ch = cluster.characteristics || {};
|
|
2212
|
+
await env.DB.prepare(`
|
|
2213
|
+
INSERT INTO ml_segments (
|
|
2214
|
+
cluster_id, cluster_name, clustering_algorithm, client_vertical,
|
|
2215
|
+
size, percentage,
|
|
2216
|
+
avg_ltv_class, avg_behavior_score, avg_engagement_score,
|
|
2217
|
+
avg_intention_level, avg_days_since_lead,
|
|
2218
|
+
dominant_countries, dominant_states, dominant_utm_sources, dominant_features,
|
|
2219
|
+
silhouette_score, action_recommendations, bid_recommendations, campaign_recommendations,
|
|
2220
|
+
is_active, created_at, updated_at
|
|
2221
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)
|
|
2222
|
+
`).bind(
|
|
2223
|
+
cluster.cluster_id || 0,
|
|
2224
|
+
cluster.name || `Segmento ${cluster.cluster_id}`,
|
|
2225
|
+
algorithm,
|
|
2226
|
+
clientVertical,
|
|
2227
|
+
cluster.size || 0,
|
|
2228
|
+
cluster.percentage || 0,
|
|
2229
|
+
ch.avg_ltv_class || 0,
|
|
2230
|
+
ch.avg_behavior_score || 0,
|
|
2231
|
+
ch.avg_engagement_score || 0,
|
|
2232
|
+
ch.avg_intention_level || 0,
|
|
2233
|
+
ch.avg_days_since_lead || 0,
|
|
2234
|
+
JSON.stringify(ch.dominant_countries || ['BR']),
|
|
2235
|
+
JSON.stringify(ch.dominant_states || []),
|
|
2236
|
+
JSON.stringify(ch.dominant_utm_sources || []),
|
|
2237
|
+
JSON.stringify(ch.top_features || []),
|
|
2238
|
+
mlResult.silhouette_score || 0,
|
|
2239
|
+
JSON.stringify([cluster.action_recommendation || '']),
|
|
2240
|
+
JSON.stringify([]),
|
|
2241
|
+
JSON.stringify([]),
|
|
2242
|
+
now,
|
|
2243
|
+
now,
|
|
2244
|
+
).run();
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
// 9. Log no histórico de clustering
|
|
2248
|
+
try {
|
|
2249
|
+
await env.DB.prepare(`
|
|
2250
|
+
INSERT INTO ml_clustering_history (
|
|
2251
|
+
clustering_id, started_at, completed_at, algorithm,
|
|
2252
|
+
n_leads_processed, n_clusters_created, total_duration_ms,
|
|
2253
|
+
workers_ai_neurons_used, status, parameters, results_summary
|
|
2254
|
+
) VALUES (0, ?, datetime('now'), ?, ?, ?, ?, ?, 'completed', ?, ?)
|
|
2255
|
+
`).bind(
|
|
2256
|
+
new Date(startTime).toISOString(),
|
|
2257
|
+
algorithm,
|
|
2258
|
+
leads.length,
|
|
2259
|
+
mlResult.clusters.length,
|
|
2260
|
+
duration,
|
|
2261
|
+
Math.ceil(duration * 0.01),
|
|
2262
|
+
JSON.stringify({ algorithm, n_clusters: nClusters, vertical: clientVertical }),
|
|
2263
|
+
JSON.stringify({ clusters: mlResult.clusters.length, silhouette: mlResult.silhouette_score }),
|
|
2264
|
+
).run();
|
|
2265
|
+
} catch (e) { console.error('[Segmentation] history log error:', e.message); }
|
|
2266
|
+
|
|
2267
|
+
return new Response(JSON.stringify({
|
|
2268
|
+
success: true,
|
|
2269
|
+
algorithm,
|
|
2270
|
+
n_clusters: mlResult.clusters.length,
|
|
2271
|
+
client_vertical: clientVertical,
|
|
2272
|
+
leads_analyzed: leads.length,
|
|
2273
|
+
duration_ms: duration,
|
|
2274
|
+
silhouette_score: mlResult.silhouette_score || null,
|
|
2275
|
+
clusters: mlResult.clusters,
|
|
2276
|
+
generated_at: now,
|
|
2277
|
+
}), { status: 200, headers });
|
|
2278
|
+
|
|
2279
|
+
} catch (err) {
|
|
2280
|
+
console.error('[Segmentation] cluster error:', err.message);
|
|
2281
|
+
try {
|
|
2282
|
+
if (env.DB) {
|
|
2283
|
+
await env.DB.prepare(`
|
|
2284
|
+
INSERT INTO ml_clustering_history
|
|
2285
|
+
(clustering_id, started_at, algorithm, n_leads_processed, n_clusters_created,
|
|
2286
|
+
total_duration_ms, workers_ai_neurons_used, status, error_message, parameters, results_summary)
|
|
2287
|
+
VALUES (0, datetime('now'), ?, 0, 0, 0, 0, 'failed', ?, ?, '{}')
|
|
2288
|
+
`).bind(algorithm, err.message, JSON.stringify({ algorithm, n_clusters: nClusters })).run();
|
|
2289
|
+
}
|
|
2290
|
+
} catch { /* não bloquear a resposta de erro */ }
|
|
2291
|
+
|
|
2292
|
+
return new Response(JSON.stringify({ error: 'Erro ao executar clustering', message: err.message }), { status: 500, headers });
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
// ── GET /api/segmentation/list ────────────────────────────────────────────────
|
|
2297
|
+
// Lista todos os segmentos ativos com estatísticas
|
|
2298
|
+
async function handleSegmentationList(env, request, headers) {
|
|
2299
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
2300
|
+
|
|
2301
|
+
const url = new URL(request.url);
|
|
2302
|
+
const algorithm = url.searchParams.get('algorithm') || null;
|
|
2303
|
+
const vertical = url.searchParams.get('vertical') || null;
|
|
2304
|
+
|
|
2305
|
+
try {
|
|
2306
|
+
const conditions = ['is_active = 1'];
|
|
2307
|
+
const bindings = [];
|
|
2308
|
+
if (algorithm) { conditions.push('clustering_algorithm = ?'); bindings.push(algorithm); }
|
|
2309
|
+
if (vertical) { conditions.push('client_vertical = ?'); bindings.push(vertical); }
|
|
2310
|
+
|
|
2311
|
+
const query = `
|
|
2312
|
+
SELECT id, cluster_id, cluster_name, clustering_algorithm, client_vertical,
|
|
2313
|
+
size, percentage, avg_ltv_class, avg_behavior_score, avg_engagement_score,
|
|
2314
|
+
avg_intention_level, avg_days_since_lead, silhouette_score,
|
|
2315
|
+
dominant_countries, dominant_states, dominant_utm_sources, dominant_features,
|
|
2316
|
+
action_recommendations, bid_recommendations, campaign_recommendations,
|
|
2317
|
+
is_active, created_at, updated_at
|
|
2318
|
+
FROM ml_segments
|
|
2319
|
+
WHERE ${conditions.join(' AND ')}
|
|
2320
|
+
ORDER BY created_at DESC
|
|
2321
|
+
LIMIT 50
|
|
2322
|
+
`;
|
|
2323
|
+
|
|
2324
|
+
const result = await env.DB.prepare(query).bind(...bindings).all();
|
|
2325
|
+
const segments = (result.results || []).map(s => ({
|
|
2326
|
+
...s,
|
|
2327
|
+
dominant_countries: tryParseJson(s.dominant_countries, []),
|
|
2328
|
+
dominant_states: tryParseJson(s.dominant_states, []),
|
|
2329
|
+
dominant_utm_sources: tryParseJson(s.dominant_utm_sources, []),
|
|
2330
|
+
dominant_features: tryParseJson(s.dominant_features, []),
|
|
2331
|
+
action_recommendations: tryParseJson(s.action_recommendations, []),
|
|
2332
|
+
bid_recommendations: tryParseJson(s.bid_recommendations, []),
|
|
2333
|
+
campaign_recommendations: tryParseJson(s.campaign_recommendations, []),
|
|
2334
|
+
}));
|
|
2335
|
+
|
|
2336
|
+
return new Response(JSON.stringify({
|
|
2337
|
+
success: true,
|
|
2338
|
+
total: segments.length,
|
|
2339
|
+
segments,
|
|
2340
|
+
}), { status: 200, headers });
|
|
2341
|
+
|
|
2342
|
+
} catch (err) {
|
|
2343
|
+
console.error('[Segmentation] list error:', err.message);
|
|
2344
|
+
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
// ── GET /api/segmentation/outliers ───────────────────────────────────────────
|
|
2349
|
+
// Lista leads marcados como outliers no ml_segment_members (DBSCAN)
|
|
2350
|
+
async function handleSegmentationOutliers(env, request, headers) {
|
|
2351
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
2352
|
+
|
|
2353
|
+
const url = new URL(request.url);
|
|
2354
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 200);
|
|
2355
|
+
const days = parseInt(url.searchParams.get('days') || '30');
|
|
2356
|
+
|
|
2357
|
+
try {
|
|
2358
|
+
const result = await env.DB.prepare(`
|
|
2359
|
+
SELECT msm.lead_id, msm.cluster_id, msm.confidence, msm.is_outlier,
|
|
2360
|
+
msm.outlier_reason, msm.assigned_at,
|
|
2361
|
+
l.email, l.phone, l.country, l.state, l.city,
|
|
2362
|
+
l.utm_source, l.bot_score, l.engagement_score, l.intention_level,
|
|
2363
|
+
l.created_at AS lead_created_at
|
|
2364
|
+
FROM ml_segment_members msm
|
|
2365
|
+
LEFT JOIN leads l ON CAST(msm.lead_id AS INTEGER) = l.id
|
|
2366
|
+
WHERE msm.is_outlier = 1
|
|
2367
|
+
AND msm.assigned_at >= datetime('now', '-' || ? || ' days')
|
|
2368
|
+
ORDER BY msm.assigned_at DESC
|
|
2369
|
+
LIMIT ?
|
|
2370
|
+
`).bind(days, limit).all();
|
|
2371
|
+
|
|
2372
|
+
return new Response(JSON.stringify({
|
|
2373
|
+
success: true,
|
|
2374
|
+
total: (result.results || []).length,
|
|
2375
|
+
period_days: days,
|
|
2376
|
+
outliers: result.results || [],
|
|
2377
|
+
}), { status: 200, headers });
|
|
2378
|
+
|
|
2379
|
+
} catch (err) {
|
|
2380
|
+
console.error('[Segmentation] outliers error:', err.message);
|
|
2381
|
+
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
// ── PUT /api/segmentation/update ─────────────────────────────────────────────
|
|
2386
|
+
// Atualiza recomendações de ação/bid/campanha de um segmento existente
|
|
2387
|
+
async function handleSegmentationUpdate(env, request, headers) {
|
|
2388
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
2389
|
+
|
|
2390
|
+
let body;
|
|
2391
|
+
try { body = await request.json(); }
|
|
2392
|
+
catch { return new Response(JSON.stringify({ error: 'JSON inválido no body da requisição' }), { status: 400, headers }); }
|
|
2393
|
+
|
|
2394
|
+
const { cluster_id, action_recommendations, bid_recommendations, campaign_recommendations } = body;
|
|
2395
|
+
|
|
2396
|
+
if (cluster_id === undefined || cluster_id === null) {
|
|
2397
|
+
return new Response(JSON.stringify({ error: 'cluster_id é obrigatório' }), { status: 400, headers });
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
try {
|
|
2401
|
+
const sets = [];
|
|
2402
|
+
const bindings = [];
|
|
2403
|
+
|
|
2404
|
+
if (action_recommendations !== undefined) { sets.push('action_recommendations = ?'); bindings.push(JSON.stringify(action_recommendations)); }
|
|
2405
|
+
if (bid_recommendations !== undefined) { sets.push('bid_recommendations = ?'); bindings.push(JSON.stringify(bid_recommendations)); }
|
|
2406
|
+
if (campaign_recommendations !== undefined) { sets.push('campaign_recommendations = ?'); bindings.push(JSON.stringify(campaign_recommendations)); }
|
|
2407
|
+
|
|
2408
|
+
if (sets.length === 0) {
|
|
2409
|
+
return new Response(JSON.stringify({ error: 'Nenhum campo válido para atualizar (action_recommendations, bid_recommendations, campaign_recommendations)' }), { status: 400, headers });
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
sets.push("updated_at = datetime('now')");
|
|
2413
|
+
bindings.push(cluster_id);
|
|
2414
|
+
|
|
2415
|
+
await env.DB.prepare(
|
|
2416
|
+
`UPDATE ml_segments SET ${sets.join(', ')} WHERE id = ?`
|
|
2417
|
+
).bind(...bindings).run();
|
|
2418
|
+
|
|
2419
|
+
return new Response(JSON.stringify({
|
|
2420
|
+
success: true,
|
|
2421
|
+
cluster_id,
|
|
2422
|
+
fields_updated: sets.length - 1, // exclui o updated_at
|
|
2423
|
+
}), { status: 200, headers });
|
|
2424
|
+
|
|
2425
|
+
} catch (err) {
|
|
2426
|
+
console.error('[Segmentation] update error:', err.message);
|
|
2427
|
+
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2432
|
+
// FRAUD DETECTION ENGINE — Fase 4 Enterprise-Level
|
|
2433
|
+
// Heurístico puro (sem AI) — latência zero no /track
|
|
2434
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2435
|
+
|
|
2436
|
+
// Domínios de email descartáveis
|
|
2437
|
+
const DISPOSABLE_EMAIL_DOMAINS = new Set([
|
|
2438
|
+
'mailinator.com','guerrillamail.com','tempmail.com','throwaway.email',
|
|
2439
|
+
'yopmail.com','sharklasers.com','guerrillamailblock.com','spam4.me',
|
|
2440
|
+
'10minutemail.com','trashmail.com','maildrop.cc','fakeinbox.com',
|
|
2441
|
+
'dispostable.com','mailnull.com','tempr.email','getnada.com',
|
|
2442
|
+
]);
|
|
2443
|
+
|
|
2444
|
+
// ASNs conhecidos de datacenters (evitar falsos negativos em ASNs legítimos)
|
|
2445
|
+
const DATACENTER_PATTERNS = /amazon|google|microsoft|digitalocean|linode|ovh|vultr|hetzner|contabo|cloudflare|packet|rackspace|leaseweb/i;
|
|
2446
|
+
|
|
2447
|
+
// ── checkFraudGate — roda sincronamente ANTES de processar o evento ────────────
|
|
2448
|
+
// Retorna { allowed, score, reasons, action }
|
|
2449
|
+
// NUNCA joga erro — qualquer falha = allowed (fail-safe)
|
|
2450
|
+
async function checkFraudGate(env, request, payload) {
|
|
2451
|
+
const result = { allowed: true, score: 0, reasons: [], action: 'allowed' };
|
|
2452
|
+
|
|
2453
|
+
try {
|
|
2454
|
+
const ip = request.headers.get('CF-Connecting-IP') || '';
|
|
2455
|
+
const ua = request.headers.get('User-Agent') || '';
|
|
2456
|
+
const fingerprint = payload.fingerprint || '';
|
|
2457
|
+
const email = payload.email || '';
|
|
2458
|
+
const botScore = parseInt(payload.botScore || payload.bot_score || 0);
|
|
2459
|
+
const asn = String(request.cf?.asOrganization || '').toLowerCase();
|
|
2460
|
+
const country = (request.cf?.country || '').toUpperCase();
|
|
2461
|
+
const acceptLang = request.headers.get('Accept-Language');
|
|
2462
|
+
|
|
2463
|
+
// 1. KV blocklist check — instantâneo (~0ms)
|
|
2464
|
+
if (env.GEO_CACHE && ip) {
|
|
2465
|
+
const ipBlocked = await env.GEO_CACHE.get(`fraud_block:ip:${ip}`);
|
|
2466
|
+
if (ipBlocked) {
|
|
2467
|
+
return { allowed: false, score: 100, reasons: ['ip_blocklisted'], action: 'dropped' };
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
if (env.GEO_CACHE && fingerprint) {
|
|
2471
|
+
const fpBlocked = await env.GEO_CACHE.get(`fraud_block:fp:${fingerprint}`);
|
|
2472
|
+
if (fpBlocked) {
|
|
2473
|
+
return { allowed: false, score: 100, reasons: ['fingerprint_blocklisted'], action: 'dropped' };
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
// 2. Bot score (já calculado pelo Worker)
|
|
2478
|
+
if (botScore >= 3) { result.score += 60; result.reasons.push('bot_score_high'); }
|
|
2479
|
+
else if (botScore === 2) { result.score += 30; result.reasons.push('bot_score_medium'); }
|
|
2480
|
+
|
|
2481
|
+
// 3. User-Agent suspeito
|
|
2482
|
+
if (/headless|phantomjs|selenium|webdriver|curl|python|scrapy|bot|crawler|spider/i.test(ua)) {
|
|
2483
|
+
result.score += 40; result.reasons.push('suspicious_user_agent');
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
// 4. Datacenter IP
|
|
2487
|
+
if (ip && DATACENTER_PATTERNS.test(asn)) {
|
|
2488
|
+
result.score += 35; result.reasons.push('datacenter_ip');
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
// 5. Sem Accept-Language (bots raramente enviam)
|
|
2492
|
+
if (!acceptLang) {
|
|
2493
|
+
result.score += 20; result.reasons.push('no_accept_language');
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2496
|
+
// 6. Email descartável
|
|
2497
|
+
if (email) {
|
|
2498
|
+
const domain = email.split('@')[1]?.toLowerCase();
|
|
2499
|
+
if (domain && DISPOSABLE_EMAIL_DOMAINS.has(domain)) {
|
|
2500
|
+
result.score += 25; result.reasons.push('disposable_email');
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
// 7. Velocity check via KV
|
|
2505
|
+
if (env.GEO_CACHE && ip) {
|
|
2506
|
+
const velKey1h = `fraud_velocity:${ip}:h`;
|
|
2507
|
+
const velStr = await env.GEO_CACHE.get(velKey1h);
|
|
2508
|
+
const vel1h = parseInt(velStr || '0') + 1;
|
|
2509
|
+
|
|
2510
|
+
// Atualizar contador (TTL: 3600s = 1h)
|
|
2511
|
+
await env.GEO_CACHE.put(velKey1h, String(vel1h), { expirationTtl: 3600 });
|
|
2512
|
+
|
|
2513
|
+
if (vel1h > 20) { result.score += 50; result.reasons.push('ip_velocity_very_high'); }
|
|
2514
|
+
else if (vel1h > 10) { result.score += 25; result.reasons.push('ip_velocity_high'); }
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
result.score = Math.min(100, result.score);
|
|
2518
|
+
|
|
2519
|
+
// 8. Decisão final
|
|
2520
|
+
if (result.score >= 80) {
|
|
2521
|
+
result.allowed = false;
|
|
2522
|
+
result.action = 'dropped';
|
|
2523
|
+
} else if (result.score >= 40) {
|
|
2524
|
+
result.action = 'flagged';
|
|
2525
|
+
// Ainda permite o evento, mas loga como suspeito
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
return result;
|
|
2529
|
+
|
|
2530
|
+
} catch (err) {
|
|
2531
|
+
console.error('[Fraud] checkFraudGate error:', err.message);
|
|
2532
|
+
return { allowed: true, score: 0, reasons: ['gate_error_fallback'], action: 'allowed' };
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
// ── logFraudSignal — persiste no D1 em background via ctx.waitUntil ───────────
|
|
2537
|
+
async function logFraudSignal(env, request, payload, fraudResult) {
|
|
2538
|
+
if (!env.DB || fraudResult.action === 'allowed') return; // só loga suspeitos/dropped
|
|
2539
|
+
try {
|
|
2540
|
+
const ip = request.headers.get('CF-Connecting-IP') || '';
|
|
2541
|
+
const ua = request.headers.get('User-Agent') || '';
|
|
2542
|
+
const fingerprint = payload.fingerprint || '';
|
|
2543
|
+
const botScore = parseInt(payload.botScore || payload.bot_score || 0);
|
|
2544
|
+
const asn = String(request.cf?.asOrganization || '');
|
|
2545
|
+
const country = (request.cf?.country || '');
|
|
2546
|
+
const velKey1h = `fraud_velocity:${ip}:h`;
|
|
2547
|
+
const vel1h = env.GEO_CACHE ? parseInt(await env.GEO_CACHE.get(velKey1h) || '0') : 0;
|
|
2548
|
+
|
|
2549
|
+
let emailHash = null;
|
|
2550
|
+
if (payload.email) {
|
|
2551
|
+
try { emailHash = await sha256(payload.email.trim().toLowerCase()); } catch {}
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
await env.DB.prepare(`
|
|
2555
|
+
INSERT INTO fraud_signals (
|
|
2556
|
+
ip_address, fingerprint, user_id, email_hash, event_name, event_id,
|
|
2557
|
+
fraud_score, action_taken, reasons,
|
|
2558
|
+
ip_country, ip_asn, user_agent, bot_score, velocity_1h, detected_at
|
|
2559
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
|
|
2560
|
+
`).bind(
|
|
2561
|
+
ip, fingerprint || null, payload.userId || null, emailHash,
|
|
2562
|
+
payload.eventName || null, payload.eventId || null,
|
|
2563
|
+
fraudResult.score, fraudResult.action, JSON.stringify(fraudResult.reasons),
|
|
2564
|
+
country, asn, ua.substring(0, 255), botScore, vel1h,
|
|
2565
|
+
).run();
|
|
2566
|
+
|
|
2567
|
+
// Se dropped com score alto → criar/atualizar fraud_alert para este IP
|
|
2568
|
+
if (fraudResult.action === 'dropped' && ip) {
|
|
2569
|
+
await env.DB.prepare(`
|
|
2570
|
+
INSERT INTO fraud_alerts (alert_type, entity_type, entity_value, events_total, events_dropped, peak_score, first_seen, last_seen, top_reasons)
|
|
2571
|
+
VALUES ('ip_attack', 'ip', ?, 1, 1, ?, datetime('now'), datetime('now'), ?)
|
|
2572
|
+
ON CONFLICT(entity_type, entity_value) DO UPDATE SET
|
|
2573
|
+
events_total = events_total + 1,
|
|
2574
|
+
events_dropped = events_dropped + 1,
|
|
2575
|
+
peak_score = MAX(peak_score, excluded.peak_score),
|
|
2576
|
+
last_seen = datetime('now'),
|
|
2577
|
+
updated_at = datetime('now')
|
|
2578
|
+
`).bind(ip, fraudResult.score, JSON.stringify(fraudResult.reasons)).run().catch(() => {
|
|
2579
|
+
// Pode falhar se ON CONFLICT não funcionar (schema sem UNIQUE) — silent
|
|
2580
|
+
});
|
|
2581
|
+
}
|
|
2582
|
+
} catch (err) {
|
|
2583
|
+
console.error('[Fraud] logFraudSignal error:', err.message);
|
|
2584
|
+
}
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
// ── handleFraudAlerts — GET /api/fraud/alerts ─────────────────────────────────
|
|
2588
|
+
async function handleFraudAlerts(env, request, headers) {
|
|
2589
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
2590
|
+
|
|
2591
|
+
const url = new URL(request.url);
|
|
2592
|
+
const action = url.searchParams.get('action') || null; // 'dropped','flagged'
|
|
2593
|
+
const hours = parseInt(url.searchParams.get('hours') || '24');
|
|
2594
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 200);
|
|
2595
|
+
|
|
2596
|
+
try {
|
|
2597
|
+
const cond = action ? 'AND action_taken = ?' : '';
|
|
2598
|
+
const bindings = action ? [hours, action, limit] : [hours, limit];
|
|
2599
|
+
|
|
2600
|
+
const result = await env.DB.prepare(`
|
|
2601
|
+
SELECT ip_address, fingerprint, event_name, fraud_score, action_taken,
|
|
2602
|
+
reasons, ip_country, ip_asn, bot_score, velocity_1h, detected_at
|
|
2603
|
+
FROM fraud_signals
|
|
2604
|
+
WHERE detected_at >= datetime('now', '-' || ? || ' hours')
|
|
2605
|
+
${cond}
|
|
2606
|
+
ORDER BY fraud_score DESC, detected_at DESC
|
|
2607
|
+
LIMIT ?
|
|
2608
|
+
`).bind(...bindings).all();
|
|
2609
|
+
|
|
2610
|
+
const signals = (result.results || []).map(s => ({
|
|
2611
|
+
...s,
|
|
2612
|
+
reasons: tryParseJson(s.reasons, []),
|
|
2613
|
+
}));
|
|
2614
|
+
|
|
2615
|
+
// Stats rápidas
|
|
2616
|
+
const stats = await env.DB.prepare(`SELECT * FROM v_fraud_dashboard`).first().catch(() => null);
|
|
2617
|
+
|
|
2618
|
+
return new Response(JSON.stringify({
|
|
2619
|
+
success: true,
|
|
2620
|
+
period_hours: hours,
|
|
2621
|
+
total: signals.length,
|
|
2622
|
+
stats,
|
|
2623
|
+
alerts: signals,
|
|
2624
|
+
}), { status: 200, headers });
|
|
2625
|
+
|
|
2626
|
+
} catch (err) {
|
|
2627
|
+
console.error('[Fraud] alerts error:', err.message);
|
|
2628
|
+
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
// ── handleFraudBlocklist — GET /api/fraud/blocklist ──────────────────────────
|
|
2633
|
+
async function handleFraudBlocklist(env, request, headers) {
|
|
2634
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
2635
|
+
|
|
2636
|
+
try {
|
|
2637
|
+
const result = await env.DB.prepare(`
|
|
2638
|
+
SELECT entity_type, entity_value, events_total, events_dropped,
|
|
2639
|
+
peak_score, first_seen, last_seen, blocked_at, block_expires, top_reasons
|
|
2640
|
+
FROM fraud_alerts
|
|
2641
|
+
WHERE is_blocked = 1
|
|
2642
|
+
ORDER BY events_dropped DESC
|
|
2643
|
+
LIMIT 100
|
|
2644
|
+
`).all();
|
|
2645
|
+
|
|
2646
|
+
const blocklist = (result.results || []).map(r => ({
|
|
2647
|
+
...r,
|
|
2648
|
+
top_reasons: tryParseJson(r.top_reasons, []),
|
|
2649
|
+
}));
|
|
2650
|
+
|
|
2651
|
+
return new Response(JSON.stringify({
|
|
2652
|
+
success: true,
|
|
2653
|
+
total: blocklist.length,
|
|
2654
|
+
blocklist,
|
|
2655
|
+
}), { status: 200, headers });
|
|
2656
|
+
|
|
2657
|
+
} catch (err) {
|
|
2658
|
+
console.error('[Fraud] blocklist error:', err.message);
|
|
2659
|
+
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
// ── handleFraudBlocklistAdd — POST /api/fraud/blocklist/add ──────────────────
|
|
2664
|
+
async function handleFraudBlocklistAdd(env, request, headers) {
|
|
2665
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
2666
|
+
|
|
2667
|
+
let body;
|
|
2668
|
+
try { body = await request.json(); }
|
|
2669
|
+
catch { return new Response(JSON.stringify({ error: 'JSON inválido' }), { status: 400, headers }); }
|
|
2670
|
+
|
|
2671
|
+
const { entity_type, entity_value, ttl_hours = 24, reason = 'manual_block' } = body;
|
|
2672
|
+
if (!entity_type || !entity_value) {
|
|
2673
|
+
return new Response(JSON.stringify({ error: 'entity_type (ip|fingerprint) e entity_value são obrigatórios' }), { status: 400, headers });
|
|
2674
|
+
}
|
|
2675
|
+
if (!['ip', 'fingerprint'].includes(entity_type)) {
|
|
2676
|
+
return new Response(JSON.stringify({ error: 'entity_type deve ser: ip ou fingerprint' }), { status: 400, headers });
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
try {
|
|
2680
|
+
const kvKey = `fraud_block:${entity_type}:${entity_value}`;
|
|
2681
|
+
const ttlSec = Math.min(ttl_hours * 3600, 7 * 24 * 3600); // max 7 dias
|
|
2682
|
+
const expiresAt = new Date(Date.now() + ttlSec * 1000).toISOString();
|
|
2683
|
+
|
|
2684
|
+
// Adicionar no KV (checagem instantânea em /track)
|
|
2685
|
+
if (env.GEO_CACHE) {
|
|
2686
|
+
await env.GEO_CACHE.put(kvKey, JSON.stringify({ reason, blocked_at: new Date().toISOString() }), { expirationTtl: ttlSec });
|
|
2687
|
+
}
|
|
2688
|
+
|
|
2689
|
+
// Registrar no D1 para auditoria
|
|
2690
|
+
await env.DB.prepare(`
|
|
2691
|
+
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)
|
|
2692
|
+
VALUES ('manual', ?, ?, 0, 0, 100, datetime('now'), datetime('now'), 1, datetime('now'), ?, ?)
|
|
2693
|
+
ON CONFLICT DO UPDATE SET is_blocked = 1, blocked_at = datetime('now'), block_expires = excluded.block_expires, updated_at = datetime('now')
|
|
2694
|
+
`).bind(entity_type, entity_value, expiresAt, JSON.stringify([reason])).run().catch(() => {
|
|
2695
|
+
// fallback se não tiver UNIQUE constraint na fraud_alerts
|
|
2696
|
+
});
|
|
2697
|
+
|
|
2698
|
+
return new Response(JSON.stringify({
|
|
2699
|
+
success: true,
|
|
2700
|
+
entity_type,
|
|
2701
|
+
entity_value,
|
|
2702
|
+
kv_key: kvKey,
|
|
2703
|
+
ttl_hours,
|
|
2704
|
+
expires_at: expiresAt,
|
|
2705
|
+
message: `${entity_type} '${entity_value}' bloqueado por ${ttl_hours}h. Efeito imediato via KV.`,
|
|
2706
|
+
}), { status: 200, headers });
|
|
2707
|
+
|
|
2708
|
+
} catch (err) {
|
|
2709
|
+
console.error('[Fraud] blocklist add error:', err.message);
|
|
2710
|
+
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
// ── handleFraudBlocklistRemove — DELETE /api/fraud/blocklist/remove ──────────
|
|
2715
|
+
async function handleFraudBlocklistRemove(env, request, headers) {
|
|
2716
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
2717
|
+
|
|
2718
|
+
let body;
|
|
2719
|
+
try { body = await request.json(); }
|
|
2720
|
+
catch { return new Response(JSON.stringify({ error: 'JSON inválido' }), { status: 400, headers }); }
|
|
2721
|
+
|
|
2722
|
+
const { entity_type, entity_value } = body;
|
|
2723
|
+
if (!entity_type || !entity_value) {
|
|
2724
|
+
return new Response(JSON.stringify({ error: 'entity_type e entity_value são obrigatórios' }), { status: 400, headers });
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
try {
|
|
2728
|
+
const kvKey = `fraud_block:${entity_type}:${entity_value}`;
|
|
2729
|
+
if (env.GEO_CACHE) await env.GEO_CACHE.delete(kvKey);
|
|
2730
|
+
|
|
2731
|
+
await env.DB.prepare(
|
|
2732
|
+
`UPDATE fraud_alerts SET is_blocked = 0, resolved_at = datetime('now'), resolved_by = 'manual' WHERE entity_type = ? AND entity_value = ?`
|
|
2733
|
+
).bind(entity_type, entity_value).run();
|
|
2734
|
+
|
|
2735
|
+
return new Response(JSON.stringify({
|
|
2736
|
+
success: true,
|
|
2737
|
+
entity_type,
|
|
2738
|
+
entity_value,
|
|
2739
|
+
message: `${entity_type} '${entity_value}' removido do blocklist. Efeito imediato via KV.`,
|
|
2740
|
+
}), { status: 200, headers });
|
|
2741
|
+
|
|
2742
|
+
} catch (err) {
|
|
2743
|
+
console.error('[Fraud] blocklist remove error:', err.message);
|
|
2744
|
+
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
2745
|
+
}
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
// ── handleFraudStats — GET /api/fraud/stats ───────────────────────────────────
|
|
2749
|
+
async function handleFraudStats(env, request, headers) {
|
|
2750
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
2751
|
+
|
|
2752
|
+
try {
|
|
2753
|
+
const dashboard = await env.DB.prepare(`SELECT * FROM v_fraud_dashboard`).first();
|
|
2754
|
+
const topIps = await env.DB.prepare(`
|
|
2755
|
+
SELECT ip_address, COUNT(*) as events, MAX(fraud_score) as peak_score
|
|
2756
|
+
FROM fraud_signals
|
|
2757
|
+
WHERE detected_at >= datetime('now', '-24 hours') AND action_taken = 'dropped'
|
|
2758
|
+
GROUP BY ip_address ORDER BY events DESC LIMIT 10
|
|
2759
|
+
`).all();
|
|
2760
|
+
const topReasons = await env.DB.prepare(`
|
|
2761
|
+
SELECT action_taken, COUNT(*) as count FROM fraud_signals
|
|
2762
|
+
WHERE detected_at >= datetime('now', '-24 hours')
|
|
2763
|
+
GROUP BY action_taken
|
|
2764
|
+
`).all();
|
|
2765
|
+
|
|
2766
|
+
return new Response(JSON.stringify({
|
|
2767
|
+
success: true,
|
|
2768
|
+
period: '24h',
|
|
2769
|
+
dashboard,
|
|
2770
|
+
top_attacking_ips: topIps.results || [],
|
|
2771
|
+
by_action: topReasons.results || [],
|
|
2772
|
+
}), { status: 200, headers });
|
|
2773
|
+
|
|
2774
|
+
} catch (err) {
|
|
2775
|
+
console.error('[Fraud] stats error:', err.message);
|
|
2776
|
+
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2781
|
+
// A/B TESTING DE PROMPTS LTV — Fase 3 Enterprise-Level
|
|
2782
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2783
|
+
|
|
2784
|
+
// Cache key para o teste ativo (KV — evita hit no D1 a cada request /track)
|
|
2785
|
+
const AB_LTV_CACHE_KEY = 'ab_ltv_active_test';
|
|
2786
|
+
|
|
2787
|
+
// ── getLtvAbVariation — busca e sorteia variação do teste ativo ─────────────
|
|
2788
|
+
// Retorna null se não há teste ativo ou DB/KV indisponíveis
|
|
2789
|
+
async function getLtvAbVariation(env) {
|
|
2790
|
+
if (!env.DB) return null;
|
|
2791
|
+
|
|
2792
|
+
try {
|
|
2793
|
+
// 1. Tentar cache KV (TTL: 5 min)
|
|
2794
|
+
let testData = null;
|
|
2795
|
+
if (env.GEO_CACHE) {
|
|
2796
|
+
const cached = await env.GEO_CACHE.get(AB_LTV_CACHE_KEY, 'json');
|
|
2797
|
+
if (cached) testData = cached;
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
// 2. Cache miss ou KV indisponível → buscar do D1
|
|
2801
|
+
if (!testData) {
|
|
2802
|
+
const test = await env.DB.prepare(`
|
|
2803
|
+
SELECT t.id AS test_id, v.id AS variation_id,
|
|
2804
|
+
v.name, v.system_prompt, v.weight, v.is_control
|
|
2805
|
+
FROM ltv_ab_tests t
|
|
2806
|
+
JOIN ltv_ab_variations v ON v.test_id = t.id
|
|
2807
|
+
WHERE t.status = 'running'
|
|
2808
|
+
ORDER BY t.id DESC
|
|
2809
|
+
`).all();
|
|
2810
|
+
|
|
2811
|
+
if (!test.results || test.results.length === 0) {
|
|
2812
|
+
// Sem teste ativo — cachear null por 5 min para não bater no D1
|
|
2813
|
+
if (env.GEO_CACHE) {
|
|
2814
|
+
await env.GEO_CACHE.put(AB_LTV_CACHE_KEY, JSON.stringify(null), { expirationTtl: 300 });
|
|
2815
|
+
}
|
|
2816
|
+
return null;
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
testData = test.results;
|
|
2820
|
+
if (env.GEO_CACHE) {
|
|
2821
|
+
await env.GEO_CACHE.put(AB_LTV_CACHE_KEY, JSON.stringify(testData), { expirationTtl: 300 });
|
|
2822
|
+
}
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
if (!testData || testData.length === 0) return null;
|
|
2826
|
+
|
|
2827
|
+
// 3. Sortear variação por peso ponderado
|
|
2828
|
+
const totalWeight = testData.reduce((s, v) => s + (v.weight || 0.5), 0);
|
|
2829
|
+
let rand = Math.random() * totalWeight;
|
|
2830
|
+
for (const variation of testData) {
|
|
2831
|
+
rand -= (variation.weight || 0.5);
|
|
2832
|
+
if (rand <= 0) return variation;
|
|
2833
|
+
}
|
|
2834
|
+
return testData[testData.length - 1];
|
|
2835
|
+
|
|
2836
|
+
} catch (err) {
|
|
2837
|
+
console.error('[AB-LTV] getLtvAbVariation error:', err.message);
|
|
2838
|
+
return null; // graceful fallback — nunca quebra o fluxo principal
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
// ── recordAbAssignment — registra a variação usada para um lead ──────────────
|
|
2843
|
+
// Executado via ctx.waitUntil — não bloqueia o /track
|
|
2844
|
+
async function recordAbAssignment(env, userId, variationId, testId, predictedLtv, predictedClass, emailHash) {
|
|
2845
|
+
if (!env.DB) return;
|
|
2846
|
+
try {
|
|
2847
|
+
await env.DB.prepare(`
|
|
2848
|
+
INSERT INTO ltv_ab_assignments
|
|
2849
|
+
(test_id, variation_id, user_id, email_hash, predicted_ltv, predicted_class, assigned_at)
|
|
2850
|
+
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
|
2851
|
+
`).bind(testId, variationId, userId, emailHash || null, predictedLtv || null, predictedClass || null).run();
|
|
2852
|
+
|
|
2853
|
+
// Incrementar contador na variação
|
|
2854
|
+
await env.DB.prepare(
|
|
2855
|
+
`UPDATE ltv_ab_variations SET total_assigned = total_assigned + 1 WHERE id = ?`
|
|
2856
|
+
).bind(variationId).run();
|
|
2857
|
+
} catch (err) {
|
|
2858
|
+
console.error('[AB-LTV] recordAbAssignment error:', err.message);
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
// ── handleLtvAbTestCreate — POST /api/ltv/ab-test/create ─────────────────────
|
|
2863
|
+
async function handleLtvAbTestCreate(env, request, headers) {
|
|
2864
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
2865
|
+
|
|
2866
|
+
let body;
|
|
2867
|
+
try { body = await request.json(); }
|
|
2868
|
+
catch { return new Response(JSON.stringify({ error: 'JSON inválido no body' }), { status: 400, headers }); }
|
|
2869
|
+
|
|
2870
|
+
const { name, description, min_sample = 100, variations } = body;
|
|
2871
|
+
|
|
2872
|
+
if (!name) return new Response(JSON.stringify({ error: 'name é obrigatório' }), { status: 400, headers });
|
|
2873
|
+
if (!Array.isArray(variations) || variations.length < 2) {
|
|
2874
|
+
return new Response(JSON.stringify({ error: 'Mínimo 2 variações são necessárias' }), { status: 400, headers });
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
// Verificar se há teste em andamento
|
|
2878
|
+
const running = await env.DB.prepare(`SELECT id FROM ltv_ab_tests WHERE status = 'running' LIMIT 1`).first();
|
|
2879
|
+
if (running) {
|
|
2880
|
+
return new Response(JSON.stringify({
|
|
2881
|
+
error: 'Já existe um teste em andamento. Pause ou conclua o teste atual antes de criar um novo.',
|
|
2882
|
+
running_test_id: running.id,
|
|
2883
|
+
}), { status: 409, headers });
|
|
2884
|
+
}
|
|
2885
|
+
|
|
2886
|
+
try {
|
|
2887
|
+
const now = new Date().toISOString();
|
|
2888
|
+
|
|
2889
|
+
// Validar que pesos somam aproximadamente 1.0
|
|
2890
|
+
const totalWeight = variations.reduce((s, v) => s + (v.weight || 0.5), 0);
|
|
2891
|
+
if (Math.abs(totalWeight - 1.0) > 0.05) {
|
|
2892
|
+
return new Response(JSON.stringify({
|
|
2893
|
+
error: `A soma dos weights deve ser 1.0. Recebido: ${totalWeight.toFixed(3)}`,
|
|
2894
|
+
}), { status: 400, headers });
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
const hasControl = variations.some(v => v.is_control);
|
|
2898
|
+
if (!hasControl) {
|
|
2899
|
+
return new Response(JSON.stringify({ error: 'Pelo menos uma variação deve ter is_control: true (baseline)' }), { status: 400, headers });
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
// Criar teste
|
|
2903
|
+
const testRes = await env.DB.prepare(`
|
|
2904
|
+
INSERT INTO ltv_ab_tests (name, description, status, min_sample, created_at)
|
|
2905
|
+
VALUES (?, ?, 'running', ?, ?)
|
|
2906
|
+
`).bind(name, description || null, min_sample, now).run();
|
|
2907
|
+
|
|
2908
|
+
const testId = testRes.meta?.last_row_id;
|
|
2909
|
+
if (!testId) throw new Error('Falha ao criar o teste no D1');
|
|
2910
|
+
|
|
2911
|
+
// Criar variações
|
|
2912
|
+
const createdVariations = [];
|
|
2913
|
+
for (const v of variations) {
|
|
2914
|
+
const vRes = await env.DB.prepare(`
|
|
2915
|
+
INSERT INTO ltv_ab_variations (test_id, name, system_prompt, weight, is_control, created_at)
|
|
2916
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
2917
|
+
`).bind(testId, v.name, v.system_prompt, v.weight || 0.5, v.is_control ? 1 : 0, now).run();
|
|
2918
|
+
createdVariations.push({ id: vRes.meta?.last_row_id, name: v.name, weight: v.weight, is_control: !!v.is_control });
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
// Invalidar cache KV
|
|
2922
|
+
if (env.GEO_CACHE) await env.GEO_CACHE.delete(AB_LTV_CACHE_KEY);
|
|
2923
|
+
|
|
2924
|
+
return new Response(JSON.stringify({
|
|
2925
|
+
success: true,
|
|
2926
|
+
test_id: testId,
|
|
2927
|
+
name,
|
|
2928
|
+
status: 'running',
|
|
2929
|
+
min_sample,
|
|
2930
|
+
variations: createdVariations,
|
|
2931
|
+
started_at: now,
|
|
2932
|
+
}), { status: 201, headers });
|
|
2933
|
+
|
|
2934
|
+
} catch (err) {
|
|
2935
|
+
console.error('[AB-LTV] create error:', err.message);
|
|
2936
|
+
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
// ── handleLtvAbTestList — GET /api/ltv/ab-test/list ──────────────────────────
|
|
2941
|
+
async function handleLtvAbTestList(env, request, headers) {
|
|
2942
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
2943
|
+
|
|
2944
|
+
const url = new URL(request.url);
|
|
2945
|
+
const status = url.searchParams.get('status') || null;
|
|
2946
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 50);
|
|
2947
|
+
|
|
2948
|
+
try {
|
|
2949
|
+
const cond = status ? 'WHERE t.status = ?' : '';
|
|
2950
|
+
const bindings = status ? [status, limit] : [limit];
|
|
2951
|
+
|
|
2952
|
+
const tests = await env.DB.prepare(`
|
|
2953
|
+
SELECT t.id, t.name, t.description, t.status, t.winner_id,
|
|
2954
|
+
t.started_at, t.completed_at, t.created_at, t.min_sample,
|
|
2955
|
+
COUNT(DISTINCT v.id) AS variation_count,
|
|
2956
|
+
SUM(v.total_assigned) AS total_assigned
|
|
2957
|
+
FROM ltv_ab_tests t
|
|
2958
|
+
LEFT JOIN ltv_ab_variations v ON v.test_id = t.id
|
|
2959
|
+
${cond}
|
|
2960
|
+
GROUP BY t.id
|
|
2961
|
+
ORDER BY t.created_at DESC
|
|
2962
|
+
LIMIT ?
|
|
2963
|
+
`).bind(...bindings).all();
|
|
2964
|
+
|
|
2965
|
+
return new Response(JSON.stringify({
|
|
2966
|
+
success: true,
|
|
2967
|
+
total: (tests.results || []).length,
|
|
2968
|
+
tests: tests.results || [],
|
|
2969
|
+
}), { status: 200, headers });
|
|
2970
|
+
|
|
2971
|
+
} catch (err) {
|
|
2972
|
+
console.error('[AB-LTV] list error:', err.message);
|
|
2973
|
+
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
// ── handleLtvAbTestResults — GET /api/ltv/ab-test/results ────────────────────
|
|
2978
|
+
async function handleLtvAbTestResults(env, request, headers) {
|
|
2979
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
2980
|
+
|
|
2981
|
+
const url = new URL(request.url);
|
|
2982
|
+
const testId = url.searchParams.get('test_id') || null;
|
|
2983
|
+
|
|
2984
|
+
try {
|
|
2985
|
+
const cond = testId ? 'WHERE test_id = ?' : 'WHERE status = \'running\'';
|
|
2986
|
+
const testBind = testId ? [parseInt(testId)] : [];
|
|
2987
|
+
|
|
2988
|
+
const testRes = await env.DB.prepare(`
|
|
2989
|
+
SELECT id, name, status, min_sample, winner_id, started_at FROM ltv_ab_tests ${cond} LIMIT 1
|
|
2990
|
+
`).bind(...testBind).first();
|
|
2991
|
+
|
|
2992
|
+
if (!testRes) {
|
|
2993
|
+
return new Response(JSON.stringify({ error: 'Nenhum teste encontrado' }), { status: 404, headers });
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
const perf = await env.DB.prepare(`
|
|
2997
|
+
SELECT * FROM v_ab_test_performance WHERE test_id = ?
|
|
2998
|
+
`).bind(testRes.id).all();
|
|
2999
|
+
|
|
3000
|
+
const variations = perf.results || [];
|
|
3001
|
+
const ready = variations.every(v => (v.total_assigned || 0) >= testRes.min_sample);
|
|
3002
|
+
let recommendation = null;
|
|
3003
|
+
|
|
3004
|
+
if (ready && variations.length > 0) {
|
|
3005
|
+
const best = variations.reduce((a, b) =>
|
|
3006
|
+
(b.accuracy_score || 0) > (a.accuracy_score || 0) ? b : a
|
|
3007
|
+
);
|
|
3008
|
+
const control = variations.find(v => v.is_control);
|
|
3009
|
+
const improvement = control
|
|
3010
|
+
? ((best.accuracy_score || 0) - (control.accuracy_score || 0)) * 100
|
|
3011
|
+
: null;
|
|
3012
|
+
recommendation = {
|
|
3013
|
+
winner_variation_id: best.variation_id,
|
|
3014
|
+
winner_variation_name: best.variation_name,
|
|
3015
|
+
accuracy_score: best.accuracy_score,
|
|
3016
|
+
improvement_vs_control: improvement ? `+${improvement.toFixed(1)}%` : null,
|
|
3017
|
+
ready_to_declare: true,
|
|
3018
|
+
};
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
return new Response(JSON.stringify({
|
|
3022
|
+
success: true,
|
|
3023
|
+
test: {
|
|
3024
|
+
id: testRes.id,
|
|
3025
|
+
name: testRes.name,
|
|
3026
|
+
status: testRes.status,
|
|
3027
|
+
min_sample: testRes.min_sample,
|
|
3028
|
+
started_at: testRes.started_at,
|
|
3029
|
+
is_ready: ready,
|
|
3030
|
+
},
|
|
3031
|
+
variations,
|
|
3032
|
+
recommendation,
|
|
3033
|
+
}), { status: 200, headers });
|
|
3034
|
+
|
|
3035
|
+
} catch (err) {
|
|
3036
|
+
console.error('[AB-LTV] results error:', err.message);
|
|
3037
|
+
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
// ── handleLtvAbTestWinner — POST /api/ltv/ab-test/winner ─────────────────────
|
|
3042
|
+
async function handleLtvAbTestWinner(env, request, headers) {
|
|
3043
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
3044
|
+
|
|
3045
|
+
let body;
|
|
3046
|
+
try { body = await request.json(); }
|
|
3047
|
+
catch { return new Response(JSON.stringify({ error: 'JSON inválido' }), { status: 400, headers }); }
|
|
3048
|
+
|
|
3049
|
+
const { test_id, variation_id } = body;
|
|
3050
|
+
if (!test_id || !variation_id) {
|
|
3051
|
+
return new Response(JSON.stringify({ error: 'test_id e variation_id são obrigatórios' }), { status: 400, headers });
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
try {
|
|
3055
|
+
const variation = await env.DB.prepare(
|
|
3056
|
+
`SELECT id, name, system_prompt, is_control FROM ltv_ab_variations WHERE id = ? AND test_id = ?`
|
|
3057
|
+
).bind(variation_id, test_id).first();
|
|
3058
|
+
|
|
3059
|
+
if (!variation) {
|
|
3060
|
+
return new Response(JSON.stringify({ error: 'Variação não encontrada para este teste' }), { status: 404, headers });
|
|
3061
|
+
}
|
|
3062
|
+
|
|
3063
|
+
// Marcar winner e concluir o teste
|
|
3064
|
+
await env.DB.prepare(
|
|
3065
|
+
`UPDATE ltv_ab_tests SET winner_id = ?, status = 'completed', completed_at = datetime('now') WHERE id = ?`
|
|
3066
|
+
).bind(variation_id, test_id).run();
|
|
3067
|
+
|
|
3068
|
+
// Invalidar cache KV (não há mais teste ativo)
|
|
3069
|
+
if (env.GEO_CACHE) await env.GEO_CACHE.delete(AB_LTV_CACHE_KEY);
|
|
3070
|
+
|
|
3071
|
+
return new Response(JSON.stringify({
|
|
3072
|
+
success: true,
|
|
3073
|
+
test_id,
|
|
3074
|
+
winner_variation_id: variation_id,
|
|
3075
|
+
winner_name: variation.name,
|
|
3076
|
+
is_control: variation.is_control === 1,
|
|
3077
|
+
winning_prompt: variation.system_prompt,
|
|
3078
|
+
message: variation.is_control === 1
|
|
3079
|
+
? 'O prompt original (controle) venceu. Nenhuma alteração necessária.'
|
|
3080
|
+
: 'Novo prompt vencedor identificado. Copie o campo winning_prompt e aplique ao predictLtv() como novo default.',
|
|
3081
|
+
}), { status: 200, headers });
|
|
3082
|
+
|
|
3083
|
+
} catch (err) {
|
|
3084
|
+
console.error('[AB-LTV] winner error:', err.message);
|
|
3085
|
+
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3090
|
+
// BIDDING RECOMMENDATIONS — Handlers das Rotas de Otimização de Bids
|
|
3091
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3092
|
+
|
|
3093
|
+
// Fatores de plataforma (conservadores por design — usuário escala gradualmente)
|
|
3094
|
+
const PLATFORM_FACTORS = { meta: 0.85, google: 0.90, tiktok: 0.75 };
|
|
3095
|
+
|
|
3096
|
+
// Multiplicadores por tier de segmento (baseado em avg_ltv_class + avg_behavior_score)
|
|
3097
|
+
function getSegmentMultiplier(avgLtvClass, avgBehaviorScore) {
|
|
3098
|
+
const ltv = parseFloat(avgLtvClass || 0);
|
|
3099
|
+
const eng = parseFloat(avgBehaviorScore || 0);
|
|
3100
|
+
if (ltv >= 0.7 && eng >= 0.7) return 1.4; // Alto Valor + Alto Engajamento
|
|
3101
|
+
if (ltv >= 0.7 && eng >= 0.4) return 1.2; // Alto Valor + Médio Engajamento
|
|
3102
|
+
if (ltv >= 0.4 && eng >= 0.7) return 1.0; // Médio Valor + Alto Engajamento
|
|
3103
|
+
if (ltv >= 0.4 && eng >= 0.4) return 0.8; // Médio Valor + Médio Engajamento
|
|
3104
|
+
return 0.6; // Baixo Valor ou Baixo Engajamento
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
// Ajuste de confiança baseado em volume de conversões
|
|
3108
|
+
function getConfidenceAdjustment(confidence) {
|
|
3109
|
+
if (confidence >= 0.7) return 1.00;
|
|
3110
|
+
if (confidence >= 0.4) return 0.85;
|
|
3111
|
+
return 0.70;
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
// ── POST /api/bidding/recommend ───────────────────────────────────────────────
|
|
3115
|
+
// Gera recomendações de bid para uma plataforma e vertical
|
|
3116
|
+
// Requer binding: DB (AI é opcional — usa fórmula determinística se indisponível)
|
|
3117
|
+
async function handleBiddingRecommend(env, request, headers) {
|
|
3118
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
3119
|
+
|
|
3120
|
+
let body;
|
|
3121
|
+
try { body = await request.json(); }
|
|
3122
|
+
catch { return new Response(JSON.stringify({ error: 'JSON inválido no body' }), { status: 400, headers }); }
|
|
3123
|
+
|
|
3124
|
+
const {
|
|
3125
|
+
vertical = 'geral',
|
|
3126
|
+
platform = 'meta',
|
|
3127
|
+
target_roi = 3.5,
|
|
3128
|
+
period_days = 30,
|
|
3129
|
+
campaign_id = null,
|
|
3130
|
+
budget = null,
|
|
3131
|
+
} = body;
|
|
3132
|
+
|
|
3133
|
+
const platforms = platform === 'all'
|
|
3134
|
+
? Object.keys(PLATFORM_FACTORS)
|
|
3135
|
+
: [platform].filter(p => PLATFORM_FACTORS[p]);
|
|
3136
|
+
|
|
3137
|
+
if (platforms.length === 0) {
|
|
3138
|
+
return new Response(JSON.stringify({ error: 'platform deve ser: meta, google, tiktok ou all' }), { status: 400, headers });
|
|
3139
|
+
}
|
|
3140
|
+
if (target_roi < 1 || target_roi > 20) {
|
|
3141
|
+
return new Response(JSON.stringify({ error: 'target_roi deve estar entre 1 e 20' }), { status: 400, headers });
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
try {
|
|
3145
|
+
// 1. Checar se há segmentos ML ativos (com dados de LTV real)
|
|
3146
|
+
const segmentsRes = await env.DB.prepare(`
|
|
3147
|
+
SELECT
|
|
3148
|
+
ms.id AS segment_id,
|
|
3149
|
+
ms.cluster_name,
|
|
3150
|
+
ms.avg_ltv_class,
|
|
3151
|
+
ms.avg_behavior_score,
|
|
3152
|
+
ms.avg_engagement_score,
|
|
3153
|
+
ms.silhouette_score,
|
|
3154
|
+
COUNT(msm.id) AS member_count,
|
|
3155
|
+
AVG(l.predicted_ltv) AS real_avg_ltv,
|
|
3156
|
+
SUM(CASE WHEN l.event_name IN ('Purchase','CompletePayment') THEN 1 ELSE 0 END) AS conversions
|
|
3157
|
+
FROM ml_segments ms
|
|
3158
|
+
LEFT JOIN ml_segment_members msm ON msm.cluster_id = ms.id
|
|
3159
|
+
LEFT JOIN leads l ON CAST(msm.lead_id AS INTEGER) = l.id
|
|
3160
|
+
AND l.created_at >= datetime('now', '-' || ? || ' days')
|
|
3161
|
+
WHERE ms.is_active = 1
|
|
3162
|
+
AND ms.client_vertical IN (?, 'general')
|
|
3163
|
+
GROUP BY ms.id
|
|
3164
|
+
HAVING member_count > 0
|
|
3165
|
+
ORDER BY real_avg_ltv DESC
|
|
3166
|
+
LIMIT 10
|
|
3167
|
+
`).bind(period_days, vertical).all();
|
|
3168
|
+
|
|
3169
|
+
const segments = segmentsRes.results || [];
|
|
3170
|
+
|
|
3171
|
+
// 2. Fallback se não houver segmentos: usar LTV global dos leads
|
|
3172
|
+
let globalLtv = 0, globalLeads = 0, globalConversions = 0;
|
|
3173
|
+
if (segments.length === 0) {
|
|
3174
|
+
const globalRes = await env.DB.prepare(`
|
|
3175
|
+
SELECT
|
|
3176
|
+
COUNT(*) AS total_leads,
|
|
3177
|
+
AVG(predicted_ltv) AS avg_ltv,
|
|
3178
|
+
SUM(CASE WHEN event_name IN ('Purchase','CompletePayment') THEN 1 ELSE 0 END) AS conversions
|
|
3179
|
+
FROM leads
|
|
3180
|
+
WHERE created_at >= datetime('now', '-' || ? || ' days')
|
|
3181
|
+
AND (bot_score IS NULL OR bot_score < 2)
|
|
3182
|
+
`).bind(period_days).first();
|
|
3183
|
+
|
|
3184
|
+
globalLeads = globalRes?.total_leads || 0;
|
|
3185
|
+
globalLtv = globalRes?.avg_ltv || 0;
|
|
3186
|
+
globalConversions = globalRes?.conversions || 0;
|
|
3187
|
+
|
|
3188
|
+
if (globalLeads < 10) {
|
|
3189
|
+
return new Response(JSON.stringify({
|
|
3190
|
+
error: `Dados insuficientes. Apenas ${globalLeads} leads no período de ${period_days} dias. Mínimo: 10.`,
|
|
3191
|
+
leads_found: globalLeads,
|
|
3192
|
+
required: 10,
|
|
3193
|
+
}), { status: 400, headers });
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
// 3. Gerar recomendações por plataforma × segmento
|
|
3198
|
+
const now = new Date().toISOString();
|
|
3199
|
+
const recommendations = [];
|
|
3200
|
+
|
|
3201
|
+
const targetSegments = segments.length > 0
|
|
3202
|
+
? segments
|
|
3203
|
+
: [{ 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 }];
|
|
3204
|
+
|
|
3205
|
+
for (const seg of targetSegments) {
|
|
3206
|
+
const avgLtv = parseFloat(seg.real_avg_ltv || 0);
|
|
3207
|
+
const convs = parseInt(seg.conversions || 0);
|
|
3208
|
+
const confidence = Math.min(1, convs / 100);
|
|
3209
|
+
|
|
3210
|
+
// Sem LTV real? Usar LTV estimado pela classe do segmento
|
|
3211
|
+
const estimatedLtv = avgLtv > 0 ? avgLtv :
|
|
3212
|
+
seg.avg_ltv_class >= 0.7 ? 497 :
|
|
3213
|
+
seg.avg_ltv_class >= 0.4 ? 297 : 97;
|
|
3214
|
+
|
|
3215
|
+
const cpaTarget = estimatedLtv / target_roi;
|
|
3216
|
+
const segMult = getSegmentMultiplier(seg.avg_ltv_class, seg.avg_behavior_score);
|
|
3217
|
+
const confAdj = getConfidenceAdjustment(confidence);
|
|
3218
|
+
|
|
3219
|
+
const alertMsg = convs < 30
|
|
3220
|
+
? `Atenção: apenas ${convs} conversões no período. Bid baseado em estimativa de LTV — aplique com cautela.`
|
|
3221
|
+
: null;
|
|
3222
|
+
|
|
3223
|
+
for (const plat of platforms) {
|
|
3224
|
+
const platFactor = PLATFORM_FACTORS[plat];
|
|
3225
|
+
const recommendedBid = Math.max(5, cpaTarget * platFactor * segMult * confAdj);
|
|
3226
|
+
const expectedRoi = estimatedLtv / (recommendedBid / platFactor);
|
|
3227
|
+
|
|
3228
|
+
// Guardar no D1 em background
|
|
3229
|
+
const reasoning = `Segmento "${seg.cluster_name}": LTV=${estimatedLtv.toFixed(0)} BRL, ` +
|
|
3230
|
+
`CPA_alvo=${cpaTarget.toFixed(0)} BRL, fator_plataforma=${platFactor}, ` +
|
|
3231
|
+
`mult_segmento=${segMult}, ajuste_confiança=${confAdj}, ` +
|
|
3232
|
+
`base: ${convs} conversões em ${period_days} dias.`;
|
|
3233
|
+
|
|
3234
|
+
try {
|
|
3235
|
+
await env.DB.prepare(`
|
|
3236
|
+
INSERT INTO bid_recommendations (
|
|
3237
|
+
generated_at, vertical, platform, period_days, target_roi,
|
|
3238
|
+
segment_id, segment_name, leads_analyzed, conversions_found,
|
|
3239
|
+
avg_ltv, cpa_target, recommended_bid, bid_currency,
|
|
3240
|
+
confidence, expected_roi, reasoning, ai_used, alert_message,
|
|
3241
|
+
platform_factor, confidence_adjustment, segment_multiplier, is_active
|
|
3242
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,?,?,?,?,1)
|
|
3243
|
+
`).bind(
|
|
3244
|
+
now, vertical, plat, period_days, target_roi,
|
|
3245
|
+
seg.segment_id || null, seg.cluster_name,
|
|
3246
|
+
seg.member_count || globalLeads, convs,
|
|
3247
|
+
estimatedLtv, cpaTarget, recommendedBid, 'BRL',
|
|
3248
|
+
confidence, expectedRoi, reasoning, alertMsg || null,
|
|
3249
|
+
platFactor, confAdj, segMult,
|
|
3250
|
+
).run();
|
|
3251
|
+
} catch (e) { console.error('[Bidding] D1 insert error:', e.message); }
|
|
3252
|
+
|
|
3253
|
+
recommendations.push({
|
|
3254
|
+
platform: plat,
|
|
3255
|
+
segment: seg.cluster_name,
|
|
3256
|
+
segment_id: seg.segment_id || null,
|
|
3257
|
+
avg_ltv: Math.round(estimatedLtv * 100) / 100,
|
|
3258
|
+
avg_ltv_class: seg.avg_ltv_class >= 0.7 ? 'High' : seg.avg_ltv_class >= 0.4 ? 'Medium' : 'Low',
|
|
3259
|
+
cpa_target: Math.round(cpaTarget * 100) / 100,
|
|
3260
|
+
recommended_bid: Math.round(recommendedBid * 100) / 100,
|
|
3261
|
+
bid_currency: 'BRL',
|
|
3262
|
+
confidence: Math.round(confidence * 100) / 100,
|
|
3263
|
+
expected_roi: Math.round(expectedRoi * 100) / 100,
|
|
3264
|
+
reasoning,
|
|
3265
|
+
alert: alertMsg,
|
|
3266
|
+
});
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
// Desativar recomendações anteriores da mesma vertical/plataforma
|
|
3271
|
+
await env.DB.prepare(
|
|
3272
|
+
`UPDATE bid_recommendations SET is_active = 0 WHERE vertical = ? AND generated_at < ? AND is_active = 1`
|
|
3273
|
+
).bind(vertical, now).run().catch(() => {});
|
|
3274
|
+
|
|
3275
|
+
const avgConfidence = recommendations.length > 0
|
|
3276
|
+
? recommendations.reduce((s, r) => s + r.confidence, 0) / recommendations.length
|
|
3277
|
+
: 0;
|
|
3278
|
+
|
|
3279
|
+
return new Response(JSON.stringify({
|
|
3280
|
+
success: true,
|
|
3281
|
+
generated_at: now,
|
|
3282
|
+
vertical,
|
|
3283
|
+
period_days,
|
|
3284
|
+
target_roi,
|
|
3285
|
+
data_quality: {
|
|
3286
|
+
leads_analyzed: targetSegments.reduce((s, sg) => s + (sg.member_count || 0), 0),
|
|
3287
|
+
conversions_found: targetSegments.reduce((s, sg) => s + (sg.conversions || 0), 0),
|
|
3288
|
+
segments_active: segments.length,
|
|
3289
|
+
confidence: Math.round(avgConfidence * 100) / 100,
|
|
3290
|
+
},
|
|
3291
|
+
recommendations,
|
|
3292
|
+
global_summary: {
|
|
3293
|
+
total_recommendations: recommendations.length,
|
|
3294
|
+
avg_confidence: Math.round(avgConfidence * 100) / 100,
|
|
3295
|
+
expected_cost_reduction: avgConfidence >= 0.7 ? '-20%' : avgConfidence >= 0.4 ? '-10%' : 'indefinido (dados insuficientes)',
|
|
3296
|
+
segments_analyzed: segments.length,
|
|
3297
|
+
},
|
|
3298
|
+
}), { status: 200, headers });
|
|
3299
|
+
|
|
3300
|
+
} catch (err) {
|
|
3301
|
+
console.error('[Bidding] recommend error:', err.message);
|
|
3302
|
+
return new Response(JSON.stringify({ error: 'Erro ao gerar recomendações', message: err.message }), { status: 500, headers });
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
// ── GET /api/bidding/history ──────────────────────────────────────────────────
|
|
3307
|
+
// Retorna histórico de recomendações de bids geradas
|
|
3308
|
+
async function handleBiddingHistory(env, request, headers) {
|
|
3309
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
3310
|
+
|
|
3311
|
+
const url = new URL(request.url);
|
|
3312
|
+
const vertical = url.searchParams.get('vertical') || null;
|
|
3313
|
+
const platform = url.searchParams.get('platform') || null;
|
|
3314
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '20'), 100);
|
|
3315
|
+
|
|
3316
|
+
try {
|
|
3317
|
+
const conditions = [];
|
|
3318
|
+
const bindings = [];
|
|
3319
|
+
|
|
3320
|
+
if (vertical) { conditions.push('vertical = ?'); bindings.push(vertical); }
|
|
3321
|
+
if (platform) { conditions.push('platform = ?'); bindings.push(platform); }
|
|
3322
|
+
|
|
3323
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
3324
|
+
|
|
3325
|
+
const result = await env.DB.prepare(`
|
|
3326
|
+
SELECT id, generated_at, vertical, platform, period_days, target_roi,
|
|
3327
|
+
segment_name, leads_analyzed, conversions_found, avg_ltv, cpa_target,
|
|
3328
|
+
recommended_bid, bid_currency, confidence, expected_roi,
|
|
3329
|
+
reasoning, alert_message, ai_used, is_active,
|
|
3330
|
+
applied_at, applied_campaign, applied_result
|
|
3331
|
+
FROM bid_recommendations
|
|
3332
|
+
${where}
|
|
3333
|
+
ORDER BY generated_at DESC
|
|
3334
|
+
LIMIT ?
|
|
3335
|
+
`).bind(...bindings, limit).all();
|
|
3336
|
+
|
|
3337
|
+
const items = (result.results || []).map(r => ({
|
|
3338
|
+
...r,
|
|
3339
|
+
applied_result: tryParseJson(r.applied_result, null),
|
|
3340
|
+
}));
|
|
3341
|
+
|
|
3342
|
+
return new Response(JSON.stringify({
|
|
3343
|
+
success: true,
|
|
3344
|
+
total: items.length,
|
|
3345
|
+
history: items,
|
|
3346
|
+
}), { status: 200, headers });
|
|
3347
|
+
|
|
3348
|
+
} catch (err) {
|
|
3349
|
+
console.error('[Bidding] history error:', err.message);
|
|
3350
|
+
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
// ── GET /api/bidding/status ───────────────────────────────────────────────────
|
|
3355
|
+
// Status atual das recomendações ativas (última por plataforma por vertical)
|
|
3356
|
+
async function handleBiddingStatus(env, request, headers) {
|
|
3357
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
3358
|
+
|
|
3359
|
+
const url = new URL(request.url);
|
|
3360
|
+
const vertical = url.searchParams.get('vertical') || null;
|
|
3361
|
+
|
|
3362
|
+
try {
|
|
3363
|
+
let query = `
|
|
3364
|
+
SELECT platform, vertical, MAX(generated_at) as last_generated,
|
|
3365
|
+
AVG(confidence) as avg_confidence, AVG(recommended_bid) as avg_bid,
|
|
3366
|
+
COUNT(*) as recommendations_count,
|
|
3367
|
+
SUM(CASE WHEN alert_message IS NOT NULL THEN 1 ELSE 0 END) as alerts_count
|
|
3368
|
+
FROM bid_recommendations
|
|
3369
|
+
WHERE is_active = 1
|
|
3370
|
+
`;
|
|
3371
|
+
const bindings = [];
|
|
3372
|
+
if (vertical) { query += ' AND vertical = ?'; bindings.push(vertical); }
|
|
3373
|
+
query += ' GROUP BY platform, vertical ORDER BY last_generated DESC';
|
|
3374
|
+
|
|
3375
|
+
const result = await env.DB.prepare(query).bind(...bindings).all();
|
|
3376
|
+
|
|
3377
|
+
return new Response(JSON.stringify({
|
|
3378
|
+
success: true,
|
|
3379
|
+
status: result.results || [],
|
|
3380
|
+
}), { status: 200, headers });
|
|
3381
|
+
|
|
3382
|
+
} catch (err) {
|
|
3383
|
+
console.error('[Bidding] status error:', err.message);
|
|
3384
|
+
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
|
|
1951
3388
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1952
3389
|
// HANDLER PRINCIPAL
|
|
1953
3390
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1956,7 +3393,7 @@ export default {
|
|
|
1956
3393
|
const origin = request.headers.get('Origin') || '';
|
|
1957
3394
|
const headers = {
|
|
1958
3395
|
'Content-Type': 'application/json',
|
|
1959
|
-
...corsHeaders(origin),
|
|
3396
|
+
...corsHeaders(origin, env.SITE_DOMAIN),
|
|
1960
3397
|
};
|
|
1961
3398
|
|
|
1962
3399
|
// Preflight CORS
|
|
@@ -1966,6 +3403,29 @@ export default {
|
|
|
1966
3403
|
|
|
1967
3404
|
const url = new URL(request.url);
|
|
1968
3405
|
|
|
3406
|
+
// ── Fraud Gate — Fase 4 (apenas em /track e /api) ────────────────────────
|
|
3407
|
+
// Roda ANTES de qualquer processamento de evento
|
|
3408
|
+
// Silent drop (200) — bots não sabem que foram detectados
|
|
3409
|
+
if (url.pathname === '/track' && request.method === 'POST') {
|
|
3410
|
+
let trackBodyForFraud;
|
|
3411
|
+
try {
|
|
3412
|
+
const cloned = request.clone();
|
|
3413
|
+
trackBodyForFraud = await cloned.json().catch(() => ({}));
|
|
3414
|
+
} catch { trackBodyForFraud = {}; }
|
|
3415
|
+
|
|
3416
|
+
const fraudResult = await checkFraudGate(env, request, trackBodyForFraud);
|
|
3417
|
+
if (!fraudResult.allowed) {
|
|
3418
|
+
// Log em background — não bloqueia a resposta
|
|
3419
|
+
ctx.waitUntil(logFraudSignal(env, request, trackBodyForFraud, fraudResult));
|
|
3420
|
+
// Silent drop: retorna 200 com payload de sucesso falso
|
|
3421
|
+
return new Response(JSON.stringify({ status: 'ok', queued: true }), { status: 200, headers });
|
|
3422
|
+
}
|
|
3423
|
+
if (fraudResult.action === 'flagged') {
|
|
3424
|
+
// Suspeito mas permitido — loga em background
|
|
3425
|
+
ctx.waitUntil(logFraudSignal(env, request, trackBodyForFraud, fraudResult));
|
|
3426
|
+
}
|
|
3427
|
+
}
|
|
3428
|
+
|
|
1969
3429
|
// ── GET /export/customer-match — exporta leads para Google Ads (download) ──
|
|
1970
3430
|
if (request.method === 'GET' && url.pathname === '/export/customer-match') {
|
|
1971
3431
|
// Proteção simples por token (mesmo META_ACCESS_TOKEN ou secret dedicado)
|
|
@@ -1981,18 +3441,69 @@ export default {
|
|
|
1981
3441
|
});
|
|
1982
3442
|
}
|
|
1983
3443
|
|
|
1984
|
-
// ── GET /health
|
|
3444
|
+
// ── GET /health — Smoke Test completo ────────────────────────────────────
|
|
1985
3445
|
if (request.method === 'GET' && url.pathname === '/health') {
|
|
3446
|
+
const results = {};
|
|
3447
|
+
|
|
3448
|
+
// D1 — query real
|
|
3449
|
+
try {
|
|
3450
|
+
await env.DB.prepare('SELECT 1').run();
|
|
3451
|
+
results.d1 = 'ok';
|
|
3452
|
+
} catch (err) {
|
|
3453
|
+
results.d1 = `FAILED: ${err.message}`;
|
|
3454
|
+
}
|
|
3455
|
+
|
|
3456
|
+
// KV — leitura real
|
|
3457
|
+
try {
|
|
3458
|
+
await env.GEO_CACHE.get('__health_check__');
|
|
3459
|
+
results.kv = 'ok';
|
|
3460
|
+
} catch (err) {
|
|
3461
|
+
results.kv = `FAILED: ${err.message}`;
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
// Workers AI — ping
|
|
3465
|
+
try {
|
|
3466
|
+
await env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
|
|
3467
|
+
messages: [{ role: 'user', content: 'ping' }],
|
|
3468
|
+
max_tokens: 1,
|
|
3469
|
+
});
|
|
3470
|
+
results.ai = 'ok';
|
|
3471
|
+
} catch (err) {
|
|
3472
|
+
results.ai = `FAILED: ${err.message}`;
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
// Vars obrigatórias
|
|
3476
|
+
const vars = {
|
|
3477
|
+
META_PIXEL_ID: env.META_PIXEL_ID ? 'set' : 'MISSING',
|
|
3478
|
+
GA4_MEASUREMENT_ID: env.GA4_MEASUREMENT_ID ? 'set' : 'MISSING',
|
|
3479
|
+
TIKTOK_PIXEL_ID: env.TIKTOK_PIXEL_ID ? 'set' : 'MISSING',
|
|
3480
|
+
SITE_DOMAIN: env.SITE_DOMAIN ? 'set' : 'MISSING',
|
|
3481
|
+
};
|
|
3482
|
+
|
|
3483
|
+
// Secrets obrigatórios
|
|
3484
|
+
const secrets = {
|
|
3485
|
+
META_ACCESS_TOKEN: env.META_ACCESS_TOKEN ? 'set' : 'MISSING',
|
|
3486
|
+
GA4_API_SECRET: env.GA4_API_SECRET ? 'set' : 'MISSING',
|
|
3487
|
+
WA_WEBHOOK_VERIFY_TOKEN: env.WA_WEBHOOK_VERIFY_TOKEN ? 'set' : 'MISSING',
|
|
3488
|
+
WHATSAPP_ACCESS_TOKEN: env.WHATSAPP_ACCESS_TOKEN ? 'set' : 'not set (optional - only for auto-reply)',
|
|
3489
|
+
WHATSAPP_PHONE_NUMBER_ID: env.WHATSAPP_PHONE_NUMBER_ID ? 'set' : 'not set (optional - only for auto-reply)',
|
|
3490
|
+
WA_NOTIFY_NUMBER: env.WA_NOTIFY_NUMBER ? 'set' : 'not set (optional - only for auto-reply)',
|
|
3491
|
+
TIKTOK_ACCESS_TOKEN: env.TIKTOK_ACCESS_TOKEN ? 'set' : 'not set (optional)',
|
|
3492
|
+
CALLMEBOT_PHONE: env.CALLMEBOT_PHONE ? 'set' : 'not set (optional)',
|
|
3493
|
+
};
|
|
3494
|
+
|
|
3495
|
+
const hasMissing =
|
|
3496
|
+
Object.values(vars).includes('MISSING') ||
|
|
3497
|
+
Object.values(secrets).includes('MISSING') ||
|
|
3498
|
+
results.d1 !== 'ok';
|
|
3499
|
+
|
|
1986
3500
|
return new Response(JSON.stringify({
|
|
1987
|
-
status:
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
ga4_secret: env.GA4_API_SECRET ? 'set' : 'MISSING',
|
|
1994
|
-
tiktok_token: env.TIKTOK_ACCESS_TOKEN ? 'set' : 'not set (optional)',
|
|
1995
|
-
}), { headers });
|
|
3501
|
+
status: hasMissing ? 'degraded' : 'ok',
|
|
3502
|
+
timestamp: new Date().toISOString(),
|
|
3503
|
+
bindings: results,
|
|
3504
|
+
vars,
|
|
3505
|
+
secrets,
|
|
3506
|
+
}, null, 2), { headers });
|
|
1996
3507
|
}
|
|
1997
3508
|
|
|
1998
3509
|
// ── POST /track — evento do browser ───────────────────────────────────────
|
|
@@ -2084,12 +3595,14 @@ export default {
|
|
|
2084
3595
|
|
|
2085
3596
|
const ga4Name = META_TO_GA4[eventName] || eventName.toLowerCase();
|
|
2086
3597
|
|
|
2087
|
-
// ── LTV Prediction
|
|
3598
|
+
// ── LTV Prediction (+ A/B Testing de Prompts) ────────────────────────────
|
|
2088
3599
|
// Lead, Contact, Schedule: sem valor monetário real → injetar LTV preditivo
|
|
2089
3600
|
// Isso treina os algoritmos de Meta/TikTok a priorizar leads de alto valor
|
|
2090
3601
|
const LTV_EVENTS = ['Lead', 'Contact', 'Schedule', 'CompleteRegistration'];
|
|
2091
3602
|
if (LTV_EVENTS.includes(eventName) && !payload.value) {
|
|
2092
|
-
|
|
3603
|
+
// A/B Testing: busca variação ativa (usa KV cache — ~0ms de latência extra)
|
|
3604
|
+
const abVariation = await getLtvAbVariation(env);
|
|
3605
|
+
const ltv = await predictLtv(env, payload, request, abVariation?.system_prompt || null);
|
|
2093
3606
|
payload.value = ltv.value;
|
|
2094
3607
|
payload.currency = payload.currency || 'BRL';
|
|
2095
3608
|
payload.ltvClass = ltv.class;
|
|
@@ -2098,6 +3611,23 @@ export default {
|
|
|
2098
3611
|
ctx.waitUntil(
|
|
2099
3612
|
upsertLtvProfile(env, payload.userId, ltv)
|
|
2100
3613
|
);
|
|
3614
|
+
// Registrar assignment do A/B test em background (não bloqueia)
|
|
3615
|
+
if (abVariation) {
|
|
3616
|
+
const emailHash = payload.email
|
|
3617
|
+
? await sha256(payload.email.trim().toLowerCase())
|
|
3618
|
+
: null;
|
|
3619
|
+
ctx.waitUntil(
|
|
3620
|
+
recordAbAssignment(
|
|
3621
|
+
env,
|
|
3622
|
+
payload.userId,
|
|
3623
|
+
abVariation.variation_id,
|
|
3624
|
+
abVariation.test_id,
|
|
3625
|
+
ltv.value,
|
|
3626
|
+
ltv.class,
|
|
3627
|
+
emailHash,
|
|
3628
|
+
)
|
|
3629
|
+
);
|
|
3630
|
+
}
|
|
2101
3631
|
}
|
|
2102
3632
|
|
|
2103
3633
|
// Cross-Device Graph — background (não bloqueia resposta)
|
|
@@ -2153,7 +3683,7 @@ export default {
|
|
|
2153
3683
|
}
|
|
2154
3684
|
|
|
2155
3685
|
const resHeaders = new Headers(headers);
|
|
2156
|
-
resHeaders.set('Set-Cookie', `_cdp_uid=${finalUserId}; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000; Path
|
|
3686
|
+
resHeaders.set('Set-Cookie', `_cdp_uid=${finalUserId}; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000; Path=/; Domain=.${env.SITE_DOMAIN}`);
|
|
2157
3687
|
|
|
2158
3688
|
return new Response(JSON.stringify({
|
|
2159
3689
|
ok: true,
|
|
@@ -2340,7 +3870,7 @@ export default {
|
|
|
2340
3870
|
|
|
2341
3871
|
// ── POST /webhook/ticto ───────────────────────────────────────────────────
|
|
2342
3872
|
// Ticto Webhook v2 (JSON) — configurar em: Produto → Webhooks → Versão 2.0 → JSON
|
|
2343
|
-
// URL a cadastrar na Ticto: https://
|
|
3873
|
+
// URL a cadastrar na Ticto: https://SEU_DOMINIO/webhook/ticto
|
|
2344
3874
|
// Evento a selecionar: "Venda Realizada" (status: paid | approved | complete)
|
|
2345
3875
|
if (request.method === 'POST' && url.pathname === '/webhook/ticto') {
|
|
2346
3876
|
// Validação HMAC-SHA256 Ticto (X-Ticto-Signature)
|
|
@@ -2535,6 +4065,62 @@ export default {
|
|
|
2535
4065
|
return new Response(JSON.stringify({ ok: true, ...result }), { status: 200, headers });
|
|
2536
4066
|
}
|
|
2537
4067
|
|
|
4068
|
+
// ── Segmentação Dinâmica ML ──────────────────────────────────────────
|
|
4069
|
+
if (url.pathname === '/api/segmentation/cluster' && request.method === 'POST') {
|
|
4070
|
+
return handleSegmentationCluster(env, request, headers);
|
|
4071
|
+
}
|
|
4072
|
+
if (url.pathname === '/api/segmentation/list' && request.method === 'GET') {
|
|
4073
|
+
return handleSegmentationList(env, request, headers);
|
|
4074
|
+
}
|
|
4075
|
+
if (url.pathname === '/api/segmentation/outliers' && request.method === 'GET') {
|
|
4076
|
+
return handleSegmentationOutliers(env, request, headers);
|
|
4077
|
+
}
|
|
4078
|
+
if (url.pathname === '/api/segmentation/update' && request.method === 'PUT') {
|
|
4079
|
+
return handleSegmentationUpdate(env, request, headers);
|
|
4080
|
+
}
|
|
4081
|
+
|
|
4082
|
+
// ── Bidding Recommendations ML ────────────────────────────────────────────
|
|
4083
|
+
if (url.pathname === '/api/bidding/recommend' && request.method === 'POST') {
|
|
4084
|
+
return handleBiddingRecommend(env, request, headers);
|
|
4085
|
+
}
|
|
4086
|
+
if (url.pathname === '/api/bidding/history' && request.method === 'GET') {
|
|
4087
|
+
return handleBiddingHistory(env, request, headers);
|
|
4088
|
+
}
|
|
4089
|
+
if (url.pathname === '/api/bidding/status' && request.method === 'GET') {
|
|
4090
|
+
return handleBiddingStatus(env, request, headers);
|
|
4091
|
+
}
|
|
4092
|
+
|
|
4093
|
+
// ── A/B Testing de Prompts LTV ────────────────────────────────────────────
|
|
4094
|
+
if (url.pathname === '/api/ltv/ab-test/create' && request.method === 'POST') {
|
|
4095
|
+
return handleLtvAbTestCreate(env, request, headers);
|
|
4096
|
+
}
|
|
4097
|
+
if (url.pathname === '/api/ltv/ab-test/list' && request.method === 'GET') {
|
|
4098
|
+
return handleLtvAbTestList(env, request, headers);
|
|
4099
|
+
}
|
|
4100
|
+
if (url.pathname === '/api/ltv/ab-test/results' && request.method === 'GET') {
|
|
4101
|
+
return handleLtvAbTestResults(env, request, headers);
|
|
4102
|
+
}
|
|
4103
|
+
if (url.pathname === '/api/ltv/ab-test/winner' && request.method === 'POST') {
|
|
4104
|
+
return handleLtvAbTestWinner(env, request, headers);
|
|
4105
|
+
}
|
|
4106
|
+
|
|
4107
|
+
// ── Fraud Detection — Fase 4 ──────────────────────────────────────────────
|
|
4108
|
+
if (url.pathname === '/api/fraud/alerts' && request.method === 'GET') {
|
|
4109
|
+
return handleFraudAlerts(env, request, headers);
|
|
4110
|
+
}
|
|
4111
|
+
if (url.pathname === '/api/fraud/blocklist' && request.method === 'GET') {
|
|
4112
|
+
return handleFraudBlocklist(env, request, headers);
|
|
4113
|
+
}
|
|
4114
|
+
if (url.pathname === '/api/fraud/blocklist/add' && request.method === 'POST') {
|
|
4115
|
+
return handleFraudBlocklistAdd(env, request, headers);
|
|
4116
|
+
}
|
|
4117
|
+
if (url.pathname === '/api/fraud/blocklist/remove' && request.method === 'DELETE') {
|
|
4118
|
+
return handleFraudBlocklistRemove(env, request, headers);
|
|
4119
|
+
}
|
|
4120
|
+
if (url.pathname === '/api/fraud/stats' && request.method === 'GET') {
|
|
4121
|
+
return handleFraudStats(env, request, headers);
|
|
4122
|
+
}
|
|
4123
|
+
|
|
2538
4124
|
// 404 para rotas não encontradas
|
|
2539
4125
|
return new Response(
|
|
2540
4126
|
JSON.stringify({ error: 'rota não encontrada' }),
|