étude de cas · 2026

Plateforme de formation / LMS sur mesure
portaldeestudio.aibymel.site

portaldeestudio.aibymel.site

01

le défi

IAgency, une académie colombienne de formation à l'IA, faisait tourner sa promotion sur trois outils empruntés : le cours vivait chez Hotmart, les échanges avec les élèves sur WhatsApp, et l'agenda des séances individuelles dans un tableur. Chacun marchait seul ; ensemble, ils ne se parlaient pas. Personne ne pouvait dire, à un instant donné, où en était une élève : ce qu'elle avait vu du cours, ce qu'elle avait demandé, quand elle avait rendez-vous. La commande initiale tenait en une phrase : un écran de connexion avec un lien vers le cours hébergé sur Hotmart. Elle a grandi à mesure que chaque outil emprunté montrait sa limite : un contenu qu'on ne peut pas ouvrir cohorte par cohorte, une conversation professionnelle mêlée aux messages personnels, un rendez-vous encaissé à la main. Deux contraintes cadraient le reste, et elles n'ont pas bougé : les élèves entrent depuis leur téléphone, et l'hébergement est un mutualisé Hostinger, sans conteneur, pas de build sur le serveur, pas de node_modules dans le paquet.

02

la solution

Une application web privée, conçue et développée de zéro, où le contenu, la messagerie, l'agenda avec encaissement et les avis vivent au même endroit. L'élève y voit son cours, ses classes en direct, les enregistrements de ses séances individuelles, ses objectifs et son certificat ; l'équipe gère tout cela depuis le même portail, sans jamais toucher à la base de données. La décision structurelle est un contrat : un seul fichier OpenAPI décrit les 116 opérations de l'API, et Orval en génère deux paquets que personne n'édite à la main : les schémas Zod dont le serveur se sert pour valider l'entrée et façonner la sortie, et les hooks TanStack Query que consomme le front. L'API et l'écran ne peuvent plus diverger en silence : le typecheck tombe avant l'utilisateur. La deuxième règle, écrite dans le code, est que le portail appartient à la cliente : aucune intégration ne doit pouvoir le faire tomber. Google, l'assistant, le courrier, le push et le paiement éteignent leur fonction, l'écrivent à l'écran, et le reste continue. C'est vrai des sept.

03

le résultat

En production, avec de vraies élèves. Hotmart, WhatsApp et le tableur de l'agenda ont été retirés : le contenu propre s'ouvre cohorte par cohorte à date fixe ou « X jours après le début de ta promotion », la messagerie porte les pièces jointes derrière la session, et une séance avec un coach se réserve et se paie sans quitter la page. À ce jour : ~34 000 lignes de TypeScript, 37 tables, 116 opérations d'API, 123 handlers Express répartis sur 18 routeurs, 107 commits en quatre mois. Ce que j'en retiens tient surtout dans les pannes. Un hébergeur qui suspend la boîte mail pour « activité suspecte », sans intrus : c'était le portail lui-même, une annonce partant en trente envois simultanés. Une vérification de marque Google échouée deux fois parce que le robot téléchargeait une SPA vide derrière un écran de connexion. Un `drizzle-kit push --force` qui a proposé de vider la table des élèves. Chacune a laissé une règle écrite quelque part : dans le code, dans le package.json, dans le document de contrôle qu'on lit avant de toucher à quoi que ce soit.

dimension

le projet en chiffres

~34 000

lignes de TypeScript : 22 400 au front, 11 800 à l'API

37

tables, plus celle des sessions

116

opérations décrites dans le contrat OpenAPI

123

handlers Express, sur 18 routeurs

107

commits, de mai à septembre 2026

7

intégrations externes, toutes facultatives

le mandat

ce que le portail a remplacé

Hotmart

Le cours est maintenant dans le portail : modules, leçons, progression par élève, ressources et fils de commentaires sous chaque leçon. Et surtout l'ouverture programmée par promotion (à date fixe ou « X jours après le début de ta promotion »), qui n'existait pas avant.

WhatsApp

Conversations directes élève ↔ équipe et salles par promotion, avec photos, vidéos et liens. Le travail est sorti du téléphone personnel des professeures, et les échanges restent là où sont les élèves.

le tableur de l'agenda

Chaque coach ouvre ses créneaux, l'élève choisit son jour à l'heure de Colombie et paie par ePayco sans quitter la page. Le rendez-vous se retient trente minutes, puis se libère seul.

fonctionnalité

trois surfaces de travail

