Files
COMERCIAL-GTI/gti-maquina-comercial/docs/superpowers/plans/2026-08-01-campaign-outreach-port.md
T
GustavoandClaude Sonnet 5 f5af038e19 chore: import gti-maquina-comercial history via subtree
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

git-subtree-dir: gti-maquina-comercial
git-subtree-mainline: 93ca82979e
git-subtree-split: b77484946f
2026-09-28 15:50:08 -03:00

94 KiB

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/<arquivo>.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:

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):

  // 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
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:

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:

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
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:

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:

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
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:

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:

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
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:

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:

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
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:

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:

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
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:

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:

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';):

import { startCampaignScheduler } from '../src/outreach/campaign-scheduler.mjs';

E logo depois de startOutreachScheduler({ db, log });:

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
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:

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:

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';):

import { registerCampaignRoutes } from './routes/campaigns.mjs';

E depois de registerOutreachRoutes(router, deps);:

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
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:

    <button class="tab" data-tab="tratamento" role="tab" aria-selected="false">📊 Tratar Leads</button>
    <button class="tab" data-tab="campanhas" role="tab" aria-selected="false">🎯 Campanhas</button>

Adicionar um novo <section> logo antes de <script src="/js/api.js"></script> (perto do fim do <main>, depois da section tab-tratamento existente):

  <!-- ==================== CAMPANHAS ==================== -->
  <section id="tab-campanhas" class="tab-panel">
    <div class="row space-between">
      <h2>Campanhas de outreach</h2>
      <button class="btn-ghost btn-sm" id="btn-nova-campanha" type="button">+ Nova campanha</button>
    </div>
    <div id="campanhas-lista" class="lista"></div>

    <dialog id="modal-nova-campanha">
      <form id="form-nova-campanha" method="dialog">
        <h2>Nova campanha</h2>
        <div class="field">
          <label for="campanha-nome">Nome</label>
          <input type="text" id="campanha-nome" required>
        </div>
        <div class="field">
          <label for="campanha-produto">Produto</label>
          <select id="campanha-produto" required></select>
        </div>
        <div class="field">
          <label>Segmentos</label>
          <div id="campanha-segmentos" class="chips"></div>
        </div>
        <div class="field">
          <label>Variantes de mensagem (A/B/C)</label>
          <div id="campanha-variantes"></div>
          <button class="btn-ghost btn-sm" id="btn-add-variante" type="button">+ Variante</button>
        </div>
        <div class="row">
          <label>Limite diário inicial <input type="number" id="campanha-limite-inicial" value="10" min="1"></label>
          <label>Teto do limite <input type="number" id="campanha-limite-teto" value="80" min="1"></label>
        </div>
        <p class="muted" id="campanha-erro"></p>
        <div class="row space-between">
          <button class="btn-ghost" id="btn-cancelar-campanha" type="button">Cancelar</button>
          <button class="btn btn-primary" id="btn-salvar-campanha" type="button">Criar campanha</button>
        </div>
      </form>
    </dialog>
  </section>

</main>
<script src="/js/api.js"></script>

(o </main> e o <script src="/js/api.js"> já existem no arquivo — o bloco acima só insere a nova <section> imediatamente antes deles, sem duplicar as tags de fechamento.)

  • Step 2: Registrar o script e o disparo da aba em app.js

Em public/index.html, adicionar antes de <script src="/js/app.js"></script>:

<script src="/js/campanhas.js"></script>

Em public/js/app.js, dentro do loop de troca de aba (perto da linha 66), trocar:

      if (tab.dataset.tab === 'tratamento') Tratamento.ativar();

por:

      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
/* 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
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 <section id="tab-campanhas">, logo depois de <div id="campanhas-lista" class="lista"></div>, adicionar:

    <div id="campanha-detalhe" class="card hidden">
      <div class="row space-between">
        <h2 id="detalhe-nome"></h2>
        <div class="row">
          <span class="chip" id="detalhe-status"></span>
          <button class="btn-ghost btn-sm" id="btn-fechar-detalhe" type="button">Fechar</button>
        </div>
      </div>
      <div class="row" id="detalhe-acoes">
        <button class="btn-ghost btn-sm" id="btn-iniciar-campanha" type="button">Iniciar</button>
        <button class="btn-ghost btn-sm" id="btn-pausar-campanha" type="button">Pausar</button>
        <button class="btn-ghost btn-sm" id="btn-retomar-campanha" type="button">Retomar</button>
        <button class="btn-ghost btn-sm" id="btn-gerar-fila" type="button">Gerar fila</button>
      </div>
      <p class="muted" id="detalhe-saude"></p>
      <div id="detalhe-variantes" class="row"></div>

      <div class="row space-between">
        <h3>Fila</h3>
        <select id="fila-filtro-status">
          <option value="preview">Preview</option>
          <option value="approved">Aprovadas</option>
          <option value="sent">Enviadas</option>
          <option value="error">Erro</option>
          <option value="cancelled">Canceladas</option>
        </select>
      </div>
      <div class="row">
        <button class="btn-ghost btn-sm" id="btn-aprovar-selecionados" type="button">Aprovar selecionados</button>
        <button class="btn-ghost btn-sm" id="btn-cancelar-selecionados" type="button">Cancelar selecionados</button>
      </div>
      <table>
        <thead><tr><th></th><th>Empresa</th><th>Numero</th><th>Mensagem</th><th>Status</th></tr></thead>
        <tbody id="fila-tabela"></tbody>
      </table>
    </div>
  • Step 2: Implementar em public/js/campanhas.js

Adicionar, dentro do IIFE, antes do return { ativar }; final:

  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' }, ...)):

  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
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 <thead> da tabela de fila (adicionada na Task 10) para incluir uma coluna de ação:

        <thead><tr><th></th><th>Empresa</th><th>Numero</th><th>Mensagem</th><th>Status</th><th>Estagio</th></tr></thead>
  • 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):

  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
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.