Pular para conteúdo

Arquitetura — Sistema de Prospecção de Representantes

Mapeamento completo dos workflows, banco de dados e integrações.


Visão geral do sistema

[Fonte de leads]          [Dispatcher + Worker]              [Canais de saída]
     │                         │                                  │
 CSV/Apollo ──► WF-Rep-Import ─┐                           ┌─► Amazon SES (email)
                               ├──► PostgreSQL ──► WF-Rep-1 ──► WF-Rep-Worker ─┤
 JSON API ────► WF-Rep-0 ─────┘     (tabela            (Dispatcher)    │       └─► Chatwoot (WhatsApp)
                                   representantes)                     │
                                                                       └─► Calendly (agendamento)

Arquitetura Dispatcher + Worker (desde 2026-05-13): - WF-Rep-1 (Dispatcher) — 2 crons (8h e 14h), verifica se há leads pendentes, dispara o Worker - WF-Rep-Worker — webhook, processa 1 lead por vez, self-trigger com wait aleatório 2-7 min - Execuções curtas (~30s cada) em vez de execução monolítica de 8h


Workflows n8n

WF-Rep-Import — Ingestão via Google Sheet

  • ID: cxpqpgjSPIEX18AL
  • Trigger: Manual
  • Status: Ativo (usado sob demanda)

Fluxo:

Manual Trigger
  → HTTP Request (GET Google Sheet publicada como CSV)
    → Code: parse CSV
      → Code: dedup contra banco (SELECT emails existentes)
        → Code: round-robin sender_slot (A→B→C→A...)
          → PostgreSQL: INSERT batch

Notas: - Google Sheet deve ser publicada como CSV (File > Share > Publish to web > CSV) - Dedup compara email — se já existe, pula - Round-robin respeita contagem atual do banco (não recomeça do A)


WF-Rep-0 — Ignição (Webhook)

  • ID: I425eBF09Khja5vL
  • Trigger: Webhook POST /webhook/rep-import
  • Status: Inativo (alternativa ao Import, não usado atualmente)

Payload esperado:

{
  "contacts": [
    {
      "nome": "Fulano",
      "email": "fulano@empresa.com",
      "telefone": "11999999999",
      "empresa": "Empresa X",
      "cidade": "São Paulo",
      "estado": "SP",
      "linkedin_url": "https://linkedin.com/in/fulano"
    }
  ]
}

Fluxo:

Webhook POST
  → Code: valida + dedup + round-robin sender_slot
    → PostgreSQL: INSERT batch


WF-Rep-1 — Dispatcher (ex-Motor Diário)

  • ID: cstgB6ONs7R0d01r
  • Trigger: 2 Crons — 8h e 14h (America/Sao_Paulo), seg-sáb
  • Status: ATIVO em produção
  • Nós: 5

Fluxo completo nó-por-nó:

Cron Diário 8h (0 8 * * 1-6)
  │                              Cron Segurança 14h (0 14 * * 1-6)
  │                                │
  └──────────────┬─────────────────┘
                 ▼
PostgreSQL: "Conta Leads Pendentes"
  SQL: SELECT COUNT(*) as pending FROM representantes
       WHERE sequence_status = 'active'
       AND next_action_date <= CURRENT_DATE
  │
  ▼
IF: "Tem Leads?" (pending > 0)
  │
  ├─── true ──────────────────────┐
  │                               ▼
  │                    HTTP Request: "Dispara Worker"
  │                      GET https://n8n.bepex.com.br/webhook/rep-worker
  │
  └─── false ─── (nada a fazer, encerra)

Notas: - O Dispatcher NÃO processa leads — apenas verifica se existem pendentes e dispara o Worker - Dois crons para garantir que leads adicionados pela manhã que não foram pegos às 8h tenham segunda chance às 14h - O IF protege contra o erro "No item to return" que o Worker dá quando não há leads


