diff --git a/src/outreach/campaign-queue.mjs b/src/outreach/campaign-queue.mjs new file mode 100644 index 0000000..67a2e87 --- /dev/null +++ b/src/outreach/campaign-queue.mjs @@ -0,0 +1,88 @@ +import { getCampaign } from './campaign-store.mjs'; +import { variantForLead, renderCampaignMessage } from './campaign-messages.mjs'; + +const ACTIVE_QUEUE_STATUSES = "'preview','approved','sent'"; + +function candidateQuery() { + return ` + SELECT leads.id, leads.empresa, leads.whatsapp, leads.segmento, leads.cidade, leads.uf, + leads.segmento_label, leads.prioridade, leads.score + FROM leads + WHERE leads.produto_principal = ? + AND leads.segmento = ? + AND leads.telefone_valido = 1 + AND leads.whatsapp != '' + AND NOT EXISTS ( + SELECT 1 FROM outreach_sends + WHERE outreach_sends.produto = ? AND outreach_sends.status = 'enviado' + AND (outreach_sends.lead_id = leads.id OR outreach_sends.numero = leads.whatsapp) + ) + AND NOT EXISTS ( + SELECT 1 FROM outreach_campaign_queue q + JOIN outreach_campaigns c ON c.id = q.campaign_id + WHERE q.lead_id = leads.id AND c.produto = ? AND q.status IN (${ACTIVE_QUEUE_STATUSES}) + ) + AND NOT EXISTS (SELECT 1 FROM outreach_opt_out WHERE numero = leads.whatsapp) + ORDER BY leads.prioridade ASC, leads.score DESC, leads.id ASC`; +} + +// Gera ate `quantidade` linhas novas de fila (status 'preview') para a +// campanha, buscando candidatos em todos os segmentos configurados. Nao e +// limitado ao limite diario de envio (isso so controla a VELOCIDADE de envio +// do worker) — o operador pode gerar e revisar um lote maior do que vai +// enviar hoje. +export function generateCampaignQueue(db, campaignId, { quantidade = 50 } = {}) { + const campaign = getCampaign(db, campaignId); + if (!campaign) throw new Error('campanha nao encontrada'); + if (!campaign.variants.length) throw new Error('campanha sem variantes ativas'); + if (!campaign.segmentos.length) throw new Error('campanha sem segmentos configurados'); + + const statement = db.prepare(candidateQuery()); + const candidatos = campaign.segmentos + .flatMap((segmento) => statement.all(campaign.produto, segmento, campaign.produto, campaign.produto)) + .sort((a, b) => a.prioridade - b.prioridade || b.score - a.score || a.id - b.id) + .slice(0, quantidade); + + const insert = db.prepare( + `INSERT INTO outreach_campaign_queue + (campaign_id, lead_id, variant_id, segmento, numero, rendered_message, status) + VALUES (?, ?, ?, ?, ?, ?, 'preview')` + ); + let adicionados = 0; + for (const lead of candidatos) { + const variant = variantForLead(campaign.variants, lead.id); + const renderedMessage = renderCampaignMessage(variant, lead); + insert.run(campaignId, lead.id, variant.id, lead.segmento, lead.whatsapp, renderedMessage); + adicionados += 1; + } + return { candidatosEncontrados: candidatos.length, adicionadosNaFila: adicionados }; +} + +export function listQueue(db, campaignId, status = null) { + if (status) { + return db.prepare( + 'SELECT * FROM outreach_campaign_queue WHERE campaign_id = ? AND status = ? ORDER BY id' + ).all(campaignId, status); + } + return db.prepare( + 'SELECT * FROM outreach_campaign_queue WHERE campaign_id = ? ORDER BY id' + ).all(campaignId); +} + +export function approveQueueRows(db, campaignId, queueIds) { + if (!queueIds.length) return 0; + const placeholders = queueIds.map(() => '?').join(', '); + return db.prepare( + `UPDATE outreach_campaign_queue SET status = 'approved', updated_at = datetime('now') + WHERE campaign_id = ? AND status = 'preview' AND id IN (${placeholders})` + ).run(campaignId, ...queueIds).changes; +} + +export function cancelQueueRows(db, campaignId, queueIds, reason = 'operador') { + if (!queueIds.length) return 0; + const placeholders = queueIds.map(() => '?').join(', '); + return db.prepare( + `UPDATE outreach_campaign_queue SET status = 'cancelled', cancel_reason = ?, updated_at = datetime('now') + WHERE campaign_id = ? AND status IN ('preview','approved') AND id IN (${placeholders})` + ).run(reason, campaignId, ...queueIds).changes; +} diff --git a/tests/campaign-queue.test.mjs b/tests/campaign-queue.test.mjs new file mode 100644 index 0000000..edf934a --- /dev/null +++ b/tests/campaign-queue.test.mjs @@ -0,0 +1,139 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { openDb, createLeadInserter } from '../src/db.mjs'; +import { createCampaign } from '../src/outreach/campaign-store.mjs'; +import { generateCampaignQueue, listQueue, approveQueueRows, cancelQueueRows } from '../src/outreach/campaign-queue.mjs'; + +function inserirLead(db, overrides = {}) { + const inserir = createLeadInserter(db); + const dedup = overrides.dedup_key || `lead-${Math.random()}`; + inserir({ + dedup_key: dedup, empresa: 'Oficina Teste', legal_name: '', whatsapp: '5511999990001', + ddd: '11', email: '', cidade: 'SP', uf: 'SP', segmento: 'oficinas_mecanicas', segmento_label: '', + interesse: '', cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]', origem: 'teste', + telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 80, classificacao: 'bom', + motivo_classificacao: '', score_qualidade: 80, score_comercial: 80, prioridade: 1, + produto_principal: 'ERP', produtos_json: '[]', capture_run_id: null, captured_at: null, exported_at: null, + ...overrides + }); + return db.prepare('SELECT * FROM leads WHERE dedup_key = ?').get(dedup); +} + +function campanhaComVariantes(db, overrides = {}) { + return createCampaign(db, { + nome: 'ERP oficinas', produto: 'ERP', segmentos: ['oficinas_mecanicas'], + variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }], + ...overrides + }); +} + +test('generateCampaignQueue cria linhas preview so para leads do produto/segmento certos', () => { + const db = openDb(':memory:'); + inserirLead(db, { dedup_key: 'l1', whatsapp: '5511999990001' }); + inserirLead(db, { dedup_key: 'l2', segmento: 'outro_segmento', whatsapp: '5511999990002' }); + const campanha = campanhaComVariantes(db); + + const resultado = generateCampaignQueue(db, campanha.id); + assert.equal(resultado.candidatosEncontrados, 1); + assert.equal(resultado.adicionadosNaFila, 1); + + const fila = listQueue(db, campanha.id); + assert.equal(fila.length, 1); + assert.equal(fila[0].status, 'preview'); + assert.equal(fila[0].rendered_message, 'Ola Oficina Teste'); + db.close(); +}); + +test('generateCampaignQueue exclui lead com envio ja enviado para o mesmo produto (por lead_id)', () => { + const db = openDb(':memory:'); + const lead = inserirLead(db); + db.prepare( + `INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'ERP', ?, 'oi', 'enviado')` + ).run(lead.id, lead.whatsapp); + const campanha = campanhaComVariantes(db); + + const resultado = generateCampaignQueue(db, campanha.id); + assert.equal(resultado.candidatosEncontrados, 0); + db.close(); +}); + +test('generateCampaignQueue exclui numero com envio manual ja enviado (sem lead_id) para o mesmo produto', () => { + const db = openDb(':memory:'); + inserirLead(db, { whatsapp: '5511999990009' }); + db.prepare( + `INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, origem) + VALUES (NULL, 'ERP', '5511999990009', 'oi', 'enviado', 'manual')` + ).run(); + const campanha = campanhaComVariantes(db); + + const resultado = generateCampaignQueue(db, campanha.id); + assert.equal(resultado.candidatosEncontrados, 0); + db.close(); +}); + +test('generateCampaignQueue exclui numero em opt-out', () => { + const db = openDb(':memory:'); + inserirLead(db, { whatsapp: '5511999990001' }); + db.prepare(`INSERT INTO outreach_opt_out (numero) VALUES ('5511999990001')`).run(); + const campanha = campanhaComVariantes(db); + + const resultado = generateCampaignQueue(db, campanha.id); + assert.equal(resultado.candidatosEncontrados, 0); + db.close(); +}); + +test('generateCampaignQueue nao duplica lead ja na fila (preview/approved/sent) de outra campanha do mesmo produto', () => { + const db = openDb(':memory:'); + inserirLead(db, { dedup_key: 'unico', whatsapp: '5511999990001' }); + const campanha1 = campanhaComVariantes(db, { nome: 'Campanha 1' }); + generateCampaignQueue(db, campanha1.id); + + const campanha2 = campanhaComVariantes(db, { nome: 'Campanha 2' }); + const resultado = generateCampaignQueue(db, campanha2.id); + assert.equal(resultado.candidatosEncontrados, 0); + db.close(); +}); + +test('generateCampaignQueue respeita o parametro quantidade', () => { + const db = openDb(':memory:'); + for (let i = 0; i < 5; i += 1) { + inserirLead(db, { dedup_key: `lead-${i}`, whatsapp: `551199999000${i}` }); + } + const campanha = campanhaComVariantes(db); + const resultado = generateCampaignQueue(db, campanha.id, { quantidade: 3 }); + assert.equal(resultado.adicionadosNaFila, 3); + db.close(); +}); + +test('approveQueueRows so aprova linhas em preview da campanha certa', () => { + const db = openDb(':memory:'); + inserirLead(db, { whatsapp: '5511999990001' }); + const campanha = campanhaComVariantes(db); + generateCampaignQueue(db, campanha.id); + const [linha] = listQueue(db, campanha.id); + + const total = approveQueueRows(db, campanha.id, [linha.id]); + assert.equal(total, 1); + assert.equal(listQueue(db, campanha.id, 'approved').length, 1); +}); + +test('cancelQueueRows cancela e registra motivo', () => { + const db = openDb(':memory:'); + inserirLead(db, { whatsapp: '5511999990001' }); + const campanha = campanhaComVariantes(db); + generateCampaignQueue(db, campanha.id); + const [linha] = listQueue(db, campanha.id); + + const total = cancelQueueRows(db, campanha.id, [linha.id], 'nao interessa'); + assert.equal(total, 1); + const cancelada = listQueue(db, campanha.id, 'cancelled')[0]; + assert.equal(cancelada.cancel_reason, 'nao interessa'); +}); + +test('approveQueueRows/cancelQueueRows com lista vazia nao lanca erro', () => { + const db = openDb(':memory:'); + const campanha = campanhaComVariantes(db); + assert.equal(approveQueueRows(db, campanha.id, []), 0); + assert.equal(cancelQueueRows(db, campanha.id, []), 0); + db.close(); +});