Vinicius Aguiar
Case Study

Case Study: Vox Pet Digital — migrando un SaaS veterinario de Express a NestJS sin downtime

16 de abr. de 2026 · 14 min de lectura

Vox Pet Digital es un SaaS vertical para pet shops y clínicas veterinarias. No es un proyecto pequeño: son 95 modelos en Prisma, 91 páginas en el frontend, 22 módulos de feature, integraciones con OpenAI, WhatsApp, Stripe, Mercado Pago, Asaas, NF-e y una migración activa de Express a NestJS corriendo en el mismo proceso. En este case study, voy a detallar los 4 mayores desafíos técnicos que enfrenté.

El sistema

Vox Pet cubre el ciclo completo de una clínica veterinaria: citas, historial médico, vacunas, prescripciones, hospitalizaciones, ventas, caja, inventario, comisiones, NF-e y atención automatizada vía WhatsApp. Es multi-tenant (cada clínica es un tenant aislado) y multi-filial (una red de clínicas comparte datos entre sucursales con control granular).

  • Backend: Node.js — Express (v1) + NestJS (v2) coexistiendo en el mismo proceso
  • Frontend: Next.js 16 + React 19 con App Router, MUI v7 + shadcn/ui + Tailwind v4
  • Base de datos: PostgreSQL 16 vía Prisma — 95 modelos, 93 con tenantid, 76 con branchid
  • IA: OpenAI (GPT-4o-mini + embeddings + Whisper) + Baileys (WhatsApp self-hosted)
  • Pagos: Stripe (suscripciones SaaS) + Mercado Pago + Asaas (pagos BR)
  • Fiscal: Focus NFe + NFe.io (dos providers con fallback)
  • Storage: Firebase Admin
  • Leads: Integración Meta/Facebook

Desafío #1: migración gradual Express → NestJS en el mismo proceso

Cuando entré al proyecto, el backend era un monolito Express con 41 controllers — sin tipado fuerte, sin DTOs, sin validación consistente. Reescribir todo de una vez era inviable: el sistema estaba en producción con clínicas dependiendo de él diariamente.

La solución fue el patrón strangler fig: Express y NestJS corriendo en el mismo proceso Node.js. Las rutas v2 viven en /api/v2, mientras las v1 siguen funcionando normalmente. Ambos comparten la misma instancia de Prisma, el mismo sistema de auth y el mismo tracer.

Las reglas para cualquier código v2 son estrictas:

  • TypeScript strict — sin any, sin // @ts-ignore
  • Thin controllers — lógica de negocio en services, controller solo rutea
  • DTOs con validación — todo input pasa por class-validator
  • tenant_id obligatorio — no existe query sin filtro de tenant
  • Zero console.log — todo vía logger estructurado con contexto

Hasta ahora, 12 módulos fueron migrados a 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, y analytics. El resto — clientes, ventas, citas, historial, vacunas, caja, WhatsApp, admin — todavía corre en v1 y se va migrando incrementalmente.

// bootstrap.ts — Express y NestJS coexistiendo
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)

Desafío #2: WhatsApp + IA 24h self-hosted

La atención vía WhatsApp es uno de los mayores diferenciales de Vox Pet. No es un chatbot simple — es un agente de IA con 10 tools, RAG (knowledge base del negocio), memoria de conversación, procesamiento de media (audio vía Whisper) y follow-up automático.

La arquitectura:

  1. Conexión: Baileys (WhatsApp Web self-hosted) corriendo en Railway con persistent disk para mantener la sesión
  2. Orchestrator: recibe el mensaje, identifica el tenant, carga el contexto (historial + knowledge base) y decide qué tool usar
  3. 10 tools disponibles: agendar consulta, verificar horarios, consultar precio, recomendar producto, buscar historial médico, enviar recordatorio, entre otras
  4. RAG: knowledge base por tenant con embeddings OpenAI, búsqueda semántica para contextualizar respuestas
  5. Whisper: cuando el cliente manda audio, transcribe automáticamente y procesa como texto
  6. Memoria: historial de conversación por cliente, mantiene contexto entre mensajes
  7. Follow-up: cron minuto-a-minuto verifica conversaciones sin respuesta en la ventana de 15–240min y envía 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)

  // Si es audio, transcribe con Whisper primero
  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),
  })

  // Ejecuta tool calls si hay
  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)
}