WF-Rep-Worker — Worker (processa 1 lead por vez)

  • ID: KrMlipmgxuc2EdlL
  • Trigger: Webhook GET /webhook/rep-worker
  • Status: ATIVO em produção
  • Nós: 28

SEQUENCE_MAP (cadência por dia):

const SEQUENCE_MAP = {
  0: 'email',    // Dia 0: email de apresentação
  3: 'email',    // Dia 3: email de follow-up
  7: 'whatsapp', // Dia 7: WhatsApp contato direto
  14: 'whatsapp' // Dia 14: WhatsApp follow-up final
};

Lógica de fallback de canal: - Lead SEM email → pula dias de email (0, 3) → salta direto para WhatsApp (dia 7) - Lead SEM telefone → pula dias de WhatsApp (7, 14) → usa apenas email - Lead SEM email E SEM telefone → marca completed imediatamente

Fluxo completo nó-por-nó:

Webhook GET /rep-worker
  │
  ▼
PostgreSQL: "Busca 1 Lead"
  SQL: SELECT * FROM representantes
       WHERE sequence_status = 'active'
       AND next_action_date <= CURRENT_DATE
       ORDER BY ROW_NUMBER() OVER (PARTITION BY sender_slot)
       LIMIT 1
  │
  ▼
Code: "Roteia por Canal"
  - Lê SEQUENCE_MAP[sequence_day]
  - Lê SENDERS[sender_slot]
  - Aplica fallback de canal (ver lógica acima)
  - Calcula next_sequence_day e next_action_date
  - Output: { canal, email_from, email_to, inbox_id, recontato_inbox63, ... }
  │
  ▼
IF: "Recontato Email Guard"  ← [adicionado 2026-06-30]
  - Condições AND: canal == 'email' AND recontato_inbox63 == true
  - TRUE: lead recontato chegou no step de email (dia 22) → vai pra "Marca Recontato Completed"
  - FALSE: qualquer outro caso → segue fluxo normal para "É Email?"
  │
  ├─── TRUE ──────────────────────────────────┐
  │                                            ▼
  │                               Postgres: "Marca Recontato Completed"
  │                                 - UPDATE SET sequence_status='completed',
  │                                              recontato_inbox63=FALSE
  │                                            │
  │                                            ▼
  │                               Code: "Responde Recontato OK" (terminal)
  │                                 - Não self-triggera
  │
  └─── FALSE ──────────────────────────────────┐
                                               ▼
  ├─── IF canal == 'email' ──────────────────────┐
  │                                                ▼
  │                                    Code: "Prepara Email (SES)"
  │                                      - Monta subject, body com nome personalizado
  │                                      - Cópias V2 por sequence_day
  │                                                │
  │                                                ▼
  │                                    HTTP Request: "Envia Email SES"
  │                                      - URL: https://email.us-east-2.amazonaws.com
  │                                      - Auth: AWS (credential kqmbhAWS0tyL5fuI)
  │                                      - contentType: raw
  │                                      - rawContentType: application/x-www-form-urlencoded
  │                                      - Body: encodeURIComponent manual (ver referencia-tecnica.md)
  │                                                │
  │                                                ▼
  │                                    Code: "Restore Email Data"
  │                                      - Restaura campos originais do lead
  │                                        (SES response sobrescreve $json)
  │                                                │
  │                                                ▼
  │                                    ────────── Merge ◄──────────────
  │                                                │
  ├─── IF canal == 'whatsapp' ───────────────────┐│
  │                                               ▼│
  │                                    Code: "Prepara WhatsApp (Chatwoot)"
  │                                      - Monta mensagem V2 com nome personalizado
  │                                      - Seleciona inbox_id pelo sender_slot
  │                                               │
  │                                               ▼
  │                                    HTTP Request: "Busca Contato Chatwoot"
  │                                      - GET /api/v1/accounts/2/contacts/search
  │                                      - Query: telefone do lead
  │                                               │
  │                                               ▼
  │                                    IF: "Contato Encontrado?"
  │                                      - true (payload.length > 0): usa existente
  │                                      - false: cria novo contato
  │                                               │
  │                                    ┌──── true ─┤── false ────┐
  │                                    │           │             ▼
  │                                    │           │  HTTP Request: "Cria Contato Chatwoot"
  │                                    │           │  POST /api/v1/accounts/2/contacts
  │                                    │           │             │
  │                                    ▼           │             ▼
  │                                    Code: "Extrai Contato + Prepara Conversa"
  │                                      - Parseia search result (array) OU create result (object)
  │                                      - Extrai contact_id
  │                                               │
  │                                               ▼
  │                                    HTTP Request: "Cria Conversa Chatwoot"
  │                                      - POST /api/v1/accounts/2/conversations
  │                                      - inbox_id do sender_slot
  │                                               │
  │                                               ▼
  │                                    Code: "Extrai Conversa"
  │                                      - Extrai conversation_id da resposta
  │                                               │
  │                                               ▼
  │                                    HTTP Request: "Envia Msg Chatwoot"
  │                                      - POST /api/v1/accounts/2/conversations/{id}/messages
  │                                               │
  │                                               ▼
  │                                    HTTP Request: "Adiciona Label ia-atendendo"
  │                                      - POST /api/v1/accounts/2/conversations/{id}/labels
  │                                      - Body: {"labels":["ia-atendendo"]}
  │                                      - Auth: Header Auth (Chatwoot Bepex)
  │                                               │
  │                                               ▼
  │                                    Code: "Prepara Update WhatsApp"
  │                                      - Extrai {id, next_sequence_day, next_action_date}
  │                                               │
  │                                               ▼
  │                                    ────────── Merge ──────────────
  │
  ▼
