ArchitectureNiveaux communaute (XP)

Niveaux communaute (XP)

Surface publique : /community/levels et une page par barreau, /community/levels/[level].

Surface publique : /community/levels et une page par barreau, /community/levels/[level].

Ce que le systeme est

Un journal en ajout seul, et rien d'autre. XpEvent porte une ligne par credit (userId, source, refId, amount), UserXp porte le total cache et le niveau calcule dans la MEME transaction que le credit. Le niveau n'est jamais ecrit a la main : il se derive du total par LEVEL_THRESHOLDS.

L'idempotence est structurelle, pas defensive : l'unique (userId, source, refId) fait qu'un webhook rejoue ou une reponse resoumise ne credite pas deux fois. C'est ce qui autorise awardXp a etre appele depuis le handler qui fait deja l'ecriture metier (creation de fil, bascule de RSVP, webhook Stripe) sans etage de deduplication.

SourceXPAppelant
forum_thread20POST /api/community/forum/threads
forum_reply5POST /api/community/forum/threads/[slug]/posts
forum_answer_accepted15POST /api/community/forum/threads/[slug]/answer
event_rsvp10POST /api/community/events/[id]/rsvp
marketplace_order50services/webhooks.ts (Stripe, commande payee)
manual0outil operateur — vaut zero, donc n'est pas un chemin d'entree

Les montants ne sont ecrits qu'a un endroit, XP_AMOUNTS dans src/services/community/xp.ts. Les pages publiques les LISENT. Avant growth-web/0641 la page liste portait sa propre copie des cinq nombres, a cote de sa propre copie des huit paliers et de sa propre union 1 | 2 | … | 8 : trois redites d'une table qui vit dans le service, aucune gardee.

Ce que le systeme n'est PAS

Aucun niveau ne conditionne quoi que ce soit dans le produit. Rien dans ce depot ne lit UserXp.level pour ouvrir une porte. Un niveau est un signal de reputation public : un rang, un badge, et le journal qui les justifie.

C'est la raison d'etre de src/services/community/levels.ts. La page annoncait publiquement cinq avantages — support forum prioritaire, creneaux d'office hours en tete-a-tete, le Slack des fondateurs, une invitation a l'offsite annuel, l'acces anticipe a chaque nouveaute — dont zero existait en code. Les chaines vivaient dans messages/*.json sans rien a quoi les comparer, donc la derive etait invisible pour toutes les verifications du projet.

Un avantage est desormais un enregistrement type :

{ key: "public_rank", status: "live",
  gate: { file: "src/services/community/xp.ts", symbol: "leaderboard" } }
{ key: "early_access", status: "planned" }

live doit nommer le fichier et le symbole qui l'implementent. levels.test.ts les resout a chaque execution : supprimez ou renommez l'implementation, la promesse publique casse la suite au lieu de rester sur la page. planned ne peut pas porter de gate, donc « prevu » ne peut pas se deguiser en « livre ».

Les quatre avantages retires plutot que passes en planned (Slack des fondateurs, offsite annuel, office hours 1:1, support prioritaire) sont des engagements humains qu'aucun code ne satisfera jamais : les livrer n'aurait donc jamais fait basculer un statut ici. Les dix restants sont implementables dans CE depot — une feuille de route qu'un lecteur peut nous opposer, pas une aspiration.

Ce que les gardes tiennent

levels.test.ts refuse :

  1. un avantage live dont le fichier ou le symbole n'existe plus ;
  2. un avantage planned qui porte un gate ;
  3. des paliers non contigus, non croissants, ou en desaccord avec levelOf a n'importe quelle borne ;
  4. un palier sans avantage, ou deux avantages de meme cle ;
  5. EARNABLE_SOURCES en desaccord avec XP_AMOUNTS ;
  6. une cle de palier, d'avantage ou de source absente de l'une des six locales — et l'inverse, une cle traduite que le catalogue ne declare plus ;
  7. un LEVEL_THRESHOLDS[].label (anglais, cote ledger) different du tiers.<n>.title anglais (cote page) : deux noms pour un niveau est la maniere dont un membre finit par lire « Builder » sur la page et « Veteran » dans l'export de la meme ligne.

Le piege de typage, et pourquoi il vaut un commentaire

LEVEL_THRESHOLDS n'est pas annote d'un ReadonlyArray<{ level: number … }>. L'annotation elargissait level en number, et next-intl type ses cles de message : chaque t(`tiers.${level}.title`) devenait alors une erreur de type, ce qui poussait la page a re-declarer sa propre union 1 | 2 | … | 8 a cote de la table. C'est exactement la duplication que ce module supprime. Le as const nu garde les litteraux, LevelNumber s'en derive, et la page ne re-declare plus rien.

