cdp-edge 2.3.1 → 2.3.6

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 });
@@ -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,91 @@ export default {
273
281
  return new Response(JSON.stringify({ error: `eventName desconhecido: ${eventName.slice(0, 64)}` }), { status: 400, headers });
274
282
  }
275
283
 
276
- 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 });
284
+ // ── Sanitização e Validação de Campos String ──────────────────────
285
+ type SanitizeResult = { error?: string; sanitized: string | null };
286
+
287
+ const SANITIZE_FIELDS: Record<string, (val: string) => SanitizeResult> = {
288
+ email: (val: string) => {
289
+ if (!isValidEmail(val)) return { error: 'email inválido (formato incorreto)' };
290
+ return { sanitized: val.toLowerCase().trim() };
291
+ },
292
+ firstName: (val: string) => ({ sanitized: sanitizeString(val, 100) }),
293
+ lastName: (val: string) => ({ sanitized: sanitizeString(val, 100) }),
294
+ city: (val: string) => ({ sanitized: sanitizeString(val, 100) }),
295
+ state: (val: string) => ({ sanitized: sanitizeString(val, 100) }),
296
+ zip: (val: string) => ({ sanitized: sanitizeString(val, 20) }),
297
+ dob: (val: string) => ({ sanitized: sanitizeString(val, 20) }),
298
+ productName: (val: string) => ({ sanitized: sanitizeString(val, 200) }),
299
+ pageUrl: (val: string) => {
300
+ if (!isValidUrl(val)) return { error: 'pageUrl inválido (formato incorreto)' };
301
+ return { sanitized: val.trim() };
302
+ },
303
+ currency: (val: string) => {
304
+ if (!isValidCurrency(val)) return { error: 'currency inválido (deve ser código ISO 4217)' };
305
+ return { sanitized: val.trim().toUpperCase() };
306
+ },
307
+ };
308
+
309
+ // Sanitiza e valida campos específicos
310
+ for (const [field, validator] of Object.entries(SANITIZE_FIELDS)) {
311
+ const value = trackPayload[field as keyof TrackPayload];
312
+ if (value !== undefined && value !== null) {
313
+ if (typeof value !== 'string') {
314
+ return new Response(JSON.stringify({ error: `Campo ${field} deve ser string` }), { status: 400, headers });
315
+ }
316
+ const result = validator(value);
317
+ if (result.error) {
318
+ return new Response(JSON.stringify({ error: result.error }), { status: 400, headers });
319
+ }
320
+ if (result.sanitized !== null) {
321
+ trackPayload[field as keyof TrackPayload] = result.sanitized as any;
280
322
  }
281
323
  }
282
324
  }
283
325
 
326
+ // Sanitiza campos de string genéricos
327
+ const GENERIC_SANITIZE_FIELDS = ['utmSource', 'utmMedium', 'utmCampaign', 'utmContent', 'utmTerm'];
328
+ for (const field of GENERIC_SANITIZE_FIELDS) {
329
+ const value = trackPayload[field as keyof TrackPayload];
330
+ if (value !== undefined && value !== null) {
331
+ if (typeof value !== 'string') {
332
+ return new Response(JSON.stringify({ error: `Campo ${field} deve ser string` }), { status: 400, headers });
333
+ }
334
+ const utmType = `utm_${field.replace('utm', '').toLowerCase()}`;
335
+ if (!isValidUTM(value, utmType)) {
336
+ return new Response(JSON.stringify({ error: `Campo ${field} contém caracteres perigosos` }), { status: 400, headers });
337
+ }
338
+ const sanitized = sanitizeString(value, 200);
339
+ if (sanitized === null) {
340
+ return new Response(JSON.stringify({ error: `Campo ${field} inválido após sanitização` }), { status: 400, headers });
341
+ }
342
+ trackPayload[field as keyof TrackPayload] = sanitized as any;
343
+ }
344
+ }
345
+
346
+ // Sanitiza campos de string restantes
347
+ const TRACKING_ID_FIELDS = ['transactionId', 'fbclid', 'ttclid', 'gclid'];
348
+
349
+ for (const field of TRACKING_ID_FIELDS) {
350
+ const value = trackPayload[field as keyof TrackPayload];
351
+ if (value !== undefined && value !== null) {
352
+ if (typeof value !== 'string') {
353
+ return new Response(JSON.stringify({ error: `Campo ${field} deve ser string` }), { status: 400, headers });
354
+ }
355
+ const sanitized = sanitizeString(value, 512);
356
+ if (sanitized === null) {
357
+ return new Response(JSON.stringify({ error: `Campo ${field} inválido após sanitização` }), { status: 400, headers });
358
+ }
359
+ trackPayload[field as keyof TrackPayload] = sanitized as any;
360
+ }
361
+ }
362
+
363
+ // ── Validação de Valor Numérico ───────────────────────────────────
284
364
  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 });
