Add campaign health auto-pause and variant comparison stats

This commit is contained in:
Gustavo
2026-08-03 08:04:48 -03:00
parent 9bf77d88d3
commit a2ecb135d4
2 changed files with 242 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
function dateKey(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function applyPause(db, campaignId, reason) {
db.prepare(
`UPDATE outreach_campaigns SET status = 'pausado', pause_reason = ?, updated_at = datetime('now')
WHERE id = ? AND status = 'rodando'`
).run(reason, campaignId);
}
// Mesmos 3 criterios e limiares numericos da Maquina Pessoal (10 tentativas/
// 10% erro, 20 contatos/10% negativo+opt-out, 50 contatos/5% resposta),
// adaptados as tabelas da Comercial via JOIN por outreach_campaign_queue
// (nao por um valor de origem dedicado).
export function evaluateCampaignHealth(db, campaignId, now = new Date()) {
const campaign = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId);
if (!campaign || campaign.status !== 'rodando') return { pause: false, reason: null };
const todayKey = dateKey(now);
const attemptsToday = Number(db.prepare(
`SELECT COUNT(*) AS total
FROM outreach_campaign_queue q
JOIN outreach_sends s ON s.id = q.outreach_send_id
WHERE q.campaign_id = ? AND date(s.sent_at, 'localtime') = ?`
).get(campaignId, todayKey).total);
if (attemptsToday >= 10) {
const failureCount = Number(db.prepare(
`SELECT COUNT(*) AS total
FROM outreach_campaign_queue q
JOIN outreach_sends s ON s.id = q.outreach_send_id
WHERE q.campaign_id = ? AND s.status = 'erro' AND date(s.sent_at, 'localtime') = ?`
).get(campaignId, todayKey).total);
if (failureCount / attemptsToday > 0.10) {
applyPause(db, campaignId, 'high_failure_rate');
return { pause: true, reason: 'high_failure_rate' };
}
}
const contacts = Number(db.prepare(
`SELECT COUNT(*) AS total FROM outreach_campaign_queue WHERE campaign_id = ? AND status IN ('sent','error')`
).get(campaignId).total);
if (contacts >= 20) {
const negativeCount = Number(db.prepare(
`SELECT COUNT(DISTINCT q.lead_id) AS total
FROM outreach_campaign_queue q
JOIN outreach_lead_states ls ON ls.lead_id = q.lead_id
WHERE q.campaign_id = ? AND q.status IN ('sent','error')
AND ls.stage IN ('sem_interesse', 'opt_out')`
).get(campaignId).total);
if (negativeCount / contacts > 0.10) {
applyPause(db, campaignId, 'high_negative_rate');
return { pause: true, reason: 'high_negative_rate' };
}
}
if (contacts >= 50) {
const respondedCount = Number(db.prepare(
`SELECT COUNT(DISTINCT q.lead_id) AS total
FROM outreach_campaign_queue q
JOIN outreach_stage_events se ON se.lead_id = q.lead_id AND se.campaign_id = q.campaign_id
WHERE q.campaign_id = ? AND q.status IN ('sent','error')`
).get(campaignId).total);
if (respondedCount / contacts < 0.05) {
applyPause(db, campaignId, 'low_reply_rate');
return { pause: true, reason: 'low_reply_rate' };
}
}
return { pause: false, reason: null };
}
// Numero de leads distintos com resposta por variante, via subquery
// correlacionada (nao um JOIN direto) para nao contar duplicado quando um
// lead tem mais de um outreach_stage_events.
export function variantStats(db, campaignId) {
const rows = db.prepare(
`SELECT v.id AS variant_id, v.code,
COUNT(*) AS enviados,
(SELECT COUNT(DISTINCT q2.lead_id)
FROM outreach_campaign_queue q2
JOIN outreach_stage_events se ON se.lead_id = q2.lead_id AND se.campaign_id = q2.campaign_id
WHERE q2.campaign_id = q.campaign_id AND q2.variant_id = v.id AND q2.status IN ('sent','error')
) AS respostas
FROM outreach_campaign_queue q
JOIN outreach_campaign_variants v ON v.id = q.variant_id
WHERE q.campaign_id = ? AND q.status IN ('sent','error')
GROUP BY v.id, v.code`
).all(campaignId);
return rows.map((row) => ({
code: row.code,
enviados: row.enviados,
respostas: row.respostas,
taxaResposta: row.enviados > 0 ? row.respostas / row.enviados : 0,
elegivelParaComparacao: row.enviados >= 40
}));
}
+140
View File
@@ -0,0 +1,140 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, createLeadInserter } from '../src/db.mjs';
import { createCampaign, setCampaignStatus } from '../src/outreach/campaign-store.mjs';
import { evaluateCampaignHealth, variantStats } from '../src/outreach/campaign-metrics.mjs';
function inserirLead(db, dedup) {
const inserir = createLeadInserter(db);
inserir({
dedup_key: dedup, empresa: 'E', legal_name: '', whatsapp: `55119999${dedup.padStart(5, '0')}`,
ddd: '11', email: '', cidade: '', uf: '', segmento: 'x', segmento_label: '', interesse: '',
cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]', origem: 't', telefone_valido: 1,
cnpj_valido: 0, empresa_valida: 1, score: 50, classificacao: 'bom', motivo_classificacao: '',
score_qualidade: 50, score_comercial: 50, prioridade: 1, produto_principal: 'ERP',
produtos_json: '[]', capture_run_id: null, captured_at: null, exported_at: null
});
return db.prepare('SELECT id, whatsapp FROM leads WHERE dedup_key = ?').get(dedup);
}
function campanhaRodando(db) {
const campanha = createCampaign(db, {
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }]
});
setCampaignStatus(db, campanha.id, 'rodando');
return campanha;
}
function simularEnvio(db, campanhaId, variantId, leadId, numero, status) {
const sendId = db.prepare(
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'ERP', ?, 'oi', ?)`
).run(leadId, numero, status).lastInsertRowid;
db.prepare(
`INSERT INTO outreach_campaign_queue (campaign_id, lead_id, variant_id, numero, rendered_message, status, outreach_send_id)
VALUES (?, ?, ?, ?, 'oi', ?, ?)`
).run(campanhaId, leadId, variantId, numero, status === 'erro' ? 'error' : 'sent', sendId);
}
test('evaluateCampaignHealth nao pausa campanha saudavel', () => {
const db = openDb(':memory:');
const campanha = campanhaRodando(db);
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
for (let i = 0; i < 10; i += 1) {
const lead = inserirLead(db, `l${i}`);
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
}
const resultado = evaluateCampaignHealth(db, campanha.id);
assert.equal(resultado.pause, false);
db.close();
});
test('evaluateCampaignHealth pausa por high_failure_rate quando erro > 10% com 10+ tentativas', () => {
const db = openDb(':memory:');
const campanha = campanhaRodando(db);
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
for (let i = 0; i < 8; i += 1) {
const lead = inserirLead(db, `ok${i}`);
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
}
for (let i = 0; i < 2; i += 1) {
const lead = inserirLead(db, `err${i}`);
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro');
}
const resultado = evaluateCampaignHealth(db, campanha.id);
assert.equal(resultado.pause, true);
assert.equal(resultado.reason, 'high_failure_rate');
const atualizada = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
assert.equal(atualizada.status, 'pausado');
db.close();
});
test('evaluateCampaignHealth nao pausa por falha com menos de 10 tentativas no dia', () => {
const db = openDb(':memory:');
const campanha = campanhaRodando(db);
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
const lead = inserirLead(db, 'so1');
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro');
const resultado = evaluateCampaignHealth(db, campanha.id);
assert.equal(resultado.pause, false);
db.close();
});
test('evaluateCampaignHealth pausa por high_negative_rate quando >10% dos 20+ contatados estao em sem_interesse/opt_out', () => {
const db = openDb(':memory:');
const campanha = campanhaRodando(db);
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
for (let i = 0; i < 20; i += 1) {
const lead = inserirLead(db, `neg${i}`);
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
if (i < 3) {
db.prepare(`INSERT INTO outreach_lead_states (lead_id, stage) VALUES (?, 'sem_interesse')`).run(lead.id);
}
}
const resultado = evaluateCampaignHealth(db, campanha.id);
assert.equal(resultado.pause, true);
assert.equal(resultado.reason, 'high_negative_rate');
db.close();
});
test('evaluateCampaignHealth pausa por low_reply_rate quando <5% dos 50+ contatados responderam', () => {
const db = openDb(':memory:');
const campanha = campanhaRodando(db);
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
for (let i = 0; i < 50; i += 1) {
const lead = inserirLead(db, `rep${i}`);
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
}
const resultado = evaluateCampaignHealth(db, campanha.id);
assert.equal(resultado.pause, true);
assert.equal(resultado.reason, 'low_reply_rate');
db.close();
});
test('evaluateCampaignHealth em campanha ja pausada nao faz nada', () => {
const db = openDb(':memory:');
const campanha = campanhaRodando(db);
setCampaignStatus(db, campanha.id, 'pausado', 'manual');
const resultado = evaluateCampaignHealth(db, campanha.id);
assert.equal(resultado.pause, false);
db.close();
});
test('variantStats calcula taxa de resposta e elegibilidade (limiar 40 envios)', () => {
const db = openDb(':memory:');
const campanha = campanhaRodando(db);
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
for (let i = 0; i < 5; i += 1) {
const lead = inserirLead(db, `v${i}`);
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
if (i < 1) {
db.prepare(`INSERT INTO outreach_stage_events (lead_id, campaign_id, stage) VALUES (?, ?, 'interessado')`).run(lead.id, campanha.id);
}
}
const stats = variantStats(db, campanha.id);
assert.equal(stats.length, 1);
assert.equal(stats[0].enviados, 5);
assert.equal(stats[0].respostas, 1);
assert.equal(stats[0].taxaResposta, 0.2);
assert.equal(stats[0].elegivelParaComparacao, false);
db.close();
});