Em produção2026-03 – atualDesenvolvedor full-stack, único

FunilChat AI

Um CRM de WhatsApp, vários clientes pagantes, nenhum pode ver um byte sequer dos dados do outro.

  • fastapi
  • postgresql
  • react
  • n8n
  • redis

O problema e as restrições

O FunilChat AI existe para responder uma mensagem de WhatsApp antes que um humano tenha tempo de abrir o aplicativo. O cliente, WA Financial Services, ajuda imigrantes brasileiros nos Estados Unidos a lidar com papelada financeira e legal: declaração de imposto, abertura de empresa, o tipo de formulário em que um prazo perdido tem consequência real. A maior parte do primeiro contato acontece pelo WhatsApp, em horários em que ninguém está numa mesa, e o fundador, Wolney, era a única pessoa respondendo. Cada minuto que um lead esperava por uma resposta era um minuto em que ele podia mandar mensagem para um concorrente em vez disso.

A resposta óbvia (colocar uma IA na frente do WhatsApp) vem com um conjunto de restrições bem menos óbvio quando o negócio é real e a IA está conversando com clientes em potencial de verdade sobre dinheiro e status de imigração de verdade.

A primeira restrição era conquistar confiança, o que pesava aqui mais do que velocidade bruta. Um cliente em potencial decidindo se entrega os próprios documentos fiscais a um desconhecido está lendo cada sinal para saber se aquela operação é competente e cuidadosa, e uma resposta um pouco errada da IA custa muito mais do que dar de ombros e tentar de novo. Se a IA e um humano respondem na mesma conversa, ou a IA continua falando depois que um humano já assumiu, esse tropeço visível causa mais dano do que uma resposta lenta jamais causaria. A troca entre IA e humano precisava ser invisível do lado do cliente e inequívoca do lado do sistema, nunca os dois respondendo, nunca nenhum dos dois.

A segunda restrição era que isso precisava funcionar para mais de um cliente sem virar mais de um sistema. A WA Financial Services era o tenant um, mas o plano desde o início era vender o mesmo produto para outras empresas pequenas de serviço que atendem o primeiro contato pelo WhatsApp. Isso descartava qualquer coisa que assumisse um negócio único e fixo (um número de WhatsApp, uma base de conhecimento, um conjunto de usuários), porque cada uma dessas suposições teria que ser desfeita depois, num sistema já no ar, para o segundo cliente pagante. Ao mesmo tempo, não havia equipe de operação nem orçamento para uma: fosse lá o que “suportar múltiplos tenants” significasse, tinha que ser algo que um desenvolvedor sozinho conseguisse rodar e entender.

A terceira restrição era peso regulatório sem orçamento regulatório. Os clientes finais aqui estão enviando o tipo de documento (SSN, equivalentes de ITIN/EIN, papelada ligada a imigração) para o qual uma fintech ou uma empresa de serviços jurídicos teria uma equipe de compliance e um orçamento de segurança dedicados. Este sistema não tem nenhum dos dois. Cada controle de segurança (autenticação multifator, revogação de sessão, cifragem de campos sensíveis, varredura de malware em documento enviado, trilha de auditoria de quem mudou o quê) teve que ser construído dentro do próprio produto, porque não havia uma camada ou equipe de segurança separada para acrescentar depois.

A quarta restrição era operacional: uma única VPS, sem ambiente de staging, e um fundador que também é o atendente humano principal respondendo o CRM de WhatsApp todo dia. Toda migration roda direto contra o banco de produção. Não existe um ensaio geral em ambiente separado onde uma mudança de schema é testada antes. A rede de segurança é revisão cuidadosa antes de rodar; não existe cópia da produção disponível para quebrar no lugar.

