O modelo de dados de um CRM não é óbvio. As 4 iterações até acertar o histórico, snapshots e consistência no banco.

Quando comecei a construir o Axxos CRM, assumi que a parte mais difícil seria o frontend — o pipeline visual estilo Kanban, os drag-and-drops, os gráficos em tempo real. Estava errado. A parte mais difícil foi modelar o funil de vendas no banco de dados.
Parece simples: um lead passa por etapas até virar cliente. Mas quando você começa a pensar nas perguntas reais do negócio — um lead pode estar em mais de uma etapa? O que acontece quando a etapa é renomeada? Como rastrear quanto tempo ficou em cada fase? — a complexidade aparece rápido.
Foram quatro iterações até chegar num modelo que funcionasse bem em produção. Aqui estão todas elas, com os erros e o que cada uma me ensinou.
O contexto
O Axxos CRM precisava suportar o ciclo de vida completo de um lead comercial — desde o primeiro contato até o fechamento da venda e o pós-venda. O time tinha necessidades específicas:
- Etapas do funil configuráveis (sem deploy para mudar)
- Histórico completo de movimentação de cada lead
- Relatórios de tempo médio por etapa e taxa de conversão
- Múltiplos usuários operando simultaneamente sem conflito
- Auditoria de quem moveu o quê e quando
O que parecia ser um simples status num campo da tabela rapidamente mostrou que não seria suficiente.
Iteração 1 — O enum ingênuo
A primeira versão foi a mais óbvia. Um campo status na tabela de leads com um enum fixo no banco:
CREATE TYPE lead_status AS ENUM (
'novo',
'contato_feito',
'proposta_enviada',
'negociacao',
'fechado',
'perdido'
);
CREATE TABLE leads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
nome TEXT NOT NULL,
email TEXT,
status lead_status NOT NULL DEFAULT 'novo',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);Por que pareceu certo: simples, rápido de implementar, queries diretas. WHERE status = 'proposta_enviada' funciona perfeitamente.
Por que não funcionou:
O problema apareceu na primeira reunião com o time comercial. Eles queriam adicionar uma etapa "Demonstração Agendada" entre contato e proposta. Com enum, isso significa uma migration em produção, um deploy e torcer para ninguém estar usando o sistema naquele momento.
Pior: enums no PostgreSQL não permitem remover valores sem recriar o tipo. Se uma etapa cai em desuso, ela fica lá para sempre.
O enum fixo amarra o banco ao processo de negócio. E processo de negócio muda.
Iteração 2 — A tabela de etapas
A solução natural foi extrair as etapas para uma tabela separada:
CREATE TABLE pipeline_stages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
nome TEXT NOT NULL,
ordem INTEGER NOT NULL,
cor TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE leads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
nome TEXT NOT NULL,
email TEXT,
stage_id UUID REFERENCES pipeline_stages(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);Agora o time comercial pode criar, renomear e reordenar etapas sem nenhum deploy. Melhor.
Por que não funcionou:
Dois problemas apareceram juntos.
O primeiro: sem histórico. Quando um lead muda de etapa, o stage_id é sobrescrito. Não há como saber quanto tempo ficou em cada fase, quem fez a mudança ou qual era a etapa anterior. Isso tornava relatórios de tempo médio por etapa impossíveis.
O segundo — mais sutil — apareceu quando um lead foi excluído de uma etapa por engano. Não havia como desfazer. A operação era destrutiva.
Qualquer sistema de vendas real precisa de auditoria. Sem ela, o time perde confiança nos dados rapidamente.
Iteração 3 — A tabela de histórico
Adicionei uma tabela de histórico para registrar cada movimentação:
CREATE TABLE lead_stage_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lead_id UUID NOT NULL REFERENCES leads(id),
stage_id UUID NOT NULL REFERENCES pipeline_stages(id),
user_id UUID NOT NULL REFERENCES users(id),
entered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
exited_at TIMESTAMPTZ
);A lógica ficou assim: quando um lead muda de etapa, o registro atual recebe exited_at = NOW() e um novo registro é inserido com entered_at = NOW() e exited_at = NULL. A etapa atual é sempre o registro sem exited_at.
Agora dava para calcular tempo por etapa, rastrear quem moveu, ver o histórico completo.
Por que ainda não estava certo:
O modelo funcionava para movimentações simples, mas tinha uma falha de consistência: o stage_id na tabela leads e o registro atual em lead_stage_history podiam ficar dessincronizados se uma das operações falhasse no meio. Era necessário atualizar dois lugares atomicamente toda vez.
Resolvi com transações, mas ficou frágil. A fonte da verdade estava duplicada — e duplicação de fonte da verdade é um problema esperando para acontecer.
O outro problema: e se a etapa fosse deletada? Todos os históricos que referenciavam aquela etapa ficariam órfãos, ou eu precisaria de ON DELETE RESTRICT que bloqueava a deleção de etapas que já tinham leads.
Iteração 4 — O modelo que foi para produção
A versão final resolveu os dois problemas: eliminou a duplicação de fonte da verdade e protegeu o histórico contra deleções.
-- Etapas do funil (configuráveis pelo usuário)
CREATE TABLE pipeline_stages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
nome TEXT NOT NULL,
ordem INTEGER NOT NULL,
cor TEXT NOT NULL DEFAULT '#6366f1',
is_terminal BOOLEAN NOT NULL DEFAULT FALSE, -- fechado ou perdido
archived_at TIMESTAMPTZ, -- soft delete
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Leads
CREATE TABLE leads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
nome TEXT NOT NULL,
email TEXT,
telefone TEXT,
empresa TEXT,
responsavel UUID REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW()
-- sem stage_id aqui — a etapa atual vem do histórico
);
-- Histórico de movimentações (fonte única da verdade)
CREATE TABLE lead_stage_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
stage_id UUID NOT NULL REFERENCES pipeline_stages(id),
-- snapshot do nome da etapa no momento da movimentação
stage_nome TEXT NOT NULL,
moved_by UUID NOT NULL REFERENCES users(id),
entered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
exited_at TIMESTAMPTZ,
CONSTRAINT apenas_uma_etapa_ativa
EXCLUDE USING gist (
lead_id WITH =,
tstzrange(entered_at, exited_at) WITH &&
)
);
-- Índices para queries de dashboard
CREATE INDEX idx_lead_stage_history_lead_id ON lead_stage_history(lead_id);
CREATE INDEX idx_lead_stage_history_stage_id ON lead_stage_history(stage_id);
CREATE INDEX idx_lead_stage_history_ativa ON lead_stage_history(lead_id) WHERE exited_at IS NULL;As três decisões que fizeram diferença:
1. Remover stage_id de leads. A etapa atual agora é sempre consultada via WHERE exited_at IS NULL. Uma fonte de verdade, sem risco de dessincronização. A query fica um join a mais, mas com o índice parcial em exited_at IS NULL isso é imperceptível.
2. stage_nome como snapshot. Quando uma etapa é renomeada, o histórico antigo preserva o nome que ela tinha no momento da movimentação. Isso é importante para relatórios históricos — você quer saber que o lead passou por "Proposta Enviada", não por "Proposta", que é como a etapa foi renomeada depois.
3. archived_at em vez de DELETE. Etapas nunca são deletadas. São arquivadas. Isso protege o histórico e resolve o problema de ON DELETE RESTRICT que bloqueava a gestão do funil.
A constraint de exclusão garante que um lead nunca esteja em dois lugares ao mesmo tempo — sem depender de lógica na aplicação.
As queries que o dashboard usa
Com esse modelo, as queries de relatório ficaram limpas:
-- Etapa atual de cada lead (para o Kanban)
SELECT
l.id,
l.nome,
l.empresa,
h.stage_id,
h.stage_nome,
h.entered_at
FROM leads l
JOIN lead_stage_history h ON h.lead_id = l.id AND h.exited_at IS NULL
ORDER BY h.entered_at DESC;
-- Tempo médio por etapa (para relatórios de funil)
SELECT
stage_nome,
AVG(
EXTRACT(EPOCH FROM (COALESCE(exited_at, NOW()) - entered_at)) / 3600
) AS media_horas,
COUNT(*) AS total_passagens
FROM lead_stage_history
WHERE entered_at >= NOW() - INTERVAL '90 days'
GROUP BY stage_nome
ORDER BY media_horas DESC;
-- Taxa de conversão por etapa
SELECT
stage_nome,
COUNT(*) AS total,
COUNT(exited_at) AS saiu,
ROUND(COUNT(exited_at)::NUMERIC / COUNT(*) * 100, 1) AS taxa_saida_pct
FROM lead_stage_history
GROUP BY stage_nome
ORDER BY MIN(
SELECT ordem FROM pipeline_stages WHERE nome = stage_nome LIMIT 1
);Métricas de Crescimento
Tempo de desenvolvimento por iteração
Complexidade vs Flexibilidade
Stack técnica
PostgreSQL
Banco principal. A constraint de exclusão com gist e os índices parciais foram decisivos para performance e consistência sem depender de lógica na aplicação.
Prisma ORM
Usado para as queries do dia a dia. As queries de relatório mais complexas foram escritas em SQL puro via prisma.$queryRaw para ter controle total.
NestJS
A camada de serviço encapsula toda a lógica de movimentação em transações atômicas — garantindo que entered_at e exited_at sempre sejam atualizados juntos.
Redis
Cache das métricas de dashboard (tempo médio por etapa, taxa de conversão) com TTL de 5 minutos. Evita recalcular agregações pesadas a cada requisição.
O que eu faria diferente
Teria chegado na iteração 4 mais rápido. O padrão de histórico com snapshot é conhecido — chama-se "append-only log" ou "event sourcing lite" dependendo de quem você perguntar. Se eu tivesse pesquisado antes de implementar, teria pulado as iterações 1 e 2 diretamente.
Teria adicionado metadata desde o início. Um campo JSONB metadata na tabela de histórico permite registrar contexto extra sem migrations — o motivo da movimentação, uma observação do consultor, dados da integração que triggerou a mudança. Adicionei depois e foi uma migration simples, mas teria sido mais limpo ter desde o começo.
A lição mais importante: modelo de dados é onde você paga mais caro por decisões erradas. Frontend errado você refatora em dias. Modelo de dados errado em produção, com dados reais, é uma cirurgia — e às vezes sem anestesia. Vale a pena iterar no papel antes de iterar no banco.