# Campanhas de Outreach (GTI Comercial) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Portar a metodologia de campanhas de outreach da Máquina Comercial Pessoal (fila com preview/aprovação, variantes A/B/C, escalonamento semanal de limite, pausa automática por saúde, classificação de estágio, agendamento) para a GTI Comercial, adaptada ao envio via Z-PRO (canal não-oficial, texto livre) e a um catálogo dinâmico (operador escolhe produto/segmentos/mensagens pela tela, em vez de um catálogo fixo em código). **Architecture:** Novo diretório `src/outreach/campaign-*.mjs`, paralelo aos módulos existentes `run.mjs`/`scheduler.mjs` (nenhum dos dois é modificado). Uma migração de schema nova (v8) adiciona as tabelas de campanha. Uma rota nova (`src/server/routes/campaigns.mjs`) expõe tudo via HTTP. Uma aba nova no front-end (`public/js/campanhas.js`) consome essas rotas. O envio em si usa `zproClient.sendText` (síncrono: ou resolve `enviado` ou lança erro, sem estado intermediário de "reservado" — diferente da Pessoal, que precisa desse estado por causa da entrega assíncrona incerta do Baileys; aqui não é necessário). **Tech Stack:** Node.js (`node:sqlite` via `DatabaseSync`), `node:test`/`node:assert`, HTML/CSS/JS vanilla (sem framework), mesmo padrão de módulos IIFE do front-end existente (`Tratamento`). ## Global Constraints - Não modificar `src/outreach/run.mjs`, `src/outreach/scheduler.mjs`, `src/server/routes/outreach.mjs`, `public/js/app.js` (além do ponto de wiring da nova aba), nem qualquer arquivo com trabalho não commitado alheio a esta feature — o repositório tem WIP substancial em andamento que não deve ser tocado ou conflitado. - Vocabulário de status segue o idioma português já usado em todo o resto do schema da GTI Comercial (`enviado`/`erro` em `outreach_sends`, não `aceito`/`pendente`/`desconhecido` como na Pessoal). Campanha: `rascunho|rodando|pausado|concluido`. Fila: `preview|approved|cancelled|sent|error`. - Variantes A/B/C só existem no canal não-oficial (`zproClient.sendText`) — o canal oficial (WABA) não aceita texto livre, então não há como variar template. - `outreach_sends` recebe `origem='automatico'` para envios de campanha (mesma convenção do `run.mjs`, não um valor `'campanha'` novo) — a atribuição de um envio a uma campanha é feita pelo JOIN via `outreach_campaign_queue.outreach_send_id`, não por um valor de `origem` dedicado. - Prevenção de contato duplicado com o `runOutreachBatch` existente: um lead com `outreach_sends.status='enviado'` para o mesmo produto (seja por `lead_id` ou por `numero`, cobrindo tanto envio automático quanto manual) nunca entra na fila de campanha para aquele produto. - Cada task abaixo tem teste próprio (`node --test tests/.test.mjs`) e termina com commit. --- ### Task 1: Migração de schema (v8) — tabelas de campanha **Files:** - Modify: `src/db.mjs` (array `MIGRATIONS`, adicionar 8º elemento) - Test: `tests/db.test.mjs` (adicionar um teste ao arquivo existente) **Interfaces:** - Produces: tabelas `outreach_campaigns`, `outreach_campaign_variants`, `outreach_campaign_queue`, `outreach_lead_states`, `outreach_stage_events`, `outreach_appointments`, `outreach_opt_out`, todas acessíveis via `db.prepare(...)` após `openDb()`/`migrate()`. - [ ] **Step 1: Escrever o teste que falha** Adicionar ao final de `tests/db.test.mjs`: ```js test('migracao v8 cria as tabelas de campanha de outreach', () => { const db = openDb(':memory:'); const tabelas = db.prepare( `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'outreach_%'` ).all().map((r) => r.name); for (const nome of [ 'outreach_campaigns', 'outreach_campaign_variants', 'outreach_campaign_queue', 'outreach_lead_states', 'outreach_stage_events', 'outreach_appointments', 'outreach_opt_out' ]) { assert.ok(tabelas.includes(nome), `tabela ${nome} deveria existir`); } db.prepare(`INSERT INTO outreach_campaigns (nome, produto, segmentos_json) VALUES ('Teste', 'ERP', '["oficinas"]')`).run(); const campanha = db.prepare('SELECT * FROM outreach_campaigns').get(); assert.equal(campanha.status, 'rascunho'); assert.equal(campanha.limite_diario_atual, 10); db.close(); }); ``` - [ ] **Step 2: Rodar e confirmar que falha** Run: `node --test tests/db.test.mjs` Expected: FAIL — `no such table: outreach_campaigns` - [ ] **Step 3: Adicionar a migração v8** Em `src/db.mjs`, adicionar como último elemento do array `MIGRATIONS` (depois do comentário `// v7 — ...` e sua string, antes do `];` que fecha o array): ```js // v8 — campanhas de outreach com catalogo dinamico (a Comercial tem 57 // segmentos e 4 produtos, catalogo fixo em codigo nao cabe nessa escala: // o operador cria a campanha pela tela). Roda ao lado do runOutreachBatch // existente (run.mjs) sem substitui-lo. outreach_opt_out e novo aqui (nao // existia antes): uma fila que recontata o mesmo lead ao longo de semanas // precisa checar quem ja pediu pra parar. ` CREATE TABLE outreach_campaigns ( id INTEGER PRIMARY KEY, nome TEXT NOT NULL, produto TEXT NOT NULL, segmentos_json TEXT NOT NULL DEFAULT '[]', status TEXT NOT NULL DEFAULT 'rascunho', pause_reason TEXT, limite_diario_atual INTEGER NOT NULL DEFAULT 10, limite_diario_teto INTEGER NOT NULL DEFAULT 80, semana_iniciada_em TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE outreach_campaign_variants ( id INTEGER PRIMARY KEY, campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id), code TEXT NOT NULL, mensagem TEXT NOT NULL, ativo INTEGER NOT NULL DEFAULT 1 ); CREATE INDEX idx_campaign_variants_campaign ON outreach_campaign_variants(campaign_id); CREATE TABLE outreach_campaign_queue ( id INTEGER PRIMARY KEY, campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id), lead_id INTEGER NOT NULL REFERENCES leads(id), variant_id INTEGER NOT NULL REFERENCES outreach_campaign_variants(id), segmento TEXT NOT NULL DEFAULT '', numero TEXT NOT NULL, rendered_message TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'preview', cancel_reason TEXT, outreach_send_id INTEGER REFERENCES outreach_sends(id), created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_campaign_queue_campaign_status ON outreach_campaign_queue(campaign_id, status); CREATE INDEX idx_campaign_queue_lead ON outreach_campaign_queue(lead_id); CREATE TABLE outreach_lead_states ( lead_id INTEGER PRIMARY KEY REFERENCES leads(id), stage TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE outreach_stage_events ( id INTEGER PRIMARY KEY, lead_id INTEGER NOT NULL REFERENCES leads(id), campaign_id INTEGER REFERENCES outreach_campaigns(id), stage TEXT NOT NULL, note TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_stage_events_lead ON outreach_stage_events(lead_id); CREATE TABLE outreach_appointments ( id INTEGER PRIMARY KEY, lead_id INTEGER NOT NULL REFERENCES leads(id), kind TEXT NOT NULL, scheduled_at TEXT, note TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX idx_appointments_lead ON outreach_appointments(lead_id); CREATE TABLE outreach_opt_out ( numero TEXT PRIMARY KEY, motivo TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); ` ``` - [ ] **Step 4: Rodar e confirmar que passa** Run: `node --test tests/db.test.mjs` Expected: PASS - [ ] **Step 5: Rodar a suite inteira (garantir que a migracao nao quebrou nada)** Run: `node --test tests/*.test.mjs` Expected: todos os testes existentes continuam passando - [ ] **Step 6: Commit** ```bash git add src/db.mjs tests/db.test.mjs git commit -m "Add campaign outreach schema (migration v8)" ``` --- ### Task 2: `campaign-store.mjs` — CRUD de campanha, variantes, opt-out, escalonamento **Files:** - Create: `src/outreach/campaign-store.mjs` - Test: `tests/campaign-store.test.mjs` **Interfaces:** - Consumes: `transaction(db, fn)` de `../db.mjs` (já existe). - Produces: `createCampaign(db, {nome, produto, segmentos, variantes, limiteDiarioInicial, limiteDiarioTeto}) -> campanha`, `getCampaign(db, id) -> campanha|null` (com `.segmentos` array e `.variants` array), `listCampaigns(db) -> campanha[]`, `listVariants(db, campaignId) -> variant[]`, `setCampaignStatus(db, campaignId, status, pauseReason) -> campanha`, `resumeCampaign(db, campaignId) -> campanha`, `escalateWeeklyLimits(db, now) -> number`, `isOptedOut(db, numero) -> boolean`, `addOptOut(db, numero, motivo) -> void`, `removeOptOut(db, numero) -> void`. Essas assinaturas são usadas por `campaign-queue.mjs`, `campaign-worker.mjs` e `routes/campaigns.mjs` nas próximas tasks. - [ ] **Step 1: Escrever os testes que falham** Criar `tests/campaign-store.test.mjs`: ```js import test from 'node:test'; import assert from 'node:assert/strict'; import { openDb } from '../src/db.mjs'; import { createCampaign, getCampaign, listCampaigns, setCampaignStatus, resumeCampaign, escalateWeeklyLimits, isOptedOut, addOptOut, removeOptOut } from '../src/outreach/campaign-store.mjs'; function campanhaBase(overrides = {}) { return { nome: 'ERP oficinas', produto: 'ERP', segmentos: ['oficinas_mecanicas', 'autopecas'], variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }, { code: 'B', mensagem: 'Oi {empresa}' }], ...overrides }; } test('createCampaign grava campanha, segmentos e variantes', () => { const db = openDb(':memory:'); const campanha = createCampaign(db, campanhaBase()); assert.equal(campanha.status, 'rascunho'); assert.equal(campanha.produto, 'ERP'); assert.deepEqual(campanha.segmentos, ['oficinas_mecanicas', 'autopecas']); assert.equal(campanha.variants.length, 2); assert.equal(campanha.variants[0].code, 'A'); db.close(); }); test('listCampaigns retorna mais recente primeiro', () => { const db = openDb(':memory:'); createCampaign(db, campanhaBase({ nome: 'Primeira' })); createCampaign(db, campanhaBase({ nome: 'Segunda' })); const campanhas = listCampaigns(db); assert.equal(campanhas.length, 2); assert.equal(campanhas[0].nome, 'Segunda'); db.close(); }); test('setCampaignStatus pausa e resumeCampaign retoma limpando o motivo', () => { const db = openDb(':memory:'); const campanha = createCampaign(db, campanhaBase()); setCampaignStatus(db, campanha.id, 'rodando'); const pausada = setCampaignStatus(db, campanha.id, 'pausado', 'high_failure_rate'); assert.equal(pausada.status, 'pausado'); assert.equal(pausada.pause_reason, 'high_failure_rate'); const retomada = resumeCampaign(db, campanha.id); assert.equal(retomada.status, 'rodando'); assert.equal(retomada.pause_reason, null); db.close(); }); test('resumeCampaign nao faz nada se a campanha nao estiver pausada', () => { const db = openDb(':memory:'); const campanha = createCampaign(db, campanhaBase()); const resultado = resumeCampaign(db, campanha.id); assert.equal(resultado.status, 'rascunho'); db.close(); }); test('escalateWeeklyLimits dobra o limite apos 7 dias rodando, respeitando o teto', () => { const db = openDb(':memory:'); const campanha = createCampaign(db, campanhaBase({ limiteDiarioInicial: 10, limiteDiarioTeto: 15 })); setCampaignStatus(db, campanha.id, 'rodando'); db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-8 days') WHERE id = ?`).run(campanha.id); const escaladas = escalateWeeklyLimits(db); assert.equal(escaladas, 1); const atualizada = getCampaign(db, campanha.id); assert.equal(atualizada.limite_diario_atual, 15); // dobraria pra 20, mas o teto e 15 db.close(); }); test('escalateWeeklyLimits nao avanca campanha pausada nem antes de 7 dias', () => { const db = openDb(':memory:'); const rodando = createCampaign(db, campanhaBase({ nome: 'Rodando ha pouco' })); setCampaignStatus(db, rodando.id, 'rodando'); db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-2 days') WHERE id = ?`).run(rodando.id); const pausada = createCampaign(db, campanhaBase({ nome: 'Pausada' })); setCampaignStatus(db, pausada.id, 'rodando'); setCampaignStatus(db, pausada.id, 'pausado', 'manual'); db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-10 days') WHERE id = ?`).run(pausada.id); const escaladas = escalateWeeklyLimits(db); assert.equal(escaladas, 0); db.close(); }); test('opt-out: addOptOut bloqueia, removeOptOut libera', () => { const db = openDb(':memory:'); assert.equal(isOptedOut(db, '5511999990001'), false); addOptOut(db, '5511999990001', 'pediu pra parar'); assert.equal(isOptedOut(db, '5511999990001'), true); removeOptOut(db, '5511999990001'); assert.equal(isOptedOut(db, '5511999990001'), false); db.close(); }); ``` - [ ] **Step 2: Rodar e confirmar que falha** Run: `node --test tests/campaign-store.test.mjs` Expected: FAIL — `Cannot find module '../src/outreach/campaign-store.mjs'` - [ ] **Step 3: Implementar** Criar `src/outreach/campaign-store.mjs`: ```js import { transaction } from '../db.mjs'; export function createCampaign(db, { nome, produto, segmentos, variantes, limiteDiarioInicial = 10, limiteDiarioTeto = 80 }) { return transaction(db, () => { const result = db.prepare( `INSERT INTO outreach_campaigns (nome, produto, segmentos_json, limite_diario_atual, limite_diario_teto) VALUES (?, ?, ?, ?, ?)` ).run(nome, produto, JSON.stringify(segmentos || []), limiteDiarioInicial, limiteDiarioTeto); const campaignId = Number(result.lastInsertRowid); const insertVariant = db.prepare( `INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (?, ?, ?)` ); for (const variante of variantes) { insertVariant.run(campaignId, variante.code, variante.mensagem); } return getCampaign(db, campaignId); }); } export function getCampaign(db, id) { const row = db.prepare('SELECT * FROM outreach_campaigns WHERE id = ?').get(id); if (!row) return null; return { ...row, segmentos: JSON.parse(row.segmentos_json || '[]'), variants: listVariants(db, id) }; } export function listCampaigns(db) { return db.prepare('SELECT * FROM outreach_campaigns ORDER BY id DESC').all() .map((row) => ({ ...row, segmentos: JSON.parse(row.segmentos_json || '[]') })); } export function listVariants(db, campaignId) { return db.prepare( 'SELECT * FROM outreach_campaign_variants WHERE campaign_id = ? AND ativo = 1 ORDER BY code' ).all(campaignId); } // status: 'rascunho' -> 'rodando' -> 'pausado' <-> 'rodando' -> 'concluido'. // pause_reason so fica setado quando o novo status e 'pausado'. export function setCampaignStatus(db, campaignId, status, pauseReason = null) { db.prepare( `UPDATE outreach_campaigns SET status = ?, pause_reason = ?, updated_at = datetime('now') WHERE id = ?` ).run(status, status === 'pausado' ? pauseReason : null, campaignId); return getCampaign(db, campaignId); } // So avanca campanha que estiver de fato pausada — retomar uma campanha em // rascunho, por exemplo, deveria usar setCampaignStatus(db, id, 'rodando'). export function resumeCampaign(db, campaignId) { db.prepare( `UPDATE outreach_campaigns SET status = 'rodando', pause_reason = NULL, updated_at = datetime('now') WHERE id = ? AND status = 'pausado'` ).run(campaignId); return getCampaign(db, campaignId); } // Escalonamento semanal: dobra o limite diario a cada 7 dias corridos rodando // sem pausa desde semana_iniciada_em, ate o teto da campanha. So campanhas com // status='rodando' avancam — uma pausada fica congelada ate o operador retomar. export function escalateWeeklyLimits(db, now = new Date()) { const campaigns = db.prepare( `SELECT id, limite_diario_atual, limite_diario_teto, semana_iniciada_em FROM outreach_campaigns WHERE status = 'rodando'` ).all(); let escaladas = 0; for (const campaign of campaigns) { if (!campaign.semana_iniciada_em) continue; const dias = (now.getTime() - new Date(campaign.semana_iniciada_em.replace(' ', 'T') + 'Z').getTime()) / 86400000; if (dias < 7) continue; const novoLimite = Math.min(campaign.limite_diario_teto, campaign.limite_diario_atual * 2); if (novoLimite === campaign.limite_diario_atual) continue; db.prepare( `UPDATE outreach_campaigns SET limite_diario_atual = ?, semana_iniciada_em = datetime('now'), updated_at = datetime('now') WHERE id = ?` ).run(novoLimite, campaign.id); escaladas += 1; } return escaladas; } export function isOptedOut(db, numero) { return Boolean(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get(numero)); } export function addOptOut(db, numero, motivo = null) { db.prepare( `INSERT INTO outreach_opt_out (numero, motivo) VALUES (?, ?) ON CONFLICT(numero) DO UPDATE SET motivo = excluded.motivo` ).run(numero, motivo); } export function removeOptOut(db, numero) { db.prepare('DELETE FROM outreach_opt_out WHERE numero = ?').run(numero); } ``` Nota sobre `escalateWeeklyLimits`: `datetime('now')` do SQLite grava `"YYYY-MM-DD HH:MM:SS"` (sem `T`/`Z`); `.replace(' ', 'T') + 'Z'` converte pra um formato que `new Date(...)` do JS interpreta corretamente como UTC. - [ ] **Step 4: Rodar e confirmar que passa** Run: `node --test tests/campaign-store.test.mjs` Expected: PASS (7 testes) - [ ] **Step 5: Commit** ```bash git add src/outreach/campaign-store.mjs tests/campaign-store.test.mjs git commit -m "Add campaign-store: CRUD, opt-out and weekly limit escalation" ``` --- ### Task 3: `campaign-messages.mjs` — seleção determinística de variante + render **Files:** - Create: `src/outreach/campaign-messages.mjs` - Test: `tests/campaign-messages.test.mjs` **Interfaces:** - Consumes: `renderTemplate(mensagem, lead)` de `./templates.mjs` (já existe, faz substituição de `{empresa}`/`{cidade}`/`{produto}`/`{segmento}`). - Produces: `variantForLead(variants, leadId) -> variant`, `renderCampaignMessage(variant, lead) -> string`. Usadas por `campaign-queue.mjs` (Task 4). - [ ] **Step 1: Escrever o teste que falha** Criar `tests/campaign-messages.test.mjs`: ```js import test from 'node:test'; import assert from 'node:assert/strict'; import { variantForLead, renderCampaignMessage } from '../src/outreach/campaign-messages.mjs'; const variants = [{ id: 1, code: 'A', mensagem: 'Ola A' }, { id: 2, code: 'B', mensagem: 'Ola B' }, { id: 3, code: 'C', mensagem: 'Ola C' }]; test('variantForLead e deterministico (mesmo lead sempre cai na mesma variante)', () => { const primeira = variantForLead(variants, 42); const segunda = variantForLead(variants, 42); assert.equal(primeira.code, segunda.code); }); test('variantForLead distribui entre variantes diferentes conforme o id', () => { assert.equal(variantForLead(variants, 0).code, 'A'); assert.equal(variantForLead(variants, 1).code, 'B'); assert.equal(variantForLead(variants, 2).code, 'C'); assert.equal(variantForLead(variants, 3).code, 'A'); }); test('variantForLead lanca erro sem variantes ativas', () => { assert.throws(() => variantForLead([], 1), /sem variantes/); }); test('renderCampaignMessage substitui variaveis do lead', () => { const texto = renderCampaignMessage({ mensagem: 'Ola {empresa}, sobre {produto}' }, { empresa: 'Oficina do Ze', produto: 'ERP' }); assert.equal(texto, 'Ola Oficina do Ze, sobre ERP'); }); ``` - [ ] **Step 2: Rodar e confirmar que falha** Run: `node --test tests/campaign-messages.test.mjs` Expected: FAIL — módulo não existe - [ ] **Step 3: Implementar** Criar `src/outreach/campaign-messages.mjs`: ```js import { renderTemplate } from './templates.mjs'; // Distribuicao deterministica por lead_id % variantes.length: o mesmo lead // sempre cai na mesma variante mesmo que a fila seja regenerada depois, sem // precisar guardar nenhum estado extra so pra isso. export function variantForLead(variants, leadId) { if (!variants.length) throw new Error('campanha sem variantes ativas'); const index = Number(leadId) % variants.length; return variants[index]; } export function renderCampaignMessage(variant, lead) { return renderTemplate(variant.mensagem, lead); } ``` - [ ] **Step 4: Rodar e confirmar que passa** Run: `node --test tests/campaign-messages.test.mjs` Expected: PASS (4 testes) - [ ] **Step 5: Commit** ```bash git add src/outreach/campaign-messages.mjs tests/campaign-messages.test.mjs git commit -m "Add deterministic variant selection and message rendering" ``` --- ### Task 4: `campaign-queue.mjs` — seleção de candidatos, geração de fila, aprovação **Files:** - Create: `src/outreach/campaign-queue.mjs` - Test: `tests/campaign-queue.test.mjs` **Interfaces:** - Consumes: `getCampaign` de `./campaign-store.mjs` (Task 2), `variantForLead`/`renderCampaignMessage` de `./campaign-messages.mjs` (Task 3). - Produces: `generateCampaignQueue(db, campaignId, {quantidade}) -> {candidatosEncontrados, adicionadosNaFila}`, `listQueue(db, campaignId, status|null) -> row[]`, `approveQueueRows(db, campaignId, queueIds) -> number`, `cancelQueueRows(db, campaignId, queueIds, reason) -> number`. Usadas por `routes/campaigns.mjs` (Task 8) e por `campaign-worker.mjs` indiretamente (via as linhas que essas funções produzem na tabela). - [ ] **Step 1: Escrever os testes que falham** Criar `tests/campaign-queue.test.mjs`: ```js 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(); }); ``` - [ ] **Step 2: Rodar e confirmar que falha** Run: `node --test tests/campaign-queue.test.mjs` Expected: FAIL — módulo não existe - [ ] **Step 3: Implementar** Criar `src/outreach/campaign-queue.mjs`: ```js 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; } ``` - [ ] **Step 4: Rodar e confirmar que passa** Run: `node --test tests/campaign-queue.test.mjs` Expected: PASS (9 testes) - [ ] **Step 5: Commit** ```bash git add src/outreach/campaign-queue.mjs tests/campaign-queue.test.mjs git commit -m "Add campaign candidate selection, queue generation and approval" ``` --- ### Task 5: `campaign-metrics.mjs` — saúde/auto-pausa e comparação de variantes **Files:** - Create: `src/outreach/campaign-metrics.mjs` - Test: `tests/campaign-metrics.test.mjs` **Interfaces:** - Produces: `evaluateCampaignHealth(db, campaignId, now) -> {pause: boolean, reason: string|null}`, `variantStats(db, campaignId) -> [{code, enviados, respostas, taxaResposta, elegivelParaComparacao}]`. Usadas por `campaign-worker.mjs` (Task 6) e `routes/campaigns.mjs` (Task 8). - [ ] **Step 1: Escrever os testes que falham** Criar `tests/campaign-metrics.test.mjs`: ```js import test from 'node:test'; import assert from 'node:assert/strict'; import { openDb, createLeadInserter } from '../src/db.mjs'; import { createCampaign, setCampaignStatus } from '../src/outreach/campaign-store.mjs'; import { evaluateCampaignHealth, variantStats } from '../src/outreach/campaign-metrics.mjs'; function inserirLead(db, dedup) { const inserir = createLeadInserter(db); inserir({ dedup_key: dedup, empresa: 'E', legal_name: '', whatsapp: `55119999${dedup.padStart(5, '0')}`, ddd: '11', email: '', cidade: '', uf: '', segmento: 'x', 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: 'ERP', produtos_json: '[]', capture_run_id: null, captured_at: null, exported_at: null }); return db.prepare('SELECT id, whatsapp FROM leads WHERE dedup_key = ?').get(dedup); } function campanhaRodando(db) { const campanha = createCampaign(db, { nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }] }); setCampaignStatus(db, campanha.id, 'rodando'); return campanha; } function simularEnvio(db, campanhaId, variantId, leadId, numero, status) { const sendId = db.prepare( `INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'ERP', ?, 'oi', ?)` ).run(leadId, numero, status).lastInsertRowid; db.prepare( `INSERT INTO outreach_campaign_queue (campaign_id, lead_id, variant_id, numero, rendered_message, status, outreach_send_id) VALUES (?, ?, ?, ?, 'oi', ?, ?)` ).run(campanhaId, leadId, variantId, numero, status === 'erro' ? 'error' : 'sent', sendId); } test('evaluateCampaignHealth nao pausa campanha saudavel', () => { const db = openDb(':memory:'); const campanha = campanhaRodando(db); const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id); for (let i = 0; i < 10; i += 1) { const lead = inserirLead(db, `l${i}`); simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado'); } const resultado = evaluateCampaignHealth(db, campanha.id); assert.equal(resultado.pause, false); db.close(); }); test('evaluateCampaignHealth pausa por high_failure_rate quando erro > 10% com 10+ tentativas', () => { const db = openDb(':memory:'); const campanha = campanhaRodando(db); const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id); for (let i = 0; i < 8; i += 1) { const lead = inserirLead(db, `ok${i}`); simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado'); } for (let i = 0; i < 2; i += 1) { const lead = inserirLead(db, `err${i}`); simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro'); } const resultado = evaluateCampaignHealth(db, campanha.id); assert.equal(resultado.pause, true); assert.equal(resultado.reason, 'high_failure_rate'); const atualizada = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id); assert.equal(atualizada.status, 'pausado'); db.close(); }); test('evaluateCampaignHealth nao pausa por falha com menos de 10 tentativas no dia', () => { const db = openDb(':memory:'); const campanha = campanhaRodando(db); const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id); const lead = inserirLead(db, 'so1'); simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro'); const resultado = evaluateCampaignHealth(db, campanha.id); assert.equal(resultado.pause, false); db.close(); }); test('evaluateCampaignHealth pausa por high_negative_rate quando >10% dos 20+ contatados estao em sem_interesse/opt_out', () => { const db = openDb(':memory:'); const campanha = campanhaRodando(db); const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id); for (let i = 0; i < 20; i += 1) { const lead = inserirLead(db, `neg${i}`); simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado'); if (i < 3) { db.prepare(`INSERT INTO outreach_lead_states (lead_id, stage) VALUES (?, 'sem_interesse')`).run(lead.id); } } const resultado = evaluateCampaignHealth(db, campanha.id); assert.equal(resultado.pause, true); assert.equal(resultado.reason, 'high_negative_rate'); db.close(); }); test('evaluateCampaignHealth pausa por low_reply_rate quando <5% dos 50+ contatados responderam', () => { const db = openDb(':memory:'); const campanha = campanhaRodando(db); const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id); for (let i = 0; i < 50; i += 1) { const lead = inserirLead(db, `rep${i}`); simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado'); } const resultado = evaluateCampaignHealth(db, campanha.id); assert.equal(resultado.pause, true); assert.equal(resultado.reason, 'low_reply_rate'); db.close(); }); test('evaluateCampaignHealth em campanha ja pausada nao faz nada', () => { const db = openDb(':memory:'); const campanha = campanhaRodando(db); setCampaignStatus(db, campanha.id, 'pausado', 'manual'); const resultado = evaluateCampaignHealth(db, campanha.id); assert.equal(resultado.pause, false); db.close(); }); test('variantStats calcula taxa de resposta e elegibilidade (limiar 40 envios)', () => { const db = openDb(':memory:'); const campanha = campanhaRodando(db); const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id); for (let i = 0; i < 5; i += 1) { const lead = inserirLead(db, `v${i}`); simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado'); if (i < 1) { db.prepare(`INSERT INTO outreach_stage_events (lead_id, campaign_id, stage) VALUES (?, ?, 'interessado')`).run(lead.id, campanha.id); } } const stats = variantStats(db, campanha.id); assert.equal(stats.length, 1); assert.equal(stats[0].enviados, 5); assert.equal(stats[0].respostas, 1); assert.equal(stats[0].taxaResposta, 0.2); assert.equal(stats[0].elegivelParaComparacao, false); db.close(); }); ``` - [ ] **Step 2: Rodar e confirmar que falha** Run: `node --test tests/campaign-metrics.test.mjs` Expected: FAIL — módulo não existe - [ ] **Step 3: Implementar** Criar `src/outreach/campaign-metrics.mjs`: ```js function dateKey(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } function applyPause(db, campaignId, reason) { db.prepare( `UPDATE outreach_campaigns SET status = 'pausado', pause_reason = ?, updated_at = datetime('now') WHERE id = ? AND status = 'rodando'` ).run(reason, campaignId); } // Mesmos 3 criterios e limiares numericos da Maquina Pessoal (10 tentativas/ // 10% erro, 20 contatos/10% negativo+opt-out, 50 contatos/5% resposta), // adaptados as tabelas da Comercial via JOIN por outreach_campaign_queue // (nao por um valor de origem dedicado). export function evaluateCampaignHealth(db, campaignId, now = new Date()) { const campaign = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId); if (!campaign || campaign.status !== 'rodando') return { pause: false, reason: null }; const todayKey = dateKey(now); const attemptsToday = Number(db.prepare( `SELECT COUNT(*) AS total FROM outreach_campaign_queue q JOIN outreach_sends s ON s.id = q.outreach_send_id WHERE q.campaign_id = ? AND date(s.sent_at, 'localtime') = ?` ).get(campaignId, todayKey).total); if (attemptsToday >= 10) { const failureCount = Number(db.prepare( `SELECT COUNT(*) AS total FROM outreach_campaign_queue q JOIN outreach_sends s ON s.id = q.outreach_send_id WHERE q.campaign_id = ? AND s.status = 'erro' AND date(s.sent_at, 'localtime') = ?` ).get(campaignId, todayKey).total); if (failureCount / attemptsToday > 0.10) { applyPause(db, campaignId, 'high_failure_rate'); return { pause: true, reason: 'high_failure_rate' }; } } const contacts = Number(db.prepare( `SELECT COUNT(*) AS total FROM outreach_campaign_queue WHERE campaign_id = ? AND status IN ('sent','error')` ).get(campaignId).total); if (contacts >= 20) { const negativeCount = Number(db.prepare( `SELECT COUNT(DISTINCT q.lead_id) AS total FROM outreach_campaign_queue q JOIN outreach_lead_states ls ON ls.lead_id = q.lead_id WHERE q.campaign_id = ? AND q.status IN ('sent','error') AND ls.stage IN ('sem_interesse', 'opt_out')` ).get(campaignId).total); if (negativeCount / contacts > 0.10) { applyPause(db, campaignId, 'high_negative_rate'); return { pause: true, reason: 'high_negative_rate' }; } } if (contacts >= 50) { const respondedCount = Number(db.prepare( `SELECT COUNT(DISTINCT q.lead_id) AS total FROM outreach_campaign_queue q JOIN outreach_stage_events se ON se.lead_id = q.lead_id AND se.campaign_id = q.campaign_id WHERE q.campaign_id = ? AND q.status IN ('sent','error')` ).get(campaignId).total); if (respondedCount / contacts < 0.05) { applyPause(db, campaignId, 'low_reply_rate'); return { pause: true, reason: 'low_reply_rate' }; } } return { pause: false, reason: null }; } // Numero de leads distintos com resposta por variante, via subquery // correlacionada (nao um JOIN direto) para nao contar duplicado quando um // lead tem mais de um outreach_stage_events. export function variantStats(db, campaignId) { const rows = db.prepare( `SELECT v.id AS variant_id, v.code, COUNT(*) AS enviados, (SELECT COUNT(DISTINCT q2.lead_id) FROM outreach_campaign_queue q2 JOIN outreach_stage_events se ON se.lead_id = q2.lead_id AND se.campaign_id = q2.campaign_id WHERE q2.campaign_id = q.campaign_id AND q2.variant_id = v.id AND q2.status IN ('sent','error') ) AS respostas FROM outreach_campaign_queue q JOIN outreach_campaign_variants v ON v.id = q.variant_id WHERE q.campaign_id = ? AND q.status IN ('sent','error') GROUP BY v.id, v.code` ).all(campaignId); return rows.map((row) => ({ code: row.code, enviados: row.enviados, respostas: row.respostas, taxaResposta: row.enviados > 0 ? row.respostas / row.enviados : 0, elegivelParaComparacao: row.enviados >= 40 })); } ``` - [ ] **Step 4: Rodar e confirmar que passa** Run: `node --test tests/campaign-metrics.test.mjs` Expected: PASS (7 testes) - [ ] **Step 5: Commit** ```bash git add src/outreach/campaign-metrics.mjs tests/campaign-metrics.test.mjs git commit -m "Add campaign health auto-pause and variant comparison stats" ``` --- ### Task 6: `campaign-worker.mjs` — envio das linhas aprovadas **Files:** - Create: `src/outreach/campaign-worker.mjs` - Test: `tests/campaign-worker.test.mjs` **Interfaces:** - Consumes: `isOptedOut` de `./campaign-store.mjs` (Task 2), `evaluateCampaignHealth` de `./campaign-metrics.mjs` (Task 5), `createZproClient` de `./zpro-client.mjs` (já existe). - Produces: `runCampaignQueue({db, zproClient, now, log}) -> {enviados, erros, pulados}`. Usada por `campaign-scheduler.mjs` (Task 7). - [ ] **Step 1: Escrever os testes que falham** Criar `tests/campaign-worker.test.mjs`: ```js import test from 'node:test'; import assert from 'node:assert/strict'; import { openDb, createLeadInserter } from '../src/db.mjs'; import { createCampaign, setCampaignStatus, addOptOut } from '../src/outreach/campaign-store.mjs'; import { generateCampaignQueue, listQueue, approveQueueRows } from '../src/outreach/campaign-queue.mjs'; import { runCampaignQueue } from '../src/outreach/campaign-worker.mjs'; function inserirLead(db, dedup, whatsapp) { const inserir = createLeadInserter(db); inserir({ dedup_key: dedup, empresa: 'Empresa', legal_name: '', whatsapp, ddd: '11', email: '', cidade: '', uf: '', segmento: 'x', 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: 'ERP', produtos_json: '[]', capture_run_id: null, captured_at: null, exported_at: null }); } function campanhaAprovada(db, quantidadeLeads = 1) { for (let i = 0; i < quantidadeLeads; i += 1) { inserirLead(db, `lead-${i}`, `551199999000${i}`); } const campanha = createCampaign(db, { nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }], limiteDiarioInicial: 10 }); setCampaignStatus(db, campanha.id, 'rodando'); generateCampaignQueue(db, campanha.id); const fila = listQueue(db, campanha.id, 'preview'); approveQueueRows(db, campanha.id, fila.map((r) => r.id)); return campanha; } function fakeZproClient(overrides = {}) { const enviados = []; return { enviados, sendText: async (args) => { enviados.push(args); return { data: { ticketId: 999 } }; }, ...overrides }; } test('runCampaignQueue envia linhas aprovadas via sendText e marca sent', async () => { const db = openDb(':memory:'); const campanha = campanhaAprovada(db); const zproClient = fakeZproClient(); const resumo = await runCampaignQueue({ db, zproClient }); assert.equal(resumo.enviados, 1); assert.equal(zproClient.enviados.length, 1); assert.equal(zproClient.enviados[0].message, 'Ola Empresa'); const fila = listQueue(db, campanha.id, 'sent'); assert.equal(fila.length, 1); assert.ok(fila[0].outreach_send_id); const send = db.prepare('SELECT * FROM outreach_sends WHERE id = ?').get(fila[0].outreach_send_id); assert.equal(send.status, 'enviado'); assert.equal(send.origem, 'automatico'); db.close(); }); test('runCampaignQueue registra erro quando o Z-PRO falha, sem travar o restante', async () => { const db = openDb(':memory:'); const campanha = campanhaAprovada(db, 2); let chamada = 0; const zproClient = fakeZproClient({ sendText: async (args) => { chamada += 1; if (chamada === 1) throw new Error('Z-PRO falhou'); return { data: { ticketId: 1 } }; } }); const resumo = await runCampaignQueue({ db, zproClient }); assert.equal(resumo.enviados, 1); assert.equal(resumo.erros, 1); const erro = listQueue(db, campanha.id, 'error')[0]; const send = db.prepare('SELECT * FROM outreach_sends WHERE id = ?').get(erro.outreach_send_id); assert.equal(send.status, 'erro'); db.close(); }); test('runCampaignQueue respeita o limite diario da campanha', async () => { const db = openDb(':memory:'); for (let i = 0; i < 3; i += 1) inserirLead(db, `l${i}`, `551199999000${i}`); const campanha = createCampaign(db, { nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }], limiteDiarioInicial: 1 }); setCampaignStatus(db, campanha.id, 'rodando'); generateCampaignQueue(db, campanha.id); approveQueueRows(db, campanha.id, listQueue(db, campanha.id, 'preview').map((r) => r.id)); const zproClient = fakeZproClient(); const resumo = await runCampaignQueue({ db, zproClient }); assert.equal(resumo.enviados, 1); assert.equal(zproClient.enviados.length, 1); assert.equal(listQueue(db, campanha.id, 'approved').length, 2); db.close(); }); test('runCampaignQueue pula e cancela linha de numero que entrou em opt-out depois da fila gerada', async () => { const db = openDb(':memory:'); const campanha = campanhaAprovada(db); addOptOut(db, '5511999990000', 'pediu pra parar'); db.prepare(`UPDATE outreach_campaign_queue SET numero = '5511999990000' WHERE campaign_id = ?`).run(campanha.id); const zproClient = fakeZproClient(); const resumo = await runCampaignQueue({ db, zproClient }); assert.equal(resumo.pulados, 1); assert.equal(zproClient.enviados.length, 0); const cancelada = listQueue(db, campanha.id, 'cancelled')[0]; assert.equal(cancelada.cancel_reason, 'opt_out'); db.close(); }); test('runCampaignQueue so processa campanhas com status rodando', async () => { const db = openDb(':memory:'); inserirLead(db, 'l1', '5511999990001'); const campanha = createCampaign(db, { nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }] }); generateCampaignQueue(db, campanha.id); approveQueueRows(db, campanha.id, listQueue(db, campanha.id, 'preview').map((r) => r.id)); // campanha continua em 'rascunho' — nunca foi iniciada const zproClient = fakeZproClient(); const resumo = await runCampaignQueue({ db, zproClient }); assert.equal(resumo.enviados, 0); assert.equal(zproClient.enviados.length, 0); db.close(); }); test('runCampaignQueue interrompe a campanha quando a saude aciona auto-pausa', async () => { const db = openDb(':memory:'); const campanha = campanhaAprovada(db, 10); db.prepare(`UPDATE outreach_campaigns SET limite_diario_atual = 10 WHERE id = ?`).run(campanha.id); let chamada = 0; const zproClient = fakeZproClient({ sendText: async () => { chamada += 1; if (chamada <= 8) return { data: { ticketId: chamada } }; throw new Error('falha simulada'); } }); await runCampaignQueue({ db, zproClient }); const atualizada = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id); // 10 tentativas, 2 erros = 20% > 10% -> deveria pausar antes de esgotar a fila assert.equal(atualizada.status, 'pausado'); db.close(); }); ``` - [ ] **Step 2: Rodar e confirmar que falha** Run: `node --test tests/campaign-worker.test.mjs` Expected: FAIL — módulo não existe - [ ] **Step 3: Implementar** Criar `src/outreach/campaign-worker.mjs`: ```js import { createZproClient } from './zpro-client.mjs'; import { isOptedOut } from './campaign-store.mjs'; import { evaluateCampaignHealth } from './campaign-metrics.mjs'; function todayAttempts(db, campaignId) { return Number(db.prepare( `SELECT COUNT(*) AS total FROM outreach_campaign_queue q JOIN outreach_sends s ON s.id = q.outreach_send_id WHERE q.campaign_id = ? AND date(s.sent_at, 'localtime') = date('now', 'localtime')` ).get(campaignId).total); } // Diferente da Maquina Pessoal (que reserva o envio antes de mandar, porque a // entrega via Baileys e assincrona/incerta), aqui o envio via Z-PRO // (zproClient.sendText) e sincrono: ou resolve (aceito pelo Z-PRO) ou lanca // erro. Por isso o resultado ja e gravado direto como 'enviado'/'erro', sem // um estado intermediario "reservado" nem logica de recuperacao pos-crash. export async function runCampaignQueue({ db, zproClient, now = new Date(), log = () => {} }) { const client = zproClient || createZproClient(db); const summary = { enviados: 0, erros: 0, pulados: 0 }; const campaigns = db.prepare(`SELECT id FROM outreach_campaigns WHERE status = 'rodando'`).all(); for (const { id: campaignId } of campaigns) { const campaign = db.prepare('SELECT * FROM outreach_campaigns WHERE id = ?').get(campaignId); let attemptsToday = todayAttempts(db, campaignId); while (attemptsToday < campaign.limite_diario_atual) { const row = db.prepare( `SELECT * FROM outreach_campaign_queue WHERE campaign_id = ? AND status = 'approved' ORDER BY id ASC LIMIT 1` ).get(campaignId); if (!row) break; if (isOptedOut(db, row.numero)) { db.prepare( `UPDATE outreach_campaign_queue SET status = 'cancelled', cancel_reason = 'opt_out', updated_at = datetime('now') WHERE id = ?` ).run(row.id); summary.pulados += 1; continue; } const jaEnviado = db.prepare( `SELECT 1 FROM outreach_sends WHERE produto = ? AND status = 'enviado' AND (lead_id = ? OR numero = ?) LIMIT 1` ).get(campaign.produto, row.lead_id, row.numero); if (jaEnviado) { db.prepare( `UPDATE outreach_campaign_queue SET status = 'cancelled', cancel_reason = 'ja_contatado', updated_at = datetime('now') WHERE id = ?` ).run(row.id); summary.pulados += 1; continue; } const externalKey = `campanha-${campaignId}-${row.id}`; const insert = db.prepare( `INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, erro, zpro_ticket_id, canal, origem) VALUES (?, ?, ?, ?, ?, ?, ?, 'nao_oficial', 'automatico')` ); let sendId; let queueStatus; try { const resposta = await client.sendText({ number: row.numero, message: row.rendered_message, externalKey }); const ticketId = resposta?.data?.ticketId ? String(resposta.data.ticketId) : null; sendId = Number(insert.run(row.lead_id, campaign.produto, row.numero, row.rendered_message, 'enviado', null, ticketId).lastInsertRowid); queueStatus = 'sent'; summary.enviados += 1; } catch (error) { sendId = Number(insert.run(row.lead_id, campaign.produto, row.numero, row.rendered_message, 'erro', String(error.message || error), null).lastInsertRowid); queueStatus = 'error'; summary.erros += 1; log(`campanha #${campaignId}: envio falhou (fila #${row.id}): ${error.message}`); } db.prepare( `UPDATE outreach_campaign_queue SET status = ?, outreach_send_id = ?, updated_at = datetime('now') WHERE id = ?` ).run(queueStatus, sendId, row.id); attemptsToday += 1; const health = evaluateCampaignHealth(db, campaignId, now); if (health.pause) { log(`campanha #${campaignId}: pausada automaticamente (${health.reason})`); break; } } } return summary; } ``` - [ ] **Step 4: Rodar e confirmar que passa** Run: `node --test tests/campaign-worker.test.mjs` Expected: PASS (6 testes) - [ ] **Step 5: Rodar a suite inteira** Run: `node --test tests/*.test.mjs` Expected: todos os testes passam - [ ] **Step 6: Commit** ```bash git add src/outreach/campaign-worker.mjs tests/campaign-worker.test.mjs git commit -m "Add campaign send worker with daily limit and health auto-pause" ``` --- ### Task 7: `campaign-scheduler.mjs` — timer + wiring no boot do servidor **Files:** - Create: `src/outreach/campaign-scheduler.mjs` - Modify: `scripts/servidor.mjs` - Test: `tests/campaign-scheduler.test.mjs` **Interfaces:** - Consumes: `runCampaignQueue` de `./campaign-worker.mjs` (Task 6), `escalateWeeklyLimits` de `./campaign-store.mjs` (Task 2). - Produces: `startCampaignScheduler({db, log}) -> stopFn`. - [ ] **Step 1: Escrever o teste que falha** Criar `tests/campaign-scheduler.test.mjs`: ```js import test from 'node:test'; import assert from 'node:assert/strict'; import { openDb } from '../src/db.mjs'; import { startCampaignScheduler } from '../src/outreach/campaign-scheduler.mjs'; test('startCampaignScheduler retorna funcao de parada que limpa os timers sem lancar erro', () => { const db = openDb(':memory:'); const stop = startCampaignScheduler({ db, log: () => {} }); assert.equal(typeof stop, 'function'); assert.doesNotThrow(() => stop()); db.close(); }); ``` - [ ] **Step 2: Rodar e confirmar que falha** Run: `node --test tests/campaign-scheduler.test.mjs` Expected: FAIL — módulo não existe - [ ] **Step 3: Implementar** Criar `src/outreach/campaign-scheduler.mjs`: ```js import { runCampaignQueue } from './campaign-worker.mjs'; import { escalateWeeklyLimits } from './campaign-store.mjs'; const CICLO_MS = 20 * 60 * 1000; // Timer interno separado do scheduler.mjs existente (run.mjs/runOutreachBatch) // — mesmo padrao (setInterval com .unref(), primeiro ciclo antecipado pouco // depois do boot), rodando ao lado sem interferir um no outro. export function startCampaignScheduler({ db, log = () => {} }) { const ciclo = () => { escalateWeeklyLimits(db); runCampaignQueue({ db, log }).catch((error) => { log(`campanhas: erro inesperado no ciclo automatico: ${error.message}`); }); }; const primeiro = setTimeout(ciclo, 45 * 1000); primeiro.unref(); const timer = setInterval(ciclo, CICLO_MS); timer.unref(); return () => { clearTimeout(primeiro); clearInterval(timer); }; } ``` - [ ] **Step 4: Rodar e confirmar que passa** Run: `node --test tests/campaign-scheduler.test.mjs` Expected: PASS - [ ] **Step 5: Ligar no boot do servidor** Em `scripts/servidor.mjs`, adicionar o import junto aos demais (depois de `import { startOutreachScheduler } from '../src/outreach/scheduler.mjs';`): ```js import { startCampaignScheduler } from '../src/outreach/campaign-scheduler.mjs'; ``` E logo depois de `startOutreachScheduler({ db, log });`: ```js startCampaignScheduler({ db, log }); ``` - [ ] **Step 6: Rodar a suite inteira** Run: `node --test tests/*.test.mjs` Expected: todos os testes passam (o boot do servidor em si não tem teste automatizado — verificação manual: `npm start` sobe sem erro) - [ ] **Step 7: Commit** ```bash git add src/outreach/campaign-scheduler.mjs scripts/servidor.mjs tests/campaign-scheduler.test.mjs git commit -m "Wire campaign scheduler into server boot" ``` --- ### Task 8: Rotas HTTP (`src/server/routes/campaigns.mjs`) **Files:** - Create: `src/server/routes/campaigns.mjs` - Modify: `src/server/index.mjs` - Test: `tests/campaign-routes.test.mjs` **Interfaces:** - Consumes: tudo das Tasks 2, 4, 5 (`campaign-store.mjs`, `campaign-queue.mjs`, `campaign-metrics.mjs`). - Produces: `registerCampaignRoutes(router, {db})`, registrada em `createAppServer`. - [ ] **Step 1: Escrever os testes que falham** Criar `tests/campaign-routes.test.mjs`: ```js 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(); }); ``` - [ ] **Step 2: Rodar e confirmar que falha** Run: `node --test tests/campaign-routes.test.mjs` Expected: FAIL — módulo não existe - [ ] **Step 3: Implementar** Criar `src/server/routes/campaigns.mjs`: ```js 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 }); }); } ``` - [ ] **Step 4: Registrar as rotas no servidor** Em `src/server/index.mjs`, adicionar o import junto aos outros (depois de `import { registerOutreachRoutes } from './routes/outreach.mjs';`): ```js import { registerCampaignRoutes } from './routes/campaigns.mjs'; ``` E depois de `registerOutreachRoutes(router, deps);`: ```js registerCampaignRoutes(router, deps); ``` - [ ] **Step 5: Rodar e confirmar que passa** Run: `node --test tests/campaign-routes.test.mjs` Expected: PASS (6 testes) - [ ] **Step 6: Rodar a suite inteira** Run: `node --test tests/*.test.mjs` Expected: todos os testes passam - [ ] **Step 7: Commit** ```bash git add src/server/routes/campaigns.mjs src/server/index.mjs tests/campaign-routes.test.mjs git commit -m "Add campaign HTTP routes" ``` --- ### Task 9: Front-end — aba Campanhas (esqueleto + criação de campanha) **Files:** - Modify: `public/index.html` - Modify: `public/js/app.js` - Create: `public/js/campanhas.js` **Interfaces:** - Consumes: `API.get`/`API.post` de `api.js`, `el`/`h`/`toast` de `api.js` (já existentes), rotas de `campaigns.mjs` (Task 8). - Produces: objeto global `Campanhas` com `.ativar()`, chamado pelo listener de troca de aba em `app.js` — consumido pelas Tasks 10 e 11 que vão adicionar mais funções a esse mesmo módulo. Sem teste automatizado (é UI); verificação manual descrita no Step 5. - [ ] **Step 1: Adicionar a aba e o painel em `public/index.html`** Trocar a linha 18 (depois do botão "Tratar Leads") para incluir o botão da nova aba: ```html ``` Adicionar um novo `
` logo antes de `` (perto do fim do `
`, depois da section `tab-tratamento` existente): ```html

