diff --git a/CLAUDE.md b/CLAUDE.md index 6f27c00..6a24a9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des ol ## 1. État actuel -V1 à V14 sont livrées : +V1 à V15 sont livrées : - **V1** : comptes, profils, leaderboard statique. - **V2** : authentification par email réel (plus de pseudo+email interne), rôles `public`/`judge`, verrou de pseudo (modifiable une fois), section Admin. - **V3** : attribution de points par les juges (RPC sécurisée, +/- avec confirmation explicite), journal public « le crieur », podium top 3, flèches de progression, recadrage/compression photo côté client. @@ -25,6 +25,8 @@ V1 à V14 sont livrées : - **V14** : Le Mur de la Honte (V9) s'ouvre aux Archontes — ils peuvent désormais publier une note anonyme comme n'importe quel Citoyen (`post_wall_note` n'a plus de vérification de rôle), et la limite quotidienne passe de 10 à 12 notes pour couvrir tout le groupe plutôt que les seuls Citoyens. La policy Storage d'upload sur `wall-images` perd elle aussi son filtre par rôle (sinon un Archonte aurait pu poster une note mais pas y joindre d'image). Contraste assumé avec V10 (l'Urne, où les Archontes ne votent ni ne reçoivent de jetons) : ici rien ne les distingue plus des Citoyens côté publication — ils gardent seulement, comme avant, leur visibilité privilégiée sur l'auteur de chaque note via « Historique complet ». +- **V15** : Le Mur de la Honte s'ouvre à la consultation des jours précédents, pour tout le monde (pas seulement les Archontes via « Historique complet », qui révèle lui l'auteur). `wall_notes_today()` devient `wall_notes_for_date(p_date default null)` — même garantie d'anonymat qu'avant (jamais de `user_id`), juste paramétrée par jour plutôt que hardcodée sur aujourd'hui. Nouvelle RPC `wall_note_days()` (dates ayant au moins une note, sans contenu ni auteur) peuple un sélecteur de jour au-dessus de la grille, visible par tous. Vue « aujourd'hui » toujours branchée sur le Realtime existant (`notes`/`reactionCounts`) ; un jour passé se charge à la demande dans un état séparé (`pastNotes`/`pastReactionCounts`, pas de Realtime dessus — un jour passé n'évolue plus) pour ne jamais mélanger les deux, notamment le compteur « X/12 aujourd'hui » et l'éligibilité à publier, qui restent toujours basés sur aujourd'hui quel que soit le jour affiché. `wall_note_reaction_counts()` gagne le même paramètre `p_date` (ancienne surcharge à 0 argument explicitement supprimée, même remarque que `post_wall_note`). + Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, mode grand écran. --- @@ -140,20 +142,21 @@ Deuxième volet de l'audit (revue complète de tout le code applicatif, pas seul - `submit_chariot_question(p_text)` — authenticated, rejette les juges (seuls les Citoyens proposent), valide un texte non vide (≤ 300 caractères), et applique le même gel que `submit_icarus_score` via `settings.tribunal_date` (no-op silencieux une fois l'Agora commencée). `insert ... on conflict (user_id) do update` : une ligne par personne dans `chariot_submissions`, plus une ligne append-only dans `chariot_submission_history` à chaque écriture réelle (pas lors du no-op figé). - `chariot_revealed_question()` — authenticated, `language sql`, seul moyen pour un non-juge de connaître la question actuellement révélée sans jamais lire `chariot_questions` (RLS juges uniquement, voir §3bis) — renvoie zéro ligne tant qu'aucune question n'est révélée, et calcule `question_number` (position dans l'ordre complet) côté serveur pour afficher « Question N » sur `/char` sans exposer les autres lignes. - `admin_list_members()` — authenticated, vérifie `role = 'judge'`, seule façon d'exposer `auth.users.email` (jamais stocké dans `profiles`) sur `/admin` sans passer par la `service_role key` côté client. -- `wall_notes_today()` — authenticated, `language sql`, renvoie les notes du jour (heure de Paris), y compris `image_url`, **sans `user_id`** — seul moyen de laisser un Citoyen voir les notes des autres sans jamais exposer l'auteur côté client (la RLS ne peut pas masquer une colonne pour certaines lignes). +- `wall_notes_for_date(p_date default null)` — authenticated, `language sql`, renvoie les notes d'un jour donné (heure de Paris, `p_date` à `null` = aujourd'hui), y compris `image_url`, **sans `user_id`** — seul moyen de voir les notes des autres sans jamais exposer l'auteur côté client (la RLS ne peut pas masquer une colonne pour certaines lignes), ouvert à tout le monde y compris pour les jours précédents depuis V15. +- `wall_note_days()` — authenticated, `language sql`, renvoie les dates ayant au moins une note (jamais le contenu ni l'auteur) — peuple le sélecteur de jour de `wall_notes_for_date` sur `/mur`. - `post_wall_note(p_text, p_image_url default null)` — authenticated, ouvert à tout le monde y compris les juges depuis V14 (plus de vérification de rôle), valide un texte non vide (≤ 200 caractères), puis vérifie que l'appelant n'a pas déjà publié aujourd'hui et que le mur du jour a moins de 12 notes (sinon `raise exception` dans les deux cas — pas de no-op silencieux ici, l'UI doit remonter l'erreur). `p_image_url` est facultatif et doit venir du bucket `wall-images` (vérifié par un `like`). -- `wall_note_reaction_counts()` — authenticated, `language sql`, renvoie `(note_id, emoji, count)` agrégé sur les notes du jour — même principe que `urn_vote_counts` : jamais qui a réagi, seulement le total. +- `wall_note_reaction_counts(p_date default null)` — authenticated, `language sql`, renvoie `(note_id, emoji, count)` agrégé sur les notes d'un jour donné (même paramètre `p_date` que `wall_notes_for_date`, depuis V15) — même principe que `urn_vote_counts` : jamais qui a réagi, seulement le total. - `toggle_wall_note_reaction(p_note_id, p_emoji)` — authenticated, bascule une réaction (ajoute si absente, retire si déjà posée en un `delete` puis, si rien n'a été supprimé, un `insert`). `p_emoji` limité à une liste fermée (contrainte `check` sur la colonne, revérifiée dans la RPC pour un message d'erreur clair). -- `urn_vote_counts()` — authenticated, `language sql`, renvoie `(target_id, votes)` agrégé sur tout l'historique (pas de filtre de date, contrairement à `wall_notes_today`) — seul moyen de calculer un total public sans jamais exposer une ligne individuelle (qui a voté pour qui). -- `urn_vote_reasons(p_target_id)` — authenticated, `language sql`, renvoie les justifications (`reason`, `created_at`) laissées pour une personne, **sans `voter_id`** — même principe que `wall_notes_today` : le contenu est public depuis le classement, l'auteur ne l'est jamais. +- `urn_vote_counts()` — authenticated, `language sql`, renvoie `(target_id, votes)` agrégé sur tout l'historique (pas de filtre de date, contrairement à `wall_notes_for_date`) — seul moyen de calculer un total public sans jamais exposer une ligne individuelle (qui a voté pour qui). +- `urn_vote_reasons(p_target_id)` — authenticated, `language sql`, renvoie les justifications (`reason`, `created_at`) laissées pour une personne, **sans `voter_id`** — même principe que `wall_notes_for_date` : le contenu est public depuis le classement, l'auteur ne l'est jamais. - `cast_urn_vote(p_target_id, p_reason default null)` — authenticated, rejette les juges (ni votants ni cibles), rejette le vote pour soi-même, vérifie que l'appelant n'a pas déjà voté aujourd'hui (heure de Paris) — vote définitif, aucune RPC de modification/suppression. `p_reason` est une justification facultative (≤ 200 caractères, `nullif(trim(...), '')` pour normaliser une chaîne vide en `null`). ### `public.wall_notes` (Le Mur de la Honte, V9) -- `id, user_id, text, image_url, created_at` — une ligne par note, jamais éditée/supprimée (comme `points_log`) ; le « reset quotidien » n'est qu'un filtrage par date dans `wall_notes_today()`, pas une suppression. `image_url` est facultative, pointe vers le bucket `wall-images`. -- RLS `select` : le propriétaire voit sa propre note (sert à afficher « tu as déjà publié aujourd'hui » et à réafficher son propre texte), les juges voient tout avec l'auteur (embed `profiles(pseudo, avatar_url)`, section « Historique complet » sur `/mur`) — pas de visibilité entre Citoyens, c'est tout l'intérêt de `wall_notes_today()`. +- `id, user_id, text, image_url, created_at` — une ligne par note, jamais éditée/supprimée (comme `points_log`) ; le « reset quotidien » du compteur de publication n'est qu'un filtrage par date dans `wall_notes_for_date()`, pas une suppression — les notes restent consultables (anonymement) les jours suivants depuis V15. `image_url` est facultative, pointe vers le bucket `wall-images`. +- RLS `select` : le propriétaire voit sa propre note (sert à afficher « tu as déjà publié aujourd'hui » et à réafficher son propre texte), les juges voient tout avec l'auteur (embed `profiles(pseudo, avatar_url)`, section « Historique complet » sur `/mur`) — pas de visibilité entre Citoyens, c'est tout l'intérêt de `wall_notes_for_date()`. - Pas de gel via `settings.tribunal_date` : contrairement au Char, c'est une mécanique de toute la semaine. -- Bucket Storage `wall-images` (public, `file_size_limit` 3 Mo, `allowed_mime_types` image/webp+jpeg+png — même limite que le bucket `avatars`, voir §3bis) : contrairement à `avatars`, le chemin d'objet est un UUID **sans le `user_id`** — sinon l'auteur fuiterait via l'URL publique de l'image, ce que `wall_notes_today()` prend justement soin de ne jamais exposer. Compression côté client avant upload (`resizeImageToBlob`, `src/lib/image.ts`, contrairement à `cropImageToBlob` qui force un carré pour les avatars) ; la limite du bucket n'est qu'un filet de sécurité, pas la seule garantie. +- Bucket Storage `wall-images` (public, `file_size_limit` 3 Mo, `allowed_mime_types` image/webp+jpeg+png — même limite que le bucket `avatars`, voir §3bis) : contrairement à `avatars`, le chemin d'objet est un UUID **sans le `user_id`** — sinon l'auteur fuiterait via l'URL publique de l'image, ce que `wall_notes_for_date()` prend justement soin de ne jamais exposer. Compression côté client avant upload (`resizeImageToBlob`, `src/lib/image.ts`, contrairement à `cropImageToBlob` qui force un carré pour les avatars) ; la limite du bucket n'est qu'un filet de sécurité, pas la seule garantie. - `public.wall_note_reactions` (`id, note_id, user_id, emoji, created_at`, `unique (note_id, user_id, emoji)`) : réactions emoji sur une note, même principe d'anonymat que les notes elles-mêmes — RLS `select` limitée à `user_id = auth.uid()` (pour savoir lesquelles sont déjà activées), le total public passe uniquement par `wall_note_reaction_counts()`. ### `public.urn_votes` (L'Urne de l'Agora, V10) @@ -181,7 +184,7 @@ Deuxième volet de l'audit (revue complète de tout le code applicatif, pas seul | `/corne` | « La Corne d'Abondance » — jeu de fusion façon Watermelon Game (avatars aléatoires, physique `matter-js`), même mécanique de gloires que Le Vol d'Icare | | `/char` | « Le Jeu de l'Agora » — format paysage (projeté) : Le Char (2 colonnes de 3, boutons de duel), Le Gardien du Silence (tirage aléatoire, minuteur discret) et La Roulette (tirage au sort animé) | | `/char/questions` | Banque de questions du Char (ajout/édition/réordonnancement/afficher-masquer) + modération des suggestions des Citoyens (ajouter à la banque/rejeter) — juges uniquement, pilotée depuis un téléphone pendant que `/char` est projetée | -| `/mur` | « Le Mur de la Honte » — une note anonyme par personne par jour, Archontes compris depuis V14 (douze places, remis à zéro chaque jour) ; les Archontes y voient en plus l'historique complet avec l'auteur de chaque note | +| `/mur` | « Le Mur de la Honte » — une note anonyme par personne par jour, Archontes compris depuis V14 (douze places, remis à zéro chaque jour) ; les jours précédents restent consultables anonymement par tous depuis V15 (sélecteur de jour) ; les Archontes y voient en plus l'historique complet avec l'auteur de chaque note | | `/urne` | « L'Urne de l'Agora » — un jeton par Citoyen par jour sur une autre personne, classement cumulatif public identique pour tous, Archontes compris (aucune vision privilégiée ici) | | `/calendrier` | « Le Calendrier des Dieux » — agenda de la semaine, panthéon, date du Tribunal, édition réservée aux juges. Retirée du menu de navigation (la date du Tribunal, seule info largement utile au quotidien, est reprise sur `/leaderboard`) mais la route reste fonctionnelle — les juges y accèdent directement par l'URL pour gérer le calendrier et changer la date | | `/journal` | « Le Crieur » — fil live des décrets (attributions de points), lecture pour tous | diff --git a/src/app/mur/mur-view.tsx b/src/app/mur/mur-view.tsx index 65c2992..778c28f 100644 --- a/src/app/mur/mur-view.tsx +++ b/src/app/mur/mur-view.tsx @@ -149,6 +149,7 @@ export function MurView({ initialHistory, initialReactionCounts, initialMyReactions, + availableDays, }: { isJudge: boolean; currentUserId: string; @@ -157,6 +158,7 @@ export function MurView({ initialHistory: WallHistoryRow[]; initialReactionCounts: ReactionCountRow[]; initialMyReactions: MyReactionRow[]; + availableDays: string[]; }) { const [notes, setNotes] = useState(initialNotes); const [myNoteToday, setMyNoteToday] = useState(initialMyNoteToday); @@ -171,6 +173,15 @@ export function MurView({ const [error, setError] = useState(null); const [historyDateFilter, setHistoryDateFilter] = useState("all"); const [zoomedImage, setZoomedImage] = useState(null); + // Jour affiché dans la grille du haut (ouverte à tout le monde, + // contrairement à l'historique complet réservé aux Archontes plus bas) : + // null = aujourd'hui, réactif au Realtime comme avant. Un jour passé se + // charge à la demande (pas de Realtime dessus, un jour passé n'évolue + // plus) dans un état séparé pour ne jamais mélanger les deux. + const [viewedDate, setViewedDate] = useState(null); + const [pastNotes, setPastNotes] = useState([]); + const [pastReactionCounts, setPastReactionCounts] = useState>>({}); + const [pastLoading, setPastLoading] = useState(false); const refetch = useCallback(async () => { const supabase = createClient(); @@ -228,6 +239,27 @@ export function MurView({ }; }, [refetch]); + const fetchDay = useCallback(async (dateKey: string) => { + const supabase = createClient(); + const [{ data: dayNotes }, { data: dayCounts }] = await Promise.all([ + supabase.rpc("wall_notes_for_date", { p_date: dateKey }), + supabase.rpc("wall_note_reaction_counts", { p_date: dateKey }), + ]); + setPastNotes(dayNotes ?? []); + setPastReactionCounts(buildReactionCounts(dayCounts ?? [])); + }, []); + + async function handleSelectDate(value: string) { + if (value === "today") { + setViewedDate(null); + return; + } + setViewedDate(value); + setPastLoading(true); + await fetchDay(value); + setPastLoading(false); + } + function handleImageChange(event: ChangeEvent) { const file = event.target.files?.[0]; event.target.value = ""; @@ -318,7 +350,11 @@ export function MurView({ } async function handleToggleReaction(noteId: string, emoji: string) { - // Optimiste : le canal Realtime confirmera (ou corrigera) juste après. + // Optimiste : le canal Realtime confirmera (ou corrigera) juste après — + // sauf en dehors d'aujourd'hui, où il n'y a pas de Realtime (un jour + // passé n'évolue plus) : on corrige alors en rechargeant ce jour-là. + const setCounts = viewedDate === null ? setReactionCounts : setPastReactionCounts; + setMyReactions((current) => { const next = new Set(current[noteId] ?? []); const wasActive = next.has(emoji); @@ -326,7 +362,7 @@ export function MurView({ else next.add(emoji); return { ...current, [noteId]: next }; }); - setReactionCounts((current) => { + setCounts((current) => { const wasActive = myReactions[noteId]?.has(emoji) ?? false; const noteCounts = { ...(current[noteId] ?? {}) }; noteCounts[emoji] = Math.max(0, (noteCounts[emoji] ?? 0) + (wasActive ? -1 : 1)); @@ -338,11 +374,22 @@ export function MurView({ p_note_id: noteId, p_emoji: emoji, }); - if (rpcError) refetch(); + if (rpcError) { + if (viewedDate === null) refetch(); + else fetchDay(viewedDate); + } } + // wallFull/le compteur restent toujours basés sur aujourd'hui (notes), + // jamais sur le jour affiché (displayedNotes) : consulter un jour passé + // ne doit jamais changer l'éligibilité à publier aujourd'hui. const wallFull = notes.length >= MAX_NOTES_PER_DAY; + const todayKey = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" }); + const pastDays = availableDays.filter((day) => day !== todayKey); + const displayedNotes = viewedDate === null ? notes : pastNotes; + const displayedReactionCounts = viewedDate === null ? reactionCounts : pastReactionCounts; + const historyDates = Array.from(new Set(history.map((n) => localDateKey(n.created_at)))).sort((a, b) => b.localeCompare(a), ); @@ -352,19 +399,38 @@ export function MurView({ return (
-

