Le forum communautaire
Surface publique : /community/forum et /community/forum/[slug]. Pilier : growth-web (pages, copy, SEO) — le service et les handlers vivent sous src/services/community/ et src/app/api/community/forum/.
Surface publique :
/community/forumet/community/forum/[slug]. Pilier :growth-web(pages, copy, SEO) — le service et les handlers vivent soussrc/services/community/etsrc/app/api/community/forum/.
Ce document existe parce que le forum n'en avait aucun, et que l'absence
s'est payee : pendant des mois la page publique a promis, en six
langues, une coloration syntaxique que le rendu markdown n'a jamais
faite, et llms.txt a vendu aux crawlers IA une « AI-assisted
moderation » que le pipeline de signalement refuse par conception. Rien
dans le code ne reliait les deux : la copy vit dans du JSON, le
comportement dans du TSX.
Le modele de donnees
Quatre modeles, tous dans prisma/schema.prisma :
| Modele | Role |
|---|---|
ForumThread | Le fil. Porte slug (unique, public), tags[], les drapeaux de moderation pinned / locked, les compteurs caches postsCount / lastPostAt, et answeredPostId |
ForumPost | Une reponse. onDelete: Cascade depuis le fil |
ForumReaction | Un « like » polymorphe sur thread OU post. L'unicite (targetType, targetId, userId, type) est le toggle |
ContentReport | Un signalement. Jamais auto-masquant — cf. « Moderation » ci-dessous |
Deux choix meritent d'etre lus avant de toucher au schema :
answeredPostIdvit sur le FIL, pas un booleen sur le post. « Ce fil est-il resolu » devient une lecture d'UNE colonne sur la ligne du fil, sans scan de ses posts, et « au plus une reponse acceptee par fil » tombe de la forme du schema au lieu d'etre un invariant a tenir. C'est aussi ce qui rend le filtreunansweredde la liste gratuit.ForumReactionest polymorphe (targetType+targetId) plutot que deux FK nullables : rendre un troisieme objet reactable coute une valeur detargetType, pas une colonne.
Le contrat de lecture
Tout ce que /community/forum affiche sort d'UN appel a listThreads()
(src/services/community/forum.ts) :
| Argument | Effet |
|---|---|
q | contains insensible a la casse sur title + body |
tag | tags has <tag> |
status | all / resolved (answeredPostId != null) / unanswered |
sort | activity (defaut) / newest / replies — pinned gagne toujours : un fil epingle est une decision de moderation, pas un signal de classement |
page, pageSize | Pagination, pageSize plafonne a 50 |
Retour : { items, total, page, pageSize }. Chaque item porte un
excerpt (markdown aplati, ~200 caracteres), le likes du fil, et le
level communautaire de l'auteur.
listThreadTags() derive les facettes de tags des fils eux-memes, sur un
scan borne des N plus recents. Il n'y a pas de table de tags : en
creer une serait une seconde source de verite pour une valeur qui vit
deja sur ForumThread.tags.
Ce que ce contrat repare
Le service exposait tag, q et page depuis toujours. La page, elle,
appelait listThreads({ pageSize: 12 }) — sans filtre, sans recherche,
sans pagination. Le treizieme fil etait donc inatteignable depuis
l'interface pendant que GET /api/community/forum/threads les servait
tous. La page et le handler lisent maintenant les memes filtres.
Le niveau d'auteur, et le respect de l'opt-out
ThreadListItem.author.level vaut null quand l'auteur n'a pas encore
de ligne UserXp ou quand il a coche leaderboardOptOut.
L'opt-out a ete ecrit pour le classement de /community/levels. Un badge
« Niveau 6 » a cote de chaque message est un classement public plus
faible, mais c'est un classement public quand meme, et l'opt-out est le
seul signal que l'utilisateur nous ait jamais donne a ce sujet. On
l'honore ici aussi.
Ecriture, limites et XP
Les constantes publiques vivent dans
src/services/community/forum-policy.ts.
C'est le point qui compte : la page imprime ces nombres comme regles de
la maison, donc elle les LIT depuis le code qui les applique. Un nombre
retape dans du JSON de traduction est une seconde source de verite qui
pourrit des que quelqu'un ajuste une limite.
| Regle | Valeur | Applique par |
|---|---|---|
| Nouveaux fils | 5 / 24 h / compte | POST /api/community/forum/threads |
| Reponses | 30 / 24 h / compte | POST .../[slug]/posts |
| Changements de reponse acceptee | 30 / 24 h | POST + DELETE .../[slug]/answer |
| Titre | 5 a 200 caracteres | zod, meme handler |
| Corps d'un fil | 20 a 20 000 | zod |
| Corps d'une reponse | 2 a 20 000 | zod |
| Tags | 8 maximum | zod |
XP (src/services/community/xp.ts, idempotent sur
(userId, source, refId)) : 20 par fil, 5 par reponse, 15
quand votre reponse est marquee comme la bonne. Ce dernier credite
l'auteur de la reponse, pas celui qui la marque.
src/test/community-forum-claims.test.ts refuse que les handlers, le
registre XP et la copy divergent.
Reponse acceptee
Seuls l'auteur du fil et un admin peuvent marquer ou demarquer
(assertCanCurateAnswer). Le service ne connait pas les roles : il
connait le proprietaire du fil, et l'appelant lui passe actorIsAdmin.
Marquer est idempotent. Demarquer ne reprend pas l'XP deja versee — meme posture de registre append-only que partout ailleurs.
Une fois le fil resolu, et seulement alors, la page detail emet un
QAPage JSON-LD avec acceptedAnswer. Un fil non resolu est une
discussion, pas une question a reponse canonique ; emettre un QAPage
sans reponse acceptee decrirait une forme que la donnee n'a pas.
Moderation
Aucune IA n'intervient. Rien n'est masque automatiquement. Un
ContentReport leve une file, un admin tranche. C'est une decision
produit, inscrite dans le commentaire du modele, et c'est exactement
ce que llms.txt a contredit en public en annoncant une « AI-assisted
moderation ».
L'identite du signaleur ne sort jamais des surfaces admin : toute lecture
publique d'un report laisse tomber reporterId, seul
/admin/content/reports le selectionne.
pinned et locked sont admin-only. Un fil locked refuse les reponses
avec un 423, cote handler.
Rendu des messages
ProseMarkdown (src/components/shared/prose-markdown.tsx) :
react-markdown + remark-gfm, composant serveur, zero JS client.
- Tableaux, listes de taches, barre, liens automatiques : oui.
- HTML brut : retire.
- Liens externes :
target="_blank"+rel="noopener noreferrer nofollow". - Coloration syntaxique : non. Les blocs de code rendent en
<pre><code>nu. C'est un choix de poids de bundle, pas un oubli — et c'est la phrase que la page publique contredisait.
Canaux annexes
La page liste trois cartes, et une seule regle : une carte = un canal qu'on peut reellement ouvrir.
| Canal | Etat |
|---|---|
| Discord | Vivant. L'invite est PLATFORM.urls.discord, une seule constante — elle etait retapee a l'identique dans cinq endroits sur deux pages |
| Office hours | Vivant, renvoie vers /community/events |
| Product feedback | Vivant, renvoie vers /feedback |
Retire le 2026-09-12. La carte pointait vers github.com/BoostEcom/discussions — la forme d'un DEPOT nomme « discussions », pas de l'onglet Discussions d'une organisation — pour une surface que le proprietaire a confirme inexistante. Une carte qui annonce un canal que personne ne peut rejoindre est pire qu'une carte de moins |
SEO
- Les fils sont dans
sitemap.xml(src/app/sitemap.ts), pas seulement l'index. QAPageuniquement sur un fil resolu,Article+BreadcrumbListsur tous.- La page liste est
force-dynamic, comme toute l'application : le layout racine lit le nonce CSP et la locale, donc l'ISR est desactive quoi qu'en dise unexport const revalidate(cf.backlog/app-shell/0361).
Le garde
src/test/community-forum-claims.test.ts
relie ce que la page promet a ce que le code fait :
- la copy ne vend pas de coloration syntaxique tant que
ProseMarkdownn'en a pas (et elle a le droit d'en nier une) ; llms.txtne parle ni de moderation IA, ni d'enregistrements d'evenements (Eventn'a aucune colonne pour ca), ni de niveaux absents de l'echelle ;- aucune carte ne pointe vers GitHub Discussions ;
- l'invite Discord n'existe qu'une fois, dans
config/platform.ts; - les limites et l'XP imprimes viennent de
forum-policy.ts, et les six locales les ecrivent en placeholders ICU, jamais en litteraux ; - aucune date relative ni pluralisation anglaise ecrite a la main ne survit dans les deux pages ;
- la liste cable bien les filtres du service et rend une pagination.
Ajouter une promesse publique au forum = ajouter une assertion ici.