PostgreSQL: "Atualiza Sequência"
  UPDATE representantes
  SET sequence_day = $next_sequence_day,
      next_action_date = $next_action_date,
      last_action_date = CURRENT_DATE,
      sequence_status = CASE WHEN $next_sequence_day IS NULL THEN 'completed' ELSE 'active' END,
      updated_at = NOW()
  WHERE id = $id
  │
  ▼
PostgreSQL: "Conta Leads Restantes"
  SELECT COUNT(*) as remaining FROM representantes
  WHERE sequence_status = 'active'
  AND next_action_date <= CURRENT_DATE
  │
  ▼
Code: "Checa Horário Comercial"
  - Calcula hora atual em BRT (America/Sao_Paulo)
  - Verifica se está entre 8h e 18h
  - Verifica se é seg-sáb (dia 1-6)
  - Output: { continue: boolean, remaining }
  │
  ▼
IF: "Continua?" (remaining > 0 AND dentro do horário comercial)
  │
  ├─── true ──────────────────────┐
  │                               ▼
  │                    Wait: aleatório 2-7 minutos
  │                      - Espaçamento natural entre envios
  │                               │
  │                               ▼
  │                    HTTP Request: "Self-Trigger"
  │                      GET https://n8n.bepex.com.br/webhook/rep-worker
  │                      (dispara nova execução do próprio Worker)
  │
  └─── false ─── (fila vazia ou fora do horário, encerra)

Mecanismo de self-trigger: O Worker processa exatamente 1 lead por execução. Ao final, se ainda há leads pendentes e estamos em horário comercial, o Worker faz um HTTP GET para seu próprio webhook, criando uma nova execução. Isso resulta em execuções curtas e independentes (~30s cada), eliminando o problema de sessões longas que excedem o timeout do n8n.

Observação sobre o ORDER BY intercalado: O SQL usa ROW_NUMBER() OVER (PARTITION BY sender_slot) para processar na ordem A, B, C, A, B, C... Isso distribui o volume entre remetentes ao longo do tempo (com o Wait aleatório entre cada).