Campanhas de outreach

Nova campanha

``` (o `` e o ``: ```html ``` Em `public/js/app.js`, dentro do loop de troca de aba (perto da linha 66), trocar: ```js if (tab.dataset.tab === 'tratamento') Tratamento.ativar(); ``` por: ```js if (tab.dataset.tab === 'tratamento') Tratamento.ativar(); if (tab.dataset.tab === 'campanhas') Campanhas.ativar(); ``` - [ ] **Step 3: Criar `public/js/campanhas.js` com listagem e criação** ```js /* Aba Campanhas: criacao, fila de aprovacao, saude/variantes, estagio/agendamento. */ const Campanhas = (() => { let carregado = false; let variantesCount = 0; const STATUS_LABELS = { rascunho: 'Rascunho', rodando: 'Rodando', pausado: 'Pausado', concluido: 'Concluido' }; function addVarianteRow(codigoSugerido) { const codigo = codigoSugerido || String.fromCharCode(65 + variantesCount); variantesCount += 1; el('campanha-variantes').append( h('div', { class: 'row', 'data-variante-code': codigo }, h('span', { class: 'chip' }, codigo), h('textarea', { class: 'campanha-variante-mensagem', rows: 2, placeholder: 'Ola {empresa}, ...' })) ); } async function carregarProdutos() { const { produtos } = await API.get('/api/campaigns/produtos'); el('campanha-produto').replaceChildren( ...produtos.map((p) => h('option', { value: p.value }, `${p.value} (${fmt.format(p.total)} leads)`)) ); } async function carregarSegmentos(produto) { const container = el('campanha-segmentos'); if (!produto) { container.replaceChildren(); return; } const { segmentos } = await API.get(`/api/campaigns/segmentos?produto=${encodeURIComponent(produto)}`); container.replaceChildren(...segmentos.map((s) => h('label', { class: 'chip' }, h('input', { type: 'checkbox', value: s.value, class: 'campanha-segmento-check' }), ` ${s.value} (${fmt.format(s.total)})`) )); } function segmentosSelecionados() { return Array.from(document.querySelectorAll('.campanha-segmento-check:checked')).map((i) => i.value); } function variantesPreenchidas() { return Array.from(el('campanha-variantes').children).map((row) => ({ code: row.dataset.varianteCode, mensagem: row.querySelector('.campanha-variante-mensagem').value.trim() })).filter((v) => v.mensagem); } async function abrirModalNovaCampanha() { el('campanha-nome').value = ''; el('campanha-limite-inicial').value = 10; el('campanha-limite-teto').value = 80; el('campanha-erro').textContent = ''; el('campanha-variantes').replaceChildren(); variantesCount = 0; addVarianteRow('A'); await carregarProdutos(); await carregarSegmentos(el('campanha-produto').value); el('modal-nova-campanha').showModal(); } function renderCampanhaCard(campanha) { return h('div', { class: 'item' }, h('div', {}, h('strong', {}, campanha.nome), h('span', { class: 'sub' }, ` ${campanha.produto} — ${campanha.segmentos.join(', ')}`)), h('span', { class: `chip ${campanha.status === 'rodando' ? 'ok' : campanha.status === 'pausado' ? 'warn' : ''}` }, STATUS_LABELS[campanha.status] || campanha.status)); } async function carregarLista() { const { campanhas } = await API.get('/api/campaigns'); el('campanhas-lista').replaceChildren( ...(campanhas.length ? campanhas.map(renderCampanhaCard) : [h('p', { class: 'muted' }, 'Nenhuma campanha criada ainda.')]) ); } function ativar() { if (carregado) return; carregado = true; el('btn-nova-campanha').addEventListener('click', () => { abrirModalNovaCampanha().catch((error) => toast(`Erro: ${error.message}`)); }); el('btn-cancelar-campanha').addEventListener('click', () => el('modal-nova-campanha').close()); el('btn-add-variante').addEventListener('click', () => addVarianteRow()); el('campanha-produto').addEventListener('change', (event) => { carregarSegmentos(event.target.value).catch((error) => toast(`Erro: ${error.message}`)); }); el('btn-salvar-campanha').addEventListener('click', async () => { try { await API.post('/api/campaigns', { nome: el('campanha-nome').value.trim(), produto: el('campanha-produto').value, segmentos: segmentosSelecionados(), variantes: variantesPreenchidas(), limiteDiarioInicial: Number(el('campanha-limite-inicial').value) || 10, limiteDiarioTeto: Number(el('campanha-limite-teto').value) || 80 }); el('modal-nova-campanha').close(); toast('Campanha criada'); await carregarLista(); } catch (error) { el('campanha-erro').textContent = error.message; } }); carregarLista().catch((error) => toast(`Erro: ${error.message}`)); } return { ativar }; })(); ``` - [ ] **Step 4: Adicionar estilo mínimo para o modal de campanha (reaproveitando classes existentes)** Nenhuma classe CSS nova é necessária — `dialog`, `.field`, `.row`, `.chip`, `.chips`, `.item`, `.lista`, `.btn`/`.btn-primary`/`.btn-ghost` já existem em `public/css/app.css` e cobrem este formulário. Pular este step se a revisão visual (Step 5) não apontar necessidade. - [ ] **Step 5: Verificação manual no navegador** Rodar `npm start`, abrir `http://127.0.0.1:3900`, logar, clicar na aba "🎯 Campanhas", clicar em "+ Nova campanha", escolher um produto (a lista deve carregar da base de leads existente), marcar segmentos, escrever ao menos uma variante, salvar — a campanha deve aparecer na lista com status "Rascunho". - [ ] **Step 6: Commit** ```bash git add public/index.html public/js/app.js public/js/campanhas.js git commit -m "Add Campanhas tab: listing and campaign creation" ``` --- ### Task 10: Front-end — fila de aprovação, saúde e comparação de variantes **Files:** - Modify: `public/js/campanhas.js` - Modify: `public/index.html` **Interfaces:** - Consumes: rotas `/api/campaigns/:id/gerar-fila`, `/api/campaigns/:id/fila`, `/api/campaigns/:id/fila/aprovar`, `/api/campaigns/:id/fila/cancelar`, `/api/campaigns/:id/iniciar`, `/api/campaigns/:id/pausar`, `/api/campaigns/:id/retomar`, `/api/campaigns/:id/saude`, `/api/campaigns/:id/variantes-stats` (todas da Task 8). - Produces: adiciona `abrirDetalheCampanha(campanhaId)` ao módulo `Campanhas`, acionado ao clicar num card da lista (Task 9). - [ ] **Step 1: Adicionar o painel de detalhe em `public/index.html`** Dentro da `
`, logo depois de `
`, adicionar: ```html ``` - [ ] **Step 2: Implementar em `public/js/campanhas.js`** Adicionar, dentro do IIFE, antes do `return { ativar };` final: ```js let campanhaAtualId = null; async function carregarDetalhe(campanhaId) { campanhaAtualId = campanhaId; const campanha = await API.get(`/api/campaigns/${campanhaId}`); el('detalhe-nome').textContent = campanha.nome; el('detalhe-status').textContent = STATUS_LABELS[campanha.status] || campanha.status; el('campanha-detalhe').classList.remove('hidden'); await Promise.all([carregarSaude(campanhaId), carregarVariantesStats(campanhaId), carregarFila(campanhaId)]); } async function carregarSaude(campanhaId) { const saude = await API.get(`/api/campaigns/${campanhaId}/saude`); el('detalhe-saude').textContent = saude.pause ? `Pausada automaticamente: ${saude.reason}` : 'Saude: sem alertas.'; } async function carregarVariantesStats(campanhaId) { const { variantes } = await API.get(`/api/campaigns/${campanhaId}/variantes-stats`); el('detalhe-variantes').replaceChildren(...variantes.map((v) => h('span', { class: 'chip' }, `${v.code}: ${v.enviados} enviados, ${(v.taxaResposta * 100).toFixed(1)}% resposta${v.elegivelParaComparacao ? ' (elegivel)' : ''}`) )); } async function carregarFila(campanhaId) { const status = el('fila-filtro-status').value; const { fila } = await API.get(`/api/campaigns/${campanhaId}/fila?status=${status}`); el('fila-tabela').replaceChildren(...fila.map((linha) => h('tr', {}, h('td', {}, h('input', { type: 'checkbox', class: 'fila-check', value: linha.id })), h('td', {}, String(linha.lead_id)), h('td', {}, linha.numero), h('td', {}, linha.rendered_message), h('td', {}, linha.status)) )); } function filaSelecionada() { return Array.from(document.querySelectorAll('.fila-check:checked')).map((i) => Number(i.value)); } function ligarDetalheCampanha() { el('btn-fechar-detalhe').addEventListener('click', () => el('campanha-detalhe').classList.add('hidden')); el('fila-filtro-status').addEventListener('change', () => carregarFila(campanhaAtualId).catch((e) => toast(`Erro: ${e.message}`))); el('btn-gerar-fila').addEventListener('click', async () => { try { const resultado = await API.post(`/api/campaigns/${campanhaAtualId}/gerar-fila`, {}); toast(`${resultado.adicionadosNaFila} leads adicionados a fila`); await carregarFila(campanhaAtualId); } catch (error) { toast(`Erro: ${error.message}`); } }); el('btn-iniciar-campanha').addEventListener('click', async () => { await API.post(`/api/campaigns/${campanhaAtualId}/iniciar`, {}); await carregarDetalhe(campanhaAtualId); await carregarLista(); }); el('btn-pausar-campanha').addEventListener('click', async () => { await API.post(`/api/campaigns/${campanhaAtualId}/pausar`, { motivo: 'manual' }); await carregarDetalhe(campanhaAtualId); await carregarLista(); }); el('btn-retomar-campanha').addEventListener('click', async () => { await API.post(`/api/campaigns/${campanhaAtualId}/retomar`, {}); await carregarDetalhe(campanhaAtualId); await carregarLista(); }); el('btn-aprovar-selecionados').addEventListener('click', async () => { const queueIds = filaSelecionada(); if (!queueIds.length) return; const resultado = await API.post(`/api/campaigns/${campanhaAtualId}/fila/aprovar`, { queueIds }); toast(`${resultado.aprovados} aprovados`); await carregarFila(campanhaAtualId); }); el('btn-cancelar-selecionados').addEventListener('click', async () => { const queueIds = filaSelecionada(); if (!queueIds.length) return; const resultado = await API.post(`/api/campaigns/${campanhaAtualId}/fila/cancelar`, { queueIds, motivo: 'operador' }); toast(`${resultado.cancelados} cancelados`); await carregarFila(campanhaAtualId); }); } ``` Trocar `renderCampanhaCard` para tornar o card clicável (adicionar `onclick` no `h('div', { class: 'item' }, ...)`): ```js function renderCampanhaCard(campanha) { return h('div', { class: 'item', onclick: () => carregarDetalhe(campanha.id).catch((e) => toast(`Erro: ${e.message}`)) }, h('div', {}, h('strong', {}, campanha.nome), h('span', { class: 'sub' }, ` ${campanha.produto} — ${campanha.segmentos.join(', ')}`)), h('span', { class: `chip ${campanha.status === 'rodando' ? 'ok' : campanha.status === 'pausado' ? 'warn' : ''}` }, STATUS_LABELS[campanha.status] || campanha.status)); } ``` E, na função `ativar()`, chamar `ligarDetalheCampanha();` junto dos outros listeners (antes de `carregarLista().catch(...)`). - [ ] **Step 2: Verificação manual** Com uma campanha criada (Task 9), clicar no card dela, clicar "Iniciar", "Gerar fila", selecionar linhas e clicar "Aprovar selecionados" — a fila deve refletir o novo status e, depois de um ciclo do `campaign-scheduler` (ou chamando `runCampaignQueue` manualmente via um teste/REPL), as linhas aprovadas devem virar "sent" desde que `zpro.*` esteja configurado em Configurações. - [ ] **Step 3: Commit** ```bash git add public/index.html public/js/campanhas.js git commit -m "Add campaign detail panel: queue approval, health and variant stats" ``` --- ### Task 11: Front-end — classificação de estágio e agendamento + checagem final da suite **Files:** - Modify: `public/js/campanhas.js` - Modify: `public/index.html` - Test: rodar a suite inteira (nenhum arquivo de teste novo — esta task é só UI + verificação) **Interfaces:** - Consumes: rotas `/api/campaigns/leads/:leadId/estagio` (GET/POST) e `/api/campaigns/leads/:leadId/agendamentos` (GET/POST) da Task 8. - [ ] **Step 1: Adicionar controles de estágio/agendamento por linha da fila em `public/index.html`** Trocar o `` da tabela de fila (adicionada na Task 10) para incluir uma coluna de ação: ```html EmpresaNumeroMensagemStatusEstagio ``` - [ ] **Step 2: Implementar em `public/js/campanhas.js`** Trocar a função `carregarFila` (da Task 10) para incluir o seletor de estágio por linha (só faz sentido para linhas já `sent`/`error`, que de fato foram contatadas): ```js const STAGE_OPTIONS = [ 'respondeu', 'interessado', 'demonstracao_agendada', 'sem_interesse', 'opt_out', 'venda_fechada' ]; async function carregarFila(campanhaId) { const status = el('fila-filtro-status').value; const { fila } = await API.get(`/api/campaigns/${campanhaId}/fila?status=${status}`); el('fila-tabela').replaceChildren(...fila.map((linha) => { const podeMarcarEstagio = linha.status === 'sent' || linha.status === 'error'; const stageSelect = h('select', { class: 'fila-estagio-select', disabled: podeMarcarEstagio ? null : 'disabled' }, h('option', { value: '' }, '—'), ...STAGE_OPTIONS.map((s) => h('option', { value: s }, s))); const marcarBtn = h('button', { class: 'btn-ghost btn-sm', type: 'button', disabled: podeMarcarEstagio ? null : 'disabled', onclick: async () => { if (!stageSelect.value) return; await API.post(`/api/campaigns/leads/${linha.lead_id}/estagio`, { stage: stageSelect.value, campaignId: campanhaId }); toast('Estagio atualizado'); if (stageSelect.value === 'demonstracao_agendada') { const dataAgendamento = prompt('Data/hora do agendamento (AAAA-MM-DD HH:MM):'); if (dataAgendamento) { await API.post(`/api/campaigns/leads/${linha.lead_id}/agendamentos`, { kind: 'demonstracao', scheduledAt: dataAgendamento }); toast('Agendamento registrado'); } } } }, 'Marcar'); return h('tr', {}, h('td', {}, h('input', { type: 'checkbox', class: 'fila-check', value: linha.id })), h('td', {}, String(linha.lead_id)), h('td', {}, linha.numero), h('td', {}, linha.rendered_message), h('td', {}, linha.status), h('td', { class: 'row' }, stageSelect, marcarBtn)); })); } ``` - [ ] **Step 3: Verificação manual** Com uma linha de fila em status `sent` (após um envio real ou simulado), abrir o detalhe da campanha, escolher um estágio no seletor da linha, clicar "Marcar" — checar via `GET /api/campaigns/leads/:leadId/estagio` (ou na aba Tratar Leads, se o lead for aberto lá) que o estágio foi gravado. Marcar `demonstracao_agendada` deve pedir a data e criar um agendamento. - [ ] **Step 4: Rodar a suite inteira e revisar `git status`** Run: `node --test tests/*.test.mjs` Expected: todos os testes passam (backend Tasks 1-8 + qualquer teste pré-existente) Run: `git status --short` Expected: só os arquivos desta feature aparecem como modificados/staged do lado do que este plano tocou — nada do WIP alheio (outreach existente, capture, etc.) deve ter sido alterado por engano. - [ ] **Step 5: Commit** ```bash git add public/index.html public/js/campanhas.js git commit -m "Add stage classification and appointment scheduling UI" ``` --- ## Self-Review **Cobertura da spec:** catálogo dinâmico (Task 2/9), fila/preview/aprovação (Task 4/10), variantes A/B/C só não-oficial (Task 3/6, `zproClient.sendText`), escalonamento semanal (Task 2/7), saúde/auto-pausa (Task 5/6), classificação de estágio e agendamento manual (Task 8/11), prevenção de contato duplicado com `run.mjs` (Task 4, cláusula `NOT EXISTS` por `lead_id OR numero`), opt-out (Task 2/6). Tudo coberto. **Placeholder scan:** nenhum "TBD"/"implementar depois" — todo step tem código completo. **Consistência de tipos:** `getCampaign` retorna `{...row, segmentos: [], variants: []}` — usado identicamente em `campaign-queue.mjs`, `campaign-worker.mjs` (via `db.prepare` direto, não via `getCampaign`, para pegar `limite_diario_atual` fresco a cada iteração do loop) e nas rotas. `variantForLead`/`renderCampaignMessage` com a mesma assinatura em `campaign-messages.mjs`, `campaign-queue.mjs`. Status de fila (`preview|approved|cancelled|sent|error`) e de campanha (`rascunho|rodando|pausado|concluido`) usados de forma consistente em todas as tasks.