11-task TDD plan covering schema migration, campaign store, candidate selection/queue, health metrics, send worker, scheduler, HTTP routes and the Campanhas UI tab.
2279 lines
94 KiB
Markdown
2279 lines
94 KiB
Markdown
# 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`:
|
|
|
|
```js
|
|
test('migracao v8 cria as tabelas de campanha de outreach', () => {
|
|
const db = openDb(':memory:');
|
|
const tabelas = db.prepare(
|
|
`SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'outreach_%'`
|
|
).all().map((r) => r.name);
|
|
for (const nome of [
|
|
'outreach_campaigns', 'outreach_campaign_variants', 'outreach_campaign_queue',
|
|
'outreach_lead_states', 'outreach_stage_events', 'outreach_appointments', 'outreach_opt_out'
|
|
]) {
|
|
assert.ok(tabelas.includes(nome), `tabela ${nome} deveria existir`);
|
|
}
|
|
db.prepare(`INSERT INTO outreach_campaigns (nome, produto, segmentos_json) VALUES ('Teste', 'ERP', '["oficinas"]')`).run();
|
|
const campanha = db.prepare('SELECT * FROM outreach_campaigns').get();
|
|
assert.equal(campanha.status, 'rascunho');
|
|
assert.equal(campanha.limite_diario_atual, 10);
|
|
db.close();
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Rodar e confirmar que falha**
|
|
|
|
Run: `node --test tests/db.test.mjs`
|
|
Expected: FAIL — `no such table: outreach_campaigns`
|
|
|
|
- [ ] **Step 3: Adicionar a migração v8**
|
|
|
|
Em `src/db.mjs`, adicionar como último elemento do array `MIGRATIONS` (depois do comentário `// v7 — ...` e sua string, antes do `];` que fecha o array):
|
|
|
|
```js
|
|
// v8 — campanhas de outreach com catalogo dinamico (a Comercial tem 57
|
|
// segmentos e 4 produtos, catalogo fixo em codigo nao cabe nessa escala:
|
|
// o operador cria a campanha pela tela). Roda ao lado do runOutreachBatch
|
|
// existente (run.mjs) sem substitui-lo. outreach_opt_out e novo aqui (nao
|
|
// existia antes): uma fila que recontata o mesmo lead ao longo de semanas
|
|
// precisa checar quem ja pediu pra parar.
|
|
`
|
|
CREATE TABLE outreach_campaigns (
|
|
id INTEGER PRIMARY KEY,
|
|
nome TEXT NOT NULL,
|
|
produto TEXT NOT NULL,
|
|
segmentos_json TEXT NOT NULL DEFAULT '[]',
|
|
status TEXT NOT NULL DEFAULT 'rascunho',
|
|
pause_reason TEXT,
|
|
limite_diario_atual INTEGER NOT NULL DEFAULT 10,
|
|
limite_diario_teto INTEGER NOT NULL DEFAULT 80,
|
|
semana_iniciada_em TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE outreach_campaign_variants (
|
|
id INTEGER PRIMARY KEY,
|
|
campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id),
|
|
code TEXT NOT NULL,
|
|
mensagem TEXT NOT NULL,
|
|
ativo INTEGER NOT NULL DEFAULT 1
|
|
);
|
|
CREATE INDEX idx_campaign_variants_campaign ON outreach_campaign_variants(campaign_id);
|
|
|
|
CREATE TABLE outreach_campaign_queue (
|
|
id INTEGER PRIMARY KEY,
|
|
campaign_id INTEGER NOT NULL REFERENCES outreach_campaigns(id),
|
|
lead_id INTEGER NOT NULL REFERENCES leads(id),
|
|
variant_id INTEGER NOT NULL REFERENCES outreach_campaign_variants(id),
|
|
segmento TEXT NOT NULL DEFAULT '',
|
|
numero TEXT NOT NULL,
|
|
rendered_message TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'preview',
|
|
cancel_reason TEXT,
|
|
outreach_send_id INTEGER REFERENCES outreach_sends(id),
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE INDEX idx_campaign_queue_campaign_status ON outreach_campaign_queue(campaign_id, status);
|
|
CREATE INDEX idx_campaign_queue_lead ON outreach_campaign_queue(lead_id);
|
|
|
|
CREATE TABLE outreach_lead_states (
|
|
lead_id INTEGER PRIMARY KEY REFERENCES leads(id),
|
|
stage TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE outreach_stage_events (
|
|
id INTEGER PRIMARY KEY,
|
|
lead_id INTEGER NOT NULL REFERENCES leads(id),
|
|
campaign_id INTEGER REFERENCES outreach_campaigns(id),
|
|
stage TEXT NOT NULL,
|
|
note TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE INDEX idx_stage_events_lead ON outreach_stage_events(lead_id);
|
|
|
|
CREATE TABLE outreach_appointments (
|
|
id INTEGER PRIMARY KEY,
|
|
lead_id INTEGER NOT NULL REFERENCES leads(id),
|
|
kind TEXT NOT NULL,
|
|
scheduled_at TEXT,
|
|
note TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
CREATE INDEX idx_appointments_lead ON outreach_appointments(lead_id);
|
|
|
|
CREATE TABLE outreach_opt_out (
|
|
numero TEXT PRIMARY KEY,
|
|
motivo TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
`
|
|
```
|
|
|
|
- [ ] **Step 4: Rodar e confirmar que passa**
|
|
|
|
Run: `node --test tests/db.test.mjs`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Rodar a suite inteira (garantir que a migracao nao quebrou nada)**
|
|
|
|
Run: `node --test tests/*.test.mjs`
|
|
Expected: todos os testes existentes continuam passando
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/db.mjs tests/db.test.mjs
|
|
git commit -m "Add campaign outreach schema (migration v8)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: `campaign-store.mjs` — CRUD de campanha, variantes, opt-out, escalonamento
|
|
|
|
**Files:**
|
|
- Create: `src/outreach/campaign-store.mjs`
|
|
- Test: `tests/campaign-store.test.mjs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `transaction(db, fn)` de `../db.mjs` (já existe).
|
|
- Produces: `createCampaign(db, {nome, produto, segmentos, variantes, limiteDiarioInicial, limiteDiarioTeto}) -> campanha`, `getCampaign(db, id) -> campanha|null` (com `.segmentos` array e `.variants` array), `listCampaigns(db) -> campanha[]`, `listVariants(db, campaignId) -> variant[]`, `setCampaignStatus(db, campaignId, status, pauseReason) -> campanha`, `resumeCampaign(db, campaignId) -> campanha`, `escalateWeeklyLimits(db, now) -> number`, `isOptedOut(db, numero) -> boolean`, `addOptOut(db, numero, motivo) -> void`, `removeOptOut(db, numero) -> void`. Essas assinaturas são usadas por `campaign-queue.mjs`, `campaign-worker.mjs` e `routes/campaigns.mjs` nas próximas tasks.
|
|
|
|
- [ ] **Step 1: Escrever os testes que falham**
|
|
|
|
Criar `tests/campaign-store.test.mjs`:
|
|
|
|
```js
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { openDb } from '../src/db.mjs';
|
|
import {
|
|
createCampaign, getCampaign, listCampaigns, setCampaignStatus, resumeCampaign,
|
|
escalateWeeklyLimits, isOptedOut, addOptOut, removeOptOut
|
|
} from '../src/outreach/campaign-store.mjs';
|
|
|
|
function campanhaBase(overrides = {}) {
|
|
return {
|
|
nome: 'ERP oficinas',
|
|
produto: 'ERP',
|
|
segmentos: ['oficinas_mecanicas', 'autopecas'],
|
|
variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }, { code: 'B', mensagem: 'Oi {empresa}' }],
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
test('createCampaign grava campanha, segmentos e variantes', () => {
|
|
const db = openDb(':memory:');
|
|
const campanha = createCampaign(db, campanhaBase());
|
|
assert.equal(campanha.status, 'rascunho');
|
|
assert.equal(campanha.produto, 'ERP');
|
|
assert.deepEqual(campanha.segmentos, ['oficinas_mecanicas', 'autopecas']);
|
|
assert.equal(campanha.variants.length, 2);
|
|
assert.equal(campanha.variants[0].code, 'A');
|
|
db.close();
|
|
});
|
|
|
|
test('listCampaigns retorna mais recente primeiro', () => {
|
|
const db = openDb(':memory:');
|
|
createCampaign(db, campanhaBase({ nome: 'Primeira' }));
|
|
createCampaign(db, campanhaBase({ nome: 'Segunda' }));
|
|
const campanhas = listCampaigns(db);
|
|
assert.equal(campanhas.length, 2);
|
|
assert.equal(campanhas[0].nome, 'Segunda');
|
|
db.close();
|
|
});
|
|
|
|
test('setCampaignStatus pausa e resumeCampaign retoma limpando o motivo', () => {
|
|
const db = openDb(':memory:');
|
|
const campanha = createCampaign(db, campanhaBase());
|
|
setCampaignStatus(db, campanha.id, 'rodando');
|
|
const pausada = setCampaignStatus(db, campanha.id, 'pausado', 'high_failure_rate');
|
|
assert.equal(pausada.status, 'pausado');
|
|
assert.equal(pausada.pause_reason, 'high_failure_rate');
|
|
const retomada = resumeCampaign(db, campanha.id);
|
|
assert.equal(retomada.status, 'rodando');
|
|
assert.equal(retomada.pause_reason, null);
|
|
db.close();
|
|
});
|
|
|
|
test('resumeCampaign nao faz nada se a campanha nao estiver pausada', () => {
|
|
const db = openDb(':memory:');
|
|
const campanha = createCampaign(db, campanhaBase());
|
|
const resultado = resumeCampaign(db, campanha.id);
|
|
assert.equal(resultado.status, 'rascunho');
|
|
db.close();
|
|
});
|
|
|
|
test('escalateWeeklyLimits dobra o limite apos 7 dias rodando, respeitando o teto', () => {
|
|
const db = openDb(':memory:');
|
|
const campanha = createCampaign(db, campanhaBase({ limiteDiarioInicial: 10, limiteDiarioTeto: 15 }));
|
|
setCampaignStatus(db, campanha.id, 'rodando');
|
|
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-8 days') WHERE id = ?`).run(campanha.id);
|
|
|
|
const escaladas = escalateWeeklyLimits(db);
|
|
assert.equal(escaladas, 1);
|
|
const atualizada = getCampaign(db, campanha.id);
|
|
assert.equal(atualizada.limite_diario_atual, 15); // dobraria pra 20, mas o teto e 15
|
|
db.close();
|
|
});
|
|
|
|
test('escalateWeeklyLimits nao avanca campanha pausada nem antes de 7 dias', () => {
|
|
const db = openDb(':memory:');
|
|
const rodando = createCampaign(db, campanhaBase({ nome: 'Rodando ha pouco' }));
|
|
setCampaignStatus(db, rodando.id, 'rodando');
|
|
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-2 days') WHERE id = ?`).run(rodando.id);
|
|
|
|
const pausada = createCampaign(db, campanhaBase({ nome: 'Pausada' }));
|
|
setCampaignStatus(db, pausada.id, 'rodando');
|
|
setCampaignStatus(db, pausada.id, 'pausado', 'manual');
|
|
db.prepare(`UPDATE outreach_campaigns SET semana_iniciada_em = datetime('now', '-10 days') WHERE id = ?`).run(pausada.id);
|
|
|
|
const escaladas = escalateWeeklyLimits(db);
|
|
assert.equal(escaladas, 0);
|
|
db.close();
|
|
});
|
|
|
|
test('opt-out: addOptOut bloqueia, removeOptOut libera', () => {
|
|
const db = openDb(':memory:');
|
|
assert.equal(isOptedOut(db, '5511999990001'), false);
|
|
addOptOut(db, '5511999990001', 'pediu pra parar');
|
|
assert.equal(isOptedOut(db, '5511999990001'), true);
|
|
removeOptOut(db, '5511999990001');
|
|
assert.equal(isOptedOut(db, '5511999990001'), false);
|
|
db.close();
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Rodar e confirmar que falha**
|
|
|
|
Run: `node --test tests/campaign-store.test.mjs`
|
|
Expected: FAIL — `Cannot find module '../src/outreach/campaign-store.mjs'`
|
|
|
|
- [ ] **Step 3: Implementar**
|
|
|
|
Criar `src/outreach/campaign-store.mjs`:
|
|
|
|
```js
|
|
import { transaction } from '../db.mjs';
|
|
|
|
export function createCampaign(db, {
|
|
nome, produto, segmentos, variantes, limiteDiarioInicial = 10, limiteDiarioTeto = 80
|
|
}) {
|
|
return transaction(db, () => {
|
|
const result = db.prepare(
|
|
`INSERT INTO outreach_campaigns
|
|
(nome, produto, segmentos_json, limite_diario_atual, limite_diario_teto)
|
|
VALUES (?, ?, ?, ?, ?)`
|
|
).run(nome, produto, JSON.stringify(segmentos || []), limiteDiarioInicial, limiteDiarioTeto);
|
|
const campaignId = Number(result.lastInsertRowid);
|
|
const insertVariant = db.prepare(
|
|
`INSERT INTO outreach_campaign_variants (campaign_id, code, mensagem) VALUES (?, ?, ?)`
|
|
);
|
|
for (const variante of variantes) {
|
|
insertVariant.run(campaignId, variante.code, variante.mensagem);
|
|
}
|
|
return getCampaign(db, campaignId);
|
|
});
|
|
}
|
|
|
|
export function getCampaign(db, id) {
|
|
const row = db.prepare('SELECT * FROM outreach_campaigns WHERE id = ?').get(id);
|
|
if (!row) return null;
|
|
return {
|
|
...row,
|
|
segmentos: JSON.parse(row.segmentos_json || '[]'),
|
|
variants: listVariants(db, id)
|
|
};
|
|
}
|
|
|
|
export function listCampaigns(db) {
|
|
return db.prepare('SELECT * FROM outreach_campaigns ORDER BY id DESC').all()
|
|
.map((row) => ({ ...row, segmentos: JSON.parse(row.segmentos_json || '[]') }));
|
|
}
|
|
|
|
export function listVariants(db, campaignId) {
|
|
return db.prepare(
|
|
'SELECT * FROM outreach_campaign_variants WHERE campaign_id = ? AND ativo = 1 ORDER BY code'
|
|
).all(campaignId);
|
|
}
|
|
|
|
// status: 'rascunho' -> 'rodando' -> 'pausado' <-> 'rodando' -> 'concluido'.
|
|
// pause_reason so fica setado quando o novo status e 'pausado'.
|
|
export function setCampaignStatus(db, campaignId, status, pauseReason = null) {
|
|
db.prepare(
|
|
`UPDATE outreach_campaigns SET status = ?, pause_reason = ?, updated_at = datetime('now') WHERE id = ?`
|
|
).run(status, status === 'pausado' ? pauseReason : null, campaignId);
|
|
return getCampaign(db, campaignId);
|
|
}
|
|
|
|
// So avanca campanha que estiver de fato pausada — retomar uma campanha em
|
|
// rascunho, por exemplo, deveria usar setCampaignStatus(db, id, 'rodando').
|
|
export function resumeCampaign(db, campaignId) {
|
|
db.prepare(
|
|
`UPDATE outreach_campaigns SET status = 'rodando', pause_reason = NULL, updated_at = datetime('now')
|
|
WHERE id = ? AND status = 'pausado'`
|
|
).run(campaignId);
|
|
return getCampaign(db, campaignId);
|
|
}
|
|
|
|
// Escalonamento semanal: dobra o limite diario a cada 7 dias corridos rodando
|
|
// sem pausa desde semana_iniciada_em, ate o teto da campanha. So campanhas com
|
|
// status='rodando' avancam — uma pausada fica congelada ate o operador retomar.
|
|
export function escalateWeeklyLimits(db, now = new Date()) {
|
|
const campaigns = db.prepare(
|
|
`SELECT id, limite_diario_atual, limite_diario_teto, semana_iniciada_em
|
|
FROM outreach_campaigns WHERE status = 'rodando'`
|
|
).all();
|
|
let escaladas = 0;
|
|
for (const campaign of campaigns) {
|
|
if (!campaign.semana_iniciada_em) continue;
|
|
const dias = (now.getTime() - new Date(campaign.semana_iniciada_em.replace(' ', 'T') + 'Z').getTime()) / 86400000;
|
|
if (dias < 7) continue;
|
|
const novoLimite = Math.min(campaign.limite_diario_teto, campaign.limite_diario_atual * 2);
|
|
if (novoLimite === campaign.limite_diario_atual) continue;
|
|
db.prepare(
|
|
`UPDATE outreach_campaigns
|
|
SET limite_diario_atual = ?, semana_iniciada_em = datetime('now'), updated_at = datetime('now')
|
|
WHERE id = ?`
|
|
).run(novoLimite, campaign.id);
|
|
escaladas += 1;
|
|
}
|
|
return escaladas;
|
|
}
|
|
|
|
export function isOptedOut(db, numero) {
|
|
return Boolean(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get(numero));
|
|
}
|
|
|
|
export function addOptOut(db, numero, motivo = null) {
|
|
db.prepare(
|
|
`INSERT INTO outreach_opt_out (numero, motivo) VALUES (?, ?)
|
|
ON CONFLICT(numero) DO UPDATE SET motivo = excluded.motivo`
|
|
).run(numero, motivo);
|
|
}
|
|
|
|
export function removeOptOut(db, numero) {
|
|
db.prepare('DELETE FROM outreach_opt_out WHERE numero = ?').run(numero);
|
|
}
|
|
```
|
|
|
|
Nota sobre `escalateWeeklyLimits`: `datetime('now')` do SQLite grava `"YYYY-MM-DD HH:MM:SS"` (sem `T`/`Z`); `.replace(' ', 'T') + 'Z'` converte pra um formato que `new Date(...)` do JS interpreta corretamente como UTC.
|
|
|
|
- [ ] **Step 4: Rodar e confirmar que passa**
|
|
|
|
Run: `node --test tests/campaign-store.test.mjs`
|
|
Expected: PASS (7 testes)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/outreach/campaign-store.mjs tests/campaign-store.test.mjs
|
|
git commit -m "Add campaign-store: CRUD, opt-out and weekly limit escalation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: `campaign-messages.mjs` — seleção determinística de variante + render
|
|
|
|
**Files:**
|
|
- Create: `src/outreach/campaign-messages.mjs`
|
|
- Test: `tests/campaign-messages.test.mjs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `renderTemplate(mensagem, lead)` de `./templates.mjs` (já existe, faz substituição de `{empresa}`/`{cidade}`/`{produto}`/`{segmento}`).
|
|
- Produces: `variantForLead(variants, leadId) -> variant`, `renderCampaignMessage(variant, lead) -> string`. Usadas por `campaign-queue.mjs` (Task 4).
|
|
|
|
- [ ] **Step 1: Escrever o teste que falha**
|
|
|
|
Criar `tests/campaign-messages.test.mjs`:
|
|
|
|
```js
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { variantForLead, renderCampaignMessage } from '../src/outreach/campaign-messages.mjs';
|
|
|
|
const variants = [{ id: 1, code: 'A', mensagem: 'Ola A' }, { id: 2, code: 'B', mensagem: 'Ola B' }, { id: 3, code: 'C', mensagem: 'Ola C' }];
|
|
|
|
test('variantForLead e deterministico (mesmo lead sempre cai na mesma variante)', () => {
|
|
const primeira = variantForLead(variants, 42);
|
|
const segunda = variantForLead(variants, 42);
|
|
assert.equal(primeira.code, segunda.code);
|
|
});
|
|
|
|
test('variantForLead distribui entre variantes diferentes conforme o id', () => {
|
|
assert.equal(variantForLead(variants, 0).code, 'A');
|
|
assert.equal(variantForLead(variants, 1).code, 'B');
|
|
assert.equal(variantForLead(variants, 2).code, 'C');
|
|
assert.equal(variantForLead(variants, 3).code, 'A');
|
|
});
|
|
|
|
test('variantForLead lanca erro sem variantes ativas', () => {
|
|
assert.throws(() => variantForLead([], 1), /sem variantes/);
|
|
});
|
|
|
|
test('renderCampaignMessage substitui variaveis do lead', () => {
|
|
const texto = renderCampaignMessage({ mensagem: 'Ola {empresa}, sobre {produto}' }, { empresa: 'Oficina do Ze', produto: 'ERP' });
|
|
assert.equal(texto, 'Ola Oficina do Ze, sobre ERP');
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Rodar e confirmar que falha**
|
|
|
|
Run: `node --test tests/campaign-messages.test.mjs`
|
|
Expected: FAIL — módulo não existe
|
|
|
|
- [ ] **Step 3: Implementar**
|
|
|
|
Criar `src/outreach/campaign-messages.mjs`:
|
|
|
|
```js
|
|
import { renderTemplate } from './templates.mjs';
|
|
|
|
// Distribuicao deterministica por lead_id % variantes.length: o mesmo lead
|
|
// sempre cai na mesma variante mesmo que a fila seja regenerada depois, sem
|
|
// precisar guardar nenhum estado extra so pra isso.
|
|
export function variantForLead(variants, leadId) {
|
|
if (!variants.length) throw new Error('campanha sem variantes ativas');
|
|
const index = Number(leadId) % variants.length;
|
|
return variants[index];
|
|
}
|
|
|
|
export function renderCampaignMessage(variant, lead) {
|
|
return renderTemplate(variant.mensagem, lead);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Rodar e confirmar que passa**
|
|
|
|
Run: `node --test tests/campaign-messages.test.mjs`
|
|
Expected: PASS (4 testes)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/outreach/campaign-messages.mjs tests/campaign-messages.test.mjs
|
|
git commit -m "Add deterministic variant selection and message rendering"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: `campaign-queue.mjs` — seleção de candidatos, geração de fila, aprovação
|
|
|
|
**Files:**
|
|
- Create: `src/outreach/campaign-queue.mjs`
|
|
- Test: `tests/campaign-queue.test.mjs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `getCampaign` de `./campaign-store.mjs` (Task 2), `variantForLead`/`renderCampaignMessage` de `./campaign-messages.mjs` (Task 3).
|
|
- Produces: `generateCampaignQueue(db, campaignId, {quantidade}) -> {candidatosEncontrados, adicionadosNaFila}`, `listQueue(db, campaignId, status|null) -> row[]`, `approveQueueRows(db, campaignId, queueIds) -> number`, `cancelQueueRows(db, campaignId, queueIds, reason) -> number`. Usadas por `routes/campaigns.mjs` (Task 8) e por `campaign-worker.mjs` indiretamente (via as linhas que essas funções produzem na tabela).
|
|
|
|
- [ ] **Step 1: Escrever os testes que falham**
|
|
|
|
Criar `tests/campaign-queue.test.mjs`:
|
|
|
|
```js
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { openDb, createLeadInserter } from '../src/db.mjs';
|
|
import { createCampaign } from '../src/outreach/campaign-store.mjs';
|
|
import { generateCampaignQueue, listQueue, approveQueueRows, cancelQueueRows } from '../src/outreach/campaign-queue.mjs';
|
|
|
|
function inserirLead(db, overrides = {}) {
|
|
const inserir = createLeadInserter(db);
|
|
const dedup = overrides.dedup_key || `lead-${Math.random()}`;
|
|
inserir({
|
|
dedup_key: dedup, empresa: 'Oficina Teste', legal_name: '', whatsapp: '5511999990001',
|
|
ddd: '11', email: '', cidade: 'SP', uf: 'SP', segmento: 'oficinas_mecanicas', segmento_label: '',
|
|
interesse: '', cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]', origem: 'teste',
|
|
telefone_valido: 1, cnpj_valido: 0, empresa_valida: 1, score: 80, classificacao: 'bom',
|
|
motivo_classificacao: '', score_qualidade: 80, score_comercial: 80, prioridade: 1,
|
|
produto_principal: 'ERP', produtos_json: '[]', capture_run_id: null, captured_at: null, exported_at: null,
|
|
...overrides
|
|
});
|
|
return db.prepare('SELECT * FROM leads WHERE dedup_key = ?').get(dedup);
|
|
}
|
|
|
|
function campanhaComVariantes(db, overrides = {}) {
|
|
return createCampaign(db, {
|
|
nome: 'ERP oficinas', produto: 'ERP', segmentos: ['oficinas_mecanicas'],
|
|
variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }],
|
|
...overrides
|
|
});
|
|
}
|
|
|
|
test('generateCampaignQueue cria linhas preview so para leads do produto/segmento certos', () => {
|
|
const db = openDb(':memory:');
|
|
inserirLead(db, { dedup_key: 'l1', whatsapp: '5511999990001' });
|
|
inserirLead(db, { dedup_key: 'l2', segmento: 'outro_segmento', whatsapp: '5511999990002' });
|
|
const campanha = campanhaComVariantes(db);
|
|
|
|
const resultado = generateCampaignQueue(db, campanha.id);
|
|
assert.equal(resultado.candidatosEncontrados, 1);
|
|
assert.equal(resultado.adicionadosNaFila, 1);
|
|
|
|
const fila = listQueue(db, campanha.id);
|
|
assert.equal(fila.length, 1);
|
|
assert.equal(fila[0].status, 'preview');
|
|
assert.equal(fila[0].rendered_message, 'Ola Oficina Teste');
|
|
db.close();
|
|
});
|
|
|
|
test('generateCampaignQueue exclui lead com envio ja enviado para o mesmo produto (por lead_id)', () => {
|
|
const db = openDb(':memory:');
|
|
const lead = inserirLead(db);
|
|
db.prepare(
|
|
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'ERP', ?, 'oi', 'enviado')`
|
|
).run(lead.id, lead.whatsapp);
|
|
const campanha = campanhaComVariantes(db);
|
|
|
|
const resultado = generateCampaignQueue(db, campanha.id);
|
|
assert.equal(resultado.candidatosEncontrados, 0);
|
|
db.close();
|
|
});
|
|
|
|
test('generateCampaignQueue exclui numero com envio manual ja enviado (sem lead_id) para o mesmo produto', () => {
|
|
const db = openDb(':memory:');
|
|
inserirLead(db, { whatsapp: '5511999990009' });
|
|
db.prepare(
|
|
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, origem)
|
|
VALUES (NULL, 'ERP', '5511999990009', 'oi', 'enviado', 'manual')`
|
|
).run();
|
|
const campanha = campanhaComVariantes(db);
|
|
|
|
const resultado = generateCampaignQueue(db, campanha.id);
|
|
assert.equal(resultado.candidatosEncontrados, 0);
|
|
db.close();
|
|
});
|
|
|
|
test('generateCampaignQueue exclui numero em opt-out', () => {
|
|
const db = openDb(':memory:');
|
|
inserirLead(db, { whatsapp: '5511999990001' });
|
|
db.prepare(`INSERT INTO outreach_opt_out (numero) VALUES ('5511999990001')`).run();
|
|
const campanha = campanhaComVariantes(db);
|
|
|
|
const resultado = generateCampaignQueue(db, campanha.id);
|
|
assert.equal(resultado.candidatosEncontrados, 0);
|
|
db.close();
|
|
});
|
|
|
|
test('generateCampaignQueue nao duplica lead ja na fila (preview/approved/sent) de outra campanha do mesmo produto', () => {
|
|
const db = openDb(':memory:');
|
|
inserirLead(db, { dedup_key: 'unico', whatsapp: '5511999990001' });
|
|
const campanha1 = campanhaComVariantes(db, { nome: 'Campanha 1' });
|
|
generateCampaignQueue(db, campanha1.id);
|
|
|
|
const campanha2 = campanhaComVariantes(db, { nome: 'Campanha 2' });
|
|
const resultado = generateCampaignQueue(db, campanha2.id);
|
|
assert.equal(resultado.candidatosEncontrados, 0);
|
|
db.close();
|
|
});
|
|
|
|
test('generateCampaignQueue respeita o parametro quantidade', () => {
|
|
const db = openDb(':memory:');
|
|
for (let i = 0; i < 5; i += 1) {
|
|
inserirLead(db, { dedup_key: `lead-${i}`, whatsapp: `551199999000${i}` });
|
|
}
|
|
const campanha = campanhaComVariantes(db);
|
|
const resultado = generateCampaignQueue(db, campanha.id, { quantidade: 3 });
|
|
assert.equal(resultado.adicionadosNaFila, 3);
|
|
db.close();
|
|
});
|
|
|
|
test('approveQueueRows so aprova linhas em preview da campanha certa', () => {
|
|
const db = openDb(':memory:');
|
|
inserirLead(db, { whatsapp: '5511999990001' });
|
|
const campanha = campanhaComVariantes(db);
|
|
generateCampaignQueue(db, campanha.id);
|
|
const [linha] = listQueue(db, campanha.id);
|
|
|
|
const total = approveQueueRows(db, campanha.id, [linha.id]);
|
|
assert.equal(total, 1);
|
|
assert.equal(listQueue(db, campanha.id, 'approved').length, 1);
|
|
});
|
|
|
|
test('cancelQueueRows cancela e registra motivo', () => {
|
|
const db = openDb(':memory:');
|
|
inserirLead(db, { whatsapp: '5511999990001' });
|
|
const campanha = campanhaComVariantes(db);
|
|
generateCampaignQueue(db, campanha.id);
|
|
const [linha] = listQueue(db, campanha.id);
|
|
|
|
const total = cancelQueueRows(db, campanha.id, [linha.id], 'nao interessa');
|
|
assert.equal(total, 1);
|
|
const cancelada = listQueue(db, campanha.id, 'cancelled')[0];
|
|
assert.equal(cancelada.cancel_reason, 'nao interessa');
|
|
});
|
|
|
|
test('approveQueueRows/cancelQueueRows com lista vazia nao lanca erro', () => {
|
|
const db = openDb(':memory:');
|
|
const campanha = campanhaComVariantes(db);
|
|
assert.equal(approveQueueRows(db, campanha.id, []), 0);
|
|
assert.equal(cancelQueueRows(db, campanha.id, []), 0);
|
|
db.close();
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Rodar e confirmar que falha**
|
|
|
|
Run: `node --test tests/campaign-queue.test.mjs`
|
|
Expected: FAIL — módulo não existe
|
|
|
|
- [ ] **Step 3: Implementar**
|
|
|
|
Criar `src/outreach/campaign-queue.mjs`:
|
|
|
|
```js
|
|
import { getCampaign } from './campaign-store.mjs';
|
|
import { variantForLead, renderCampaignMessage } from './campaign-messages.mjs';
|
|
|
|
const ACTIVE_QUEUE_STATUSES = "'preview','approved','sent'";
|
|
|
|
function candidateQuery() {
|
|
return `
|
|
SELECT leads.id, leads.empresa, leads.whatsapp, leads.segmento, leads.cidade, leads.uf,
|
|
leads.segmento_label, leads.prioridade, leads.score
|
|
FROM leads
|
|
WHERE leads.produto_principal = ?
|
|
AND leads.segmento = ?
|
|
AND leads.telefone_valido = 1
|
|
AND leads.whatsapp != ''
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM outreach_sends
|
|
WHERE outreach_sends.produto = ? AND outreach_sends.status = 'enviado'
|
|
AND (outreach_sends.lead_id = leads.id OR outreach_sends.numero = leads.whatsapp)
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM outreach_campaign_queue q
|
|
JOIN outreach_campaigns c ON c.id = q.campaign_id
|
|
WHERE q.lead_id = leads.id AND c.produto = ? AND q.status IN (${ACTIVE_QUEUE_STATUSES})
|
|
)
|
|
AND NOT EXISTS (SELECT 1 FROM outreach_opt_out WHERE numero = leads.whatsapp)
|
|
ORDER BY leads.prioridade ASC, leads.score DESC, leads.id ASC`;
|
|
}
|
|
|
|
// Gera ate `quantidade` linhas novas de fila (status 'preview') para a
|
|
// campanha, buscando candidatos em todos os segmentos configurados. Nao e
|
|
// limitado ao limite diario de envio (isso so controla a VELOCIDADE de envio
|
|
// do worker) — o operador pode gerar e revisar um lote maior do que vai
|
|
// enviar hoje.
|
|
export function generateCampaignQueue(db, campaignId, { quantidade = 50 } = {}) {
|
|
const campaign = getCampaign(db, campaignId);
|
|
if (!campaign) throw new Error('campanha nao encontrada');
|
|
if (!campaign.variants.length) throw new Error('campanha sem variantes ativas');
|
|
if (!campaign.segmentos.length) throw new Error('campanha sem segmentos configurados');
|
|
|
|
const statement = db.prepare(candidateQuery());
|
|
const candidatos = campaign.segmentos
|
|
.flatMap((segmento) => statement.all(campaign.produto, segmento, campaign.produto, campaign.produto))
|
|
.sort((a, b) => a.prioridade - b.prioridade || b.score - a.score || a.id - b.id)
|
|
.slice(0, quantidade);
|
|
|
|
const insert = db.prepare(
|
|
`INSERT INTO outreach_campaign_queue
|
|
(campaign_id, lead_id, variant_id, segmento, numero, rendered_message, status)
|
|
VALUES (?, ?, ?, ?, ?, ?, 'preview')`
|
|
);
|
|
let adicionados = 0;
|
|
for (const lead of candidatos) {
|
|
const variant = variantForLead(campaign.variants, lead.id);
|
|
const renderedMessage = renderCampaignMessage(variant, lead);
|
|
insert.run(campaignId, lead.id, variant.id, lead.segmento, lead.whatsapp, renderedMessage);
|
|
adicionados += 1;
|
|
}
|
|
return { candidatosEncontrados: candidatos.length, adicionadosNaFila: adicionados };
|
|
}
|
|
|
|
export function listQueue(db, campaignId, status = null) {
|
|
if (status) {
|
|
return db.prepare(
|
|
'SELECT * FROM outreach_campaign_queue WHERE campaign_id = ? AND status = ? ORDER BY id'
|
|
).all(campaignId, status);
|
|
}
|
|
return db.prepare(
|
|
'SELECT * FROM outreach_campaign_queue WHERE campaign_id = ? ORDER BY id'
|
|
).all(campaignId);
|
|
}
|
|
|
|
export function approveQueueRows(db, campaignId, queueIds) {
|
|
if (!queueIds.length) return 0;
|
|
const placeholders = queueIds.map(() => '?').join(', ');
|
|
return db.prepare(
|
|
`UPDATE outreach_campaign_queue SET status = 'approved', updated_at = datetime('now')
|
|
WHERE campaign_id = ? AND status = 'preview' AND id IN (${placeholders})`
|
|
).run(campaignId, ...queueIds).changes;
|
|
}
|
|
|
|
export function cancelQueueRows(db, campaignId, queueIds, reason = 'operador') {
|
|
if (!queueIds.length) return 0;
|
|
const placeholders = queueIds.map(() => '?').join(', ');
|
|
return db.prepare(
|
|
`UPDATE outreach_campaign_queue SET status = 'cancelled', cancel_reason = ?, updated_at = datetime('now')
|
|
WHERE campaign_id = ? AND status IN ('preview','approved') AND id IN (${placeholders})`
|
|
).run(reason, campaignId, ...queueIds).changes;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Rodar e confirmar que passa**
|
|
|
|
Run: `node --test tests/campaign-queue.test.mjs`
|
|
Expected: PASS (9 testes)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/outreach/campaign-queue.mjs tests/campaign-queue.test.mjs
|
|
git commit -m "Add campaign candidate selection, queue generation and approval"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: `campaign-metrics.mjs` — saúde/auto-pausa e comparação de variantes
|
|
|
|
**Files:**
|
|
- Create: `src/outreach/campaign-metrics.mjs`
|
|
- Test: `tests/campaign-metrics.test.mjs`
|
|
|
|
**Interfaces:**
|
|
- Produces: `evaluateCampaignHealth(db, campaignId, now) -> {pause: boolean, reason: string|null}`, `variantStats(db, campaignId) -> [{code, enviados, respostas, taxaResposta, elegivelParaComparacao}]`. Usadas por `campaign-worker.mjs` (Task 6) e `routes/campaigns.mjs` (Task 8).
|
|
|
|
- [ ] **Step 1: Escrever os testes que falham**
|
|
|
|
Criar `tests/campaign-metrics.test.mjs`:
|
|
|
|
```js
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { openDb, createLeadInserter } from '../src/db.mjs';
|
|
import { createCampaign, setCampaignStatus } from '../src/outreach/campaign-store.mjs';
|
|
import { evaluateCampaignHealth, variantStats } from '../src/outreach/campaign-metrics.mjs';
|
|
|
|
function inserirLead(db, dedup) {
|
|
const inserir = createLeadInserter(db);
|
|
inserir({
|
|
dedup_key: dedup, empresa: 'E', legal_name: '', whatsapp: `55119999${dedup.padStart(5, '0')}`,
|
|
ddd: '11', email: '', cidade: '', uf: '', segmento: 'x', segmento_label: '', interesse: '',
|
|
cnpj: '', cnae: '', endereco: '', website: '', pains_json: '[]', origem: 't', telefone_valido: 1,
|
|
cnpj_valido: 0, empresa_valida: 1, score: 50, classificacao: 'bom', motivo_classificacao: '',
|
|
score_qualidade: 50, score_comercial: 50, prioridade: 1, produto_principal: 'ERP',
|
|
produtos_json: '[]', capture_run_id: null, captured_at: null, exported_at: null
|
|
});
|
|
return db.prepare('SELECT id, whatsapp FROM leads WHERE dedup_key = ?').get(dedup);
|
|
}
|
|
|
|
function campanhaRodando(db) {
|
|
const campanha = createCampaign(db, {
|
|
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }]
|
|
});
|
|
setCampaignStatus(db, campanha.id, 'rodando');
|
|
return campanha;
|
|
}
|
|
|
|
function simularEnvio(db, campanhaId, variantId, leadId, numero, status) {
|
|
const sendId = db.prepare(
|
|
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status) VALUES (?, 'ERP', ?, 'oi', ?)`
|
|
).run(leadId, numero, status).lastInsertRowid;
|
|
db.prepare(
|
|
`INSERT INTO outreach_campaign_queue (campaign_id, lead_id, variant_id, numero, rendered_message, status, outreach_send_id)
|
|
VALUES (?, ?, ?, ?, 'oi', ?, ?)`
|
|
).run(campanhaId, leadId, variantId, numero, status === 'erro' ? 'error' : 'sent', sendId);
|
|
}
|
|
|
|
test('evaluateCampaignHealth nao pausa campanha saudavel', () => {
|
|
const db = openDb(':memory:');
|
|
const campanha = campanhaRodando(db);
|
|
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
|
|
for (let i = 0; i < 10; i += 1) {
|
|
const lead = inserirLead(db, `l${i}`);
|
|
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
|
|
}
|
|
const resultado = evaluateCampaignHealth(db, campanha.id);
|
|
assert.equal(resultado.pause, false);
|
|
db.close();
|
|
});
|
|
|
|
test('evaluateCampaignHealth pausa por high_failure_rate quando erro > 10% com 10+ tentativas', () => {
|
|
const db = openDb(':memory:');
|
|
const campanha = campanhaRodando(db);
|
|
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
|
|
for (let i = 0; i < 8; i += 1) {
|
|
const lead = inserirLead(db, `ok${i}`);
|
|
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
|
|
}
|
|
for (let i = 0; i < 2; i += 1) {
|
|
const lead = inserirLead(db, `err${i}`);
|
|
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro');
|
|
}
|
|
const resultado = evaluateCampaignHealth(db, campanha.id);
|
|
assert.equal(resultado.pause, true);
|
|
assert.equal(resultado.reason, 'high_failure_rate');
|
|
const atualizada = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
|
|
assert.equal(atualizada.status, 'pausado');
|
|
db.close();
|
|
});
|
|
|
|
test('evaluateCampaignHealth nao pausa por falha com menos de 10 tentativas no dia', () => {
|
|
const db = openDb(':memory:');
|
|
const campanha = campanhaRodando(db);
|
|
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
|
|
const lead = inserirLead(db, 'so1');
|
|
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'erro');
|
|
const resultado = evaluateCampaignHealth(db, campanha.id);
|
|
assert.equal(resultado.pause, false);
|
|
db.close();
|
|
});
|
|
|
|
test('evaluateCampaignHealth pausa por high_negative_rate quando >10% dos 20+ contatados estao em sem_interesse/opt_out', () => {
|
|
const db = openDb(':memory:');
|
|
const campanha = campanhaRodando(db);
|
|
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
|
|
for (let i = 0; i < 20; i += 1) {
|
|
const lead = inserirLead(db, `neg${i}`);
|
|
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
|
|
if (i < 3) {
|
|
db.prepare(`INSERT INTO outreach_lead_states (lead_id, stage) VALUES (?, 'sem_interesse')`).run(lead.id);
|
|
}
|
|
}
|
|
const resultado = evaluateCampaignHealth(db, campanha.id);
|
|
assert.equal(resultado.pause, true);
|
|
assert.equal(resultado.reason, 'high_negative_rate');
|
|
db.close();
|
|
});
|
|
|
|
test('evaluateCampaignHealth pausa por low_reply_rate quando <5% dos 50+ contatados responderam', () => {
|
|
const db = openDb(':memory:');
|
|
const campanha = campanhaRodando(db);
|
|
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
|
|
for (let i = 0; i < 50; i += 1) {
|
|
const lead = inserirLead(db, `rep${i}`);
|
|
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
|
|
}
|
|
const resultado = evaluateCampaignHealth(db, campanha.id);
|
|
assert.equal(resultado.pause, true);
|
|
assert.equal(resultado.reason, 'low_reply_rate');
|
|
db.close();
|
|
});
|
|
|
|
test('evaluateCampaignHealth em campanha ja pausada nao faz nada', () => {
|
|
const db = openDb(':memory:');
|
|
const campanha = campanhaRodando(db);
|
|
setCampaignStatus(db, campanha.id, 'pausado', 'manual');
|
|
const resultado = evaluateCampaignHealth(db, campanha.id);
|
|
assert.equal(resultado.pause, false);
|
|
db.close();
|
|
});
|
|
|
|
test('variantStats calcula taxa de resposta e elegibilidade (limiar 40 envios)', () => {
|
|
const db = openDb(':memory:');
|
|
const campanha = campanhaRodando(db);
|
|
const variant = db.prepare('SELECT id FROM outreach_campaign_variants WHERE campaign_id = ?').get(campanha.id);
|
|
for (let i = 0; i < 5; i += 1) {
|
|
const lead = inserirLead(db, `v${i}`);
|
|
simularEnvio(db, campanha.id, variant.id, lead.id, lead.whatsapp, 'enviado');
|
|
if (i < 1) {
|
|
db.prepare(`INSERT INTO outreach_stage_events (lead_id, campaign_id, stage) VALUES (?, ?, 'interessado')`).run(lead.id, campanha.id);
|
|
}
|
|
}
|
|
const stats = variantStats(db, campanha.id);
|
|
assert.equal(stats.length, 1);
|
|
assert.equal(stats[0].enviados, 5);
|
|
assert.equal(stats[0].respostas, 1);
|
|
assert.equal(stats[0].taxaResposta, 0.2);
|
|
assert.equal(stats[0].elegivelParaComparacao, false);
|
|
db.close();
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Rodar e confirmar que falha**
|
|
|
|
Run: `node --test tests/campaign-metrics.test.mjs`
|
|
Expected: FAIL — módulo não existe
|
|
|
|
- [ ] **Step 3: Implementar**
|
|
|
|
Criar `src/outreach/campaign-metrics.mjs`:
|
|
|
|
```js
|
|
function dateKey(date) {
|
|
const y = date.getFullYear();
|
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
const d = String(date.getDate()).padStart(2, '0');
|
|
return `${y}-${m}-${d}`;
|
|
}
|
|
|
|
function applyPause(db, campaignId, reason) {
|
|
db.prepare(
|
|
`UPDATE outreach_campaigns SET status = 'pausado', pause_reason = ?, updated_at = datetime('now')
|
|
WHERE id = ? AND status = 'rodando'`
|
|
).run(reason, campaignId);
|
|
}
|
|
|
|
// Mesmos 3 criterios e limiares numericos da Maquina Pessoal (10 tentativas/
|
|
// 10% erro, 20 contatos/10% negativo+opt-out, 50 contatos/5% resposta),
|
|
// adaptados as tabelas da Comercial via JOIN por outreach_campaign_queue
|
|
// (nao por um valor de origem dedicado).
|
|
export function evaluateCampaignHealth(db, campaignId, now = new Date()) {
|
|
const campaign = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campaignId);
|
|
if (!campaign || campaign.status !== 'rodando') return { pause: false, reason: null };
|
|
|
|
const todayKey = dateKey(now);
|
|
const attemptsToday = Number(db.prepare(
|
|
`SELECT COUNT(*) AS total
|
|
FROM outreach_campaign_queue q
|
|
JOIN outreach_sends s ON s.id = q.outreach_send_id
|
|
WHERE q.campaign_id = ? AND date(s.sent_at, 'localtime') = ?`
|
|
).get(campaignId, todayKey).total);
|
|
|
|
if (attemptsToday >= 10) {
|
|
const failureCount = Number(db.prepare(
|
|
`SELECT COUNT(*) AS total
|
|
FROM outreach_campaign_queue q
|
|
JOIN outreach_sends s ON s.id = q.outreach_send_id
|
|
WHERE q.campaign_id = ? AND s.status = 'erro' AND date(s.sent_at, 'localtime') = ?`
|
|
).get(campaignId, todayKey).total);
|
|
if (failureCount / attemptsToday > 0.10) {
|
|
applyPause(db, campaignId, 'high_failure_rate');
|
|
return { pause: true, reason: 'high_failure_rate' };
|
|
}
|
|
}
|
|
|
|
const contacts = Number(db.prepare(
|
|
`SELECT COUNT(*) AS total FROM outreach_campaign_queue WHERE campaign_id = ? AND status IN ('sent','error')`
|
|
).get(campaignId).total);
|
|
|
|
if (contacts >= 20) {
|
|
const negativeCount = Number(db.prepare(
|
|
`SELECT COUNT(DISTINCT q.lead_id) AS total
|
|
FROM outreach_campaign_queue q
|
|
JOIN outreach_lead_states ls ON ls.lead_id = q.lead_id
|
|
WHERE q.campaign_id = ? AND q.status IN ('sent','error')
|
|
AND ls.stage IN ('sem_interesse', 'opt_out')`
|
|
).get(campaignId).total);
|
|
if (negativeCount / contacts > 0.10) {
|
|
applyPause(db, campaignId, 'high_negative_rate');
|
|
return { pause: true, reason: 'high_negative_rate' };
|
|
}
|
|
}
|
|
|
|
if (contacts >= 50) {
|
|
const respondedCount = Number(db.prepare(
|
|
`SELECT COUNT(DISTINCT q.lead_id) AS total
|
|
FROM outreach_campaign_queue q
|
|
JOIN outreach_stage_events se ON se.lead_id = q.lead_id AND se.campaign_id = q.campaign_id
|
|
WHERE q.campaign_id = ? AND q.status IN ('sent','error')`
|
|
).get(campaignId).total);
|
|
if (respondedCount / contacts < 0.05) {
|
|
applyPause(db, campaignId, 'low_reply_rate');
|
|
return { pause: true, reason: 'low_reply_rate' };
|
|
}
|
|
}
|
|
|
|
return { pause: false, reason: null };
|
|
}
|
|
|
|
// Numero de leads distintos com resposta por variante, via subquery
|
|
// correlacionada (nao um JOIN direto) para nao contar duplicado quando um
|
|
// lead tem mais de um outreach_stage_events.
|
|
export function variantStats(db, campaignId) {
|
|
const rows = db.prepare(
|
|
`SELECT v.id AS variant_id, v.code,
|
|
COUNT(*) AS enviados,
|
|
(SELECT COUNT(DISTINCT q2.lead_id)
|
|
FROM outreach_campaign_queue q2
|
|
JOIN outreach_stage_events se ON se.lead_id = q2.lead_id AND se.campaign_id = q2.campaign_id
|
|
WHERE q2.campaign_id = q.campaign_id AND q2.variant_id = v.id AND q2.status IN ('sent','error')
|
|
) AS respostas
|
|
FROM outreach_campaign_queue q
|
|
JOIN outreach_campaign_variants v ON v.id = q.variant_id
|
|
WHERE q.campaign_id = ? AND q.status IN ('sent','error')
|
|
GROUP BY v.id, v.code`
|
|
).all(campaignId);
|
|
return rows.map((row) => ({
|
|
code: row.code,
|
|
enviados: row.enviados,
|
|
respostas: row.respostas,
|
|
taxaResposta: row.enviados > 0 ? row.respostas / row.enviados : 0,
|
|
elegivelParaComparacao: row.enviados >= 40
|
|
}));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Rodar e confirmar que passa**
|
|
|
|
Run: `node --test tests/campaign-metrics.test.mjs`
|
|
Expected: PASS (7 testes)
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add src/outreach/campaign-metrics.mjs tests/campaign-metrics.test.mjs
|
|
git commit -m "Add campaign health auto-pause and variant comparison stats"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: `campaign-worker.mjs` — envio das linhas aprovadas
|
|
|
|
**Files:**
|
|
- Create: `src/outreach/campaign-worker.mjs`
|
|
- Test: `tests/campaign-worker.test.mjs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `isOptedOut` de `./campaign-store.mjs` (Task 2), `evaluateCampaignHealth` de `./campaign-metrics.mjs` (Task 5), `createZproClient` de `./zpro-client.mjs` (já existe).
|
|
- Produces: `runCampaignQueue({db, zproClient, now, log}) -> {enviados, erros, pulados}`. Usada por `campaign-scheduler.mjs` (Task 7).
|
|
|
|
- [ ] **Step 1: Escrever os testes que falham**
|
|
|
|
Criar `tests/campaign-worker.test.mjs`:
|
|
|
|
```js
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { openDb, createLeadInserter } from '../src/db.mjs';
|
|
import { createCampaign, setCampaignStatus, addOptOut } from '../src/outreach/campaign-store.mjs';
|
|
import { generateCampaignQueue, listQueue, approveQueueRows } from '../src/outreach/campaign-queue.mjs';
|
|
import { runCampaignQueue } from '../src/outreach/campaign-worker.mjs';
|
|
|
|
function inserirLead(db, dedup, whatsapp) {
|
|
const inserir = createLeadInserter(db);
|
|
inserir({
|
|
dedup_key: dedup, empresa: 'Empresa', legal_name: '', whatsapp, ddd: '11', email: '',
|
|
cidade: '', uf: '', segmento: 'x', segmento_label: '', interesse: '', cnpj: '', cnae: '',
|
|
endereco: '', website: '', pains_json: '[]', origem: 't', telefone_valido: 1, cnpj_valido: 0,
|
|
empresa_valida: 1, score: 50, classificacao: 'bom', motivo_classificacao: '', score_qualidade: 50,
|
|
score_comercial: 50, prioridade: 1, produto_principal: 'ERP', produtos_json: '[]',
|
|
capture_run_id: null, captured_at: null, exported_at: null
|
|
});
|
|
}
|
|
|
|
function campanhaAprovada(db, quantidadeLeads = 1) {
|
|
for (let i = 0; i < quantidadeLeads; i += 1) {
|
|
inserirLead(db, `lead-${i}`, `551199999000${i}`);
|
|
}
|
|
const campanha = createCampaign(db, {
|
|
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'Ola {empresa}' }],
|
|
limiteDiarioInicial: 10
|
|
});
|
|
setCampaignStatus(db, campanha.id, 'rodando');
|
|
generateCampaignQueue(db, campanha.id);
|
|
const fila = listQueue(db, campanha.id, 'preview');
|
|
approveQueueRows(db, campanha.id, fila.map((r) => r.id));
|
|
return campanha;
|
|
}
|
|
|
|
function fakeZproClient(overrides = {}) {
|
|
const enviados = [];
|
|
return {
|
|
enviados,
|
|
sendText: async (args) => { enviados.push(args); return { data: { ticketId: 999 } }; },
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
test('runCampaignQueue envia linhas aprovadas via sendText e marca sent', async () => {
|
|
const db = openDb(':memory:');
|
|
const campanha = campanhaAprovada(db);
|
|
const zproClient = fakeZproClient();
|
|
|
|
const resumo = await runCampaignQueue({ db, zproClient });
|
|
assert.equal(resumo.enviados, 1);
|
|
assert.equal(zproClient.enviados.length, 1);
|
|
assert.equal(zproClient.enviados[0].message, 'Ola Empresa');
|
|
|
|
const fila = listQueue(db, campanha.id, 'sent');
|
|
assert.equal(fila.length, 1);
|
|
assert.ok(fila[0].outreach_send_id);
|
|
const send = db.prepare('SELECT * FROM outreach_sends WHERE id = ?').get(fila[0].outreach_send_id);
|
|
assert.equal(send.status, 'enviado');
|
|
assert.equal(send.origem, 'automatico');
|
|
db.close();
|
|
});
|
|
|
|
test('runCampaignQueue registra erro quando o Z-PRO falha, sem travar o restante', async () => {
|
|
const db = openDb(':memory:');
|
|
const campanha = campanhaAprovada(db, 2);
|
|
let chamada = 0;
|
|
const zproClient = fakeZproClient({
|
|
sendText: async (args) => {
|
|
chamada += 1;
|
|
if (chamada === 1) throw new Error('Z-PRO falhou');
|
|
return { data: { ticketId: 1 } };
|
|
}
|
|
});
|
|
|
|
const resumo = await runCampaignQueue({ db, zproClient });
|
|
assert.equal(resumo.enviados, 1);
|
|
assert.equal(resumo.erros, 1);
|
|
const erro = listQueue(db, campanha.id, 'error')[0];
|
|
const send = db.prepare('SELECT * FROM outreach_sends WHERE id = ?').get(erro.outreach_send_id);
|
|
assert.equal(send.status, 'erro');
|
|
db.close();
|
|
});
|
|
|
|
test('runCampaignQueue respeita o limite diario da campanha', async () => {
|
|
const db = openDb(':memory:');
|
|
for (let i = 0; i < 3; i += 1) inserirLead(db, `l${i}`, `551199999000${i}`);
|
|
const campanha = createCampaign(db, {
|
|
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }],
|
|
limiteDiarioInicial: 1
|
|
});
|
|
setCampaignStatus(db, campanha.id, 'rodando');
|
|
generateCampaignQueue(db, campanha.id);
|
|
approveQueueRows(db, campanha.id, listQueue(db, campanha.id, 'preview').map((r) => r.id));
|
|
|
|
const zproClient = fakeZproClient();
|
|
const resumo = await runCampaignQueue({ db, zproClient });
|
|
assert.equal(resumo.enviados, 1);
|
|
assert.equal(zproClient.enviados.length, 1);
|
|
assert.equal(listQueue(db, campanha.id, 'approved').length, 2);
|
|
db.close();
|
|
});
|
|
|
|
test('runCampaignQueue pula e cancela linha de numero que entrou em opt-out depois da fila gerada', async () => {
|
|
const db = openDb(':memory:');
|
|
const campanha = campanhaAprovada(db);
|
|
addOptOut(db, '5511999990000', 'pediu pra parar');
|
|
db.prepare(`UPDATE outreach_campaign_queue SET numero = '5511999990000' WHERE campaign_id = ?`).run(campanha.id);
|
|
|
|
const zproClient = fakeZproClient();
|
|
const resumo = await runCampaignQueue({ db, zproClient });
|
|
assert.equal(resumo.pulados, 1);
|
|
assert.equal(zproClient.enviados.length, 0);
|
|
const cancelada = listQueue(db, campanha.id, 'cancelled')[0];
|
|
assert.equal(cancelada.cancel_reason, 'opt_out');
|
|
db.close();
|
|
});
|
|
|
|
test('runCampaignQueue so processa campanhas com status rodando', async () => {
|
|
const db = openDb(':memory:');
|
|
inserirLead(db, 'l1', '5511999990001');
|
|
const campanha = createCampaign(db, {
|
|
nome: 'C', produto: 'ERP', segmentos: ['x'], variantes: [{ code: 'A', mensagem: 'oi' }]
|
|
});
|
|
generateCampaignQueue(db, campanha.id);
|
|
approveQueueRows(db, campanha.id, listQueue(db, campanha.id, 'preview').map((r) => r.id));
|
|
// campanha continua em 'rascunho' — nunca foi iniciada
|
|
|
|
const zproClient = fakeZproClient();
|
|
const resumo = await runCampaignQueue({ db, zproClient });
|
|
assert.equal(resumo.enviados, 0);
|
|
assert.equal(zproClient.enviados.length, 0);
|
|
db.close();
|
|
});
|
|
|
|
test('runCampaignQueue interrompe a campanha quando a saude aciona auto-pausa', async () => {
|
|
const db = openDb(':memory:');
|
|
const campanha = campanhaAprovada(db, 10);
|
|
db.prepare(`UPDATE outreach_campaigns SET limite_diario_atual = 10 WHERE id = ?`).run(campanha.id);
|
|
let chamada = 0;
|
|
const zproClient = fakeZproClient({
|
|
sendText: async () => {
|
|
chamada += 1;
|
|
if (chamada <= 8) return { data: { ticketId: chamada } };
|
|
throw new Error('falha simulada');
|
|
}
|
|
});
|
|
|
|
await runCampaignQueue({ db, zproClient });
|
|
const atualizada = db.prepare('SELECT status FROM outreach_campaigns WHERE id = ?').get(campanha.id);
|
|
// 10 tentativas, 2 erros = 20% > 10% -> deveria pausar antes de esgotar a fila
|
|
assert.equal(atualizada.status, 'pausado');
|
|
db.close();
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Rodar e confirmar que falha**
|
|
|
|
Run: `node --test tests/campaign-worker.test.mjs`
|
|
Expected: FAIL — módulo não existe
|
|
|
|
- [ ] **Step 3: Implementar**
|
|
|
|
Criar `src/outreach/campaign-worker.mjs`:
|
|
|
|
```js
|
|
import { createZproClient } from './zpro-client.mjs';
|
|
import { isOptedOut } from './campaign-store.mjs';
|
|
import { evaluateCampaignHealth } from './campaign-metrics.mjs';
|
|
|
|
function todayAttempts(db, campaignId) {
|
|
return Number(db.prepare(
|
|
`SELECT COUNT(*) AS total
|
|
FROM outreach_campaign_queue q
|
|
JOIN outreach_sends s ON s.id = q.outreach_send_id
|
|
WHERE q.campaign_id = ? AND date(s.sent_at, 'localtime') = date('now', 'localtime')`
|
|
).get(campaignId).total);
|
|
}
|
|
|
|
// Diferente da Maquina Pessoal (que reserva o envio antes de mandar, porque a
|
|
// entrega via Baileys e assincrona/incerta), aqui o envio via Z-PRO
|
|
// (zproClient.sendText) e sincrono: ou resolve (aceito pelo Z-PRO) ou lanca
|
|
// erro. Por isso o resultado ja e gravado direto como 'enviado'/'erro', sem
|
|
// um estado intermediario "reservado" nem logica de recuperacao pos-crash.
|
|
export async function runCampaignQueue({ db, zproClient, now = new Date(), log = () => {} }) {
|
|
const client = zproClient || createZproClient(db);
|
|
const summary = { enviados: 0, erros: 0, pulados: 0 };
|
|
|
|
const campaigns = db.prepare(`SELECT id FROM outreach_campaigns WHERE status = 'rodando'`).all();
|
|
for (const { id: campaignId } of campaigns) {
|
|
const campaign = db.prepare('SELECT * FROM outreach_campaigns WHERE id = ?').get(campaignId);
|
|
let attemptsToday = todayAttempts(db, campaignId);
|
|
|
|
while (attemptsToday < campaign.limite_diario_atual) {
|
|
const row = db.prepare(
|
|
`SELECT * FROM outreach_campaign_queue
|
|
WHERE campaign_id = ? AND status = 'approved'
|
|
ORDER BY id ASC LIMIT 1`
|
|
).get(campaignId);
|
|
if (!row) break;
|
|
|
|
if (isOptedOut(db, row.numero)) {
|
|
db.prepare(
|
|
`UPDATE outreach_campaign_queue SET status = 'cancelled', cancel_reason = 'opt_out', updated_at = datetime('now') WHERE id = ?`
|
|
).run(row.id);
|
|
summary.pulados += 1;
|
|
continue;
|
|
}
|
|
|
|
const jaEnviado = db.prepare(
|
|
`SELECT 1 FROM outreach_sends WHERE produto = ? AND status = 'enviado'
|
|
AND (lead_id = ? OR numero = ?) LIMIT 1`
|
|
).get(campaign.produto, row.lead_id, row.numero);
|
|
if (jaEnviado) {
|
|
db.prepare(
|
|
`UPDATE outreach_campaign_queue SET status = 'cancelled', cancel_reason = 'ja_contatado', updated_at = datetime('now') WHERE id = ?`
|
|
).run(row.id);
|
|
summary.pulados += 1;
|
|
continue;
|
|
}
|
|
|
|
const externalKey = `campanha-${campaignId}-${row.id}`;
|
|
const insert = db.prepare(
|
|
`INSERT INTO outreach_sends (lead_id, produto, numero, mensagem_enviada, status, erro, zpro_ticket_id, canal, origem)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, 'nao_oficial', 'automatico')`
|
|
);
|
|
let sendId;
|
|
let queueStatus;
|
|
try {
|
|
const resposta = await client.sendText({ number: row.numero, message: row.rendered_message, externalKey });
|
|
const ticketId = resposta?.data?.ticketId ? String(resposta.data.ticketId) : null;
|
|
sendId = Number(insert.run(row.lead_id, campaign.produto, row.numero, row.rendered_message, 'enviado', null, ticketId).lastInsertRowid);
|
|
queueStatus = 'sent';
|
|
summary.enviados += 1;
|
|
} catch (error) {
|
|
sendId = Number(insert.run(row.lead_id, campaign.produto, row.numero, row.rendered_message, 'erro', String(error.message || error), null).lastInsertRowid);
|
|
queueStatus = 'error';
|
|
summary.erros += 1;
|
|
log(`campanha #${campaignId}: envio falhou (fila #${row.id}): ${error.message}`);
|
|
}
|
|
db.prepare(
|
|
`UPDATE outreach_campaign_queue SET status = ?, outreach_send_id = ?, updated_at = datetime('now') WHERE id = ?`
|
|
).run(queueStatus, sendId, row.id);
|
|
|
|
attemptsToday += 1;
|
|
const health = evaluateCampaignHealth(db, campaignId, now);
|
|
if (health.pause) {
|
|
log(`campanha #${campaignId}: pausada automaticamente (${health.reason})`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return summary;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Rodar e confirmar que passa**
|
|
|
|
Run: `node --test tests/campaign-worker.test.mjs`
|
|
Expected: PASS (6 testes)
|
|
|
|
- [ ] **Step 5: Rodar a suite inteira**
|
|
|
|
Run: `node --test tests/*.test.mjs`
|
|
Expected: todos os testes passam
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add src/outreach/campaign-worker.mjs tests/campaign-worker.test.mjs
|
|
git commit -m "Add campaign send worker with daily limit and health auto-pause"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: `campaign-scheduler.mjs` — timer + wiring no boot do servidor
|
|
|
|
**Files:**
|
|
- Create: `src/outreach/campaign-scheduler.mjs`
|
|
- Modify: `scripts/servidor.mjs`
|
|
- Test: `tests/campaign-scheduler.test.mjs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `runCampaignQueue` de `./campaign-worker.mjs` (Task 6), `escalateWeeklyLimits` de `./campaign-store.mjs` (Task 2).
|
|
- Produces: `startCampaignScheduler({db, log}) -> stopFn`.
|
|
|
|
- [ ] **Step 1: Escrever o teste que falha**
|
|
|
|
Criar `tests/campaign-scheduler.test.mjs`:
|
|
|
|
```js
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { openDb } from '../src/db.mjs';
|
|
import { startCampaignScheduler } from '../src/outreach/campaign-scheduler.mjs';
|
|
|
|
test('startCampaignScheduler retorna funcao de parada que limpa os timers sem lancar erro', () => {
|
|
const db = openDb(':memory:');
|
|
const stop = startCampaignScheduler({ db, log: () => {} });
|
|
assert.equal(typeof stop, 'function');
|
|
assert.doesNotThrow(() => stop());
|
|
db.close();
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Rodar e confirmar que falha**
|
|
|
|
Run: `node --test tests/campaign-scheduler.test.mjs`
|
|
Expected: FAIL — módulo não existe
|
|
|
|
- [ ] **Step 3: Implementar**
|
|
|
|
Criar `src/outreach/campaign-scheduler.mjs`:
|
|
|
|
```js
|
|
import { runCampaignQueue } from './campaign-worker.mjs';
|
|
import { escalateWeeklyLimits } from './campaign-store.mjs';
|
|
|
|
const CICLO_MS = 20 * 60 * 1000;
|
|
|
|
// Timer interno separado do scheduler.mjs existente (run.mjs/runOutreachBatch)
|
|
// — mesmo padrao (setInterval com .unref(), primeiro ciclo antecipado pouco
|
|
// depois do boot), rodando ao lado sem interferir um no outro.
|
|
export function startCampaignScheduler({ db, log = () => {} }) {
|
|
const ciclo = () => {
|
|
escalateWeeklyLimits(db);
|
|
runCampaignQueue({ db, log }).catch((error) => {
|
|
log(`campanhas: erro inesperado no ciclo automatico: ${error.message}`);
|
|
});
|
|
};
|
|
|
|
const primeiro = setTimeout(ciclo, 45 * 1000);
|
|
primeiro.unref();
|
|
const timer = setInterval(ciclo, CICLO_MS);
|
|
timer.unref();
|
|
return () => {
|
|
clearTimeout(primeiro);
|
|
clearInterval(timer);
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Rodar e confirmar que passa**
|
|
|
|
Run: `node --test tests/campaign-scheduler.test.mjs`
|
|
Expected: PASS
|
|
|
|
- [ ] **Step 5: Ligar no boot do servidor**
|
|
|
|
Em `scripts/servidor.mjs`, adicionar o import junto aos demais (depois de `import { startOutreachScheduler } from '../src/outreach/scheduler.mjs';`):
|
|
|
|
```js
|
|
import { startCampaignScheduler } from '../src/outreach/campaign-scheduler.mjs';
|
|
```
|
|
|
|
E logo depois de `startOutreachScheduler({ db, log });`:
|
|
|
|
```js
|
|
startCampaignScheduler({ db, log });
|
|
```
|
|
|
|
- [ ] **Step 6: Rodar a suite inteira**
|
|
|
|
Run: `node --test tests/*.test.mjs`
|
|
Expected: todos os testes passam (o boot do servidor em si não tem teste automatizado — verificação manual: `npm start` sobe sem erro)
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/outreach/campaign-scheduler.mjs scripts/servidor.mjs tests/campaign-scheduler.test.mjs
|
|
git commit -m "Wire campaign scheduler into server boot"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Rotas HTTP (`src/server/routes/campaigns.mjs`)
|
|
|
|
**Files:**
|
|
- Create: `src/server/routes/campaigns.mjs`
|
|
- Modify: `src/server/index.mjs`
|
|
- Test: `tests/campaign-routes.test.mjs`
|
|
|
|
**Interfaces:**
|
|
- Consumes: tudo das Tasks 2, 4, 5 (`campaign-store.mjs`, `campaign-queue.mjs`, `campaign-metrics.mjs`).
|
|
- Produces: `registerCampaignRoutes(router, {db})`, registrada em `createAppServer`.
|
|
|
|
- [ ] **Step 1: Escrever os testes que falham**
|
|
|
|
Criar `tests/campaign-routes.test.mjs`:
|
|
|
|
```js
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { openDb, createLeadInserter } from '../src/db.mjs';
|
|
import { createRouter } from '../src/server/router.mjs';
|
|
import { registerCampaignRoutes } from '../src/server/routes/campaigns.mjs';
|
|
|
|
function setupRouter(db) {
|
|
const router = createRouter();
|
|
registerCampaignRoutes(router, { db });
|
|
return router;
|
|
}
|
|
|
|
function fakeJson() {
|
|
const calls = [];
|
|
return { json: (status, data) => calls.push({ status, data }), calls };
|
|
}
|
|
|
|
function inserirLead(db, dedup, produto = 'ERP', segmento = 'oficinas') {
|
|
const inserir = createLeadInserter(db);
|
|
inserir({
|
|
dedup_key: dedup, empresa: 'Empresa', legal_name: '', whatsapp: `551199990${dedup}`, ddd: '11',
|
|
email: '', cidade: '', uf: '', segmento, segmento_label: '', interesse: '', cnpj: '', cnae: '',
|
|
endereco: '', website: '', pains_json: '[]', origem: 't', telefone_valido: 1, cnpj_valido: 0,
|
|
empresa_valida: 1, score: 50, classificacao: 'bom', motivo_classificacao: '', score_qualidade: 50,
|
|
score_comercial: 50, prioridade: 1, produto_principal: produto, produtos_json: '[]',
|
|
capture_run_id: null, captured_at: null, exported_at: null
|
|
});
|
|
}
|
|
|
|
test('POST /api/campaigns cria campanha com segmentos e variantes', async () => {
|
|
const db = openDb(':memory:');
|
|
const router = setupRouter(db);
|
|
const { handler } = router.match('POST', '/api/campaigns');
|
|
const { json, calls } = fakeJson();
|
|
|
|
await handler(null, null, { json, body: {
|
|
nome: 'ERP oficinas', produto: 'ERP', segmentos: ['oficinas'],
|
|
variantes: [{ mensagem: 'Ola {empresa}' }]
|
|
} });
|
|
|
|
assert.equal(calls[0].status, 200);
|
|
assert.equal(calls[0].data.produto, 'ERP');
|
|
assert.equal(calls[0].data.variants.length, 1);
|
|
db.close();
|
|
});
|
|
|
|
test('POST /api/campaigns rejeita sem segmentos', async () => {
|
|
const db = openDb(':memory:');
|
|
const router = setupRouter(db);
|
|
const { handler } = router.match('POST', '/api/campaigns');
|
|
const { json, calls } = fakeJson();
|
|
|
|
await handler(null, null, { json, body: { nome: 'X', produto: 'ERP', segmentos: [], variantes: [{ mensagem: 'oi' }] } });
|
|
assert.equal(calls[0].status, 400);
|
|
db.close();
|
|
});
|
|
|
|
test('GET /api/campaigns/segmentos lista segmentos distintos do produto com contagem', async () => {
|
|
const db = openDb(':memory:');
|
|
inserirLead(db, '1', 'ERP', 'oficinas');
|
|
inserirLead(db, '2', 'ERP', 'oficinas');
|
|
inserirLead(db, '3', 'ERP', 'padarias');
|
|
inserirLead(db, '4', 'G-Chat', 'padarias');
|
|
const router = setupRouter(db);
|
|
const { handler } = router.match('GET', '/api/campaigns/segmentos');
|
|
const { json, calls } = fakeJson();
|
|
|
|
await handler(null, null, { json, query: new URLSearchParams({ produto: 'ERP' }) });
|
|
assert.equal(calls[0].status, 200);
|
|
const segmentos = calls[0].data.segmentos;
|
|
assert.equal(segmentos.find((s) => s.value === 'oficinas').total, 2);
|
|
assert.equal(segmentos.find((s) => s.value === 'padarias').total, 1);
|
|
db.close();
|
|
});
|
|
|
|
test('fluxo completo: criar campanha, gerar fila, aprovar e checar saude', async () => {
|
|
const db = openDb(':memory:');
|
|
inserirLead(db, '1', 'ERP', 'oficinas');
|
|
const router = setupRouter(db);
|
|
|
|
const criar = router.match('POST', '/api/campaigns');
|
|
const respCriar = fakeJson();
|
|
await criar.handler(null, null, { json: respCriar.json, body: {
|
|
nome: 'C', produto: 'ERP', segmentos: ['oficinas'], variantes: [{ mensagem: 'Ola {empresa}' }]
|
|
} });
|
|
const campaignId = respCriar.calls[0].data.id;
|
|
|
|
const gerarFila = router.match('POST', `/api/campaigns/${campaignId}/gerar-fila`);
|
|
const respFila = fakeJson();
|
|
await gerarFila.handler(null, null, { json: respFila.json, params: { id: String(campaignId) }, body: {} });
|
|
assert.equal(respFila.calls[0].data.adicionadosNaFila, 1);
|
|
|
|
const listarFila = router.match('GET', `/api/campaigns/${campaignId}/fila`);
|
|
const respLista = fakeJson();
|
|
await listarFila.handler(null, null, { json: respLista.json, params: { id: String(campaignId) }, query: new URLSearchParams() });
|
|
const queueId = respLista.calls[0].data.fila[0].id;
|
|
|
|
const aprovar = router.match('POST', `/api/campaigns/${campaignId}/fila/aprovar`);
|
|
const respAprovar = fakeJson();
|
|
await aprovar.handler(null, null, { json: respAprovar.json, params: { id: String(campaignId) }, body: { queueIds: [queueId] } });
|
|
assert.equal(respAprovar.calls[0].data.aprovados, 1);
|
|
|
|
const saude = router.match('GET', `/api/campaigns/${campaignId}/saude`);
|
|
const respSaude = fakeJson();
|
|
await saude.handler(null, null, { json: respSaude.json, params: { id: String(campaignId) } });
|
|
assert.equal(respSaude.calls[0].data.pause, false);
|
|
db.close();
|
|
});
|
|
|
|
test('POST /api/campaigns/leads/:leadId/estagio grava estagio atual e historico', async () => {
|
|
const db = openDb(':memory:');
|
|
inserirLead(db, '1');
|
|
const lead = db.prepare('SELECT id FROM leads LIMIT 1').get();
|
|
const router = setupRouter(db);
|
|
|
|
const marcar = router.match('POST', '/api/campaigns/leads/:leadId/estagio');
|
|
const respMarcar = fakeJson();
|
|
await marcar.handler(null, null, { json: respMarcar.json, params: { leadId: String(lead.id) }, body: { stage: 'interessado', note: 'ligar amanha' } });
|
|
assert.equal(respMarcar.calls[0].status, 200);
|
|
|
|
const consultar = router.match('GET', '/api/campaigns/leads/:leadId/estagio');
|
|
const respConsultar = fakeJson();
|
|
await consultar.handler(null, null, { json: respConsultar.json, params: { leadId: String(lead.id) } });
|
|
assert.equal(respConsultar.calls[0].data.atual.stage, 'interessado');
|
|
assert.equal(respConsultar.calls[0].data.historico.length, 1);
|
|
db.close();
|
|
});
|
|
|
|
test('POST /api/campaigns/opt-out bloqueia e DELETE libera', async () => {
|
|
const db = openDb(':memory:');
|
|
const router = setupRouter(db);
|
|
|
|
const adicionar = router.match('POST', '/api/campaigns/opt-out');
|
|
await adicionar.handler(null, null, { json: fakeJson().json, body: { numero: '5511999990001', motivo: 'pediu' } });
|
|
assert.ok(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001'));
|
|
|
|
const remover = router.match('DELETE', '/api/campaigns/opt-out/:numero');
|
|
await remover.handler(null, null, { json: fakeJson().json, params: { numero: '5511999990001' } });
|
|
assert.equal(db.prepare('SELECT 1 FROM outreach_opt_out WHERE numero = ?').get('5511999990001'), undefined);
|
|
db.close();
|
|
});
|
|
```
|
|
|
|
- [ ] **Step 2: Rodar e confirmar que falha**
|
|
|
|
Run: `node --test tests/campaign-routes.test.mjs`
|
|
Expected: FAIL — módulo não existe
|
|
|
|
- [ ] **Step 3: Implementar**
|
|
|
|
Criar `src/server/routes/campaigns.mjs`:
|
|
|
|
```js
|
|
import {
|
|
createCampaign, getCampaign, listCampaigns, setCampaignStatus, resumeCampaign,
|
|
addOptOut, removeOptOut
|
|
} from '../../outreach/campaign-store.mjs';
|
|
import { generateCampaignQueue, listQueue, approveQueueRows, cancelQueueRows } from '../../outreach/campaign-queue.mjs';
|
|
import { evaluateCampaignHealth, variantStats } from '../../outreach/campaign-metrics.mjs';
|
|
|
|
export function registerCampaignRoutes(router, { db }) {
|
|
router.add('POST', '/api/campaigns', async (req, res, { json, body }) => {
|
|
const nome = String(body?.nome || '').trim();
|
|
const produto = String(body?.produto || '').trim();
|
|
const segmentos = Array.isArray(body?.segmentos) ? body.segmentos.filter(Boolean) : [];
|
|
const variantesValidas = Array.isArray(body?.variantes)
|
|
? body.variantes.filter((v) => v && String(v.mensagem || '').trim())
|
|
: [];
|
|
if (!nome || !produto) { json(400, { erro: 'informe nome e produto' }); return; }
|
|
if (!segmentos.length) { json(400, { erro: 'escolha ao menos um segmento' }); return; }
|
|
if (!variantesValidas.length) { json(400, { erro: 'escreva ao menos uma variante de mensagem' }); return; }
|
|
|
|
const campanha = createCampaign(db, {
|
|
nome, produto, segmentos,
|
|
variantes: variantesValidas.map((v, i) => ({ code: v.code || String.fromCharCode(65 + i), mensagem: v.mensagem })),
|
|
limiteDiarioInicial: Number(body.limiteDiarioInicial) || 10,
|
|
limiteDiarioTeto: Number(body.limiteDiarioTeto) || 80
|
|
});
|
|
json(200, campanha);
|
|
});
|
|
|
|
router.add('GET', '/api/campaigns', async (req, res, { json }) => {
|
|
json(200, { campanhas: listCampaigns(db) });
|
|
});
|
|
|
|
// Precisa vir ANTES de GET /api/campaigns/:id (mesmo numero de segmentos no
|
|
// path — o router retorna a primeira rota que casar).
|
|
router.add('GET', '/api/campaigns/segmentos', async (req, res, { json, query }) => {
|
|
const produto = String(query?.get('produto') || '').trim();
|
|
if (!produto) { json(400, { erro: 'informe o produto' }); return; }
|
|
const segmentos = db.prepare(
|
|
`SELECT segmento AS value, COUNT(*) AS total FROM leads
|
|
WHERE produto_principal = ? AND segmento != '' GROUP BY segmento ORDER BY total DESC`
|
|
).all(produto);
|
|
json(200, { segmentos });
|
|
});
|
|
|
|
router.add('GET', '/api/campaigns/produtos', async (req, res, { json }) => {
|
|
const produtos = db.prepare(
|
|
`SELECT produto_principal AS value, COUNT(*) AS total FROM leads
|
|
WHERE produto_principal != '' GROUP BY produto_principal ORDER BY total DESC`
|
|
).all();
|
|
json(200, { produtos });
|
|
});
|
|
|
|
router.add('GET', '/api/campaigns/:id', async (req, res, { json, params }) => {
|
|
const campanha = getCampaign(db, Number(params.id));
|
|
if (!campanha) { json(404, { erro: 'campanha nao encontrada' }); return; }
|
|
json(200, campanha);
|
|
});
|
|
|
|
router.add('POST', '/api/campaigns/:id/gerar-fila', async (req, res, { json, params, body }) => {
|
|
try {
|
|
const resultado = generateCampaignQueue(db, Number(params.id), { quantidade: Number(body?.quantidade) || 50 });
|
|
json(200, resultado);
|
|
} catch (error) {
|
|
json(400, { erro: String(error.message || error) });
|
|
}
|
|
});
|
|
|
|
router.add('GET', '/api/campaigns/:id/fila', async (req, res, { json, params, query }) => {
|
|
json(200, { fila: listQueue(db, Number(params.id), query?.get('status') || null) });
|
|
});
|
|
|
|
router.add('POST', '/api/campaigns/:id/fila/aprovar', async (req, res, { json, params, body }) => {
|
|
const queueIds = Array.isArray(body?.queueIds) ? body.queueIds.map(Number).filter(Number.isFinite) : [];
|
|
json(200, { aprovados: approveQueueRows(db, Number(params.id), queueIds) });
|
|
});
|
|
|
|
router.add('POST', '/api/campaigns/:id/fila/cancelar', async (req, res, { json, params, body }) => {
|
|
const queueIds = Array.isArray(body?.queueIds) ? body.queueIds.map(Number).filter(Number.isFinite) : [];
|
|
json(200, { cancelados: cancelQueueRows(db, Number(params.id), queueIds, body?.motivo || 'operador') });
|
|
});
|
|
|
|
router.add('POST', '/api/campaigns/:id/iniciar', async (req, res, { json, params }) => {
|
|
json(200, setCampaignStatus(db, Number(params.id), 'rodando'));
|
|
});
|
|
|
|
router.add('POST', '/api/campaigns/:id/pausar', async (req, res, { json, params, body }) => {
|
|
json(200, setCampaignStatus(db, Number(params.id), 'pausado', body?.motivo || 'manual'));
|
|
});
|
|
|
|
router.add('POST', '/api/campaigns/:id/retomar', async (req, res, { json, params }) => {
|
|
json(200, resumeCampaign(db, Number(params.id)));
|
|
});
|
|
|
|
router.add('GET', '/api/campaigns/:id/saude', async (req, res, { json, params }) => {
|
|
json(200, evaluateCampaignHealth(db, Number(params.id)));
|
|
});
|
|
|
|
router.add('GET', '/api/campaigns/:id/variantes-stats', async (req, res, { json, params }) => {
|
|
json(200, { variantes: variantStats(db, Number(params.id)) });
|
|
});
|
|
|
|
router.add('POST', '/api/campaigns/leads/:leadId/estagio', async (req, res, { json, params, body }) => {
|
|
const stage = String(body?.stage || '').trim();
|
|
if (!stage) { json(400, { erro: 'informe o estagio' }); return; }
|
|
const leadId = Number(params.leadId);
|
|
db.prepare(
|
|
`INSERT INTO outreach_lead_states (lead_id, stage, updated_at) VALUES (?, ?, datetime('now'))
|
|
ON CONFLICT(lead_id) DO UPDATE SET stage = excluded.stage, updated_at = excluded.updated_at`
|
|
).run(leadId, stage);
|
|
db.prepare(
|
|
`INSERT INTO outreach_stage_events (lead_id, campaign_id, stage, note) VALUES (?, ?, ?, ?)`
|
|
).run(leadId, body?.campaignId ? Number(body.campaignId) : null, stage, body?.note || null);
|
|
json(200, { ok: true });
|
|
});
|
|
|
|
router.add('GET', '/api/campaigns/leads/:leadId/estagio', async (req, res, { json, params }) => {
|
|
const leadId = Number(params.leadId);
|
|
json(200, {
|
|
atual: db.prepare('SELECT * FROM outreach_lead_states WHERE lead_id = ?').get(leadId) || null,
|
|
historico: db.prepare('SELECT * FROM outreach_stage_events WHERE lead_id = ? ORDER BY id DESC').all(leadId)
|
|
});
|
|
});
|
|
|
|
router.add('POST', '/api/campaigns/leads/:leadId/agendamentos', async (req, res, { json, params, body }) => {
|
|
const kind = String(body?.kind || '').trim();
|
|
if (!kind) { json(400, { erro: 'informe o tipo de agendamento' }); return; }
|
|
db.prepare(
|
|
`INSERT INTO outreach_appointments (lead_id, kind, scheduled_at, note) VALUES (?, ?, ?, ?)`
|
|
).run(Number(params.leadId), kind, body?.scheduledAt || null, body?.note || null);
|
|
json(200, { ok: true });
|
|
});
|
|
|
|
router.add('GET', '/api/campaigns/leads/:leadId/agendamentos', async (req, res, { json, params }) => {
|
|
json(200, {
|
|
agendamentos: db.prepare('SELECT * FROM outreach_appointments WHERE lead_id = ? ORDER BY id DESC').all(Number(params.leadId))
|
|
});
|
|
});
|
|
|
|
router.add('POST', '/api/campaigns/opt-out', async (req, res, { json, body }) => {
|
|
const numero = String(body?.numero || '').trim();
|
|
if (!numero) { json(400, { erro: 'informe o numero' }); return; }
|
|
addOptOut(db, numero, body?.motivo || null);
|
|
json(200, { ok: true });
|
|
});
|
|
|
|
router.add('DELETE', '/api/campaigns/opt-out/:numero', async (req, res, { json, params }) => {
|
|
removeOptOut(db, params.numero);
|
|
json(200, { ok: true });
|
|
});
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Registrar as rotas no servidor**
|
|
|
|
Em `src/server/index.mjs`, adicionar o import junto aos outros (depois de `import { registerOutreachRoutes } from './routes/outreach.mjs';`):
|
|
|
|
```js
|
|
import { registerCampaignRoutes } from './routes/campaigns.mjs';
|
|
```
|
|
|
|
E depois de `registerOutreachRoutes(router, deps);`:
|
|
|
|
```js
|
|
registerCampaignRoutes(router, deps);
|
|
```
|
|
|
|
- [ ] **Step 5: Rodar e confirmar que passa**
|
|
|
|
Run: `node --test tests/campaign-routes.test.mjs`
|
|
Expected: PASS (6 testes)
|
|
|
|
- [ ] **Step 6: Rodar a suite inteira**
|
|
|
|
Run: `node --test tests/*.test.mjs`
|
|
Expected: todos os testes passam
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/server/routes/campaigns.mjs src/server/index.mjs tests/campaign-routes.test.mjs
|
|
git commit -m "Add campaign HTTP routes"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Front-end — aba Campanhas (esqueleto + criação de campanha)
|
|
|
|
**Files:**
|
|
- Modify: `public/index.html`
|
|
- Modify: `public/js/app.js`
|
|
- Create: `public/js/campanhas.js`
|
|
|
|
**Interfaces:**
|
|
- Consumes: `API.get`/`API.post` de `api.js`, `el`/`h`/`toast` de `api.js` (já existentes), rotas de `campaigns.mjs` (Task 8).
|
|
- Produces: objeto global `Campanhas` com `.ativar()`, chamado pelo listener de troca de aba em `app.js` — consumido pelas Tasks 10 e 11 que vão adicionar mais funções a esse mesmo módulo.
|
|
|
|
Sem teste automatizado (é UI); verificação manual descrita no Step 5.
|
|
|
|
- [ ] **Step 1: Adicionar a aba e o painel em `public/index.html`**
|
|
|
|
Trocar a linha 18 (depois do botão "Tratar Leads") para incluir o botão da nova aba:
|
|
|
|
```html
|
|
<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):
|
|
|
|
```html
|
|
<!-- ==================== 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>`:
|
|
|
|
```html
|
|
<script src="/js/campanhas.js"></script>
|
|
```
|
|
|
|
Em `public/js/app.js`, dentro do loop de troca de aba (perto da linha 66), trocar:
|
|
|
|
```js
|
|
if (tab.dataset.tab === 'tratamento') Tratamento.ativar();
|
|
```
|
|
|
|
por:
|
|
|
|
```js
|
|
if (tab.dataset.tab === 'tratamento') Tratamento.ativar();
|
|
if (tab.dataset.tab === 'campanhas') Campanhas.ativar();
|
|
```
|
|
|
|
- [ ] **Step 3: Criar `public/js/campanhas.js` com listagem e criação**
|
|
|
|
```js
|
|
/* Aba Campanhas: criacao, fila de aprovacao, saude/variantes, estagio/agendamento. */
|
|
const Campanhas = (() => {
|
|
let carregado = false;
|
|
let variantesCount = 0;
|
|
|
|
const STATUS_LABELS = { rascunho: 'Rascunho', rodando: 'Rodando', pausado: 'Pausado', concluido: 'Concluido' };
|
|
|
|
function addVarianteRow(codigoSugerido) {
|
|
const codigo = codigoSugerido || String.fromCharCode(65 + variantesCount);
|
|
variantesCount += 1;
|
|
el('campanha-variantes').append(
|
|
h('div', { class: 'row', 'data-variante-code': codigo },
|
|
h('span', { class: 'chip' }, codigo),
|
|
h('textarea', { class: 'campanha-variante-mensagem', rows: 2, placeholder: 'Ola {empresa}, ...' }))
|
|
);
|
|
}
|
|
|
|
async function carregarProdutos() {
|
|
const { produtos } = await API.get('/api/campaigns/produtos');
|
|
el('campanha-produto').replaceChildren(
|
|
...produtos.map((p) => h('option', { value: p.value }, `${p.value} (${fmt.format(p.total)} leads)`))
|
|
);
|
|
}
|
|
|
|
async function carregarSegmentos(produto) {
|
|
const container = el('campanha-segmentos');
|
|
if (!produto) { container.replaceChildren(); return; }
|
|
const { segmentos } = await API.get(`/api/campaigns/segmentos?produto=${encodeURIComponent(produto)}`);
|
|
container.replaceChildren(...segmentos.map((s) =>
|
|
h('label', { class: 'chip' },
|
|
h('input', { type: 'checkbox', value: s.value, class: 'campanha-segmento-check' }),
|
|
` ${s.value} (${fmt.format(s.total)})`)
|
|
));
|
|
}
|
|
|
|
function segmentosSelecionados() {
|
|
return Array.from(document.querySelectorAll('.campanha-segmento-check:checked')).map((i) => i.value);
|
|
}
|
|
|
|
function variantesPreenchidas() {
|
|
return Array.from(el('campanha-variantes').children).map((row) => ({
|
|
code: row.dataset.varianteCode,
|
|
mensagem: row.querySelector('.campanha-variante-mensagem').value.trim()
|
|
})).filter((v) => v.mensagem);
|
|
}
|
|
|
|
async function abrirModalNovaCampanha() {
|
|
el('campanha-nome').value = '';
|
|
el('campanha-limite-inicial').value = 10;
|
|
el('campanha-limite-teto').value = 80;
|
|
el('campanha-erro').textContent = '';
|
|
el('campanha-variantes').replaceChildren();
|
|
variantesCount = 0;
|
|
addVarianteRow('A');
|
|
await carregarProdutos();
|
|
await carregarSegmentos(el('campanha-produto').value);
|
|
el('modal-nova-campanha').showModal();
|
|
}
|
|
|
|
function renderCampanhaCard(campanha) {
|
|
return h('div', { class: 'item' },
|
|
h('div', {},
|
|
h('strong', {}, campanha.nome),
|
|
h('span', { class: 'sub' }, ` ${campanha.produto} — ${campanha.segmentos.join(', ')}`)),
|
|
h('span', { class: `chip ${campanha.status === 'rodando' ? 'ok' : campanha.status === 'pausado' ? 'warn' : ''}` },
|
|
STATUS_LABELS[campanha.status] || campanha.status));
|
|
}
|
|
|
|
async function carregarLista() {
|
|
const { campanhas } = await API.get('/api/campaigns');
|
|
el('campanhas-lista').replaceChildren(
|
|
...(campanhas.length ? campanhas.map(renderCampanhaCard) : [h('p', { class: 'muted' }, 'Nenhuma campanha criada ainda.')])
|
|
);
|
|
}
|
|
|
|
function ativar() {
|
|
if (carregado) return;
|
|
carregado = true;
|
|
|
|
el('btn-nova-campanha').addEventListener('click', () => {
|
|
abrirModalNovaCampanha().catch((error) => toast(`Erro: ${error.message}`));
|
|
});
|
|
el('btn-cancelar-campanha').addEventListener('click', () => el('modal-nova-campanha').close());
|
|
el('btn-add-variante').addEventListener('click', () => addVarianteRow());
|
|
el('campanha-produto').addEventListener('change', (event) => {
|
|
carregarSegmentos(event.target.value).catch((error) => toast(`Erro: ${error.message}`));
|
|
});
|
|
|
|
el('btn-salvar-campanha').addEventListener('click', async () => {
|
|
try {
|
|
await API.post('/api/campaigns', {
|
|
nome: el('campanha-nome').value.trim(),
|
|
produto: el('campanha-produto').value,
|
|
segmentos: segmentosSelecionados(),
|
|
variantes: variantesPreenchidas(),
|
|
limiteDiarioInicial: Number(el('campanha-limite-inicial').value) || 10,
|
|
limiteDiarioTeto: Number(el('campanha-limite-teto').value) || 80
|
|
});
|
|
el('modal-nova-campanha').close();
|
|
toast('Campanha criada');
|
|
await carregarLista();
|
|
} catch (error) {
|
|
el('campanha-erro').textContent = error.message;
|
|
}
|
|
});
|
|
|
|
carregarLista().catch((error) => toast(`Erro: ${error.message}`));
|
|
}
|
|
|
|
return { ativar };
|
|
})();
|
|
```
|
|
|
|
- [ ] **Step 4: Adicionar estilo mínimo para o modal de campanha (reaproveitando classes existentes)**
|
|
|
|
Nenhuma classe CSS nova é necessária — `dialog`, `.field`, `.row`, `.chip`, `.chips`, `.item`, `.lista`, `.btn`/`.btn-primary`/`.btn-ghost` já existem em `public/css/app.css` e cobrem este formulário. Pular este step se a revisão visual (Step 5) não apontar necessidade.
|
|
|
|
- [ ] **Step 5: Verificação manual no navegador**
|
|
|
|
Rodar `npm start`, abrir `http://127.0.0.1:3900`, logar, clicar na aba "🎯 Campanhas", clicar em "+ Nova campanha", escolher um produto (a lista deve carregar da base de leads existente), marcar segmentos, escrever ao menos uma variante, salvar — a campanha deve aparecer na lista com status "Rascunho".
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add public/index.html public/js/app.js public/js/campanhas.js
|
|
git commit -m "Add Campanhas tab: listing and campaign creation"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Front-end — fila de aprovação, saúde e comparação de variantes
|
|
|
|
**Files:**
|
|
- Modify: `public/js/campanhas.js`
|
|
- Modify: `public/index.html`
|
|
|
|
**Interfaces:**
|
|
- Consumes: rotas `/api/campaigns/:id/gerar-fila`, `/api/campaigns/:id/fila`, `/api/campaigns/:id/fila/aprovar`, `/api/campaigns/:id/fila/cancelar`, `/api/campaigns/:id/iniciar`, `/api/campaigns/:id/pausar`, `/api/campaigns/:id/retomar`, `/api/campaigns/:id/saude`, `/api/campaigns/:id/variantes-stats` (todas da Task 8).
|
|
- Produces: adiciona `abrirDetalheCampanha(campanhaId)` ao módulo `Campanhas`, acionado ao clicar num card da lista (Task 9).
|
|
|
|
- [ ] **Step 1: Adicionar o painel de detalhe em `public/index.html`**
|
|
|
|
Dentro da `<section id="tab-campanhas">`, logo depois de `<div id="campanhas-lista" class="lista"></div>`, adicionar:
|
|
|
|
```html
|
|
<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:
|
|
|
|
```js
|
|
let campanhaAtualId = null;
|
|
|
|
async function carregarDetalhe(campanhaId) {
|
|
campanhaAtualId = campanhaId;
|
|
const campanha = await API.get(`/api/campaigns/${campanhaId}`);
|
|
el('detalhe-nome').textContent = campanha.nome;
|
|
el('detalhe-status').textContent = STATUS_LABELS[campanha.status] || campanha.status;
|
|
el('campanha-detalhe').classList.remove('hidden');
|
|
await Promise.all([carregarSaude(campanhaId), carregarVariantesStats(campanhaId), carregarFila(campanhaId)]);
|
|
}
|
|
|
|
async function carregarSaude(campanhaId) {
|
|
const saude = await API.get(`/api/campaigns/${campanhaId}/saude`);
|
|
el('detalhe-saude').textContent = saude.pause
|
|
? `Pausada automaticamente: ${saude.reason}`
|
|
: 'Saude: sem alertas.';
|
|
}
|
|
|
|
async function carregarVariantesStats(campanhaId) {
|
|
const { variantes } = await API.get(`/api/campaigns/${campanhaId}/variantes-stats`);
|
|
el('detalhe-variantes').replaceChildren(...variantes.map((v) =>
|
|
h('span', { class: 'chip' },
|
|
`${v.code}: ${v.enviados} enviados, ${(v.taxaResposta * 100).toFixed(1)}% resposta${v.elegivelParaComparacao ? ' (elegivel)' : ''}`)
|
|
));
|
|
}
|
|
|
|
async function carregarFila(campanhaId) {
|
|
const status = el('fila-filtro-status').value;
|
|
const { fila } = await API.get(`/api/campaigns/${campanhaId}/fila?status=${status}`);
|
|
el('fila-tabela').replaceChildren(...fila.map((linha) =>
|
|
h('tr', {},
|
|
h('td', {}, h('input', { type: 'checkbox', class: 'fila-check', value: linha.id })),
|
|
h('td', {}, String(linha.lead_id)),
|
|
h('td', {}, linha.numero),
|
|
h('td', {}, linha.rendered_message),
|
|
h('td', {}, linha.status))
|
|
));
|
|
}
|
|
|
|
function filaSelecionada() {
|
|
return Array.from(document.querySelectorAll('.fila-check:checked')).map((i) => Number(i.value));
|
|
}
|
|
|
|
function ligarDetalheCampanha() {
|
|
el('btn-fechar-detalhe').addEventListener('click', () => el('campanha-detalhe').classList.add('hidden'));
|
|
el('fila-filtro-status').addEventListener('change', () => carregarFila(campanhaAtualId).catch((e) => toast(`Erro: ${e.message}`)));
|
|
|
|
el('btn-gerar-fila').addEventListener('click', async () => {
|
|
try {
|
|
const resultado = await API.post(`/api/campaigns/${campanhaAtualId}/gerar-fila`, {});
|
|
toast(`${resultado.adicionadosNaFila} leads adicionados a fila`);
|
|
await carregarFila(campanhaAtualId);
|
|
} catch (error) { toast(`Erro: ${error.message}`); }
|
|
});
|
|
|
|
el('btn-iniciar-campanha').addEventListener('click', async () => {
|
|
await API.post(`/api/campaigns/${campanhaAtualId}/iniciar`, {});
|
|
await carregarDetalhe(campanhaAtualId);
|
|
await carregarLista();
|
|
});
|
|
el('btn-pausar-campanha').addEventListener('click', async () => {
|
|
await API.post(`/api/campaigns/${campanhaAtualId}/pausar`, { motivo: 'manual' });
|
|
await carregarDetalhe(campanhaAtualId);
|
|
await carregarLista();
|
|
});
|
|
el('btn-retomar-campanha').addEventListener('click', async () => {
|
|
await API.post(`/api/campaigns/${campanhaAtualId}/retomar`, {});
|
|
await carregarDetalhe(campanhaAtualId);
|
|
await carregarLista();
|
|
});
|
|
|
|
el('btn-aprovar-selecionados').addEventListener('click', async () => {
|
|
const queueIds = filaSelecionada();
|
|
if (!queueIds.length) return;
|
|
const resultado = await API.post(`/api/campaigns/${campanhaAtualId}/fila/aprovar`, { queueIds });
|
|
toast(`${resultado.aprovados} aprovados`);
|
|
await carregarFila(campanhaAtualId);
|
|
});
|
|
el('btn-cancelar-selecionados').addEventListener('click', async () => {
|
|
const queueIds = filaSelecionada();
|
|
if (!queueIds.length) return;
|
|
const resultado = await API.post(`/api/campaigns/${campanhaAtualId}/fila/cancelar`, { queueIds, motivo: 'operador' });
|
|
toast(`${resultado.cancelados} cancelados`);
|
|
await carregarFila(campanhaAtualId);
|
|
});
|
|
}
|
|
```
|
|
|
|
Trocar `renderCampanhaCard` para tornar o card clicável (adicionar `onclick` no `h('div', { class: 'item' }, ...)`):
|
|
|
|
```js
|
|
function renderCampanhaCard(campanha) {
|
|
return h('div', { class: 'item', onclick: () => carregarDetalhe(campanha.id).catch((e) => toast(`Erro: ${e.message}`)) },
|
|
h('div', {},
|
|
h('strong', {}, campanha.nome),
|
|
h('span', { class: 'sub' }, ` ${campanha.produto} — ${campanha.segmentos.join(', ')}`)),
|
|
h('span', { class: `chip ${campanha.status === 'rodando' ? 'ok' : campanha.status === 'pausado' ? 'warn' : ''}` },
|
|
STATUS_LABELS[campanha.status] || campanha.status));
|
|
}
|
|
```
|
|
|
|
E, na função `ativar()`, chamar `ligarDetalheCampanha();` junto dos outros listeners (antes de `carregarLista().catch(...)`).
|
|
|
|
- [ ] **Step 2: Verificação manual**
|
|
|
|
Com uma campanha criada (Task 9), clicar no card dela, clicar "Iniciar", "Gerar fila", selecionar linhas e clicar "Aprovar selecionados" — a fila deve refletir o novo status e, depois de um ciclo do `campaign-scheduler` (ou chamando `runCampaignQueue` manualmente via um teste/REPL), as linhas aprovadas devem virar "sent" desde que `zpro.*` esteja configurado em Configurações.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add public/index.html public/js/campanhas.js
|
|
git commit -m "Add campaign detail panel: queue approval, health and variant stats"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 11: Front-end — classificação de estágio e agendamento + checagem final da suite
|
|
|
|
**Files:**
|
|
- Modify: `public/js/campanhas.js`
|
|
- Modify: `public/index.html`
|
|
- Test: rodar a suite inteira (nenhum arquivo de teste novo — esta task é só UI + verificação)
|
|
|
|
**Interfaces:**
|
|
- Consumes: rotas `/api/campaigns/leads/:leadId/estagio` (GET/POST) e `/api/campaigns/leads/:leadId/agendamentos` (GET/POST) da Task 8.
|
|
|
|
- [ ] **Step 1: Adicionar controles de estágio/agendamento por linha da fila em `public/index.html`**
|
|
|
|
Trocar o `<thead>` da tabela de fila (adicionada na Task 10) para incluir uma coluna de ação:
|
|
|
|
```html
|
|
<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):
|
|
|
|
```js
|
|
const STAGE_OPTIONS = [
|
|
'respondeu', 'interessado', 'demonstracao_agendada', 'sem_interesse', 'opt_out', 'venda_fechada'
|
|
];
|
|
|
|
async function carregarFila(campanhaId) {
|
|
const status = el('fila-filtro-status').value;
|
|
const { fila } = await API.get(`/api/campaigns/${campanhaId}/fila?status=${status}`);
|
|
el('fila-tabela').replaceChildren(...fila.map((linha) => {
|
|
const podeMarcarEstagio = linha.status === 'sent' || linha.status === 'error';
|
|
const stageSelect = h('select', { class: 'fila-estagio-select', disabled: podeMarcarEstagio ? null : 'disabled' },
|
|
h('option', { value: '' }, '—'),
|
|
...STAGE_OPTIONS.map((s) => h('option', { value: s }, s)));
|
|
const marcarBtn = h('button', {
|
|
class: 'btn-ghost btn-sm', type: 'button', disabled: podeMarcarEstagio ? null : 'disabled',
|
|
onclick: async () => {
|
|
if (!stageSelect.value) return;
|
|
await API.post(`/api/campaigns/leads/${linha.lead_id}/estagio`, { stage: stageSelect.value, campaignId: campanhaId });
|
|
toast('Estagio atualizado');
|
|
if (stageSelect.value === 'demonstracao_agendada') {
|
|
const dataAgendamento = prompt('Data/hora do agendamento (AAAA-MM-DD HH:MM):');
|
|
if (dataAgendamento) {
|
|
await API.post(`/api/campaigns/leads/${linha.lead_id}/agendamentos`, { kind: 'demonstracao', scheduledAt: dataAgendamento });
|
|
toast('Agendamento registrado');
|
|
}
|
|
}
|
|
}
|
|
}, 'Marcar');
|
|
return h('tr', {},
|
|
h('td', {}, h('input', { type: 'checkbox', class: 'fila-check', value: linha.id })),
|
|
h('td', {}, String(linha.lead_id)),
|
|
h('td', {}, linha.numero),
|
|
h('td', {}, linha.rendered_message),
|
|
h('td', {}, linha.status),
|
|
h('td', { class: 'row' }, stageSelect, marcarBtn));
|
|
}));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Verificação manual**
|
|
|
|
Com uma linha de fila em status `sent` (após um envio real ou simulado), abrir o detalhe da campanha, escolher um estágio no seletor da linha, clicar "Marcar" — checar via `GET /api/campaigns/leads/:leadId/estagio` (ou na aba Tratar Leads, se o lead for aberto lá) que o estágio foi gravado. Marcar `demonstracao_agendada` deve pedir a data e criar um agendamento.
|
|
|
|
- [ ] **Step 4: Rodar a suite inteira e revisar `git status`**
|
|
|
|
Run: `node --test tests/*.test.mjs`
|
|
Expected: todos os testes passam (backend Tasks 1-8 + qualquer teste pré-existente)
|
|
|
|
Run: `git status --short`
|
|
Expected: só os arquivos desta feature aparecem como modificados/staged do lado do que este plano tocou — nada do WIP alheio (outreach existente, capture, etc.) deve ter sido alterado por engano.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add public/index.html public/js/campanhas.js
|
|
git commit -m "Add stage classification and appointment scheduling UI"
|
|
```
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
**Cobertura da spec:** catálogo dinâmico (Task 2/9), fila/preview/aprovação (Task 4/10), variantes A/B/C só não-oficial (Task 3/6, `zproClient.sendText`), escalonamento semanal (Task 2/7), saúde/auto-pausa (Task 5/6), classificação de estágio e agendamento manual (Task 8/11), prevenção de contato duplicado com `run.mjs` (Task 4, cláusula `NOT EXISTS` por `lead_id OR numero`), opt-out (Task 2/6). Tudo coberto.
|
|
|
|
**Placeholder scan:** nenhum "TBD"/"implementar depois" — todo step tem código completo.
|
|
|
|
**Consistência de tipos:** `getCampaign` retorna `{...row, segmentos: [], variants: []}` — usado identicamente em `campaign-queue.mjs`, `campaign-worker.mjs` (via `db.prepare` direto, não via `getCampaign`, para pegar `limite_diario_atual` fresco a cada iteração do loop) e nas rotas. `variantForLead`/`renderCampaignMessage` com a mesma assinatura em `campaign-messages.mjs`, `campaign-queue.mjs`. Status de fila (`preview|approved|cancelled|sent|error`) e de campanha (`rascunho|rodando|pausado|concluido`) usados de forma consistente em todas as tasks.
|