le portail de l'élève

« Mon portail »
  • Six sections dans un menu latéral, et la dernière ouverte est retenue : séances 1:1, classes en direct, contenu IAgency, certificats, objectifs, projets.
  • Les séances individuelles sont embarquées depuis Google Drive, avec leurs notes et leurs fichiers complémentaires.
  • Les objectifs se lisent à trois niveaux (les siens, ceux de sa promotion, ceux de toute l'académie) et l'étiquette dit à qui chacun s'adresse. L'élève coche, l'équipe valide.
  • Sous les projets, l'élève dépose le lien vers son travail (Drive, Figma, Canva, un site) avec ses pièces jointes, et l'équipe lui répond dans un fil.

le panneau de l'équipe

« Administration »
  • Dix sections : élèves, équipe, promotions, classes en direct, annonces, cours, commentaires, objectifs, assistant IA et sa propre fiche de coach.
  • La promotion est l'unité de tout : qui voit quel contenu, à qui s'adresse une classe, quelle promotion a droit à une séance gratuite avec tel coach.
  • Trois rôles. La professeure partage le panneau avec l'admin, sauf trois gestes réservés : inscrire ou supprimer une élève, créer ou supprimer un cours, publier une annonce. Le serveur l'exige ; le front se contente de ne pas montrer des boutons qui rendraient 403.
  • Boîte unifiée des commentaires, leçons et classes mêlées, en mode fil, pour répondre sans passer de section en section.

la messagerie

« Conversations »
  • Conversations directes et salles par promotion : les membres d'une salle se déduisent du groupe au lieu d'être matérialisés, pour qu'une arrivée ou un départ n'ait rien à rattraper.
  • Les pièces jointes sont vérifiées par leurs octets magiques contre le type MIME déclaré : un .png qui contient en réalité du HTML est effacé et refusé.
  • La ligne naît avec message_id à NULL et se réclame à l'envoi, donc le serveur ne fait jamais confiance à un chemin venu du client ; ce qui reste non réclamé est balayé au bout de 24 h.
  • Le rafraîchissement en direct se fait sans WebSockets : le service worker relaie le push à l'onglet ouvert et seules les clés de la messagerie sont invalidées.

fonctionnalité

les deux faces du portail

Le bandeau du haut est l'aperçu en lecture seule de l'équipe : un admin voit le portail exactement comme une élève précise le voit, sans pouvoir cocher ni rendre à sa place. Le même constructeur de tableau de bord sert les deux, pour ne pas écrire deux fois les règles de visibilité. Noms et courriels floutés.
L'encart « le courrier fonctionne », avec son bouton de test, est né de la suspension de la boîte mail : depuis, l'état de l'expéditeur se lit en haut du panneau et se vérifie sans ouvrir les journaux.

messagerie

ce qui a remplacé whatsapp

La liste des conversations : à gauche les échanges directs avec l'équipe, et la salle de chaque promotion. Les membres d'une salle se déduisent du groupe au lieu d'être matérialisés, donc une arrivée ou un départ n'a rien à rattraper. Noms floutés.
Les téléchargements passent par GET /api/uploads/:name derrière la session, avec le support des plages d'octets pour qu'une vidéo s'avance sans se télécharger en entier. Messages et noms floutés.

stack

ce qu'il y a dessous

Front

React 19.1.0 figé dans le catalogue · Vite 7 · Tailwind CSS 4 · ~28 primitives Radix sous enveloppe shadcn (52 composants) · Framer Motion · wouter pour 9 routes · TanStack Query sur hooks générés · PWA installable, manifeste et service worker

API

Express 5 sur Node.js 24, empaqueté par esbuild en un seul fichier avec sourcemaps · express-session avec store MySQL, cookie httpOnly et secure, 7 jours · Zod généré du contrat à l'entrée et à la sortie · pino · 18 routeurs montés sous /api

Données

MariaDB/MySQL avec Drizzle ORM sur mysql2, migré du Postgres d'origine en juillet 2026 · 37 tables en 12 fichiers par domaine · sans migrations : push en développement, DDL relu à la main en production · clés primaires en int().autoincrement(), jamais serial

Intégrations

Google OAuth, flux code écrit à la main sans dépendance ajoutée · Google Calendar et Meet · Groq (openai/gpt-oss-120b, endpoint compatible OpenAI) · ePayco (Davivienda) · SMTP Hostinger · Web Push VAPID · AES-256-GCM pour le refresh_token Google de chaque coach

Monorepo

pnpm workspaces, npm et yarn refusés par un hook preinstall · versions figées dans le bloc catalog: de pnpm-workspace.yaml · TypeScript 5.9 avec project references · minimumReleaseAge à 1 440 minutes : aucun paquet npm publié depuis moins de 24 h n'entre dans le projet

architecture

le contrat d'abord

La décision structurelle du projet : il n'y a pas un type côté serveur et un type côté client qu'on garde synchronisés à la main. Il y a un contrat, et deux paquets qui en descendent.

↕ orval : les deux dossiers generated/ ne se touchent jamais à la main

Contrat

lib/api-spec/openapi.yaml

OpenAPI 3.1 : 5 823 lignes, 116 opérations, 143 schémas. La seule source de ce que l'API accepte et de ce qu'elle renvoie.

Généré

lib/api-zod

Schémas Zod et types TypeScript. L'API fait Schema.safeParse(req.body) à l'entrée et Response.parse(...) à la sortie : le serveur ne peut pas renvoyer quelque chose que le contrat ne décrit pas.

lib/api-client-react

Hooks TanStack Query avec leur propre wrapper de fetch, base /api. Le front n'écrit plus un seul fetch à la main ; le cache et l'invalidation se font par clé.

Hors contrat

et délibérément

Les redirections OAuth, le téléchargement des pièces jointes, le PDF du certificat et la confirmation serveur-à-serveur d'ePayco. Ce n'est pas du JSON : cela n'a rien à faire dans le YAML, et l'y forcer aurait décrit le système de travers.

Changer l'API, c'est toujours le même geste : éditer le YAML, relancer le codegen, puis toucher la route Express et l'écran. Front et back ne peuvent pas diverger en silence : le typecheck le crie avant l'utilisateur.

écrans

le cours, du module à la leçon

Le cours complet, phase par phase. La progression se compte sur les leçons que cette élève-là voit : trois niveaux d'accès cumulatifs (cours, module, leçon) et aucune ligne signifie « visible par toutes ».
La leçon s'ouvre sur place au lieu d'emmener ailleurs : la vidéo, ses ressources et son fil de commentaires, sans perdre le fil du module.
Les classes en direct de sa promotion, toutes à l'heure de Colombie (COT, UTC−5) et dites comme telles. Après coup, la même ligne porte l'enregistrement et le matériel. Noms des coachs floutés.
L'étiquette dit à qui va chaque objectif : « Pour toi » est individuel, « IAgency » s'adresse à toutes. L'élève coche, l'équipe valide, et cette validation compte, parce que le certificat en dépend.

encaissement

prendre rendez-vous, et payer

Tant qu'un coach n'a pas publié sa fiche, il n'apparaît nulle part. La première séance gratuite se règle coach par coach, et se limite si besoin à certaines promotions.
Le calendrier est à l'heure de Colombie, le prix en pesos avec un basculement informatif vers le dollar (taux mis en cache 24 h). Sur cette capture le coach n'a pas encore ouvert de dates, et l'écran le dit au lieu de proposer un calendrier vide. Ce qui décide qu'une séance est payée n'est pas le navigateur : c'est l'avis serveur-à-serveur d'ePayco, et seulement si la signature SHA-256 tombe juste, si le montant est exactement celui de la réservation et si ePayco confirme la référence quand on la lui redemande.

assistant

une ia qui ne répond qu'avec les données de qui demande

Une vraie question et sa réponse. Le contexte est relu en base à chaque question (ses cours, les classes de sa promotion, ses séances, ses objectifs, ses certificats, les annonces récentes et ses réservations) avec les mêmes règles de visibilité que le portail, et jamais les données d'une autre élève. D'où les dates à l'heure de Colombie et le lien de sa réunion à elle. Noms et liens floutés.
Le modèle, le plafond quotidien par élève (30 par défaut, compté à l'heure de Colombie ; l'équipe n'en a pas) et le message d'accueil se règlent ici, sans toucher au code. L'admin nourrit aussi l'assistant de documents : le texte est extrait (PDF avec unpdf, DOCX avec mammoth), découpé en fragments d'environ mille caractères, et à chaque question un classement BM25 ne retient que les passages utiles, car le plan gratuit de Groq coupe vers 8 000 jetons par minute, donc tout envoyer n'était pas une option. Du fichier, seul le texte est conservé.

livraison

tenir sur un hébergement mutualisé

Pas de conteneur, pas de build sur le serveur, pas de node_modules dans le paquet : le panneau Hostinger n'installe que mysql2 et express-mysql-session. Tout le reste de la livraison est écrit pour vivre avec cette contrainte plutôt que contre elle.

  1. 01

    Un seul fichier, construit ici

    esbuild empaquette l'API en un unique .mjs avec sourcemaps. Le zip de livraison contient le serveur déjà compilé et le front statique, rien d'autre. Rien ne se construit sur la machine de production, donc rien ne peut y échouer à la construction.

  2. 02

    Le schéma avant le zip, toujours

    Les changements de schéma partent en SQL prêt à coller dans phpMyAdmin (deploy/schema-*.sql), et ils passent avant le code, jamais après. L'ordre inverse donne une API qui interroge des colonnes qui n'existent pas encore.

  3. 03

    L'index.html lu une seule fois au démarrage

    Hostinger déploie dans des dossiers versionnés et supprime le précédent. Avec sendFile, toutes les routes finissaient en 404 après une livraison : le chemin résolu au démarrage pointait vers un dossier qui n'existait plus. Le fichier est donc lu en mémoire une fois, à l'allumage.

  4. 04

    UPLOADS_DIR hors du dossier de déploiement

    Même cause, conséquence plus chère : un chemin relatif emportait les photos envoyées par les élèves à chaque livraison. La variable pointe désormais en dehors, et l'avertissement est écrit dans le document de contrôle.

  5. 05

    Chaque livraison porte son document, et son retour arrière

    deploy/INSTRUCCIONES.md et deploy/QUE-SUBIR.md disent ce qui monte, dans quel ordre, quelles variables sont nécessaires, ce qui change pour l'utilisatrice et ce qui a été vérifié en local. Le paquet précédent est conservé daté (portal-deploy-2026-09-11-anterior.zip) pour que revenir en arrière soit un envoi de fichier, pas une reconstruction.

ingénierie

cinq pannes réelles, et la règle qu'elles ont laissée

Elles sont arrivées en production, sur le vrai hébergement et avec de vraies élèves. C'est la partie du projet dont j'ai le plus appris, alors je la documente avec le symptôme tel qu'il s'est présenté.

l'hébergeur a suspendu la boîte mail

courrier
symptôme
Hostinger a suspendu le compte de messagerie pour « activité suspecte » et en a supprimé le contenu. Les avis par courrier sont tombés d'un coup.
cause
Il n'y avait aucun intrus : c'était le portail. Une annonce à une promotion partait en une trentaine d'envois simultanés, chaque message du chat déclenchait le sien, et une fois la boîte fermée le serveur continuait à réessayer, ce qui ressemblait encore davantage à un envoi en masse.
solution
lib/mail-policy.ts : un rythme soutenable par heure, un plafond quotidien global et par destinataire, une pause entre les avis bavards (chat et commentaires) vers la même personne, une liste des adresses qui rebondissent, et un coupe-circuit qui s'arrête net quand la faute vient de l'expéditeur. Tout en mémoire, à dessein : ce sont des mesures de débit, pas des données du portail.

google n'arrivait pas à vérifier le site

référencement
symptôme
La vérification de marque de Google a échoué deux fois de suite, sans que rien ne soit cassé côté portail.
cause
Le robot n'exécute pas la SPA. Il téléchargeait une page vide derrière un écran de connexion, et la vérification dépendait de ce qu'il pouvait lire.
solution
Une page publique en HTML pur (public/inicio/index.html), écrite sans une ligne de JavaScript, exprès. Elle explique ce qu'est le portail, ce qu'on y trouve et comment les données y sont traitées, lisible par le robot comme par quelqu'un qui n'a pas de compte.

push --force a proposé de vider la table des élèves

données
symptôme
Au cours d'une poussée de schéma, drizzle-kit a proposé de TRUNCATE la table students.
cause
Sur MySQL, drizzle-kit push --force accepte automatiquement le TRUNCATE des tables référencées par de nouvelles clés étrangères. Le drapeau qui sert à ne pas être interrompu est précisément celui qui dit oui à la question qu'il fallait lire.
solution
La commande est interdite, et bloquée en plus dans le package.json : elle sort avec le code 1. Le schéma se pousse en interactif en lisant chaque instruction proposée, ou la table se crée à la main en DDL qui correspond à Drizzle. L'interdiction est aussi l'une des lignes rouges du document de contrôle.

la table des sessions ne se créait plus, sans un mot

build
symptôme
Après empaquetage, les sessions ne tenaient plus. Aucune erreur, aucune trace : simplement personne ne restait connecté.
cause
express-mysql-session lit son schema.sql depuis son propre dossier de paquet, à l'exécution. Empaqueté par esbuild, le fichier n'était plus là où la bibliothèque le cherchait, et la création de la table échouait en silence.
solution
Le paquet reste dans la liste external d'esbuild, et la raison est écrite à côté, sans quoi la prochaine personne qui optimise le bundle le réempaquette de bonne foi.

le champ date natif ne rendait rien sur le téléphone de la cliente

mobile
symptôme
Sur son téléphone, choisir une date ou une heure semblait fonctionner, mais le formulaire partait avec le champ vide.
cause
L'input natif de date et d'heure ne remontait pas sa valeur sur cet appareil-là. Sur les nôtres, tout allait bien, et c'est exactement ce qui rend ce genre de panne long à voir.
solution
Des sélecteurs maison (date-field.tsx, time-field.tsx) à la place des inputs natifs. Depuis, chaque écran se vérifie à 375 px avant d'être considéré comme terminé : les élèves entrent par le téléphone, donc c'est là que la page doit être juste en premier.

écrans

la porte d'entrée, et le téléphone

Google en voie principale, le code en secours pour qui n'a pas de compte. La table des élèves fait office de liste blanche : un courriel qui n'y est pas n'entre pas, même avec un Google valide.
La page en HTML pur née des deux vérifications ratées. Elle sert aussi de page de confiance : le bloc vert explique ce que le portail fait du Google Calendar d'un coach : il lit les heures déjà prises, jamais le contenu, le titre ni les invités.
Inscription, promotion, régénération du code d'accès et aperçu de son portail. Il n'y a pas d'inscription ouverte : quelqu'un de l'équipe crée le compte, et c'est la seule porte. Noms et courriels floutés.
Les sections s'empilent et se numérotent au lieu de se serrer dans un menu latéral ; l'horaire garde la date, l'heure de Colombie et le bouton pour entrer ; la liste des conversations prend toute la largeur. Sur iPhone, ajouter le portail à l'écran d'accueil est le seul chemin pour recevoir les notifications push, et c'est documenté comme tel pour les élèves.

critère

ce que j'en retiens

une intégration qui tombe ne peut pas faire tomber le portail

Les sept dégradent sans casser : Google rend 503 et il reste le code d'accès, l'assistant dit qu'il est éteint, ePayco laisse réserver et retient la date en l'expliquant, le courrier laisse la cloche et le push. Le portail appartient à la cliente : une clé expirée ne peut pas mettre quelqu'un dehors.

le navigateur ne décide pas ce qui a été payé

Une séance n'est payée que quand ePayco le confirme de serveur à serveur, avec la bonne signature, le montant exact et une contre-question à son API. C'est la même règle que pour les pièces jointes : le serveur ne fait jamais confiance à ce que le client lui raconte de son propre état.

le certificat exige les deux, et pas l'un ou l'autre

Toutes les leçons que cette élève-là voit, terminées, et tous ses objectifs validés par l'équipe ; cocher soi-même ne suffit pas, parce que le document affirme que quelqu'un a vérifié. Le code de vérification s'écrit dans un alphabet sans 0/O ni 1/I : il se lit à voix haute.

le document de contrôle décrit la cicatrice, pas le système

Le projet a un document de contrôle qu'on lit avant de toucher à quoi que ce soit, et il ne décrit pas le système : il porte les lignes rouges, et chacune existe parce que quelque chose a coûté cher une fois. « Ceci a déjà tenté de vider students » se respecte ; « ne pas utiliser --force » se contourne en se persuadant que son cas est différent.

Et une honnêteté que le projet impose : le logiciel est propriétaire et le dépôt privé. Les captures viennent de la production du 17 septembre 2026, avec les noms, les courriels, les codes d'accès et les messages des élèves floutés.

Contrat OpenAPI comme source de vérité Assistant IA à contexte propre Messagerie avec pièces jointes Paiement en ligne Certificats PDF vérifiables Installable (PWA)
technologies

React 19·TypeScript·Vite·Tailwind CSS 4·TanStack Query·Express 5·Node.js 24·MySQL/MariaDB·Drizzle ORM·OpenAPI + Orval·Zod·pnpm workspaces·esbuild·Groq / LLM·ePayco·Google OAuth + Calendar·Web Push·PWA

un projet
similaire ?

parlons-en