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.
- package/README.md +597 -414
- package/contracts/agent-versions.json +14 -0
- package/extracted-skill/tracking-events-generator/agents/master-orchestrator.md +71 -14
- package/extracted-skill/tracking-events-generator/agents/utm-agent.md +191 -0
- package/package.json +1 -1
- package/server-edge-tracker/config/utm-mapping.json +64 -0
- package/server-edge-tracker/dist-check/README.md +1 -0
- package/server-edge-tracker/dist-check/index.js +5164 -0
- package/server-edge-tracker/dist-check/index.js.map +8 -0
- package/server-edge-tracker/index.ts +171 -77
- package/server-edge-tracker/modules/db.ts +62 -2
- package/server-edge-tracker/modules/dispatch/ga4.ts +5 -0
- package/server-edge-tracker/modules/dispatch/meta.ts +5 -0
- package/server-edge-tracker/modules/dispatch/platforms.ts +16 -0
- package/server-edge-tracker/modules/dispatch/tiktok.ts +5 -0
- package/server-edge-tracker/modules/ml/fraud.ts +9 -1
- package/server-edge-tracker/modules/ml/logistic.ts +7 -1
- package/server-edge-tracker/modules/ml/ltv.ts +20 -5
- package/server-edge-tracker/modules/ml/matchquality.ts +14 -2
- package/server-edge-tracker/modules/utils.ts +123 -0
- package/server-edge-tracker/modules/utm/utm-enricher.ts +231 -0
- package/server-edge-tracker/schema-utm.sql +80 -0
- package/server-edge-tracker/types.ts +4 -1
|
@@ -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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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 =
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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')
|
|
892
|
-
if (platform === 'ga4')
|
|
893
|
-
if (platform === 'tiktok')
|
|
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 {
|
|
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')
|
|
381
|
+
const testId = url.searchParams.get('test_id');
|
|
382
382
|
|
|
383
383
|
try {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
}
|