ArchitectureLe forum communautaire

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/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/.

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 :

ModeleRole
ForumThreadLe fil. Porte slug (unique, public), tags[], les drapeaux de moderation pinned / locked, les compteurs caches postsCount / lastPostAt, et answeredPostId
ForumPostUne reponse. onDelete: Cascade depuis le fil
ForumReactionUn « like » polymorphe sur thread OU post. L'unicite (targetType, targetId, userId, type) est le toggle
ContentReportUn signalement. Jamais auto-masquant — cf. « Moderation » ci-dessous

Deux choix meritent d'etre lus avant de toucher au schema :

  • answeredPostId vit 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 filtre unanswered de la liste gratuit.
  • ForumReaction est polymorphe (targetType + targetId) plutot que deux FK nullables : rendre un troisieme objet reactable coute une valeur de targetType, pas une colonne.

Le contrat de lecture

Tout ce que /community/forum affiche sort d'UN appel a listThreads() (src/services/community/forum.ts) :

ArgumentEffet
qcontains insensible a la casse sur title + body
tagtags has <tag>
statusall / resolved (answeredPostId != null) / unanswered
sortactivity (defaut) / newest / replies — pinned gagne toujours : un fil epingle est une decision de moderation, pas un signal de classement
page, pageSizePagination, 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.

RegleValeurApplique par
Nouveaux fils5 / 24 h / comptePOST /api/community/forum/threads
Reponses30 / 24 h / comptePOST .../[slug]/posts
Changements de reponse acceptee30 / 24 hPOST + DELETE .../[slug]/answer
Titre5 a 200 caractereszod, meme handler
Corps d'un fil20 a 20 000zod
Corps d'une reponse2 a 20 000zod
Tags8 maximumzod

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.

CanalEtat
DiscordVivant. L'invite est PLATFORM.urls.discord, une seule constante — elle etait retapee a l'identique dans cinq endroits sur deux pages
Office hoursVivant, renvoie vers /community/events
Product feedbackVivant, renvoie vers /feedback
GitHub DiscussionsRetire 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.
  • QAPage uniquement sur un fil resolu, Article + BreadcrumbList sur 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 un export 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 ProseMarkdown n'en a pas (et elle a le droit d'en nier une) ;
  • llms.txt ne parle ni de moderation IA, ni d'enregistrements d'evenements (Event n'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.