cdp-edge 2.3.0 → 2.3.5
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 +554 -391
- 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/.client.env.example +14 -0
- package/server-edge-tracker/config/utm-mapping.json +64 -0
- package/server-edge-tracker/deploy-client.js +76 -0
- package/server-edge-tracker/index.ts +165 -74
- package/server-edge-tracker/modules/db.ts +63 -3
- package/server-edge-tracker/modules/dispatch/whatsapp.ts +15 -3
- 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 +6 -2
- package/server-edge-tracker/wrangler.toml +6 -6
|
@@ -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
|
|
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,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;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
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 });
|
|
280
341
|
}
|
|
342
|
+
trackPayload[field as keyof TrackPayload] = sanitized as any;
|
|
281
343
|
}
|
|
282
344
|
}
|
|
283
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),
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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 ${
|
|
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 {
|
|
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
|
}
|