diff --git a/docs/superpowers/specs/2026-08-01-gti-campaign-port-design.md b/docs/superpowers/specs/2026-08-01-gti-campaign-port-design.md new file mode 100644 index 0000000..50ac661 --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-gti-campaign-port-design.md @@ -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'`.