ArchitectureLes evenements communautaires

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]/ics

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

ConstatPreuve
Aucun chemin de creation d'evenementprisma.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 emissionconsequence directe de la ligne precedente
Le RSVP etait un cul-de-sacon 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 orphelinesl'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 ambiguestartsAt 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 Discordcommunity/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 un Event JSON-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 : startDate est 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 Event porte ce kind, 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 :

  1. Le JSON-LD ne le porte jamais. eventJsonLd recoit le location textuel, pas le lien de session : un balisage est lu par tout le monde.
  2. Le .ics public 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 repond Cache-Control: private, no-store.
  3. 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 :

  1. awardXp (inchange, idempotent sur (userId, "event_rsvp", eventId)) ;
  2. sendEventRsvpConfirmation — fire-and-forget, comme awardXp. 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 :

FonctionRole
safeTimeZoneEvent.timezone est un String libre : une valeur invalide fait jeter Intl et tomber la page. Repli UTC, jamais une exception
eventRangeParts / formatEventRangerendu dans la zone de l'evenement, dans la locale du lecteur, le nom de zone sur la borne de fin seulement
zonedWallClockToUtcce que le formulaire admin saisit → l'instant. Deux passes, parce que le decalage depend de l'instant et l'instant du decalage
utcToZonedWallClockl'inverse, calcule cote serveur et passe en prop : le deriver dans le composant client le lirait dans le fuseau du navigateur de l'operateur
toIcsUtcl'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 where de sitemap.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 :

TuileCe qu'elle dit
Upcoming with no join linkchaque inscrit a cette session n'a aucun moyen de venir
Past with no recordingcette 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 ;
  • rsvpCount n'est jamais ecrit par updateEvent — c'est un compteur cache tenu par toggleRsvp dans la meme transaction que les lignes EventRsvp ;
  • endsAt <= startsAt est refuse deux fois (zod dans le formulaire, assertWritable dans 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

QuoiOu
Catalogue des formats + invitation Discordsrc/config/community-events.ts
Lectures, gate joinUrl, ecriture adminsrc/services/community/events.ts
Temps et fuseauxsrc/services/community/event-time.ts
Index publicsrc/app/(marketing)/community/events/page.tsx
Detail publicsrc/app/(marketing)/community/events/[slug]/page.tsx
RSVPsrc/app/api/community/events/[id]/rsvp/route.ts
Calendriersrc/app/api/community/events/[id]/ics/route.ts
Panneau adminsrc/app/(dashboard)/admin/content/community-events/
Emailsrc/modules/email/templates/event-rsvp.tsx + sendEventRsvpConfirmation
i18nmarketingPages.community.events, …eventDetail, email.eventRsvp (six locales)