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

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

10 KiB

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

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