diff --git a/src/server/routes/campaigns.mjs b/src/server/routes/campaigns.mjs index 822a2b2..8ccc8f0 100644 --- a/src/server/routes/campaigns.mjs +++ b/src/server/routes/campaigns.mjs @@ -4,6 +4,7 @@ import { } 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 }) => { @@ -91,6 +92,9 @@ export function registerCampaignRoutes(router, { db }) { 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))); }); @@ -110,6 +114,14 @@ export function registerCampaignRoutes(router, { db }) { 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 }); }); @@ -137,14 +149,14 @@ export function registerCampaignRoutes(router, { db }) { }); router.add('POST', '/api/campaigns/opt-out', async (req, res, { json, body }) => { - const numero = String(body?.numero || '').trim(); + 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, params.numero); + removeOptOut(db, onlyDigits(params.numero)); json(200, { ok: true }); }); } diff --git a/tests/campaign-routes.test.mjs b/tests/campaign-routes.test.mjs index 817a7b0..f252983 100644 --- a/tests/campaign-routes.test.mjs +++ b/tests/campaign-routes.test.mjs @@ -139,3 +139,79 @@ test('POST /api/campaigns/opt-out bloqueia e DELETE libera', async () => { 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(); +});