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:
Generated
+15
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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
@@ -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'))
|
||||
);
|
||||
`
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user