Ajoute les suggestions de questions des Citoyens et les emails sur Admin
Build and deploy / deploy (push) Successful in 35s
Build and deploy / deploy (push) Successful in 35s
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.
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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 (
|
||||
<li className="marble-surface flex flex-col gap-2 rounded-lg border border-gold/25 px-4 py-3 shadow-sm sm:flex-row sm:items-center sm:gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar pseudo={member.pseudo} avatarUrl={member.avatar_url} size="sm" />
|
||||
<input
|
||||
type="text"
|
||||
value={pseudo}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar pseudo={member.pseudo} avatarUrl={member.avatar_url} size="sm" />
|
||||
<input
|
||||
type="text"
|
||||
value={pseudo}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
</div>
|
||||
<p className="truncate pl-10 text-xs text-text-mut">{member.email}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto w-full max-w-3xl px-4 py-8">
|
||||
|
||||
@@ -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<SubmissionRow[]>();
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl px-4 py-8">
|
||||
<div className="mb-6 text-center">
|
||||
@@ -62,6 +75,7 @@ export default async function CharQuestionsPage() {
|
||||
<QuestionsView
|
||||
initialQuestions={questions ?? []}
|
||||
initialRevealedId={settings?.chariot_revealed_question_id ?? null}
|
||||
initialSubmissions={submissions ?? []}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<li className="flex items-start gap-2 rounded-md px-2 py-1.5 text-sm">
|
||||
<Avatar pseudo={pseudo} avatarUrl={profile?.avatar_url ?? null} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-text-mut">{pseudo}</p>
|
||||
<p className="text-text-marble">{submission.text}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddToBank}
|
||||
className="rounded-md border border-gold/40 px-2 py-1 text-xs font-medium text-text-marble hover:bg-gold/10"
|
||||
>
|
||||
Ajouter à la banque
|
||||
</button>
|
||||
{confirmingReject ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReject}
|
||||
className="rounded-md bg-oxblood px-2 py-1 text-xs text-marble"
|
||||
>
|
||||
Confirmer ?
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingReject(true)}
|
||||
onBlur={() => setConfirmingReject(false)}
|
||||
aria-label="Rejeter"
|
||||
title="Rejeter"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-oxblood/70 hover:bg-oxblood/10 hover:text-oxblood"
|
||||
>
|
||||
<IconTrash className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
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<SubmissionRow[]>(),
|
||||
]);
|
||||
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 (
|
||||
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
|
||||
{questions.length === 0 && !adding && (
|
||||
<p className="mb-2 text-sm text-text-mut">Aucune question dans la banque.</p>
|
||||
)}
|
||||
<ul className="flex flex-col divide-y divide-gold/10">
|
||||
{questions.map((question, index) => (
|
||||
<QuestionRowItem
|
||||
key={question.id}
|
||||
question={question}
|
||||
orderNumber={index + 1}
|
||||
isFirst={index === 0}
|
||||
isLast={index === questions.length - 1}
|
||||
isRevealed={revealedId === question.id}
|
||||
onMove={(direction) => moveQuestion(question, direction)}
|
||||
onToggleReveal={() => toggleReveal(question)}
|
||||
onChanged={refetch}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
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();
|
||||
}
|
||||
|
||||
<div className="mt-3">
|
||||
{adding ? (
|
||||
<QuestionForm
|
||||
nextPosition={(questions.at(-1)?.position ?? 0) + 1}
|
||||
onDone={() => {
|
||||
setAdding(false);
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdding(true)}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-text-mut hover:text-text-marble"
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
Ajouter une question
|
||||
</button>
|
||||
async function rejectSubmission(userId: string) {
|
||||
const supabase = createClient();
|
||||
await supabase.from("chariot_submissions").delete().eq("user_id", userId);
|
||||
refetch();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{submissions.length > 0 && (
|
||||
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
|
||||
<p className="mb-2 font-heading text-sm tracking-wide text-text-mut uppercase">
|
||||
Suggestions des Citoyens
|
||||
</p>
|
||||
<ul className="flex flex-col divide-y divide-gold/10">
|
||||
{submissions.map((submission) => (
|
||||
<SubmissionRowItem
|
||||
key={submission.user_id}
|
||||
submission={submission}
|
||||
onAddToBank={() => addSubmissionToBank(submission)}
|
||||
onReject={() => rejectSubmission(submission.user_id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
|
||||
{questions.length === 0 && !adding && (
|
||||
<p className="mb-2 text-sm text-text-mut">Aucune question dans la banque.</p>
|
||||
)}
|
||||
<ul className="flex flex-col divide-y divide-gold/10">
|
||||
{questions.map((question, index) => (
|
||||
<QuestionRowItem
|
||||
key={question.id}
|
||||
question={question}
|
||||
orderNumber={index + 1}
|
||||
isFirst={index === 0}
|
||||
isLast={index === questions.length - 1}
|
||||
isRevealed={revealedId === question.id}
|
||||
onMove={(direction) => moveQuestion(question, direction)}
|
||||
onToggleReveal={() => toggleReveal(question)}
|
||||
onChanged={refetch}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-3">
|
||||
{adding ? (
|
||||
<QuestionForm
|
||||
nextPosition={(questions.at(-1)?.position ?? 0) + 1}
|
||||
onDone={() => {
|
||||
setAdding(false);
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdding(true)}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-text-mut hover:text-text-marble"
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
Ajouter une question
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
if (frozen) {
|
||||
return (
|
||||
<div>
|
||||
<p className="rounded-md border border-gold/20 bg-white/40 px-3 py-2 text-sm text-text-marble">
|
||||
{initialText || "Tu n'as proposé aucune question."}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-text-mut">Les propositions sont closes, l'Agora a commencé.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(event) => {
|
||||
setText(event.target.value);
|
||||
setSuccess(false);
|
||||
}}
|
||||
maxLength={MAX_LENGTH}
|
||||
rows={3}
|
||||
placeholder="Écrire une question…"
|
||||
className="rounded-md border border-gold/30 bg-white/50 px-3 py-2 text-sm text-text-marble outline-none focus:border-gold"
|
||||
/>
|
||||
<p className="text-right text-[0.65rem] text-text-mut">
|
||||
{text.length}/{MAX_LENGTH}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-oxblood">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{success && <p className="text-sm text-olive">Question enregistrée.</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Enregistrement…" : "Enregistrer"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { ProfileForm } from "./profile-form";
|
||||
import { ChariotSubmissionForm } from "./chariot-submission-form";
|
||||
|
||||
export default async function ProfilePage() {
|
||||
const supabase = await createClient();
|
||||
@@ -20,6 +21,17 @@ export default async function ProfilePage() {
|
||||
|
||||
const isJudge = profile?.role === "judge";
|
||||
|
||||
let submissionText = "";
|
||||
let frozen = false;
|
||||
if (!isJudge) {
|
||||
const [{ data: submission }, { data: settings }] = await Promise.all([
|
||||
supabase.from("chariot_submissions").select("text").eq("user_id", user.id).maybeSingle(),
|
||||
supabase.from("settings").select("tribunal_date").eq("id", true).single(),
|
||||
]);
|
||||
submissionText = submission?.text ?? "";
|
||||
frozen = Boolean(settings?.tribunal_date) && new Date(settings!.tribunal_date!) <= new Date();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-sm px-4 py-8">
|
||||
<div className="mb-6 text-center">
|
||||
@@ -44,6 +56,15 @@ export default async function ProfilePage() {
|
||||
initialPseudoLocked={profile?.pseudo_locked ?? false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isJudge && (
|
||||
<div className="marble-surface mt-4 rounded-2xl border border-gold/40 p-6 shadow-xl sm:p-8">
|
||||
<h2 className="mb-3 font-heading text-sm tracking-wide text-text-mut uppercase">
|
||||
Ma question pour Le Char
|
||||
</h2>
|
||||
<ChariotSubmissionForm initialText={submissionText} frozen={frozen} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -861,3 +861,127 @@ exception
|
||||
when others then
|
||||
raise notice 'pg_cron indisponible : active l''extension via le Dashboard Supabase (Database → Extensions → pg_cron), puis ré-exécute ce script pour planifier le reroll automatique du Gardien.';
|
||||
end $$;
|
||||
|
||||
-- 13. Suggestions de questions par les Citoyens + emails sur Admin
|
||||
-- -----------------------------------------------------------------
|
||||
|
||||
-- Une proposition par Citoyen (une ligne par user_id, comme icarus_scores).
|
||||
-- Verrouillée en écriture comme points_log/icarus_scores : seule la RPC
|
||||
-- submit_chariot_question() (SECURITY DEFINER) peut insert/update ; les
|
||||
-- juges gardent un accès direct en lecture (modération) et en suppression
|
||||
-- (rejet), sans passer par une RPC pour ce cas simple.
|
||||
create table if not exists public.chariot_submissions (
|
||||
user_id uuid primary key references public.profiles (id) on delete cascade,
|
||||
text text not null,
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table public.chariot_submissions enable row level security;
|
||||
revoke insert, update on public.chariot_submissions from authenticated, anon;
|
||||
grant select, delete on public.chariot_submissions to authenticated;
|
||||
|
||||
drop policy if exists "chariot_submissions readable by owner or judges" on public.chariot_submissions;
|
||||
create policy "chariot_submissions readable by owner or judges"
|
||||
on public.chariot_submissions for select to authenticated
|
||||
using (
|
||||
user_id = auth.uid()
|
||||
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')
|
||||
);
|
||||
|
||||
drop policy if exists "chariot_submissions delete by judges" on public.chariot_submissions;
|
||||
create policy "chariot_submissions delete by judges" on public.chariot_submissions for delete to authenticated
|
||||
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
||||
|
||||
do $$
|
||||
begin
|
||||
alter publication supabase_realtime add table public.chariot_submissions;
|
||||
exception
|
||||
when duplicate_object then null;
|
||||
end $$;
|
||||
|
||||
-- Seul point d'entrée pour proposer/modifier sa question. Réutilise
|
||||
-- settings.tribunal_date comme déclencheur de gel — même philosophie que
|
||||
-- submit_icarus_score() : une fois l'Agora commencée, no-op silencieux
|
||||
-- (retourne la ligne existante sans la modifier) plutôt qu'une erreur.
|
||||
create or replace function public.submit_chariot_question(p_text text)
|
||||
returns public.chariot_submissions
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_caller_role text;
|
||||
v_tribunal_date timestamptz;
|
||||
v_row public.chariot_submissions;
|
||||
begin
|
||||
if auth.uid() is null then
|
||||
raise exception 'authentication required';
|
||||
end if;
|
||||
|
||||
select role into v_caller_role from public.profiles where id = auth.uid();
|
||||
if v_caller_role = 'judge' then
|
||||
raise exception 'only citizens can submit a chariot question';
|
||||
end if;
|
||||
|
||||
if p_text is null or length(trim(p_text)) = 0 then
|
||||
raise exception 'question vide';
|
||||
end if;
|
||||
if length(p_text) > 300 then
|
||||
raise exception 'question trop longue';
|
||||
end if;
|
||||
|
||||
select tribunal_date into v_tribunal_date from public.settings where id = true;
|
||||
|
||||
if v_tribunal_date is not null and now() >= v_tribunal_date then
|
||||
select * into v_row from public.chariot_submissions where user_id = auth.uid();
|
||||
return v_row;
|
||||
end if;
|
||||
|
||||
insert into public.chariot_submissions (user_id, text, updated_at)
|
||||
values (auth.uid(), trim(p_text), now())
|
||||
on conflict (user_id) do update
|
||||
set text = excluded.text,
|
||||
updated_at = now();
|
||||
|
||||
select * into v_row from public.chariot_submissions where user_id = auth.uid();
|
||||
return v_row;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.submit_chariot_question(text) to authenticated;
|
||||
|
||||
-- Émails des membres pour /admin : jamais stockés dans profiles (voir §4 du
|
||||
-- doc — le pseudo est le seul nom montré aux autres), donc invisibles côté
|
||||
-- client sans passer par auth.users. Plutôt que d'exposer la service_role
|
||||
-- key côté client, une RPC SECURITY DEFINER fait la jointure, gardée par la
|
||||
-- même vérification de rôle que les autres RPC juge-only du projet.
|
||||
create or replace function public.admin_list_members()
|
||||
returns table (
|
||||
id uuid,
|
||||
pseudo text,
|
||||
avatar_url text,
|
||||
role text,
|
||||
pseudo_locked boolean,
|
||||
email text
|
||||
)
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
begin
|
||||
-- Colonnes de sortie qualifiées par un alias partout : returns table(...)
|
||||
-- déclare id/role comme variables PL/pgSQL, qui masqueraient sinon les
|
||||
-- colonnes de même nom dans profiles (erreur 42702 "ambiguous").
|
||||
if not exists (select 1 from public.profiles pr where pr.id = auth.uid() and pr.role = 'judge') then
|
||||
raise exception 'only judges can list members';
|
||||
end if;
|
||||
|
||||
return query
|
||||
select p.id, p.pseudo, p.avatar_url, p.role, p.pseudo_locked, u.email::text
|
||||
from public.profiles p
|
||||
join auth.users u on u.id = p.id
|
||||
order by p.pseudo asc;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.admin_list_members() to authenticated;
|
||||
|
||||
Reference in New Issue
Block a user