-- 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, 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() ); -- 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)); alter table public.profiles enable row level security; -- 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); -- 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; 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); 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); -- Aucune policy delete => suppression interdite en V2. -- 4. Bucket avatars ------------------------------------------------------- insert into storage.buckets (id, name, public) values ('avatars', 'avatars', true) on conflict (id) do nothing; -- Le bucket public sert la lecture via l'URL publique (hors RLS), mais une -- policy SELECT reste nécessaire : pour un upload en upsert (remplacement -- d'une photo existante), Postgres doit pouvoir lire la ligne existante en -- interne pour évaluer la clause USING de l'UPDATE — sans SELECT, l'upsert -- échoue avec "new row violates row-level security policy" même si les -- policies INSERT/UPDATE sont correctes. drop policy if exists "Avatars are publicly viewable" on storage.objects; create policy "Avatars are publicly viewable" on storage.objects for select using (bucket_id = 'avatars'); 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 with check ( bucket_id = 'avatars' and split_part(name, '.', 1) = auth.uid()::text ); 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 using ( bucket_id = 'avatars' and split_part(name, '.', 1) = auth.uid()::text ) with check ( 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. Le Calendrier des Dieux -------------------------------------------------- -- Agenda de la semaine : chaque journée est placée sous le patronage d'une -- divinité (nom/domaine en texte libre, l'Archonte choisit un préréglage -- côté client ou saisit le sien). Lecture ouverte à tous, écriture réservée -- aux juges (Archontes), vérifiée côté serveur par RLS — jamais par le -- masquage des boutons en front. create table if not exists public.days ( id uuid primary key default gen_random_uuid(), date date not null unique, god_name text not null, god_domain text not null, description text, created_at timestamptz not null default now() ); create table if not exists public.events ( id uuid primary key default gen_random_uuid(), day_id uuid not null references public.days (id) on delete cascade, title text not null, description text, type text not null default 'activite' check (type in ('activite', 'defi', 'epreuve', 'tribunal')), start_time time, location text, created_by uuid references public.profiles (id) on delete set null, created_at timestamptz not null default now() ); -- Ligne de configuration unique (id toujours = true) : date du Tribunal. create table if not exists public.settings ( id boolean primary key default true, tribunal_date timestamptz, constraint settings_singleton check (id) ); insert into public.settings (id) values (true) on conflict (id) do nothing; alter table public.days enable row level security; alter table public.events enable row level security; alter table public.settings enable row level security; grant select, insert, update, delete on public.days, public.events to authenticated; grant select, update on public.settings to authenticated; -- Lecture : tout utilisateur authentifié. drop policy if exists "days viewable by authenticated" on public.days; create policy "days viewable by authenticated" on public.days for select to authenticated using (true); drop policy if exists "events viewable by authenticated" on public.events; create policy "events viewable by authenticated" on public.events for select to authenticated using (true); drop policy if exists "settings viewable by authenticated" on public.settings; create policy "settings viewable by authenticated" on public.settings for select to authenticated using (true); -- Écriture : réservée aux juges. drop policy if exists "days insert by judges" on public.days; create policy "days insert by judges" on public.days for insert to authenticated with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); drop policy if exists "days update by judges" on public.days; create policy "days update by judges" on public.days for update to authenticated using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')) with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); drop policy if exists "days delete by judges" on public.days; create policy "days delete by judges" on public.days for delete to authenticated using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); drop policy if exists "events insert by judges" on public.events; create policy "events insert by judges" on public.events for insert to authenticated with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); drop policy if exists "events update by judges" on public.events; create policy "events update by judges" on public.events for update to authenticated using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')) with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); drop policy if exists "events delete by judges" on public.events; create policy "events delete by judges" on public.events for delete to authenticated using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); drop policy if exists "settings update by judges" on public.settings; create policy "settings update by judges" on public.settings for update to authenticated using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')) with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')); -- created_by ne fait jamais confiance au client : toujours l'auteur réel. create or replace function public.enforce_event_insert() returns trigger language plpgsql as $$ begin new.created_by := auth.uid(); return new; end; $$; drop trigger if exists events_before_insert on public.events; create trigger events_before_insert before insert on public.events for each row execute function public.enforce_event_insert(); do $$ begin alter publication supabase_realtime add table public.days; exception when duplicate_object then null; end $$; do $$ begin alter publication supabase_realtime add table public.events; exception when duplicate_object then null; end $$; do $$ begin alter publication supabase_realtime add table public.settings; exception when duplicate_object then null; end $$; -- 10. Désigner les premiers juges --------------------------------------------- -- À faire une fois les comptes créés (SQL Editor) : -- -- update public.profiles set role = 'judge' where id = ''; -- -- (Récupérer l'uuid via Authentication → Users, ou : -- select id, pseudo from public.profiles;) -- 11. La Course du Char -------------------------------------------------------- -- Mini-jeu : course de char en vue du dessus, une piste générée aléatoirement -- par jour (calculée côté client à partir de la date Europe/Paris, jamais -- stockée ici), chacun rejoue autant qu'il veut, seul le meilleur temps du -- jour compte. Les points du jour sont attribués automatiquement (pas -- d'Archonte impliqué) par une tâche planifiée pg_cron — seul mécanisme de -- points de tout le projet qui n'attend pas de décision d'un juge. -- Un point attribué automatiquement n'a pas de juge : judge_id devient -- optionnel (le Crieur affiche alors "Le Tribunal" à la place d'un pseudo). alter table public.points_log alter column judge_id drop not null; create table if not exists public.chariot_runs ( user_id uuid not null references public.profiles (id) on delete cascade, race_date date not null, best_time_ms integer not null check (best_time_ms between 2000 and 300000), ghost_path jsonb not null default '[]'::jsonb, updated_at timestamptz not null default now(), primary key (user_id, race_date) ); create index if not exists chariot_runs_race_date_idx on public.chariot_runs (race_date, best_time_ms); alter table public.chariot_runs enable row level security; -- Même modèle que points_log : verrouillée en écriture, seule la RPC -- submit_chariot_run() (SECURITY DEFINER) peut écrire. revoke insert, update, delete on public.chariot_runs from authenticated, anon; grant select on public.chariot_runs to authenticated; drop policy if exists "chariot_runs readable by authenticated" on public.chariot_runs; create policy "chariot_runs readable by authenticated" on public.chariot_runs for select to authenticated using (true); -- Seul point d'entrée pour soumettre un temps : la date du jour est calculée -- côté serveur (jamais fournie par le client, pour ne pas pouvoir soumettre -- "pour" un autre jour), le temps est borné grossièrement (anti-triche -- minimal, suffisant pour un jeu entre amis — pas de vérification fine par -- piste), et on n'écrase le temps existant que s'il est strictement meilleur. create or replace function public.submit_chariot_run(p_time_ms integer, p_ghost_path jsonb default '[]'::jsonb) returns public.chariot_runs language plpgsql security definer set search_path = public as $$ declare v_race_date date; v_row public.chariot_runs; begin if auth.uid() is null then raise exception 'authentication required'; end if; if p_time_ms is null or p_time_ms < 2000 or p_time_ms > 300000 then raise exception 'invalid time_ms'; end if; v_race_date := (now() at time zone 'Europe/Paris')::date; insert into public.chariot_runs (user_id, race_date, best_time_ms, ghost_path, updated_at) values (auth.uid(), v_race_date, p_time_ms, coalesce(p_ghost_path, '[]'::jsonb), now()) on conflict (user_id, race_date) do update set best_time_ms = excluded.best_time_ms, ghost_path = excluded.ghost_path, updated_at = now() where excluded.best_time_ms < public.chariot_runs.best_time_ms; -- On resélectionne toujours plutôt que de dépendre du RETURNING de -- l'upsert : si la clause WHERE bloque la mise à jour (temps pas -- meilleur), l'INSERT ne retourne aucune ligne. On renvoie dans tous les -- cas le meilleur temps courant du joueur, qu'il vienne d'être battu ou non. select * into v_row from public.chariot_runs where user_id = auth.uid() and race_date = v_race_date; return v_row; end; $$; grant execute on function public.submit_chariot_run(integer, jsonb) to authenticated; do $$ begin alter publication supabase_realtime add table public.chariot_runs; exception when duplicate_object then null; end $$; -- Marqueur interne : quelles journées ont déjà été clôturées (idempotence de -- la tâche planifiée, qui tourne toutes les 15 minutes plutôt qu'une seule -- fois à minuit pile — le changement d'heure CET/CEST décalerait un horaire -- UTC fixe). Table strictement interne : aucune policy, jamais lue/écrite -- par le client. create table if not exists public.chariot_race_closes ( race_date date primary key, closed_at timestamptz not null default now() ); alter table public.chariot_race_closes enable row level security; revoke all on public.chariot_race_closes from authenticated, anon; -- Clôture de la journée précédente (Europe/Paris) : classe chariot_runs, -- attribue les gloires du top 3 (ex-aequo inclus au même rang), journalise -- dans points_log avec judge_id = null, puis marque la date comme close. -- No-op si déjà clôturée. Volontairement pas de grant execute à -- authenticated : uniquement appelée par pg_cron ou depuis le SQL Editor. create or replace function public.close_daily_chariot_race() returns void language plpgsql security definer set search_path = public as $$ declare v_target_date date := ((now() at time zone 'Europe/Paris')::date - 1); r record; v_points int; begin if exists (select 1 from public.chariot_race_closes where race_date = v_target_date) then return; end if; for r in with ranked as ( select user_id, best_time_ms, rank() over (order by best_time_ms asc) as rnk from public.chariot_runs where race_date = v_target_date ) select * from ranked where rnk <= 3 loop v_points := case r.rnk when 1 then 3 when 2 then 2 when 3 then 1 else 0 end; update public.profiles set points = points + v_points where id = r.user_id; insert into public.points_log (target_id, judge_id, delta, reason) values (r.user_id, null, v_points, 'Course du Char — ' || to_char(v_target_date, 'DD/MM') || ' (rang ' || r.rnk || ')'); end loop; insert into public.chariot_race_closes (race_date) values (v_target_date); end; $$; -- ⚠️ pg_cron doit être activé une fois pour toutes via le Dashboard Supabase -- (Database → Extensions → "pg_cron" → Enable) — pas scriptable depuis ce -- fichier, et pas garanti self-service selon le plan/la région du projet. -- Les deux blocs ci-dessous n'échouent jamais bruyamment si l'extension n'est -- pas encore activée (schéma "cron" inexistant) : le reste de ce script, -- ré-exécuté en entier à chaque changement, doit toujours pouvoir passer. do $$ begin perform cron.unschedule('close-daily-chariot-race'); exception when others then null; -- la tâche n'existe pas encore, ou pg_cron pas activé end $$; do $$ begin perform cron.schedule( 'close-daily-chariot-race', '*/15 * * * *', $cron$select public.close_daily_chariot_race();$cron$ ); exception when others then raise notice 'pg_cron indisponible : active l''extension via le Dashboard Supabase (Database → Extensions → pg_cron), puis ré-exécute ce script pour planifier la clôture automatique de la Course du Char.'; end $$;