O Vox Pet Digital é um SaaS vertical para pet shops e clínicas veterinárias. Não é um projeto pequeno: são 95 modelos no Prisma, 91 páginas no frontend, 22 módulos de feature, integrações com OpenAI, WhatsApp, Stripe, Mercado Pago, Asaas, NF-e e uma migração ativa de Express para NestJS rodando no mesmo processo. Neste case study, vou detalhar os 4 maiores desafios técnicos que enfrentei.
O sistema
O Vox Pet cobre o ciclo completo de uma clínica veterinária: agendamentos, prontuário, vacinas, prescrições, internações, vendas, caixa, estoque, comissões, NF-e e atendimento automatizado via WhatsApp. É multi-tenant (cada clínica é um tenant isolado) e multi-filial (uma rede de clínicas compartilha dados entre unidades com controle granular).
- Backend: Node.js — Express (v1) + NestJS (v2) coexistindo no mesmo processo
- Frontend: Next.js 16 + React 19 com App Router, MUI v7 + shadcn/ui + Tailwind v4
- Banco: PostgreSQL 16 via Prisma — 95 modelos, 93 com tenantid, 76 com branchid
- IA: OpenAI (GPT-4o-mini + embeddings + Whisper) + Baileys (WhatsApp self-hosted)
- Pagamentos: Stripe (assinaturas SaaS) + Mercado Pago + Asaas (pagamentos BR)
- Fiscal: Focus NFe + NFe.io (dois providers com fallback)
- Storage: Firebase Admin
- Leads: Meta/Facebook integration
Desafio #1: migração gradual Express → NestJS no mesmo processo
Quando entrei no projeto, o backend era um monolito Express com 41 controllers — sem tipagem forte, sem DTOs, sem validação consistente. Reescrever tudo de uma vez era inviável: o sistema estava em produção com clínicas dependendo dele diariamente.
A solução foi o padrão strangler fig: Express e NestJS rodando no mesmo processo Node.js. As rotas v2 vivem em /api/v2, enquanto as v1 continuam funcionando normalmente. Ambos compartilham a mesma instância do Prisma, o mesmo sistema de auth e o mesmo tracer.
As regras para qualquer código v2 são rígidas:
- TypeScript strict — sem
any, sem// @ts-ignore - Thin controllers — lógica de negócio nos services, controller só roteia
- DTOs com validação — todo input passa por class-validator
- tenant_id obrigatório — não existe query sem filtro de tenant
- Zero
console.log— tudo via logger estruturado com contexto
Até agora, 12 módulos foram migrados para v2: pets (17 endpoints), hospitalizations (8), procedures (6), branches, stock-transfers (5 + workflow approve/reject/complete), reminders, fiscal/NF-e (11), imports (NF-e XML), booking público, e analytics. O restante — clientes, vendas, agendamentos, prontuário, vacinas, caixa, WhatsApp, admin — ainda roda na v1 e vai sendo migrado incrementalmente.
// bootstrap.ts — Express e NestJS coexistindo
const expressApp = express()
// v1 routes (legacy)
expressApp.use('/api/v1', authMiddleware, v1Router)
// v2 routes (NestJS)
const nestApp = await NestFactory.create(AppModule)
nestApp.setGlobalPrefix('api/v2')
const nestAdapter = nestApp.getHttpAdapter().getInstance()
expressApp.use(nestAdapter)
// Shared: Prisma, auth, tracer
expressApp.listen(PORT)Desafio #2: WhatsApp + IA 24h self-hosted
O atendimento via WhatsApp é um dos maiores diferenciais do Vox Pet. Não é um chatbot simples — é um agente de IA com 10 tools, RAG (knowledge base do negócio), memória de conversa, processamento de mídia (áudio via Whisper) e follow-up automático.
A arquitetura:
- Conexão: Baileys (WhatsApp Web self-hosted) rodando em Railway com persistent disk para manter a sessão
- Orchestrator: recebe a mensagem, identifica o tenant, carrega o contexto (histórico + knowledge base) e decide qual tool usar
- 10 tools disponíveis: agendar consulta, verificar horários, consultar preço, recomendar produto, buscar prontuário, enviar lembrete, entre outras
- RAG: knowledge base por tenant com embeddings OpenAI, busca semântica para contextualizar respostas
- Whisper: quando o cliente manda áudio, transcreve automaticamente e processa como texto
- Memória: histórico de conversa por cliente, mantém contexto entre mensagens
- Follow-up: cron minuto-a-minuto verifica conversas sem resposta na janela de 15–240min e envia follow-up automático
// Orchestrator simplificado
async function handleMessage(tenantId: string, message: WAMessage) {
const tenant = await loadTenantConfig(tenantId)
const history = await getConversationHistory(message.from, tenantId)
const knowledge = await ragSearch(message.text, tenantId)
// Se for áudio, transcreve com Whisper primeiro
const text = message.type === 'audio'
? await whisperTranscribe(message.media)
: message.text
const response = await openai.chat({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: buildSystemPrompt(tenant, knowledge) },
...history,
{ role: 'user', content: text },
],
tools: getAvailableTools(tenant),
})
// Executa tool calls se houver
if (response.tool_calls) {
for (const call of response.tool_calls) {
await executeToolCall(call, tenantId)
}
}
await sendWhatsAppMessage(message.from, response.content)
await saveToHistory(message.from, tenantId, text, response.content)
}O maior desafio aqui não foi a IA — foi a confiabilidade. O Baileys reconecta sozinho, mas quando o Railway reinicia o container, a sessão pode ser perdida. Implementamos persistent disk + health check + alerting para garantir que o bot nunca fica offline sem aviso.
Desafio #3: multi-tenant + multi-filial consistente
Multi-tenant em SaaS é comum. Multi-filial dentro de um tenant é outro nível de complexidade. No Vox Pet, uma rede de clínicas pode ter 3 filiais que compartilham cadastro de clientes e pets, mas cada filial tem seu próprio estoque, caixa, agenda e comissões.
Dos 95 modelos no Prisma:
- 93 modelos têm
tenant_id— isolamento total entre clínicas - 76 modelos têm
branch_id— isolamento por filial dentro do tenant - 15 migrações para chegar nessa estrutura sem quebrar dados existentes
O caso mais complexo é a transferência de estoque entre filiais. O fluxo tem 3 estados (pendente → aprovado/rejeitado → concluído) com 5 endpoints dedicados e workflow de aprovação. A filial de origem solicita, a filial de destino aprova ou rejeita, e só depois o estoque é movido atomicamente.
// Transferência de estoque entre filiais — transação atômica
async function completeTransfer(transferId: string, tenantId: string) {
return prisma.$transaction(async (tx) => {
const transfer = await tx.stockTransfer.findUnique({
where: { id: transferId, tenantId },
include: { items: true },
})
if (transfer.status !== 'APPROVED') {
throw new BadRequestException('Transfer must be approved first')
}
for (const item of transfer.items) {
// Deduz da filial de origem
await tx.stock.update({
where: { productId_branchId: { productId: item.productId, branchId: transfer.fromBranchId } },
data: { quantity: { decrement: item.quantity } },
})
// Adiciona na filial de destino
await tx.stock.upsert({
where: { productId_branchId: { productId: item.productId, branchId: transfer.toBranchId } },
create: { productId: item.productId, branchId: transfer.toBranchId, tenantId, quantity: item.quantity },
update: { quantity: { increment: item.quantity } },
})
}
await tx.stockTransfer.update({
where: { id: transferId },
data: { status: 'COMPLETED', completedAt: new Date() },
})
})
}Desafio #4: emissão fiscal com dois providers e fallback
Emissão de NF-e no Brasil é crítica — se o provider de nota fiscal cair, a clínica não pode vender. Integramos dois providers (Focus NFe e NFe.io) com fallback automático. Um cron job v2 processa a fila a cada 30 segundos.
- 11 endpoints dedicados a NF-e no módulo v2
- Import de XML — clínica pode importar notas recebidas de fornecedores
- Fila resiliente — se o provider primário falha, tenta o secundário automaticamente
- Processamento a cada 30s — evita chamadas em burst e respeita rate limits dos providers
Escala do sistema
Alguns números que mostram a complexidade real do sistema:
- 95 modelos no Prisma (banco relacional complexo)
- 91 páginas no frontend (86 funcionais)
- 22 módulos de feature no frontend
- 41 controllers Express (v1) + 12 módulos NestJS (v2)
- 14 services de integração externa
- 4 cron jobs em operação (follow-up WhatsApp, NF-e, lembretes, analytics)
- 10 tools do agente de IA no WhatsApp
Lições aprendidas
- Strangler fig funciona. Migrar gradualmente dentro do mesmo processo é mais seguro que big bang. A chave é ter regras rígidas pro código novo e nunca relaxar
- WhatsApp self-hosted é frágil. Baileys resolve, mas exige infra dedicada com persistent disk e monitoring. Se pudesse começar de novo, avaliaria a API oficial do WhatsApp Business para tenants maiores
- Multi-filial é 3x mais complexo que multi-tenant. Não é só adicionar branch_id — são regras de negócio diferentes por modelo. Estoque é por filial, cliente é por tenant, comissão é por filial, pet é por tenant
- Fallback em integrações críticas não é opcional. O dia que o Focus NFe ficou fora por 2 horas e o NFe.io assumiu sem ninguém perceber foi o dia que o investimento se pagou
--- Quer conhecer mais sobre o projeto? Acesse a página dedicada do Vox Pet Digital.
