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..822a2b2 --- /dev/null +++ b/src/server/routes/campaigns.mjs @@ -0,0 +1,150 @@ +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'; + +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))); + }); + + 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); + 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 = String(body?.numero || '').trim(); + 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, params.numero); + json(200, { ok: true }); + }); +} diff --git a/tests/campaign-routes.test.mjs b/tests/campaign-routes.test.mjs new file mode 100644 index 0000000..817a7b0 --- /dev/null +++ b/tests/campaign-routes.test.mjs @@ -0,0 +1,141 @@ +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(); +});