Merge branch 'feature/campanhas-outreach'

# Conflicts:
#	public/css/app.css
#	scripts/servidor.mjs
#	src/db.mjs
#	src/server/index.mjs
This commit is contained in:
Gustavo
2026-08-03 13:16:37 -03:00
23 changed files with 2048 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"name": "gti-maquina-comercial",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gti-maquina-comercial",
"version": "2.0.0",
"engines": {
"node": ">=24"
}
}
}
}
+14
View File
@@ -459,6 +459,20 @@ button:disabled { opacity: 0.55; cursor: not-allowed; transform: none !important
.chips .chip { cursor: pointer; }
.chips .chip::after { content: " x"; color: var(--muted); }
.chips .chip:hover { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--surface)); }
.segmento-lista { display: flex; flex-wrap: wrap; gap: 7px; margin: 8px 0 12px; }
.segmento-opcao {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--ink-2);
border-radius: 999px;
padding: 5px 11px;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.nichos-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
+81
View File
@@ -16,6 +16,7 @@
<nav class="tabs" role="tablist">
<button class="tab active" data-tab="captura" role="tab" aria-selected="true">📡 Capturar Leads</button>
<button class="tab" data-tab="tratamento" role="tab" aria-selected="false">📊 Tratar Leads</button>
<button class="tab" data-tab="campanhas" role="tab" aria-selected="false">🎯 Campanhas</button>
</nav>
<span class="muted" id="usuario-logado"></span>
<button class="btn-ghost hidden" id="btn-usuarios" title="Usuários">👤 Usuários</button>
@@ -226,6 +227,85 @@
<div id="historico-exports" class="lista"></div>
</div>
</section>
<!-- ==================== CAMPANHAS ==================== -->
<section id="tab-campanhas" class="tab-panel">
<div class="row space-between">
<h2>Campanhas de outreach</h2>
<button class="btn-ghost btn-sm" id="btn-nova-campanha" type="button">+ Nova campanha</button>
</div>
<div id="campanhas-lista" class="lista"></div>
<div id="campanha-detalhe" class="card hidden">
<div class="row space-between">
<h2 id="detalhe-nome"></h2>
<div class="row">
<span class="chip" id="detalhe-status"></span>
<button class="btn-ghost btn-sm" id="btn-fechar-detalhe" type="button">Fechar</button>
</div>
</div>
<div class="row" id="detalhe-acoes">
<button class="btn-ghost btn-sm" id="btn-iniciar-campanha" type="button">Iniciar</button>
<button class="btn-ghost btn-sm" id="btn-pausar-campanha" type="button">Pausar</button>
<button class="btn-ghost btn-sm" id="btn-retomar-campanha" type="button">Retomar</button>
<button class="btn-ghost btn-sm" id="btn-gerar-fila" type="button">Gerar fila</button>
</div>
<p class="muted" id="detalhe-saude"></p>
<div id="detalhe-variantes" class="row"></div>
<div class="row space-between">
<h3>Fila</h3>
<select id="fila-filtro-status">
<option value="preview">Preview</option>
<option value="approved">Aprovadas</option>
<option value="sent">Enviadas</option>
<option value="error">Erro</option>
<option value="cancelled">Canceladas</option>
</select>
</div>
<div class="row">
<button class="btn-ghost btn-sm" id="btn-aprovar-selecionados" type="button">Aprovar selecionados</button>
<button class="btn-ghost btn-sm" id="btn-cancelar-selecionados" type="button">Cancelar selecionados</button>
</div>
<table>
<thead><tr><th></th><th>Lead ID</th><th>Numero</th><th>Mensagem</th><th>Status</th><th>Estagio</th></tr></thead>
<tbody id="fila-tabela"></tbody>
</table>
</div>
<dialog id="modal-nova-campanha">
<form id="form-nova-campanha" method="dialog">
<h2>Nova campanha</h2>
<div class="field">
<label for="campanha-nome">Nome</label>
<input type="text" id="campanha-nome" required>
</div>
<div class="field">
<label for="campanha-produto">Produto</label>
<select id="campanha-produto" required></select>
</div>
<div class="field">
<label>Segmentos</label>
<div id="campanha-segmentos" class="segmento-lista"></div>
</div>
<div class="field">
<label>Variantes de mensagem (A/B/C)</label>
<div id="campanha-variantes"></div>
<button class="btn-ghost btn-sm" id="btn-add-variante" type="button">+ Variante</button>
</div>
<div class="row">
<label>Limite diário inicial <input type="number" id="campanha-limite-inicial" value="10" min="1"></label>
<label>Teto do limite <input type="number" id="campanha-limite-teto" value="80" min="1"></label>
</div>
<p class="muted" id="campanha-erro"></p>
<div class="row space-between">
<button class="btn-ghost" id="btn-cancelar-campanha" type="button">Cancelar</button>
<button class="btn btn-primary" id="btn-salvar-campanha" type="button">Criar campanha</button>
</div>
</form>
</dialog>
</section>
</main>
<!-- Modal de exportação -->
@@ -480,6 +560,7 @@
<script src="/js/api.js"></script>
<script src="/js/captura.js"></script>
<script src="/js/tratamento.js"></script>
<script src="/js/campanhas.js"></script>
<script src="/js/app.js"></script>
</body>
</html>
+1
View File
@@ -64,6 +64,7 @@
panel.classList.toggle('active', panel.id === `tab-${tab.dataset.tab}`);
}
if (tab.dataset.tab === 'tratamento') Tratamento.ativar();
if (tab.dataset.tab === 'campanhas') Campanhas.ativar();
});
}
+253
View File
@@ -0,0 +1,253 @@
/* Aba Campanhas: criacao, fila de aprovacao, saude/variantes, estagio/agendamento. */
const Campanhas = (() => {
let carregado = false;
let variantesCount = 0;
const STATUS_LABELS = { rascunho: 'Rascunho', rodando: 'Rodando', pausado: 'Pausado', concluido: 'Concluido' };
const MAX_VARIANTES = 3; // A/B/C — mesmo limite usado na convencao do restante da UI/backend.
function addVarianteRow(codigoSugerido) {
if (variantesCount >= MAX_VARIANTES) return;
const codigo = codigoSugerido || String.fromCharCode(65 + variantesCount);
variantesCount += 1;
el('campanha-variantes').append(
h('div', { class: 'row', 'data-variante-code': codigo },
h('span', { class: 'chip' }, codigo),
h('textarea', { class: 'campanha-variante-mensagem', rows: 2, placeholder: 'Ola {empresa}, ...' }))
);
atualizarBotaoAddVariante();
}
function atualizarBotaoAddVariante() {
el('btn-add-variante').disabled = variantesCount >= MAX_VARIANTES;
}
async function carregarProdutos() {
const { produtos } = await API.get('/api/campaigns/produtos');
el('campanha-produto').replaceChildren(
...produtos.map((p) => h('option', { value: p.value }, `${p.value} (${fmt.format(p.total)} leads)`))
);
}
async function carregarSegmentos(produto) {
const container = el('campanha-segmentos');
if (!produto) { container.replaceChildren(); return; }
const { segmentos } = await API.get(`/api/campaigns/segmentos?produto=${encodeURIComponent(produto)}`);
container.replaceChildren(...segmentos.map((s) =>
h('label', { class: 'segmento-opcao' },
h('input', { type: 'checkbox', value: s.value, class: 'campanha-segmento-check' }),
` ${s.value} (${fmt.format(s.total)})`)
));
}
function segmentosSelecionados() {
return Array.from(document.querySelectorAll('.campanha-segmento-check:checked')).map((i) => i.value);
}
function variantesPreenchidas() {
return Array.from(el('campanha-variantes').children).map((row) => ({
code: row.dataset.varianteCode,
mensagem: row.querySelector('.campanha-variante-mensagem').value.trim()
})).filter((v) => v.mensagem);
}
async function abrirModalNovaCampanha() {
el('campanha-nome').value = '';
el('campanha-limite-inicial').value = 10;
el('campanha-limite-teto').value = 80;
el('campanha-erro').textContent = '';
el('campanha-variantes').replaceChildren();
variantesCount = 0;
addVarianteRow('A');
await carregarProdutos();
await carregarSegmentos(el('campanha-produto').value);
el('modal-nova-campanha').showModal();
}
function renderCampanhaCard(campanha) {
return h('div', { class: 'item', onclick: () => carregarDetalhe(campanha.id).catch((e) => toast(`Erro: ${e.message}`)) },
h('div', {},
h('strong', {}, campanha.nome),
h('span', { class: 'sub' }, ` ${campanha.produto} — ${campanha.segmentos.join(', ')}`)),
h('span', { class: `chip ${campanha.status === 'rodando' ? 'ok' : campanha.status === 'pausado' ? 'warn' : ''}` },
STATUS_LABELS[campanha.status] || campanha.status));
}
async function carregarLista() {
const { campanhas } = await API.get('/api/campaigns');
el('campanhas-lista').replaceChildren(
...(campanhas.length ? campanhas.map(renderCampanhaCard) : [h('p', { class: 'muted' }, 'Nenhuma campanha criada ainda.')])
);
}
function ativar() {
if (carregado) return;
carregado = true;
el('btn-nova-campanha').addEventListener('click', () => {
abrirModalNovaCampanha().catch((error) => toast(`Erro: ${error.message}`));
});
el('btn-cancelar-campanha').addEventListener('click', () => el('modal-nova-campanha').close());
el('btn-add-variante').addEventListener('click', () => addVarianteRow());
el('campanha-produto').addEventListener('change', (event) => {
// O modal de nova campanha esta aberto (<dialog> via showModal()) quando
// isto dispara — toast sozinho fica escondido atras do dialog (ver
// comentario em api.js), entao o erro tambem precisa aparecer no
// paragrafo inline #campanha-erro que ja existe pro erro de submit.
carregarSegmentos(event.target.value).catch((error) => {
toast(`Erro: ${error.message}`);
el('campanha-erro').textContent = `Erro: ${error.message}`;
});
});
el('btn-salvar-campanha').addEventListener('click', async () => {
try {
await API.post('/api/campaigns', {
nome: el('campanha-nome').value.trim(),
produto: el('campanha-produto').value,
segmentos: segmentosSelecionados(),
variantes: variantesPreenchidas(),
limiteDiarioInicial: Number(el('campanha-limite-inicial').value) || 10,
limiteDiarioTeto: Number(el('campanha-limite-teto').value) || 80
});
el('modal-nova-campanha').close();
toast('Campanha criada');
await carregarLista();
} catch (error) {
el('campanha-erro').textContent = error.message;
}
});
ligarDetalheCampanha();
carregarLista().catch((error) => toast(`Erro: ${error.message}`));
}
let campanhaAtualId = null;
async function carregarDetalhe(campanhaId) {
campanhaAtualId = campanhaId;
const campanha = await API.get(`/api/campaigns/${campanhaId}`);
el('detalhe-nome').textContent = campanha.nome;
el('detalhe-status').textContent = STATUS_LABELS[campanha.status] || campanha.status;
el('campanha-detalhe').classList.remove('hidden');
await Promise.all([carregarSaude(campanhaId), carregarVariantesStats(campanhaId), carregarFila(campanhaId)]);
}
async function carregarSaude(campanhaId) {
const saude = await API.get(`/api/campaigns/${campanhaId}/saude`);
el('detalhe-saude').textContent = saude.pause
? `Pausada automaticamente: ${saude.reason}`
: 'Saude: sem alertas.';
}
async function carregarVariantesStats(campanhaId) {
const { variantes } = await API.get(`/api/campaigns/${campanhaId}/variantes-stats`);
el('detalhe-variantes').replaceChildren(...variantes.map((v) =>
h('span', { class: 'chip' },
`${v.code}: ${v.enviados} enviados, ${(v.taxaResposta * 100).toFixed(1)}% resposta${v.elegivelParaComparacao ? ' (elegivel)' : ''}`)
));
}
const STAGE_OPTIONS = [
'respondeu', 'interessado', 'demonstracao_agendada', 'sem_interesse', 'opt_out', 'venda_fechada'
];
async function carregarFila(campanhaId) {
const status = el('fila-filtro-status').value;
const { fila } = await API.get(`/api/campaigns/${campanhaId}/fila?status=${status}`);
el('fila-tabela').replaceChildren(...fila.map((linha) => {
const podeMarcarEstagio = linha.status === 'sent' || linha.status === 'error';
const stageSelect = h('select', { class: 'fila-estagio-select', disabled: podeMarcarEstagio ? null : 'disabled' },
h('option', { value: '' }, '—'),
...STAGE_OPTIONS.map((s) => h('option', { value: s }, s)));
const marcarBtn = h('button', {
class: 'btn-ghost btn-sm', type: 'button', disabled: podeMarcarEstagio ? null : 'disabled',
onclick: async () => {
try {
if (!stageSelect.value) return;
await API.post(`/api/campaigns/leads/${linha.lead_id}/estagio`, { stage: stageSelect.value, campaignId: campanhaId });
toast('Estagio atualizado');
if (stageSelect.value === 'demonstracao_agendada') {
const dataAgendamento = prompt('Data/hora do agendamento (AAAA-MM-DD HH:MM):');
if (dataAgendamento) {
await API.post(`/api/campaigns/leads/${linha.lead_id}/agendamentos`, { kind: 'demonstracao', scheduledAt: dataAgendamento });
toast('Agendamento registrado');
}
}
} catch (error) {
toast(`Erro: ${error.message}`);
}
}
}, 'Marcar');
return h('tr', {},
h('td', {}, h('input', { type: 'checkbox', class: 'fila-check', value: linha.id })),
h('td', {}, String(linha.lead_id)),
h('td', {}, linha.numero),
h('td', {}, linha.rendered_message),
h('td', {}, linha.status),
h('td', { class: 'row' }, stageSelect, marcarBtn));
}));
}
function filaSelecionada() {
return Array.from(document.querySelectorAll('.fila-check:checked')).map((i) => Number(i.value));
}
function ligarDetalheCampanha() {
el('btn-fechar-detalhe').addEventListener('click', () => el('campanha-detalhe').classList.add('hidden'));
el('fila-filtro-status').addEventListener('change', () => carregarFila(campanhaAtualId).catch((e) => toast(`Erro: ${e.message}`)));
el('btn-gerar-fila').addEventListener('click', async () => {
try {
const resultado = await API.post(`/api/campaigns/${campanhaAtualId}/gerar-fila`, {});
toast(`${resultado.adicionadosNaFila} leads adicionados a fila`);
await carregarFila(campanhaAtualId);
} catch (error) { toast(`Erro: ${error.message}`); }
});
el('btn-iniciar-campanha').addEventListener('click', async () => {
try {
await API.post(`/api/campaigns/${campanhaAtualId}/iniciar`, {});
await carregarDetalhe(campanhaAtualId);
await carregarLista();
} catch (error) { toast(`Erro: ${error.message}`); }
});
el('btn-pausar-campanha').addEventListener('click', async () => {
try {
await API.post(`/api/campaigns/${campanhaAtualId}/pausar`, { motivo: 'manual' });
await carregarDetalhe(campanhaAtualId);
await carregarLista();
} catch (error) { toast(`Erro: ${error.message}`); }
});
el('btn-retomar-campanha').addEventListener('click', async () => {
try {
await API.post(`/api/campaigns/${campanhaAtualId}/retomar`, {});
await carregarDetalhe(campanhaAtualId);
await carregarLista();
} catch (error) { toast(`Erro: ${error.message}`); }
});
el('btn-aprovar-selecionados').addEventListener('click', async () => {
const queueIds = filaSelecionada();
if (!queueIds.length) return;
try {
const resultado = await API.post(`/api/campaigns/${campanhaAtualId}/fila/aprovar`, { queueIds });
toast(`${resultado.aprovados} aprovados`);
await carregarFila(campanhaAtualId);
} catch (error) { toast(`Erro: ${error.message}`); }
});
el('btn-cancelar-selecionados').addEventListener('click', async () => {
const queueIds = filaSelecionada();
if (!queueIds.length) return;
try {
const resultado = await API.post(`/api/campaigns/${campanhaAtualId}/fila/cancelar`, { queueIds, motivo: 'operador' });
toast(`${resultado.cancelados} cancelados`);
await carregarFila(campanhaAtualId);
} catch (error) { toast(`Erro: ${error.message}`); }
});
}
return { ativar };
})();
+2
View File
@@ -3,6 +3,7 @@ import { openDb, ensureSeeds } from '../src/db.mjs';
import { createJobManager, recoverOrphans } from '../src/jobs/manager.mjs';
import { createAppServer } from '../src/server/index.mjs';
import { startOutreachScheduler } from '../src/outreach/scheduler.mjs';
import { startCampaignScheduler } from '../src/outreach/campaign-scheduler.mjs';
// Servidor local do GTI Maquina Comercial. Inicie com:
// npm start (ou node scripts/servidor.mjs, ou o GTI-Comercial.exe)
@@ -19,6 +20,7 @@ recoverOrphans(db, log);
const jobs = createJobManager();
const server = createAppServer({ db, jobs, log });
startOutreachScheduler({ db, log });
startCampaignScheduler({ db, log });
const port = GTI_PORT;
server.listen(port, '127.0.0.1', () => {
+79
View File
@@ -238,6 +238,85 @@ const MIGRATIONS = [
// usar placeholders resolvidos por lead: {empresa}, {produto}, {cidade}, etc.
`
ALTER TABLE message_templates ADD COLUMN template_waba_variaveis_json TEXT NOT NULL DEFAULT '[]';
`,
// v8 — campanhas de outreach com catalogo dinamico (a Comercial tem 57
// segmentos e 4 produtos, catalogo fixo em codigo nao cabe nessa escala:
// o operador cria a campanha pela tela). Roda ao lado do runOutreachBatch
// existente (run.mjs) sem substitui-lo. outreach_opt_out e novo aqui (nao
// existia antes): uma fila que recontata o mesmo lead ao longo de semanas
// precisa checar quem ja pediu pra parar.
`
CREATE TABLE outreach_campaigns (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
produto TEXT NOT NULL,
segmentos_json TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'rascunho',
pause_reason TEXT,
limite_diario_atual INTEGER NOT NULL DEFAULT 10,
limite_diario_teto INTEGER NOT NULL DEFAULT 80,
semana_iniciada_em TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE outreach_campaign_variants (
id INTEGER PRIMARY KEY,
campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id),
code TEXT NOT NULL,
mensagem TEXT NOT NULL,
ativo INTEGER NOT NULL DEFAULT 1
);
CREATE INDEX idx_campaign_variants_campaign ON outreach_campaign_variants(campaign_id);
CREATE TABLE outreach_campaign_queue (
id INTEGER PRIMARY KEY,
campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id),
lead_id INTEGER NOT NULL REFERENCES leads(id),
variant_id INTEGER NOT NULL REFERENCES outreach_campaign_variants(id),
segmento TEXT NOT NULL DEFAULT '',
numero TEXT NOT NULL,
rendered_message TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'preview',
cancel_reason TEXT,
outreach_send_id INTEGER REFERENCES outreach_sends(id),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_campaign_queue_campaign_status ON outreach_campaign_queue(campaign_id, status);
CREATE INDEX idx_campaign_queue_lead ON outreach_campaign_queue(lead_id);
CREATE TABLE outreach_lead_states (
lead_id INTEGER PRIMARY KEY REFERENCES leads(id),
stage TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE outreach_stage_events (
id INTEGER PRIMARY KEY,
lead_id INTEGER NOT NULL REFERENCES leads(id),
campaign_id INTEGER REFERENCES outreach_campaigns(id),
stage TEXT NOT NULL,
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_stage_events_lead ON outreach_stage_events(lead_id);
CREATE TABLE outreach_appointments (
id INTEGER PRIMARY KEY,
lead_id INTEGER NOT NULL REFERENCES leads(id),
kind TEXT NOT NULL,
scheduled_at TEXT,
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_appointments_lead ON outreach_appointments(lead_id);
CREATE TABLE outreach_opt_out (
numero TEXT PRIMARY KEY,
motivo TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`
];
+14
View File
@@ -0,0 +1,14 @@
import { renderTemplate } from './templates.mjs';
// Distribuicao deterministica por lead_id % variantes.length: o mesmo lead
// sempre cai na mesma variante mesmo que a fila seja regenerada depois, sem
// precisar guardar nenhum estado extra so pra isso.
export function variantForLead(variants, leadId) {
if (!variants.length) throw new Error('campanha sem variantes ativas');
const index = Number(leadId) % variants.length;
return variants[index];
}
export function renderCampaignMessage(variant, lead) {
return renderTemplate(variant.mensagem, lead);
}
+116
View File
@@ -0,0 +1,116 @@
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).
//
// PURA/read-only: so calcula e retorna {pause, reason}, nunca escreve no
// banco. Quem precisa da mutacao (pausar de verdade quando pause=true) deve
// chamar applyCampaignHealth, nao esta funcao — assim uma rota GET (como
// /api/campaigns/:id/saude, chamada so de abrir o painel na UI) nunca pausa
// uma campanha como efeito colateral de uma leitura.
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) {
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) {
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) {
return { pause: true, reason: 'low_reply_rate' };
}
}
return { pause: false, reason: null };
}
// MUTANTE: chama a avaliacao pura acima e, se ela mandar pausar, persiste a
// pausa de verdade via applyPause. E isto que o worker deve chamar apos cada
// envio real — e so o worker, nunca uma rota de leitura.
export function applyCampaignHealth(db, campaignId, now = new Date()) {
const resultado = evaluateCampaignHealth(db, campaignId, now);
if (resultado.pause) {
applyPause(db, campaignId, resultado.reason);
}
return resultado;
}
// 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
}));
}
+94
View File
@@ -0,0 +1,94 @@
import { getCampaign } from './campaign-store.mjs';
import { variantForLead, renderCampaignMessage } from './campaign-messages.mjs';
const ACTIVE_QUEUE_STATUSES = "'preview','approved','sent'";
function candidateQuery() {
return `
SELECT leads.id, leads.empresa, leads.whatsapp, leads.segmento, leads.cidade, leads.uf,
leads.segmento_label, leads.prioridade, leads.score
FROM leads
WHERE leads.produto_principal = ?
AND leads.segmento = ?
AND leads.telefone_valido = 1
AND leads.whatsapp != ''
AND NOT EXISTS (
SELECT 1 FROM outreach_sends
WHERE outreach_sends.produto = ? AND outreach_sends.status = 'enviado'
AND outreach_sends.lead_id = leads.id
)
AND NOT EXISTS (
SELECT 1 FROM outreach_sends
WHERE outreach_sends.status = 'enviado'
AND outreach_sends.numero = leads.whatsapp
AND outreach_sends.origem = 'manual'
)
AND NOT EXISTS (
SELECT 1 FROM outreach_campaign_queue q
JOIN outreach_campaigns c ON c.id = q.campaign_id
WHERE q.lead_id = leads.id AND c.produto = ? AND q.status IN (${ACTIVE_QUEUE_STATUSES})
)
AND NOT EXISTS (SELECT 1 FROM outreach_opt_out WHERE numero = leads.whatsapp)
ORDER BY leads.prioridade ASC, leads.score DESC, leads.id ASC`;
}
// Gera ate `quantidade` linhas novas de fila (status 'preview') para a
// campanha, buscando candidatos em todos os segmentos configurados. Nao e
// limitado ao limite diario de envio (isso so controla a VELOCIDADE de envio
// do worker) — o operador pode gerar e revisar um lote maior do que vai
// enviar hoje.
export function generateCampaignQueue(db, campaignId, { quantidade = 50 } = {}) {
const campaign = getCampaign(db, campaignId);
if (!campaign) throw new Error('campanha nao encontrada');
if (!campaign.variants.length) throw new Error('campanha sem variantes ativas');
if (!campaign.segmentos.length) throw new Error('campanha sem segmentos configurados');
const statement = db.prepare(candidateQuery());
const todosCandidatos = campaign.segmentos
.flatMap((segmento) => statement.all(campaign.produto, segmento, campaign.produto, campaign.produto))
.sort((a, b) => a.prioridade - b.prioridade || b.score - a.score || a.id - b.id);
const candidatos = todosCandidatos.slice(0, quantidade);
const insert = db.prepare(
`INSERT INTO outreach_campaign_queue
(campaign_id, lead_id, variant_id, segmento, numero, rendered_message, status)
VALUES (?, ?, ?, ?, ?, ?, 'preview')`
);
let adicionados = 0;
for (const lead of candidatos) {
const variant = variantForLead(campaign.variants, lead.id);
const renderedMessage = renderCampaignMessage(variant, lead);
insert.run(campaignId, lead.id, variant.id, lead.segmento, lead.whatsapp, renderedMessage);
adicionados += 1;
}
return { candidatosEncontrados: todosCandidatos.length, adicionadosNaFila: adicionados };
}
export function listQueue(db, campaignId, status = null) {
if (status) {
return db.prepare(
'SELECT * FROM outreach_campaign_queue WHERE campaign_id = ? AND status = ? ORDER BY id'
).all(campaignId, status);
}
return db.prepare(
'SELECT * FROM outreach_campaign_queue WHERE campaign_id = ? ORDER BY id'
).all(campaignId);
}
export function approveQueueRows(db, campaignId, queueIds) {
if (!queueIds.length) return 0;
const placeholders = queueIds.map(() => '?').join(', ');
return db.prepare(
`UPDATE outreach_campaign_queue SET status = 'approved', updated_at = datetime('now')
WHERE campaign_id = ? AND status = 'preview' AND id IN (${placeholders})`
).run(campaignId, ...queueIds).changes;
}
export function cancelQueueRows(db, campaignId, queueIds, reason = 'operador') {
if (!queueIds.length) return 0;
const placeholders = queueIds.map(() => '?').join(', ');
return db.prepare(
`UPDATE outreach_campaign_queue SET status = 'cancelled', cancel_reason = ?, updated_at = datetime('now')
WHERE campaign_id = ? AND status IN ('preview','approved') AND id IN (${placeholders})`
).run(reason, campaignId, ...queueIds).changes;
}
+25
View File
@@ -0,0 +1,25 @@
import { runCampaignQueue } from './campaign-worker.mjs';
import { escalateWeeklyLimits } from './campaign-store.mjs';
const CICLO_MS = 20 * 60 * 1000;
// Timer interno separado do scheduler.mjs existente (run.mjs/runOutreachBatch)
// — mesmo padrao (setInterval com .unref(), primeiro ciclo antecipado pouco
// depois do boot), rodando ao lado sem interferir um no outro.
export function startCampaignScheduler({ db, log = () => {} }) {
const ciclo = () => {
escalateWeeklyLimits(db);
runCampaignQueue({ db, log }).catch((error) => {
log(`campanhas: erro inesperado no ciclo automatico: ${error.message}`);
});
};
const primeiro = setTimeout(ciclo, 45 * 1000);
primeiro.unref();
const timer = setInterval(ciclo, CICLO_MS);
timer.unref();
return () => {
clearTimeout(primeiro);
clearInterval(timer);
};
}
+115
View File
@@ -0,0 +1,115 @@
import { transaction } from '../db.mjs';
export function createCampaign(db, {
nome, produto, segmentos, variantes, limiteDiarioInicial = 10, limiteDiarioTeto = 80
}) {
return transaction(db, () => {
const result = db.prepare(
`INSERT INTO outreach_campaigns
(nome, produto, segmentos_json, limite_diario_atual, limite_diario_teto)
VALUES (?, ?, ?, ?, ?)`
).run(nome, produto, JSON.stringify(segmentos || []), limiteDiarioInicial, limiteDiarioTeto);
const campaignId = Number(result.lastInsertRowid);
const insertVariant = db.prepare(
`INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (?, ?, ?)`
);
for (const variante of variantes) {
insertVariant.run(campaignId, variante.code, variante.mensagem);
}
return getCampaign(db, campaignId);
});
}
export function getCampaign(db, id) {
const row = db.prepare('SELECT * FROM outreach_campaigns WHERE id = ?').get(id);
if (!row) return null;
return {
...row,
segmentos: JSON.parse(row.segmentos_json || '[]'),
variants: listVariants(db, id)
};
}
export function listCampaigns(db) {
return db.prepare('SELECT * FROM outreach_campaigns ORDER BY id DESC').all()
.map((row) => ({ ...row, segmentos: JSON.parse(row.segmentos_json || '[]') }));
}
export function listVariants(db, campaignId) {
return db.prepare(
'SELECT * FROM outreach_campaign_variants WHERE campaign_id = ? AND ativo = 1 ORDER BY code'
).all(campaignId);
}
// status: 'rascunho' -> 'rodando' -> 'pausado' <-> 'rodando' -> 'concluido'.
// pause_reason so fica setado quando o novo status e 'pausado'.
export function setCampaignStatus(db, campaignId, status, pauseReason = null) {
if (status === 'rodando') {
// When transitioning to 'rodando', stamp semana_iniciada_em only on first run (if currently NULL)
db.prepare(
`UPDATE outreach_campaigns
SET status = ?, pause_reason = ?, semana_iniciada_em = COALESCE(semana_iniciada_em, datetime('now')), updated_at = datetime('now')
WHERE id = ?`
).run(status, status === 'pausado' ? pauseReason : null, campaignId);
} else {
db.prepare(
`UPDATE outreach_campaigns SET status = ?, pause_reason = ?, updated_at = datetime('now') WHERE id = ?`
).run(status, status === 'pausado' ? pauseReason : null, campaignId);
}
return getCampaign(db, campaignId);
}
// So avanca campanha que estiver de fato pausada — retomar uma campanha em
// rascunho, por exemplo, deveria usar setCampaignStatus(db, id, 'rodando').
export function resumeCampaign(db, campaignId) {
// Reseta semana_iniciada_em pra agora: uma campanha que ficou pausada (por
// saude ruim ou motivo manual) por varios dias nao pode, ao ser retomada,
// já satisfazer o "rodando ha 7+ dias" do escalateWeeklyLimits e dobrar o
// limite diario logo de cara — isso inverteria a logica de seguranca.
db.prepare(
`UPDATE outreach_campaigns
SET status = 'rodando', pause_reason = NULL, semana_iniciada_em = datetime('now'), updated_at = datetime('now')
WHERE id = ? AND status = 'pausado'`
).run(campaignId);
return getCampaign(db, campaignId);
}
// Escalonamento semanal: dobra o limite diario a cada 7 dias corridos rodando
// sem pausa desde semana_iniciada_em, ate o teto da campanha. So campanhas com
// status='rodando' avancam — uma pausada fica congelada ate o operador retomar.
export function escalateWeeklyLimits(db, now = new Date()) {
const campaigns = db.prepare(
`SELECT id, limite_diario_atual, limite_diario_teto, semana_iniciada_em
FROM outreach_campaigns WHERE status = 'rodando'`
).all();
let escaladas = 0;
for (const campaign of campaigns) {
if (!campaign.semana_iniciada_em) continue;
const dias = (now.getTime() - new Date(campaign.semana_iniciada_em.replace(' ', 'T') + 'Z').getTime()) / 86400000;
if (dias < 7) continue;
const novoLimite = Math.min(campaign.limite_diario_teto, campaign.limite_diario_atual * 2);
if (novoLimite === campaign.limite_diario_atual) continue;
db.prepare(
`UPDATE outreach_campaigns
SET limite_diario_atual = ?, semana_iniciada_em = datetime('now'), updated_at = datetime('now')
WHERE id = ?`
).run(novoLimite, campaign.id);
escaladas += 1;
}
return escaladas;
}
export function isOptedOut(db, numero) {
return Boolean(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get(numero));
}
export function addOptOut(db, numero, motivo = null) {
db.prepare(
`INSERT INTO outreach_opt_out (numero, motivo) VALUES (?, ?)
ON CONFLICT(numero) DO UPDATE SET motivo = excluded.motivo`
).run(numero, motivo);
}
export function removeOptOut(db, numero) {
db.prepare('DELETE FROM outreach_opt_out WHERE numero = ?').run(numero);
}
+131
View File
@@ -0,0 +1,131 @@
import { createZproClient } from './zpro-client.mjs';
import { isOptedOut } from './campaign-store.mjs';
import { applyCampaignHealth } from './campaign-metrics.mjs';
import { getSetting } from '../db.mjs';
// Mesmo intervalo aleatorio entre envios do run.mjs (INTERVALO_MIN_MS/
// INTERVALO_MAX_MS), duplicado aqui de proposito — os dois workers devem se
// comportar de forma consistente contra o mesmo numero de WhatsApp, mas sao
// modulos independentes (nao importar um do outro).
const SLEEP_MIN_MS = 3000;
const SLEEP_MAX_MS = 8000;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function randomSleepMs() {
return SLEEP_MIN_MS + Math.random() * (SLEEP_MAX_MS - SLEEP_MIN_MS);
}
// Mesma logica de comparacao de string HH:MM do run.mjs.
function withinBusinessHours(horaInicio, horaFim, now = new Date()) {
const hhmm = now.toTimeString().slice(0, 5);
return hhmm >= horaInicio && hhmm <= horaFim;
}
function todayAttempts(db, campaignId) {
return 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') = date('now', 'localtime')`
).get(campaignId).total);
}
// Diferente da Maquina Pessoal (que reserva o envio antes de mandar, porque a
// entrega via Baileys e assincrona/incerta), aqui o envio via Z-PRO
// (zproClient.sendText) e sincrono: ou resolve (aceito pelo Z-PRO) ou lanca
// erro. Por isso o resultado ja e gravado direto como 'enviado'/'erro', sem
// um estado intermediario "reservado" nem logica de recuperacao pos-crash.
export async function runCampaignQueue({ db, zproClient, now = new Date(), log = () => {}, sleepMs = null }) {
const client = zproClient || createZproClient(db);
const summary = { enviados: 0, erros: 0, pulados: 0 };
// Mesmas duas travas de seguranca do disparo automatico (run.mjs):
// interruptor geral e janela de horario comercial. Sem elas, o worker de
// campanha mandaria mensagem 24h/dia mesmo com o operador tendo desligado
// o outreach nas Configuracoes.
if (getSetting(db, 'outreach.ativo', 'false') !== 'true') {
log('campanhas: desativado (outreach.ativo=false)');
return summary;
}
const horaInicio = getSetting(db, 'outreach.horaInicio', '08:00');
const horaFim = getSetting(db, 'outreach.horaFim', '18:00');
if (!withinBusinessHours(horaInicio, horaFim, now)) {
log(`campanhas: fora do horario comercial (${horaInicio}-${horaFim})`);
return summary;
}
const campaigns = db.prepare(`SELECT id FROM outreach_campaigns WHERE status = 'rodando'`).all();
for (const { id: campaignId } of campaigns) {
const campaign = db.prepare('SELECT * FROM outreach_campaigns WHERE id = ?').get(campaignId);
let attemptsToday = todayAttempts(db, campaignId);
while (attemptsToday < campaign.limite_diario_atual) {
const row = db.prepare(
`SELECT * FROM outreach_campaign_queue
WHERE campaign_id = ? AND status = 'approved'
ORDER BY id ASC LIMIT 1`
).get(campaignId);
if (!row) break;
if (isOptedOut(db, row.numero)) {
db.prepare(
`UPDATE outreach_campaign_queue SET status = 'cancelled', cancel_reason = 'opt_out', updated_at = datetime('now') WHERE id = ?`
).run(row.id);
summary.pulados += 1;
continue;
}
const jaEnviado = db.prepare(
`SELECT 1 FROM outreach_sends WHERE produto = ? AND status = 'enviado'
AND (lead_id = ? OR numero = ?) LIMIT 1`
).get(campaign.produto, row.lead_id, row.numero);
if (jaEnviado) {
db.prepare(
`UPDATE outreach_campaign_queue SET status = 'cancelled', cancel_reason = 'ja_contatado', updated_at = datetime('now') WHERE id = ?`
).run(row.id);
summary.pulados += 1;
continue;
}
const externalKey = `campanha-${campaignId}-${row.id}`;
const insert = db.prepare(
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, erro, zpro_ticket_id, canal, origem)
VALUES (?, ?, ?, ?, ?, ?, ?, 'nao_oficial', 'automatico')`
);
let sendId;
let queueStatus;
try {
const resposta = await client.sendText({ number: row.numero, message: row.rendered_message, externalKey });
const ticketId = resposta?.data?.ticketId ? String(resposta.data.ticketId) : null;
sendId = Number(insert.run(row.lead_id, campaign.produto, row.numero, row.rendered_message, 'enviado', null, ticketId).lastInsertRowid);
queueStatus = 'sent';
summary.enviados += 1;
} catch (error) {
sendId = Number(insert.run(row.lead_id, campaign.produto, row.numero, row.rendered_message, 'erro', String(error.message || error), null).lastInsertRowid);
queueStatus = 'error';
summary.erros += 1;
log(`campanha #${campaignId}: envio falhou (fila #${row.id}): ${error.message}`);
}
db.prepare(
`UPDATE outreach_campaign_queue SET status = ?, outreach_send_id = ?, updated_at = datetime('now') WHERE id = ?`
).run(queueStatus, sendId, row.id);
attemptsToday += 1;
// Jitter entre tentativas reais de envio (nao entre pulos/cancelamentos,
// que nao batem no Z-PRO) — mesmo padrao de run.mjs, pra nao parecer
// disparo automatizado agressivo pro mesmo numero de WhatsApp.
await sleep(sleepMs != null ? sleepMs : randomSleepMs());
const health = applyCampaignHealth(db, campaignId, now);
if (health.pause) {
log(`campanha #${campaignId}: pausada automaticamente (${health.reason})`);
break;
}
}
}
return summary;
}
+2
View File
@@ -10,6 +10,7 @@ import { registerLeadRoutes } from './routes/leads.mjs';
import { registerExportRoutes } from './routes/exports.mjs';
import { registerAuthRoutes } from './routes/auth.mjs';
import { registerOutreachRoutes } from './routes/outreach.mjs';
import { registerCampaignRoutes } from './routes/campaigns.mjs';
import { getCookie, getSession, SESSION_COOKIE } from './session.mjs';
const PUBLIC_API_PATHS = new Set(['/api/auth/login', '/api/health']);
@@ -55,6 +56,7 @@ export function createAppServer({ db, jobs, log = () => {} }) {
registerLeadRoutes(router, deps);
registerExportRoutes(router, deps);
registerOutreachRoutes(router, deps);
registerCampaignRoutes(router, deps);
// SSE de progresso dos jobs (rota especial: gerencia a resposta manualmente)
router.add('GET', '/api/jobs/:jobId/events', async (req, res, { params }) => {
+162
View File
@@ -0,0 +1,162 @@
import {
createCampaign, getCampaign, listCampaigns, setCampaignStatus, resumeCampaign,
addOptOut, removeOptOut
} from '../../outreach/campaign-store.mjs';
import { generateCampaignQueue, listQueue, approveQueueRows, cancelQueueRows } from '../../outreach/campaign-queue.mjs';
import { evaluateCampaignHealth, variantStats } from '../../outreach/campaign-metrics.mjs';
import { onlyDigits } from '../../scoring.mjs';
export function registerCampaignRoutes(router, { db }) {
router.add('POST', '/api/campaigns', async (req, res, { json, body }) => {
const nome = String(body?.nome || '').trim();
const produto = String(body?.produto || '').trim();
const segmentos = Array.isArray(body?.segmentos) ? body.segmentos.filter(Boolean) : [];
const variantesValidas = Array.isArray(body?.variantes)
? body.variantes.filter((v) => v && String(v.mensagem || '').trim())
: [];
if (!nome || !produto) { json(400, { erro: 'informe nome e produto' }); return; }
if (!segmentos.length) { json(400, { erro: 'escolha ao menos um segmento' }); return; }
if (!variantesValidas.length) { json(400, { erro: 'escreva ao menos uma variante de mensagem' }); return; }
const campanha = createCampaign(db, {
nome, produto, segmentos,
variantes: variantesValidas.map((v, i) => ({ code: v.code || String.fromCharCode(65 + i), mensagem: v.mensagem })),
limiteDiarioInicial: Number(body.limiteDiarioInicial) || 10,
limiteDiarioTeto: Number(body.limiteDiarioTeto) || 80
});
json(200, campanha);
});
router.add('GET', '/api/campaigns', async (req, res, { json }) => {
json(200, { campanhas: listCampaigns(db) });
});
// Precisa vir ANTES de GET /api/campaigns/:id (mesmo numero de segmentos no
// path — o router retorna a primeira rota que casar).
router.add('GET', '/api/campaigns/segmentos', async (req, res, { json, query }) => {
const produto = String(query?.get('produto') || '').trim();
if (!produto) { json(400, { erro: 'informe o produto' }); return; }
const segmentos = db.prepare(
`SELECT segmento AS value, COUNT(*) AS total FROM leads
WHERE produto_principal = ? AND segmento != '' GROUP BY segmento ORDER BY total DESC`
).all(produto);
json(200, { segmentos });
});
router.add('GET', '/api/campaigns/produtos', async (req, res, { json }) => {
const produtos = db.prepare(
`SELECT produto_principal AS value, COUNT(*) AS total FROM leads
WHERE produto_principal != '' GROUP BY produto_principal ORDER BY total DESC`
).all();
json(200, { produtos });
});
router.add('GET', '/api/campaigns/:id', async (req, res, { json, params }) => {
const campanha = getCampaign(db, Number(params.id));
if (!campanha) { json(404, { erro: 'campanha nao encontrada' }); return; }
json(200, campanha);
});
router.add('POST', '/api/campaigns/:id/gerar-fila', async (req, res, { json, params, body }) => {
try {
const resultado = generateCampaignQueue(db, Number(params.id), { quantidade: Number(body?.quantidade) || 50 });
json(200, resultado);
} catch (error) {
json(400, { erro: String(error.message || error) });
}
});
router.add('GET', '/api/campaigns/:id/fila', async (req, res, { json, params, query }) => {
json(200, { fila: listQueue(db, Number(params.id), query?.get('status') || null) });
});
router.add('POST', '/api/campaigns/:id/fila/aprovar', async (req, res, { json, params, body }) => {
const queueIds = Array.isArray(body?.queueIds) ? body.queueIds.map(Number).filter(Number.isFinite) : [];
json(200, { aprovados: approveQueueRows(db, Number(params.id), queueIds) });
});
router.add('POST', '/api/campaigns/:id/fila/cancelar', async (req, res, { json, params, body }) => {
const queueIds = Array.isArray(body?.queueIds) ? body.queueIds.map(Number).filter(Number.isFinite) : [];
json(200, { cancelados: cancelQueueRows(db, Number(params.id), queueIds, body?.motivo || 'operador') });
});
router.add('POST', '/api/campaigns/:id/iniciar', async (req, res, { json, params }) => {
json(200, setCampaignStatus(db, Number(params.id), 'rodando'));
});
router.add('POST', '/api/campaigns/:id/pausar', async (req, res, { json, params, body }) => {
json(200, setCampaignStatus(db, Number(params.id), 'pausado', body?.motivo || 'manual'));
});
router.add('POST', '/api/campaigns/:id/retomar', async (req, res, { json, params }) => {
json(200, resumeCampaign(db, Number(params.id)));
});
// So le o status atual — nao aplica pausa. A aplicacao de pausa (mutacao)
// acontece exclusivamente no worker (applyCampaignHealth), nunca como efeito
// colateral de abrir o painel de detalhe da campanha.
router.add('GET', '/api/campaigns/:id/saude', async (req, res, { json, params }) => {
json(200, evaluateCampaignHealth(db, Number(params.id)));
});
router.add('GET', '/api/campaigns/:id/variantes-stats', async (req, res, { json, params }) => {
json(200, { variantes: variantStats(db, Number(params.id)) });
});
router.add('POST', '/api/campaigns/leads/:leadId/estagio', async (req, res, { json, params, body }) => {
const stage = String(body?.stage || '').trim();
if (!stage) { json(400, { erro: 'informe o estagio' }); return; }
const leadId = Number(params.leadId);
db.prepare(
`INSERT INTO outreach_lead_states (lead_id, stage, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT(lead_id) DO UPDATE SET stage = excluded.stage, updated_at = excluded.updated_at`
).run(leadId, stage);
db.prepare(
`INSERT INTO outreach_stage_events (lead_id, campaign_id, stage, note) VALUES (?, ?, ?, ?)`
).run(leadId, body?.campaignId ? Number(body.campaignId) : null, stage, body?.note || null);
// Marcar 'opt_out' via estagio precisa REALMENTE tirar o lead de circulacao
// (senao ele continua elegivel na candidateQuery/worker) — nao basta
// registrar o estagio, tem que gravar em outreach_opt_out tambem.
if (stage === 'opt_out') {
const lead = db.prepare('SELECT whatsapp FROM leads WHERE id = ?').get(leadId);
const numero = onlyDigits(lead?.whatsapp || '');
if (numero) addOptOut(db, numero, 'marcado via estagio');
}
json(200, { ok: true });
});
router.add('GET', '/api/campaigns/leads/:leadId/estagio', async (req, res, { json, params }) => {
const leadId = Number(params.leadId);
json(200, {
atual: db.prepare('SELECT * FROM outreach_lead_states WHERE lead_id = ?').get(leadId) || null,
historico: db.prepare('SELECT * FROM outreach_stage_events WHERE lead_id = ? ORDER BY id DESC').all(leadId)
});
});
router.add('POST', '/api/campaigns/leads/:leadId/agendamentos', async (req, res, { json, params, body }) => {
const kind = String(body?.kind || '').trim();
if (!kind) { json(400, { erro: 'informe o tipo de agendamento' }); return; }
db.prepare(
`INSERT INTO outreach_appointments (lead_id, kind, scheduled_at, note) VALUES (?, ?, ?, ?)`
).run(Number(params.leadId), kind, body?.scheduledAt || null, body?.note || null);
json(200, { ok: true });
});
router.add('GET', '/api/campaigns/leads/:leadId/agendamentos', async (req, res, { json, params }) => {
json(200, {
agendamentos: db.prepare('SELECT * FROM outreach_appointments WHERE lead_id = ? ORDER BY id DESC').all(Number(params.leadId))
});
});
router.add('POST', '/api/campaigns/opt-out', async (req, res, { json, body }) => {
const numero = onlyDigits(body?.numero || '');
if (!numero) { json(400, { erro: 'informe o numero' }); return; }
addOptOut(db, numero, body?.motivo || null);
json(200, { ok: true });
});
router.add('DELETE', '/api/campaigns/opt-out/:numero', async (req, res, { json, params }) => {
removeOptOut(db, onlyDigits(params.numero));
json(200, { ok: true });
});
}
+27
View File
@@ -0,0 +1,27 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { variantForLead, renderCampaignMessage } from '../src/outreach/campaign-messages.mjs';
const variants = [{ id: 1, code: 'A', mensagem: 'Ola A' }, { id: 2, code: 'B', mensagem: 'Ola B' }, { id: 3, code: 'C', mensagem: 'Ola C' }];
test('variantForLead e deterministico (mesmo lead sempre cai na mesma variante)', () => {
const primeira = variantForLead(variants, 42);
const segunda = variantForLead(variants, 42);
assert.equal(primeira.code, segunda.code);
});
test('variantForLead distribui entre variantes diferentes conforme o id', () => {
assert.equal(variantForLead(variants, 0).code, 'A');
assert.equal(variantForLead(variants, 1).code, 'B');
assert.equal(variantForLead(variants, 2).code, 'C');
assert.equal(variantForLead(variants, 3).code, 'A');
});
test('variantForLead lanca erro sem variantes ativas', () => {
assert.throws(() => variantForLead([], 1), /sem variantes/);
});
test('renderCampaignMessage substitui variaveis do lead', () => {
const texto = renderCampaignMessage({ mensagem: 'Ola {empresa}, sobre {produto}' }, { empresa: 'Oficina do Ze', produto: 'ERP' });
assert.equal(texto, 'Ola Oficina do Ze, sobre ERP');
});
+170
View File
@@ -0,0 +1,170 @@
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, applyCampaignHealth, 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');
// evaluateCampaignHealth e read-only: so retorna o veredito, nao mexe no banco.
const semMutacao = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
assert.equal(semMutacao.status, 'rodando');
// A mutacao (pausar de verdade) so acontece via applyCampaignHealth.
const aplicado = applyCampaignHealth(db, campanha.id);
assert.equal(aplicado.pause, true);
assert.equal(aplicado.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 nunca muda outreach_campaigns.status, mesmo quando retornaria pause:true (GET /saude nao deve pausar como efeito colateral de uma leitura)', () => {
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, `puro-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, `puro-err${i}`);
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro');
}
for (let i = 0; i < 5; i += 1) {
const resultado = evaluateCampaignHealth(db, campanha.id);
assert.equal(resultado.pause, true, 'deveria continuar sinalizando pause:true a cada chamada');
}
const aindaRodando = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
assert.equal(aindaRodando.status, 'rodando', 'chamar evaluateCampaignHealth repetidamente nao deveria ter pausado a campanha');
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();
});
+155
View File
@@ -0,0 +1,155 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, createLeadInserter } from '../src/db.mjs';
import { createCampaign } from '../src/outreach/campaign-store.mjs';
import { generateCampaignQueue, listQueue, approveQueueRows, cancelQueueRows } from '../src/outreach/campaign-queue.mjs';
function inserirLead(db, overrides = {}) {
const inserir = createLeadInserter(db);
const dedup = overrides.dedup_key || `lead-${Math.random()}`;
inserir({
dedup_key: dedup, empresa: 'Oficina Teste', legal_name: '', whatsapp: '5511999990001',
ddd: '11', email: '', cidade: 'SP', uf: 'SP', segmento: 'oficinas_mecanicas', segmento_label: '',
interesse: '', cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]', origem: 'teste',
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 80, classificacao: 'bom',
motivo_classificacao: '', score_qualidade: 80, score_comercial: 80, prioridade: 1,
produto_principal: 'ERP', produtos_json: '[]', capture_run_id: null, captured_at: null, exported_at: null,
...overrides
});
return db.prepare('SELECT * FROM leads WHERE dedup_key = ?').get(dedup);
}
function campanhaComVariantes(db, overrides = {}) {
return createCampaign(db, {
nome: 'ERP oficinas', produto: 'ERP', segmentos: ['oficinas_mecanicas'],
variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }],
...overrides
});
}
test('generateCampaignQueue cria linhas preview so para leads do produto/segmento certos', () => {
const db = openDb(':memory:');
inserirLead(db, { dedup_key: 'l1', whatsapp: '5511999990001' });
inserirLead(db, { dedup_key: 'l2', segmento: 'outro_segmento', whatsapp: '5511999990002' });
const campanha = campanhaComVariantes(db);
const resultado = generateCampaignQueue(db, campanha.id);
assert.equal(resultado.candidatosEncontrados, 1);
assert.equal(resultado.adicionadosNaFila, 1);
const fila = listQueue(db, campanha.id);
assert.equal(fila.length, 1);
assert.equal(fila[0].status, 'preview');
assert.equal(fila[0].rendered_message, 'Ola Oficina Teste');
db.close();
});
test('generateCampaignQueue exclui lead com envio ja enviado para o mesmo produto (por lead_id)', () => {
const db = openDb(':memory:');
const lead = inserirLead(db);
db.prepare(
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'ERP', ?, 'oi', 'enviado')`
).run(lead.id, lead.whatsapp);
const campanha = campanhaComVariantes(db);
const resultado = generateCampaignQueue(db, campanha.id);
assert.equal(resultado.candidatosEncontrados, 0);
db.close();
});
test('generateCampaignQueue exclui numero com envio manual ja enviado (sem lead_id, produto vazio, como o envio manual real grava)', () => {
const db = openDb(':memory:');
inserirLead(db, { whatsapp: '5511999990009' });
// Reproduz exatamente como src/server/routes/outreach.mjs grava um envio
// manual: lead_id NULL, produto '' (nunca o produto da campanha) e
// origem='manual'. A exclusao precisa valer mesmo assim, por numero.
db.prepare(
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, origem)
VALUES (NULL, '', '5511999990009', 'oi', 'enviado', 'manual')`
).run();
const campanha = campanhaComVariantes(db);
const resultado = generateCampaignQueue(db, campanha.id);
assert.equal(resultado.candidatosEncontrados, 0);
db.close();
});
test('generateCampaignQueue exclui numero em opt-out', () => {
const db = openDb(':memory:');
inserirLead(db, { whatsapp: '5511999990001' });
db.prepare(`INSERT INTO outreach_opt_out (numero) VALUES ('5511999990001')`).run();
const campanha = campanhaComVariantes(db);
const resultado = generateCampaignQueue(db, campanha.id);
assert.equal(resultado.candidatosEncontrados, 0);
db.close();
});
test('generateCampaignQueue nao duplica lead ja na fila (preview/approved/sent) de outra campanha do mesmo produto', () => {
const db = openDb(':memory:');
inserirLead(db, { dedup_key: 'unico', whatsapp: '5511999990001' });
const campanha1 = campanhaComVariantes(db, { nome: 'Campanha 1' });
generateCampaignQueue(db, campanha1.id);
const campanha2 = campanhaComVariantes(db, { nome: 'Campanha 2' });
const resultado = generateCampaignQueue(db, campanha2.id);
assert.equal(resultado.candidatosEncontrados, 0);
db.close();
});
test('generateCampaignQueue respeita o parametro quantidade', () => {
const db = openDb(':memory:');
for (let i = 0; i < 5; i += 1) {
inserirLead(db, { dedup_key: `lead-${i}`, whatsapp: `551199999000${i}` });
}
const campanha = campanhaComVariantes(db);
const resultado = generateCampaignQueue(db, campanha.id, { quantidade: 3 });
assert.equal(resultado.adicionadosNaFila, 3);
db.close();
});
test('generateCampaignQueue reporta candidatosEncontrados como o pool elegivel completo, mesmo com quantidade menor', () => {
const db = openDb(':memory:');
for (let i = 0; i < 5; i += 1) {
inserirLead(db, { dedup_key: `pool-${i}`, whatsapp: `551199998000${i}` });
}
const campanha = campanhaComVariantes(db);
const resultado = generateCampaignQueue(db, campanha.id, { quantidade: 2 });
assert.equal(resultado.candidatosEncontrados, 5);
assert.equal(resultado.adicionadosNaFila, 2);
assert.equal(listQueue(db, campanha.id).length, 2);
db.close();
});
test('approveQueueRows so aprova linhas em preview da campanha certa', () => {
const db = openDb(':memory:');
inserirLead(db, { whatsapp: '5511999990001' });
const campanha = campanhaComVariantes(db);
generateCampaignQueue(db, campanha.id);
const [linha] = listQueue(db, campanha.id);
const total = approveQueueRows(db, campanha.id, [linha.id]);
assert.equal(total, 1);
assert.equal(listQueue(db, campanha.id, 'approved').length, 1);
});
test('cancelQueueRows cancela e registra motivo', () => {
const db = openDb(':memory:');
inserirLead(db, { whatsapp: '5511999990001' });
const campanha = campanhaComVariantes(db);
generateCampaignQueue(db, campanha.id);
const [linha] = listQueue(db, campanha.id);
const total = cancelQueueRows(db, campanha.id, [linha.id], 'nao interessa');
assert.equal(total, 1);
const cancelada = listQueue(db, campanha.id, 'cancelled')[0];
assert.equal(cancelada.cancel_reason, 'nao interessa');
});
test('approveQueueRows/cancelQueueRows com lista vazia nao lanca erro', () => {
const db = openDb(':memory:');
const campanha = campanhaComVariantes(db);
assert.equal(approveQueueRows(db, campanha.id, []), 0);
assert.equal(cancelQueueRows(db, campanha.id, []), 0);
db.close();
});
+217
View File
@@ -0,0 +1,217 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, createLeadInserter } from '../src/db.mjs';
import { createRouter } from '../src/server/router.mjs';
import { registerCampaignRoutes } from '../src/server/routes/campaigns.mjs';
function setupRouter(db) {
const router = createRouter();
registerCampaignRoutes(router, { db });
return router;
}
function fakeJson() {
const calls = [];
return { json: (status, data) => calls.push({ status, data }), calls };
}
function inserirLead(db, dedup, produto = 'ERP', segmento = 'oficinas') {
const inserir = createLeadInserter(db);
inserir({
dedup_key: dedup, empresa: 'Empresa', legal_name: '', whatsapp: `551199990${dedup}`, ddd: '11',
email: '', cidade: '', uf: '', segmento, 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: produto, produtos_json: '[]',
capture_run_id: null, captured_at: null, exported_at: null
});
}
test('POST /api/campaigns cria campanha com segmentos e variantes', async () => {
const db = openDb(':memory:');
const router = setupRouter(db);
const { handler } = router.match('POST', '/api/campaigns');
const { json, calls } = fakeJson();
await handler(null, null, { json, body: {
nome: 'ERP oficinas', produto: 'ERP', segmentos: ['oficinas'],
variantes: [{ mensagem: 'Ola {empresa}' }]
} });
assert.equal(calls[0].status, 200);
assert.equal(calls[0].data.produto, 'ERP');
assert.equal(calls[0].data.variants.length, 1);
db.close();
});
test('POST /api/campaigns rejeita sem segmentos', async () => {
const db = openDb(':memory:');
const router = setupRouter(db);
const { handler } = router.match('POST', '/api/campaigns');
const { json, calls } = fakeJson();
await handler(null, null, { json, body: { nome: 'X', produto: 'ERP', segmentos: [], variantes: [{ mensagem: 'oi' }] } });
assert.equal(calls[0].status, 400);
db.close();
});
test('GET /api/campaigns/segmentos lista segmentos distintos do produto com contagem', async () => {
const db = openDb(':memory:');
inserirLead(db, '1', 'ERP', 'oficinas');
inserirLead(db, '2', 'ERP', 'oficinas');
inserirLead(db, '3', 'ERP', 'padarias');
inserirLead(db, '4', 'G-Chat', 'padarias');
const router = setupRouter(db);
const { handler } = router.match('GET', '/api/campaigns/segmentos');
const { json, calls } = fakeJson();
await handler(null, null, { json, query: new URLSearchParams({ produto: 'ERP' }) });
assert.equal(calls[0].status, 200);
const segmentos = calls[0].data.segmentos;
assert.equal(segmentos.find((s) => s.value === 'oficinas').total, 2);
assert.equal(segmentos.find((s) => s.value === 'padarias').total, 1);
db.close();
});
test('fluxo completo: criar campanha, gerar fila, aprovar e checar saude', async () => {
const db = openDb(':memory:');
inserirLead(db, '1', 'ERP', 'oficinas');
const router = setupRouter(db);
const criar = router.match('POST', '/api/campaigns');
const respCriar = fakeJson();
await criar.handler(null, null, { json: respCriar.json, body: {
nome: 'C', produto: 'ERP', segmentos: ['oficinas'], variantes: [{ mensagem: 'Ola {empresa}' }]
} });
const campaignId = respCriar.calls[0].data.id;
const gerarFila = router.match('POST', `/api/campaigns/${campaignId}/gerar-fila`);
const respFila = fakeJson();
await gerarFila.handler(null, null, { json: respFila.json, params: { id: String(campaignId) }, body: {} });
assert.equal(respFila.calls[0].data.adicionadosNaFila, 1);
const listarFila = router.match('GET', `/api/campaigns/${campaignId}/fila`);
const respLista = fakeJson();
await listarFila.handler(null, null, { json: respLista.json, params: { id: String(campaignId) }, query: new URLSearchParams() });
const queueId = respLista.calls[0].data.fila[0].id;
const aprovar = router.match('POST', `/api/campaigns/${campaignId}/fila/aprovar`);
const respAprovar = fakeJson();
await aprovar.handler(null, null, { json: respAprovar.json, params: { id: String(campaignId) }, body: { queueIds: [queueId] } });
assert.equal(respAprovar.calls[0].data.aprovados, 1);
const saude = router.match('GET', `/api/campaigns/${campaignId}/saude`);
const respSaude = fakeJson();
await saude.handler(null, null, { json: respSaude.json, params: { id: String(campaignId) } });
assert.equal(respSaude.calls[0].data.pause, false);
db.close();
});
test('POST /api/campaigns/leads/:leadId/estagio grava estagio atual e historico', async () => {
const db = openDb(':memory:');
inserirLead(db, '1');
const lead = db.prepare('SELECT id FROM leads LIMIT 1').get();
const router = setupRouter(db);
const marcar = router.match('POST', '/api/campaigns/leads/:leadId/estagio');
const respMarcar = fakeJson();
await marcar.handler(null, null, { json: respMarcar.json, params: { leadId: String(lead.id) }, body: { stage: 'interessado', note: 'ligar amanha' } });
assert.equal(respMarcar.calls[0].status, 200);
const consultar = router.match('GET', '/api/campaigns/leads/:leadId/estagio');
const respConsultar = fakeJson();
await consultar.handler(null, null, { json: respConsultar.json, params: { leadId: String(lead.id) } });
assert.equal(respConsultar.calls[0].data.atual.stage, 'interessado');
assert.equal(respConsultar.calls[0].data.historico.length, 1);
db.close();
});
test('POST /api/campaigns/opt-out bloqueia e DELETE libera', async () => {
const db = openDb(':memory:');
const router = setupRouter(db);
const adicionar = router.match('POST', '/api/campaigns/opt-out');
await adicionar.handler(null, null, { json: fakeJson().json, body: { numero: '5511999990001', motivo: 'pediu' } });
assert.ok(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001'));
const remover = router.match('DELETE', '/api/campaigns/opt-out/:numero');
await remover.handler(null, null, { json: fakeJson().json, params: { numero: '5511999990001' } });
assert.equal(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001'), undefined);
db.close();
});
test('POST/DELETE /api/campaigns/opt-out normalizam o numero pra digitos, igual ao resto do app', async () => {
const db = openDb(':memory:');
const router = setupRouter(db);
const adicionar = router.match('POST', '/api/campaigns/opt-out');
await adicionar.handler(null, null, { json: fakeJson().json, body: { numero: '+55 (11) 99999-0001', motivo: 'pediu' } });
// Tem que ter gravado so digitos, senao isOptedOut/candidateQuery (que comparam
// contra leads.whatsapp, ja digit-only) nunca vao casar com este registro.
assert.ok(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001'));
assert.equal(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('+55 (11) 99999-0001'), undefined);
const remover = router.match('DELETE', '/api/campaigns/opt-out/:numero');
await remover.handler(null, null, { json: fakeJson().json, params: { numero: '+55 (11) 99999-0001' } });
assert.equal(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001'), undefined);
db.close();
});
test('POST /api/campaigns/leads/:leadId/estagio com stage=opt_out tambem grava em outreach_opt_out (numero digit-only)', async () => {
const db = openDb(':memory:');
inserirLead(db, '1');
const lead = db.prepare('SELECT id, whatsapp FROM leads LIMIT 1').get();
const router = setupRouter(db);
const marcar = router.match('POST', '/api/campaigns/leads/:leadId/estagio');
const respMarcar = fakeJson();
await marcar.handler(null, null, { json: respMarcar.json, params: { leadId: String(lead.id) }, body: { stage: 'opt_out' } });
assert.equal(respMarcar.calls[0].status, 200);
assert.ok(
db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get(lead.whatsapp),
'marcar estagio opt_out deveria ter chamado addOptOut com o numero do lead'
);
db.close();
});
test('GET /:id/saude nao muta o status da campanha (leitura pura); o worker e quem aplica a pausa', async () => {
const db = openDb(':memory:');
inserirLead(db, '1', 'ERP', 'oficinas');
const router = setupRouter(db);
const criar = router.match('POST', '/api/campaigns');
const respCriar = fakeJson();
await criar.handler(null, null, { json: respCriar.json, body: {
nome: 'C', produto: 'ERP', segmentos: ['oficinas'], variantes: [{ mensagem: 'Ola {empresa}' }]
} });
const campaignId = respCriar.calls[0].data.id;
const iniciar = router.match('POST', `/api/campaigns/${campaignId}/iniciar`);
await iniciar.handler(null, null, { json: fakeJson().json, params: { id: String(campaignId) } });
// Simula 10 tentativas hoje, todas com erro (100% > 10%) -> evaluateCampaignHealth
// deveria retornar pause:true, mas a rota GET nao pode persistir essa pausa.
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campaignId);
for (let i = 0; i < 10; i += 1) {
const dedup = `saude-${i}`;
inserirLead(db, dedup, 'ERP', 'oficinas');
const l = db.prepare('SELECT id, whatsapp FROM leads WHERE dedup_key = ?').get(dedup);
const sendId = db.prepare(
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'ERP', ?, 'oi', 'erro')`
).run(l.id, l.whatsapp).lastInsertRowid;
db.prepare(
`INSERT INTO outreach_campaign_queue (campaign_id, lead_id, variant_id, numero, rendered_message, status, outreach_send_id)
VALUES (?, ?, ?, ?, 'oi', 'error', ?)`
).run(campaignId, l.id, variant.id, l.whatsapp, sendId);
}
const saude = router.match('GET', `/api/campaigns/${campaignId}/saude`);
const respSaude = fakeJson();
await saude.handler(null, null, { json: respSaude.json, params: { id: String(campaignId) } });
assert.equal(respSaude.calls[0].data.pause, true, 'a avaliacao deveria sinalizar pause:true dado 100% de erro');
const aindaRodando = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId);
assert.equal(aindaRodando.status, 'rodando', 'abrir o painel de saude (GET) nao deveria ter pausado a campanha');
db.close();
});
+12
View File
@@ -0,0 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb } from '../src/db.mjs';
import { startCampaignScheduler } from '../src/outreach/campaign-scheduler.mjs';
test('startCampaignScheduler retorna funcao de parada que limpa os timers sem lancar erro', () => {
const db = openDb(':memory:');
const stop = startCampaignScheduler({ db, log: () => {} });
assert.equal(typeof stop, 'function');
assert.doesNotThrow(() => stop());
db.close();
});
+144
View File
@@ -0,0 +1,144 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb } from '../src/db.mjs';
import {
createCampaign, getCampaign, listCampaigns, setCampaignStatus, resumeCampaign,
escalateWeeklyLimits, isOptedOut, addOptOut, removeOptOut
} from '../src/outreach/campaign-store.mjs';
function campanhaBase(overrides = {}) {
return {
nome: 'ERP oficinas',
produto: 'ERP',
segmentos: ['oficinas_mecanicas', 'autopecas'],
variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }, { code: 'B', mensagem: 'Oi {empresa}' }],
...overrides
};
}
test('createCampaign grava campanha, segmentos e variantes', () => {
const db = openDb(':memory:');
const campanha = createCampaign(db, campanhaBase());
assert.equal(campanha.status, 'rascunho');
assert.equal(campanha.produto, 'ERP');
assert.deepEqual(campanha.segmentos, ['oficinas_mecanicas', 'autopecas']);
assert.equal(campanha.variants.length, 2);
assert.equal(campanha.variants[0].code, 'A');
db.close();
});
test('listCampaigns retorna mais recente primeiro', () => {
const db = openDb(':memory:');
createCampaign(db, campanhaBase({ nome: 'Primeira' }));
createCampaign(db, campanhaBase({ nome: 'Segunda' }));
const campanhas = listCampaigns(db);
assert.equal(campanhas.length, 2);
assert.equal(campanhas[0].nome, 'Segunda');
db.close();
});
test('setCampaignStatus pausa e resumeCampaign retoma limpando o motivo', () => {
const db = openDb(':memory:');
const campanha = createCampaign(db, campanhaBase());
setCampaignStatus(db, campanha.id, 'rodando');
const pausada = setCampaignStatus(db, campanha.id, 'pausado', 'high_failure_rate');
assert.equal(pausada.status, 'pausado');
assert.equal(pausada.pause_reason, 'high_failure_rate');
const retomada = resumeCampaign(db, campanha.id);
assert.equal(retomada.status, 'rodando');
assert.equal(retomada.pause_reason, null);
db.close();
});
test('setCampaignStatus rodando stamps semana_iniciada_em on first transition', () => {
const db = openDb(':memory:');
const campanha = createCampaign(db, campanhaBase());
assert.equal(campanha.semana_iniciada_em, null);
const rodando = setCampaignStatus(db, campanha.id, 'rodando');
assert.notEqual(rodando.semana_iniciada_em, null, 'semana_iniciada_em should be set on first rodando transition');
db.close();
});
test('setCampaignStatus rodando does not reset already-set semana_iniciada_em', () => {
const db = openDb(':memory:');
const campanha = createCampaign(db, campanhaBase());
// Set to rodando first time, which stamps semana_iniciada_em
setCampaignStatus(db, campanha.id, 'rodando');
// Manually set to a known past value via raw SQL
const knownTime = '2026-07-15 10:00:00';
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = ? WHERE id = ?`).run(knownTime, campanha.id);
// Call setCampaignStatus again while already 'rodando'
const unchanged = setCampaignStatus(db, campanha.id, 'rodando');
// Verify semana_iniciada_em was not changed
assert.equal(unchanged.semana_iniciada_em, knownTime, 'semana_iniciada_em should not be reset on subsequent rodando calls');
db.close();
});
test('resumeCampaign reseta semana_iniciada_em, evitando escalonamento imediato apos retomar de uma pausa longa', () => {
const db = openDb(':memory:');
const campanha = createCampaign(db, campanhaBase({ limiteDiarioInicial: 10, limiteDiarioTeto: 80 }));
setCampaignStatus(db, campanha.id, 'rodando');
setCampaignStatus(db, campanha.id, 'pausado', 'high_failure_rate');
// Simula que a campanha ficou pausada por muito tempo: semana_iniciada_em
// backdated 10 dias, o que satisfaria os "7 dias corridos" do escalonamento.
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-10 days') WHERE id = ?`).run(campanha.id);
resumeCampaign(db, campanha.id);
const escaladas = escalateWeeklyLimits(db);
assert.equal(escaladas, 0, 'retomar deveria ter resetado o relogio semanal, entao nao deveria escalonar imediatamente');
const atualizada = getCampaign(db, campanha.id);
assert.equal(atualizada.limite_diario_atual, 10);
db.close();
});
test('resumeCampaign nao faz nada se a campanha nao estiver pausada', () => {
const db = openDb(':memory:');
const campanha = createCampaign(db, campanhaBase());
const resultado = resumeCampaign(db, campanha.id);
assert.equal(resultado.status, 'rascunho');
db.close();
});
test('escalateWeeklyLimits dobra o limite apos 7 dias rodando, respeitando o teto', () => {
const db = openDb(':memory:');
const campanha = createCampaign(db, campanhaBase({ limiteDiarioInicial: 10, limiteDiarioTeto: 15 }));
setCampaignStatus(db, campanha.id, 'rodando');
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-8 days') WHERE id = ?`).run(campanha.id);
const escaladas = escalateWeeklyLimits(db);
assert.equal(escaladas, 1);
const atualizada = getCampaign(db, campanha.id);
assert.equal(atualizada.limite_diario_atual, 15); // dobraria pra 20, mas o teto e 15
db.close();
});
test('escalateWeeklyLimits nao avanca campanha pausada nem antes de 7 dias', () => {
const db = openDb(':memory:');
const rodando = createCampaign(db, campanhaBase({ nome: 'Rodando ha pouco' }));
setCampaignStatus(db, rodando.id, 'rodando');
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-2 days') WHERE id = ?`).run(rodando.id);
const pausada = createCampaign(db, campanhaBase({ nome: 'Pausada' }));
setCampaignStatus(db, pausada.id, 'rodando');
setCampaignStatus(db, pausada.id, 'pausado', 'manual');
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-10 days') WHERE id = ?`).run(pausada.id);
const escaladas = escalateWeeklyLimits(db);
assert.equal(escaladas, 0);
db.close();
});
test('opt-out: addOptOut bloqueia, removeOptOut libera', () => {
const db = openDb(':memory:');
assert.equal(isOptedOut(db, '5511999990001'), false);
addOptOut(db, '5511999990001', 'pediu pra parar');
assert.equal(isOptedOut(db, '5511999990001'), true);
removeOptOut(db, '5511999990001');
assert.equal(isOptedOut(db, '5511999990001'), false);
db.close();
});
+201
View File
@@ -0,0 +1,201 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { openDb, createLeadInserter, setSetting } from '../src/db.mjs';
import { createCampaign, setCampaignStatus, addOptOut } from '../src/outreach/campaign-store.mjs';
import { generateCampaignQueue, listQueue, approveQueueRows } from '../src/outreach/campaign-queue.mjs';
import { runCampaignQueue } from '../src/outreach/campaign-worker.mjs';
// Testes de banco em memoria nao seedam settings.outreach.* (fica no fallback
// 'false'), entao por padrao o worker nao faria nada — precisa ligar
// explicitamente e abrir a janela de horario pro dia todo pra nao depender do
// horario real em que o teste roda.
function ativarWorker(db) {
setSetting(db, 'outreach.ativo', 'true');
setSetting(db, 'outreach.horaInicio', '00:00');
setSetting(db, 'outreach.horaFim', '23:59');
}
function inserirLead(db, dedup, whatsapp) {
const inserir = createLeadInserter(db);
inserir({
dedup_key: dedup, empresa: 'Empresa', legal_name: '', whatsapp, 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
});
}
function campanhaAprovada(db, quantidadeLeads = 1) {
for (let i = 0; i < quantidadeLeads; i += 1) {
inserirLead(db, `lead-${i}`, `551199999000${i}`);
}
const campanha = createCampaign(db, {
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }],
limiteDiarioInicial: 10
});
setCampaignStatus(db, campanha.id, 'rodando');
generateCampaignQueue(db, campanha.id);
const fila = listQueue(db, campanha.id, 'preview');
approveQueueRows(db, campanha.id, fila.map((r) => r.id));
return campanha;
}
function fakeZproClient(overrides = {}) {
const enviados = [];
return {
enviados,
sendText: async (args) => { enviados.push(args); return { data: { ticketId: 999 } }; },
...overrides
};
}
test('runCampaignQueue envia linhas aprovadas via sendText e marca sent', async () => {
const db = openDb(':memory:');
ativarWorker(db);
const campanha = campanhaAprovada(db);
const zproClient = fakeZproClient();
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.enviados, 1);
assert.equal(zproClient.enviados.length, 1);
assert.equal(zproClient.enviados[0].message, 'Ola Empresa');
const fila = listQueue(db, campanha.id, 'sent');
assert.equal(fila.length, 1);
assert.ok(fila[0].outreach_send_id);
const send = db.prepare('SELECT * FROM outreach_sends WHERE id = ?').get(fila[0].outreach_send_id);
assert.equal(send.status, 'enviado');
assert.equal(send.origem, 'automatico');
db.close();
});
test('runCampaignQueue registra erro quando o Z-PRO falha, sem travar o restante', async () => {
const db = openDb(':memory:');
ativarWorker(db);
const campanha = campanhaAprovada(db, 2);
let chamada = 0;
const zproClient = fakeZproClient({
sendText: async (args) => {
chamada += 1;
if (chamada === 1) throw new Error('Z-PRO falhou');
return { data: { ticketId: 1 } };
}
});
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.enviados, 1);
assert.equal(resumo.erros, 1);
const erro = listQueue(db, campanha.id, 'error')[0];
const send = db.prepare('SELECT * FROM outreach_sends WHERE id = ?').get(erro.outreach_send_id);
assert.equal(send.status, 'erro');
db.close();
});
test('runCampaignQueue respeita o limite diario da campanha', async () => {
const db = openDb(':memory:');
ativarWorker(db);
for (let i = 0; i < 3; i += 1) inserirLead(db, `l${i}`, `551199999000${i}`);
const campanha = createCampaign(db, {
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }],
limiteDiarioInicial: 1
});
setCampaignStatus(db, campanha.id, 'rodando');
generateCampaignQueue(db, campanha.id);
approveQueueRows(db, campanha.id, listQueue(db, campanha.id, 'preview').map((r) => r.id));
const zproClient = fakeZproClient();
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.enviados, 1);
assert.equal(zproClient.enviados.length, 1);
assert.equal(listQueue(db, campanha.id, 'approved').length, 2);
db.close();
});
test('runCampaignQueue pula e cancela linha de numero que entrou em opt-out depois da fila gerada', async () => {
const db = openDb(':memory:');
ativarWorker(db);
const campanha = campanhaAprovada(db);
addOptOut(db, '5511999990000', 'pediu pra parar');
db.prepare(`UPDATE outreach_campaign_queue SET numero = '5511999990000' WHERE campaign_id = ?`).run(campanha.id);
const zproClient = fakeZproClient();
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.pulados, 1);
assert.equal(zproClient.enviados.length, 0);
const cancelada = listQueue(db, campanha.id, 'cancelled')[0];
assert.equal(cancelada.cancel_reason, 'opt_out');
db.close();
});
test('runCampaignQueue so processa campanhas com status rodando', async () => {
const db = openDb(':memory:');
ativarWorker(db);
inserirLead(db, 'l1', '5511999990001');
const campanha = createCampaign(db, {
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }]
});
generateCampaignQueue(db, campanha.id);
approveQueueRows(db, campanha.id, listQueue(db, campanha.id, 'preview').map((r) => r.id));
// campanha continua em 'rascunho' — nunca foi iniciada
const zproClient = fakeZproClient();
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.enviados, 0);
assert.equal(zproClient.enviados.length, 0);
db.close();
});
test('runCampaignQueue interrompe a campanha quando a saude aciona auto-pausa', async () => {
const db = openDb(':memory:');
ativarWorker(db);
const campanha = campanhaAprovada(db, 10);
db.prepare(`UPDATE outreach_campaigns SET limite_diario_atual = 10 WHERE id = ?`).run(campanha.id);
let chamada = 0;
const zproClient = fakeZproClient({
sendText: async () => {
chamada += 1;
if (chamada <= 8) return { data: { ticketId: chamada } };
throw new Error('falha simulada');
}
});
await runCampaignQueue({ db, zproClient, sleepMs: 0 });
const atualizada = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
// 10 tentativas, 2 erros = 20% > 10% -> deveria pausar antes de esgotar a fila
assert.equal(atualizada.status, 'pausado');
db.close();
});
test('runCampaignQueue nao envia nada quando outreach.ativo esta desligado', async () => {
const db = openDb(':memory:');
// Nao chama ativarWorker: settings.outreach.ativo fica no fallback 'false'.
const campanha = campanhaAprovada(db);
const zproClient = fakeZproClient();
const resumo = await runCampaignQueue({ db, zproClient, sleepMs: 0 });
assert.equal(resumo.enviados, 0);
assert.equal(zproClient.enviados.length, 0);
assert.equal(listQueue(db, campanha.id, 'approved').length, 1);
db.close();
});
test('runCampaignQueue nao envia nada fora do horario comercial configurado', async () => {
const db = openDb(':memory:');
setSetting(db, 'outreach.ativo', 'true');
setSetting(db, 'outreach.horaInicio', '08:00');
setSetting(db, 'outreach.horaFim', '18:00');
const campanha = campanhaAprovada(db);
const zproClient = fakeZproClient();
// 03:00 (horario local), bem fora da janela 08:00-18:00 configurada acima.
const foraDoHorario = new Date();
foraDoHorario.setHours(3, 0, 0, 0);
const resumo = await runCampaignQueue({ db, zproClient, now: foraDoHorario, sleepMs: 0 });
assert.equal(resumo.enviados, 0);
assert.equal(zproClient.enviados.length, 0);
assert.equal(listQueue(db, campanha.id, 'approved').length, 1);
db.close();
});
+18
View File
@@ -126,3 +126,21 @@ test('seeds de segments, municipios e geo_cache', () => {
assert.equal(bbox.provider, 'config-legacy');
db.close();
});
test('migracao v8 cria as tabelas de campanha de outreach', () => {
const db = openDb(':memory:');
const tabelas = db.prepare(
`SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'outreach_%'`
).all().map((r) => r.name);
for (const nome of [
'outreach_campaigns', 'outreach_campaign_variants', 'outreach_campaign_queue',
'outreach_lead_states', 'outreach_stage_events', 'outreach_appointments', 'outreach_opt_out'
]) {
assert.ok(tabelas.includes(nome), `tabela ${nome} deveria existir`);
}
db.prepare(`INSERT INTO outreach_campaigns (nome, produto, segmentos_json) VALUES ('Teste', 'ERP', '["oficinas"]')`).run();
const campanha = db.prepare('SELECT * FROM outreach_campaigns').get();
assert.equal(campanha.status, 'rascunho');
assert.equal(campanha.limite_diario_atual, 10);
db.close();
});