365
+ if (!isValidValue(trackPayload.value)) {
366
+ return new Response(JSON.stringify({ error: 'value fora do intervalo permitido (0-9,999,999)' }), { status: 400, headers });
288
367
  }
289
- trackPayload.value = v;
368
+ trackPayload.value = Number(trackPayload.value);
290
369
  }
291
370
 
292
371
  // ── Extrair dados comportamentais do browser ──────────────────────────
@@ -297,14 +376,46 @@ export default {
297
376
  // Sinais de engajamento profundo — usados no anti-falso-positivo e no LTV
298
377
  payload.scrollScore = behavioral_data.scroll_score ?? null;
299
378
  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;
379
+
380
+ // ── Sanitiza dados do behavioral_data ────────────────────────
381
+ // Os dados do behavioral_data podem vir do browser e ser manipulados
382
+ const sanitizedBehavioralEmail = behavioral_data.email && isValidEmail(behavioral_data.email)
383
+ ? behavioral_data.email.toLowerCase().trim()
384
+ : null;
385
+ const sanitizedBehavioralPhone = behavioral_data.phone
386
+ ? sanitizeString(behavioral_data.phone, 50)
387
+ : null;
388
+ const sanitizedBehavioralFirstName = behavioral_data.first_name || behavioral_data.firstName
389
+ ? sanitizeString(behavioral_data.first_name || behavioral_data.firstName, 100)
390
+ : null;
391
+ const sanitizedBehavioralLastName = behavioral_data.last_name || behavioral_data.lastName
392
+ ? sanitizeString(behavioral_data.last_name || behavioral_data.lastName, 100)
393
+ : null;
394
+ const sanitizedBehavioralCity = behavioral_data.city
395
+ ? sanitizeString(behavioral_data.city, 100)
396
+ : null;
397
+
398
+ // Usa dados sanitizados do behavioral_data se não existirem no payload principal
399
+ payload.email = payload.email || sanitizedBehavioralEmail;
400
+ payload.phone = payload.phone || sanitizedBehavioralPhone;
401
+ payload.firstName = payload.firstName || sanitizedBehavioralFirstName;
402
+ payload.lastName = payload.lastName || sanitizedBehavioralLastName;
403
+ payload.city = payload.city || sanitizedBehavioralCity;
404
+
405
+ // Sanitiza campos restantes do behavioral_data
406
+ const sanitizedBehavioralState = behavioral_data.state
407
+ ? sanitizeString(behavioral_data.state, 100)
408
+ : null;
409
+ const sanitizedBehavioralZip = behavioral_data.zip
410
+ ? sanitizeString(behavioral_data.zip, 20)
411
+ : null;
412
+ const sanitizedBehavioralDob = behavioral_data.dob
413
+ ? sanitizeString(behavioral_data.dob, 20)
414
+ : null;
415
+
416
+ payload.state = payload.state || sanitizedBehavioralState;
417
+ payload.zip = payload.zip || sanitizedBehavioralZip;
418
+ payload.dob = payload.dob || sanitizedBehavioralDob;
308
419
  }
309
420
 
310
421
  // ── Normalização de intent_score → 0.0–1.0 ──────────────────────────
@@ -510,7 +621,13 @@ export default {
510
621
  try {
511
622
  const profileRow = await env.DB.prepare('SELECT score FROM user_profiles WHERE user_id = ?').bind(trackPayload.userId).first();
512
623
  if (profileRow) currentScore = Number(profileRow.score) || 0;
513
- } catch {}
624
+ } catch (err: any) {
625
+ console.error('[POST /track] Error fetching user profile score:', {
626
+ userId: trackPayload.userId,
627
+ error: err?.message || String(err),
628
+ stack: err?.stack,
629
+ });
630
+ }
514
631
  }
515
632
 
516
633
  const resHeaders = new Headers(headers);
@@ -549,15 +666,12 @@ export default {
549
666
  }
550
667
 
551
668
  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 {}
669
+ const dupCheck = await processWebhookDuplicateCheck(env, 'hotmart', hmTxId, JSON.stringify(wh), {
670
+ email: buyer.email,
671
+ });
672
+
673
+ if (dupCheck.duplicate) {
674
+ return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
561
675
  }
562
676
 
563
677
  const profile = await getProfileByEmail(env, buyer.email);
@@ -584,14 +698,6 @@ export default {
584
698
  country: profile?.country,
585
699
  };
