Post-Mortems — XAMLÉ Platform
Ce document recense les bugs significatifs rencontrés en production, leurs causes racines, et les règles à appliquer pour ne pas les reproduire. À lire avant tout changement touchant l'infrastructure, les APIs tierces ou l'authentification.
Architecture de référence
Netlify (Admin Frontend — React/Vite)
│
│ VITE_API_URL = https://safetrack-edtech.hf.space
▼
HuggingFace Space (safetrack/edtech)
└── API Fastify [port 7860] ← répond à toutes les requêtes REST du frontend
│
│ BullMQ (Redis partagé)
▼
Railway "whatsapp-worker"
├── API Fastify [port 7860] ← PM2, même image Docker
└── Worker Bridge [port 8082] ← consommateur BullMQ, envoie les messages WhatsApp
seul à pouvoir appeler graph.facebook.com
(HuggingFace est bloqué réseau vers Meta)
Règle fondamentale : HuggingFace ne peut pas appeler
graph.facebook.comdirectement. Tout appel Meta doit passer par le Worker Railway via un job BullMQ.
Post-Mortem #1 — Daily Limit affiche « — » (mai 2026)
Symptôme
La carte "Daily Limit" (Limite Quotidienne WhatsApp) sur /clients affichait — au lieu du tier (TIER_1K, TIER_10K…).
Cause racine
Le service fetchMetaStatus appelait l'API Meta avec la version v19.0 :
`https://graph.facebook.com/v19.0/${phoneNumberId}?fields=messaging_limit_tier,quality_rating`
v19.0 a été dépréciée en février 2026 (2 ans après sa sortie en février 2024). Meta renvoie une erreur de dépréciation, mais le catch { /* non-fatal */ } du code la masquait silencieusement. messagingLimitTier restait undefined → affichage —.
Le même problème touchait 7 fichiers avec des versions hardcodées incohérentes (v18.0 et v19.0 mélangées).
Correction appliquée
- Toutes les URLs Meta remplacées par
process.env.META_GRAPH_API_VERSION || 'v22.0' - Le
catchsilencieux remplacé par unlogger.errorvisible - Log de démarrage ajouté dans l'API et le Worker :
✅ [META] Graph API version: v22.0
Règles à retenir
⚠️ Ne jamais hardcoder une version d'API Meta dans le code.
Toujours utiliser la variable d'environnementMETA_GRAPH_API_VERSION.
⚠️ Meta déprécie ses versions tous les ~2 ans.
Vérifier la version active sur developers.facebook.com/docs/graph-api/changelog lors de chaque mise à jour majeure du projet.
⚠️ Un
catch { /* non-fatal */ }masque les bugs en production.
Tout catch doit au minimum faire unlogger.error(err).
Procédure de mise à jour future
- Changer
META_GRAPH_API_VERSION=vXX.0dans Railway (variables d'env) - Redémarrer le service → vérifier le log
✅ [META] Graph API version: vXX.0 - Vider le cache Redis :
redis-cli --scan --pattern "meta:status:*" | xargs redis-cli del - Aucun changement de code nécessaire
Post-Mortem #2 — SSE Stream : 401 Unauthorized (mai 2026)
Symptôme
safetrack-edtech.hf.space/v1/organizations/.../stream → 401 Unauthorized
La connexion SSE (Server-Sent Events) pour les notifications en temps réel échouait systématiquement. Le badge de messages non lus ne se mettait jamais à jour.
Cause racine : deux problèmes distincts
2A — VITE_API_URL pointait vers le mauvais service
À un moment, la variable pointait vers https://whatsapp-worker-production-0bc0.up.railway.app (le Worker Railway) au lieu de https://safetrack-edtech.hf.space (l'API HuggingFace). Le Worker n'expose que 2 routes (/v1/internal/whatsapp/inbound et /health). Tous les autres appels retournaient 401/404.
2B — L'API EventSource du navigateur ne peut pas envoyer de headers
// ❌ AVANT — le JWT n'arrive jamais au serveur
const es = new EventSource(`${apiBase}/v1/organizations/${orgId}/stream`);
// ✅ APRÈS — le JWT passe en query param
const es = new EventSource(`${apiBase}/v1/organizations/${orgId}/stream?token=${encodeURIComponent(token)}`);
L'API web EventSource est une limitation navigateur : elle ne supporte pas les headers personnalisés (Authorization: Bearer ...). Le serveur ne recevait donc jamais le JWT → 401.
Correction appliquée
- Frontend (
MainLayout.tsx,CrmConversationalDashboard.tsx) : ajout de?token=dans l'URL EventSource - Backend (
verifyJwt.ts) : injection du query param dans le headerAuthorizationavantjwtVerify()
// verifyJwt.ts — fallback query param pour SSE
const queryToken = (request.query as Record<string, string>)?.token;
if (queryToken && !request.headers.authorization) {
request.headers.authorization = `Bearer ${queryToken}`;
}
await request.jwtVerify();
Règles à retenir
⚠️
EventSourcene supporte pas les headers custom.
Toute connexion SSE nécessite le token en query param (?token=...). C'est le standard pour SSE — ne pas essayer de passer le JWT autrement.
⚠️
VITE_API_URLdoit toujours pointer vers l'API HuggingFace, jamais vers le Worker Railway.
Valeur correcte sur Netlify :https://safetrack-edtech.hf.space
ℹ️ Le service Railway s'appelle "whatsapp-worker" mais fait tourner les deux processus (API + Worker) via PM2 sur des ports distincts (7860 et 8082). Le nom du service Railway est trompeur.
Post-Mortem #3 — Import Excel : "File chooser dialog can only be shown with a user activation" (mai 2026)
Symptôme
Cliquer sur "Parcourir mes fichiers" dans le CRM ouvrait un sélecteur de fichier, mais après sélection du fichier, une erreur console bloquait le traitement :
File chooser dialog can only be shown with a user activation.
onFileUpload @ index-BcnTfhzI.js:598
Cause racine
Deux <input type="file"> en cascade — architecture incorrecte entre CrmAIAssistant et FileImporter :
Utilisateur clique "Parcourir mes fichiers"
└── CrmAIAssistant : <label> ouvre son propre <input type="file"> ← Input 1
│
└── onChange={() => onFileUpload()} ← déclenché après sélection
│
└── document.getElementById('crm-file-upload')?.click()
│ ← Input 2 (FileImporter)
└── 🚫 BLOQUÉ — .click() depuis un onChange()
n'est pas une "user activation" directe
(Chrome 126+, Safari 17+)
Le fichier sélectionné dans l'Input 1 était ignoré. L'Input 2 tentait de s'ouvrir depuis un onChange — ce que les navigateurs modernes refusent.
Correction appliquée
Suppression de l'input embarqué dans CrmAIAssistant. Le <label> pointe maintenant directement sur l'input de FileImporter via htmlFor :
// ❌ AVANT — double input en cascade
<label>
Parcourir mes fichiers
<input type="file" onChange={() => onFileUpload()} /> {/* déclenche un 2ème input */}
</label>
// ✅ APRÈS — un seul input, liaison directe
<label htmlFor="crm-file-upload">
Parcourir mes fichiers
</label>
{/* FileImporter rend : <input id="crm-file-upload" type="file" onChange={handleFile} /> */}
Règles à retenir
⚠️ Ne jamais appeler
.click()sur un<input type="file">depuis un handleronChange,setTimeout, ou toute fonction async.
Les navigateurs modernes exigent une activation utilisateur directe (click, keydown) pour ouvrir un sélecteur de fichier.
✅ Pattern correct : utiliser
<label htmlFor="id-de-l-input">pour déclencher un input caché. C'est natif, fiable dans tous les navigateurs, aucun JavaScript nécessaire.
⚠️ Ne jamais avoir deux
<input type="file">pour le même flux d'upload.
Un seul input, un seul handler. Si plusieurs composants doivent déclencher le même upload, ils partagent tous le mêmeidviahtmlFor.
Checklist avant tout déploiement
-
META_GRAPH_API_VERSIONest défini dans les variables d'env Railway ET est ≥ v21.0 -
VITE_API_URLsur Netlify =https://safetrack-edtech.hf.space(HuggingFace, pas Railway) - Toute connexion SSE passe le token en
?token=dans l'URL - Tout
catchloggue au minimum l'erreur - Aucun appel direct à
graph.facebook.comdepuis le code HuggingFace — passer par BullMQ
Variables d'environnement critiques
| Variable | Service | Valeur correcte | Risque si absent/faux |
|---|---|---|---|
META_GRAPH_API_VERSION |
Railway API + Worker | v22.0 (ou version récente) |
Daily Limit affiche —, erreurs Meta silencieuses |
VITE_API_URL |
Netlify | https://safetrack-edtech.hf.space |
Tous les appels API retournent 401/404 |
JWT_SECRET |
Railway | chaîne aléatoire ≥ 64 chars | Auth compromise |
ADMIN_API_KEY |
Railway | chaîne aléatoire ≥ 32 chars | Bridge Worker non sécurisé |
REDIS_URL |
Railway + HF | URL Redis partagée | BullMQ ne fonctionne plus, pas de jobs |
DATABASE_URL |
Railway + HF | Neon PostgreSQL | Toute l'app en erreur |