A última restrição era risco de fornecedor na única dependência pela qual o produto inteiro existe para conversar: o próprio WhatsApp. Não existe um caminho oficial de WhatsApp Business Cloud que caiba numa operação self-hosted e sensível a custo nesse estágio, o que significa que o sistema depende de um provedor de automação de WhatsApp cujos termos, preço ou confiabilidade estão fora do controle de qualquer um aqui. O sistema já tinha trocado de provedor uma vez. O que fosse construído a seguir tinha que partir do princípio de que isso aconteceria de novo.

Arquitetura

Uma mensagem começa no celular do cliente e chega a um módulo de WhatsApp por tenant, uma instância conectada por cliente, para que as conversas do tenant A nunca passem fisicamente pela sessão do tenant B. Dali ela passa pelo Redis, que existe por um motivo: pessoas raramente mandam uma mensagem de WhatsApp por pensamento. Um cliente de verdade manda três ou quatro mensagens curtas seguidas, e responder depois da primeira significa responder meia frase. O Redis retém e agrupa mensagens que chegam numa janela curta de tempo, para que a IA responda o pensamento inteiro em vez de interrompê-lo.

A mensagem agrupada chega a um workflow do n8n, que é o verdadeiro cérebro da camada de primeira resposta: ele contém o agente de IA (rodando sobre a OpenAI), a busca na base de conhecimento e a lógica de retomada, tudo como um único fluxo orquestrado em vez de espalhado entre serviços. Toda vez que esse workflow precisa ler ou escrever algo que pertence a um tenant específico (um cadastro de cliente, uma conversa, uma tarefa criada a partir de uma conversa), ele passa pelo backend em FastAPI em vez de tocar o Postgres diretamente.

Essa escolha de roteamento é o que torna o isolamento entre tenants real em vez de apenas pretendido. O backend seta uma variável do Postgres com escopo de sessão identificando o tenant corrente em todo request que atende, e as políticas de row-level security nas vinte e uma tabelas com escopo de tenant usam essa variável para excluir silenciosamente toda linha que não pertença a quem está chamando. Um desenvolvedor que esquece uma cláusula WHERE tenant_id = ... em algum lugar do código da aplicação não vaza dado de outro tenant, porque o próprio banco recusa a linha antes mesmo da aplicação vê-la. O único lugar onde essa fronteira não é hermética é o papel de automação sob o qual o workflow do n8n roda: ele não seta essa variável de sessão do jeito que a API seta, então roda com um bypass em vez disso, uma exceção estreita e deliberadamente visível a uma regra que, fora dela, é fail-closed, discutida como decisão própria mais abaixo.

O CRM em React é onde um atendente humano vê a mesma conversa que a IA está conduzindo, em algo próximo de tempo real. É também onde a retomada de fato acontece: um botão chamado “Assumir” liga uma flag naquela conversa, e toda conferência seguinte que o workflow de IA faz antes de mandar uma resposta vê essa flag e fica em silêncio. Nada aqui é negociado entre a IA e o humano. É um único booleano que o workflow lê antes de ter permissão para falar, o que também é o motivo de ter sido barato deixar isso à prova de falha. Trinta minutos depois da última resposta humana sem nenhuma outra ação, a flag se limpa sozinha e a IA retoma, para que uma conversa que um humano esqueceu de devolver não fique presa em silêncio indefinidamente.

Tudo que a IA ou o atendente humano tocam é escrito pelo mesmo backend, validado pelo mesmo schema e restrito pela mesma row-level security. Existe exatamente um caminho de escrita para o sistema de registro, não importa de qual lado da fronteira entre IA e humano a escrita tenha vindo.

Uma conversa de WhatsApp no FunilChat AI em que a IA Amanda responde uma dúvida de bookkeeping, o cliente pede para falar com uma pessoa; o Wolney assume pelo painel e, mais tarde, responde de novo direto do celular, cada balão rotulado 'Amanda', 'Wolney' ou 'Respondido via WhatsApp' conforme quem realmente enviou. O cabeçalho mostra 'Aguardando atendente' com um botão 'Devolver para IA', e o painel direito mostra a ficha do cliente: empresa, serviço, status do lead, e-mail e notas. Toda mensagem, foto e dado de ficha mostrado é conteúdo fictício de demonstração.
A retomada descrita acima, exatamente na interface que o cliente vê: a IA responde, entrega quando pedido, e o rótulo de status é o mesmo booleano que o workflow da IA confere antes de ter permissão para responder.