Guarda de horário comercial: O Worker só se re-dispara entre 8h e 18h BRT, de segunda a sábado. Se o processamento ultrapassar as 18h, ele para e os leads restantes são pegos no próximo cron do Dispatcher.

Unsubscribe nos emails: O Code node "Prepara Email (SES)" adiciona rodapé em todos os emails dos dias 0 e 3:

---
Para não receber mais nossos emails: https://n8n-hook.bepex.com.br/webhook/rep-optout?email=EMAIL


WF-Rep-Worker — Camada de IA + cap (adição 2026-07-07)

Só afeta o ramo WhatsApp (dias 7/14/21). Email e o resto do fluxo acima permanecem iguais. Worker passou a ter 40 nós. Detalhes e IDs em referencia-tecnica.md § Camada de IA e no histórico (historico.md, § 2026-07-07).

O trecho É WhatsApp? (true) → Prepara WhatsApp → Busca Contato foi reencadeado assim:

É WhatsApp? (true)
  ▼
Conta WA Hoje (Postgres: COUNT do dia p/ este inbox em rep_msg_historico)
  ▼
Atingiu Cap? (IF: enviados_hoje >= 20)
  ├── true ─► Adia Lead +1 (next_action_date+1, não envia) ─► Responde Cap [fim]
  └── false ─►
Prepara WhatsApp (Chatwoot)   ← agora lê $('Roteia por Canal e Monta Payload'), gera a mensagem-BASE (fallback)
  ▼
Busca Anti-Repeticao (Postgres: últimas 10 aberturas do mesmo dia)
  ▼
Monta Prompt WA (Code: prompt com estilo/ângulos sorteados + avoid-list)
  ▼
IA Reescreve WA (HTTP OpenRouter gemini-2.5-flash, neverError)
  ▼
Valida e Fallback WA (Code: exige nome/links/bepex/fachada, sem nome próprio; senão usa a BASE)
  ▼
Busca Contato Chatwoot → ... → Envia Msg WhatsApp
  ▼
Adiciona Label ia-atendendo
  ▼
Grava Historico WA (Postgres: INSERT da mensagem enviada em rep_msg_historico)
  ▼
Prepara Update WhatsApp → Atualiza Sequência
  • Fallback garantido: qualquer falha da IA (erro, texto fora das regras) → usa o template estático de antes. Pior caso = comportamento idêntico ao anterior.
  • Cap 20/número/dia: editável no nó IF "Atingiu Cap?".
  • Tabela rep_msg_historico alimenta anti-repetição (por dia) e cap (por inbox/dia).

WF-Rep-Optout — Descadastro via email

  • ID: IGBDGuIDLMpfCX8O
  • Trigger: Webhook GET /webhook/rep-optout?email=EMAIL_CODIFICADO
  • Status: ATIVO em produção (desde 2026-06-01)
  • Nós: 3

Fluxo:

Webhook GET /rep-optout?email={encoded}
  │
  ▼
PostgreSQL: "Marca Opted Out"
  WITH upd AS (
    UPDATE representantes
    SET sequence_status = 'opted_out', updated_at = NOW()
    WHERE LOWER(email) = LOWER('{{ $json.query.email }}')
    AND sequence_status NOT IN ('opted_out')
    RETURNING id
  )
  SELECT CASE WHEN COUNT(*) > 0 THEN 'removed' ELSE 'not_found' END as status FROM upd
  (continueOnFail: true — sempre passa para o próximo nó)
  │
  ▼
Respond to Webhook: página HTML de confirmação
  Content-Type: text/html; charset=utf-8
  Body: página estática de descadastro com link para bepex.com.br

Comportamento: sempre retorna 200 + HTML, independente de o email existir ou não na base. O SQL com CTE garante 1 linha de resultado (removed/not_found). WhatsApp não tem mecanismo automático — opt-out é manual via SQL.


Banco de dados

Tabela representantes

