cdp-edge 1.23.3 → 1.24.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.
Files changed (24) hide show
  1. package/README.md +39 -2
  2. package/bin/cdp-edge.js +10 -1
  3. package/contracts/types.ts +81 -0
  4. package/dist/commands/install.js +6 -1
  5. package/docs/whatsapp-ctwa.md +3 -2
  6. package/package.json +7 -4
  7. package/server-edge-tracker/{index.js → index.ts} +91 -82
  8. package/server-edge-tracker/modules/{db.js → db.ts} +116 -76
  9. package/server-edge-tracker/modules/dispatch/{ga4.js → ga4.ts} +12 -10
  10. package/server-edge-tracker/modules/dispatch/{meta.js → meta.ts} +35 -28
  11. package/server-edge-tracker/modules/dispatch/{platforms.js → platforms.ts} +58 -56
  12. package/server-edge-tracker/modules/dispatch/{tiktok.js → tiktok.ts} +22 -20
  13. package/server-edge-tracker/modules/dispatch/{whatsapp.js → whatsapp.ts} +59 -25
  14. package/server-edge-tracker/modules/{intelligence.js → intelligence.ts} +175 -60
  15. package/server-edge-tracker/modules/ml/{bidding.js → bidding.ts} +37 -35
  16. package/server-edge-tracker/modules/ml/{fraud.js → fraud.ts} +48 -40
  17. package/server-edge-tracker/modules/ml/{logistic.js → logistic.ts} +44 -19
  18. package/server-edge-tracker/modules/ml/{ltv.js → ltv.ts} +135 -90
  19. package/server-edge-tracker/modules/ml/{matchquality.js → matchquality.ts} +70 -26
  20. package/server-edge-tracker/modules/ml/{segmentation.js → segmentation.ts} +109 -48
  21. package/server-edge-tracker/modules/{utils.js → utils.ts} +41 -22
  22. package/server-edge-tracker/types.ts +251 -0
  23. package/server-edge-tracker/wrangler.toml +8 -8
  24. package/docs/PixelBuilder-Documentacao-Completa (2).docx +0 -0
@@ -6,9 +6,43 @@
6
6
  */
7
7
 
8
8
  import { sendCallMeBot } from '../dispatch/whatsapp.js';
9
+ import { Env, TrackPayload } from '../../types.js';
10
+ import { D1Database } from '@cloudflare/workers-types';
11
+
12
+ // ── Tipos ───────────────────────────────────────────────────────────────────────
13
+ export interface MatchQualityThresholds {
14
+ email_rate_min: number;
15
+ fbp_rate_min: number;
16
+ composite_min: number;
17
+ min_events_alert: number;
18
+ }
19
+
20
+ export interface EnrichedPayloadResult {
21
+ payload: TrackPayload;
22
+ recovered: { email: boolean; utm: boolean };
23
+ }
24
+
25
+ export interface MatchQualityAlert {
26
+ type: string;
27
+ metric: string;
28
+ message: string;
29
+ severity?: 'critical' | 'warning';
30
+ }
31
+
32
+ export interface MatchQualityAnalysis {
33
+ total?: number;
34
+ email_rate?: number;
35
+ phone_rate?: number;
36
+ fbp_rate?: number;
37
+ fbc_rate?: number;
38
+ ext_id_rate?: number;
39
+ email_recovered_rate?: number;
40
+ composite_score?: number;
41
+ alerts: MatchQualityAlert[];
42
+ }
9
43
 
10
44
  // ── Thresholds de alerta ──────────────────────────────────────────────────────