Arquitetura

FunilChat AI: architecture A customer sends a WhatsApp message. It reaches a per-tenant WhatsApp module, which forwards it through Redis message grouping into an n8n workflow running the AI agent. The workflow reads and writes tenant-scoped data in PostgreSQL, enforced by row-level security, through the FastAPI backend. A human agent using the React CRM can claim the conversation at any point, which silences the AI for that thread until released or after 30 minutes of inactivity. WhatsApp module one instance per tenant Redis message grouping n8n workflow AI agent + handoff logic React CRM human agent takes over FastAPI backend sets tenant context per request PostgreSQL 21 tenant tables row-level security, fail-closed Tenant A / B / C claims / releases conversation

Decisões

DECISION 01/04 · Isolamento entre tenants

EscolhidoSchema compartilhado, um único banco Postgres, fronteira de tenant garantida por row-level security (RLS) a partir de uma variável de sessão setada por request

DescartadoUm banco (ou um schema) por tenant

Um cliente novo precisa ser incorporado em minutos, sem provisionar infraestrutura nova, e não existe equipe de operação para administrar uma frota crescente de bancos

Custo aceitoA camada de automação (n8n) não consegue setar facilmente uma variável de sessão por request como a API faz, então o papel dela no banco roda com BYPASSRLS, um ponto cego real e permanente no modelo de isolamento, que precisa ser revisado manualmente toda vez que um workflow novo toca o banco

DECISION 02/04 · Provedor de WhatsApp

EscolhidoEsconder o provedor de WhatsApp atrás de uma abstração interna genérica ("módulo WhatsApp"), para que o fornecedor possa mudar sem que o produto visível ao cliente mude

DescartadoIntegrar direto contra o SDK de um fornecedor e expor o nome dele em código, documentação e interface

O projeto já trocou de provedor uma vez; um sistema cuja identidade de produto está acoplada à API de um fornecedor específico obriga a reescrita toda vez que os termos, o preço ou a confiabilidade desse fornecedor mudam

Custo aceitoUma camada extra de tradução entre o formato real do webhook do provedor e o modelo de evento interno, mais um lugar onde um bug pode se esconder, e mais uma coisa para manter sincronizada quando a API do provedor muda de forma

DECISION 03/04 · Retomada por humano

EscolhidoUm atendente humano pode assumir uma conversa a qualquer momento; a IA confere uma flag por conversa antes de cada resposta e fica em silêncio depois que ela é assumida, liberando automaticamente após 30 minutos de inatividade humana

DescartadoDevolução só manual, sem liberação automática

Um cliente decidindo se confia numa empresa pequena com papelada financeira e de imigração não pode ver a IA e um atendente humano falando um por cima do outro na mesma conversa. Esse momento quebra confiança de um jeito que nenhuma mensagem depois conserta

Custo aceitoUma conversa pode voltar silenciosamente para a IA no meio do raciocínio se o atendente humano for chamado para outra coisa por mais de 30 minutos sem querer devolver. O temporizador precisa ser ajustado com dado real de tempo de resposta, em vez de uma suposição feita uma vez e esquecida

DECISION 04/04 · Cifragem de campos sensíveis

EscolhidoCifragem em nível de aplicação (Fernet, com prefixo versionado) para o segredo de MFA e o campo de documento fiscal, opt-in e compatível com linhas em texto puro durante a transição

DescartadoDepender só da cifragem em disco oferecida pela plataforma de hospedagem

Os próprios clientes do cliente são imigrantes enviando documentos fiscais equivalentes a SSN/ITIN/EIN; cifragem em disco protege contra um disco roubado, mas não faz nada por um dump de banco ou um backup que acaba em algum lugar que não deveria