Consequence pratique : un neuvieme palier ajoute au ledger ne peut plus etre rendu par une page qui l'ignore. Avant, il ne s'affichait nulle part et faisait lever tiers.9 sur le classement pour le premier membre a l'atteindre.

Confidentialite

User.leaderboardOptOut retire le nom et l'avatar du classement public. Le membre garde son XP et son rang ; la ligne se rend en « membre prive » avec le niveau seul. Le reglage vit dans /account/settings/privacy et s'ecrit par PATCH /api/account/privacy.

Cout de lecture

/community/levels est force-dynamic (comme toute page de ce site, cf. la section « Aucune de ces pages n'est en ISR » de CLAUDE.md) et fait deux requetes par affichage : leaderboard(50) et, pour un visiteur connecte, un COUNT sur UserXp pour le rang. Les huit pages detail n'en font aucune : tout y est derive du catalogue. Si le classement devient couteux, c'est lui qu'il faut cacher, pas la page.


Le reste de la section communaute

La meme discipline s'applique aux deux autres surfaces ou /community publiait des engagements operationnels : src/services/community/commitments.ts.

La regle

Une recurrence ne peut etre publiee que si quelque chose dans le depot peut la produire.

/community/events affichait un calendrier complet — « 60 minutes chaque mercredi avec le fondateur et au moins un ingenieur senior », « premier vendredi du mois », « quatre sessions d'octobre a novembre », un build-along mensuel — alors qu'aucun chemin de code de ce depot n'ecrit jamais une ligne Event. La page ne pouvait rendre que son etat vide, sous un calendrier que personne ne pouvait tenir. Trois affirmations sont parties avec : « chaque session enregistree et transcrite », un #events-archive de replays cherchables, et « un email par semaine » sur un CTA qui ouvre le formulaire d'inscription.

Une cadence porte donc un producer : le fichier et le symbole qui la programment. Sans lui, ce n'est pas un rythme, c'est un format, et la page le rend comme tel (pastille neutre « annonce une fois programme »).

La regle est tenue deux fois, ce qui est le point interessant :

  1. commitments.test.ts refuse une cle cadence.<slug>.label pour une cadence sans producer. La verification porte sur la PRESENCE d'une cle, pas sur des mots, donc elle vaut dans les six langues sans liste de vocabulaire ;
  2. la cle ayant disparu des catalogues, next-intl type t(`cadence. $&#123;slug}.label`) comme une erreur. Le compilateur refuse la promesse avant que le test n'ait a la voir.

Qui livre l'ordonnanceur ajoute le producer, la cle et la branche de rendu ensemble ; le test demande les trois.

Les canaux

Un canal est internal (une route servie par cette app, que le garde resout sur le disque) ou external (une destination hors depot). Rien ici ne peut attester de ce qui se passe dans un canal externe, donc sa copy decrit ce que le canal est, jamais ce qui s'y produit a heure fixe.

Trois affirmations retirees de /community/forum :

  • le canal GitHub Discussions pointait sur github.com/BoostEcom/discussions. Ce depot n'existe pas, et tous les depots de l'organisation sont prives : il n'y avait donc aucune surface Discussions publique vers laquelle le repointer. Le canal est supprime plutot que relie ailleurs ;
  • « Founders + senior team join every Wednesday for office hours » sur le blurb Discord, et un appel hebdomadaire de 60 minutes sur celui des office hours : meme cause que la cadence ci-dessus ;
  • « Routed to the team lead for that area within one business day » sur le retour produit. Il n'y a ni routage, ni SLA, ni chefs d'equipe.

Et sur le hub : « Unlock perks: free credits, beta features, swag » contredisait directement la page niveaux corrigee plus haut, « Weekly · video + transcripts » promettait un rythme et un pipeline video inexistants, « Calendar-synced reminders » une synchronisation d'agenda qui n'existe pas. Sur le forum, « Code blocks render with syntax highlighting (Liquid, JS, TS, GraphQL, shell) » etait contredit par le commentaire du renderer lui-meme (prose-markdown.tsx : « no syntax highlight »).

Ce qui n'a PAS ete touche, et pourquoi

  • Le lien Discord reste. Il ne peut pas etre verifie depuis un clone, et c'est le serveur du proprietaire : le supprimer sur une hypothese serait le symetrique du defaut qu'on corrige.
  • /careers publie sept postes ouverts, une politique de remuneration et une semaine de quatre jours. C'est une decision business du proprietaire, pas un fait de code : un agent n'a pas a la trancher. Suivi dans backlog/growth-web/0642.