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 (
-
);
}
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;