11
- const THRESHOLDS = {
45
+ const THRESHOLDS: MatchQualityThresholds = {
12
46
  email_rate_min: 0.40, // < 40% dos eventos com email → alerta
13
47
  fbp_rate_min: 0.30, // < 30% com fbp cookie → alerta
14
48
  composite_min: 0.45, // < 45% score composto → alerta crítico
@@ -20,7 +54,7 @@ const THRESHOLDS = {
20
54
  /**
21
55
  * Registra flags de qualidade de um evento no D1 (background, não bloqueia).
22
56
  */
23
- export async function logMatchQuality(DB, eventName, payload, recovered = {}) {
57
+ export async function logMatchQuality(DB: D1Database, eventName: string, payload: TrackPayload, recovered: { email: boolean; utm: boolean } = { email: false, utm: false }): Promise<void> {
24
58
  if (!DB) return;
25
59
  try {
26
60
  await DB.prepare(`
@@ -47,7 +81,7 @@ export async function logMatchQuality(DB, eventName, payload, recovered = {}) {
47
81
  * Tenta enriquecer o payload com dados do Identity Graph antes do envio ao Meta.
48
82
  * Retorna { payload enriquecido, flags de recuperação }.
49
83
  */
50
- export async function autoEnrichPayload(env, payload) {
84
+ export async function autoEnrichPayload(env: Env, payload: TrackPayload): Promise<EnrichedPayloadResult> {
51
85
  const recovered = { email: false, utm: false };
52
86
  if (!env.DB) return { payload, recovered };
53
87
 
@@ -60,12 +94,12 @@ export async function autoEnrichPayload(env, payload) {
60
94
 
61
95
  if (profile) {
62
96
  if (profile.email && !payload.email) {
63
- payload.email = profile.email;
97
+ payload.email = profile.email as string;
64
98
  recovered.email = true;
65
99
  }
66
- if (profile.fbp && !payload.fbp) payload.fbp = profile.fbp;
67
- if (profile.fbc && !payload.fbc) payload.fbc = profile.fbc;
68
- if (profile.phone && !payload.phone) payload.phone = profile.phone;
100
+ if (profile.fbp && !payload.fbp) payload.fbp = profile.fbp as string;
101
+ if (profile.fbc && !payload.fbc) payload.fbc = profile.fbc as string;
102
+ if (profile.phone && !payload.phone) payload.phone = profile.phone as string;
69
103
  }
70
104
  } catch {}
71
105
  }
@@ -81,7 +115,7 @@ export async function autoEnrichPayload(env, payload) {
81
115
  /**
82
116
  * Analisa a qualidade das últimas 2h e retorna métricas + alertas.
83
117
  */
84
- export async function analyzeMatchQuality(env) {
118
+ export async function analyzeMatchQuality(env: Env): Promise<MatchQualityAnalysis | null> {
85
119
  if (!env.DB) return null;
86
120
 
87
121
  try {
@@ -99,45 +133,55 @@ export async function analyzeMatchQuality(env) {
99
133
  WHERE logged_at >= datetime('now', '-2 hours')
100
134
  `).first();
101
135
 
102
- if (!row || row.total < THRESHOLDS.min_events_alert) return { total: row?.total || 0, alerts: [] };
136
+ if (!row || Number((row as any).total) < THRESHOLDS.min_events_alert) return { total: Number((row as any)?.total || 0), alerts: [] };
103
137
 
104
- const alerts = [];
138
+ const alerts: MatchQualityAlert[] = [];
105
139
 
106
- if ((row.email_rate || 0) < THRESHOLDS.email_rate_min * 100) {
140
+ if (Number((row as any).email_rate || 0) < THRESHOLDS.email_rate_min * 100) {
107
141
  alerts.push({
108
142
  type: 'email_low',
109
- metric: `email_rate: ${row.email_rate}%`,
110
- message: `Taxa de email baixa: ${row.email_rate}% (mínimo: ${THRESHOLDS.email_rate_min * 100}%)`,
143
+ metric: `email_rate: ${(row as any).email_rate}%`,
144
+ message: `Taxa de email baixa: ${(row as any).email_rate}% (mínimo: ${THRESHOLDS.email_rate_min * 100}%)`,
111
145
  });
112
146
  }
113
147
 
114
- if ((row.fbp_rate || 0) < THRESHOLDS.fbp_rate_min * 100) {
148
+ if (Number((row as any).fbp_rate || 0) < THRESHOLDS.fbp_rate_min * 100) {
115
149
  alerts.push({
116
150
  type: 'fbp_low',
117
- metric: `fbp_rate: ${row.fbp_rate}%`,
118
- message: `Cookie fbp ausente em ${100 - row.fbp_rate}% dos eventos — verificar cdpTrack.js`,
151
+ metric: `fbp_rate: ${(row as any).fbp_rate}%`,
152
+ message: `Cookie fbp ausente em ${100 - Number((row as any).fbp_rate)}% dos eventos — verificar cdpTrack.js`,
119
153
  });
120
154
  }
121
155
 
122
- if ((row.composite_score || 0) < THRESHOLDS.composite_min * 100) {
156
+ if (Number((row as any).composite_score || 0) < THRESHOLDS.composite_min * 100) {
123
157
  alerts.push({
124
158
  type: 'composite_critical',
125
- metric: `composite: ${row.composite_score}%`,
126
- message: `Score composto de match quality crítico: ${row.composite_score}%`,
159
+ metric: `composite: ${(row as any).composite_score}%`,
160
+ message: `Score composto de match quality crítico: ${(row as any).composite_score}%`,
127
161
  severity: 'critical',
128
162
  });
129
163
  }
130
164
 
131
- return { ...row, alerts };
132
- } catch (err) {
133
- console.error('[MatchQuality] analyze error:', err.message);
165
+ return {
166
+ total: Number((row as any).total),
167
+ email_rate: Number((row as any).email_rate),
168
+ phone_rate: Number((row as any).phone_rate),
169
+ fbp_rate: Number((row as any).fbp_rate),
170
+ fbc_rate: Number((row as any).fbc_rate),
171
+ ext_id_rate: Number((row as any).ext_id_rate),
172
+ email_recovered_rate: Number((row as any).email_recovered_rate),
173
+ composite_score: Number((row as any).composite_score),
174
+ alerts,
175
+ };
176
+ } catch (err: any) {
177
+ console.error('[MatchQuality] analyze error:', err?.message || String(err));
134
178
  return null;
135
179
  }
136
180
  }
137
181
 
138
182
  // ── Alerta via CallMeBot ──────────────────────────────────────────────────────
139
183
 
140
- export async function alertMatchQuality(env, analysis) {
184
+ export async function alertMatchQuality(env: Env, analysis: MatchQualityAnalysis): Promise<void> {
141
185
  if (!analysis || analysis.alerts.length === 0) return;
142
186
 
143
187
  const hasCritical = analysis.alerts.some(a => a.severity === 'critical');
@@ -146,9 +190,9 @@ export async function alertMatchQuality(env, analysis) {
146
190
  const lines = [
147
191
  `${icon} CDP Edge — Match Quality Alert`,
148
192
  ``,
149
- `📊 Últimas 2h (${analysis.total} eventos):`,
193
+ `📊 Últimas 2h (${analysis.total || 0} eventos):`,
150
194
  ` Email: ${analysis.email_rate ?? 0}% ${(analysis.email_rate ?? 0) < 40 ? '❌' : '✅'}`,
151
- ` fbp: ${analysis.fbp_rate ?? 0}% ${(analysis.fbp_rate ?? 0) < 30 ? '❌' : '✅'}`,
195
+ ` fbp: ${analysis.fbp_rate ?? 0}% ${(analysis.fbp_rate ?? 0) < 30 ? '❌' : '✅'}`,
152
196
  ` Score: ${analysis.composite_score ?? 0}%`,
153
197
  ``,
154
198
  `🔍 Problemas:`,
@@ -166,7 +210,7 @@ export async function alertMatchQuality(env, analysis) {
166
210
 
167
211
  // ── Purge periódico (mensal) ──────────────────────────────────────────────────
168
212
 
169
- export async function purgeOldMatchQualityLogs(DB) {
213
+ export async function purgeOldMatchQualityLogs(DB: D1Database): Promise<void> {
170
214
  if (!DB) return;
171
215
  try {
172
216
  await DB.prepare(
@@ -4,16 +4,59 @@
4
4
  */
5
5
 
6
6
  import { tryParseJson } from '../utils.js';
7
+ import { Env } from '../../types.js';
8
+
9
+ // ── Tipos ───────────────────────────────────────────────────────────────────────
10
+ interface KmeansResult {
11
+ assignments: number[];
12
+ centroids: number[][];
13
+ }
14
+
15
+ interface ClusterStats {
16
+ c: number;
17
+ size: number;
18
+ pct: number;
19
+ avgLtv: number;
20
+ avgEng: number;
21
+ avgDays: number;
22
+ topSource: string;
23
+ topState: string;
24
+ topIntent: string;
25
+ }
26
+
27
+ interface Cluster {
28
+ cluster_id: number;
29
+ name: string;
30
+ size: number;
31
+ percentage: number;
32
+ action_recommendation: string;
33
+ characteristics: {
34
+ avg_ltv_class: number;
35
+ avg_engagement_score: number;
36
+ avg_intention_level: number;
37
+ avg_days_since_lead: number;
38
+ dominant_countries: string[];
39
+ dominant_states: string[];
40
+ dominant_utm_sources: string[];
41
+ top_features: string[];
42
+ };
43
+ }
44
+
45
+ interface ClusterInfo {
46
+ cluster_id: number;
47
+ name: string;
48
+ action: string;
49
+ }
7
50
 
8
51
  // ── Helpers K-means vetorial ──────────────────────────────────────────────────
9
52
 
10
- function _cosDist(a, b) {
53
+ function _cosDist(a: number[], b: number[]): number {
11
54
  let dot = 0, na = 0, nb = 0;
12
55
  for (let i = 0; i < a.length; i++) { dot += a[i]*b[i]; na += a[i]*a[i]; nb += b[i]*b[i]; }
13
56
  return 1 - dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-10);
14
57
  }
15
58
 
16
- function _kmeansRun(vectors, k, maxIter = 25) {
59
+ function _kmeansRun(vectors: number[][], k: number, maxIter = 25): KmeansResult {
17
60
  const n = vectors.length, dim = vectors[0].length;
18
61
  const centroids = [vectors[Math.floor(Math.random() * n)]];
19
62
  while (centroids.length < k) {
@@ -41,7 +84,7 @@ function _kmeansRun(vectors, k, maxIter = 25) {
41
84
  return { assignments, centroids };
42
85
  }
43
86
 
44
- function _silhouette(vectors, assignments, k) {
87
+ function _silhouette(vectors: number[][], assignments: number[], k: number): number {
45
88
  const n = vectors.length;
46
89
  let total = 0;
47
90
  for (let i = 0; i < n; i++) {
@@ -59,7 +102,7 @@ function _silhouette(vectors, assignments, k) {
59
102
  return Math.round((total / n) * 1000) / 1000;
60
103
  }
61
104
 
62
- function _buildLeadProfile(l) {
105
+ function _buildLeadProfile(l: any): string {
63
106
  return [
64
107
  `LTV: ${l.predicted_ltv_class || 'desconhecido'}`,
65
108
  `engajamento: ${Math.round(l.engagement_score || 0)}`,
@@ -76,7 +119,7 @@ function _buildLeadProfile(l) {
76
119
 
77
120
  // ── POST /api/segmentation/cluster ────────────────────────────────────────────
78
121
  // Clustering real: embeddinggemma-300m → K-means vetorial → Granite para nomear
79
- export async function handleSegmentationCluster(env, request, headers) {
122
+ export async function handleSegmentationCluster(env: Env, request: Request, headers: Headers): Promise<Response> {
80
123
  if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
81
124
  if (!env.AI) return new Response(JSON.stringify({ error: 'Workers AI não configurado' }), { status: 503, headers });
82
125
 
@@ -98,11 +141,11 @@ export async function handleSegmentationCluster(env, request, headers) {
98
141
  ORDER BY created_at DESC LIMIT 1
99
142
  `).bind(algorithm, clientVertical).first();
100
143
  if (existing) {
101
- const ageDays = (Date.now() - new Date(existing.created_at).getTime()) / 864e5;
144
+ const ageDays = (Date.now() - new Date((existing as any).created_at).getTime()) / 864e5;
102
145
  if (ageDays < 7) {
103
146
  return new Response(JSON.stringify({
104
147
  success: true, message: 'Cluster existente ainda válido (< 7 dias). Use ?force=true para re-clustering.',
105
- cluster_id: existing.id, cluster_name: existing.cluster_name,
148
+ cluster_id: (existing as any).id, cluster_name: (existing as any).cluster_name,
106
149
  age_days: Math.round(ageDays * 10) / 10, use_existing: true,
107
150
  }), { status: 200, headers });
108
151
  }
@@ -131,7 +174,7 @@ export async function handleSegmentationCluster(env, request, headers) {
131
174
 
132
175
  // Embeddings reais via embeddinggemma-300m
133
176
  const embRes = await env.AI.run('@cf/baai/bge-m3', { text: profiles });
134
- const vectors = embRes.data;
177
+ const vectors = (embRes as any).data as number[][];
135
178
  if (!vectors || vectors.length < nClusters) throw new Error(`embeddinggemma retornou ${vectors?.length ?? 0} vetores`);
136
179
 
137
180
  // K-means vetorial real
@@ -139,40 +182,58 @@ export async function handleSegmentationCluster(env, request, headers) {
139
182
  const silhouetteScore = _silhouette(vectors, assignments, nClusters);
140
183
 
141
184
  // Agregação por cluster para nomear com Granite
142
- const clusterStats = Array.from({ length: nClusters }, (_, c) => {
185
+ const clusterStats: (ClusterStats | null)[] = Array.from({ length: nClusters }, (_, c) => {
143
186
  const members = sample.filter((_, i) => assignments[i] === c);
144
187
  if (!members.length) return null;
145
- const ltvMap = { High: 1, Medium: 0.5, Low: 0 };
146
- const avgLtv = members.reduce((s, l) => s + (ltvMap[l.predicted_ltv_class] ?? 0), 0) / members.length;
147
- const avgEng = members.reduce((s, l) => s + (l.engagement_score || 0), 0) / members.length;
148
- const avgDays = members.reduce((s, l) => s + (l.days_since_lead || 0), 0) / members.length;
149
- const freq = (arr) => arr.length ? [...arr.reduce((m,s) => m.set(s,(m.get(s)||0)+1), new Map())].sort((a,b)=>b[1]-a[1])[0]?.[0] : null;
188
+ const ltvMap: Record<string, number> = { High: 1, Medium: 0.5, Low: 0 };
189
+ const avgLtv = members.reduce((s: number, l: any) => s + (ltvMap[l.predicted_ltv_class] ?? 0), 0) / members.length;
190
+ const avgEng = members.reduce((s: number, l: any) => s + (l.engagement_score || 0), 0) / members.length;
191
+ const avgDays = members.reduce((s: number, l: any) => s + (l.days_since_lead || 0), 0) / members.length;
192
+ const freq = (arr: string[]) => arr.length ? [...arr.reduce((m,s) => m.set(s,(m.get(s)||0)+1), new Map())].sort((a,b)=>b[1]-a[1])[0]?.[0] : null;
150
193
  return {
151
194
  c, size: members.length, pct: Math.round(members.length / sample.length * 100),
152
195
  avgLtv, avgEng, avgDays,
153
- topSource: freq(members.map(l => l.utm_source).filter(Boolean)) || 'direto',
154
- topState: freq(members.map(l => l.state).filter(Boolean)) || 'BR',
155
- topIntent: freq(members.map(l => l.intention_level).filter(Boolean)) || 'desconhecida',
196
+ topSource: freq(members.map((l: any) => l.utm_source).filter(Boolean)) || 'direto',
197
+ topState: freq(members.map((l: any) => l.state).filter(Boolean)) || 'BR',
198
+ topIntent: freq(members.map((l: any) => l.intention_level).filter(Boolean)) || 'desconhecida',
156
199
  };
157
- }).filter(Boolean);
200
+ }).filter(Boolean) as ClusterStats[];
201
+
202
+ // Type guard function to filter null values
203
+ function isNotNull<T>(value: T | null): value is T {
204
+ return value !== null;
205
+ }
206
+
207
+ const validClusterStats = clusterStats.filter(isNotNull);
158
208
 
159
209
  // Granite apenas para nomear segmentos
160
210
  const namingPrompt =
161
211
  `Você é especialista em segmentação de clientes. Dê um nome descritivo em português e uma recomendação de campanha para cada segmento. Retorne SOMENTE JSON válido:
162
212
  {"segments":[{"cluster_id":0,"name":"...","action":"..."},...]}
163
213
 
164
- ${clusterStats.map(s => `Cluster ${s.c}: LTV=${s.avgLtv.toFixed(2)}, engajamento=${s.avgEng.toFixed(0)}, intenção="${s.topIntent}", origem="${s.topSource}", estado="${s.topState}", recência=${s.avgDays.toFixed(0)} dias, tamanho=${s.size}`).join('\n')}`;
214
+ ${validClusterStats.map(s => `Cluster ${s.c}: LTV=${s.avgLtv.toFixed(2)}, engajamento=${s.avgEng.toFixed(0)}, intenção="${s.topIntent}", origem="${s.topSource}", estado="${s.topState}", recência=${s.avgDays.toFixed(0)} dias, tamanho=${s.size}`).join('\n')}`;
165
215
 
166
216
  const nameRes = await env.AI.run('@cf/ibm-granite/granite-4.0-h-micro', { messages: [{ role: 'user', content: namingPrompt }], max_tokens: 800 });
167
- let clusterNames = {};
217
+ let clusterNames: Record<number, ClusterInfo> = {};
168
218
  try {
169
- const m = (nameRes?.response || '').match(/\{[\s\S]*\}/);
170
- if (m) (JSON.parse(m[0]).segments || []).forEach(s => { clusterNames[s.cluster_id] = { name: s.name, action: s.action }; });
219
+ const m = ((nameRes as any)?.response || '').match(/\{[\s\S]*\}/);
220
+ if (m) {
221
+ const parsed = JSON.parse(m[0]);
222
+ (parsed.segments || []).forEach((s: any) => {
223
+ if (typeof s.cluster_id === 'number') {
224
+ clusterNames[s.cluster_id] = {
225
+ cluster_id: s.cluster_id,
226
+ name: s.name || `Segmento ${s.cluster_id + 1}`,
227
+ action: s.action || '',
228
+ };
229
+ }
230
+ });
231
+ }
171
232
  } catch { /* usa nomes fallback */ }
172
233
 
173
234
  const duration = Date.now() - startTime;
174
235
 
175
- const clusters = clusterStats.map(s => ({
236
+ const clusters: Cluster[] = validClusterStats.map(s => ({
176
237
  cluster_id: s.c,
177
238
  name: clusterNames[s.c]?.name || `Segmento ${s.c + 1}`,
178
239
  size: s.size, percentage: s.pct,
@@ -180,8 +241,8 @@ ${clusterStats.map(s => `Cluster ${s.c}: LTV=${s.avgLtv.toFixed(2)}, engajamento
180
241
  characteristics: {
181
242
  avg_ltv_class: s.avgLtv, avg_engagement_score: s.avgEng,
182
243
  avg_intention_level: s.avgLtv, avg_days_since_lead: s.avgDays,
183
- dominant_countries: ['BR'], dominant_states: [s.topState],
184
- dominant_utm_sources: [s.topSource], top_features: ['ltv', 'engagement', 'intention'],
244
+ dominant_countries: ['BR'], dominant_states: [s.topState || 'BR'],
245
+ dominant_utm_sources: [s.topSource || 'direto'], top_features: ['ltv', 'engagement', 'intention'],
185
246
  },
186
247
  }));
187
248
 
@@ -217,7 +278,7 @@ ${clusterStats.map(s => `Cluster ${s.c}: LTV=${s.avgLtv.toFixed(2)}, engajamento
217
278
  JSON.stringify({ algorithm, n_clusters: nClusters, vertical: clientVertical, engine: 'embeddinggemma-300m+kmeans' }),
218
279
  JSON.stringify({ clusters: clusters.length, silhouette: silhouetteScore }),
219
280
  ).run();
220
- } catch (e) { console.error('[Segmentation] history log error:', e.message); }
281
+ } catch (e: any) { console.error('[Segmentation] history log error:', e?.message || String(e)); }
221
282
 
222
283
  return new Response(JSON.stringify({
223
284
  success: true, algorithm, engine: 'embeddinggemma-300m + kmeans vetorial',
@@ -227,20 +288,20 @@ ${clusterStats.map(s => `Cluster ${s.c}: LTV=${s.avgLtv.toFixed(2)}, engajamento
227
288
  clusters, generated_at: now,
228
289
  }), { status: 200, headers });
229
290
 
230
- } catch (err) {
231
- console.error('[Segmentation] cluster error:', err.message);
291
+ } catch (err: any) {
292
+ console.error('[Segmentation] cluster error:', err?.message || String(err));
232
293
  try {
233
294
  if (env.DB) await env.DB.prepare(`
234
295
  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)
235
296
  VALUES (0, datetime('now'), ?, 0, 0, 0, 0, 'failed', ?, ?, '{}')
236
- `).bind(algorithm, err.message, JSON.stringify({ algorithm, n_clusters: nClusters })).run();
297
+ `).bind(algorithm, err?.message || String(err), JSON.stringify({ algorithm, n_clusters: nClusters })).run();
237
298
  } catch { /* não bloquear */ }
238
- return new Response(JSON.stringify({ error: 'Erro ao executar clustering', message: err.message }), { status: 500, headers });
299
+ return new Response(JSON.stringify({ error: 'Erro ao executar clustering', message: err?.message || String(err) }), { status: 500, headers });
239
300
  }
240
301
  }
241
302
 
242
303
  // ── GET /api/segmentation/list ────────────────────────────────────────────────
243
- export async function handleSegmentationList(env, request, headers) {
304
+ export async function handleSegmentationList(env: Env, request: Request, headers: Headers): Promise<Response> {
244
305
  if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
245
306
 
246
307
  const url = new URL(request.url);
@@ -248,8 +309,8 @@ export async function handleSegmentationList(env, request, headers) {
248
309
  const vertical = url.searchParams.get('vertical') || null;
249
310
 
250
311
  try {
251
- const conditions = ['is_active = 1'];
252
- const bindings = [];
312
+ const conditions: string[] = ['is_active = 1'];
313
+ const bindings: (string | number)[] = [];
253
314
  if (algorithm) { conditions.push('clustering_algorithm = ?'); bindings.push(algorithm); }
254
315
  if (vertical) { conditions.push('client_vertical = ?'); bindings.push(vertical); }
255
316
 
@@ -266,7 +327,7 @@ export async function handleSegmentationList(env, request, headers) {
266
327
  LIMIT 50
267
328
  `).bind(...bindings).all();
268
329
 
269
- const segments = (result.results || []).map(s => ({
330
+ const segments = (result.results || []).map((s: any) => ({
270
331
  ...s,
271
332
  dominant_countries: tryParseJson(s.dominant_countries, []),
272
333
  dominant_states: tryParseJson(s.dominant_states, []),
@@ -278,14 +339,14 @@ export async function handleSegmentationList(env, request, headers) {
278
339
  }));
279
340
 
280
341
  return new Response(JSON.stringify({ success: true, total: segments.length, segments }), { status: 200, headers });
281
- } catch (err) {
282
- console.error('[Segmentation] list error:', err.message);
283
- return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
342
+ } catch (err: any) {
343
+ console.error('[Segmentation] list error:', err?.message || String(err));
344
+ return new Response(JSON.stringify({ error: err?.message || String(err) }), { status: 500, headers });
284
345
  }
285
346
  }
286
347
 
287
348
  // ── GET /api/segmentation/outliers ───────────────────────────────────────────
288
- export async function handleSegmentationOutliers(env, request, headers) {
349
+ export async function handleSegmentationOutliers(env: Env, request: Request, headers: Headers): Promise<Response> {
289
350
  if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
290
351
 
291
352
  const url = new URL(request.url);
@@ -304,17 +365,17 @@ export async function handleSegmentationOutliers(env, request, headers) {
304
365
  `).bind(days, limit).all();
305
366
 
306
367
  return new Response(JSON.stringify({ success: true, total: (result.results || []).length, period_days: days, outliers: result.results || [] }), { status: 200, headers });
307
- } catch (err) {
308
- console.error('[Segmentation] outliers error:', err.message);
309
- return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
368
+ } catch (err: any) {
369
+ console.error('[Segmentation] outliers error:', err?.message || String(err));
370
+ return new Response(JSON.stringify({ error: err?.message || String(err) }), { status: 500, headers });
310
371
  }
311
372
  }
312
373
 
313
374
  // ── PUT /api/segmentation/update ─────────────────────────────────────────────
314
- export async function handleSegmentationUpdate(env, request, headers) {
375
+ export async function handleSegmentationUpdate(env: Env, request: Request, headers: Headers): Promise<Response> {
315
376
  if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
316
377
 
317
- let body;
378
+ let body: any;
318
379
  try { body = await request.json(); }
319
380
  catch { return new Response(JSON.stringify({ error: 'JSON inválido no body da requisição' }), { status: 400, headers }); }
320
381
 
@@ -324,8 +385,8 @@ export async function handleSegmentationUpdate(env, request, headers) {
324
385
  }
325
386
 
326
387
  try {
327
- const sets = [];
328
- const bindings = [];
388
+ const sets: string[] = [];
389
+ const bindings: (string | number)[] = [];
329
390
  if (action_recommendations !== undefined) { sets.push('action_recommendations = ?'); bindings.push(JSON.stringify(action_recommendations)); }
330
391
  if (bid_recommendations !== undefined) { sets.push('bid_recommendations = ?'); bindings.push(JSON.stringify(bid_recommendations)); }
331
392
  if (campaign_recommendations !== undefined) { sets.push('campaign_recommendations = ?'); bindings.push(JSON.stringify(campaign_recommendations)); }
@@ -339,8 +400,8 @@ export async function handleSegmentationUpdate(env, request, headers) {
339
400
 
340
401
  await env.DB.prepare(`UPDATE ml_segments SET ${sets.join(', ')} WHERE id = ?`).bind(...bindings).run();
341
402
  return new Response(JSON.stringify({ success: true, cluster_id, fields_updated: sets.length - 1 }), { status: 200, headers });
342
- } catch (err) {
343
- console.error('[Segmentation] update error:', err.message);
344
- return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
403
+ } catch (err: any) {
404
+ console.error('[Segmentation] update error:', err?.message || String(err));
405
+ return new Response(JSON.stringify({ error: err?.message || String(err) }), { status: 500, headers });
345
406
  }
346
407
  }
@@ -4,8 +4,26 @@
4
4
  * Importadas por todos os outros módulos.
5
5
  */
6
6
 
7
+ // ── Tipos ───────────────────────────────────────────────────────────────────────
8
+ export interface FunnelStageResult {
9
+ depth: string;
10
+ funnelDepth: string;
11
+ }
12
+
13
+ export interface MetaSignalWeights {
14
+ intent: number;
15
+ ltv: number;
16
+ dist: number;
17
+ }
18
+
19
+ export type DistanceBucket = 'very_close' | 'close' | 'nearby' | 'moderate' | 'far';
20
+
21
+ export type FunnelLevel = 'top' | 'mid' | 'bottom' | 'conversion' | 'unknown';
22
+
23
+ export type MetaSignalBucket = 'hot' | 'warm' | 'cold';
24
+
7
25
  // ── CORS ──────────────────────────────────────────────────────────────────────
8
- export function isAllowedOrigin(origin, siteDomain) {
26
+ export function isAllowedOrigin(origin: string | null, siteDomain: string | null): boolean {
9
27
  if (!origin || !siteDomain) return false;
10
28
  return origin === `https://${siteDomain}`
11
29
  || origin.endsWith(`.${siteDomain}`)
@@ -13,10 +31,10 @@ export function isAllowedOrigin(origin, siteDomain) {
13
31
  || origin === 'http://localhost:5173';
14
32
  }
15
33
 
16
- export function corsHeaders(origin, siteDomain) {
17
- const allowed = isAllowedOrigin(origin, siteDomain) ? origin : `https://${siteDomain}`;
34
+ export function corsHeaders(origin: string | null, siteDomain: string | null): Record<string, string> {
35
+ const allowed = isAllowedOrigin(origin, siteDomain) ? origin : (siteDomain ? `https://${siteDomain}` : '*');
18
36
  return {
19
- 'Access-Control-Allow-Origin': allowed,
37
+ 'Access-Control-Allow-Origin': allowed || '*',
20
38
  'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
21
39
  'Access-Control-Allow-Headers': 'Content-Type, X-Requested-With',
22
40
  'Access-Control-Max-Age': '86400',
@@ -24,7 +42,7 @@ export function corsHeaders(origin, siteDomain) {
24
42
  }
25
43
 
26
44
  // ── SHA-256 via WebCrypto (obrigatório no Cloudflare Workers) ─────────────────
27
- export async function sha256(value) {
45
+ export async function sha256(value: string | null | undefined): Promise<string | undefined> {
28
46
  if (!value) return undefined;
29
47
  const clean = String(value).toLowerCase().trim();
30
48
  if (!clean) return undefined;
@@ -38,7 +56,7 @@ export async function sha256(value) {
38
56
  }
39
57
 
40
58
  // ── Normalização de telefone → somente dígitos + DDI 55 ──────────────────────
41
- export function normalizePhone(phone) {
59
+ export function normalizePhone(phone: string | null | undefined): string | undefined {
42
60
  if (!phone) return undefined;
43
61
  let digits = String(phone).replace(/\D/g, '');
44
62
  if (digits.length === 11 && !digits.startsWith('55')) digits = '55' + digits;
@@ -47,7 +65,7 @@ export function normalizePhone(phone) {
47
65
  }
48
66
 
49
67
  // ── Normalização de cidade → lowercase sem acentos ────────────────────────────
50
- export function normalizeCity(city) {
68
+ export function normalizeCity(city: string | null | undefined): string | undefined {
51
69
  if (!city) return undefined;
52
70
  return String(city)
53
71
  .toLowerCase()
@@ -57,13 +75,13 @@ export function normalizeCity(city) {
57
75
  }
58
76
 
59
77
  // ── Parse seguro de JSON armazenado como TEXT no D1 ───────────────────────────
60
- export function tryParseJson(str, fallback) {
78
+ export function tryParseJson<T = any>(str: string | null, fallback?: T): T | null {
61
79
  if (!str) return fallback !== undefined ? fallback : null;
62
80
  try { return JSON.parse(str); } catch { return fallback !== undefined ? fallback : null; }
63
81
  }
64
82
 
65
83
  // ── Mapa Meta → GA4 event names ───────────────────────────────────────────────
66
- export const META_TO_GA4 = {
84
+ export const META_TO_GA4: Record<string, string> = {
67
85
  PageView: 'page_view',
68
86
  ViewContent: 'view_item',
69
87
  Lead: 'generate_lead',
@@ -100,18 +118,18 @@ export const FUNNEL_TAXONOMY = {
100
118
  };
101
119
 
102
120
  // Índice invertido: funnel_stage → depth (construído uma vez, zero custo em runtime)
103
- const _STAGE_TO_DEPTH = Object.entries(FUNNEL_TAXONOMY).reduce((acc, [depth, stages]) => {
104
- stages.forEach(s => { acc[s] = depth; });
121
+ const _STAGE_TO_DEPTH: Record<string, FunnelLevel> = Object.entries(FUNNEL_TAXONOMY).reduce((acc, [depth, stages]) => {
122
+ stages.forEach(s => { acc[s] = depth as FunnelLevel; });
105
123
  return acc;
106
- }, {});
124
+ }, {} as Record<string, FunnelLevel>);
107
125
 
108
126
  /**
109
127
  * Resolve funnel_stage em funnelDepth semântico.
110
128
  * bottom_intent = intenção forte (route_click, whatsapp_click)
111
129
  * bottom_conversion = ação confirmada (schedule_confirmed, lead_form)
112
130
  */
113
- export function resolveFunnelStage(funnel_stage) {
114
- const depth = _STAGE_TO_DEPTH[funnel_stage] || 'unknown';
131
+ export function resolveFunnelStage(funnel_stage: string | null | undefined): FunnelStageResult {
132
+ const depth = _STAGE_TO_DEPTH[funnel_stage || ''] || 'unknown';
115
133
  const funnelDepth = depth === 'conversion' ? 'bottom_conversion'
116
134
  : depth === 'bottom' ? 'bottom_intent'
117
135
  : depth;
@@ -120,12 +138,12 @@ export function resolveFunnelStage(funnel_stage) {
120
138
 
121
139
  // ── Normalização de intent_score → 0.0–1.0 ───────────────────────────────────
122
140
  // Aceita: string ('high'/'medium'/'low'), numérico 0-1 ou numérico 0-100
123
- const _INTENT_STRING_MAP = { high: 0.92, medium: 0.65, low: 0.30 };
141
+ const _INTENT_STRING_MAP: Record<string, number> = { high: 0.92, medium: 0.65, low: 0.30 };
124
142
 
125
- export function resolveIntentScore(value) {
143
+ export function resolveIntentScore(value: string | number | null | undefined): number | null {
126
144
  if (value === null || value === undefined) return null;
127
145
  if (typeof value === 'string') return _INTENT_STRING_MAP[value.toLowerCase()] ?? null;
128
- const num = parseFloat(value);
146
+ const num = parseFloat(String(value));
129
147
  if (isNaN(num)) return null;
130
148
  const normalized = num > 1 ? num / 100 : num; // escala 0-100 → 0-1
131
149
  return Math.min(1, Math.max(0, Math.round(normalized * 100) / 100));
@@ -135,9 +153,9 @@ export function resolveIntentScore(value) {
135
153
  * Distância (distanceBucket) → peso numérico para meta_signal.
136
154
  * very_close=1.0 ... far=0.1 ... sem dado=0.3 (neutro)
137
155
  */
138
- export function distanceBucketWeight(bucket) {
139
- const map = { very_close: 1.0, close: 0.75, nearby: 0.5, moderate: 0.25, far: 0.1 };
140
- return map[bucket] ?? 0.3;
156
+ export function distanceBucketWeight(bucket: string | null | undefined): number {
157
+ const map: Record<DistanceBucket, number> = { very_close: 1.0, close: 0.75, nearby: 0.5, moderate: 0.25, far: 0.1 };
158
+ return map[bucket as DistanceBucket] ?? 0.3;
141
159
  }
142
160
 
143
161
  /**
@@ -146,7 +164,7 @@ export function distanceBucketWeight(bucket) {
146
164
  * Topo: perfil pesa mais (ltv).
147
165
  * Default (mid/unknown): balanceado.
148
166
  */
149
- export function computeMetaSignalWeights(funnelLevel) {
167
+ export function computeMetaSignalWeights(funnelLevel: FunnelLevel | string | null | undefined): MetaSignalWeights {
150
168
  if (funnelLevel === 'bottom' || funnelLevel === 'conversion') {
151
169
  return { intent: 0.5, ltv: 0.2, dist: 0.3 };
152
170
  }
@@ -160,7 +178,8 @@ export function computeMetaSignalWeights(funnelLevel) {
160
178
  * Quantiza meta_signal contínuo em bucket legível.
161
179
  * Usado em criação de públicos e leitura de BI.
162
180
  */
163
- export function metaSignalBucket(score) {
181
+ export function metaSignalBucket(score: number | null | undefined): MetaSignalBucket {
182
+ if (!score) return 'cold';
164
183
  if (score >= 0.8) return 'hot';
165
184
  if (score >= 0.6) return 'warm';
166
185
  return 'cold';