Add design doc for campaign outreach subsystem port

Ports the Máquina Comercial Pessoal's campaign methodology (queue/preview/
approval, A/B/C variants, weekly limit escalation, health-based auto-pause,
stage classification, appointment scheduling), adapted for the Z-PRO/WABA
send layer and a dynamic operator-defined catalog instead of a hardcoded one.
This commit is contained in:
Gustavo
2026-08-01 09:47:49 -03:00
parent b8d10b210c
commit 2195b3f2b1
@@ -0,0 +1,213 @@
# Campanhas de Outreach — GTI Comercial
Data: 2026-08-01
Status: aprovado pelo usuário, pendente de plano de implementação
## Contexto e motivação
A Máquina Comercial Pessoal tem um subsistema de campanhas de outreach (fila com
preview/aprovação, variantes A/B/C, escalonamento semanal de limite diário, pausa
automática por saúde, classificação de estágio comercial, agendamento de
demonstração) construído sobre o envio direto via Baileys. A GTI Comercial já tem
sua própria automação de outreach (`src/outreach/run.mjs` + `scheduler.mjs`),
que dispara um lote a cada 20 minutos, respeitando `produto_principal` de cada
lead, canal oficial (WABA/template aprovado) ou não-oficial (texto livre via
Z-PRO), e grava tudo em `outreach_sends`.
O pedido: portar a mesma metodologia de campanhas da Pessoal para a Comercial,
mas adaptada a duas diferenças reais entre os projetos:
1. **Meio de conexão**: a Comercial não fala com o WhatsApp diretamente (Baileys);
ela passa pelo Z-PRO (`src/outreach/zpro-client.mjs`), que oferece dois canais
distintos — não-oficial (`sendText`/`bulkIndividual`, texto livre) e oficial
(`sendTemplateWaba`, só templates pré-aprovados no Meta Business Manager, sem
texto livre nem variáveis além das que o próprio template já define).
2. **Catálogo de negócio**: a Pessoal tem 2-3 campanhas fixas, hardcoded em
`campaign-catalog.mjs`, cada uma amarrada a um segmento específico. A
Comercial tem 57 segmentos e 4 produtos (G-Chat, Sistema de Ponto, ERP,
G-Delivery) — catálogo fixo não cabe nessa escala. Aqui, campanha precisa ser
um registro que o operador cria pela tela (produto + segmentos + textos),
não código.
Decisões já validadas com o usuário:
- Campanhas rodam **ao lado** do `runOutreachBatch` existente, sem substituí-lo.
- Variantes A/B/C só funcionam no canal **não-oficial** (o oficial não aceita
texto livre, então não há o que variar).
- Pacote completo: escalonamento semanal, fila/preview/aprovação, variantes com
eleição de vencedora, pausa automática por saúde, classificação de estágio
comercial, agendamento de demonstração.
- Classificação de estágio e registro de resposta são **manuais** por enquanto —
não existe endpoint de webhook de resposta do Z-PRO implementado nesta base.
## Arquitetura
Novo diretório `src/outreach/campaign-*.mjs`, paralelo a `run.mjs`/`scheduler.mjs`
— nenhum dos dois é modificado. Único ponto de integração: `outreach_sends`
ganha um novo valor de `origem` (`'campanha'`, além dos já existentes
`'automatico'`/`'manual'`) e a seleção de candidatos de campanha exclui leads
recentemente contatados para o mesmo produto **por qualquer origem**, evitando
contato duplicado entre a automação existente e as campanhas novas.
```
src/outreach/
run.mjs (existente, intocado)
scheduler.mjs (existente, intocado)
zpro-client.mjs (existente, intocado — reusado)
templates.mjs (existente, intocado — renderTemplate reusado)
campaign-catalog.mjs (novo — CRUD de outreach_campaigns/variants)
campaign-queue.mjs (novo — seleção de candidatos, preview, aprovação)
campaign-worker.mjs (novo — envio das linhas aprovadas)
campaign-metrics.mjs (novo — saúde, auto-pausa, comparação de variantes)
campaign-scheduler.mjs (novo — timer próprio, chama campaign-worker)
```
## Modelo de dados
Migração nova em `src/db.mjs` (mesmo padrão das migrações v5-v7 já existentes:
`ALTER TABLE`/`CREATE TABLE IF NOT EXISTS` guardado por versão do schema).
```sql
CREATE TABLE outreach_campaigns (
id INTEGER PRIMARY KEY,
nome TEXT NOT NULL,
produto TEXT NOT NULL,
segmentos_json TEXT NOT NULL, -- ["Oficina", "Restaurante", ...]
status TEXT NOT NULL DEFAULT 'rascunho', -- rascunho|rodando|pausado|concluido
pause_reason TEXT,
limite_diario_atual INTEGER NOT NULL DEFAULT 10,
semana_iniciada_em TEXT, -- controla quando escalar o limite
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, -- A|B|C
mensagem TEXT NOT NULL,
ativo INTEGER NOT NULL DEFAULT 1
);
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,
status TEXT NOT NULL DEFAULT 'preview', -- preview|approved|cancelled|reserved|sent|unknown|error
outreach_send_id INTEGER REFERENCES outreach_sends(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE outreach_lead_states (
lead_id INTEGER PRIMARY KEY REFERENCES leads(id),
stage TEXT NOT NULL, -- interessado|sem_interesse|opt_out|venda_fechada|...
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 TABLE outreach_appointments (
id INTEGER PRIMARY KEY,
lead_id INTEGER NOT NULL REFERENCES leads(id),
kind TEXT NOT NULL, -- demonstracao|teste_g_delivery|...
scheduled_at TEXT,
note TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE outreach_opt_out (
numero TEXT PRIMARY KEY,
motivo TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
ALTER TABLE outreach_sends ADD COLUMN campaign_queue_id INTEGER REFERENCES outreach_campaign_queue(id);
```
`outreach_opt_out` não existe hoje na Comercial (a Pessoal tem um equivalente
mais complexo por causa da equivalência de números 8/9 dígitos). Aqui basta uma
tabela simples por `numero` já normalizado (`onlyDigits`), sem a complexidade de
variantes de número da Pessoal — a Comercial já normaliza números na validação
(`validatePhone`/`onlyDigits`). Está incluída porque uma fila que recontata o
mesmo lead ao longo de semanas sem checar opt-out é um risco real de spam/banimento,
não porque foi pedida explicitamente — sinalizando aqui para confirmação.
## Fluxo
1. **Criar campanha**: operador escolhe produto, segmentos (lista vindo de
`SELECT DISTINCT segmento, COUNT(*) FROM leads WHERE produto_principal = ?
GROUP BY segmento` — mesmo padrão de facet já usado em outros lugares do
projeto), escreve 1-3 variantes de mensagem (texto livre, mesmas variáveis
de `templates.mjs`: `{empresa}`, `{produto}`, `{cidade}`, `{segmento}`),
define limite diário inicial.
2. **Gerar fila**: candidatos = leads do produto/segmentos escolhidos, com
`telefone_valido=1`, sem envio recente (`outreach_sends`, qualquer origem)
para o mesmo produto, sem opt-out. Distribuição round-robin entre variantes
ativas. Linhas entram como `preview` — nada é enviado.
3. **Revisar e aprovar**: tela lista a fila em preview (empresa, telefone,
variante, mensagem já renderizada); operador aprova em lote ou individualmente
→ status `approved`. Só linhas aprovadas entram no worker.
4. **Envio**: `campaign-worker.mjs`, rodando no `campaign-scheduler.mjs` (timer
próprio, mesmo padrão do `scheduler.mjs` existente — `setInterval` com
`.unref()`), pega linhas `approved` respeitando `limite_diario_atual` da
campanha, revalida o lead na hora (ainda não opt-out, ainda no segmento),
envia via `zproClient.sendText`, grava em `outreach_sends`
(`origem='campanha'`, `campaign_queue_id` preenchido), atualiza a linha da
fila para `sent`/`error`.
5. **Escalonamento semanal**: a cada 7 dias corridos sem pausa por saúde desde
`semana_iniciada_em`, `limite_diario_atual` dobra (10→20→40...), até um teto
configurável.
6. **Saúde/auto-pausa** (`campaign-metrics.mjs`, mesmos 3 critérios da Pessoal,
adaptados às tabelas daqui):
- taxa de erro de envio > 10% com pelo menos 10 tentativas no dia → pausa
`high_failure_rate`
- taxa de estágio negativo/opt-out > 10% com pelo menos 20 contatos → pausa
`high_negative_rate`
- taxa de resposta < 5% com pelo menos 50 contatos → pausa `low_reply_rate`
(mesmos limiares numéricos já usados em `campaign-metrics.mjs` da Pessoal)
Pausa muda `status` para `pausado`; o worker para de pegar novas linhas
dessa campanha até o operador retomar manualmente.
7. **Variantes e eleição de vencedora**: após um mínimo de 40 envios por
variante, a tela mostra taxa de resposta por variante e sinaliza a de melhor
desempenho — indicativo, não desliga variantes sozinho.
8. **Classificação de estágio e agendamento**: 100% manual por enquanto —
operador marca o estágio do lead pela tela (gera `outreach_stage_events` +
atualiza `outreach_lead_states`) e cria agendamentos (`outreach_appointments`)
manualmente. Sem webhook de resposta do Z-PRO nesta fase.
## Prevenção de contato duplicado com a automação existente
`runOutreachBatch` (`run.mjs`) já verifica `lastEnviado` antes de mandar. A
seleção de candidatos de campanha faz a checagem simétrica: exclui qualquer
lead com `outreach_sends` para o mesmo produto nos últimos N dias, **indepen-
dente de `origem`**. Isso garante que uma campanha nunca contata quem o lote
automático já contatou recentemente para aquele produto, e vice-versa — os
dois sistemas competem pelo mesmo "direito de contato" por produto, não
disparam em paralelo para o mesmo lead.
## Fora de escopo (explicitamente adiado)
- Webhook de resposta do Z-PRO / classificação automática de estágio.
- Envio de variantes pelo canal oficial (impossível hoje — WABA não aceita
texto livre).
- Equivalência de números 8/9 dígitos ou detecção de número compartilhado
(não existe na Comercial hoje e não foi pedido).
## Testes
Mesma disciplina da Pessoal: um arquivo de teste por módulo novo
(`campaign-catalog`, `campaign-queue`, `campaign-worker`, `campaign-metrics`),
cobrindo pelo menos: geração de fila respeita segmentos/produto/exclusões,
aprovação só libera linhas aprovadas para o worker, worker respeita limite
diário e revalida antes de enviar, auto-pausa dispara nos 3 critérios e
bloqueia novas linhas, escalonamento semanal avança e não avança durante
pausa, prevenção de contato duplicado entre `origem='automatico'` e
`origem='campanha'`.