CREATE TABLE representantes (
    id SERIAL PRIMARY KEY,
    nome VARCHAR(255) NOT NULL,
    email VARCHAR(255),
    telefone VARCHAR(50),
    empresa VARCHAR(255),
    cargo VARCHAR(255),
    cidade VARCHAR(255),
    estado VARCHAR(10),
    linkedin_url TEXT,
    sender_slot VARCHAR(5) NOT NULL,      -- 'A', 'B', 'C', etc.
    sequence_day INTEGER DEFAULT 0,        -- dia atual na cadência
    sequence_status VARCHAR(50) DEFAULT 'active',  -- active, paused, completed, opted_out
    next_action_date DATE DEFAULT CURRENT_DATE,
    last_action_date DATE,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),
    notas TEXT,
    recontato_inbox63 BOOLEAN DEFAULT FALSE  -- TRUE = lead afetado pelo incidente inbox 63 jun/2026; vai direto pra completed quando chega no dia 22 (email)
);

CREATE INDEX idx_rep_next_action ON representantes(next_action_date);
CREATE INDEX idx_rep_sender_slot ON representantes(sender_slot);
CREATE INDEX idx_rep_status ON representantes(sequence_status);

Credencial PostgreSQL no n8n: "Postgres n8n v2" (ID: 7F3gxDoPsQGjAbOW)

Ciclo de vida de um lead

INSERT (sequence_day=0, status=active, next_action_date=hoje)
  │
  ▼ Dia 0: email enviado
  sequence_day=3, next_action_date=hoje+3
  │
  ▼ Dia 3: email enviado
  sequence_day=7, next_action_date=hoje+4
  │
  ▼ Dia 7: WhatsApp enviado
  sequence_day=14, next_action_date=hoje+7
  │
  ▼ Dia 14: WhatsApp enviado
  sequence_day=NULL, status=completed

Com fallback de canal (exemplo: lead sem email):

INSERT (sequence_day=0, status=active, next_action_date=hoje)
  │
  ▼ Dia 0: sem email → pula → salta para dia 7
  sequence_day=7, next_action_date=hoje+7
  │
  ▼ Dia 7: WhatsApp enviado
  sequence_day=14, next_action_date=hoje+7
  │
  ▼ Dia 14: WhatsApp enviado
  sequence_day=NULL, status=completed


Integrações externas

Amazon SES (email)

  • Região: us-east-2
  • Domínio verificado: bepexmkt.com.br (DKIM OK, fora do sandbox, 50k/day)
  • Credencial n8n: "AWS account" (ID: kqmbhAWS0tyL5fuI)
  • Endpoint: https://email.us-east-2.amazonaws.com
  • Workaround obrigatório: ver referencia-tecnica.md § SES

Chatwoot (WhatsApp)

  • URL: https://chat.bepex.com.br
  • Account: 2 (confirmado via API em 2026-05-12 e execuções de produção em 2026-05-13)
  • Credencial n8n: Header Auth com user_access_token
  • Inboxes de prospecção:
  • Prospecção 1: inbox_id 62 (slot A)
  • Prospecção 2: inbox_id 63 (slot B)
  • Prospecção 3: inbox_id 64 (slot C)

Calendly (agendamento)

  • Link: https://calendly.com/rafael-bepex/30min
  • Conta: rafael@bepex.com.br
  • Calendar: "Agenda Representantes" (Google Calendar da mesma conta)

Hostinger (email + redirect)

  • Domínio: bepexmkt.com.br
  • Caixas de email criadas:
  • andrei.mazer@bepexmkt.com.br (slot A)
  • andrei@bepexmkt.com.br (slot B) — caixa principal de monitoramento
  • a.mazer@bepexmkt.com.br (slot C)
  • Encaminhamentos: A→B e C→B (tudo chega em andrei@bepexmkt.com.br)
  • Redirect: bepexmkt.com.br → bepex.com.br (301)