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