V2 + V3 : rôles, points, journal, podium et recadrage photo
V2 — authentification et administration :
- Authentification par email + mot de passe (email confirmé, un compte
par email), abandon de l'email interne dérivé du pseudo.
- Rôles public/judge sur profiles, section Administration (juges
uniquement, vérifiée côté serveur) pour gérer les membres.
- Verrou de pseudo : modifiable une fois par son propriétaire puis figé,
contournable par un juge.
- RLS étendue par un trigger BEFORE UPDATE (enforce_profile_update) pour
verrouiller les colonnes sensibles (role, points, pseudo_locked, pseudo
figé) — la RLS seule ne peut pas exprimer une règle par colonne.
V3 — points, journal, podium, progression, photo :
- RPC award_points() (SECURITY DEFINER) : seul point d'écriture de la
colonne points, vérifie le rôle juge côté serveur, delta signé sans
plancher à 0, trace chaque opération dans points_log.
- Leaderboard temps réel (Supabase Realtime) avec podium top 3
(égalités gérées), flèches de progression (previous_rank), et
contrôles de points juges avec confirmation explicite (plus de
debounce auto) avant envoi.
- Page /journal ("le crieur") : fil live et public des attributions de
points.
- Recadrage photo carré + compression client (react-easy-crop + canvas)
au signup et sur le profil.
- Passe de polish visuel : cartes/boutons cohérents, lien actif dans la
nav, podium retravaillé.
schema.sql, README.md et CLAUDE.md mis à jour en conséquence (schéma
idempotent, instructions SMTP/rôles/migration, conventions RLS+trigger
documentées pour les futures colonnes sensibles).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+299
-20
@@ -1,51 +1,198 @@
|
||||
-- Le Tribunal — schéma V1 (table profiles + RLS + bucket avatars)
|
||||
-- Le Tribunal — schéma V2 + V3 (auth par email, rôles, verrou de pseudo,
|
||||
-- points/journal/podium/indicateurs de progression)
|
||||
-- À exécuter dans l'éditeur SQL du projet Supabase.
|
||||
|
||||
-- ============================================================
|
||||
-- Migration depuis V1 (authentification pseudo + email interne)
|
||||
-- ============================================================
|
||||
-- V1 utilisait un email fictif dérivé du pseudo (slug@letribunal.test).
|
||||
-- V2 exige un vrai email par utilisateur (unique, confirmé). Les comptes
|
||||
-- créés sous V1 sont donc invalides pour V2. Comme il ne s'agit que de
|
||||
-- données de test, on repart de zéro :
|
||||
--
|
||||
-- 1. Dashboard Supabase → Authentication → Providers → Email
|
||||
-- → activer "Confirm email".
|
||||
-- 2. SQL Editor → exécuter la ligne ci-dessous pour supprimer tous les
|
||||
-- comptes de test (cascade automatiquement sur public.profiles) :
|
||||
--
|
||||
-- delete from auth.users;
|
||||
--
|
||||
-- 3. Exécuter tout le script ci-dessous.
|
||||
-- 4. Storage → bucket "avatars" → vider les fichiers de test existants
|
||||
-- (les anciens noms de fichiers restent valides, ils ne gênent pas,
|
||||
-- mais autant repartir propre).
|
||||
-- ============================================================
|
||||
|
||||
-- 1. Table profiles ----------------------------------------------------
|
||||
|
||||
create table if not exists public.profiles (
|
||||
id uuid primary key references auth.users (id) on delete cascade,
|
||||
pseudo text not null,
|
||||
slug text not null,
|
||||
avatar_url text,
|
||||
points integer not null default 0,
|
||||
role text not null default 'public' check (role in ('public', 'judge')),
|
||||
pseudo_locked boolean not null default false,
|
||||
previous_rank integer,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
-- Unicité du pseudo affiché (insensible à la casse) et du slug technique.
|
||||
-- Colonnes ajoutées si la table existait déjà depuis une version antérieure
|
||||
-- (no-op si déjà présentes).
|
||||
alter table public.profiles add column if not exists role text not null default 'public';
|
||||
alter table public.profiles add column if not exists pseudo_locked boolean not null default false;
|
||||
alter table public.profiles add column if not exists previous_rank integer;
|
||||
alter table public.profiles drop column if exists slug;
|
||||
|
||||
alter table public.profiles drop constraint if exists profiles_role_check;
|
||||
alter table public.profiles add constraint profiles_role_check check (role in ('public', 'judge'));
|
||||
|
||||
-- Unicité du pseudo affiché (insensible à la casse).
|
||||
drop index if exists profiles_slug_key;
|
||||
create unique index if not exists profiles_pseudo_lower_key on public.profiles (lower(pseudo));
|
||||
create unique index if not exists profiles_slug_key on public.profiles (slug);
|
||||
|
||||
alter table public.profiles enable row level security;
|
||||
|
||||
-- Lecture : tout utilisateur authentifié peut voir tous les profils (leaderboard).
|
||||
-- Pas de grant insert : la création du profil passe exclusivement par le
|
||||
-- trigger handle_new_user() (SECURITY DEFINER, contourne la RLS).
|
||||
grant select, update on public.profiles to authenticated;
|
||||
|
||||
-- 2. Fonctions & triggers -------------------------------------------------
|
||||
-- La RLS est au niveau ligne : elle ne peut pas exprimer "cette colonne
|
||||
-- seulement si tel rôle". On verrouille donc les colonnes sensibles
|
||||
-- (role, points, pseudo_locked, pseudo figé) via des triggers, qui ne
|
||||
-- font jamais confiance à ce que le client envoie.
|
||||
|
||||
-- À la création d'un profil : on ignore ce que le client a pu envoyer
|
||||
-- pour role/pseudo_locked/points et on force les valeurs par défaut.
|
||||
-- Le pseudo initial est repris depuis auth.users si non fourni explicitement.
|
||||
create or replace function public.enforce_profile_insert()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
new.role := 'public';
|
||||
new.pseudo_locked := false;
|
||||
new.points := 0;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists profiles_before_insert on public.profiles;
|
||||
create trigger profiles_before_insert
|
||||
before insert on public.profiles
|
||||
for each row execute function public.enforce_profile_insert();
|
||||
|
||||
-- À la mise à jour : selon le rôle de l'appelant (auth.uid()), on
|
||||
-- autorise ou rejette les changements de colonnes sensibles.
|
||||
create or replace function public.enforce_profile_update()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
declare
|
||||
caller_role text;
|
||||
points_bypass boolean := coalesce(current_setting('app.bypass_points_lock', true), 'off') = 'on';
|
||||
begin
|
||||
-- auth.uid() est NULL hors contexte d'une requête utilisateur (SQL Editor,
|
||||
-- migrations, clé service_role). Ces accès sont déjà pleinement fiables
|
||||
-- par construction (accès direct à la base), donc on les laisse passer.
|
||||
if auth.uid() is null then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
-- `points` ne peut être modifié que via la RPC award_points(), jamais
|
||||
-- directement par le client — même un juge ne peut pas l'écrire à la main.
|
||||
if new.points is distinct from old.points and not points_bypass then
|
||||
raise exception 'points must be modified via award_points()';
|
||||
end if;
|
||||
|
||||
select role into caller_role from public.profiles where id = auth.uid();
|
||||
|
||||
if caller_role = 'judge' then
|
||||
-- Un juge peut changer pseudo / pseudo_locked / role / avatar_url /
|
||||
-- previous_rank de n'importe qui.
|
||||
return new;
|
||||
end if;
|
||||
|
||||
-- Appelant non-juge : ne peut modifier que sa propre ligne (déjà imposé
|
||||
-- par la RLS), et seulement pseudo / avatar_url, sous conditions.
|
||||
if new.role is distinct from old.role then
|
||||
raise exception 'only judges can change role';
|
||||
end if;
|
||||
|
||||
if new.previous_rank is distinct from old.previous_rank then
|
||||
raise exception 'only judges can reset the rank reference';
|
||||
end if;
|
||||
|
||||
if new.pseudo_locked is distinct from old.pseudo_locked and new.pseudo_locked = false then
|
||||
raise exception 'only judges can unlock a pseudo';
|
||||
end if;
|
||||
|
||||
if new.pseudo is distinct from old.pseudo then
|
||||
if old.pseudo_locked then
|
||||
raise exception 'pseudo is locked, ask a judge';
|
||||
end if;
|
||||
-- Premier changement de pseudo : on le fige automatiquement.
|
||||
new.pseudo_locked := true;
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists profiles_before_update on public.profiles;
|
||||
create trigger profiles_before_update
|
||||
before update on public.profiles
|
||||
for each row execute function public.enforce_profile_update();
|
||||
|
||||
-- Création automatique du profil à l'inscription (auth.users → profiles),
|
||||
-- indépendamment de l'état de la session (fonctionne même si l'email
|
||||
-- n'est pas encore confirmé, contrairement à un insert fait depuis le client).
|
||||
create or replace function public.handle_new_user()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer set search_path = public
|
||||
as $$
|
||||
begin
|
||||
insert into public.profiles (id, pseudo)
|
||||
values (new.id, coalesce(new.raw_user_meta_data ->> 'pseudo', 'Nouveau membre'));
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists on_auth_user_created on auth.users;
|
||||
create trigger on_auth_user_created
|
||||
after insert on auth.users
|
||||
for each row execute function public.handle_new_user();
|
||||
|
||||
-- 3. Policies RLS ----------------------------------------------------------
|
||||
|
||||
drop policy if exists "Profiles are viewable by authenticated users" on public.profiles;
|
||||
create policy "Profiles are viewable by authenticated users"
|
||||
on public.profiles for select
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
-- Création : un utilisateur ne peut créer que sa propre ligne.
|
||||
create policy "Users can insert their own profile"
|
||||
on public.profiles for insert
|
||||
to authenticated
|
||||
with check (auth.uid() = id);
|
||||
-- Plus de policy INSERT côté client : la création du profil passe
|
||||
-- exclusivement par le trigger handle_new_user() (SECURITY DEFINER).
|
||||
drop policy if exists "Users can insert their own profile" on public.profiles;
|
||||
|
||||
-- Modification : un utilisateur ne peut modifier que sa propre ligne.
|
||||
drop policy if exists "Users can update their own profile" on public.profiles;
|
||||
create policy "Users can update their own profile"
|
||||
on public.profiles for update
|
||||
to authenticated
|
||||
using (auth.uid() = id)
|
||||
with check (auth.uid() = id);
|
||||
|
||||
-- Aucune policy delete => suppression interdite en V1.
|
||||
drop policy if exists "Judges can update any profile" on public.profiles;
|
||||
create policy "Judges can update any profile"
|
||||
on public.profiles for update
|
||||
to authenticated
|
||||
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'))
|
||||
with check (true);
|
||||
|
||||
-- RLS est au niveau ligne : on verrouille aussi la colonne `points` au niveau
|
||||
-- colonne pour qu'un utilisateur ne puisse jamais la modifier via l'app,
|
||||
-- même en modifiant sa propre ligne (les points seront gérés plus tard côté admin).
|
||||
revoke update on public.profiles from authenticated;
|
||||
grant update (pseudo, slug, avatar_url) on public.profiles to authenticated;
|
||||
-- Aucune policy delete => suppression interdite en V2.
|
||||
|
||||
-- 2. Bucket avatars -------------------------------------------------------
|
||||
-- 4. Bucket avatars -------------------------------------------------------
|
||||
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('avatars', 'avatars', true)
|
||||
@@ -53,7 +200,7 @@ on conflict (id) do nothing;
|
||||
|
||||
-- Bucket public => la lecture se fait via l'URL publique, pas besoin de policy SELECT.
|
||||
|
||||
-- Upload : un utilisateur ne peut créer que le fichier `{user_id}.<ext>`.
|
||||
drop policy if exists "Users can upload their own avatar" on storage.objects;
|
||||
create policy "Users can upload their own avatar"
|
||||
on storage.objects for insert
|
||||
to authenticated
|
||||
@@ -62,7 +209,7 @@ create policy "Users can upload their own avatar"
|
||||
and split_part(name, '.', 1) = auth.uid()::text
|
||||
);
|
||||
|
||||
-- Remplacement de la photo (upsert) : même règle en update.
|
||||
drop policy if exists "Users can update their own avatar" on storage.objects;
|
||||
create policy "Users can update their own avatar"
|
||||
on storage.objects for update
|
||||
to authenticated
|
||||
@@ -74,3 +221,135 @@ create policy "Users can update their own avatar"
|
||||
bucket_id = 'avatars'
|
||||
and split_part(name, '.', 1) = auth.uid()::text
|
||||
);
|
||||
|
||||
-- 5. Vérification du pseudo avant inscription -----------------------------
|
||||
-- Un visiteur non connecté (rôle anon) n'a pas le droit de lire `profiles`
|
||||
-- (RLS réservée aux authentifiés). Pour afficher "ce pseudo est déjà pris"
|
||||
-- avant même de créer le compte, on expose une RPC qui ne renvoie qu'un
|
||||
-- booléen, sans jamais exposer le contenu de la table aux anonymes.
|
||||
create or replace function public.is_pseudo_taken(p_pseudo text)
|
||||
returns boolean
|
||||
language sql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
select exists (select 1 from public.profiles where lower(pseudo) = lower(p_pseudo));
|
||||
$$;
|
||||
|
||||
grant execute on function public.is_pseudo_taken(text) to anon, authenticated;
|
||||
|
||||
-- 6. Points, journal ("le crieur") ------------------------------------------
|
||||
|
||||
create table if not exists public.points_log (
|
||||
id bigint generated always as identity primary key,
|
||||
target_id uuid not null references public.profiles (id) on delete cascade,
|
||||
judge_id uuid not null references public.profiles (id) on delete cascade,
|
||||
delta integer not null,
|
||||
reason text,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table public.points_log enable row level security;
|
||||
|
||||
-- Lecture ouverte à tous les authentifiés (transparence du journal).
|
||||
-- Aucune policy insert/update/delete : seule la RPC award_points()
|
||||
-- (SECURITY DEFINER, propriétaire de la table) peut écrire.
|
||||
revoke insert, update, delete on public.points_log from authenticated, anon;
|
||||
grant select on public.points_log to authenticated;
|
||||
|
||||
drop policy if exists "points_log readable by authenticated" on public.points_log;
|
||||
create policy "points_log readable by authenticated"
|
||||
on public.points_log for select
|
||||
to authenticated
|
||||
using (true);
|
||||
|
||||
-- Seul point d'entrée pour modifier les points : vérifie le rôle juge
|
||||
-- côté serveur, applique le delta (négatif autorisé, pas de plancher),
|
||||
-- et trace l'opération dans points_log. Le flag app.bypass_points_lock
|
||||
-- autorise le trigger enforce_profile_update() à laisser passer CETTE
|
||||
-- écriture précise sur la colonne points.
|
||||
create or replace function public.award_points(p_target_id uuid, p_delta int, p_reason text default null)
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
caller_role text;
|
||||
begin
|
||||
select role into caller_role from public.profiles where id = auth.uid();
|
||||
if caller_role is distinct from 'judge' then
|
||||
raise exception 'only judges can award points';
|
||||
end if;
|
||||
|
||||
if not exists (select 1 from public.profiles where id = p_target_id) then
|
||||
raise exception 'target member not found';
|
||||
end if;
|
||||
|
||||
perform set_config('app.bypass_points_lock', 'on', true);
|
||||
update public.profiles set points = points + p_delta where id = p_target_id;
|
||||
perform set_config('app.bypass_points_lock', 'off', true);
|
||||
|
||||
insert into public.points_log (target_id, judge_id, delta, reason)
|
||||
values (p_target_id, auth.uid(), p_delta, p_reason);
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.award_points(uuid, int, text) to authenticated;
|
||||
|
||||
-- 7. Repère de classement (flèches de progression) --------------------------
|
||||
-- Fige le rang actuel de chacun dans previous_rank ; les flèches côté
|
||||
-- client comparent le rang courant à cette référence.
|
||||
|
||||
create or replace function public.reset_rank_reference()
|
||||
returns void
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
caller_role text;
|
||||
begin
|
||||
select role into caller_role from public.profiles where id = auth.uid();
|
||||
if caller_role is distinct from 'judge' then
|
||||
raise exception 'only judges can reset the rank reference';
|
||||
end if;
|
||||
|
||||
with ranked as (
|
||||
select id, rank() over (order by points desc) as r
|
||||
from public.profiles
|
||||
)
|
||||
update public.profiles p
|
||||
set previous_rank = ranked.r
|
||||
from ranked
|
||||
where p.id = ranked.id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
grant execute on function public.reset_rank_reference() to authenticated;
|
||||
|
||||
-- 8. Realtime ----------------------------------------------------------------
|
||||
-- Permet au leaderboard et au journal de s'actualiser en direct pour tout
|
||||
-- le monde (Dashboard → Database → Replication fait la même chose).
|
||||
|
||||
do $$
|
||||
begin
|
||||
alter publication supabase_realtime add table public.profiles;
|
||||
exception
|
||||
when duplicate_object then null;
|
||||
end $$;
|
||||
|
||||
do $$
|
||||
begin
|
||||
alter publication supabase_realtime add table public.points_log;
|
||||
exception
|
||||
when duplicate_object then null;
|
||||
end $$;
|
||||
|
||||
-- 9. Désigner les premiers juges ---------------------------------------------
|
||||
-- À faire une fois les comptes créés (SQL Editor) :
|
||||
--
|
||||
-- update public.profiles set role = 'judge' where id = '<uuid-du-membre>';
|
||||
--
|
||||
-- (Récupérer l'uuid via Authentication → Users, ou :
|
||||
-- select id, pseudo from public.profiles;)
|
||||
|
||||
Reference in New Issue
Block a user