cdp-edge 2.3.7 → 2.3.9

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.
@@ -1,15 +1,15 @@
1
1
  # Modelo: Quiz Funnel (Cloudflare Native)
2
2
 
3
- Este modelo é destinado a funis de quiz, onde o usuário responde a uma série de perguntas antes de ser redirecionado para a oferta final. O rastreamento foca na progressão do usuário e na captura de dados intermediários.
3
+ Este modelo é destinado a funis de quiz, onde o usuário responde a uma série de perguntas antes de ser redirecionado para a oferta final. O rastreamento foca na progressão do usuário e na **qualificação automática de intenção via Workers AI**.
4
4
 
5
5
  ---
6
6
 
7
7
  ## 🏗️ ARQUITETURA TÉCNICA (Quantum Tier)
8
8
 
9
- O rastreamento segue a lógica de micro-eventos:
9
+ O rastreamento segue a lógica de micro-eventos + scoring automático:
10
10
  1. **Página**: Dispara um evento a cada resposta dada no quiz via `cdpTrack.track()`.
11
- 2. **Servidor (Worker)**: Recebe e armazena o progresso no banco D1.
12
- 3. **Database (D1)**: Mantém o histórico de respostas vinculado ao `track_user_id`.
11
+ 2. **Servidor (Worker)**: Ao receber `QuizComplete`, envia as respostas ao **Quiz Scoring Engine** (Granite 4.0 Micro) que classifica o respondente.
12
+ 3. **Pipeline CDP**: A qualificação (`comprador | interessado | curioso | perdido`) é injetada como `intentionLevel` e flui automaticamente para LTV Prediction, Meta Signal Score, D1 e CAPI dispatch.
13
13
 
14
14
  ---
15
15
 
@@ -19,50 +19,114 @@ O rastreamento segue a lógica de micro-eventos:
19
19
  |---|---|---|
20
20
  | **QuizStart** | Início do quiz | `quiz_name`, `source` |
21
21
  | **QuizAnswer** | Resposta a uma pergunta | `question`, `answer`, `step` |
22
- | **QuizComplete** | Finalização do quiz | `result`, `completion_time` |
22
+ | **QuizComplete** | Finalização + qualificação AI | `quiz_name`, `quiz_answers[]`, `result` |
23
+
24
+ ---
25
+
26
+ ## 🤖 QUALIFICAÇÃO AUTOMÁTICA (Quiz Scoring Engine — Fase 6)
27
+
28
+ Ao receber `QuizComplete` com `quiz_answers`, o Worker classifica automaticamente:
29
+
30
+ | Qualificação | Significado | intent_score |
31
+ |---|---|---|
32
+ | **comprador** | Pronto para comprar agora | 0.80–1.00 |
33
+ | **interessado** | Interesse real, avaliando | 0.50–0.79 |
34
+ | **curioso** | Pesquisando, sem urgência | 0.20–0.49 |
35
+ | **perdido** | Fora do público, sem fit | 0.00–0.19 |
36
+
37
+ O `intent_score` resultante:
38
+ - Alimenta o **LTV Prediction** (comprador → LTV High automaticamente)
39
+ - Compõe o **Meta Signal Score** (pesos dinâmicos por funil)
40
+ - Persiste em `leads.intention_level` e `quiz_sessions` no D1
41
+ - É enviado como `custom_data` para Meta CAPI, GA4 e TikTok
23
42
 
24
43
  ---
25
44
 
26
45
  ## 🛠️ PASSO 1: CONFIGURAÇÃO DO SITE
27
46
 
28
47
  ### 1.1 Rastreamento de Respostas
29
- Integre este código na lógica de clique do seu quiz.
48
+ Acumule as respostas do quiz em um array local.
30
49
 
31
50
  ```javascript
32
- // Exemplo de captura de resposta
51
+ const quizAnswers = [];
52
+
33
53
  function onResponder(pergunta, resposta, etapa) {
54
+ // Armazena localmente para enviar no QuizComplete
55
+ quizAnswers.push({ question: pergunta, answer: resposta, step: etapa });
56
+
57
+ // Dispara micro-evento por resposta (opcional, para análise granular)
34
58
  cdpTrack.track('QuizAnswer', {
35
59
  question: pergunta,
36
60
  answer: resposta,
37
61
  step: etapa,
38
- event_id: cdpTrack.generateId()
62
+ event_id: cdpTrack.generateId(),
39
63
  });
40
64
  }
41
65
  ```
