Les evenements communautaires
/community/events · /community/events/[slug] · /admin/content/community-events · POST /api/community/events/[id]/rsvp · GET /api/community/events/[id]/ics Cette page n'existait pas. La surface non plus, en pratique…
/community/events·/community/events/[slug]·/admin/content/community-events·POST /api/community/events/[id]/rsvp·GET /api/community/events/[id]/icsCette page n'existait pas. La surface non plus, en pratique : lire pourquoi avant de la modifier evite de reintroduire exactement le meme defaut.
Ce qui n'allait pas, et qui explique la forme actuelle
Au 2026-09-12, /community/events etait morte par construction et
promettait publiquement un agenda qu'aucune donnee ne portait.
| Constat | Preuve |
|---|---|
| Aucun chemin de creation d'evenement | prisma.event.create : zero occurrence dans src/, scripts/, prisma/. Pas de seed, pas de script, pas de formulaire. L'admin savait cacher et supprimer, ses docstrings renvoyant a une « Phase 5 » absente de la roadmap (CLAUDE.md s'arrete a la Phase 3) |
Donc : section « Upcoming » vide en permanence, detail en 404 pour tout slug, zero URL d'evenement au sitemap, eventJsonLd sans aucune emission | consequence directe de la ligne precedente |
| Le RSVP etait un cul-de-sac | on pouvait cliquer, gagner de l'XP (awardXp) et consommer une place — sans lien de connexion, sans email, sans .ics. Event n'avait ni joinUrl, ni host, ni fuseau, ni recordingUrl |
| Quatre cartes « Cadence » codees en dur promettaient des dates | « 60 minutes every Wednesday with the founder + at least one senior engineer », « First Friday of the month », « Four sessions in October to November ». Confirme le 2026-09-12 qu'aucune n'est tenue |
| Trois affirmations que le produit ne portait pas | « Every event is recorded and transcripted » (aucun champ) ; « Recordings sit in the Discord under #events-archive » (invérifiable ici) ; « Subscribe to the events feed — one email a week » (aucun cron ne l'envoyait) |
| Les sessions passees etaient orphelines | l'index ne listait que startsAt >= now, mais le sitemap les soumettait au titre d'un « evergreen recap content » qu'aucune colonne ne portait |
| L'heure etait ambigue | startsAt formate dans le fuseau du SERVEUR, sans jamais le nommer. « 6:00 PM » ne dit pas 18h ou |
| Cinq copies en dur de l'invitation Discord | community/events/page.tsx (x4), community/forum/page.tsx |
Le principe : la page ne peut plus depasser la donnee
Tout ce que la page affiche vient d'une ligne. Rien n'y est promis en prose.
- Une session est une ligne
Event. Elle a une date, donc elle est affichable, citable, et elle recoit unEventJSON-LD. - Un format (
office-hours,ama,bfcm-clinic,agent-build-along) decrit ce qu'est une session sans dire quand. Il ne recoit pas de JSON-LD :startDateest requis et un format n'en a pas, donc lui en donner un serait l'inventer — c'est exactement ce que les badges de frequence faisaient en prose. - La carte d'un format affiche la prochaine occurrence quand une
ligne
Eventporte cekind, et renvoie vers Discord sinon.
Corollaire : le jour ou l'equipe tient a nouveau des office hours hebdomadaires, la page le dira parce qu'il y aura quatre lignes, pas parce que quelqu'un aura retape « Weekly » dans un catalogue i18n.
joinUrl : la seule regle de securite de cette surface
Event.joinUrl est le lien Zoom / Meet de la session. Il ne sort que
vers un lecteur qui a RSVP.
La gate vit dans src/services/community/events.ts, pas dans les pages :
toListItem() compose un objet explicite et n'y met joinUrl que si
isRsvpdByViewer. Les deux lectures publiques ont perdu leur
return { ...row } pour cette raison precise — un spread aurait publie
le lien dans le payload RSC de chaque page de detail, y compris pour un
visiteur anonyme. Ce n'est donc pas un masquage CSS : le lien n'est pas
dans le HTML.
Trois consequences a ne pas defaire :
- Le JSON-LD ne le porte jamais.
eventJsonLdrecoit lelocationtextuel, pas le lien de session : un balisage est lu par tout le monde. - Le
.icspublic ne le porte que pour un appelant identifie qui a RSVP. La route est publique a dessein (ajouter une session a son agenda ne doit pas demander de compte), donc elle relit le RSVP avant d'inclure le lien, et repondCache-Control: private, no-store. - L'email de confirmation le porte, et c'est legitime : le destinataire vient de s'inscrire.
src/services/community/events.test.ts garde les cinq cas.
Le RSVP delivre quelque chose
POST /api/community/events/[id]/rsvp → toggleRsvp → puis, sur un
passage a going :
awardXp(inchange, idempotent sur(userId, "event_rsvp", eventId)) ;sendEventRsvpConfirmation— fire-and-forget, commeawardXp. Un RSVP enregistre ne doit pas echouer parce que Resend a hoquete, et l'envoi part apres l'ecriture, donc il ne peut pas confirmer une inscription qui n'existe pas.
L'email porte le creneau (dans le fuseau de l'evenement et dans la
locale du destinataire), le lieu, l'animateur, le lien de session s'il
existe, un lien « ajouter a l'agenda » et le lien d'annulation. Il est
transactionnel : aucune pressure policy, donc jamais plafonne.
La plage est formatee dans le sender, pas chez l'appelant : c'est le
seul endroit ou getEmailContext(to) a resolu la locale du destinataire.
Le temps
Event.timezone porte la zone IANA dans laquelle la session a ete
planifiee. Tout rendu passe par
src/services/community/event-time.ts :
| Fonction | Role |
|---|---|
safeTimeZone | Event.timezone est un String libre : une valeur invalide fait jeter Intl et tomber la page. Repli UTC, jamais une exception |
eventRangeParts / formatEventRange | rendu dans la zone de l'evenement, dans la locale du lecteur, le nom de zone sur la borne de fin seulement |
zonedWallClockToUtc | ce que le formulaire admin saisit → l'instant. Deux passes, parce que le decalage depend de l'instant et l'instant du decalage |
utcToZonedWallClock | l'inverse, calcule cote serveur et passe en prop : le deriver dans le composant client le lirait dans le fuseau du navigateur de l'operateur |
toIcsUtc | l'horodatage compact Z du .ics |
hourCycle: "h23" partout : hour12: false rend 24 a minuit dans
plusieurs locales, ce qui decalerait la journee entiere.
Les replays, et le sitemap
Une session terminee n'a de contenu que si elle a un recordingUrl.
C'est donc lui qui decide de son existence publique, aux deux endroits :
- la section Replays de l'index (
listPastEventsWithRecording) ; - le
wheredesitemap.ts, qui n'accepte une session passee que si elle en a un.
Avant, l'index ne listait que le futur et le sitemap soumettait tout le passe : des URLs orphelines promettant un recap inexistant.
Le panneau admin
/admin/content/community-events — creation, edition, masquage,
suppression. Les deux tuiles du bas comptent ce que rien d'autre ne
regarde :
| Tuile | Ce qu'elle dit |
|---|---|
| Upcoming with no join link | chaque inscrit a cette session n'a aucun moyen de venir |
| Past with no recording | cette session n'est ni dans les Replays ni dans le sitemap |
Trois regles portees par le formulaire :
- une session naît cachee : le slug, l'heure et le lien se relisent avant que le public la voie ;
rsvpCountn'est jamais ecrit parupdateEvent— c'est un compteur cache tenu partoggleRsvpdans la meme transaction que les lignesEventRsvp;endsAt <= startsAtest refuse deux fois (zod dans le formulaire,assertWritabledans le service), parce qu'une server action est une entree HTTP publique.
Ce que cette surface n'a toujours pas
Ecrit ici pour que personne ne l'affirme ailleurs :
- aucune transcription. L'ancienne copie promettait « recorded and transcripted » ; seul l'enregistrement existe.
- aucun rappel avant la session. Le RSVP confirme, il ne relance pas. Un cron de rappel serait le prochain ajout utile.
- aucun email d'annulation. Supprimer un evenement fait cascader ses RSVP sans prevenir personne — le dialogue de confirmation le dit.
- aucun feed d'evenements par email. Le CTA n'en promet plus un.
- aucune recurrence. Quatre office hours = quatre lignes. C'est volontaire : une recurrence stockee est une promesse que personne ne verifie.
Fichiers
| Quoi | Ou |
|---|---|
| Catalogue des formats + invitation Discord | src/config/community-events.ts |
Lectures, gate joinUrl, ecriture admin | src/services/community/events.ts |
| Temps et fuseaux | src/services/community/event-time.ts |
| Index public | src/app/(marketing)/community/events/page.tsx |
| Detail public | src/app/(marketing)/community/events/[slug]/page.tsx |
| RSVP | src/app/api/community/events/[id]/rsvp/route.ts |
| Calendrier | src/app/api/community/events/[id]/ics/route.ts |
| Panneau admin | src/app/(dashboard)/admin/content/community-events/ |
src/modules/email/templates/event-rsvp.tsx + sendEventRsvpConfirmation | |
| i18n | marketingPages.community.events, …eventDetail, email.eventRsvp (six locales) |