From e8a9a0ddb9eab52e70bcc9891abf4992615cfa9d Mon Sep 17 00:00:00 2001 From: Valentin ROBIN Date: Sun, 23 Aug 2026 22:48:25 +0200 Subject: [PATCH] Ajoute des justifications anonymes aux votes de L'Urne de l'Agora MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un Citoyen peut désormais laisser un motif facultatif en déposant son jeton (toujours deux étapes avant l'envoi). Le classement permet de consulter les justifications reçues par une personne via une nouvelle RPC urn_vote_reasons() — même principe que wall_notes_today : le contenu est public, l'auteur ne l'est jamais, ce qui préserve la garantie de L'Urne que personne (Archontes compris) ne voit qui a voté pour qui. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 7 +- src/app/urne/urne-view.tsx | 184 +++++++++++++++++++++++++++++++------ supabase/schema.sql | 46 ++++++++-- 3 files changed, 199 insertions(+), 38 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2b6d28e..f077d9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,7 +108,8 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè - `wall_notes_today()` — authenticated, `language sql`, renvoie les notes du jour (heure de Paris) **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). - `post_wall_note(p_text)` — authenticated, rejette les juges (seuls les Citoyens publient), 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 10 notes (sinon `raise exception` dans les deux cas — pas de no-op silencieux ici, l'UI doit remonter l'erreur). - `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). -- `cast_urn_vote(p_target_id)` — 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. +- `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. +- `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) @@ -118,8 +119,8 @@ Ce pattern (RLS pour l'accès à la ligne + trigger `BEFORE UPDATE` pour l'accè ### `public.urn_votes` (L'Urne de l'Agora, V10) -- `id, voter_id, target_id, created_at` — journal append-only (comme `points_log`/`chariot_entries`) d'un vote par ligne, jamais un upsert : les jetons s'accumulent d'un jour sur l'autre, contrairement à `wall_notes` qui ne montre que le jour courant. -- RLS `select` : **pas d'exception juge** (contrairement à `chariot_submissions`/`wall_notes`) — le votant ne voit que sa propre ligne (pour savoir « j'ai déjà voté aujourd'hui »), personne d'autre, Archontes compris, ne peut voir qui a voté pour qui ; seul `urn_vote_counts()` expose un total agrégé, identique pour tout le monde. +- `id, voter_id, target_id, reason, created_at` — journal append-only (comme `points_log`/`chariot_entries`) d'un vote par ligne, jamais un upsert : les jetons s'accumulent d'un jour sur l'autre, contrairement à `wall_notes` qui ne montre que le jour courant. `reason` est une justification facultative laissée par le votant. +- RLS `select` : **pas d'exception juge** (contrairement à `chariot_submissions`/`wall_notes`) — le votant ne voit que sa propre ligne (pour savoir « j'ai déjà voté aujourd'hui »), personne d'autre, Archontes compris, ne peut voir qui a voté pour qui ; seul `urn_vote_counts()` expose un total agrégé, et `urn_vote_reasons()` le contenu des justifications sans leur auteur, identiques pour tout le monde. --- diff --git a/src/app/urne/urne-view.tsx b/src/app/urne/urne-view.tsx index baf336e..053eb3b 100644 --- a/src/app/urne/urne-view.tsx +++ b/src/app/urne/urne-view.tsx @@ -2,38 +2,147 @@ import { useCallback, useEffect, useState } from "react"; import { Avatar } from "@/components/avatar"; +import { IconClose } from "@/components/icons"; import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client"; import type { CitizenRow, MyVoteRow } from "./page"; -function CitizenVoteRow({ citizen, onVote }: { citizen: CitizenRow; onVote: () => void }) { - const [confirming, setConfirming] = useState(false); +type VoteReason = { reason: string; created_at: string }; - return ( -
  • - - {citizen.pseudo} - {confirming ? ( +function CitizenVoteRow({ + citizen, + onVote, +}: { + citizen: CitizenRow; + onVote: (reason: string) => void; +}) { + const [picking, setPicking] = useState(false); + const [reason, setReason] = useState(""); + + if (!picking) { + return ( +
  • + + {citizen.pseudo} - ) : ( - - )} +
  • + ); + } + + return ( +
  • +
    + + {citizen.pseudo} +
    + setReason(event.target.value)} + maxLength={200} + placeholder="Pourquoi ? (optionnel)" + className="rounded-md border border-gold/30 bg-white/50 px-2 py-1.5 text-xs text-text-marble outline-none focus:border-gold" + /> +
    + + +
  • ); } +function ReasonsModal({ citizen, onClose }: { citizen: CitizenRow; onClose: () => void }) { + const [reasons, setReasons] = useState(null); + + useEffect(() => { + let cancelled = false; + const supabase = createClient(); + supabase.rpc("urn_vote_reasons", { p_target_id: citizen.id }).then(({ data }) => { + if (!cancelled) setReasons(data ?? []); + }); + return () => { + cancelled = true; + }; + }, [citizen.id]); + + return ( +
    +
    event.stopPropagation()} + > +
    +
    + +

    + Pourquoi {citizen.pseudo} ? +

    +
    + +
    + +
    + {reasons === null &&

    Chargement…

    } + + {reasons !== null && reasons.length === 0 && ( +

    + Personne n'a laissé de justification pour l'instant — voter sans motif reste + possible. +

    + )} + + {reasons !== null && reasons.length > 0 && ( +
      + {reasons.map((r) => ( +
    • + « {r.reason} » +
    • + ))} +
    + )} + +

    + Anonyme — même les Archontes ne savent pas qui a écrit quoi. +

    +
    +
    +
    + ); +} + export function UrneView({ isJudge, currentUserId, @@ -50,6 +159,7 @@ export function UrneView({ const [voteCounts, setVoteCounts] = useState(initialVoteCounts); const [myVoteToday, setMyVoteToday] = useState(initialMyVoteToday); const [error, setError] = useState(null); + const [selectedTarget, setSelectedTarget] = useState(null); const refetch = useCallback(async () => { const supabase = createClient(); @@ -97,10 +207,13 @@ export function UrneView({ }; }, [refetch]); - async function handleVote(targetId: string) { + async function handleVote(targetId: string, reason: string) { setError(null); const supabase = createClient(); - const { error: rpcError } = await supabase.rpc("cast_urn_vote", { p_target_id: targetId }); + const { error: rpcError } = await supabase.rpc("cast_urn_vote", { + p_target_id: targetId, + p_reason: reason || null, + }); if (rpcError) { setError( @@ -126,18 +239,25 @@ export function UrneView({

    Classement

    +

    Touche un nom pour voir pourquoi on a voté pour lui.

      {sortedCitizens.map((citizen) => { const count = voteCounts[citizen.id] ?? 0; return ( -
    • - - - {citizen.pseudo} - - - {count > 0 ? `×${count}` : "—"} - +
    • +
    • ); })} @@ -164,13 +284,19 @@ export function UrneView({ )}
        {otherCitizens.map((citizen) => ( - handleVote(citizen.id)} /> + handleVote(citizen.id, reason)} + /> ))}
      )}
    )} + + {selectedTarget && setSelectedTarget(null)} />}
    ); } diff --git a/supabase/schema.sql b/supabase/schema.sql index 6cdf157..a970784 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -1148,7 +1148,8 @@ grant execute on function public.post_wall_note(text) to authenticated; -- compris : contrairement à chariot_submissions/wall_notes, les Archontes -- n'ont ici AUCUNE vision privilégiée sur qui a voté pour qui, seul le total -- par personne est public. Les Archontes ne peuvent ni voter ni recevoir de --- jetons. +-- jetons. Chaque vote peut porter une justification facultative, exposée de +-- façon anonyme (urn_vote_reasons) : le contenu est public, jamais l'auteur. create table if not exists public.urn_votes ( id bigint generated always as identity primary key, @@ -1157,6 +1158,11 @@ create table if not exists public.urn_votes ( created_at timestamptz not null default now() ); +-- Justification facultative laissée par le votant. Exposée ensuite de façon +-- anonyme via urn_vote_reasons() (voir plus bas) : le contenu est public, +-- jamais son auteur — cohérent avec le reste de la table. +alter table public.urn_votes add column if not exists reason text; + alter table public.urn_votes enable row level security; revoke insert, update, delete on public.urn_votes from authenticated, anon; grant select on public.urn_votes to authenticated; @@ -1194,9 +1200,32 @@ $$; grant execute on function public.urn_vote_counts() to authenticated; +-- Permet de comprendre pourquoi les gens ont voté pour quelqu'un sans jamais +-- révéler qui a voté quoi (même principe que wall_notes_today : le contenu +-- est exposé, l'identité du votant jamais) — visible par tous depuis le +-- classement, Archontes compris (même vision que pour urn_vote_counts). +create or replace function public.urn_vote_reasons(p_target_id uuid) +returns table (reason text, created_at timestamptz) +language sql +security definer +set search_path = public +as $$ + select v.reason, v.created_at + from public.urn_votes v + where v.target_id = p_target_id + and v.reason is not null + order by v.created_at desc; +$$; + +grant execute on function public.urn_vote_reasons(uuid) to authenticated; + -- Seul point d'entrée pour voter. Un vote par Citoyen par jour, définitif --- (aucune RPC de modification/suppression). -create or replace function public.cast_urn_vote(p_target_id uuid) +-- (aucune RPC de modification/suppression). La signature change (ajout de +-- p_reason) : l'ancienne (uuid) est explicitement supprimée, sinon +-- create or replace créerait une 2e surcharge au lieu de remplacer celle-ci. +drop function if exists public.cast_urn_vote(uuid); + +create or replace function public.cast_urn_vote(p_target_id uuid, p_reason text default null) returns public.urn_votes language plpgsql security definer @@ -1207,6 +1236,7 @@ declare v_target_role text; v_today date := (now() at time zone 'Europe/Paris')::date; v_already_voted boolean; + v_reason text := nullif(trim(coalesce(p_reason, '')), ''); v_row public.urn_votes; begin if auth.uid() is null then @@ -1230,6 +1260,10 @@ begin raise exception 'cannot vote for a judge'; end if; + if v_reason is not null and length(v_reason) > 200 then + raise exception 'justification trop longue'; + end if; + select exists ( select 1 from public.urn_votes where voter_id = auth.uid() @@ -1239,12 +1273,12 @@ begin raise exception 'already voted today'; end if; - insert into public.urn_votes (voter_id, target_id) - values (auth.uid(), p_target_id) + insert into public.urn_votes (voter_id, target_id, reason) + values (auth.uid(), p_target_id, v_reason) returning * into v_row; return v_row; end; $$; -grant execute on function public.cast_urn_vote(uuid) to authenticated; +grant execute on function public.cast_urn_vote(uuid, text) to authenticated;