42
66
 
43
- ### 1.2 Finalização do Quiz
44
- Disparar ao chegar no resultado final ou na página de captura pós-quiz.
67
+ ### 1.2 Finalização do Quiz — com qualificação AI automática
68
+ Envie todas as respostas no `QuizComplete`. O Worker qualifica automaticamente.
45
69
 
46
70
  ```javascript
47
71
  cdpTrack.track('QuizComplete', {
48
- result: 'Perfil_A',
49
- event_id: cdpTrack.generateId()
72
+ quiz_name: 'Diagnóstico de Perfil', // nome para o dashboard
73
+ quiz_answers: quizAnswers, // array com todas as respostas
74
+ result: 'Perfil_A', // resultado exibido ao usuário (opcional)
75
+ event_id: cdpTrack.generateId(),
50
76
  });
51
77
  ```
52
78
 
79
+ ### 1.3 Resposta do Worker
80
+ O endpoint `/track` retorna a qualificação para uso imediato no front:
81
+
82
+ ```json
83
+ {
84
+ "ok": true,
85
+ "userProfile": {
86
+ "score": 87,
87
+ "user_id": "uuid-xxx"
88
+ },
89
+ "quiz_qualification": "comprador",
90
+ "quiz_confidence": 0.91,
91
+ "quiz_signals": ["quero comprar", "tenho budget", "agora"],
92
+ "intent_score": 0.92,
93
+ "intent_bucket": "high"
94
+ }
95
+ ```
96
+
97
+ Use esses campos para personalizar o redirecionamento pós-quiz no front-end.
98
+
53
99
  ---
54
100
 
55
101
  ## ⚡ PASSO 2: SERVIDOR (CLOUDFLARE WORKER)
56
102
 
57
- O Worker realiza:
58
- - **Agregação**: O `user_id` permite que todas as respostas sejam vinculadas a um único perfil no banco D1.
59
- - **Enriquecimento**: Se o usuário deixar o e-mail no final, todas as respostas anteriores são associadas ao e-mail para a CAPI.
60
- - **API Dispatch**: Envio de eventos customizados para Meta e TikTok para otimização de público.
103
+ O Worker realiza automaticamente na ordem:
104
+
105
+ 1. **Quiz Scoring Engine**: Granite 4.0 Micro classifica as respostas `qualification` + `intent_score`
106
+ 2. **LTV Prediction**: usa `intentionLevel = 'comprador'` LTV High valor previsto em BRL
107
+ 3. **Meta Signal Score**: `intent_score` compõe o score composto (intent × ltv × distância)
108
+ 4. **D1 Writes**: `quiz_sessions` + `leads.intention_level` + `user_profiles.cohort_label`
109
+ 5. **CAPI Dispatch**: Meta/GA4/TikTok recebem evento com `custom_data.intention = 'comprador'`
110
+
111
+ ---
112
+
113
+ ## 🔀 FALLBACK HEURÍSTICO
114
+
115
+ Se Workers AI estiver indisponível (timeout, cold start), o sistema usa correspondência de palavras-chave:
116
+
117
+ - `comprador`: "quero", "comprar", "agora", "tenho interesse", "quanto custa"
118
+ - `interessado`: "talvez", "pensando", "em breve", "estou avaliando"
119
+ - `curioso`: "só olhando", "pesquisando", "curiosidade"
120
+ - `perdido`: "não entendi", "errei aqui", "não é para mim"
121
+
122
+ O campo `quiz_source` indica `"ai"` ou `"heuristic"` para auditoria.
61
123
 
62
124
  ---
63
125
 
64
126
  ## ✅ VALIDAÇÃO TÉCNICA
65
127
 
