Niveaux communaute (XP)
Surface publique : /community/levels et une page par barreau, /community/levels/[level].
Surface publique :
/community/levelset 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.
| Source | XP | Appelant |
|---|---|---|
forum_thread | 20 | POST /api/community/forum/threads |
forum_reply | 5 | POST /api/community/forum/threads/[slug]/posts |
forum_answer_accepted | 15 | POST /api/community/forum/threads/[slug]/answer |
event_rsvp | 10 | POST /api/community/events/[id]/rsvp |
marketplace_order | 50 | services/webhooks.ts (Stripe, commande payee) |
manual | 0 | outil 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 :
- un avantage
livedont le fichier ou le symbole n'existe plus ; - un avantage
plannedqui porte ungate; - des paliers non contigus, non croissants, ou en desaccord avec
levelOfa n'importe quelle borne ; - un palier sans avantage, ou deux avantages de meme cle ;
EARNABLE_SOURCESen desaccord avecXP_AMOUNTS;- 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 ;
- un
LEVEL_THRESHOLDS[].label(anglais, cote ledger) different dutiers.<n>.titleanglais (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 :
commitments.test.tsrefuse une clecadence.<slug>.labelpour une cadence sansproducer. La verification porte sur la PRESENCE d'une cle, pas sur des mots, donc elle vaut dans les six langues sans liste de vocabulaire ;- la cle ayant disparu des catalogues, next-intl type
t(`cadence. ${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.
/careerspublie 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 dansbacklog/growth-web/0642.