ENFRKOantony langlois
projets

LeetClimb

site webappli mobileNestJSReact NativeReactPostgreSQL

Plateforme pour salle d'escalade · une API NestJS + Prisma qui alimente une app mobile React Native pour grimpeurs, une console web d'admin et des écrans TV toujours actifs · XP gamifié, classements en direct, bilingue EN/KO

Le problème à résoudre

Les grimpeurs en salle de bloc aiment voir les vidéos de solution d'un bloc afin d'apprendre les bonnes techniques et d'atteindre le sommet de la façon prévue. Mais j'ai constaté qu'ils perdaient souvent beaucoup de temps à chercher la vidéo pertinente sur Instagram, car elle était dispersée entre plusieurs comptes, chacun avec plusieurs posts contenant plusieurs vidéos, ce qui rendait tout le processus très chronophage et pénible pour l'utilisateur.

De plus, autre chose que j'ai remarquée : les gens adorent se filmer et publier les vidéos sur un compte Instagram dédié uniquement à l'escalade tout en tenant un compteur du nombre de blocs réalisés et des niveaux réussis dans la bio de leur compte ou dans les légendes de leurs posts. Ainsi que la création de crews et de communautés entre grimpeurs.

La dernière chose que j'ai notée était l'organisation des événements et compétitions d'escalade, très brouillonne et désorganisée, avec les scores écrits sur des papiers et basés sur la confiance envers les participants, ce qui pouvait mener à des inexactitudes dans les scores finaux et à une confusion générale.

Mon idée

Mon idée centrale était de rendre tout le processus de recherche de la vidéo de solution pertinente rapide et ultra simple. Ainsi que de proposer une alternative pour les personnes qui grimpent sans leur téléphone, afin qu'elles puissent voir les solutions vidéo.

Mon deuxième concept était aussi de créer un environnement où les grimpeurs se sentent inclus dans une communauté et engagés dans leur propre parcours d'escalade, en offrant un sentiment de progression et de récompense pendant l'escalade.

Et la dernière pièce du puzzle était de fournir un vrai dashboard d'admin et un système de création d'événements, afin de rendre la création d'événements et les inscriptions un jeu d'enfant.

Architecture

Expo · React Native
Frontend · app mobile
Climber App
apps/mobile · expo router
Offline-first : cache TanStack Query + une file d'upload Zustand persistée qui survit aux crashs et se draine à la reconnexion
React · Vite
Frontend · web admin
Admin Dashboard
apps/web · react router
Ouverture de voies sur photos de murs, modération de soumissions en direct, analytics de salle, gestion d'événements
public web
Frontend · écrans
TV + Kiosk
apps/web · /display /kiosk
Classement en direct + flux de réussites, animations FLIP, se récupère seul après la mise en veille de l'écran
SaaS
Auth · SaaS
Clerk
Sessions JWT, OAuth + OTP email · les webhooks synchronisent utilisateurs et avatars
Node · Railway
Core · API REST + SSE
NestJS API
apps/api · 19 modules
Le guard Clerk global auto-provisionne les utilisateurs · les événements d'approbation pilotent un recalcul complet de l'XP depuis la source de vérité
modération de soumissionsXP · niveaux · succèsclassements · SQL + cachechallenges hebdomadairesévénements de compétitionfan-out SSE
Prisma · Railway
Base de données
PostgreSQL
apps/api/prisma
Soft-deletes partout · pagination par curseur · SQL brut pour les classements par période
S3 API
Stockage · CDN
Cloudflare R2
Photos de murs sur CDN public · vidéos de preuve derrière des URLs pré-signées, supprimées après modération
SaaS
Push · SaaS
Expo Push
6 types de notifications événementielles, filtrées par les préférences de chaque utilisateur

Un grimpeur enregistre une réussite dans la Climber App : la vidéo de preuve est mise en file d'attente sur l'appareil, puis uploadée sur POST /submissions avec un JWT Clerk dès que le WiFi de la salle coopère. L'API NestJS vérifie le token, stocke la ligne dans PostgreSQL et la vidéo dans Cloudflare R2, et pousse submission.new via SSE vers l'Admin Dashboard, où un modérateur regarde la vidéo et l'approuve avec une transition d'état atomique. L'approbation déclenche des événements de domaine : l'XP et les succès sont recalculés, Expo Push notifie le grimpeur, et chaque écran TV + Kiosk de la salle met à jour son classement et son flux de réussites en direct.

Déploiement : API → Railway · Docker, migrations Prisma au démarrage · Web + TV → Vercel · Mobile → builds Expo EAS · Base de données → Railway PostgreSQL · Média → Cloudflare R2 + CDN · CI → GitHub Actions

Résultats

  • 3 clients · mobile, admin web, TV · un seul contrat d'API
  • 15 s · le heartbeat SSE garde les TV de la salle en direct toute la journée
  • EN / KO · bilingue de bout en bout, de la base de données à l'UI
  • offline-first · les réussites sont mises en file sur l'appareil, survivent aux fermetures d'app

Compétences démontrées : recalcul événementiel plutôt que compteurs incrémentaux · transitions d'état sûres face aux TOCTOU via des updates conditionnels · fan-out SSE dans les limites de connexions du navigateur · file d'attente offline avec drain conditionné par l'hydratation · cascades de soft-delete avec invalidation de cache · idempotence des webhooks (conflits d'upsert P2002)

Problèmes et solutions

Chaque choix répond à la même contrainte : un vrai sol de salle avec un WiFi capricieux, des TV partagées et des admins à temps partiel · une API, trois clients.