66
- - **Persistência**: Verifique no banco D1 se a jornada do usuário está sendo gravada passo a passo.
67
- - **Deduplicação**: O `event_id` único por resposta evita contagens duplicadas.
68
- - **Match Quality**: A vinculação tardia do e-mail com as respostas iniciais aumenta a precisão da atribuição.
128
+ - **Persistência**: `quiz_sessions` no D1 jornada completa por `user_id`
129
+ - **Deduplicação**: `event_id` único por evento evita contagens duplicadas
130
+ - **Enriquecimento retroativo**: e-mail preenchido pós-quiz associa todas as respostas ao perfil
131
+ - **Match Quality**: `comprador` com e-mail → score máximo na CAPI Meta
132
+ - **VIEW de dashboard**: `v_quiz_qualification_summary` — distribuição de qualificações por quiz
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdp-edge",
3
- "version": "2.3.7",
3
+ "version": "2.3.9",
4
4
  "description": "CDP Edge - Quantum Tracking - Sistema multi-agente para tracking digital Cloudflare Native (Workers + D1)",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -101,6 +101,15 @@ import {
101
101
  handleFraudStats,
102
102
  } from './modules/ml/fraud';
103
103
 
104
+ // ── Quiz Scoring Engine (Fase 6) ──────────────────────────────────────────────
105
+ import {
106
+ scoreQuizAnswers,
107
+ saveQuizSession,
108
+ } from './modules/ml/quiz';
109
+
110
+ // ── Nurture Engine (Fase 7) ───────────────────────────────────────────────────
111
+ import { scheduleNurture } from './modules/nurture';
112
+
104
113
  // ── Intelligence Agent (Cron) ─────────────────────────────────────────────────