Custo aceitoPerder a chave de cifragem destrói esse dado permanentemente (não existe caminho de recuperação por desenho), e o rollout opt-in com leitura dupla faz linhas em texto puro e cifradas coexistirem até que toda linha seja reprocessada

Invariantes

  • Uma consulta rodando sob o papel de banco da API nunca retorna linha de um tenant diferente, mesmo que o código da aplicação esqueça uma cláusula WHERE

    Garantido porpolíticas de row-level security do PostgreSQL nas 21 tabelas com escopo de tenant, fail-closed por padrão, chaveadas por uma variável de sessão que o backend seta em todo request

  • A IA nunca envia uma resposta de WhatsApp numa conversa que um atendente humano assumiu

    Garantido poruma flag de bloqueio por conversa, conferida imediatamente antes de cada resposta da IA ser disparada pelo workflow do n8n

  • Uma sessão revogada para de funcionar imediatamente, mesmo que o JWT ainda não tenha expirado

    Garantido poruma claim token_version comparada com o valor guardado a cada request autenticado

  • Depois que a cifragem é ativada para um campo, aquele valor nunca mais é gravado em texto puro no banco

    Garantido poro caminho de escrita só chama o setter que cifra com Fernet; o caminho de leitura é o único lugar que ainda entende o formato antigo em texto puro

  • Uma migration que cria uma tabela escrita por um workflow do n8n sobe com o grant de sequence do papel de automação já incluído

    Garantido porum item de checklist adicionado à revisão de migration depois que essa classe exata de bug chegou à produção uma vez (ver abaixo)

O que quebrou

Sintoma
Um workflow do n8n em produção falhou ao criar uma notificação de handoff com um erro do Postgres apontando uma sequence em vez de uma tabela. O INSERT na linha, sob RLS, parecia que deveria ter sido permitido
Causa raiz
A migration que criou a tabela de notificações concedeu INSERT na tabela para os dois papéis de banco, mas concedeu USAGE e SELECT na sequence de auto-incremento só para o papel da API. O PostgreSQL trata privilégio de tabela e privilégio de sequence como concessões separadas, e os privilégios padrão do papel de automação não estavam configurados para herdar acesso à sequence do jeito que herdavam acesso à tabela. Então todo INSERT que precisava de um ID novo gerado automaticamente por aquele papel falhava, enquanto o resto da tabela funcionava normalmente
Correção
Uma migration seguinte adicionou o GRANT USAGE, SELECT ON SEQUENCE ... que faltava para o papel de automação, e a correção virou regra permanente, em vez de um ajuste pontual
Prevenção
Toda migration seguinte que cria uma tabela escrita pelos workflows de automação já inclui o grant de sequence no mesmo arquivo do grant de tabela, conferido na hora de escrever a migration em vez de descoberto depois por um erro em produção

Resultados

21tabelas sob row-level securitygrep por ENABLE ROW LEVEL SECURITY em backend/migrations/*.sql, 2026-08-26
44migrations de banco aplicadascontagem de arquivos em backend/migrations/, 2026-08-26
307commitscontagem de git log --oneline na branch main, 2026-08-26
~4.020arquivos de código-fonte (backend + frontend)contagem de arquivos .py/.ts/.tsx/.js fora de node_modules, 2026-08-26
desde abril de 2026em produção contínuaREADME do projeto, seção de status
2.21.0versão atual em produçãodocs/CHANGELOG.md, última entrada

Stack completa

Backend

  • FastAPI (Python 3.11)
  • JWT + MFA TOTP
  • Cifragem de campo com Fernet

Frontend

  • React
  • Vite
  • sessionStorage

Dados

  • PostgreSQL com row-level security
  • Redis (agrupamento de mensagens)

Infra

  • n8n (automação self-hosted)
  • EasyPanel / VPS
  • ClamAV

IA

  • OpenAI (via AI Agent do n8n)

Tem interesse em um projeto como esse? Entre em contato.

Entrar em contato