Vinicius Aguiar
Case Study

Case Study: Vox Pet Digital — migrando um SaaS veterinário de Express para NestJS sem downtime

16 de abril de 2026 · 14 min de leitura

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:

  1. Conexão: Baileys (WhatsApp Web self-hosted) rodando em Railway com persistent disk para manter a sessão
  2. Orchestrator: recebe a mensagem, identifica o tenant, carrega o contexto (histórico + knowledge base) e decide qual tool usar
  3. 10 tools disponíveis: agendar consulta, verificar horários, consultar preço, recomendar produto, buscar prontuário, enviar lembrete, entre outras
  4. RAG: knowledge base por tenant com embeddings OpenAI, busca semântica para contextualizar respostas
  5. Whisper: quando o cliente manda áudio, transcreve automaticamente e processa como texto
  6. Memória: histórico de conversa por cliente, mantém contexto entre mensagens
  7. 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

  1. 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
  2. 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
  3. 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
  4. 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.