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 IAe 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_historicoalimenta 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)