El mayor desafío aquí no fue la IA — fue la confiabilidad. Baileys reconecta solo, pero cuando Railway reinicia el container, la sesión puede perderse. Implementamos persistent disk + health check + alerting para garantizar que el bot nunca quede offline sin aviso.

Desafío #3: multi-tenant + multi-filial consistente

Multi-tenant en SaaS es común. Multi-filial dentro de un tenant es otro nivel de complejidad. En Vox Pet, una red de clínicas puede tener 3 sucursales que comparten registro de clientes y mascotas, pero cada sucursal tiene su propio inventario, caja, agenda y comisiones.

De los 95 modelos en Prisma:

  • 93 modelos tienen tenant_id — aislamiento total entre clínicas
  • 76 modelos tienen branch_id — aislamiento por sucursal dentro del tenant
  • 15 migraciones para llegar a esta estructura sin romper datos existentes

El caso más complejo es la transferencia de inventario entre sucursales. El flujo tiene 3 estados (pendiente → aprobado/rechazado → completado) con 5 endpoints dedicados y workflow de aprobación. La sucursal de origen solicita, la de destino aprueba o rechaza, y solo después el inventario se mueve atómicamente.

// Transferencia de inventario entre sucursales — transacción 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) {
      // Deduce de la sucursal de origen
      await tx.stock.update({
        where: { productId_branchId: { productId: item.productId, branchId: transfer.fromBranchId } },
        data: { quantity: { decrement: item.quantity } },
      })
      // Agrega en la sucursal 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() },
    })
  })
}

Desafío #4: emisión fiscal con dos providers y fallback

La emisión de NF-e en Brasil es crítica — si el provider de facturación cae, la clínica no puede vender. Integramos dos providers (Focus NFe y NFe.io) con fallback automático. Un cron job v2 procesa la cola cada 30 segundos.

  • 11 endpoints dedicados a NF-e en el módulo v2
  • Import de XML — la clínica puede importar notas recibidas de proveedores
  • Cola resiliente — si el provider primario falla, intenta el secundario automáticamente
  • Procesamiento cada 30s — evita llamadas en burst y respeta rate limits de los providers

Escala del sistema

Algunos números que muestran la complejidad real del sistema:

  • 95 modelos en Prisma (base de datos relacional compleja)
  • 91 páginas en el frontend (86 funcionales)
  • 22 módulos de feature en el frontend
  • 41 controllers Express (v1) + 12 módulos NestJS (v2)
  • 14 servicios de integración externa
  • 4 cron jobs en operación (follow-up WhatsApp, NF-e, recordatorios, analytics)
  • 10 tools del agente de IA en WhatsApp

Lecciones aprendidas

  1. Strangler fig funciona. Migrar gradualmente dentro del mismo proceso es más seguro que big bang. La clave es tener reglas estrictas para el código nuevo y nunca relajarlas
  2. WhatsApp self-hosted es frágil. Baileys resuelve, pero exige infra dedicada con persistent disk y monitoring. Si pudiera empezar de nuevo, evaluaría la API oficial de WhatsApp Business para tenants más grandes
  3. Multi-filial es 3x más complejo que multi-tenant. No es solo agregar branch_id — son reglas de negocio diferentes por modelo. Inventario es por sucursal, cliente es por tenant, comisión es por sucursal, mascota es por tenant
  4. Fallback en integraciones críticas no es opcional. El día que Focus NFe estuvo caído por 2 horas y NFe.io asumió sin que nadie notara fue el día que la inversión se pagó

--- ¿Quieres conocer más sobre el proyecto? Accede a la página dedicada de Vox Pet Digital.