Fix opt-out correctness: estagio=opt_out now blocks the number, numbers normalized to digits
Marking a lead opt_out via /estagio only wrote outreach_lead_states/ outreach_stage_events and never called addOptOut, so the lead stayed fully eligible for future campaign sends. Also normalize numero with onlyDigits in POST/DELETE opt-out routes and in the estagio lookup, matching how every other number in the app (leads.whatsapp, queue.numero) is stored, so opt-out comparisons never silently miss due to formatting differences.
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user