586
700
 
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
701
  ctx.waitUntil(Promise.allSettled([
596
702
  sendMetaCapi(env, 'Purchase', payload, request, ctx),
597
703
  sendGA4Mp(env, 'purchase', payload, ctx),
@@ -622,15 +728,12 @@ export default {
622
728
  }
623
729
 
624
730
  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 {}
731
+ const dupCheck = await processWebhookDuplicateCheck(env, 'kiwify', kwTxId, JSON.stringify(wh), {
732
+ email: customer.email,
733
+ });
734
+
735
+ if (dupCheck.duplicate) {
736
+ return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
634
737
  }
635
738
 
636
739
  const customer = wh.Customer || {};
@@ -659,14 +762,6 @@ export default {
659
762
  country: profile?.country,
660
763
  };
661
764
 
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
765
  ctx.waitUntil(Promise.allSettled([
671
766
  sendMetaCapi(env, 'Purchase', payload, request, ctx),
672
767
  sendGA4Mp(env, 'purchase', payload, ctx),
@@ -716,15 +811,12 @@ export default {
716
811
  const transactionId = order.hash || order.transaction_hash || order.id;
717
812
  const tcTxId = String(order.hash || order.transaction_hash || order.id || '');
718
813
 
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 {}
814
+ const dupCheck = await processWebhookDuplicateCheck(env, 'ticto', tcTxId, rawBody, {
815
+ email: customer.email,
816
+ });
817
+
818
+ if (dupCheck.duplicate) {
819
+ return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
728
820
  }
729
821
 
730
822
  const urlUserId = tracking.user_id || wh.url_params?.user_id;
@@ -734,7 +826,14 @@ export default {
734
826
  profile = await env.DB.prepare(
735
827
  'SELECT * FROM user_profiles WHERE user_id = ? ORDER BY updated_at DESC LIMIT 1'
736
828
  ).bind(urlUserId).first();
737
- } catch {}
829
+ } catch (err: any) {
830
+ console.error('[POST /webhook/ticto] Error fetching user profile by userId:', {
831
+ userId: urlUserId,
832
+ email: customer.email,
833
+ error: err?.message || String(err),
834
+ stack: err?.stack,
835
+ });
836
+ }
738
837
  }
739
838
 
740
839
  const fbclid = tracking.fbclid || wh.url_params?.fbclid;
@@ -767,14 +866,6 @@ export default {
767
866
  utmContent: tracking.utm_content || '',
768
867
  };
769
868
 
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
869
  ctx.waitUntil(Promise.allSettled([
779
870
  sendMetaCapi(env, 'Purchase', payload, request, ctx),
780
871
  sendGA4Mp(env, 'purchase', payload, ctx),
@@ -882,15 +973,18 @@ export default {
882
973
  },
883
974
 
884
975
  // ── Queue Consumer — Retry de eventos com falha ───────────────────────────────
885
- async queue(batch: any, env: Env) {
976
+ async queue(batch: any, env: Env, ctx: ExecutionContext) {
886
977
  for (const message of batch.messages) {
887
978
  const { eventType, payload, platform } = message.body as { eventType: string; payload: TrackPayload; platform: string; attempt?: number };
888
979
 
889
-
890
980
  try {
891
- if (platform === 'meta') await sendMetaCapi(env, eventType, payload, null, null);
892
- if (platform === 'ga4') await sendGA4Mp(env, eventType, payload, null);
893
- if (platform === 'tiktok') await sendTikTokApi(env, eventType, payload, null, null);
981
+ if (platform === 'meta') await sendMetaCapi(env, eventType, payload, null, ctx);
982
+ if (platform === 'ga4') await sendGA4Mp(env, eventType, payload, ctx);
983
+ if (platform === 'tiktok') await sendTikTokApi(env, eventType, payload, null, ctx);
984
+ if (platform === 'pinterest') await sendPinterestCapi(env, eventType, payload, null, ctx);
985
+ if (platform === 'reddit') await sendRedditCapi(env, eventType, payload, null, ctx);
986
+ if (platform === 'linkedin') await sendLinkedInCapi(env, eventType, payload, null, ctx);
987
+ if (platform === 'spotify') await sendSpotifyCapi(env, eventType, payload, null, ctx);
894
988
 
895
989
  message.ack();
896
990
  } catch (err: any) {
@@ -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
+ }
@@ -62,6 +62,11 @@ export async function sendGA4Mp(env: Env, ga4EventName: string, payload: TrackPa
62
62
  ctx.waitUntil(logApiFailure(env.DB, 'ga4', ga4EventName, 'FETCH_ERROR', err?.message || String(err), '', JSON.stringify(body)));
63
63
  }
64
64
 
65
+ if (env.RETRY_QUEUE) {
66
+ const send = env.RETRY_QUEUE.send({ eventType: ga4EventName, payload, platform: 'ga4' });
67
+ if (ctx) ctx.waitUntil(send); else send.catch(() => {});
68
+ }
69
+
65
70
  return { error: err?.message || String(err) };
66
71
  }
67
72
  }
@@ -133,6 +133,11 @@ export async function sendMetaCapi(env: Env, eventName: string, payload: TrackPa
133
133
  ctx.waitUntil(logApiFailure(env.DB, 'meta', eventName, 'FETCH_ERROR', err?.message || String(err), eventPayload.event_id, JSON.stringify(requestBody)));
134
134
  }
135
135
 
136
+ if (env.RETRY_QUEUE) {
137
+ const send = env.RETRY_QUEUE.send({ eventType: eventName, payload, platform: 'meta' });
138
+ if (ctx) ctx.waitUntil(send); else send.catch(() => {});
139
+ }
140
+
136
141
  return { error: err?.message || String(err) };
137
142
  }
138
143
  }
@@ -65,6 +65,10 @@ export async function sendPinterestCapi(env: Env, eventName: string, payload: Tr
65
65
  } catch (err: any) {
66
66
  console.error('Pinterest CAPI fetch failed:', err?.message || String(err));
67
67
  if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'pinterest', eventName, 'FETCH_ERROR', err?.message || String(err), '', JSON.stringify(body)));
68
+ if (env.RETRY_QUEUE) {
69
+ const send = env.RETRY_QUEUE.send({ eventType: eventName, payload, platform: 'pinterest' });
70
+ if (ctx) ctx.waitUntil(send); else send.catch(() => {});
71
+ }
68
72
  return { error: err?.message || String(err) };
69
73
  }
70
74
  }
@@ -123,6 +127,10 @@ export async function sendRedditCapi(env: Env, eventName: string, payload: Track
123
127
  } catch (err: any) {
124
128
  console.error('Reddit CAPI fetch failed:', err?.message || String(err));
125
129
  if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'reddit', eventName, 'FETCH_ERROR', err?.message || String(err), '', JSON.stringify(body)));
130
+ if (env.RETRY_QUEUE) {
131
+ const send = env.RETRY_QUEUE.send({ eventType: eventName, payload, platform: 'reddit' });
132
+ if (ctx) ctx.waitUntil(send); else send.catch(() => {});
133
+ }
126
134
  return { error: err?.message || String(err) };
127
135
  }
128
136
  }
@@ -178,6 +186,10 @@ export async function sendLinkedInCapi(env: Env, eventName: string, payload: Tra
178
186
  } catch (err: any) {
179
187
  console.error('LinkedIn CAPI fetch failed:', err?.message || String(err));
180
188
  if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'linkedin', eventName, 'FETCH_ERROR', err?.message || String(err), '', JSON.stringify(body)));
