Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> git-subtree-dir: gti-maquina-comercial git-subtree-mainline:93ca82979egit-subtree-split:b77484946f
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/erroemoutreach_sends, nãoaceito/pendente/desconhecidocomo 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_sendsrecebeorigem='automatico'para envios de campanha (mesma convenção dorun.mjs, não um valor'campanha'novo) — a atribuição de um envio a uma campanha é feita pelo JOIN viaoutreach_campaign_queue.outreach_send_id, não por um valor deorigemdedicado.- Prevenção de contato duplicado com o
runOutreachBatchexistente: um lead comoutreach_sends.status='enviado'para o mesmo produto (seja porlead_idou pornumero, 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(arrayMIGRATIONS, 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 viadb.prepare(...)apósopenDb()/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.segmentosarray e.variantsarray),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 porcampaign-queue.mjs,campaign-worker.mjseroutes/campaigns.mjsnas 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 porcampaign-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:
getCampaignde./campaign-store.mjs(Task 2),variantForLead/renderCampaignMessagede./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 porroutes/campaigns.mjs(Task 8) e porcampaign-worker.mjsindiretamente (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 porcampaign-worker.mjs(Task 6) eroutes/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:
isOptedOutde./campaign-store.mjs(Task 2),evaluateCampaignHealthde./campaign-metrics.mjs(Task 5),createZproClientde./zpro-client.mjs(já existe). -
Produces:
runCampaignQueue({db, zproClient, now, log}) -> {enviados, erros, pulados}. Usada porcampaign-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:
runCampaignQueuede./campaign-worker.mjs(Task 6),escalateWeeklyLimitsde./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 emcreateAppServer. -
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.postdeapi.js,el/h/toastdeapi.js(já existentes), rotas decampaigns.mjs(Task 8). - Produces: objeto global
Campanhascom.ativar(), chamado pelo listener de troca de aba emapp.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.jscom 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óduloCampanhas, 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.