cdp-edge 2.3.8 → 2.5.1
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 +304 -639
- package/bin/cdp-edge.js +3 -2
- package/dist/commands/validate.js +248 -84
- package/dist/sdk/cdpTrack.js +2095 -0
- package/dist/sdk/cdpTrack.min.js +64 -0
- package/dist/sdk/install-snippet.html +10 -0
- package/extracted-skill/tracking-events-generator/agents/devops-agent.md +22 -0
- package/extracted-skill/tracking-events-generator/agents/intelligence-agent.md +53 -0
- package/extracted-skill/tracking-events-generator/agents/lead-scoring-agent.md +282 -0
- package/extracted-skill/tracking-events-generator/agents/master-orchestrator.md +60 -6
- package/extracted-skill/tracking-events-generator/agents/match-quality-agent.md +304 -0
- package/extracted-skill/tracking-events-generator/agents/utm-agent.md +285 -154
- package/extracted-skill/tracking-events-generator/anti-blocking.js +1 -1
- package/extracted-skill/tracking-events-generator/cdpTrack.js +10 -18
- package/extracted-skill/tracking-events-generator/engagement-scoring.js +2 -2
- package/extracted-skill/tracking-events-generator/micro-events.js +1 -1
- package/extracted-skill/tracking-events-generator/models/quiz-funnel.md +83 -19
- package/extracted-skill/tracking-events-generator/tracking.config.js +3 -3
- package/package.json +5 -1
- package/scripts/build-sdk.js +106 -0
- package/server-edge-tracker/index.ts +174 -6
- package/server-edge-tracker/modules/intelligence.ts +155 -2
- package/server-edge-tracker/modules/ml/quiz.ts +343 -0
- package/server-edge-tracker/modules/ml/roas.ts +255 -0
- package/server-edge-tracker/modules/nurture.ts +257 -0
- package/server-edge-tracker/modules/utils.ts +2 -0
- package/server-edge-tracker/schema-quiz.sql +52 -0
- package/server-edge-tracker/schema-sales-engine.sql +113 -0
- package/templates/quiz-funnel.md +83 -19
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Edge — ROAS Feedback Loop (Fase 7)
|
|
3
|
+
*
|
|
4
|
+
* Cruza dados de leads (UTM) com compras confirmadas (Purchase events) no D1
|
|
5
|
+
* para calcular qualidade real de cada campanha:
|
|
6
|
+
* → revenue_per_lead, conversion_rate, ltv_accuracy
|
|
7
|
+
* → alimenta bidding.ts com dados reais de ROAS
|
|
8
|
+
* → relatório semanal via CallMeBot
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Env } from '../../types.js';
|
|
12
|
+
import { sendIntelligenceAlert } from '../intelligence.js';
|
|
13
|
+
|
|
14
|
+
// ── Tipos ─────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface CampaignRoas {
|
|
17
|
+
utm_source: string;
|
|
18
|
+
utm_campaign: string;
|
|
19
|
+
utm_content: string; // origem do lead: quiz_*, video_*, landing_*, ctwa_*
|
|
20
|
+
total_leads: number;
|
|
21
|
+
confirmed_buyers: number;
|
|
22
|
+
conversion_rate: number; // 0.0–1.0
|
|
23
|
+
total_revenue: number; // soma de value dos Purchase events
|
|
24
|
+
revenue_per_lead: number; // total_revenue / total_leads
|
|
25
|
+
avg_ltv_score: number; // média do ltvScore predito (valida accuracy do modelo)
|
|
26
|
+
ltv_accuracy: number; // % de leads com ltvClass=High que realmente compraram
|
|
27
|
+
top_qualification: string; // qualificação quiz mais frequente nessa campanha
|
|
28
|
+
bid_recommendation: 'increase' | 'maintain' | 'decrease' | 'pause';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RoasReport {
|
|
32
|
+
generated_at: string;
|
|
33
|
+
period_days: number;
|
|
34
|
+
campaigns: CampaignRoas[];
|
|
35
|
+
total_revenue: number;
|
|
36
|
+
total_leads: number;
|
|
37
|
+
best_campaign: string | null;
|
|
38
|
+
worst_campaign: string | null;
|
|
39
|
+
model_accuracy: number; // % geral do modelo LTV
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── computeRoasFeedback — calcula ROAS por campanha ───────────────────────────
|
|
43
|
+
|
|
44
|
+
export async function computeRoasFeedback(
|
|
45
|
+
env: Env,
|
|
46
|
+
periodDays = 30,
|
|
47
|
+
): Promise<RoasReport | null> {
|
|
48
|
+
if (!env.DB) return null;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
// Leads com UTM no período + seu LTV predito
|
|
52
|
+
const leadsRows = await env.DB.prepare(`
|
|
53
|
+
SELECT
|
|
54
|
+
COALESCE(l.utm_source, 'direct') AS utm_source,
|
|
55
|
+
COALESCE(l.utm_campaign, 'unknown') AS utm_campaign,
|
|
56
|
+
COALESCE(l.utm_content, 'unknown') AS utm_content,
|
|
57
|
+
l.user_id,
|
|
58
|
+
l.value AS predicted_value,
|
|
59
|
+
l.intention_level,
|
|
60
|
+
up.predicted_ltv_class,
|
|
61
|
+
qs.qualification AS quiz_qualification
|
|
62
|
+
FROM leads l
|
|
63
|
+
LEFT JOIN user_profiles up ON up.user_id = l.user_id
|
|
64
|
+
LEFT JOIN quiz_sessions qs ON qs.user_id = l.user_id
|
|
65
|
+
WHERE l.created_at >= datetime('now', '-' || ? || ' days')
|
|
66
|
+
AND l.event_name IN ('Lead','Contact','QuizComplete','CompleteRegistration')
|
|
67
|
+
ORDER BY l.created_at DESC
|
|
68
|
+
`).bind(periodDays).all();
|
|
69
|
+
|
|
70
|
+
// Compras confirmadas no mesmo período — JOIN por user_id
|
|
71
|
+
const purchaseRows = await env.DB.prepare(`
|
|
72
|
+
SELECT
|
|
73
|
+
l.utm_source,
|
|
74
|
+
l.utm_campaign,
|
|
75
|
+
l.utm_content,
|
|
76
|
+
l.user_id,
|
|
77
|
+
e.value AS purchase_value
|
|
78
|
+
FROM events e
|
|
79
|
+
JOIN leads l ON l.user_id = e.user_id
|
|
80
|
+
WHERE e.event_name IN ('Purchase','purchase')
|
|
81
|
+
AND e.created_at >= datetime('now', '-' || ? || ' days')
|
|
82
|
+
AND l.event_name IN ('Lead','Contact','QuizComplete','CompleteRegistration')
|
|
83
|
+
`).bind(periodDays).all();
|
|
84
|
+
|
|
85
|
+
const leads = (leadsRows.results || []) as any[];
|
|
86
|
+
const purchases = (purchaseRows.results || []) as any[];
|
|
87
|
+
|
|
88
|
+
if (leads.length === 0) return null;
|
|
89
|
+
|
|
90
|
+
// Indexa compras por user_id para lookup O(1)
|
|
91
|
+
const buyerMap = new Map<string, number>(); // user_id → purchase_value
|
|
92
|
+
for (const p of purchases) {
|
|
93
|
+
const existing = buyerMap.get(p.user_id) || 0;
|
|
94
|
+
buyerMap.set(p.user_id, existing + (parseFloat(String(p.purchase_value || 0))));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Agrupa por campanha
|
|
98
|
+
const campaignMap = new Map<string, {
|
|
99
|
+
leads: any[];
|
|
100
|
+
buyers: Set<string>;
|
|
101
|
+
revenue: number;
|
|
102
|
+
highLtvLeads: number;
|
|
103
|
+
highLtvBuyers: number;
|
|
104
|
+
qualifications: Record<string, number>;
|
|
105
|
+
}>();
|
|
106
|
+
|
|
107
|
+
for (const lead of leads) {
|
|
108
|
+
const key = `${lead.utm_source}|||${lead.utm_campaign}|||${lead.utm_content || 'unknown'}`;
|
|
109
|
+
if (!campaignMap.has(key)) {
|
|
110
|
+
campaignMap.set(key, {
|
|
111
|
+
leads: [], buyers: new Set(), revenue: 0,
|
|
112
|
+
highLtvLeads: 0, highLtvBuyers: 0,
|
|
113
|
+
qualifications: {},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const c = campaignMap.get(key)!;
|
|
117
|
+
c.leads.push(lead);
|
|
118
|
+
|
|
119
|
+
if (lead.predicted_ltv_class === 'High') c.highLtvLeads++;
|
|
120
|
+
|
|
121
|
+
const purchaseValue = buyerMap.get(lead.user_id);
|
|
122
|
+
if (purchaseValue !== undefined) {
|
|
123
|
+
c.buyers.add(lead.user_id);
|
|
124
|
+
c.revenue += purchaseValue;
|
|
125
|
+
if (lead.predicted_ltv_class === 'High') c.highLtvBuyers++;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const qual = lead.quiz_qualification || lead.intention_level || 'unknown';
|
|
129
|
+
c.qualifications[qual] = (c.qualifications[qual] || 0) + 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Monta relatório por campanha
|
|
133
|
+
const campaigns: CampaignRoas[] = [];
|
|
134
|
+
for (const [key, c] of campaignMap.entries()) {
|
|
135
|
+
const [utm_source, utm_campaign, utm_content] = key.split('|||');
|
|
136
|
+
const total_leads = c.leads.length;
|
|
137
|
+
const confirmed_buyers = c.buyers.size;
|
|
138
|
+
const conversion_rate = total_leads > 0 ? confirmed_buyers / total_leads : 0;
|
|
139
|
+
const revenue_per_lead = total_leads > 0 ? c.revenue / total_leads : 0;
|
|
140
|
+
const avg_ltv_score = c.leads.reduce((s: number, l: any) => s + parseFloat(String(l.predicted_value || 0)), 0) / total_leads;
|
|
141
|
+
const ltv_accuracy = c.highLtvLeads > 0 ? c.highLtvBuyers / c.highLtvLeads : 0;
|
|
142
|
+
|
|
143
|
+
const top_qualification = Object.entries(c.qualifications)
|
|
144
|
+
.sort((a, b) => b[1] - a[1])[0]?.[0] || 'unknown';
|
|
145
|
+
|
|
146
|
+
// Recomendação de bid baseada em conversão + accuracy
|
|
147
|
+
let bid_recommendation: CampaignRoas['bid_recommendation'];
|
|
148
|
+
if (conversion_rate >= 0.15 && ltv_accuracy >= 0.6) bid_recommendation = 'increase';
|
|
149
|
+
else if (conversion_rate >= 0.05 && ltv_accuracy >= 0.3) bid_recommendation = 'maintain';
|
|
150
|
+
else if (conversion_rate > 0 && confirmed_buyers > 0) bid_recommendation = 'decrease';
|
|
151
|
+
else bid_recommendation = 'pause';
|
|
152
|
+
|
|
153
|
+
campaigns.push({
|
|
154
|
+
utm_source, utm_campaign, utm_content,
|
|
155
|
+
total_leads, confirmed_buyers,
|
|
156
|
+
conversion_rate: Math.round(conversion_rate * 10000) / 10000,
|
|
157
|
+
total_revenue: Math.round(c.revenue * 100) / 100,
|
|
158
|
+
revenue_per_lead: Math.round(revenue_per_lead * 100) / 100,
|
|
159
|
+
avg_ltv_score: Math.round(avg_ltv_score * 100) / 100,
|
|
160
|
+
ltv_accuracy: Math.round(ltv_accuracy * 10000) / 10000,
|
|
161
|
+
top_qualification,
|
|
162
|
+
bid_recommendation,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Ordena por revenue total desc
|
|
167
|
+
campaigns.sort((a, b) => b.total_revenue - a.total_revenue);
|
|
168
|
+
|
|
169
|
+
const total_revenue = campaigns.reduce((s, c) => s + c.total_revenue, 0);
|
|
170
|
+
const total_leads = campaigns.reduce((s, c) => s + c.total_leads, 0);
|
|
171
|
+
// model_accuracy: média ponderada de ltv_accuracy por campanha (High LTV que realmente compraram)
|
|
172
|
+
const model_accuracy = campaigns.length > 0
|
|
173
|
+
? campaigns.reduce((s, c) => s + c.ltv_accuracy * c.total_leads, 0) / Math.max(total_leads, 1)
|
|
174
|
+
: 0;
|
|
175
|
+
|
|
176
|
+
const best_campaign = campaigns.find(c => c.bid_recommendation === 'increase')
|
|
177
|
+
? `${campaigns[0].utm_source} / ${campaigns[0].utm_campaign}`
|
|
178
|
+
: null;
|
|
179
|
+
const worst_campaign = campaigns.find(c => c.bid_recommendation === 'pause')
|
|
180
|
+
? (() => { const w = campaigns.find(c => c.bid_recommendation === 'pause')!; return `${w.utm_source} / ${w.utm_campaign}`; })()
|
|
181
|
+
: null;
|
|
182
|
+
|
|
183
|
+
// Persiste no D1 para histórico
|
|
184
|
+
await _persistRoasReport(env, campaigns, periodDays);
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
generated_at: new Date().toISOString(),
|
|
188
|
+
period_days: periodDays,
|
|
189
|
+
campaigns,
|
|
190
|
+
total_revenue: Math.round(total_revenue * 100) / 100,
|
|
191
|
+
total_leads,
|
|
192
|
+
best_campaign,
|
|
193
|
+
worst_campaign,
|
|
194
|
+
model_accuracy: Math.round(model_accuracy * 10000) / 10000,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
} catch (err: any) {
|
|
198
|
+
console.error('[ROAS] computeRoasFeedback error:', err?.message || String(err));
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── sendRoasAlert — relatório via CallMeBot ───────────────────────────────────
|
|
204
|
+
|
|
205
|
+
export async function sendRoasAlert(env: Env, report: RoasReport): Promise<void> {
|
|
206
|
+
const top3 = report.campaigns.slice(0, 3);
|
|
207
|
+
const lines = top3.map(c =>
|
|
208
|
+
`• ${c.utm_source}/${c.utm_campaign}/${c.utm_content}: ${c.confirmed_buyers} compradores, R$${c.total_revenue.toLocaleString('pt-BR')} (${(c.conversion_rate * 100).toFixed(1)}% conv) → ${c.bid_recommendation.toUpperCase()}`
|
|
209
|
+
).join('\n');
|
|
210
|
+
|
|
211
|
+
const pauseCount = report.campaigns.filter(c => c.bid_recommendation === 'pause').length;
|
|
212
|
+
const increaseCount = report.campaigns.filter(c => c.bid_recommendation === 'increase').length;
|
|
213
|
+
|
|
214
|
+
const details = [
|
|
215
|
+
`📅 Período: últimos ${report.period_days} dias`,
|
|
216
|
+
`💰 Receita total: R$${report.total_revenue.toLocaleString('pt-BR')}`,
|
|
217
|
+
`👥 Total leads: ${report.total_leads}`,
|
|
218
|
+
`📈 Top campanhas por receita:\n${lines}`,
|
|
219
|
+
increaseCount > 0 ? `✅ ${increaseCount} campanha(s) recomendada(s) para AUMENTAR bid` : '',
|
|
220
|
+
pauseCount > 0 ? `⛔ ${pauseCount} campanha(s) recomendada(s) para PAUSAR` : '',
|
|
221
|
+
report.best_campaign ? `🏆 Melhor: ${report.best_campaign}` : '',
|
|
222
|
+
report.worst_campaign ? `🔻 Pior: ${report.worst_campaign}` : '',
|
|
223
|
+
].filter(Boolean).join('\n');
|
|
224
|
+
|
|
225
|
+
await sendIntelligenceAlert(env, 'info', 'ROAS Feedback Semanal', details);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── _persistRoasReport — salva snapshot no D1 ────────────────────────────────
|
|
229
|
+
|
|
230
|
+
async function _persistRoasReport(
|
|
231
|
+
env: Env,
|
|
232
|
+
campaigns: CampaignRoas[],
|
|
233
|
+
periodDays: number,
|
|
234
|
+
): Promise<void> {
|
|
235
|
+
if (!env.DB) return;
|
|
236
|
+
try {
|
|
237
|
+
for (const c of campaigns) {
|
|
238
|
+
await env.DB.prepare(`
|
|
239
|
+
INSERT INTO roas_reports (
|
|
240
|
+
utm_source, utm_campaign, utm_content, period_days,
|
|
241
|
+
total_leads, confirmed_buyers, conversion_rate,
|
|
242
|
+
total_revenue, revenue_per_lead, ltv_accuracy,
|
|
243
|
+
top_qualification, bid_recommendation, created_at
|
|
244
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
|
|
245
|
+
`).bind(
|
|
246
|
+
c.utm_source, c.utm_campaign, c.utm_content, periodDays,
|
|
247
|
+
c.total_leads, c.confirmed_buyers, c.conversion_rate,
|
|
248
|
+
c.total_revenue, c.revenue_per_lead, c.ltv_accuracy,
|
|
249
|
+
c.top_qualification, c.bid_recommendation,
|
|
250
|
+
).run();
|
|
251
|
+
}
|
|
252
|
+
} catch (err: any) {
|
|
253
|
+
console.error('[ROAS] persist error:', err?.message || String(err));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Edge — Nurture Engine (Fase 7)
|
|
3
|
+
*
|
|
4
|
+
* Sequências de follow-up automáticas baseadas na qualificação do quiz:
|
|
5
|
+
* comprador → contato imediato (já tratado pelo hot lead)
|
|
6
|
+
* interessado → D+1, D+3, D+7 (WhatsApp ou email)
|
|
7
|
+
* curioso → D+2, D+5 (conteúdo/isca)
|
|
8
|
+
* perdido → exclusão do remarketing (cohort_label = excluded)
|
|
9
|
+
*
|
|
10
|
+
* Arquitetura:
|
|
11
|
+
* 1. scheduleNurture() — chamado no QuizComplete, insere sequência no D1
|
|
12
|
+
* 2. runNurtureQueue() — chamado pelo Intelligence Agent (cron diário)
|
|
13
|
+
* envia as mensagens com send_at <= now()
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Env, TrackPayload } from '../types.js';
|
|
17
|
+
|
|
18
|
+
// ── Tipos ─────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export type NurtureQualification = 'comprador' | 'interessado' | 'curioso' | 'perdido';
|
|
21
|
+
|
|
22
|
+
export interface NurtureStep {
|
|
23
|
+
delay_days: number;
|
|
24
|
+
channel: 'whatsapp' | 'email';
|
|
25
|
+
message: string; // suporta {{name}}, {{quiz_name}}, {{qualification}}
|
|
26
|
+
subject?: string; // apenas email
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface NurtureResult {
|
|
30
|
+
scheduled: number;
|
|
31
|
+
skipped: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface NurtureRunResult {
|
|
35
|
+
processed: number;
|
|
36
|
+
sent: number;
|
|
37
|
+
failed: number;
|
|
38
|
+
excluded: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Sequências por qualificação ───────────────────────────────────────────────
|
|
42
|
+
// Mensagens genéricas — o cliente personaliza via automation_rules no D1.
|
|
43
|
+
// O Nurture Engine usa estas como fallback quando não há regra cadastrada.
|
|
44
|
+
|
|
45
|
+
const NURTURE_SEQUENCES: Record<NurtureQualification, NurtureStep[]> = {
|
|
46
|
+
comprador: [
|
|
47
|
+
// comprador já dispara hot lead imediato no /track — sem sequência adicional aqui
|
|
48
|
+
],
|
|
49
|
+
interessado: [
|
|
50
|
+
{
|
|
51
|
+
delay_days: 1,
|
|
52
|
+
channel: 'whatsapp',
|
|
53
|
+
message: 'Olá {{name}}! Vi que você completou nosso diagnóstico e ficou entre os mais qualificados. Posso te enviar mais detalhes sobre como podemos ajudar?',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
delay_days: 3,
|
|
57
|
+
channel: 'whatsapp',
|
|
58
|
+
message: 'Oi {{name}}, tudo bem? Separei um conteúdo exclusivo baseado nas suas respostas no quiz "{{quiz_name}}". Posso compartilhar?',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
delay_days: 7,
|
|
62
|
+
channel: 'whatsapp',
|
|
63
|
+
message: '{{name}}, última oportunidade esta semana! Muitos que fizeram o mesmo diagnóstico que você já estão obtendo resultados. Que tal conversarmos 15 minutos?',
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
curioso: [
|
|
67
|
+
{
|
|
68
|
+
delay_days: 2,
|
|
69
|
+
channel: 'whatsapp',
|
|
70
|
+
message: 'Olá {{name}}! Você completou nosso diagnóstico. Preparei um material gratuito baseado no seu perfil. Posso enviar?',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
delay_days: 5,
|
|
74
|
+
channel: 'whatsapp',
|
|
75
|
+
message: 'Oi {{name}}! Vi que você está pesquisando sobre o assunto. Tenho uma aula gratuita que pode te ajudar muito. Interesse?',
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
perdido: [
|
|
79
|
+
// perdido não recebe mensagens — apenas é excluído do remarketing
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// ── scheduleNurture — agenda sequência após QuizComplete ─────────────────────
|
|
84
|
+
|
|
85
|
+
export async function scheduleNurture(
|
|
86
|
+
env: Env,
|
|
87
|
+
payload: TrackPayload,
|
|
88
|
+
qualification: NurtureQualification,
|
|
89
|
+
): Promise<NurtureResult> {
|
|
90
|
+
if (!env.DB) return { scheduled: 0, skipped: 'DB não disponível' };
|
|
91
|
+
|
|
92
|
+
// perdido → só atualiza cohort_label para excluir do remarketing
|
|
93
|
+
if (qualification === 'perdido') {
|
|
94
|
+
if (payload.userId) {
|
|
95
|
+
try {
|
|
96
|
+
await env.DB.prepare(`
|
|
97
|
+
UPDATE user_profiles
|
|
98
|
+
SET cohort_label = 'excluded', updated_at = datetime('now')
|
|
99
|
+
WHERE user_id = ?
|
|
100
|
+
`).bind(payload.userId).run();
|
|
101
|
+
} catch { /* não-crítico */ }
|
|
102
|
+
}
|
|
103
|
+
return { scheduled: 0, skipped: 'perdido — excluído do remarketing' };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// comprador → contato imediato já gerenciado pelo hot lead em index.ts
|
|
107
|
+
if (qualification === 'comprador') {
|
|
108
|
+
return { scheduled: 0, skipped: 'comprador — contato imediato via hot lead' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const steps = NURTURE_SEQUENCES[qualification];
|
|
112
|
+
if (!steps || steps.length === 0) {
|
|
113
|
+
return { scheduled: 0, skipped: `sem sequência para ${qualification}` };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Verifica se já tem sequência ativa para este usuário (evita duplicar)
|
|
117
|
+
if (payload.userId) {
|
|
118
|
+
try {
|
|
119
|
+
const existing = await env.DB.prepare(`
|
|
120
|
+
SELECT id FROM nurture_sequences
|
|
121
|
+
WHERE user_id = ? AND status = 'pending'
|
|
122
|
+
LIMIT 1
|
|
123
|
+
`).bind(payload.userId).first();
|
|
124
|
+
if (existing) return { scheduled: 0, skipped: 'sequência já existe para este usuário' };
|
|
125
|
+
} catch { /* continua */ }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let scheduled = 0;
|
|
129
|
+
for (const step of steps) {
|
|
130
|
+
try {
|
|
131
|
+
const sendAt = new Date();
|
|
132
|
+
sendAt.setDate(sendAt.getDate() + step.delay_days);
|
|
133
|
+
|
|
134
|
+
await env.DB.prepare(`
|
|
135
|
+
INSERT INTO nurture_sequences (
|
|
136
|
+
user_id, qualification, delay_days, channel,
|
|
137
|
+
message, subject, send_at, status,
|
|
138
|
+
quiz_name, phone, email, first_name, created_at
|
|
139
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
|
|
140
|
+
`).bind(
|
|
141
|
+
payload.userId || null,
|
|
142
|
+
qualification,
|
|
143
|
+
step.delay_days,
|
|
144
|
+
step.channel,
|
|
145
|
+
step.message,
|
|
146
|
+
step.subject || null,
|
|
147
|
+
sendAt.toISOString().slice(0, 19).replace('T', ' '),
|
|
148
|
+
'pending',
|
|
149
|
+
String((payload as any).quiz_name || ''),
|
|
150
|
+
payload.phone || null,
|
|
151
|
+
payload.email || null,
|
|
152
|
+
payload.firstName || null,
|
|
153
|
+
).run();
|
|
154
|
+
|
|
155
|
+
scheduled++;
|
|
156
|
+
} catch (err: any) {
|
|
157
|
+
console.error('[Nurture] scheduleNurture insert error:', err?.message || String(err));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { scheduled, skipped: null };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── runNurtureQueue — processa mensagens pendentes (chamado pelo cron) ────────
|
|
165
|
+
|
|
166
|
+
export async function runNurtureQueue(env: Env): Promise<NurtureRunResult> {
|
|
167
|
+
if (!env.DB) return { processed: 0, sent: 0, failed: 0, excluded: 0 };
|
|
168
|
+
|
|
169
|
+
const result: NurtureRunResult = { processed: 0, sent: 0, failed: 0, excluded: 0 };
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
// Busca até 50 mensagens pendentes com send_at <= agora
|
|
173
|
+
const pending = await env.DB.prepare(`
|
|
174
|
+
SELECT * FROM nurture_sequences
|
|
175
|
+
WHERE status = 'pending'
|
|
176
|
+
AND send_at <= datetime('now')
|
|
177
|
+
ORDER BY send_at ASC
|
|
178
|
+
LIMIT 50
|
|
179
|
+
`).all();
|
|
180
|
+
|
|
181
|
+
const rows = (pending.results || []) as any[];
|
|
182
|
+
if (rows.length === 0) return result;
|
|
183
|
+
|
|
184
|
+
for (const row of rows) {
|
|
185
|
+
result.processed++;
|
|
186
|
+
|
|
187
|
+
// Interpola variáveis na mensagem
|
|
188
|
+
const vars: Record<string, string> = {
|
|
189
|
+
name: String(row.first_name || 'você'),
|
|
190
|
+
quiz_name: String(row.quiz_name || 'diagnóstico'),
|
|
191
|
+
qualification: String(row.qualification || ''),
|
|
192
|
+
};
|
|
193
|
+
const message = String(row.message || '').replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? '');
|
|
194
|
+
const subject = row.subject ? String(row.subject).replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? '') : null;
|
|
195
|
+
|
|
196
|
+
let success = false;
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
if (row.channel === 'whatsapp' && row.phone && env.WHATSAPP_ACCESS_TOKEN && env.WHATSAPP_PHONE_NUMBER_ID) {
|
|
200
|
+
const digits = String(row.phone).replace(/\D/g, '');
|
|
201
|
+
const e164 = digits.startsWith('55') ? `+${digits}` : `+55${digits}`;
|
|
202
|
+
|
|
203
|
+
const res = await fetch(
|
|
204
|
+
`https://graph.facebook.com/v22.0/${env.WHATSAPP_PHONE_NUMBER_ID}/messages`,
|
|
205
|
+
{
|
|
206
|
+
method: 'POST',
|
|
207
|
+
headers: { 'Authorization': `Bearer ${env.WHATSAPP_ACCESS_TOKEN}`, 'Content-Type': 'application/json' },
|
|
208
|
+
body: JSON.stringify({
|
|
209
|
+
messaging_product: 'whatsapp',
|
|
210
|
+
recipient_type: 'individual',
|
|
211
|
+
to: e164,
|
|
212
|
+
type: 'text',
|
|
213
|
+
text: { body: message },
|
|
214
|
+
}),
|
|
215
|
+
}
|
|
216
|
+
);
|
|
217
|
+
success = res.ok;
|
|
218
|
+
|
|
219
|
+
} else if (row.channel === 'email' && row.email && env.RESEND_API_KEY) {
|
|
220
|
+
const res = await fetch('https://api.resend.com/emails', {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
headers: { 'Authorization': `Bearer ${env.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
|
|
223
|
+
body: JSON.stringify({
|
|
224
|
+
from: env.RESEND_FROM_EMAIL || 'noreply@cdp-edge.app',
|
|
225
|
+
to: [row.email],
|
|
226
|
+
subject: subject || `Olá, ${vars.name}!`,
|
|
227
|
+
html: `<p>${message.replace(/\n/g, '<br>')}</p>`,
|
|
228
|
+
}),
|
|
229
|
+
});
|
|
230
|
+
success = res.ok;
|
|
231
|
+
}
|
|
232
|
+
} catch (err: any) {
|
|
233
|
+
console.error(`[Nurture] dispatch error (row ${row.id}):`, err?.message || String(err));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Atualiza status no D1
|
|
237
|
+
const newStatus = success ? 'sent' : 'failed';
|
|
238
|
+
const sentAt = success ? `datetime('now')` : 'NULL';
|
|
239
|
+
try {
|
|
240
|
+
await env.DB.prepare(`
|
|
241
|
+
UPDATE nurture_sequences
|
|
242
|
+
SET status = ?, sent_at = ${success ? "datetime('now')" : 'NULL'},
|
|
243
|
+
updated_at = datetime('now')
|
|
244
|
+
WHERE id = ?
|
|
245
|
+
`).bind(newStatus, row.id).run();
|
|
246
|
+
} catch { /* não-crítico */ }
|
|
247
|
+
|
|
248
|
+
if (success) result.sent++;
|
|
249
|
+
else result.failed++;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
} catch (err: any) {
|
|
253
|
+
console.error('[Nurture] runNurtureQueue error:', err?.message || String(err));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
@@ -106,6 +106,8 @@ export const VALID_EVENT_NAMES = new Set([
|
|
|
106
106
|
'video_start','video_25','video_50','video_75','video_complete',
|
|
107
107
|
// Imóveis — intenção de visita física, financiamento e favoritar
|
|
108
108
|
'FindLocation','CustomizeProduct','AddToWishlist',
|
|
109
|
+
// Quiz Funnel (Fase 6)
|
|
110
|
+
'QuizStart','QuizAnswer','QuizComplete',
|
|
109
111
|
]);
|
|
110
112
|
|
|
111
113
|
// ── Taxonomia de funil (funnel_stage → profundidade semântica) ────────────────
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
-- CDP Edge — Schema Quiz Sessions (Fase 6 v2)
|
|
2
|
+
-- Análise Dimensional Automática por Workers AI
|
|
3
|
+
-- Executar: wrangler d1 execute cdp-edge-db --file=schema-quiz.sql --remote
|
|
4
|
+
|
|
5
|
+
CREATE TABLE IF NOT EXISTS quiz_sessions (
|
|
6
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
7
|
+
user_id TEXT, -- _cdp_uid (FK lógica → user_profiles)
|
|
8
|
+
quiz_name TEXT, -- nome do quiz (ex: "Diagnóstico Imóvel")
|
|
9
|
+
answers_json TEXT NOT NULL, -- JSON: [{question, answer, step}]
|
|
10
|
+
qualification TEXT NOT NULL, -- comprador | interessado | curioso | perdido
|
|
11
|
+
intent_score REAL NOT NULL, -- 0.0–1.0 (propagado ao pipeline CDP)
|
|
12
|
+
weighted_score REAL NOT NULL, -- score ponderado bruto Σ(score×weight)/Σ(weight)
|
|
13
|
+
confidence REAL NOT NULL DEFAULT 0.7,
|
|
14
|
+
reason TEXT, -- frase explicativa em português (audit)
|
|
15
|
+
dominant_dimension TEXT, -- dimensão com maior impacto: budget|urgency|etc.
|
|
16
|
+
dimensions_json TEXT, -- JSON: [{step, dimension, score, weight, signal}]
|
|
17
|
+
source TEXT DEFAULT 'ai', -- ai | heuristic
|
|
18
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
CREATE INDEX IF NOT EXISTS idx_quiz_user_id ON quiz_sessions(user_id);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS idx_quiz_qualification ON quiz_sessions(qualification);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_quiz_created_at ON quiz_sessions(created_at);
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_quiz_dominant_dim ON quiz_sessions(dominant_dimension);
|
|
25
|
+
|
|
26
|
+
-- VIEW: distribuição de qualificações por quiz + score médio
|
|
27
|
+
CREATE VIEW IF NOT EXISTS v_quiz_qualification_summary AS
|
|
28
|
+
SELECT
|
|
29
|
+
quiz_name,
|
|
30
|
+
qualification,
|
|
31
|
+
COUNT(*) AS total,
|
|
32
|
+
ROUND(AVG(intent_score) * 100, 1) AS avg_intent_pct,
|
|
33
|
+
ROUND(AVG(weighted_score) * 100, 1) AS avg_weighted_pct,
|
|
34
|
+
ROUND(AVG(confidence) * 100, 1) AS avg_confidence_pct,
|
|
35
|
+
COUNT(CASE WHEN source = 'ai' THEN 1 END) AS ai_scored,
|
|
36
|
+
COUNT(CASE WHEN source = 'heuristic' THEN 1 END) AS heuristic_scored
|
|
37
|
+
FROM quiz_sessions
|
|
38
|
+
GROUP BY quiz_name, qualification
|
|
39
|
+
ORDER BY quiz_name, avg_intent_pct DESC;
|
|
40
|
+
|
|
41
|
+
-- VIEW: qual dimensão mais penaliza/beneficia cada quiz
|
|
42
|
+
CREATE VIEW IF NOT EXISTS v_quiz_dimension_impact AS
|
|
43
|
+
SELECT
|
|
44
|
+
quiz_name,
|
|
45
|
+
dominant_dimension,
|
|
46
|
+
COUNT(*) AS total_sessions,
|
|
47
|
+
ROUND(AVG(weighted_score) * 100, 1) AS avg_weighted_pct,
|
|
48
|
+
qualification
|
|
49
|
+
FROM quiz_sessions
|
|
50
|
+
WHERE dominant_dimension IS NOT NULL
|
|
51
|
+
GROUP BY quiz_name, dominant_dimension, qualification
|
|
52
|
+
ORDER BY quiz_name, total_sessions DESC;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
-- CDP Edge — Schema Sales Engine (Fase 7)
|
|
2
|
+
-- ROAS Feedback Loop + Nurture Engine + Lookalike Dinâmico
|
|
3
|
+
-- Executar: wrangler d1 execute cdp-edge-db --file=schema-sales-engine.sql --remote
|
|
4
|
+
|
|
5
|
+
-- ── ROAS Reports — histórico de performance por campanha ──────────────────────
|
|
6
|
+
CREATE TABLE IF NOT EXISTS roas_reports (
|
|
7
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
8
|
+
utm_source TEXT NOT NULL,
|
|
9
|
+
utm_campaign TEXT NOT NULL,
|
|
10
|
+
utm_content TEXT NOT NULL DEFAULT 'unknown', -- origem: quiz_*, video_*, landing_*, ctwa_*
|
|
11
|
+
period_days INTEGER NOT NULL DEFAULT 30,
|
|
12
|
+
total_leads INTEGER NOT NULL DEFAULT 0,
|
|
13
|
+
confirmed_buyers INTEGER NOT NULL DEFAULT 0,
|
|
14
|
+
conversion_rate REAL NOT NULL DEFAULT 0, -- 0.0–1.0
|
|
15
|
+
total_revenue REAL NOT NULL DEFAULT 0, -- BRL
|
|
16
|
+
revenue_per_lead REAL NOT NULL DEFAULT 0,
|
|
17
|
+
ltv_accuracy REAL NOT NULL DEFAULT 0, -- % leads High LTV que realmente compraram
|
|
18
|
+
top_qualification TEXT, -- qualificação quiz dominante
|
|
19
|
+
bid_recommendation TEXT NOT NULL DEFAULT 'maintain', -- increase|maintain|decrease|pause
|
|
20
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
-- Migração para instâncias existentes (idempotente via IF NOT EXISTS não funciona em ALTER TABLE —
|
|
24
|
+
-- execute manualmente se a tabela já existir):
|
|
25
|
+
-- ALTER TABLE roas_reports ADD COLUMN utm_content TEXT NOT NULL DEFAULT 'unknown';
|
|
26
|
+
|
|
27
|
+
CREATE INDEX IF NOT EXISTS idx_roas_source_campaign ON roas_reports(utm_source, utm_campaign);
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_roas_content ON roas_reports(utm_content);
|
|
29
|
+
CREATE INDEX IF NOT EXISTS idx_roas_created_at ON roas_reports(created_at);
|
|
30
|
+
CREATE INDEX IF NOT EXISTS idx_roas_bid_rec ON roas_reports(bid_recommendation);
|
|
31
|
+
|
|
32
|
+
-- VIEW: últimos relatórios por campanha com evolução
|
|
33
|
+
CREATE VIEW IF NOT EXISTS v_roas_latest AS
|
|
34
|
+
SELECT
|
|
35
|
+
utm_source,
|
|
36
|
+
utm_campaign,
|
|
37
|
+
conversion_rate,
|
|
38
|
+
total_revenue,
|
|
39
|
+
revenue_per_lead,
|
|
40
|
+
ltv_accuracy,
|
|
41
|
+
top_qualification,
|
|
42
|
+
bid_recommendation,
|
|
43
|
+
created_at
|
|
44
|
+
FROM roas_reports r1
|
|
45
|
+
WHERE created_at = (
|
|
46
|
+
SELECT MAX(r2.created_at)
|
|
47
|
+
FROM roas_reports r2
|
|
48
|
+
WHERE r2.utm_source = r1.utm_source
|
|
49
|
+
AND r2.utm_campaign = r1.utm_campaign
|
|
50
|
+
)
|
|
51
|
+
ORDER BY total_revenue DESC;
|
|
52
|
+
|
|
53
|
+
-- ── Nurture Sequences — filas de mensagens por qualificação ──────────────────
|
|
54
|
+
CREATE TABLE IF NOT EXISTS nurture_sequences (
|
|
55
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
56
|
+
user_id TEXT, -- _cdp_uid
|
|
57
|
+
qualification TEXT NOT NULL, -- interessado | curioso
|
|
58
|
+
delay_days INTEGER NOT NULL, -- D+N após o quiz
|
|
59
|
+
channel TEXT NOT NULL, -- whatsapp | email
|
|
60
|
+
message TEXT NOT NULL, -- mensagem interpolada
|
|
61
|
+
subject TEXT, -- assunto (email only)
|
|
62
|
+
send_at TEXT NOT NULL, -- datetime de envio agendado
|
|
63
|
+
status TEXT NOT NULL DEFAULT 'pending', -- pending | sent | failed | cancelled
|
|
64
|
+
sent_at TEXT, -- quando foi realmente enviado
|
|
65
|
+
quiz_name TEXT, -- nome do quiz (para interpolação)
|
|
66
|
+
phone TEXT, -- telefone do lead
|
|
67
|
+
email TEXT, -- email do lead
|
|
68
|
+
first_name TEXT, -- nome (para personalização)
|
|
69
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
70
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
CREATE INDEX IF NOT EXISTS idx_nurture_user_id ON nurture_sequences(user_id);
|
|
74
|
+
CREATE INDEX IF NOT EXISTS idx_nurture_status ON nurture_sequences(status);
|
|
75
|
+
CREATE INDEX IF NOT EXISTS idx_nurture_send_at ON nurture_sequences(send_at);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS idx_nurture_qual ON nurture_sequences(qualification);
|
|
77
|
+
|
|
78
|
+
-- VIEW: fila de envio pendente
|
|
79
|
+
CREATE VIEW IF NOT EXISTS v_nurture_pending AS
|
|
80
|
+
SELECT
|
|
81
|
+
id, user_id, qualification, delay_days, channel,
|
|
82
|
+
message, send_at, phone, email, first_name, quiz_name
|
|
83
|
+
FROM nurture_sequences
|
|
84
|
+
WHERE status = 'pending'
|
|
85
|
+
AND send_at <= datetime('now')
|
|
86
|
+
ORDER BY send_at ASC;
|
|
87
|
+
|
|
88
|
+
-- VIEW: taxa de envio por qualificação
|
|
89
|
+
CREATE VIEW IF NOT EXISTS v_nurture_stats AS
|
|
90
|
+
SELECT
|
|
91
|
+
qualification,
|
|
92
|
+
channel,
|
|
93
|
+
COUNT(*) AS total,
|
|
94
|
+
COUNT(CASE WHEN status = 'sent' THEN 1 END) AS sent,
|
|
95
|
+
COUNT(CASE WHEN status = 'failed' THEN 1 END) AS failed,
|
|
96
|
+
COUNT(CASE WHEN status = 'pending' THEN 1 END) AS pending,
|
|
97
|
+
ROUND(COUNT(CASE WHEN status = 'sent' THEN 1 END) * 100.0 / COUNT(*), 1) AS delivery_rate_pct
|
|
98
|
+
FROM nurture_sequences
|
|
99
|
+
GROUP BY qualification, channel;
|
|
100
|
+
|
|
101
|
+
-- ── Lookalike Seeds — histórico de audiences enviadas ao Meta ─────────────────
|
|
102
|
+
CREATE TABLE IF NOT EXISTS lookalike_seeds (
|
|
103
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
104
|
+
audience_id TEXT NOT NULL, -- META_AUDIENCE_ID
|
|
105
|
+
seed_type TEXT NOT NULL, -- 'buyer_confirmed' | 'high_intent' | 'quiz_comprador'
|
|
106
|
+
profiles_sent INTEGER NOT NULL DEFAULT 0,
|
|
107
|
+
profiles_received INTEGER, -- confirmado pela API Meta
|
|
108
|
+
period_days INTEGER NOT NULL DEFAULT 30,
|
|
109
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
CREATE INDEX IF NOT EXISTS idx_lookalike_seed_type ON lookalike_seeds(seed_type);
|
|
113
|
+
CREATE INDEX IF NOT EXISTS idx_lookalike_created_at ON lookalike_seeds(created_at);
|