cdp-edge 1.13.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 +172 -172
- 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/devops-agent.md +9 -1
- package/extracted-skill/tracking-events-generator/agents/domain-setup-agent.md +6 -0
- package/extracted-skill/tracking-events-generator/agents/fraud-detection-agent.md +142 -0
- package/extracted-skill/tracking-events-generator/agents/master-orchestrator.md +11 -1
- 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 +189 -14
- 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/worker.js +1461 -18
- package/server-edge-tracker/wrangler.toml +3 -3
|
@@ -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
|
);
|
|
@@ -1161,8 +1161,8 @@ async function sendSpotifyCapi(env, eventName, payload, request, ctx) {
|
|
|
1161
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:
|
|
@@ -1179,7 +1179,7 @@ async function sendSpotifyCapi(env, eventName, payload, request, ctx) {
|
|
|
1179
1179
|
// a uma mensagem do usuário nos últimos 24h.
|
|
1180
1180
|
//
|
|
1181
1181
|
async function sendWhatsApp(env, tipo, payload, options = {}) {
|
|
1182
|
-
if (!env.
|
|
1182
|
+
if (!env.WHATSAPP_PHONE_NUMBER_ID || !env.WHATSAPP_ACCESS_TOKEN || !env.WA_NOTIFY_NUMBER) {
|
|
1183
1183
|
return { skipped: 'WhatsApp não configurado' };
|
|
1184
1184
|
}
|
|
1185
1185
|
|
|
@@ -1297,11 +1297,11 @@ async function sendWhatsApp(env, tipo, payload, options = {}) {
|
|
|
1297
1297
|
// ── Executor interno — evita duplicação de fetch entre os formatos ────────────
|
|
1298
1298
|
async function _sendWARequest(env, body) {
|
|
1299
1299
|
try {
|
|
1300
|
-
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`, {
|
|
1301
1301
|
method: 'POST',
|
|
1302
1302
|
headers: {
|
|
1303
1303
|
'Content-Type': 'application/json',
|
|
1304
|
-
'Authorization': `Bearer ${env.
|
|
1304
|
+
'Authorization': `Bearer ${env.WHATSAPP_ACCESS_TOKEN}`,
|
|
1305
1305
|
},
|
|
1306
1306
|
body: JSON.stringify(body),
|
|
1307
1307
|
});
|
|
@@ -1567,7 +1567,7 @@ async function upsertLtvProfile(env, userId, ltv) {
|
|
|
1567
1567
|
* class: 'High' | 'Medium' | 'Low'
|
|
1568
1568
|
* value: valor em BRL (base × multiplicador da classe)
|
|
1569
1569
|
*/
|
|
1570
|
-
async function predictLtv(env, payload, request) {
|
|
1570
|
+
async function predictLtv(env, payload, request, customSystemPrompt = null) {
|
|
1571
1571
|
let score = 0;
|
|
1572
1572
|
|
|
1573
1573
|
// 1. Engajamento browser (0–30)
|
|
@@ -1640,8 +1640,10 @@ async function predictLtv(env, payload, request) {
|
|
|
1640
1640
|
let aiAdjustment = 0;
|
|
1641
1641
|
if (env.AI && score >= 40) {
|
|
1642
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.';
|
|
1643
1645
|
const prompt = [
|
|
1644
|
-
{ role: 'system', content:
|
|
1646
|
+
{ role: 'system', content: systemContent },
|
|
1645
1647
|
{ role: 'user', content: JSON.stringify({
|
|
1646
1648
|
utm_source: payload.utmSource,
|
|
1647
1649
|
intention: intentionLevel,
|
|
@@ -2042,6 +2044,1347 @@ async function buildGoogleCustomerMatchExport(env) {
|
|
|
2042
2044
|
);
|
|
2043
2045
|
}
|
|
2044
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
|
+
|
|
2045
3388
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2046
3389
|
// HANDLER PRINCIPAL
|
|
2047
3390
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -2060,6 +3403,29 @@ export default {
|
|
|
2060
3403
|
|
|
2061
3404
|
const url = new URL(request.url);
|
|
2062
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
|
+
|
|
2063
3429
|
// ── GET /export/customer-match — exporta leads para Google Ads (download) ──
|
|
2064
3430
|
if (request.method === 'GET' && url.pathname === '/export/customer-match') {
|
|
2065
3431
|
// Proteção simples por token (mesmo META_ACCESS_TOKEN ou secret dedicado)
|
|
@@ -2116,12 +3482,14 @@ export default {
|
|
|
2116
3482
|
|
|
2117
3483
|
// Secrets obrigatórios
|
|
2118
3484
|
const secrets = {
|
|
2119
|
-
META_ACCESS_TOKEN:
|
|
2120
|
-
GA4_API_SECRET:
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
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)',
|
|
2125
3493
|
};
|
|
2126
3494
|
|
|
2127
3495
|
const hasMissing =
|
|
@@ -2227,12 +3595,14 @@ export default {
|
|
|
2227
3595
|
|
|
2228
3596
|
const ga4Name = META_TO_GA4[eventName] || eventName.toLowerCase();
|
|
2229
3597
|
|
|
2230
|
-
// ── LTV Prediction
|
|
3598
|
+
// ── LTV Prediction (+ A/B Testing de Prompts) ────────────────────────────
|
|
2231
3599
|
// Lead, Contact, Schedule: sem valor monetário real → injetar LTV preditivo
|
|
2232
3600
|
// Isso treina os algoritmos de Meta/TikTok a priorizar leads de alto valor
|
|
2233
3601
|
const LTV_EVENTS = ['Lead', 'Contact', 'Schedule', 'CompleteRegistration'];
|
|
2234
3602
|
if (LTV_EVENTS.includes(eventName) && !payload.value) {
|
|
2235
|
-
|
|
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);
|
|
2236
3606
|
payload.value = ltv.value;
|
|
2237
3607
|
payload.currency = payload.currency || 'BRL';
|
|
2238
3608
|
payload.ltvClass = ltv.class;
|
|
@@ -2241,6 +3611,23 @@ export default {
|
|
|
2241
3611
|
ctx.waitUntil(
|
|
2242
3612
|
upsertLtvProfile(env, payload.userId, ltv)
|
|
2243
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
|
+
}
|
|
2244
3631
|
}
|
|
2245
3632
|
|
|
2246
3633
|
// Cross-Device Graph — background (não bloqueia resposta)
|
|
@@ -2678,6 +4065,62 @@ export default {
|
|
|
2678
4065
|
return new Response(JSON.stringify({ ok: true, ...result }), { status: 200, headers });
|
|
2679
4066
|
}
|
|
2680
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
|
+
|
|
2681
4124
|
// 404 para rotas não encontradas
|
|
2682
4125
|
return new Response(
|
|
2683
4126
|
JSON.stringify({ error: 'rota não encontrada' }),
|