Add campaign HTTP routes
This commit is contained in:
@@ -10,6 +10,7 @@ import { registerLeadRoutes } from './routes/leads.mjs';
|
||||
import { registerExportRoutes } from './routes/exports.mjs';
|
||||
import { registerAuthRoutes } from './routes/auth.mjs';
|
||||
import { registerOutreachRoutes } from './routes/outreach.mjs';
|
||||
import { registerCampaignRoutes } from './routes/campaigns.mjs';
|
||||
import { getCookie, getSession, SESSION_COOKIE } from './session.mjs';
|
||||
|
||||
const PUBLIC_API_PATHS = new Set(['/api/auth/login', '/api/health']);
|
||||
@@ -55,6 +56,7 @@ export function createAppServer({ db, jobs, log = () => {} }) {
|
||||
registerLeadRoutes(router, deps);
|
||||
registerExportRoutes(router, deps);
|
||||
registerOutreachRoutes(router, deps);
|
||||
registerCampaignRoutes(router, deps);
|
||||
|
||||
// SSE de progresso dos jobs (rota especial: gerencia a resposta manualmente)
|
||||
router.add('GET', '/api/jobs/:jobId/events', async (req, res, { params }) => {
|
||||
|
||||
@@ -0,0 +1,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 });
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user