From 76fa8cd451c0120701cb65986215d4bd627bcfe1 Mon Sep 17 00:00:00 2001 From: Valentin ROBIN Date: Wed, 19 Aug 2026 01:22:36 +0200 Subject: [PATCH] Ajoute les suggestions de questions des Citoyens et les emails sur Admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chaque Citoyen peut proposer une question pour Le Char depuis /profile (upsert via submit_chariot_question, figée dès la date du Tribunal comme les scores d'Icare). Les Archontes modèrent ces suggestions directement sur /char/questions (ajouter à la banque ou rejeter). /admin affiche maintenant l'email de chaque membre via une nouvelle RPC admin_list_members(), seule façon d'exposer auth.users.email sans passer par la service_role key côté client. Corrige au passage deux bugs découverts pendant les tests : la policy interne de admin_list_members() référençait id/role sans les qualifier, ambigus avec les colonnes du RETURNS TABLE (erreur Postgres 42702) ; et l'auteur d'une suggestion s'affichait toujours comme "un Citoyen" car le embed profiles(...) avait été mal retypé en tableau alors qu'il est retourné en objet à l'exécution. --- CLAUDE.md | 18 +- src/app/admin/admin-members-table.tsx | 20 ++- src/app/admin/page.tsx | 5 +- src/app/char/questions/page.tsx | 14 ++ src/app/char/questions/questions-view.tsx | 178 +++++++++++++++----- src/app/profile/chariot-submission-form.tsx | 87 ++++++++++ src/app/profile/page.tsx | 21 +++ supabase/schema.sql | 124 ++++++++++++++ 8 files changed, 411 insertions(+), 56 deletions(-) create mode 100644 src/app/profile/chariot-submission-form.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 49adbe1..f2db1a0 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 à V7 sont livrées : +V1 à V8 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. @@ -14,6 +14,7 @@ V1 à V7 sont livrées : - **V5** : navigation entièrement repliée dans le menu déroulant du chip (header épuré : logo + chip seulement), page La Roulette (tirage au sort animé — déplacée dans `/char` depuis, voir V7), page **Le Calendrier des Dieux** (agenda de la semaine, panthéon, date du Tribunal), boutons de validation des points unifiés (`PointsConfirmControls`) entre podium et classement. - **V6** : premier mini-jeu compétitif, **Le Vol d'Icare** (`/icare`) — Flappy Bird grec (Icare vole entre des colonnes de temple), record personnel all-time (pas de piste par jour), touche l'écran pour battre des ailes, échec net au premier contact (score remis à zéro, comme l'original). Les scores sont figés dès que la date du Tribunal est atteinte, puis les gloires du top 3 sont attribuées **automatiquement** (tâche planifiée `pg_cron`, sans intervention d'un Archonte) — seul mécanisme de points de tout le projet qui ne passe pas par une décision de juge. - **V7** : outils numériques pour le vrai jeu du Tribunal (soirée physique/sociale, pas un mini-jeu digital), page **Le Jeu de l'Agora** (`/char`) — pensée **format paysage 16:9** (projetée sur grand écran) plutôt que mobile-first comme le reste de l'app. **Le Char** : 2 colonnes fixes de 3 emplacements affichées côte à côte avec un « VS » et une illustration de char vu du dessus entre les deux, affectation d'un Citoyen à un emplacement vide en un clic (liste des Citoyens libres directement cliquables, pas de menu déroulant), bouton « Retirer », boutons « Colonne A/B remporte le duel » (vide la colonne gagnante après une brève animation — les vainqueurs sortent, les perdants restent), compteur du nombre de fois que chacun est monté dans le char, avec un petit classement récapitulatif en bas de page (tout le monde, y compris ceux qui n'y sont jamais monté). La banque de questions (ajout/édition/réordonnancement/bascule afficher-masquer) vit sur une page séparée réservée aux Archontes, **`/char/questions`** — pensée pour être pilotée depuis un téléphone pendant que `/char` est projetée depuis un ordinateur ; `/char` n'affiche que la question actuellement révélée, en lecture seule. **Le Gardien du Silence** : rôle tiré au sort parmi les Citoyens (jamais les Archontes), affiché de façon discrète (barre compacte, pas une grande carte), minuteur avec reroll automatique à expiration (RPC auto-protégée, filet `pg_cron`) et bouton de reroll manuel pour les Archontes. L'app n'arbitre jamais le jeu lui-même, elle affiche/synchronise un état piloté à la main. **La Roulette** (tirage au sort animé parmi les membres, ou les Citoyens uniquement), initialement livrée en V5 sur sa propre page `/roulette`, a été fusionnée dans `/char` comme troisième section — la route et le lien de menu dédiés ont été retirés, tout l'outillage de la soirée du Tribunal vit maintenant au même endroit. +- **V8** : les Citoyens peuvent désormais proposer une question pour Le Char — une par personne (upsert), modifiable autant de fois que voulu depuis une nouvelle section sur `/profile`, jusqu'à ce que la date du Tribunal (`settings.tribunal_date`) soit atteinte (même mécanisme de gel que les scores d'Icare, no-op silencieux plutôt qu'une erreur). Les Archontes modèrent ces suggestions depuis une nouvelle section « Suggestions des Citoyens » sur `/char/questions` : chaque proposition affiche l'auteur, avec un bouton « Ajouter à la banque » (la copie dans `chariot_questions`, à la fin de l'ordre existant) et un bouton de rejet (suppression, confirmation à deux temps comme le reste de la page). Par ailleurs, `/admin` affiche maintenant l'adresse email de chaque membre (RPC `admin_list_members()`, seul moyen d'exposer `auth.users.email` aux juges sans passer par la `service_role key` côté client). Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/saisons, mode grand écran. @@ -72,6 +73,13 @@ Le script est **idempotent** : toujours le ré-exécuter en entier après une mo - `settings.chariot_revealed_question_id` : question actuellement affichée sur `/char`, directement modifiable par les juges (rien à protéger dans cette colonne-là) — bascule effectuée depuis `/char/questions`. - Préfixe `chariot_*` (pas `char_*`) volontaire : sans rapport avec l'ancienne Course du Char digitale supprimée, qui utilisait déjà ce préfixe. +### `public.chariot_submissions` (suggestions de questions par les Citoyens, V8) + +- `user_id (pk), text, updated_at` — une ligne par Citoyen (upsert, comme `icarus_scores`). Verrouillée comme `icarus_scores`/`points_log` : `insert`/`update` révoqués pour `authenticated`, seule la RPC `submit_chariot_question()` (SECURITY DEFINER) peut écrire. +- RLS `select` : le propriétaire voit sa propre ligne, les juges voient tout (modération) — pas de visibilité entre Citoyens. +- RLS `delete` : réservée aux juges (rejet direct depuis `/char/questions`, pas besoin de RPC pour une suppression simple). +- Gel réutilisant `settings.tribunal_date` (même déclencheur que `submit_icarus_score`) plutôt qu'une nouvelle colonne dédiée. + ### Colonnes sensibles : RLS + trigger, jamais confiance au client La RLS est **au niveau ligne** : elle ne peut pas exprimer « cette colonne seulement si tel rôle ». Le verrouillage fin des colonnes (`role`, `points`, `pseudo_locked`, pseudo figé, `previous_rank`) passe par le trigger `enforce_profile_update()` : @@ -92,12 +100,14 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè - `submit_icarus_score(p_score)` — authenticated, vérifie côté serveur si la date du Tribunal est déjà passée (scores figés : no-op silencieux plutôt qu'une erreur), n'écrase le record que s'il est strictement battu. - `award_icarus_points_if_due()` — **pas de grant à authenticated**, appelée uniquement par la tâche planifiée `pg_cron` (ou depuis le SQL Editor) : dès que la date du Tribunal est atteinte, attribue les gloires du top 3 automatiquement (`judge_id = null` dans `points_log`, affiché comme « Le Tribunal » dans Le Crieur), une seule fois (`settings.icarus_points_awarded`). - `reroll_gardien(p_force default false)` — authenticated. Tire un nouveau Gardien parmi les Citoyens (jamais le détenteur actuel si possible) et repousse `gardien_expires_at` de 5 minutes. `p_force=true` (vérifie `role = 'judge'`) reroll immédiatement ; `p_force=false` (appelée par le minuteur côté client à expiration, et par `pg_cron` en filet) ne fait rien tant que `gardien_expires_at` n'est pas atteint — un seul `UPDATE ... WHERE` atomique (pas de `SELECT` puis `UPDATE`), pour qu'un reroll naturel avec plusieurs téléphones ouverts au même moment ne reroll qu'une seule fois. +- `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. +- `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. --- ## 4. Authentification -**Sur invitation uniquement**, plus d'auto-inscription publique : "Allow new users to sign up" est désactivé côté Supabase. Un Archonte invite via Authentication → Users → Invite user ; le lien reçu établit une session directement sur `/signup`, qui sert alors à finaliser le compte (mot de passe, pseudo, photo) — sans session valide, la page affiche un message « invitation requise ». Le profil est créé dès l'invitation par le trigger `handle_new_user` (pseudo temporaire `"Nouveau membre"` jusqu'à finalisation). Nécessite un SMTP custom côté Supabase (le service intégré est trop limité en volume, voir README). Le pseudo est un simple nom d'affichage stocké dans `profiles`, jamais l'email n'est montré aux autres membres. +**Sur invitation uniquement**, plus d'auto-inscription publique : "Allow new users to sign up" est désactivé côté Supabase. Un Archonte invite via Authentication → Users → Invite user ; le lien reçu établit une session directement sur `/signup`, qui sert alors à finaliser le compte (mot de passe, pseudo, photo) — sans session valide, la page affiche un message « invitation requise ». Le profil est créé dès l'invitation par le trigger `handle_new_user` (pseudo temporaire `"Nouveau membre"` jusqu'à finalisation). Nécessite un SMTP custom côté Supabase (le service intégré est trop limité en volume, voir README). Le pseudo est un simple nom d'affichage stocké dans `profiles`, jamais l'email n'est montré aux autres Citoyens — seuls les Archontes le voient, sur `/admin` (via `admin_list_members()`, voir §3). --- @@ -111,10 +121,10 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè | `/leaderboard` | « L'Agora » — podium top 3 + liste, Realtime, contrôles de points pour les juges | | `/icare` | « Le Vol d'Icare » — Flappy Bird grec, record personnel all-time, scores figés au Tribunal, points attribués automatiquement | | `/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) — juges uniquement, pilotée depuis un téléphone pendant que `/char` est projetée | +| `/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 | | `/calendrier` | « Le Calendrier des Dieux » — agenda de la semaine, panthéon, date du Tribunal, édition réservée aux juges | | `/journal` | « Le Crieur » — fil live des décrets (attributions de points), lecture pour tous | -| `/profile` | Pseudo (verrouillable), photo, déconnexion | +| `/profile` | Pseudo (verrouillable), photo, déconnexion, et pour les Citoyens : proposition d'une question pour Le Char (modifiable jusqu'au Tribunal) | | `/admin` | « Admin » — juges uniquement, **vérifié côté serveur** (Server Component), habillée d'une police monospace (rupture volontaire avec le thème grec) — gestion des membres, reset du repère de classement | `/leaderboard`, `/profile`, `/admin`, `/journal`, `/calendrier`, `/icare`, `/char` (et donc `/char/questions`, couverte par le même préfixe) sont protégées par `src/proxy.ts` (redirection `/login` si non connecté). L'accès juge-only de `/admin` et `/char/questions` n'est **pas** géré par le proxy (il n'a pas facilement le rôle) — la page elle-même vérifie et redirige ; même principe pour les boutons d'édition du Calendrier (RLS + vérification serveur, jamais un simple masquage front). diff --git a/src/app/admin/admin-members-table.tsx b/src/app/admin/admin-members-table.tsx index 8f82357..e1173c7 100644 --- a/src/app/admin/admin-members-table.tsx +++ b/src/app/admin/admin-members-table.tsx @@ -12,6 +12,7 @@ type Member = { role: string; pseudo_locked: boolean; avatar_url: string | null; + email: string; }; function MemberRow({ member, isSelf }: { member: Member; isSelf: boolean }) { @@ -78,14 +79,17 @@ function MemberRow({ member, isSelf }: { member: Member; isSelf: boolean }) { return (
  • -
    - - setPseudo(event.target.value)} - className="w-full flex-1 rounded-md border border-gold/30 bg-white/50 px-2 py-1 text-sm text-text-marble outline-none focus:border-gold sm:w-auto" - /> +
    +
    + + setPseudo(event.target.value)} + className="w-full flex-1 rounded-md border border-gold/30 bg-white/50 px-2 py-1 text-sm text-text-marble outline-none focus:border-gold sm:w-auto" + /> +
    +

    {member.email}

    diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index e22243d..b00c71e 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -25,10 +25,7 @@ export default async function AdminPage() { redirect("/leaderboard"); } - const { data: members } = await supabase - .from("profiles") - .select("id, pseudo, role, pseudo_locked, avatar_url") - .order("pseudo", { ascending: true }); + const { data: members } = await supabase.rpc("admin_list_members"); return (
    diff --git a/src/app/char/questions/page.tsx b/src/app/char/questions/page.tsx index 01cf34b..d5999a9 100644 --- a/src/app/char/questions/page.tsx +++ b/src/app/char/questions/page.tsx @@ -10,6 +10,13 @@ export type QuestionRow = { created_at: string; }; +export type SubmissionRow = { + user_id: string; + text: string; + updated_at: string; + profiles: { pseudo: string; avatar_url: string | null } | null; +}; + export default async function CharQuestionsPage() { const supabase = await createClient(); const { @@ -45,6 +52,12 @@ export default async function CharQuestionsPage() { .eq("id", true) .single(); + const { data: submissions } = await supabase + .from("chariot_submissions") + .select("user_id, text, updated_at, profiles(pseudo, avatar_url)") + .order("updated_at", { ascending: true }) + .returns(); + return (
    @@ -62,6 +75,7 @@ export default async function CharQuestionsPage() {
    ); diff --git a/src/app/char/questions/questions-view.tsx b/src/app/char/questions/questions-view.tsx index 9fcf671..05ab7f4 100644 --- a/src/app/char/questions/questions-view.tsx +++ b/src/app/char/questions/questions-view.tsx @@ -1,9 +1,10 @@ "use client"; import { useCallback, useEffect, useState } from "react"; +import { Avatar } from "@/components/avatar"; import { IconChevronDown, IconPencil, IconPlus, IconTrash } from "@/components/icons"; import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client"; -import type { QuestionRow } from "./page"; +import type { QuestionRow, SubmissionRow } from "./page"; function QuestionForm({ initial, @@ -184,29 +185,91 @@ function QuestionRowItem({ ); } +function SubmissionRowItem({ + submission, + onAddToBank, + onReject, +}: { + submission: SubmissionRow; + onAddToBank: () => void; + onReject: () => void; +}) { + const [confirmingReject, setConfirmingReject] = useState(false); + const profile = submission.profiles; + const pseudo = profile?.pseudo ?? "un Citoyen"; + + return ( +
  • + +
    +

    {pseudo}

    +

    {submission.text}

    +
    +
    + + {confirmingReject ? ( + + ) : ( + + )} +
    +
  • + ); +} + export function QuestionsView({ initialQuestions, initialRevealedId, + initialSubmissions, }: { initialQuestions: QuestionRow[]; initialRevealedId: string | null; + initialSubmissions: SubmissionRow[]; }) { const [questions, setQuestions] = useState(initialQuestions); const [revealedId, setRevealedId] = useState(initialRevealedId); + const [submissions, setSubmissions] = useState(initialSubmissions); const [adding, setAdding] = useState(false); const refetch = useCallback(async () => { const supabase = createClient(); - const [{ data: q }, { data: s }] = await Promise.all([ + const [{ data: q }, { data: s }, { data: sub }] = await Promise.all([ supabase .from("chariot_questions") .select("id, text, position, created_at") .order("position", { ascending: true }) .order("created_at", { ascending: true }), supabase.from("settings").select("chariot_revealed_question_id").eq("id", true).single(), + supabase + .from("chariot_submissions") + .select("user_id, text, updated_at, profiles(pseudo, avatar_url)") + .order("updated_at", { ascending: true }) + .returns(), ]); if (q) setQuestions(q); if (s) setRevealedId(s.chariot_revealed_question_id); + if (sub) setSubmissions(sub); }, []); useEffect(() => { @@ -220,6 +283,7 @@ export function QuestionsView({ .channel("char-questions-changes") .on("postgres_changes", { event: "*", schema: "public", table: "chariot_questions" }, refetch) .on("postgres_changes", { event: "*", schema: "public", table: "settings" }, refetch) + .on("postgres_changes", { event: "*", schema: "public", table: "chariot_submissions" }, refetch) .subscribe(); }); @@ -250,46 +314,80 @@ export function QuestionsView({ refetch(); } - return ( -
    - {questions.length === 0 && !adding && ( -

    Aucune question dans la banque.

    - )} -
      - {questions.map((question, index) => ( - moveQuestion(question, direction)} - onToggleReveal={() => toggleReveal(question)} - onChanged={refetch} - /> - ))} -
    + async function addSubmissionToBank(submission: SubmissionRow) { + const supabase = createClient(); + const nextPosition = (questions.at(-1)?.position ?? 0) + 1; + await supabase.from("chariot_questions").insert({ text: submission.text, position: nextPosition }); + await supabase.from("chariot_submissions").delete().eq("user_id", submission.user_id); + refetch(); + } -
    - {adding ? ( - { - setAdding(false); - refetch(); - }} - /> - ) : ( - + async function rejectSubmission(userId: string) { + const supabase = createClient(); + await supabase.from("chariot_submissions").delete().eq("user_id", userId); + refetch(); + } + + return ( +
    + {submissions.length > 0 && ( +
    +

    + Suggestions des Citoyens +

    +
      + {submissions.map((submission) => ( + addSubmissionToBank(submission)} + onReject={() => rejectSubmission(submission.user_id)} + /> + ))} +
    +
    + )} + +
    + {questions.length === 0 && !adding && ( +

    Aucune question dans la banque.

    )} +
      + {questions.map((question, index) => ( + moveQuestion(question, direction)} + onToggleReveal={() => toggleReveal(question)} + onChanged={refetch} + /> + ))} +
    + +
    + {adding ? ( + { + setAdding(false); + refetch(); + }} + /> + ) : ( + + )} +
    ); diff --git a/src/app/profile/chariot-submission-form.tsx b/src/app/profile/chariot-submission-form.tsx new file mode 100644 index 0000000..6a508da --- /dev/null +++ b/src/app/profile/chariot-submission-form.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { createClient } from "@/lib/supabase/client"; + +const MAX_LENGTH = 300; + +export function ChariotSubmissionForm({ + initialText, + frozen, +}: { + initialText: string; + frozen: boolean; +}) { + const [text, setText] = useState(initialText); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + if (frozen) { + return ( +
    +

    + {initialText || "Tu n'as proposé aucune question."} +

    +

    Les propositions sont closes, l'Agora a commencé.

    +
    + ); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setError(null); + setSuccess(false); + + const trimmed = text.trim(); + if (!trimmed) { + setError("Écris une question avant d'enregistrer."); + return; + } + + setSaving(true); + const supabase = createClient(); + const { error: rpcError } = await supabase.rpc("submit_chariot_question", { p_text: trimmed }); + setSaving(false); + + if (rpcError) { + setError("Une erreur est survenue, réessaie."); + return; + } + setSuccess(true); + } + + return ( +
    +