Ajoute des justifications anonymes aux votes de L'Urne de l'Agora
Build and deploy / deploy (push) Successful in 35s

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 <noreply@anthropic.com>
This commit is contained in:
Valentin ROBIN
2026-08-23 22:48:25 +02:00
parent 0dfd3e7e90
commit e8a9a0ddb9
3 changed files with 199 additions and 38 deletions
+4 -3
View File
@@ -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). - `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). - `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). - `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) ### `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) ### `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. - `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é, identique pour tout le monde. - 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.
--- ---
+144 -18
View File
@@ -2,36 +2,145 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Avatar } from "@/components/avatar"; import { Avatar } from "@/components/avatar";
import { IconClose } from "@/components/icons";
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client"; import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
import type { CitizenRow, MyVoteRow } from "./page"; import type { CitizenRow, MyVoteRow } from "./page";
function CitizenVoteRow({ citizen, onVote }: { citizen: CitizenRow; onVote: () => void }) { type VoteReason = { reason: string; created_at: string };
const [confirming, setConfirming] = useState(false);
function CitizenVoteRow({
citizen,
onVote,
}: {
citizen: CitizenRow;
onVote: (reason: string) => void;
}) {
const [picking, setPicking] = useState(false);
const [reason, setReason] = useState("");
if (!picking) {
return ( return (
<li className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"> <li className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm">
<Avatar pseudo={citizen.pseudo} avatarUrl={citizen.avatar_url} size="sm" /> <Avatar pseudo={citizen.pseudo} avatarUrl={citizen.avatar_url} size="sm" />
<span className="min-w-0 flex-1 truncate text-text-marble">{citizen.pseudo}</span> <span className="min-w-0 flex-1 truncate text-text-marble">{citizen.pseudo}</span>
{confirming ? (
<button <button
type="button" type="button"
onClick={onVote} onClick={() => setPicking(true)}
className="shrink-0 rounded-md bg-oxblood px-3 py-1.5 text-xs font-medium text-marble"
>
Confirmer ?
</button>
) : (
<button
type="button"
onClick={() => setConfirming(true)}
onBlur={() => setConfirming(false)}
className="shrink-0 rounded-md border border-gold/40 px-3 py-1.5 text-xs font-medium text-text-marble hover:bg-gold/10" className="shrink-0 rounded-md border border-gold/40 px-3 py-1.5 text-xs font-medium text-text-marble hover:bg-gold/10"
> >
Déposer un jeton Déposer un jeton
</button> </button>
)}
</li> </li>
); );
}
return (
<li className="flex flex-col gap-2 rounded-md bg-gold/5 px-2 py-2 text-sm">
<div className="flex items-center gap-2">
<Avatar pseudo={citizen.pseudo} avatarUrl={citizen.avatar_url} size="sm" />
<span className="min-w-0 flex-1 truncate text-text-marble">{citizen.pseudo}</span>
</div>
<input
type="text"
value={reason}
onChange={(event) => 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"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setPicking(false);
setReason("");
}}
className="rounded-md px-3 py-1.5 text-xs text-text-mut hover:bg-gold/10"
>
Annuler
</button>
<button
type="button"
onClick={() => onVote(reason.trim())}
className="rounded-md bg-oxblood px-3 py-1.5 text-xs font-medium text-marble"
>
Confirmer ?
</button>
</div>
</li>
);
}
function ReasonsModal({ citizen, onClose }: { citizen: CitizenRow; onClose: () => void }) {
const [reasons, setReasons] = useState<VoteReason[] | null>(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 (
<div
className="fixed inset-0 z-30 flex items-center justify-center bg-ink/80 p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
className="marble-surface flex max-h-[80vh] w-full max-w-sm flex-col overflow-hidden rounded-2xl border border-gold/40 shadow-xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-2 border-b border-gold/20 px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<Avatar pseudo={citizen.pseudo} avatarUrl={citizen.avatar_url} size="sm" />
<h2 className="truncate font-heading text-sm tracking-wide text-text-marble">
Pourquoi {citizen.pseudo} ?
</h2>
</div>
<button
type="button"
onClick={onClose}
className="shrink-0 rounded-md p-1 text-text-mut transition hover:bg-gold/10 hover:text-text-marble"
aria-label="Fermer"
>
<IconClose className="h-5 w-5" />
</button>
</div>
<div className="overflow-y-auto px-4 py-4">
{reasons === null && <p className="text-sm text-text-mut">Chargement</p>}
{reasons !== null && reasons.length === 0 && (
<p className="text-sm text-text-mut">
Personne n&apos;a laissé de justification pour l&apos;instant voter sans motif reste
possible.
</p>
)}
{reasons !== null && reasons.length > 0 && (
<ul className="flex flex-col gap-2">
{reasons.map((r) => (
<li
key={r.created_at}
className="rounded-lg border border-gold/25 px-3 py-2 font-serif text-sm text-text-marble italic"
>
« {r.reason} »
</li>
))}
</ul>
)}
<p className="mt-4 text-center text-xs text-text-mut">
Anonyme même les Archontes ne savent pas qui a écrit quoi.
</p>
</div>
</div>
</div>
);
} }
export function UrneView({ export function UrneView({
@@ -50,6 +159,7 @@ export function UrneView({
const [voteCounts, setVoteCounts] = useState(initialVoteCounts); const [voteCounts, setVoteCounts] = useState(initialVoteCounts);
const [myVoteToday, setMyVoteToday] = useState(initialMyVoteToday); const [myVoteToday, setMyVoteToday] = useState(initialMyVoteToday);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedTarget, setSelectedTarget] = useState<CitizenRow | null>(null);
const refetch = useCallback(async () => { const refetch = useCallback(async () => {
const supabase = createClient(); const supabase = createClient();
@@ -97,10 +207,13 @@ export function UrneView({
}; };
}, [refetch]); }, [refetch]);
async function handleVote(targetId: string) { async function handleVote(targetId: string, reason: string) {
setError(null); setError(null);
const supabase = createClient(); 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) { if (rpcError) {
setError( setError(
@@ -126,11 +239,17 @@ export function UrneView({
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-lg"> <div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-lg">
<p className="mb-2 text-center font-heading text-sm tracking-wide text-text-mut uppercase">Classement</p> <p className="mb-2 text-center font-heading text-sm tracking-wide text-text-mut uppercase">Classement</p>
<p className="mb-3 text-center text-xs text-text-mut">Touche un nom pour voir pourquoi on a voté pour lui.</p>
<ul className="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3"> <ul className="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3">
{sortedCitizens.map((citizen) => { {sortedCitizens.map((citizen) => {
const count = voteCounts[citizen.id] ?? 0; const count = voteCounts[citizen.id] ?? 0;
return ( return (
<li key={citizen.id} className="flex min-w-0 items-center gap-1.5 text-xs"> <li key={citizen.id} className="min-w-0">
<button
type="button"
onClick={() => setSelectedTarget(citizen)}
className="flex w-full min-w-0 items-center gap-1.5 rounded-md py-0.5 text-left text-xs transition hover:bg-gold/10"
>
<Avatar pseudo={citizen.pseudo} avatarUrl={citizen.avatar_url} size="xs" /> <Avatar pseudo={citizen.pseudo} avatarUrl={citizen.avatar_url} size="xs" />
<span className={`truncate ${count === 0 ? "text-text-mut/60" : "text-text-marble"}`}> <span className={`truncate ${count === 0 ? "text-text-mut/60" : "text-text-marble"}`}>
{citizen.pseudo} {citizen.pseudo}
@@ -138,6 +257,7 @@ export function UrneView({
<span className="ml-auto shrink-0 font-heading text-gold-bright"> <span className="ml-auto shrink-0 font-heading text-gold-bright">
{count > 0 ? `×${count}` : "—"} {count > 0 ? `×${count}` : "—"}
</span> </span>
</button>
</li> </li>
); );
})} })}
@@ -164,13 +284,19 @@ export function UrneView({
)} )}
<ul className="flex flex-col divide-y divide-gold/10"> <ul className="flex flex-col divide-y divide-gold/10">
{otherCitizens.map((citizen) => ( {otherCitizens.map((citizen) => (
<CitizenVoteRow key={citizen.id} citizen={citizen} onVote={() => handleVote(citizen.id)} /> <CitizenVoteRow
key={citizen.id}
citizen={citizen}
onVote={(reason) => handleVote(citizen.id, reason)}
/>
))} ))}
</ul> </ul>
</> </>
)} )}
</div> </div>
)} )}
{selectedTarget && <ReasonsModal citizen={selectedTarget} onClose={() => setSelectedTarget(null)} />}
</div> </div>
); );
} }
+40 -6
View File
@@ -1148,7 +1148,8 @@ grant execute on function public.post_wall_note(text) to authenticated;
-- compris : contrairement à chariot_submissions/wall_notes, les Archontes -- 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 -- 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 -- 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 ( create table if not exists public.urn_votes (
id bigint generated always as identity primary key, 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() 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; alter table public.urn_votes enable row level security;
revoke insert, update, delete on public.urn_votes from authenticated, anon; revoke insert, update, delete on public.urn_votes from authenticated, anon;
grant select on public.urn_votes to authenticated; grant select on public.urn_votes to authenticated;
@@ -1194,9 +1200,32 @@ $$;
grant execute on function public.urn_vote_counts() to authenticated; 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 -- Seul point d'entrée pour voter. Un vote par Citoyen par jour, définitif
-- (aucune RPC de modification/suppression). -- (aucune RPC de modification/suppression). La signature change (ajout de
create or replace function public.cast_urn_vote(p_target_id uuid) -- 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 returns public.urn_votes
language plpgsql language plpgsql
security definer security definer
@@ -1207,6 +1236,7 @@ declare
v_target_role text; v_target_role text;
v_today date := (now() at time zone 'Europe/Paris')::date; v_today date := (now() at time zone 'Europe/Paris')::date;
v_already_voted boolean; v_already_voted boolean;
v_reason text := nullif(trim(coalesce(p_reason, '')), '');
v_row public.urn_votes; v_row public.urn_votes;
begin begin
if auth.uid() is null then if auth.uid() is null then
@@ -1230,6 +1260,10 @@ begin
raise exception 'cannot vote for a judge'; raise exception 'cannot vote for a judge';
end if; end if;
if v_reason is not null and length(v_reason) > 200 then
raise exception 'justification trop longue';
end if;
select exists ( select exists (
select 1 from public.urn_votes select 1 from public.urn_votes
where voter_id = auth.uid() where voter_id = auth.uid()
@@ -1239,12 +1273,12 @@ begin
raise exception 'already voted today'; raise exception 'already voted today';
end if; end if;
insert into public.urn_votes (voter_id, target_id) insert into public.urn_votes (voter_id, target_id, reason)
values (auth.uid(), p_target_id) values (auth.uid(), p_target_id, v_reason)
returning * into v_row; returning * into v_row;
return v_row; return v_row;
end; end;
$$; $$;
grant execute on function public.cast_urn_vote(uuid) to authenticated; grant execute on function public.cast_urn_vote(uuid, text) to authenticated;