diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..be20ffa --- /dev/null +++ b/package-lock.json @@ -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" + } + } + } +} diff --git a/public/css/app.css b/public/css/app.css index 481bc15..5761c1b 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -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)); diff --git a/public/index.html b/public/index.html index 79568da..dc3c01c 100644 --- a/public/index.html +++ b/public/index.html @@ -16,6 +16,7 @@ @@ -226,6 +227,85 @@
+ + +
+
+

Campanhas de outreach

+ +
+
+ + + + +
+

Nova campanha

+
+ + +
+
+ + +
+
+ +
+
+
+ +
+ +
+
+ + +
+

+
+ + +
+
+
+
+ @@ -480,6 +560,7 @@ + diff --git a/public/js/app.js b/public/js/app.js index 65c035d..b915b6f 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -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(); }); } diff --git a/public/js/campanhas.js b/public/js/campanhas.js new file mode 100644 index 0000000..e81dae7 --- /dev/null +++ b/public/js/campanhas.js @@ -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 ( 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 }; +})(); diff --git a/scripts/servidor.mjs b/scripts/servidor.mjs index 5cdb13a..871c293 100644 --- a/scripts/servidor.mjs +++ b/scripts/servidor.mjs @@ -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', () => { diff --git a/src/db.mjs b/src/db.mjs index 034367b..6ebf47a 100644 --- a/src/db.mjs +++ b/src/db.mjs @@ -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')) + ); ` ]; diff --git a/src/outreach/campaign-messages.mjs b/src/outreach/campaign-messages.mjs new file mode 100644 index 0000000..a77c5b0 --- /dev/null +++ b/src/outreach/campaign-messages.mjs @@ -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); +} diff --git a/src/outreach/campaign-metrics.mjs b/src/outreach/campaign-metrics.mjs new file mode 100644 index 0000000..6357512 --- /dev/null +++ b/src/outreach/campaign-metrics.mjs @@ -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 + })); +} diff --git a/src/outreach/campaign-queue.mjs b/src/outreach/campaign-queue.mjs new file mode 100644 index 0000000..e648d14 --- /dev/null +++ b/src/outreach/campaign-queue.mjs @@ -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; +} diff --git a/src/outreach/campaign-scheduler.mjs b/src/outreach/campaign-scheduler.mjs new file mode 100644 index 0000000..ed0407e --- /dev/null +++ b/src/outreach/campaign-scheduler.mjs @@ -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); + }; +} diff --git a/src/outreach/campaign-store.mjs b/src/outreach/campaign-store.mjs new file mode 100644 index 0000000..799c0bf --- /dev/null +++ b/src/outreach/campaign-store.mjs @@ -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); +} diff --git a/src/outreach/campaign-worker.mjs b/src/outreach/campaign-worker.mjs new file mode 100644 index 0000000..cf7b4eb --- /dev/null +++ b/src/outreach/campaign-worker.mjs @@ -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; +} diff --git a/src/server/index.mjs b/src/server/index.mjs index b50a065..37a4e77 100644 --- a/src/server/index.mjs +++ b/src/server/index.mjs @@ -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 }) => { diff --git a/src/server/routes/campaigns.mjs b/src/server/routes/campaigns.mjs new file mode 100644 index 0000000..8ccc8f0 --- /dev/null +++ b/src/server/routes/campaigns.mjs @@ -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 }); + }); +} diff --git a/tests/campaign-messages.test.mjs b/tests/campaign-messages.test.mjs new file mode 100644 index 0000000..fad8e65 --- /dev/null +++ b/tests/campaign-messages.test.mjs @@ -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'); +}); diff --git a/tests/campaign-metrics.test.mjs b/tests/campaign-metrics.test.mjs new file mode 100644 index 0000000..cae20ed --- /dev/null +++ b/tests/campaign-metrics.test.mjs @@ -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(); +}); diff --git a/tests/campaign-queue.test.mjs b/tests/campaign-queue.test.mjs new file mode 100644 index 0000000..e2789b9 --- /dev/null +++ b/tests/campaign-queue.test.mjs @@ -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(); +}); diff --git a/tests/campaign-routes.test.mjs b/tests/campaign-routes.test.mjs new file mode 100644 index 0000000..f252983 --- /dev/null +++ b/tests/campaign-routes.test.mjs @@ -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(); +}); diff --git a/tests/campaign-scheduler.test.mjs b/tests/campaign-scheduler.test.mjs new file mode 100644 index 0000000..544cec3 --- /dev/null +++ b/tests/campaign-scheduler.test.mjs @@ -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(); +}); diff --git a/tests/campaign-store.test.mjs b/tests/campaign-store.test.mjs new file mode 100644 index 0000000..3de6f56 --- /dev/null +++ b/tests/campaign-store.test.mjs @@ -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(); +}); diff --git a/tests/campaign-worker.test.mjs b/tests/campaign-worker.test.mjs new file mode 100644 index 0000000..8ae5f02 --- /dev/null +++ b/tests/campaign-worker.test.mjs @@ -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(); +}); diff --git a/tests/db.test.mjs b/tests/db.test.mjs index 17b28c1..c29e1cd 100644 --- a/tests/db.test.mjs +++ b/tests/db.test.mjs @@ -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(); +});