diff --git a/src/outreach/campaign-metrics.mjs b/src/outreach/campaign-metrics.mjs index 567472a..f6dc23d 100644 --- a/src/outreach/campaign-metrics.mjs +++ b/src/outreach/campaign-metrics.mjs @@ -200,10 +200,16 @@ export function evaluateCampaignHealth(db, campaignId, now = new Date()) { const todayKey = dateKey(now); + // Junta pelo vinculo real da fila (outreach_send_id), nao por + // lead_id+campaign_id: o catalogo de campanhas de hoje usa segmentos + // mutuamente exclusivos, entao um lead nunca esta em duas campanhas ao + // mesmo tempo — mas se isso mudar, lead_id+campaign_id contaria um envio + // automatico de OUTRA campanha (mesmo lead, historico anterior) nas + // metricas desta. const attemptsToday = Number(db.prepare( `SELECT COUNT(DISTINCT s.id) AS total FROM outreach_sends s - JOIN outreach_campaign_queue q ON q.lead_id = s.lead_id AND q.campaign_id = ? + JOIN outreach_campaign_queue q ON q.outreach_send_id = s.id AND q.campaign_id = ? WHERE s.origem = 'automatico' AND s.status IN ('aceito', 'erro') AND date(s.sent_at, 'localtime') = ?` @@ -213,7 +219,7 @@ export function evaluateCampaignHealth(db, campaignId, now = new Date()) { const failureCount = Number(db.prepare( `SELECT COUNT(DISTINCT s.id) AS total FROM outreach_sends s - JOIN outreach_campaign_queue q ON q.lead_id = s.lead_id AND q.campaign_id = ? + JOIN outreach_campaign_queue q ON q.outreach_send_id = s.id AND q.campaign_id = ? WHERE s.origem = 'automatico' AND s.status = 'erro' AND date(s.sent_at, 'localtime') = ?` diff --git a/tests/campaign-metrics.test.mjs b/tests/campaign-metrics.test.mjs index aca1d89..5111ae5 100644 --- a/tests/campaign-metrics.test.mjs +++ b/tests/campaign-metrics.test.mjs @@ -289,16 +289,17 @@ test('evaluateCampaignHealth pausa por high_failure_rate quando > 10%', async () const now = new Date('2026-07-29T12:00:00.000Z'); db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id); const leads = []; + const queueIds = []; for (let i = 0; i < 11; i++) { const lid = addLead(db, 100 + i); leads.push(lid); - addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 100 + i }); + queueIds.push(addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 100 + i })); } for (let i = 0; i < 8; i++) { - addSend(db, { leadId: leads[i], status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z' }); + addSend(db, { leadId: leads[i], status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z', queueId: queueIds[i] }); } for (let i = 8; i < 10; i++) { - addSend(db, { leadId: leads[i], status: 'erro', sentAt: '2026-07-29T12:00:00.000Z' }); + addSend(db, { leadId: leads[i], status: 'erro', sentAt: '2026-07-29T12:00:00.000Z', queueId: queueIds[i] }); } const result = evaluateCampaignHealth(db, campaign.id, now); @@ -313,15 +314,16 @@ test('evaluateCampaignHealth NAO pausa por failure rate exatamente 10%', async ( const now = new Date('2026-07-29T12:00:00.000Z'); db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id); const leads = []; + const queueIds = []; for (let i = 0; i < 10; i++) { const lid = addLead(db, 200 + i); leads.push(lid); - addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 200 + i }); + queueIds.push(addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 200 + i })); } for (let i = 0; i < 9; i++) { - addSend(db, { leadId: leads[i], status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z' }); + addSend(db, { leadId: leads[i], status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z', queueId: queueIds[i] }); } - addSend(db, { leadId: leads[9], status: 'erro', sentAt: '2026-07-29T12:00:00.000Z' }); // 1/10 = 10% → not > 10% + addSend(db, { leadId: leads[9], status: 'erro', sentAt: '2026-07-29T12:00:00.000Z', queueId: queueIds[9] }); // 1/10 = 10% → not > 10% const result = evaluateCampaignHealth(db, campaign.id, now); assert.equal(result.pause, false); @@ -537,6 +539,44 @@ test('F1: campaignMetrics sends nao contaminam entre campanhas (usa outreach_sen db.close(); }); +// Mesmo bug do F1, so que em evaluateCampaignHealth (que tem suas proprias +// consultas de attemptsToday/failureCount, separadas de campaignMetrics): +// antes, o join usava lead_id+campaign_id, entao um envio automatico com +// erro em OUTRA campanha (mesmo lead) podia contar como falha desta +// campanha e dispara-la a pausar por high_failure_rate indevidamente. +test('F1b: evaluateCampaignHealth nao conta falha de envio de outra campanha (mesmo lead)', async () => { + const { evaluateCampaignHealth } = await metricsModule(); + const { db, campaign } = setup(); + const now = new Date('2026-07-29T12:00:00.000Z'); + db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign.id); + + const campaign2 = db.prepare('SELECT * FROM outreach_campaigns ORDER BY id LIMIT 1 OFFSET 1').get(); + db.prepare("UPDATE outreach_campaigns SET status = 'running' WHERE id = ?").run(campaign2.id); + + const leads = []; + const queueIds = []; + for (let i = 0; i < 10; i++) { + const lid = addLead(db, 8100 + i); + leads.push(lid); + queueIds.push(addQueue(db, campaign, { leadId: lid, status: 'sent', suffix: 8100 + i })); + } + for (let i = 0; i < 10; i++) { + addSend(db, { leadId: leads[i], status: 'aceito', sentAt: '2026-07-29T12:00:00.000Z', queueId: queueIds[i] }); + } + + // Mesmos leads, agora com uma fila (e envio com erro) numa SEGUNDA campanha. + // Sem o vinculo por outreach_send_id, essas falhas contaminariam a campanha 1. + for (let i = 0; i < 5; i++) { + const qid2 = addQueue(db, campaign2, { leadId: leads[i], status: 'sent', suffix: 8200 + i }); + addSend(db, { leadId: leads[i], status: 'erro', sentAt: '2026-07-29T12:00:00.000Z', queueId: qid2 }); + } + + const result = evaluateCampaignHealth(db, campaign.id, now); + assert.equal(result.pause, false, JSON.stringify(result)); + assert.equal(result.reason, null); + db.close(); +}); + // --- F2 regression: negative/opt-out count uses COUNT(DISTINCT) --- test('F2: evaluateCampaignHealth negative count usa COUNT(DISTINCT) para evitar cartesian', async () => {