Vox Pet Digital est un SaaS vertical pour animaleries et cliniques vétérinaires. Ce n'est pas un petit projet : il compte 95 modèles Prisma, 91 pages sur le frontend, 22 modules de feature, des intégrations avec OpenAI, WhatsApp, Stripe, Mercado Pago, Asaas, NF-e et une migration active d'Express vers NestJS qui tourne dans le même processus. Dans cette étude de cas, je vais détailler les 4 plus grands défis techniques que j'ai affrontés.
Le système
Vox Pet couvre le cycle complet d'une clinique vétérinaire : rendez-vous, dossiers médicaux, vaccins, prescriptions, hospitalisations, ventes, caisse, stocks, commissions, NF-e et service client automatisé via WhatsApp. Il est multi-tenant (chaque clinique est un tenant isolé) et multi-filiale (un réseau de cliniques partage des données entre établissements avec un contrôle granulaire).
- Backend : Node.js — Express (v1) + NestJS (v2) coexistant dans le même processus
- Frontend : Next.js 16 + React 19 avec App Router, MUI v7 + shadcn/ui + Tailwind v4
- Base de données : PostgreSQL 16 via Prisma — 95 modèles, 93 avec tenantid, 76 avec branchid
- IA : OpenAI (GPT-4o-mini + embeddings + Whisper) + Baileys (WhatsApp self-hosted)
- Paiements : Stripe (abonnements SaaS) + Mercado Pago + Asaas (paiements BR)
- Fiscal : Focus NFe + NFe.io (deux providers avec fallback)
- Storage : Firebase Admin
- Leads : intégration Meta/Facebook
Défi #1 : migration progressive Express → NestJS dans le même processus
Quand j'ai rejoint le projet, le backend était un monolithe Express avec 41 controllers — sans typage fort, sans DTOs, sans validation cohérente. Tout réécrire d'un coup était irréalisable : le système était en production, avec des cliniques qui en dépendaient tous les jours.
La solution a été le pattern strangler fig : Express et NestJS tournant dans le même processus Node.js. Les routes v2 vivent sur /api/v2, tandis que les v1 continuent de fonctionner normalement. Les deux partagent la même instance de Prisma, le même système d'auth et le même tracer.
Les règles pour tout code v2 sont strictes :
- TypeScript strict — sans
any, sans// @ts-ignore - Thin controllers — la logique métier dans les services, le controller ne fait que router
- DTOs avec validation — tout input passe par class-validator
- tenant_id obligatoire — il n'existe pas de query sans filtre de tenant
- Zéro
console.log— tout passe par un logger structuré avec contexte
Jusqu'ici, 12 modules ont été migrés vers la 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 public et analytics. Le reste — clients, ventes, rendez-vous, dossiers médicaux, vaccins, caisse, WhatsApp, admin — tourne encore sur la v1 et est migré de façon incrémentale.
// bootstrap.ts — Express et NestJS coexistant
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)Défi #2 : WhatsApp + IA 24h/24 self-hosted
Le service client via WhatsApp est l'un des plus grands atouts de Vox Pet. Ce n'est pas un simple chatbot — c'est un agent d'IA avec 10 tools, du RAG (knowledge base de l'activité), de la mémoire de conversation, du traitement de médias (audio via Whisper) et du follow-up automatique.
L'architecture :
- Connexion : Baileys (WhatsApp Web self-hosted) qui tourne sur Railway avec un persistent disk pour maintenir la session
- Orchestrator : reçoit le message, identifie le tenant, charge le contexte (historique + knowledge base) et décide quel tool utiliser
- 10 tools disponibles : prendre un rendez-vous, vérifier les créneaux, consulter un prix, recommander un produit, chercher un dossier médical, envoyer un rappel, entre autres
- RAG : knowledge base par tenant avec des embeddings OpenAI, recherche sémantique pour contextualiser les réponses
- Whisper : quand le client envoie un audio, le système le transcrit automatiquement et le traite comme du texte
- Mémoire : historique de conversation par client, maintient le contexte entre les messages
- Follow-up : un cron minute par minute vérifie les conversations sans réponse dans la fenêtre de 15–240min et envoie un follow-up automatique
// Orchestrator simplifié
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 c'est un audio, transcrire d'abord avec Whisper
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),
})
// Exécute les tool calls s'il y en a
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)
}Le plus grand défi ici n'a pas été l'IA — c'était la fiabilité. Baileys se reconnecte tout seul, mais quand Railway redémarre le container, la session peut être perdue. Nous avons mis en place persistent disk + health check + alerting pour garantir que le bot ne reste jamais offline sans prévenir.
Défi #3 : multi-tenant + multi-filiale cohérent
Le multi-tenant en SaaS est courant. Le multi-filiale à l'intérieur d'un tenant est un autre niveau de complexité. Chez Vox Pet, un réseau de cliniques peut avoir 3 filiales qui partagent les fiches clients et pets, mais chaque filiale a son propre stock, sa propre caisse, son propre agenda et ses propres commissions.
Sur les 95 modèles Prisma :
- 93 modèles ont
tenant_id— isolation totale entre les cliniques - 76 modèles ont
branch_id— isolation par filiale à l'intérieur du tenant - 15 migrations pour arriver à cette structure sans casser les données existantes
Le cas le plus complexe est le transfert de stock entre filiales. Le flux a 3 états (en attente → approuvé/rejeté → terminé) avec 5 endpoints dédiés et un workflow d'approbation. La filiale d'origine fait la demande, la filiale de destination approuve ou rejette, et ce n'est qu'ensuite que le stock est déplacé de façon atomique.
// Transfert de stock entre filiales — transaction atomique
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) {
// Déduit de la filiale d'origine
await tx.stock.update({
where: { productId_branchId: { productId: item.productId, branchId: transfer.fromBranchId } },
data: { quantity: { decrement: item.quantity } },
})
// Ajoute dans la filiale de destination
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() },
})
})
}Défi #4 : facturation avec deux providers et fallback
L'émission de NF-e au Brésil est critique — si le provider de facturation tombe, la clinique ne peut plus vendre. Nous avons intégré deux providers (Focus NFe et NFe.io) avec un fallback automatique. Un cron job v2 traite la file toutes les 30 secondes.
- 11 endpoints dédiés à NF-e dans le module v2
- Import de XML — la clinique peut importer les factures reçues des fournisseurs
- File résiliente — si le provider primaire échoue, le système tente automatiquement le secondaire
- Traitement toutes les 30s — évite les appels en burst et respecte les rate limits des providers
L'échelle du système
Quelques chiffres qui montrent la complexité réelle du système :
- 95 modèles Prisma (base de données relationnelle complexe)
- 91 pages sur le frontend (86 fonctionnelles)
- 22 modules de feature sur le frontend
- 41 controllers Express (v1) + 12 modules NestJS (v2)
- 14 services d'intégration externe
- 4 cron jobs en opération (follow-up WhatsApp, NF-e, rappels, analytics)
- 10 tools de l'agent d'IA sur WhatsApp
Leçons apprises
- Le strangler fig fonctionne. Migrer progressivement dans le même processus est plus sûr qu'un big bang. La clé, c'est d'avoir des règles strictes pour le nouveau code et de ne jamais les relâcher
- WhatsApp self-hosted est fragile. Baileys règle le problème, mais exige une infra dédiée avec persistent disk et monitoring. Si je pouvais recommencer, j'évaluerais l'API officielle de WhatsApp Business pour les tenants les plus gros
- Le multi-filiale est 3x plus complexe que le multi-tenant. Il ne s'agit pas seulement d'ajouter branch_id — ce sont des règles métier différentes selon le modèle. Le stock est par filiale, le client est par tenant, la commission est par filiale, le pet est par tenant
- Le fallback sur les intégrations critiques n'est pas optionnel. Le jour où Focus NFe est resté hors service pendant 2 heures et où NFe.io a pris le relais sans que personne ne s'en aperçoive, c'est le jour où l'investissement s'est payé
--- Vous voulez en savoir plus sur le projet ? Accédez à la page dédiée de Vox Pet Digital.
