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.
- package/README.md +164 -1
- 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 +161 -73
- 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
|
@@ -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,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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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 =
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
-
|
|
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
|
}
|