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;