Ajoute des justifications anonymes aux votes de L'Urne de l'Agora
Build and deploy / deploy (push) Successful in 35s
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:
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+155
-29
@@ -2,38 +2,147 @@
|
|||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
return (
|
function CitizenVoteRow({
|
||||||
<li className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm">
|
citizen,
|
||||||
<Avatar pseudo={citizen.pseudo} avatarUrl={citizen.avatar_url} size="sm" />
|
onVote,
|
||||||
<span className="min-w-0 flex-1 truncate text-text-marble">{citizen.pseudo}</span>
|
}: {
|
||||||
{confirming ? (
|
citizen: CitizenRow;
|
||||||
|
onVote: (reason: string) => void;
|
||||||
|
}) {
|
||||||
|
const [picking, setPicking] = useState(false);
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
|
||||||
|
if (!picking) {
|
||||||
|
return (
|
||||||
|
<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" />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-text-marble">{citizen.pseudo}</span>
|
||||||
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</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'a laissé de justification pour l'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({
|
||||||
isJudge,
|
isJudge,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
@@ -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,18 +239,25 @@ 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">
|
||||||
<Avatar pseudo={citizen.pseudo} avatarUrl={citizen.avatar_url} size="xs" />
|
<button
|
||||||
<span className={`truncate ${count === 0 ? "text-text-mut/60" : "text-text-marble"}`}>
|
type="button"
|
||||||
{citizen.pseudo}
|
onClick={() => setSelectedTarget(citizen)}
|
||||||
</span>
|
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"
|
||||||
<span className="ml-auto shrink-0 font-heading text-gold-bright">
|
>
|
||||||
{count > 0 ? `×${count}` : "—"}
|
<Avatar pseudo={citizen.pseudo} avatarUrl={citizen.avatar_url} size="xs" />
|
||||||
</span>
|
<span className={`truncate ${count === 0 ? "text-text-mut/60" : "text-text-marble"}`}>
|
||||||
|
{citizen.pseudo}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto shrink-0 font-heading text-gold-bright">
|
||||||
|
{count > 0 ? `×${count}` : "—"}
|
||||||
|
</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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user