cdp-edge 2.0.2 → 2.0.4
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 +325 -308
- package/contracts/agent-versions.json +364 -0
- package/dist/commands/install.js +1 -1
- package/dist/commands/setup.js +1 -1
- package/extracted-skill/tracking-events-generator/agents/browser-tracking.md +2 -2
- package/extracted-skill/tracking-events-generator/agents/master-orchestrator.md +14 -20
- package/extracted-skill/tracking-events-generator/agents/premium-tracking-intelligence-agent.md +4 -4
- package/extracted-skill/tracking-events-generator/agents/server-tracking.md +13 -13
- package/extracted-skill/tracking-events-generator/agents/spotify-agent.md +1 -1
- package/extracted-skill/tracking-events-generator/agents/webhook-agent.md +4 -4
- package/extracted-skill/tracking-events-generator/agents/youtube-agent.md +3 -3
- package/package.json +81 -76
- package/server-edge-tracker/index.js +780 -0
- package/server-edge-tracker/modules/db.js +531 -0
- package/server-edge-tracker/modules/dispatch/ga4.js +65 -0
- package/server-edge-tracker/modules/dispatch/meta.js +103 -0
- package/server-edge-tracker/modules/dispatch/platforms.js +237 -0
- package/server-edge-tracker/modules/dispatch/tiktok.js +100 -0
- package/server-edge-tracker/modules/dispatch/whatsapp.js +233 -0
- package/server-edge-tracker/modules/intelligence.js +204 -0
- package/server-edge-tracker/modules/ml/bidding.js +245 -0
- package/server-edge-tracker/modules/ml/fraud.js +301 -0
- package/server-edge-tracker/modules/ml/ltv.js +320 -0
- package/server-edge-tracker/modules/ml/segmentation.js +316 -0
- package/server-edge-tracker/modules/utils.js +89 -0
- package/server-edge-tracker/schema-indexes.sql +67 -0
- package/server-edge-tracker/wrangler.toml +2 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Edge — ML Clustering (Fase 1)
|
|
3
|
+
* Handlers das rotas /api/segmentation/*
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { tryParseJson } from '../utils.js';
|
|
7
|
+
|
|
8
|
+
// ── POST /api/segmentation/cluster ────────────────────────────────────────────
|
|
9
|
+
export async function handleSegmentationCluster(env, request, headers) {
|
|
10
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
11
|
+
if (!env.AI) return new Response(JSON.stringify({ error: 'Workers AI não configurado (verifique binding AI no wrangler.toml)' }), { status: 503, headers });
|
|
12
|
+
|
|
13
|
+
const url = new URL(request.url);
|
|
14
|
+
const algorithm = url.searchParams.get('algorithm') || 'kmeans';
|
|
15
|
+
const nClusters = Math.min(10, Math.max(3, parseInt(url.searchParams.get('n_clusters') || '5')));
|
|
16
|
+
const clientVertical = url.searchParams.get('vertical') || 'general';
|
|
17
|
+
const forceRecluster = url.searchParams.get('force') === 'true';
|
|
18
|
+
|
|
19
|
+
if (!['kmeans', 'dbscan', 'hierarchical'].includes(algorithm)) {
|
|
20
|
+
return new Response(JSON.stringify({ error: 'algorithm deve ser: kmeans, dbscan ou hierarchical', received: algorithm }), { status: 400, headers });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
// 1. Cluster recente? Evitar re-clustering desnecessário (< 7 dias)
|
|
25
|
+
if (!forceRecluster) {
|
|
26
|
+
const existing = await env.DB.prepare(`
|
|
27
|
+
SELECT id, created_at, cluster_name FROM ml_segments
|
|
28
|
+
WHERE clustering_algorithm = ? AND is_active = 1 AND client_vertical = ?
|
|
29
|
+
ORDER BY created_at DESC LIMIT 1
|
|
30
|
+
`).bind(algorithm, clientVertical).first();
|
|
31
|
+
|
|
32
|
+
if (existing) {
|
|
33
|
+
const ageDays = (Date.now() - new Date(existing.created_at).getTime()) / (1000 * 60 * 60 * 24);
|
|
34
|
+
if (ageDays < 7) {
|
|
35
|
+
return new Response(JSON.stringify({
|
|
36
|
+
success: true, message: 'Cluster existente ainda válido (< 7 dias). Use ?force=true para re-clustering.',
|
|
37
|
+
cluster_id: existing.id, cluster_name: existing.cluster_name,
|
|
38
|
+
age_days: Math.round(ageDays * 10) / 10, use_existing: true,
|
|
39
|
+
}), { status: 200, headers });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 2. Extrair leads históricos do D1 (últimos 6 meses, excluindo bots confirmados)
|
|
45
|
+
const leadsRes = await env.DB.prepare(`
|
|
46
|
+
SELECT id, predicted_ltv_class, engagement_score, intention_level,
|
|
47
|
+
country, state, utm_source, utm_medium, bot_score,
|
|
48
|
+
CAST(strftime('%H', created_at) AS INTEGER) AS hour_of_day,
|
|
49
|
+
CAST(julianday('now') - julianday(created_at) AS INTEGER) AS days_since_lead,
|
|
50
|
+
CASE WHEN strftime('%w', created_at) IN ('0','6') THEN 1 ELSE 0 END AS is_weekend
|
|
51
|
+
FROM leads
|
|
52
|
+
WHERE created_at >= datetime('now', '-6 months')
|
|
53
|
+
AND (bot_score IS NULL OR bot_score < 2)
|
|
54
|
+
ORDER BY RANDOM()
|
|
55
|
+
LIMIT 2000
|
|
56
|
+
`).all();
|
|
57
|
+
|
|
58
|
+
const leads = leadsRes.results || [];
|
|
59
|
+
|
|
60
|
+
if (leads.length < 50) {
|
|
61
|
+
return new Response(JSON.stringify({
|
|
62
|
+
error: 'Dados insuficientes para clustering. Mínimo: 50 leads nos últimos 6 meses.',
|
|
63
|
+
leads_found: leads.length, required: 50,
|
|
64
|
+
}), { status: 400, headers });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 3. Feature Engineering — normalização 0–1
|
|
68
|
+
const features = leads.map(l => ({
|
|
69
|
+
id: l.id,
|
|
70
|
+
ltv: l.predicted_ltv_class === 'High' ? 1 : (l.predicted_ltv_class === 'Medium' ? 0.5 : 0),
|
|
71
|
+
engagement: Math.min((l.engagement_score || 0) / 100, 1),
|
|
72
|
+
intention: l.intention_level === 'comprador' || l.intention_level === 'high_intent' ? 1
|
|
73
|
+
: l.intention_level === 'interessado' ? 0.6
|
|
74
|
+
: l.intention_level === 'curioso' ? 0.3 : 0,
|
|
75
|
+
recency: Math.max(0, 1 - (l.days_since_lead || 0) / 180),
|
|
76
|
+
hour: (l.hour_of_day || 12) / 23,
|
|
77
|
+
is_weekend: l.is_weekend || 0,
|
|
78
|
+
is_br: l.country === 'BR' ? 1 : 0,
|
|
79
|
+
is_paid: ['facebook','google','tiktok','instagram','youtube'].includes((l.utm_source || '').toLowerCase()) ? 1 : 0,
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
// 4. Prompt para Workers AI
|
|
83
|
+
const sampleSize = Math.min(features.length, 100);
|
|
84
|
+
const sample = features.slice(0, sampleSize);
|
|
85
|
+
|
|
86
|
+
const clusteringPrompt =
|
|
87
|
+
`You are a customer segmentation ML expert. Perform ${algorithm} clustering on ${sampleSize} customers into ${nClusters} segments.
|
|
88
|
+
|
|
89
|
+
Customer features (all normalized 0-1):
|
|
90
|
+
- ltv: predicted lifetime value (0=Low, 0.5=Medium, 1=High)
|
|
91
|
+
- engagement: browser engagement score
|
|
92
|
+
- intention: purchase intention (0=none, 0.3=curious, 0.6=interested, 1=buyer)
|
|
93
|
+
- recency: lead recency (1=today, 0=6 months ago)
|
|
94
|
+
- hour: conversion hour of day
|
|
95
|
+
- is_weekend: converted on weekend (0/1)
|
|
96
|
+
- is_br: lead from Brazil (0/1)
|
|
97
|
+
- is_paid: from paid traffic channel (0/1)
|
|
98
|
+
|
|
99
|
+
Data (${sampleSize} customers): ${JSON.stringify(sample.slice(0, 50))}
|
|
100
|
+
|
|
101
|
+
Return ONLY valid JSON, zero explanation:
|
|
102
|
+
{
|
|
103
|
+
"clusters": [
|
|
104
|
+
{
|
|
105
|
+
"cluster_id": 0,
|
|
106
|
+
"name": "[Nome Descritivo em Português]",
|
|
107
|
+
"size": ${Math.round(sampleSize / nClusters)},
|
|
108
|
+
"percentage": ${Math.round(100 / nClusters)},
|
|
109
|
+
"characteristics": {
|
|
110
|
+
"avg_ltv_class": 0.5,
|
|
111
|
+
"avg_behavior_score": 0.5,
|
|
112
|
+
"avg_engagement_score": 0.5,
|
|
113
|
+
"avg_intention_level": 0.5,
|
|
114
|
+
"avg_days_since_lead": 30,
|
|
115
|
+
"dominant_countries": ["BR"],
|
|
116
|
+
"dominant_states": ["SP", "RJ"],
|
|
117
|
+
"dominant_utm_sources": ["facebook"],
|
|
118
|
+
"top_features": ["ltv", "engagement"]
|
|
119
|
+
},
|
|
120
|
+
"centroid": { "ltv": 0.5, "engagement": 0.5, "intention": 0.5 },
|
|
121
|
+
"action_recommendation": "[Recomendação de campanha específica para este segmento]"
|
|
122
|
+
}
|
|
123
|
+
],
|
|
124
|
+
"silhouette_score": 0.65,
|
|
125
|
+
"total_processed": ${sampleSize}
|
|
126
|
+
}`;
|
|
127
|
+
|
|
128
|
+
// 5. Workers AI
|
|
129
|
+
const startTime = Date.now();
|
|
130
|
+
const aiRes = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
|
|
131
|
+
messages: [{ role: 'user', content: clusteringPrompt }],
|
|
132
|
+
max_tokens: 2000,
|
|
133
|
+
});
|
|
134
|
+
const duration = Date.now() - startTime;
|
|
135
|
+
|
|
136
|
+
if (!aiRes?.response) throw new Error('Workers AI não retornou resposta');
|
|
137
|
+
|
|
138
|
+
const jsonMatch = aiRes.response.trim().match(/\{[\s\S]*\}/);
|
|
139
|
+
if (!jsonMatch) throw new Error('Resposta do Workers AI não contém JSON válido');
|
|
140
|
+
const mlResult = JSON.parse(jsonMatch[0]);
|
|
141
|
+
|
|
142
|
+
if (!Array.isArray(mlResult.clusters) || mlResult.clusters.length === 0) {
|
|
143
|
+
throw new Error('Workers AI não retornou clusters válidos');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 6. Inativar clusters anteriores
|
|
147
|
+
await env.DB.prepare(`UPDATE ml_segments SET is_active = 0 WHERE clustering_algorithm = ? AND client_vertical = ? AND is_active = 1`).bind(algorithm, clientVertical).run();
|
|
148
|
+
|
|
149
|
+
// 7. Persistir novos clusters
|
|
150
|
+
const now = new Date().toISOString();
|
|
151
|
+
for (const cluster of mlResult.clusters) {
|
|
152
|
+
const ch = cluster.characteristics || {};
|
|
153
|
+
await env.DB.prepare(`
|
|
154
|
+
INSERT INTO ml_segments (
|
|
155
|
+
cluster_id, cluster_name, clustering_algorithm, client_vertical,
|
|
156
|
+
size, percentage, avg_ltv_class, avg_behavior_score, avg_engagement_score,
|
|
157
|
+
avg_intention_level, avg_days_since_lead,
|
|
158
|
+
dominant_countries, dominant_states, dominant_utm_sources, dominant_features,
|
|
159
|
+
silhouette_score, action_recommendations, bid_recommendations, campaign_recommendations,
|
|
160
|
+
is_active, created_at, updated_at
|
|
161
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)
|
|
162
|
+
`).bind(
|
|
163
|
+
cluster.cluster_id || 0, cluster.name || `Segmento ${cluster.cluster_id}`, algorithm, clientVertical,
|
|
164
|
+
cluster.size || 0, cluster.percentage || 0,
|
|
165
|
+
ch.avg_ltv_class || 0, ch.avg_behavior_score || 0, ch.avg_engagement_score || 0,
|
|
166
|
+
ch.avg_intention_level || 0, ch.avg_days_since_lead || 0,
|
|
167
|
+
JSON.stringify(ch.dominant_countries || ['BR']), JSON.stringify(ch.dominant_states || []),
|
|
168
|
+
JSON.stringify(ch.dominant_utm_sources || []), JSON.stringify(ch.top_features || []),
|
|
169
|
+
mlResult.silhouette_score || 0,
|
|
170
|
+
JSON.stringify([cluster.action_recommendation || '']), JSON.stringify([]), JSON.stringify([]),
|
|
171
|
+
now, now,
|
|
172
|
+
).run();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 8. Log no histórico
|
|
176
|
+
try {
|
|
177
|
+
await env.DB.prepare(`
|
|
178
|
+
INSERT INTO ml_clustering_history (
|
|
179
|
+
clustering_id, started_at, completed_at, algorithm,
|
|
180
|
+
n_leads_processed, n_clusters_created, total_duration_ms,
|
|
181
|
+
workers_ai_neurons_used, status, parameters, results_summary
|
|
182
|
+
) VALUES (0, ?, datetime('now'), ?, ?, ?, ?, ?, 'completed', ?, ?)
|
|
183
|
+
`).bind(
|
|
184
|
+
new Date(startTime).toISOString(), algorithm, leads.length, mlResult.clusters.length,
|
|
185
|
+
duration, Math.ceil(duration * 0.01),
|
|
186
|
+
JSON.stringify({ algorithm, n_clusters: nClusters, vertical: clientVertical }),
|
|
187
|
+
JSON.stringify({ clusters: mlResult.clusters.length, silhouette: mlResult.silhouette_score }),
|
|
188
|
+
).run();
|
|
189
|
+
} catch (e) { console.error('[Segmentation] history log error:', e.message); }
|
|
190
|
+
|
|
191
|
+
return new Response(JSON.stringify({
|
|
192
|
+
success: true, algorithm, n_clusters: mlResult.clusters.length, client_vertical: clientVertical,
|
|
193
|
+
leads_analyzed: leads.length, duration_ms: duration, silhouette_score: mlResult.silhouette_score || null,
|
|
194
|
+
clusters: mlResult.clusters, generated_at: now,
|
|
195
|
+
}), { status: 200, headers });
|
|
196
|
+
|
|
197
|
+
} catch (err) {
|
|
198
|
+
console.error('[Segmentation] cluster error:', err.message);
|
|
199
|
+
try {
|
|
200
|
+
if (env.DB) {
|
|
201
|
+
await env.DB.prepare(`
|
|
202
|
+
INSERT INTO ml_clustering_history (clustering_id, started_at, algorithm, n_leads_processed, n_clusters_created, total_duration_ms, workers_ai_neurons_used, status, error_message, parameters, results_summary)
|
|
203
|
+
VALUES (0, datetime('now'), ?, 0, 0, 0, 0, 'failed', ?, ?, '{}')
|
|
204
|
+
`).bind(algorithm, err.message, JSON.stringify({ algorithm, n_clusters: nClusters })).run();
|
|
205
|
+
}
|
|
206
|
+
} catch { /* não bloquear a resposta de erro */ }
|
|
207
|
+
|
|
208
|
+
return new Response(JSON.stringify({ error: 'Erro ao executar clustering', message: err.message }), { status: 500, headers });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── GET /api/segmentation/list ────────────────────────────────────────────────
|
|
213
|
+
export async function handleSegmentationList(env, request, headers) {
|
|
214
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
215
|
+
|
|
216
|
+
const url = new URL(request.url);
|
|
217
|
+
const algorithm = url.searchParams.get('algorithm') || null;
|
|
218
|
+
const vertical = url.searchParams.get('vertical') || null;
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const conditions = ['is_active = 1'];
|
|
222
|
+
const bindings = [];
|
|
223
|
+
if (algorithm) { conditions.push('clustering_algorithm = ?'); bindings.push(algorithm); }
|
|
224
|
+
if (vertical) { conditions.push('client_vertical = ?'); bindings.push(vertical); }
|
|
225
|
+
|
|
226
|
+
const result = await env.DB.prepare(`
|
|
227
|
+
SELECT id, cluster_id, cluster_name, clustering_algorithm, client_vertical,
|
|
228
|
+
size, percentage, avg_ltv_class, avg_behavior_score, avg_engagement_score,
|
|
229
|
+
avg_intention_level, avg_days_since_lead, silhouette_score,
|
|
230
|
+
dominant_countries, dominant_states, dominant_utm_sources, dominant_features,
|
|
231
|
+
action_recommendations, bid_recommendations, campaign_recommendations,
|
|
232
|
+
is_active, created_at, updated_at
|
|
233
|
+
FROM ml_segments
|
|
234
|
+
WHERE ${conditions.join(' AND ')}
|
|
235
|
+
ORDER BY created_at DESC
|
|
236
|
+
LIMIT 50
|
|
237
|
+
`).bind(...bindings).all();
|
|
238
|
+
|
|
239
|
+
const segments = (result.results || []).map(s => ({
|
|
240
|
+
...s,
|
|
241
|
+
dominant_countries: tryParseJson(s.dominant_countries, []),
|
|
242
|
+
dominant_states: tryParseJson(s.dominant_states, []),
|
|
243
|
+
dominant_utm_sources: tryParseJson(s.dominant_utm_sources, []),
|
|
244
|
+
dominant_features: tryParseJson(s.dominant_features, []),
|
|
245
|
+
action_recommendations: tryParseJson(s.action_recommendations, []),
|
|
246
|
+
bid_recommendations: tryParseJson(s.bid_recommendations, []),
|
|
247
|
+
campaign_recommendations: tryParseJson(s.campaign_recommendations, []),
|
|
248
|
+
}));
|
|
249
|
+
|
|
250
|
+
return new Response(JSON.stringify({ success: true, total: segments.length, segments }), { status: 200, headers });
|
|
251
|
+
} catch (err) {
|
|
252
|
+
console.error('[Segmentation] list error:', err.message);
|
|
253
|
+
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── GET /api/segmentation/outliers ───────────────────────────────────────────
|
|
258
|
+
export async function handleSegmentationOutliers(env, request, headers) {
|
|
259
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
260
|
+
|
|
261
|
+
const url = new URL(request.url);
|
|
262
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 200);
|
|
263
|
+
const days = parseInt(url.searchParams.get('days') || '30');
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const result = await env.DB.prepare(`
|
|
267
|
+
SELECT msm.lead_id, msm.cluster_id, msm.confidence, msm.is_outlier, msm.outlier_reason, msm.assigned_at,
|
|
268
|
+
l.email, l.phone, l.country, l.state, l.city, l.utm_source, l.bot_score, l.engagement_score, l.intention_level, l.created_at AS lead_created_at
|
|
269
|
+
FROM ml_segment_members msm
|
|
270
|
+
LEFT JOIN leads l ON CAST(msm.lead_id AS INTEGER) = l.id
|
|
271
|
+
WHERE msm.is_outlier = 1 AND msm.assigned_at >= datetime('now', '-' || ? || ' days')
|
|
272
|
+
ORDER BY msm.assigned_at DESC
|
|
273
|
+
LIMIT ?
|
|
274
|
+
`).bind(days, limit).all();
|
|
275
|
+
|
|
276
|
+
return new Response(JSON.stringify({ success: true, total: (result.results || []).length, period_days: days, outliers: result.results || [] }), { status: 200, headers });
|
|
277
|
+
} catch (err) {
|
|
278
|
+
console.error('[Segmentation] outliers error:', err.message);
|
|
279
|
+
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── PUT /api/segmentation/update ─────────────────────────────────────────────
|
|
284
|
+
export async function handleSegmentationUpdate(env, request, headers) {
|
|
285
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
286
|
+
|
|
287
|
+
let body;
|
|
288
|
+
try { body = await request.json(); }
|
|
289
|
+
catch { return new Response(JSON.stringify({ error: 'JSON inválido no body da requisição' }), { status: 400, headers }); }
|
|
290
|
+
|
|
291
|
+
const { cluster_id, action_recommendations, bid_recommendations, campaign_recommendations } = body;
|
|
292
|
+
if (cluster_id === undefined || cluster_id === null) {
|
|
293
|
+
return new Response(JSON.stringify({ error: 'cluster_id é obrigatório' }), { status: 400, headers });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
const sets = [];
|
|
298
|
+
const bindings = [];
|
|
299
|
+
if (action_recommendations !== undefined) { sets.push('action_recommendations = ?'); bindings.push(JSON.stringify(action_recommendations)); }
|
|
300
|
+
if (bid_recommendations !== undefined) { sets.push('bid_recommendations = ?'); bindings.push(JSON.stringify(bid_recommendations)); }
|
|
301
|
+
if (campaign_recommendations !== undefined) { sets.push('campaign_recommendations = ?'); bindings.push(JSON.stringify(campaign_recommendations)); }
|
|
302
|
+
|
|
303
|
+
if (sets.length === 0) {
|
|
304
|
+
return new Response(JSON.stringify({ error: 'Nenhum campo válido para atualizar (action_recommendations, bid_recommendations, campaign_recommendations)' }), { status: 400, headers });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
sets.push("updated_at = datetime('now')");
|
|
308
|
+
bindings.push(cluster_id);
|
|
309
|
+
|
|
310
|
+
await env.DB.prepare(`UPDATE ml_segments SET ${sets.join(', ')} WHERE id = ?`).bind(...bindings).run();
|
|
311
|
+
return new Response(JSON.stringify({ success: true, cluster_id, fields_updated: sets.length - 1 }), { status: 200, headers });
|
|
312
|
+
} catch (err) {
|
|
313
|
+
console.error('[Segmentation] update error:', err.message);
|
|
314
|
+
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Edge — Utilities
|
|
3
|
+
* Funções puras sem dependências externas.
|
|
4
|
+
* Importadas por todos os outros módulos.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// ── CORS ──────────────────────────────────────────────────────────────────────
|
|
8
|
+
export function isAllowedOrigin(origin, siteDomain) {
|
|
9
|
+
if (!origin || !siteDomain) return false;
|
|
10
|
+
return origin === `https://${siteDomain}`
|
|
11
|
+
|| origin.endsWith(`.${siteDomain}`)
|
|
12
|
+
|| origin === 'http://localhost:3000'
|
|
13
|
+
|| origin === 'http://localhost:5173';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function corsHeaders(origin, siteDomain) {
|
|
17
|
+
const allowed = isAllowedOrigin(origin, siteDomain) ? origin : `https://${siteDomain}`;
|
|
18
|
+
return {
|
|
19
|
+
'Access-Control-Allow-Origin': allowed,
|
|
20
|
+
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
|
|
21
|
+
'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With',
|
|
22
|
+
'Access-Control-Max-Age': '86400',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── SHA-256 via WebCrypto (obrigatório no Cloudflare Workers) ─────────────────
|
|
27
|
+
export async function sha256(value) {
|
|
28
|
+
if (!value) return undefined;
|
|
29
|
+
const clean = String(value).toLowerCase().trim();
|
|
30
|
+
if (!clean) return undefined;
|
|
31
|
+
const buf = await crypto.subtle.digest(
|
|
32
|
+
'SHA-256',
|
|
33
|
+
new TextEncoder().encode(clean)
|
|
34
|
+
);
|
|
35
|
+
return Array.from(new Uint8Array(buf))
|
|
36
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
37
|
+
.join('');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── Normalização de telefone → somente dígitos + DDI 55 ──────────────────────
|
|
41
|
+
export function normalizePhone(phone) {
|
|
42
|
+
if (!phone) return undefined;
|
|
43
|
+
let digits = String(phone).replace(/\D/g, '');
|
|
44
|
+
if (digits.length === 11 && !digits.startsWith('55')) digits = '55' + digits;
|
|
45
|
+
if (digits.length === 10 && !digits.startsWith('55')) digits = '55' + digits;
|
|
46
|
+
return digits.length >= 10 ? digits : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Normalização de cidade → lowercase sem acentos ────────────────────────────
|
|
50
|
+
export function normalizeCity(city) {
|
|
51
|
+
if (!city) return undefined;
|
|
52
|
+
return String(city)
|
|
53
|
+
.toLowerCase()
|
|
54
|
+
.normalize('NFD')
|
|
55
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
56
|
+
.replace(/[^a-z0-9]/g, '');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Parse seguro de JSON armazenado como TEXT no D1 ───────────────────────────
|
|
60
|
+
export function tryParseJson(str, fallback) {
|
|
61
|
+
if (!str) return fallback !== undefined ? fallback : null;
|
|
62
|
+
try { return JSON.parse(str); } catch { return fallback !== undefined ? fallback : null; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Mapa Meta → GA4 event names ───────────────────────────────────────────────
|
|
66
|
+
export const META_TO_GA4 = {
|
|
67
|
+
PageView: 'page_view',
|
|
68
|
+
ViewContent: 'view_item',
|
|
69
|
+
Lead: 'generate_lead',
|
|
70
|
+
Contact: 'generate_lead',
|
|
71
|
+
Schedule: 'generate_lead',
|
|
72
|
+
InitiateCheckout: 'begin_checkout',
|
|
73
|
+
AddToCart: 'add_to_cart',
|
|
74
|
+
AddPaymentInfo: 'add_payment_info',
|
|
75
|
+
Purchase: 'purchase',
|
|
76
|
+
CompleteRegistration: 'sign_up',
|
|
77
|
+
Subscribe: 'subscribe',
|
|
78
|
+
StartTrial: 'start_trial',
|
|
79
|
+
Search: 'search',
|
|
80
|
+
AddToWishlist: 'add_to_wishlist',
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// ── Lista canônica de eventos válidos (19 eventos) ────────────────────────────
|
|
84
|
+
export const VALID_EVENT_NAMES = new Set([
|
|
85
|
+
'PageView','ViewContent','Lead','Purchase','InitiateCheckout',
|
|
86
|
+
'AddToCart','CompleteRegistration','Contact','Schedule',
|
|
87
|
+
'StartTrial','Subscribe','SubmitApplication','Search',
|
|
88
|
+
'video_start','video_25','video_50','video_75','video_complete',
|
|
89
|
+
]);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
-- CDP Edge — Composite Indexes para Performance
|
|
2
|
+
-- Executar após todos os outros schemas:
|
|
3
|
+
-- wrangler d1 execute cdp-edge-db --file=schema-indexes.sql --remote
|
|
4
|
+
--
|
|
5
|
+
-- Todos os índices usam IF NOT EXISTS — idempotente, seguro para re-executar.
|
|
6
|
+
|
|
7
|
+
-- ── leads: queries mais comuns ────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
-- Deduplicação por email + janela temporal (upsertProfile, saveLead)
|
|
10
|
+
CREATE INDEX IF NOT EXISTS idx_leads_email_created
|
|
11
|
+
ON leads(email, created_at DESC);
|
|
12
|
+
|
|
13
|
+
-- Relatórios por plataforma + período (Intelligence Agent, Dashboard)
|
|
14
|
+
CREATE INDEX IF NOT EXISTS idx_leads_platform_created
|
|
15
|
+
ON leads(platform, created_at DESC);
|
|
16
|
+
|
|
17
|
+
-- Lookup de conversão por evento + período (auditErrorRates, generateDailyReport)
|
|
18
|
+
CREATE INDEX IF NOT EXISTS idx_leads_event_created
|
|
19
|
+
ON leads(event_name, created_at DESC);
|
|
20
|
+
|
|
21
|
+
-- Identity resolution: user_id + evento para evitar duplicatas
|
|
22
|
+
CREATE INDEX IF NOT EXISTS idx_leads_userid_event
|
|
23
|
+
ON leads(user_id, event_name);
|
|
24
|
+
|
|
25
|
+
-- Exportação Customer Match: event_name + email filtrado por data
|
|
26
|
+
CREATE INDEX IF NOT EXISTS idx_leads_event_email
|
|
27
|
+
ON leads(event_name, email);
|
|
28
|
+
|
|
29
|
+
-- CRM Dashboard: ordenação por status + criação
|
|
30
|
+
CREATE INDEX IF NOT EXISTS idx_leads_crm_created
|
|
31
|
+
ON leads(crm_status, created_at DESC);
|
|
32
|
+
|
|
33
|
+
-- ── user_profiles: enrichment e Advanced Matching ────────────────────────────
|
|
34
|
+
|
|
35
|
+
-- Busca por LTV class + atualização (Bidding ML consome isso)
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_profiles_ltv_updated
|
|
37
|
+
ON user_profiles(predicted_ltv_class, updated_at DESC);
|
|
38
|
+
|
|
39
|
+
-- Score + cohort para segmentação ML (handleSegmentationCluster)
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_profiles_score_cohort
|
|
41
|
+
ON user_profiles(score DESC, cohort_label);
|
|
42
|
+
|
|
43
|
+
-- Lookup email + updated_at (resolveDeviceGraph, getProfileByEmail)
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_profiles_email_updated
|
|
45
|
+
ON user_profiles(email, updated_at DESC);
|
|
46
|
+
|
|
47
|
+
-- ── fraud_signals: dashboard e alertas ───────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
-- handleFraudAlerts: filtra por ip + período
|
|
50
|
+
CREATE INDEX IF NOT EXISTS idx_fraud_ip_created
|
|
51
|
+
ON fraud_signals(ip_address, created_at DESC);
|
|
52
|
+
|
|
53
|
+
-- handleFraudStats: fraud_score >= threshold ordenado por data
|
|
54
|
+
CREATE INDEX IF NOT EXISTS idx_fraud_score_created
|
|
55
|
+
ON fraud_signals(fraud_score DESC, created_at DESC);
|
|
56
|
+
|
|
57
|
+
-- ── ltv_ab_assignments: resultados de A/B test ───────────────────────────────
|
|
58
|
+
|
|
59
|
+
-- handleLtvAbTestResults: test_id + predicted_class para análise
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_ab_testid_class
|
|
61
|
+
ON ltv_ab_assignments(test_id, predicted_class);
|
|
62
|
+
|
|
63
|
+
-- ── ml_segment_members: join com leads para bidding ─────────────────────────
|
|
64
|
+
|
|
65
|
+
-- handleBiddingRecommend: segment_id lookup
|
|
66
|
+
CREATE INDEX IF NOT EXISTS idx_seg_members_segid
|
|
67
|
+
ON ml_segment_members(segment_id, joined_at DESC);
|