189
+ if (env.RETRY_QUEUE) {
190
+ const send = env.RETRY_QUEUE.send({ eventType: eventName, payload, platform: 'linkedin' });
191
+ if (ctx) ctx.waitUntil(send); else send.catch(() => {});
192
+ }
181
193
  return { error: err?.message || String(err) };
182
194
  }
183
195
  }
@@ -234,6 +246,10 @@ export async function sendSpotifyCapi(env: Env, eventName: string, payload: Trac
234
246
  } catch (err: any) {
235
247
  console.error('Spotify CAPI fetch failed:', err?.message || String(err));
236
248
  if (env.DB && ctx) ctx.waitUntil(logApiFailure(env.DB, 'spotify', eventName, 'FETCH_ERROR', err?.message || String(err), '', JSON.stringify(body)));
249
+ if (env.RETRY_QUEUE) {
250
+ const send = env.RETRY_QUEUE.send({ eventType: eventName, payload, platform: 'spotify' });
251
+ if (ctx) ctx.waitUntil(send); else send.catch(() => {});
252
+ }
237
253
  return { error: err?.message || String(err) };
238
254
  }
239
255
  }
@@ -97,6 +97,11 @@ export async function sendTikTokApi(env: Env, eventName: string, payload: TrackP
97
97
  ctx.waitUntil(logApiFailure(env.DB, 'tiktok', eventName, 'FETCH_ERROR', err?.message || String(err), '', JSON.stringify(body)));
98
98
  }
99
99
 
100
+ if (env.RETRY_QUEUE) {
101
+ const send = env.RETRY_QUEUE.send({ eventType: eventName, payload, platform: 'tiktok' });
102
+ if (ctx) ctx.waitUntil(send); else send.catch(() => {});
103
+ }
104
+
100
105
  return { error: err?.message || String(err) };
101
106
  }
102
107
  }
@@ -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
  }