diff --git a/docs/superpowers/plans/2026-08-01-campaign-outreach-port.md b/docs/superpowers/plans/2026-08-01-campaign-outreach-port.md new file mode 100644 index 0000000..97e948d --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-campaign-outreach-port.md @@ -0,0 +1,2278 @@ +# 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.