- {notes.length} / {MAX_NOTES_PER_DAY}{" "} - aujourd'hui -

- {notes.length === 0 ? ( -

Le mur est encore vide aujourd'hui.

+
+

+ {viewedDate === null ? `${notes.length} / ${MAX_NOTES_PER_DAY} aujourd'hui` : formatDateLabel(viewedDate)} +

+ {pastDays.length > 0 && ( + + )} +
+ {pastLoading ? ( +

Chargement…

+ ) : displayedNotes.length === 0 ? ( +

+ {viewedDate === null ? "Le mur est encore vide aujourd'hui." : "Le mur était vide ce jour-là."} +

) : (
- {notes.map((note) => ( + {displayedNotes.map((note) => ( handleToggleReaction(note.id, emoji)} onImageClick={setZoomedImage} diff --git a/src/app/mur/page.tsx b/src/app/mur/page.tsx index 88e8727..f7bea9e 100644 --- a/src/app/mur/page.tsx +++ b/src/app/mur/page.tsx @@ -44,11 +44,13 @@ export default async function MurPage() { .single(); const isJudge = callerProfile?.role === "judge"; - const [{ data: todayNotes }, { data: reactionCounts }, { data: myReactions }] = await Promise.all([ - supabase.rpc("wall_notes_today"), + const [{ data: todayNotes }, { data: reactionCounts }, { data: myReactions }, { data: days }] = await Promise.all([ + supabase.rpc("wall_notes_for_date"), supabase.rpc("wall_note_reaction_counts"), supabase.from("wall_note_reactions").select("note_id, emoji").eq("user_id", user.id), + supabase.rpc("wall_note_days"), ]); + const availableDays = (days ?? []).map((row: { note_date: string }) => row.note_date); // Les Archontes peuvent désormais publier aussi (comme les Citoyens) : // besoin de savoir s'ils ont déjà posté aujourd'hui, peu importe le rôle. @@ -74,7 +76,8 @@ export default async function MurPage() {

Le Mur de la Honte

- Une note anonyme par jour, douze places sur le mur — la journée passée, tout s'efface. + Une note anonyme par jour, douze places sur le mur — les jours précédents restent consultables, toujours + anonymement.

); diff --git a/supabase/schema.sql b/supabase/schema.sql index a6cd87e..304de68 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -1369,12 +1369,16 @@ end $$; -- Seul moyen pour un Citoyen de voir les notes des autres sans jamais -- exposer qui les a écrites côté client : la RLS ne peut pas masquer une -- colonne pour certaines lignes, donc cette RPC (SECURITY DEFINER) fait le --- filtre elle-même et ne renvoie jamais user_id. Le type de retour change --- (ajout d'image_url) : drop explicite requis, create or replace ne peut --- pas changer le type de retour d'une fonction existante. +-- filtre elle-même et ne renvoie jamais user_id. Paramétrée par jour +-- (p_date, Europe/Paris) plutôt que hardcodée sur aujourd'hui — remplace +-- wall_notes_today(), pour que n'importe qui (pas seulement les Archontes +-- via « Historique complet » plus bas, qui révèle lui l'auteur) puisse +-- consulter les jours précédents, toujours sans jamais exposer qui a +-- écrit quoi. p_date à null (par défaut) = aujourd'hui, pour que l'appel +-- de chargement initial de la page reste un simple appel sans argument. drop function if exists public.wall_notes_today(); -create or replace function public.wall_notes_today() +create or replace function public.wall_notes_for_date(p_date date default null) returns table (id uuid, text text, image_url text, created_at timestamptz) language sql security definer @@ -1382,11 +1386,28 @@ set search_path = public as $$ select w.id, w.text, w.image_url, w.created_at from public.wall_notes w - where (w.created_at at time zone 'Europe/Paris')::date = (now() at time zone 'Europe/Paris')::date + where (w.created_at at time zone 'Europe/Paris')::date = coalesce(p_date, (now() at time zone 'Europe/Paris')::date) order by w.created_at asc; $$; -grant execute on function public.wall_notes_today() to authenticated; +grant execute on function public.wall_notes_for_date(date) to authenticated; + +-- Liste des jours ayant au moins une note (jamais le contenu ni l'auteur, +-- juste la date) : peuple le sélecteur de jour de wall_notes_for_date() +-- côté client, pour tout le monde — contrairement à l'historique complet +-- avec auteur plus bas, réservé aux Archontes. +create or replace function public.wall_note_days() +returns table (note_date date) +language sql +security definer +set search_path = public +as $$ + select distinct (w.created_at at time zone 'Europe/Paris')::date as note_date + from public.wall_notes w + order by note_date desc; +$$; + +grant execute on function public.wall_note_days() to authenticated; -- Seul point d'entrée pour publier une note. Pas de gel via tribunal_date -- (contrairement au Char) : contrairement à la soirée de clôture, c'est une @@ -1523,7 +1544,15 @@ exception when duplicate_object then null; end $$; -create or replace function public.wall_note_reaction_counts() +-- Paramétrée par jour (comme wall_notes_for_date ci-dessus, même p_date à +-- null = aujourd'hui, donc les anciens appels sans argument continuent de +-- fonctionner à l'identique). L'ancienne surcharge à 0 argument est +-- explicitement supprimée : create or replace ne remplace que la même +-- signature, sinon les deux versions coexisteraient (même remarque que +-- pour post_wall_note plus haut). +drop function if exists public.wall_note_reaction_counts(); + +create or replace function public.wall_note_reaction_counts(p_date date default null) returns table (note_id uuid, emoji text, count bigint) language sql security definer @@ -1532,11 +1561,11 @@ as $$ select r.note_id, r.emoji, count(*) as count from public.wall_note_reactions r join public.wall_notes w on w.id = r.note_id - where (w.created_at at time zone 'Europe/Paris')::date = (now() at time zone 'Europe/Paris')::date + where (w.created_at at time zone 'Europe/Paris')::date = coalesce(p_date, (now() at time zone 'Europe/Paris')::date) group by r.note_id, r.emoji; $$; -grant execute on function public.wall_note_reaction_counts() to authenticated; +grant execute on function public.wall_note_reaction_counts(date) to authenticated; -- Bascule une réaction (ajoute si absente, retire si déjà posée) en un seul -- aller-retour : un delete puis, si rien n'a été supprimé, un insert —