105
114
  import {
106
115
  runIntelligenceAgent,
@@ -272,6 +281,27 @@ export default {
272
281
  const { eventName, behavioral_data, ...payload } = body as { eventName?: string; behavioral_data?: BehavioralData; [key: string]: any };
273
282
  const trackPayload: TrackPayload = payload;
274
283
 
284
+ // ── Extrair click_ids e utms de sub-objetos (formato cdpTrack.js) ────────
285
+ // cdpTrack.js envia fbp/fbc/fbclid dentro de click_ids{} e UTMs dentro de utms{}
286
+ // O Worker trabalha com campos no nível raiz — esta extração resolve o mismatch.
287
+ if (payload.click_ids && typeof payload.click_ids === 'object') {
288
+ const c = payload.click_ids as Record<string, string>;
289
+ if (!trackPayload.fbp && c.fbp) trackPayload.fbp = c.fbp;
290
+ if (!trackPayload.fbc && c.fbc) trackPayload.fbc = c.fbc;
291
+ if (!trackPayload.fbclid && c.fbclid) trackPayload.fbclid = c.fbclid;
292
+ if (!trackPayload.gclid && c.gclid) trackPayload.gclid = c.gclid;
293
+ if (!trackPayload.ttclid && c.ttclid) trackPayload.ttclid = c.ttclid;
294
+ if (!trackPayload.ttp && c.ttp) trackPayload.ttp = c.ttp; // TikTok Pixel cookie
295
+ }
296
+ if (payload.utms && typeof payload.utms === 'object') {
297
+ const u = payload.utms as Record<string, string>;
298
+ if (!trackPayload.utmSource && u.utm_source) trackPayload.utmSource = u.utm_source;
299
+ if (!trackPayload.utmMedium && u.utm_medium) trackPayload.utmMedium = u.utm_medium;
300
+ if (!trackPayload.utmCampaign && u.utm_campaign) trackPayload.utmCampaign = u.utm_campaign;
301
+ if (!trackPayload.utmContent && u.utm_content) trackPayload.utmContent = u.utm_content;
302
+ if (!trackPayload.utmTerm && u.utm_term) trackPayload.utmTerm = u.utm_term;
303
+ }
304
+
275
305
  // ── Validação de eventName ────────────────────────────────────────
276
306
  if (!eventName) {
277
307
  return new Response(JSON.stringify({ error: 'eventName é obrigatório' }), { status: 400, headers });
@@ -286,7 +316,7 @@ export default {
286
316
 
287
317
  const SANITIZE_FIELDS: Record<string, (val: string) => SanitizeResult> = {
288
318
  email: (val: string) => {
289
- if (!isValidEmail(val)) return { error: 'email inválido (formato incorreto)' };
319
+ if (!isValidEmail(val)) return { error: 'email inválido (formato incorreto)', sanitized: null };
290
320
  return { sanitized: val.toLowerCase().trim() };
291
321
  },
292
322
  firstName: (val: string) => ({ sanitized: sanitizeString(val, 100) }),
@@ -297,11 +327,11 @@ export default {
297
327
  dob: (val: string) => ({ sanitized: sanitizeString(val, 20) }),
298
328
  productName: (val: string) => ({ sanitized: sanitizeString(val, 200) }),
299
329
  pageUrl: (val: string) => {
300
- if (!isValidUrl(val)) return { error: 'pageUrl inválido (formato incorreto)' };
330
+ if (!isValidUrl(val)) return { error: 'pageUrl inválido (formato incorreto)', sanitized: null };
301
331
  return { sanitized: val.trim() };
302
332
  },
303
333
  currency: (val: string) => {
304
- if (!isValidCurrency(val)) return { error: 'currency inválido (deve ser código ISO 4217)' };
334
+ if (!isValidCurrency(val)) return { error: 'currency inválido (deve ser código ISO 4217)', sanitized: null };
305
335
  return { sanitized: val.trim().toUpperCase() };
306
336
  },
307
337
  };
@@ -360,6 +390,14 @@ export default {
360
390
  }
361
391
  }
362
392
 
393
+ // ── fbc derivado de fbclid ───────────────────────────────────────────
394
+ // Quando o usuário vem de um clique Meta, fbclid chega na URL e o cdpTrack.js
395
+ // o envia como campo. O CAPI espera fbc no formato fb.1.{ms}.{fbclid}.
396
+ // Sem essa conversão, o sinal de clique some — EMQ cai e deduplicação piora.
397
+ if (trackPayload.fbclid && !trackPayload.fbc) {
398
+ trackPayload.fbc = `fb.1.${Date.now()}.${trackPayload.fbclid}`;
399
+ }
400
+
363
401
  // ── Validação de Valor Numérico ───────────────────────────────────
364
402
  if (trackPayload.value !== undefined && trackPayload.value !== null) {
365
403
  if (!isValidValue(trackPayload.value)) {
@@ -519,8 +557,46 @@ export default {
519
557
  distKm < 60 ? 'moderate' : 'far';
520
558
  }
521
559
 
560
+ // ── Quiz Scoring Engine (Fase 6) ─────────────────────────────────────
561
+ // Roda antes do LTV para que intentionLevel qualificado alimente a predição.
562
+ // O front envia quiz_answers: [{question, answer, step}] junto com QuizComplete.
563
+ if (eventName === 'QuizComplete' && Array.isArray(payload.quiz_answers) && payload.quiz_answers.length > 0) {
564
+ try {
565
+ const quizResult = await scoreQuizAnswers(env, payload.quiz_answers, payload.quiz_name ?? null);
566
+
567
+ // Injeta qualificação no payload — flui para LTV, Meta Signal, D1 e CAPI
568
+ payload.intentionLevel = quizResult.qualification;
569
+ payload.intent_score = quizResult.intent_score;
570
+ payload.intentScoreNum = quizResult.intent_score;
571
+ payload.intent_bucket = quizResult.intent_score >= 0.8 ? 'high'
572
+ : quizResult.intent_score >= 0.5 ? 'medium' : 'low';
573
+
574
+ // Campos extras para auditoria e dashboard
575
+ (payload as any).quiz_qualification = quizResult.qualification;
576
+ (payload as any).quiz_confidence = quizResult.confidence;
577
+ (payload as any).quiz_weighted_score = quizResult.weighted_score;
578
+ (payload as any).quiz_dominant_dimension = quizResult.dominant_dimension;
579
+ (payload as any).quiz_signals = quizResult.dimensions.map((d: any) => d.signal).filter(Boolean);
580
+ (payload as any).quiz_source = quizResult.source;
581
+
582
+ // utm_term — injetado pelo Worker após qualificação (nunca configurado nos anúncios)
583
+ // Flui para Meta CAPI custom_data e D1 user_profiles para cruzar com ROAS Feedback
584
+ payload.utmTerm = quizResult.qualification; // comprador | interessado | curioso | perdido
585
+
586
+ // Persiste sessão no D1 em background
587
+ if (env.DB) ctx.waitUntil(saveQuizSession(env, payload.userId, payload, quizResult));
588
+
589
+ // Agenda nurture sequence baseada na qualificação (background)
590
+ ctx.waitUntil(scheduleNurture(env, payload, quizResult.qualification));
591
+
592
+ } catch (err: any) {
593
+ console.error('[QuizScoring] erro ao qualificar respostas:', err?.message || String(err));
594
+ // Fail-safe: continua sem qualificação
595
+ }
596
+ }
597
+
522
598
  // ── LTV Prediction (+ A/B Testing de Prompts) ─────────────────────────
523
- const LTV_EVENTS = ['Lead', 'Contact', 'Schedule', 'CompleteRegistration'];
599
+ const LTV_EVENTS = ['Lead', 'Contact', 'Schedule', 'CompleteRegistration', 'QuizComplete'];
524
600
  if (LTV_EVENTS.includes(eventName) && !payload.value) {
525
601
  const abVariation = await getLtvAbVariation(env);
526
602
  const ltv = await predictLtv(env, payload, request, abVariation?.system_prompt || null);
@@ -727,6 +803,7 @@ export default {
727
803
  return new Response(JSON.stringify({ skipped: `status ${wh.order_status}` }), { status: 200, headers });
728
804
  }
729
805
 
806
+ const customer = wh.Customer || {};
730
807
  const kwTxId = String(wh.order_id || '');
731
808
  const dupCheck = await processWebhookDuplicateCheck(env, 'kiwify', kwTxId, JSON.stringify(wh), {
732
809
  email: customer.email,
@@ -735,8 +812,6 @@ export default {
735
812
  if (dupCheck.duplicate) {
736
813
  return new Response(JSON.stringify({ ok: true, skipped: 'duplicate' }), { status: 200, headers });
737
814
  }
738
-
739
- const customer = wh.Customer || {};
740
815
  const product = wh.Product || {};
741
816
  const profile = await getProfileByEmail(env, customer.email || '');
742
817
 
@@ -9,6 +9,8 @@ import { sendCallMeBot } from './dispatch/whatsapp.js';
9
9
  import { autoDecideAbWinner } from './ml/ltv.js';
10
10
  import { analyzeMatchQuality, alertMatchQuality, purgeOldMatchQualityLogs } from './ml/matchquality.js';
11
11
  import { trainLogisticRegression, extractFeatures, saveWeights, LTV_WEIGHTS_KV_KEY } from './ml/logistic.js';
12
+ import { computeRoasFeedback, sendRoasAlert } from './ml/roas.js';
13
+ import { runNurtureQueue } from './nurture.js';
12
14
  import { Env } from '../types.js';
13
15
 
14
16
  // ── Tipos ───────────────────────────────────────────────────────────────────────
@@ -57,6 +59,22 @@ export interface IntelligenceAgentResult {
57
59
  skipped?: string;
58
60
  error?: string;
59
61
  };
62
+ roasResult?: {
63
+ campaigns: number;
64
+ total_revenue: number;
65
+ best_campaign: string | null;
66
+ skipped?: string;
67
+ };
68
+ nurtureResult?: {
69
+ processed: number;
70
+ sent: number;
71
+ failed: number;
72
+ };
73
+ lookalikeResult?: {
74
+ sent: number;
75
+ seed_type: string;
76
+ skipped?: string;
77
+ };
60
78
  }
61
79
 
62
80
  export interface CustomerMatchResult {
@@ -330,11 +348,50 @@ export async function runIntelligenceAgent(
330
348
  }
331
349
  }
332
350
 
333
- // 8. Customer Match sync semanal
351
+ // 8. Customer Match sync semanal (high_intent → Meta Audience)
334
352
  const cmResult = await syncMetaCustomAudience(env);
335
353
  console.log(`[Intelligence Agent] Customer Match Meta: sent=${cmResult?.sent ?? 0}, received=${cmResult?.received ?? 0}`);
336
354
 
337
- console.log(`[Intelligence Agent] ${runType} concluídoLTV model, A/B auto-decide, match quality, customer match`);
355
+ // 9. ROAS Feedback Loopcruza leads com compras reais por campanha
356
+ let roasResult: IntelligenceAgentResult['roasResult'] = undefined;
357
+ try {
358
+ const report = await computeRoasFeedback(env, 30);
359
+ if (report) {
360
+ roasResult = {
361
+ campaigns: report.campaigns.length,
362
+ total_revenue: report.total_revenue,
363
+ best_campaign: report.best_campaign,
364
+ };
365
+ await sendRoasAlert(env, report);
366
+ console.log(`[Intelligence Agent] ROAS: ${report.campaigns.length} campanhas, R$${report.total_revenue} receita`);
367
+ } else {
368
+ roasResult = { campaigns: 0, total_revenue: 0, best_campaign: null, skipped: 'sem dados suficientes' };
369
+ }
370
+ } catch (err: any) {
371
+ console.error('[Intelligence Agent] ROAS error:', err?.message || String(err));
372
+ }
373
+
374
+ // 10. Nurture Queue — processa mensagens agendadas (D+1, D+3, D+7)
375
+ let nurtureResult: IntelligenceAgentResult['nurtureResult'] = undefined;
376
+ try {
377
+ const nr = await runNurtureQueue(env);
378
+ nurtureResult = { processed: nr.processed, sent: nr.sent, failed: nr.failed };
379
+ console.log(`[Intelligence Agent] Nurture: ${nr.sent}/${nr.processed} mensagens enviadas`);
380
+ } catch (err: any) {
381
+ console.error('[Intelligence Agent] Nurture error:', err?.message || String(err));
382
+ }
383
+
384
+ // 11. Lookalike Dinâmico — compradores confirmados → Meta Audience seed
385
+ let lookalikeResult: IntelligenceAgentResult['lookalikeResult'] = undefined;
386
+ try {
387
+ const lr = await syncMetaLookalikeSeed(env);
388
+ lookalikeResult = lr;
389
+ console.log(`[Intelligence Agent] Lookalike seed: sent=${lr.sent}, type=${lr.seed_type}`);
390
+ } catch (err: any) {
391
+ console.error('[Intelligence Agent] Lookalike error:', err?.message || String(err));
392
+ }
393
+
394
+ console.log(`[Intelligence Agent] ${runType} concluído — LTV, A/B, match quality, customer match, ROAS, nurture, lookalike`);
338
395
 
339
396
  return {
340
397
  versionResults,
@@ -343,6 +400,9 @@ export async function runIntelligenceAgent(
343
400
  abResult,
344
401
  mqAnalysis,
345
402
  cmResult,
403
+ roasResult,
404
+ nurtureResult,
405
+ lookalikeResult,
346
406
  };
347
407
  }
348
408
 
@@ -400,6 +460,99 @@ export async function syncMetaCustomAudience(env: Env): Promise<CustomerMatchRes
400
460
  }
401
461
  }
402
462
 
463
+ // ── syncMetaLookalikeSeed — compradores confirmados → Meta Audience (Fase 7) ──
464
+ // Seed de Lookalike mais preciso: usa quem REALMENTE comprou (Purchase event)
465
+ // em vez de quem só teve intenção (cohort_label = high_intent).
466
+ // Separado do syncMetaCustomAudience para não misturar seeds de qualidade diferente.
467
+
468
+ export async function syncMetaLookalikeSeed(env: Env): Promise<{
469
+ sent: number;
470
+ seed_type: string;
471
+ skipped?: string;
472
+ }> {
473
+ if (!env.META_ACCESS_TOKEN || !env.META_AUDIENCE_ID) {
474
+ return { sent: 0, seed_type: 'buyer_confirmed', skipped: 'META secrets não configurados' };
475
+ }
476
+ if (!env.DB) return { sent: 0, seed_type: 'buyer_confirmed', skipped: 'DB não disponível' };
477
+
478
+ try {
479
+ // Busca perfis de compradores confirmados (Purchase event nos últimos 60 dias)
480
+ const confirmed = await env.DB.prepare(`
481
+ SELECT DISTINCT up.email, up.phone, up.first_name, up.last_name
482
+ FROM user_profiles up
483
+ JOIN leads l ON l.user_id = up.user_id
484
+ WHERE l.event_name IN ('Purchase','purchase')
485
+ AND l.created_at >= datetime('now', '-60 days')
486
+ AND up.email IS NOT NULL
487
+ UNION
488
+ SELECT DISTINCT up.email, up.phone, up.first_name, up.last_name
489
+ FROM user_profiles up
490
+ JOIN quiz_sessions qs ON qs.user_id = up.user_id
491
+ WHERE qs.qualification = 'comprador'
492
+ AND qs.created_at >= datetime('now', '-30 days')
493
+ AND up.email IS NOT NULL
494
+ LIMIT 10000
495
+ `).all();
496
+
497
+ if (!confirmed.results?.length) {
498
+ return { sent: 0, seed_type: 'buyer_confirmed', skipped: 'nenhum comprador confirmado no período' };
499
+ }
500
+
501
+ const data = await Promise.all(
502
+ confirmed.results.map(async (p: any) => [
503
+ p.email ? await sha256(p.email) : '',
504
+ p.phone ? await sha256(p.phone) : '',
505
+ ])
506
+ );
507
+
508
+ const body = { payload: { schema: ['EMAIL_SHA256', 'PHONE_SHA256'], data } };
509
+ const endpoint = `https://graph.facebook.com/v22.0/${env.META_AUDIENCE_ID}/users`;
510
+
511
+ const res = await fetch(endpoint, {
512
+ method: 'POST',
513
+ headers: { 'Content-Type': 'application/json' },
514
+ body: JSON.stringify({ ...body, access_token: env.META_ACCESS_TOKEN }),
515
+ });
516
+
517
+ const result = await res.json() as any;
518
+
519
+ // Persiste histórico do seed
520
+ if (env.DB) {
521
+ await env.DB.prepare(`
522
+ INSERT INTO lookalike_seeds (audience_id, seed_type, profiles_sent, profiles_received, period_days)
523
+ VALUES (?, 'buyer_confirmed', ?, ?, 60)
524
+ `).bind(
525
+ env.META_AUDIENCE_ID,
526
+ confirmed.results.length,
527
+ result.num_received ?? null,
528
+ ).run().catch(() => {});
529
+ }
530
+
531
+ if (!res.ok) {
532
+ console.error('[Lookalike] Meta erro:', result.error?.message);
533
+ return { sent: 0, seed_type: 'buyer_confirmed', skipped: result.error?.message };
534
+ }
535
+
536
+ // Atualiza cohort_label dos compradores para buyer_confirmed
537
+ await env.DB.prepare(`
538
+ UPDATE user_profiles
539
+ SET cohort_label = 'buyer_confirmed', updated_at = datetime('now')
540
+ WHERE user_id IN (
541
+ SELECT DISTINCT user_id FROM leads
542
+ WHERE event_name IN ('Purchase','purchase')
543
+ AND created_at >= datetime('now', '-60 days')
544
+ )
545
+ `).run().catch(() => {});
546
+
547
+ console.log(`[Lookalike] ${confirmed.results.length} compradores confirmados enviados ao Meta`);
548
+ return { sent: confirmed.results.length, seed_type: 'buyer_confirmed' };
549
+
550
+ } catch (err: any) {
551
+ console.error('[Lookalike] syncMetaLookalikeSeed error:', err?.message || String(err));
552
+ return { sent: 0, seed_type: 'buyer_confirmed', skipped: err?.message };
553
+ }
554
+ }
555
+
403
556
  // ── buildGoogleCustomerMatchExport — gera JSON para Google Ads Customer Match ─
404
557
  export async function buildGoogleCustomerMatchExport(env: Env): Promise<GoogleCustomerMatchExport[]> {
405
558
  if (!env.DB) return [];