cdp-edge 1.24.1 → 1.25.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.
@@ -23,6 +23,12 @@ import {
23
23
  distanceBucketWeight,
24
24
  computeMetaSignalWeights,
25
25
  metaSignalBucket,
26
+ isValidEmail,
27
+ sanitizeString,
28
+ isValidUrl,
29
+ isValidValue,
30
+ isValidCurrency,
31
+ isValidUTM,
26
32
  } from './modules/utils';
27
33
 
28
34
  // ── Banco de dados (D1) ───────────────────────────────────────────────────────
@@ -39,6 +45,7 @@ import {
39
45
  resurrectUTM,
40
46
  upsertLtvProfile,
41
47
  recordLtvFeedback,
48
+ processWebhookDuplicateCheck,
42
49
  } from './modules/db';
43
50
 
44
51
  // ── Dispatch — plataformas de ads ─────────────────────────────────────────────
@@ -135,10 +142,10 @@ export default {
135
142
  const url = new URL(request.url);
136
143
 
137
144
  // ── Rate Limiter — camada 0, antes do Fraud Gate ─────────────────────────
145
+ // Usa apenas CF-Connecting-IP (injetado pela Cloudflare, não pode ser spoofado)
146
+ // X-Forwarded-For pode ser falsificado por atacantes para bypass rate limiter
138
147
  if (url.pathname === '/track' && request.method === 'POST' && env.RATE_LIMITER) {
139
- const ip = request.headers.get('CF-Connecting-IP')
140
- || request.headers.get('X-Forwarded-For')?.split(',')[0].trim()
141
- || '0.0.0.0';
148
+ const ip = request.headers.get('CF-Connecting-IP') || 'unknown';
142
149
  const { success } = await env.RATE_LIMITER.limit({ key: ip });
143
150
  if (!success) {
144
151
  return new Response(JSON.stringify({ status: 'ok', queued: true }), { status: 200, headers });
@@ -218,8 +225,8 @@ export default {
218
225
  META_ACCESS_TOKEN: env.META_ACCESS_TOKEN ? 'set' : 'MISSING',
219
226
  GA4_API_SECRET: env.GA4_API_SECRET ? 'set' : 'MISSING',
220
227
  WA_WEBHOOK_VERIFY_TOKEN: env.WA_WEBHOOK_VERIFY_TOKEN ? 'set' : 'MISSING',
221
- WHATSAPP_ACCESS_TOKEN: env.WHATSAPP_ACCESS_TOKEN ? 'set' : 'not set (optional - only for auto-reply)',
222
- WHATSAPP_PHONE_NUMBER_ID: env.WHATSAPP_PHONE_NUMBER_ID ? 'set' : 'not set (optional - only for auto-reply)',
228
+ WHATSAPP_ACCESS_TOKEN: (env.WHATSAPP_ACCESS_TOKEN || env.WA_ACCESS_TOKEN) ? 'set' : 'not set (optional - only for auto-reply)',
229
+ WHATSAPP_PHONE_NUMBER_ID: (env.WHATSAPP_PHONE_NUMBER_ID || env.WA_PHONE_ID) ? 'set' : 'not set (optional - only for auto-reply)',
223
230
  WA_NOTIFY_NUMBER: env.WA_NOTIFY_NUMBER ? 'set' : 'not set (optional - only for auto-reply)',
224
231
  TIKTOK_ACCESS_TOKEN: env.TIKTOK_ACCESS_TOKEN ? 'set' : 'not set (optional)',
225
232
  CALLMEBOT_PHONE: env.CALLMEBOT_PHONE ? 'set' : 'not set (optional)',
@@ -265,6 +272,7 @@ export default {
265
272
  const { eventName, behavioral_data, ...payload } = body as { eventName?: string; behavioral_data?: BehavioralData; [key: string]: any };
266
273
  const trackPayload: TrackPayload = payload;
267
274
 
275
+ // ── Validação de eventName ────────────────────────────────────────
268
276
  if (!eventName) {
269
277
  return new Response(JSON.stringify({ error: 'eventName é obrigatório' }), { status: 400, headers });
270
278
  }
@@ -273,20 +281,88 @@ export default {
273
281
  return new Response(JSON.stringify({ error: `eventName desconhecido: ${eventName.slice(0, 64)}` }), { status: 400, headers });
274
282
  }
275
283
 
284
+ // ── Sanitização e Validação de Campos String ──────────────────────
285
+ const SANITIZE_FIELDS = {
286
+ email: (val: string) => {
287
+ if (!isValidEmail(val)) return { error: 'email inválido (formato incorreto)' };
288
+ return { sanitized: val.toLowerCase().trim() };
289
+ },
290
+ firstName: (val: string) => ({ sanitized: sanitizeString(val, 100) }),
291
+ lastName: (val: string) => ({ sanitized: sanitizeString(val, 100) }),
292
+ city: (val: string) => ({ sanitized: sanitizeString(val, 100) }),
293
+ state: (val: string) => ({ sanitized: sanitizeString(val, 100) }),
294
+ zip: (val: string) => ({ sanitized: sanitizeString(val, 20) }),
295
+ dob: (val: string) => ({ sanitized: sanitizeString(val, 20) }),
296
+ productName: (val: string) => ({ sanitized: sanitizeString(val, 200) }),
297
+ pageUrl: (val: string) => {
298
+ if (!isValidUrl(val)) return { error: 'pageUrl inválido (formato incorreto)' };
299
+ return { sanitized: val.trim() };
300
+ },
301
+ currency: (val: string) => {
302
+ if (!isValidCurrency(val)) return { error: 'currency inválido (deve ser código ISO 4217)' };
303
+ return { sanitized: val.trim().toUpperCase() };
304
+ },
305
+ };
306
+
307
+ // Sanitiza e valida campos específicos
308
+ for (const [field, validator] of Object.entries(SANITIZE_FIELDS)) {
309
+ const value = trackPayload[field as keyof TrackPayload];
310
+ if (value !== undefined && value !== null) {
311
+ if (typeof value !== 'string') {
312
+ return new Response(JSON.stringify({ error: `Campo ${field} deve ser string` }), { status: 400, headers });
313
+ }
314
+ const result = validator(value);
315
+ if (result.error) {
316
+ return new Response(JSON.stringify({ error: result.error }), { status: 400, headers });
317
+ }
318
+ if (result.sanitized !== null) {
319
+ trackPayload[field as keyof TrackPayload] = result.sanitized as any;
320
+ }
321
+ }
322
+ }
323
+
324
+ // Sanitiza campos de string genéricos
325
+ const GENERIC_SANITIZE_FIELDS = ['utmSource', 'utmMedium', 'utmCampaign', 'utmContent', 'utmTerm'];
326
+ for (const field of GENERIC_SANITIZE_FIELDS) {
327
+ const value = trackPayload[field as keyof TrackPayload];
328
+ if (value !== undefined && value !== null) {
329
+ if (typeof value !== 'string') {
330
+ return new Response(JSON.stringify({ error: `Campo ${field} deve ser string` }), { status: 400, headers });
331
+ }
332
+ const utmType = `utm_${field.replace('utm', '').toLowerCase()}`;
333
+ if (!isValidUTM(value, utmType)) {
334
+ return new Response(JSON.stringify({ error: `Campo ${field} contém caracteres perigosos` }), { status: 400, headers });
335
+ }
336
+ const sanitized = sanitizeString(value, 200);
337
+ if (sanitized === null) {
338
+ return new Response(JSON.stringify({ error: `Campo ${field} inválido após sanitização` }), { status: 400, headers });
339
+ }
340
+ trackPayload[field as keyof TrackPayload] = sanitized as any;
341
+ }
342
+ }
343
+
344
+ // Sanitiza campos de string restantes
345
+ const STR_FIELDS = ['transactionId', 'fbclid', 'ttclid', 'gclid'];
276
346
  for (const field of STR_FIELDS) {
277
- if (trackPayload[field as keyof TrackPayload] !== undefined && trackPayload[field as keyof TrackPayload] !== null) {
278
- if (typeof trackPayload[field as keyof TrackPayload] !== 'string' || String(trackPayload[field as keyof TrackPayload]).length > 512) {
279
- return new Response(JSON.stringify({ error: `Campo inválido: ${field}` }), { status: 400, headers });
347
+ const value = trackPayload[field as keyof TrackPayload];
348
+ if (value !== undefined && value !== null) {
349
+ if (typeof value !== 'string') {
350
+ return new Response(JSON.stringify({ error: `Campo ${field} deve ser string` }), { status: 400, headers });
351
+ }
352
+ const sanitized = sanitizeString(value, 512);
353
+ if (sanitized === null) {
354
+ return new Response(JSON.stringify({ error: `Campo ${field} inválido após sanitização` }), { status: 400, headers });
280
355
  }
356
+ trackPayload[field as keyof TrackPayload] = sanitized as any;
281
357
  }
282
358
  }
283
359
 
360
+ // ── Validação de Valor Numérico ───────────────────────────────────
284
361
  if (trackPayload.value !== undefined && trackPayload.value !== null) {
285
- const v = Number(trackPayload.value);
286
- if (isNaN(v) || v < 0 || v > 9_999_999) {
287
- return new Response(JSON.stringify({ error: 'value fora do intervalo permitido' }), { status: 400, headers });
362
+ if (!isValidValue(trackPayload.value)) {
363
+ return new Response(JSON.stringify({ error: 'value fora do intervalo permitido (0-9,999,999)' }), { status: 400, headers });
288
364
  }
289
- trackPayload.value = v;
365
+ trackPayload.value = Number(trackPayload.value);
290
366
  }
291
367
 
292
368
  // ── Extrair dados comportamentais do browser ──────────────────────────
@@ -297,14 +373,46 @@ export default {
297
373
  // Sinais de engajamento profundo — usados no anti-falso-positivo e no LTV
298
374
  payload.scrollScore = behavioral_data.scroll_score ?? null;
299
375
  payload.timeLevel = behavioral_data.time_level ?? null;
300
- payload.email = payload.email || behavioral_data.email || null;
301
- payload.phone = payload.phone || behavioral_data.phone || null;
302
- payload.firstName = payload.firstName || behavioral_data.first_name || behavioral_data.firstName || null;
303
- payload.lastName = payload.lastName || behavioral_data.last_name || behavioral_data.lastName || null;
304
- payload.city = payload.city || behavioral_data.city || null;
305
- payload.state = payload.state || behavioral_data.state || null;
306
- payload.zip = payload.zip || behavioral_data.zip || null;
307
- payload.dob = payload.dob || behavioral_data.dob || null;
376
+
377
+ // ── Sanitiza dados do behavioral_data ────────────────────────
378
+ // Os dados do behavioral_data podem vir do browser e ser manipulados
379
+ const sanitizedBehavioralEmail = behavioral_data.email && isValidEmail(behavioral_data.email)
380
+ ? behavioral_data.email.toLowerCase().trim()
381
+ : null;
382
+ const sanitizedBehavioralPhone = behavioral_data.phone
383
+ ? sanitizeString(behavioral_data.phone, 50)
384
+ : null;
385
+ const sanitizedBehavioralFirstName = behavioral_data.first_name || behavioral_data.firstName
386
+ ? sanitizeString(behavioral_data.first_name || behavioral_data.firstName, 100)
387
+ : null;
388
+ const sanitizedBehavioralLastName = behavioral_data.last_name || behavioral_data.lastName
389
+ ? sanitizeString(behavioral_data.last_name || behavioral_data.lastName, 100)
390
+ : null;
391
+ const sanitizedBehavioralCity = behavioral_data.city
392
+ ? sanitizeString(behavioral_data.city, 100)
393
+ : null;
394
+
395
+ // Usa dados sanitizados do behavioral_data se não existirem no payload principal
396
+ payload.email = payload.email || sanitizedBehavioralEmail;
397
+ payload.phone = payload.phone || sanitizedBehavioralPhone;
398
+ payload.firstName = payload.firstName || sanitizedBehavioralFirstName;
399
+ payload.lastName = payload.lastName || sanitizedBehavioralLastName;
400
+ payload.city = payload.city || sanitizedBehavioralCity;
401
+
402
+ // Sanitiza campos restantes do behavioral_data
403
+ const sanitizedBehavioralState = behavioral_data.state
404
+ ? sanitizeString(behavioral_data.state, 100)
405
+ : null;
406
+ const sanitizedBehavioralZip = behavioral_data.zip
407
+ ? sanitizeString(behavioral_data.zip, 20)
408
+ : null;
409
+ const sanitizedBehavioralDob = behavioral_data.dob
410
+ ? sanitizeString(behavioral_data.dob, 20)
411
+ : null;
412
+
413
+ payload.state = payload.state || sanitizedBehavioralState;
414
+ payload.zip = payload.zip || sanitizedBehavioralZip;
415
+ payload.dob = payload.dob || sanitizedBehavioralDob;
308
416
  }
309
417
 
310
418
  // ── Normalização de intent_score → 0.0–1.0 ──────────────────────────
@@ -510,7 +618,13 @@ export default {
510
618
  try {
511
619
  const profileRow = await env.DB.prepare('SELECT score FROM user_profiles WHERE user_id = ?').bind(trackPayload.userId).first();
512
620
  if (profileRow) currentScore = Number(profileRow.score) || 0;
513
- } catch {}
621
+ } catch (err: any) {
622
+ console.error('[POST /track] Error fetching user profile score:', {
623
+ userId: trackPayload.userId,
624
+ error: err?.message || String(err),
625
+ stack: err?.stack,
626
+ });
627
+ }
514
628
  }
515
629
 
516
630
  const resHeaders = new Headers(headers);
@@ -549,15 +663,12 @@ export default {
549
663
  }
550
664
 
551
665
  const hmTxId = String(purchase.transaction || '');
552
- if (hmTxId && env.DB) {
553
- try {
554
- const dup = await env.DB.prepare(
555
- 'SELECT id FROM webhook_events WHERE transaction_id = ? AND status = ?'
556
- ).bind(hmTxId, 'processed').first();
557
- if (dup) {
558
- return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
559
- }
560
- } catch {}
666
+ const dupCheck = await processWebhookDuplicateCheck(env, 'hotmart', hmTxId, JSON.stringify(wh), {
667
+ email: buyer.email,
668
+ });
669
+
670
+ if (dupCheck.duplicate) {
671
+ return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
561
672
  }
562
673
 
563
674
  const profile = await getProfileByEmail(env, buyer.email);
@@ -584,14 +695,6 @@ export default {
584
695
  country: profile?.country,
585
696
  };
586
697
 
587
- if (hmTxId && env.DB) {
588
- try {
589
- await env.DB.prepare(
590
- 'INSERT OR IGNORE INTO webhook_events (platform, transaction_id, email, status, raw_payload) VALUES (?,?,?,?,?)'
591
- ).bind('hotmart', hmTxId, buyer.email || null, 'processed', JSON.stringify(wh)).run();
592
- } catch {}
593
- }
594
-
595
698
  ctx.waitUntil(Promise.allSettled([
596
699
  sendMetaCapi(env, 'Purchase', payload, request, ctx),
597
700
  sendGA4Mp(env, 'purchase', payload, ctx),
@@ -622,15 +725,12 @@ export default {
622
725
  }
623
726
 
624
727
  const kwTxId = String(wh.order_id || '');
625
- if (kwTxId && env.DB) {
626
- try {
627
- const dup = await env.DB.prepare(
628
- 'SELECT id FROM webhook_events WHERE transaction_id = ? AND status = ?'
629
- ).bind(kwTxId, 'processed').first();
630
- if (dup) {
631
- return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
632
- }
633
- } catch {}
728
+ const dupCheck = await processWebhookDuplicateCheck(env, 'kiwify', kwTxId, JSON.stringify(wh), {
729
+ email: customer.email,
730
+ });
731
+
732
+ if (dupCheck.duplicate) {
733
+ return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
634
734
  }
635
735
 
636
736
  const customer = wh.Customer || {};
@@ -659,14 +759,6 @@ export default {
659
759
  country: profile?.country,
660
760
  };
661
761
 
662
- if (kwTxId && env.DB) {
663
- try {
664
- await env.DB.prepare(
665
- 'INSERT OR IGNORE INTO webhook_events (platform, transaction_id, email, status, raw_payload) VALUES (?,?,?,?,?)'
666
- ).bind('kiwify', kwTxId, customer.email || null, 'processed', JSON.stringify(wh)).run();
667
- } catch {}
668
- }
669
-
670
762
  ctx.waitUntil(Promise.allSettled([
671
763
  sendMetaCapi(env, 'Purchase', payload, request, ctx),
672
764
  sendGA4Mp(env, 'purchase', payload, ctx),
@@ -716,15 +808,12 @@ export default {
716
808
  const transactionId = order.hash || order.transaction_hash || order.id;
717
809
  const tcTxId = String(order.hash || order.transaction_hash || order.id || '');
718
810
 
719
- if (tcTxId && env.DB) {
720
- try {
721
- const dup = await env.DB.prepare(
722
- 'SELECT id FROM webhook_events WHERE transaction_id = ? AND status = ?'
723
- ).bind(tcTxId, 'processed').first();
724
- if (dup) {
725
- return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
726
- }
727
- } catch {}
811
+ const dupCheck = await processWebhookDuplicateCheck(env, 'ticto', tcTxId, rawBody, {
812
+ email: customer.email,
813
+ });
814
+
815
+ if (dupCheck.duplicate) {
816
+ return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
728
817
  }
729
818
 
730
819
  const urlUserId = tracking.user_id || wh.url_params?.user_id;
@@ -734,7 +823,14 @@ export default {
734
823
  profile = await env.DB.prepare(
735
824
  'SELECT * FROM user_profiles WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1'
736
825
  ).bind(urlUserId).first();
737
- } catch {}
826
+ } catch (err: any) {
827
+ console.error('[POST /webhook/ticto] Error fetching user profile by userId:', {
828
+ userId: urlUserId,
829
+ email: customer.email,
830
+ error: err?.message || String(err),
831
+ stack: err?.stack,
832
+ });
833
+ }
738
834
  }
739
835
 
740
836
  const fbclid = tracking.fbclid || wh.url_params?.fbclid;
@@ -767,14 +863,6 @@ export default {
767
863
  utmContent: tracking.utm_content || '',
768
864
  };
769
865
 
770
- if (tcTxId && env.DB) {
771
- try {
772
- await env.DB.prepare(
773
- 'INSERT OR IGNORE INTO webhook_events (platform, transaction_id, email, status, raw_payload) VALUES (?,?,?,?,?)'
774
- ).bind('ticto', tcTxId, customer.email || null, 'processed', JSON.stringify(wh)).run();
775
- } catch {}
776
- }
777
-
778
866
  ctx.waitUntil(Promise.allSettled([
779
867
  sendMetaCapi(env, 'Purchase', payload, request, ctx),
780
868
  sendGA4Mp(env, 'purchase', payload, ctx),
@@ -226,7 +226,7 @@ export async function resolveDeviceGraph(DB: D1Database, currentUserId: string,
226
226
  VALUES (?, ?, ?, ?)
227
227
  `).bind(primary, secondary, matchType, matchConfidence).run();
228
228
 
229
- console.log(`[DeviceGraph] Linked ${secondary} ${primary} via ${matchType} (confidence: ${matchConfidence})`);
229
+ // sem log de user IDs dados sensíveis não entram em Workers log
230
230
  }
231
231
  } catch (err: any) {
232
232
  console.error('resolveDeviceGraph error:', err?.message || String(err));
@@ -332,7 +332,13 @@ export async function enrichGeoFromEdge(request: Request, env: Env, payload: Tra
332
332
  try {
333
333
  const cached = await env.GEO_CACHE.get(`geo:${ip}`, 'json') as GeoData | null;
334
334
  if (cached) geoData = cached;
335
- } catch {}
335
+ } catch (err: any) {
336
+ console.error('[DB] Error fetching geo data from cache:', {
337
+ ip,
338
+ error: err?.message || String(err),
339
+ stack: err?.stack,
340
+ });
341
+ }
336
342
  }
337
343
 
338
344
  if (!geoData) {
@@ -355,7 +361,14 @@ export async function enrichGeoFromEdge(request: Request, env: Env, payload: Tra
355
361
  if (env.GEO_CACHE && ip && geoData.country) {
356
362
  try {
357
363
  await env.GEO_CACHE.put(`geo:${ip}`, JSON.stringify(geoData), { expirationTtl: 3600 });
358
- } catch {}
364
+ } catch (err: any) {
365
+ console.error('[DB] Error caching geo data:', {
366
+ ip,
367
+ country: geoData.country,
368
+ error: err?.message || String(err),
369
+ stack: err?.stack,
370
+ });
371
+ }
359
372
  }
360
373
  }
361
374
 
@@ -640,3 +653,50 @@ export async function logIntelligence(DB: D1Database, runType: string, platform:
640
653
  console.error('logIntelligence error:', err?.message || String(err));
641
654
  }
642
655
  }
656
+
657
+ // ── Webhook Processing Helper ─────────────────────────────────────────────────
658
+ /**
659
+ * Processa webhook de e-commerce (Hotmart, Kiwify, Ticto, etc.)
660
+ * - Verifica duplicatas
661
+ * - Registra evento em webhook_events
662
+ * - Retorna true se já foi processado, false se deve continuar
663
+ */
664
+ export async function processWebhookDuplicateCheck(
665
+ env: Env,
666
+ platform: string,
667
+ transactionId: string,
668
+ rawPayload: string,
669
+ context?: { email?: string; orderId?: string }
670
+ ): Promise<{ duplicate: boolean; error?: string }> {
671
+ if (!env.DB) {
672
+ return { duplicate: false };
673
+ }
674
+
675
+ try {
676
+ // 1. Verifica duplicatas
677
+ const dup = await env.DB.prepare(
678
+ 'SELECT id FROM webhook_events WHERE transaction_id = ? AND platform = ? AND status = ?'
679
+ ).bind(transactionId, platform, 'processed').first();
680
+
681
+ if (dup) {
682
+ console.log(`[Webhook] Duplicate event skipped: ${platform}/${transactionId}`);
683
+ return { duplicate: true };
684
+ }
685
+
686
+ // 2. Registra o evento
687
+ await env.DB.prepare(
688
+ 'INSERT OR IGNORE INTO webhook_events (platform, transaction_id, email, status, raw_payload) VALUES (?,?,?,?,?)'
689
+ ).bind(platform, transactionId, context?.email || null, 'processed', rawPayload).run();
690
+
691
+ return { duplicate: false };
692
+ } catch (err: any) {
693
+ console.error(`[Webhook] Error processing ${platform}/${transactionId}:`, {
694
+ platform,
695
+ transactionId,
696
+ email: context?.email,
697
+ error: err?.message || String(err),
698
+ stack: err?.stack,
699
+ });
700
+ return { duplicate: false, error: err?.message || String(err) };
701
+ }
702
+ }
@@ -40,9 +40,19 @@ interface WhatsAppMessage {
40
40
  };
41
41
  }
42
42
 
43
+ // ── Resolvedores de secrets (canônico + legado) ────────────────────────────────
44
+ // Meta Cloud API v22.0 usa PHONE_NUMBER_ID e ACCESS_TOKEN como termos oficiais.
45
+ // Suportamos ambos os nomes para compatibilidade com secrets já configurados.
46
+ function resolvePhoneNumberId(env: Env): string | undefined {
47
+ return env.WHATSAPP_PHONE_NUMBER_ID || env.WA_PHONE_ID;
48
+ }
49
+ function resolveAccessToken(env: Env): string | undefined {
50
+ return env.WHATSAPP_ACCESS_TOKEN || env.WA_ACCESS_TOKEN;
51
+ }
52
+
43
53
  // ── sendWhatsApp — envia mensagem via Meta Cloud API ──────────────────────────
44
54
  export async function sendWhatsApp(env: Env, tipo: string, payload: TrackPayload, options: WhatsAppOptions = {}): Promise<any> {
45
- if (!env.WHATSAPP_PHONE_NUMBER_ID || !env.WHATSAPP_ACCESS_TOKEN || !env.WA_NOTIFY_NUMBER) {
55
+ if (!resolvePhoneNumberId(env) || !resolveAccessToken(env) || !env.WA_NOTIFY_NUMBER) {
46
56
  return { skipped: 'WhatsApp não configurado' };
47
57
  }
48
58
 
@@ -116,9 +126,11 @@ export async function sendWhatsApp(env: Env, tipo: string, payload: TrackPayload
116
126
  // ── _sendWARequest — executor interno ─────────────────────────────────────────
117
127
  async function _sendWARequest(env: Env, body: Record<string, any>): Promise<any> {
118
128
  try {
119
- const res = await fetch(`https://graph.facebook.com/v22.0/${env.WHATSAPP_PHONE_NUMBER_ID}/messages`, {
129
+ const phoneNumberId = resolvePhoneNumberId(env);
130
+ const accessToken = resolveAccessToken(env);
131
+ const res = await fetch(`https://graph.facebook.com/v22.0/${phoneNumberId}/messages`, {
120
132
  method: 'POST',
121
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${env.WHATSAPP_ACCESS_TOKEN}` },
133
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}` },
122
134
  body: JSON.stringify(body),
123
135
  });
124
136
  const data = await res.json();
@@ -110,7 +110,15 @@ export async function logFraudSignal(env: Env, request: Request, payload: TrackP
110
110
 
111
111
  let emailHash = null;
112
112
  if (payload.email) {
113
- try { emailHash = await sha256(payload.email.trim().toLowerCase()); } catch {}
113
+ try {
114
+ emailHash = await sha256(payload.email.trim().toLowerCase());
115
+ } catch (err: any) {
116
+ console.error('[Fraud] Error generating email hash:', {
117
+ email: payload.email,
118
+ error: err?.message || String(err),
119
+ stack: err?.stack,
120
+ });
121
+ }
114
122
  }
115
123
 
116
124
  await env.DB.prepare(`
@@ -177,7 +177,13 @@ export async function loadActiveWeights(env: Env): Promise<LogisticModel | null>
177
177
  try {
178
178
  const cached = await env.GEO_CACHE.get(LTV_WEIGHTS_KV_KEY, 'json') as LogisticModel | null;
179
179
  if (cached?.weights?.length) return cached;
180
- } catch {}
180
+ } catch (err: any) {
181
+ console.error('[Logistic] Error fetching LTV weights from KV cache:', {
182
+ key: LTV_WEIGHTS_KV_KEY,
183
+ error: err?.message || String(err),
184
+ stack: err?.stack,
185
+ });
186
+ }
181
187
  }
182
188
 
183
189
  // 2. Fallback: D1
@@ -378,13 +378,28 @@ export async function handleLtvAbTestResults(env: Env, request: Request, headers
378
378
  if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
379
379
 
380
380
  const url = new URL(request.url);
381
- const testId = url.searchParams.get('test_id') || null;
381
+ const testId = url.searchParams.get('test_id');
382
382
 
383
383
  try {
384
- const cond = testId ? 'WHERE test_id = ?' : 'WHERE status = \'running\'';
385
- const testBind = testId ? [parseInt(testId)] : [];
386
-
387
- const testRes = await env.DB.prepare(`SELECT id, name, status, min_sample, winner_id, started_at FROM ltv_ab_tests ${cond} LIMIT 1`).bind(...testBind).first();
384
+ let testRes: any;
385
+
386
+ if (testId) {
387
+ // Query específica para teste por ID
388
+ testRes = await env.DB.prepare(`
389
+ SELECT id, name, status, min_sample, winner_id, started_at
390
+ FROM ltv_ab_tests
391
+ WHERE test_id = ?
392
+ LIMIT 1
393
+ `).bind(parseInt(testId)).first();
394
+ } else {
395
+ // Query padrão: busca teste ativo em execução
396
+ testRes = await env.DB.prepare(`
397
+ SELECT id, name, status, min_sample, winner_id, started_at
398
+ FROM ltv_ab_tests
399
+ WHERE status = 'running'
400
+ LIMIT 1
401
+ `).first();
402
+ }
388
403
  if (!testRes) return new Response(JSON.stringify({ error: 'Nenhum teste encontrado' }), { status: 404, headers });
389
404
 
390
405
  const perf = await env.DB.prepare(`SELECT * FROM v_ab_test_performance WHERE test_id = ?`).bind((testRes as any).id).all();
@@ -101,7 +101,14 @@ export async function autoEnrichPayload(env: Env, payload: TrackPayload): Promis
101
101
  if (profile.fbc && !payload.fbc) payload.fbc = profile.fbc as string;
102
102
  if (profile.phone && !payload.phone) payload.phone = profile.phone as string;
103
103
  }
104
- } catch {}
104
+ } catch (err: any) {
105
+ console.error('[MatchQuality] Error enriching payload with profile data:', {
106
+ userId: payload.userId,
107
+ email: payload.email,
108
+ error: err?.message || String(err),
109
+ stack: err?.stack,
110
+ });
111
+ }
105
112
  }
106
113
 
107
114
  // 2. UTM Resurrection já foi tentada no /track handler (payload.utmRestored)
@@ -216,5 +223,10 @@ export async function purgeOldMatchQualityLogs(DB: D1Database): Promise<void> {
216
223
  await DB.prepare(
217
224
  `DELETE FROM match_quality_log WHERE logged_at < datetime('now', '-30 days')`
218
225
  ).run();
219
- } catch {}
226
+ } catch (err: any) {
227
+ console.error('[MatchQuality] Error purging old match quality logs:', {
228
+ error: err?.message || String(err),
229
+ stack: err?.stack,
230
+ });
231
+ }
220
232
  }