█ L'XP est dérivé, jamais incrémenté

Problème : Approbations, rejets, réinitialisations de voies et bonus de challenges modifient tous l'XP d'un grimpeur. Incrémenter un compteur à chaque événement dérive dès qu'un événement est manqué, rejoué ou annulé, et un classement visiblement faux tue la confiance dans tout le jeu.

La source de vérité est l'ensemble des soumissions approuvées plus les complétions de challenges. Chaque événement de domaine déclenche un recalcul complet de la progression de cet utilisateur ; la ligne UserProgress n'est qu'un cache pour les lectures de classement.

  • Compteurs incrémentaux rejetés : le recalcul rend chaque mutation idempotente et auto-réparatrice
  • Coût de requête supplémentaire par événement d'approbation accepté, négligeable à l'échelle d'une salle
  • Backfills gratuits obtenus : un seul script re-dérive toute la progression après tout changement de règle

▚ Uploads offline-first pour les zones mortes des salles

Problème : Les grimpeurs enregistrent des vidéos de preuve au fond de bâtiments en béton sans signal. Un upload échoué après une réussite durement gagnée signifie une preuve perdue et un utilisateur en colère, et les OS mobiles tuent les apps en arrière-plan en plein upload.

Les vidéos sont copiées dans le répertoire documents de l'app et suivies dans une file Zustand persistée. Un hook de drain attend l'hydratation du store et l'auth Clerk, réessaie 3 fois, et applique un debounce de 3 secondes pour ne jamais entrer en course avec un upload au premier plan.

  • Fail-and-toast rejeté : la charge des réessais retombe sur le code, pas sur l'utilisateur
  • Les uploads interrompus repassent à l'état en file lors de la réhydratation, donc un crash en plein upload est récupérable
  • Complexité de l'état de file acceptée (barrières d'hydratation, dédup par UUID) comme prix de zéro réussite perdue

▤ SSE plutôt que WebSockets pour tout le temps réel

Problème : Les TV de la salle, les kiosks et l'écran de modération admin ont tous besoin de mises à jour en temps réel, mais les navigateurs plafonnent HTTP/1.1 à 6 connexions par hôte, et l'infra WebSocket implique des sessions collantes et un état de reconnexion sur un seul petit conteneur.

Des Server-Sent Events unidirectionnels avec un Subject RxJS par salle, un heartbeat de 15 secondes, et un budget de connexions explicite par page pour qu'aucune fonctionnalité ne parte sans avoir pris en compte son flux.

  • WebSockets rejetés : aucun message client-vers-serveur n'a jamais été nécessaire, donc une infra bidirectionnelle était un pur coût
  • Polling rejeté : plus de 10 TV interrogeant les classements marteleraient les mêmes requêtes toute la journée
  • Budget de connexions accepté comme contrainte de conception ferme plutôt que comme surprise en production

▓ Soft-delete parce que les salles réinitialisent les murs

Problème : Les vraies salles démontent et remontent leurs murs toutes les quelques semaines. Supprimer une voie en dur cascaderait à travers les soumissions, l'XP et les succès, effaçant l'historique auquel les grimpeurs tiennent le plus.

Des flags isActive sur les lieux, murs, photos et voies avec des règles de cascade explicites (désactiver une voie fait expirer ses challenges ; réactiver un mur ne ressuscite pas ses voies). Les voies archivées s'affichent toujours dans les profils avec leurs marqueurs de prise d'origine.

  • Hard delete + snapshots d'historique rejetés : deux copies des données de voie divergent inévitablement
  • Chaque requête publique filtre sur isActive, une taxe acceptée sur les chemins de lecture
  • Historique d'escalade permanent et réinitialisations de mur sûres obtenus, avec zéro perte de données

▒ Clerk avec auto-provisionnement, pas d'auth maison

Problème : Trois clients ont besoin de Google OAuth, d'OTP par email, de mots de passe, de synchro d'avatar et de suppression de compte. Coder tout cela à la main représente des semaines de travail et une responsabilité de sécurité permanente pour une plateforme maintenue en solo.

Un guard NestJS global vérifie le JWT Clerk et fait un upsert de la ligne utilisateur locale à la première apparition, pour que la base n'ait jamais à être pré-remplie. Les webhooks gardent l'email et l'avatar synchronisés ; la suppression cascade d'abord les données locales, puis le compte Clerk.

  • Passport + table utilisateur maison rejetés : les bugs d'auth sont la seule catégorie qui ne vaut pas la peine d'être possédée
  • Vendor lock-in et cas limites des webhooks acceptés, les conflits d'upsert sont gérés explicitement
  • L'ordre de suppression (BDD d'abord, Clerk ensuite) garantit aucune donnée personnelle orpheline

▞ Bilingue en renvoyant les deux langues

Problème : Une salle coréenne avec des grimpeurs anglophones : le même nom de mur doit s'afficher en coréen sur la TV, dans la langue du spectateur sur mobile, et les deux doivent se mettre à jour instantanément quand un admin le renomme.

Des colonnes nameKo optionnelles à côté de chaque nom, et l'API renvoie toujours les deux. Les clients choisissent avec un helper partagé getLocalizedName() ; les TV affichent directement les payloads bilingues.

  • Négociation Accept-Language rejetée : elle scinde le cache par langue et casse les écrans TV bilingues
  • Payloads légèrement plus lourds acceptés pour une seule forme de réponse cachable
  • Un nom coréen null retombe sur l'anglais, donc la traduction peut être en retard sur le contenu