Files
tribunal-app/supabase/schema.sql
T
Valentin ROBIN 33d2476ef6
Build and deploy / deploy (push) Successful in 37s
Remplace la Course du Char par Le Vol d'Icare
Après retour d'expérience, la course de char (vue du dessus, piste
générée par jour) laisse place à un Flappy Bird grec plus simple et
plus lisible sur téléphone : Icare vole entre des colonnes de temple,
touche l'écran pour battre des ailes, échec net au premier contact
(score remis à zéro).

Record personnel all-time (plus de piste quotidienne ni de fantômes) :
les scores se figent dès que la date du Tribunal est atteinte, puis les
gloires du top 3 sont attribuées automatiquement via une tâche
planifiée pg_cron, comme pour l'ancienne Course du Char.

schema.sql nettoie explicitement l'ancienne Course du Char (tables,
RPC, tâche planifiée) avant de poser le nouveau schéma, puisqu'elle
avait déjà été appliquée en prod.
2026-07-29 19:14:52 +02:00

651 lines
25 KiB
PL/PgSQL

-- 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 = '<uuid-du-membre>';
--
-- (Récupérer l'uuid via Authentication → Users, ou :
-- select id, pseudo from public.profiles;)
-- 11. Le Vol d'Icare -----------------------------------------------------------
-- Mini-jeu : remplace la Course du Char (abandonnée — voir nettoyage
-- ci-dessous). Flappy Bird grec : Icare vole entre des colonnes de temple,
-- un seul record personnel all-time (pas de piste/jour, générée librement à
-- chaque partie côté client). Les scores sont figés dès que la date du
-- Tribunal (settings.tribunal_date) est atteinte, puis les gloires du top 3
-- sont attribuées automatiquement (pas d'Archonte impliqué) par une tâche
-- planifiée pg_cron.
-- Nettoyage de la Course du Char (schéma déjà appliqué en prod avant cet
-- abandon — ces DROP sont nécessaires, pas juste cosmétiques).
do $$
begin
perform cron.unschedule('close-daily-chariot-race');
exception
when others then null;
end $$;
drop function if exists public.close_daily_chariot_race();
drop table if exists public.chariot_race_closes;
drop function if exists public.submit_chariot_run(integer, jsonb);
drop table if exists public.chariot_runs;
-- Un point attribué automatiquement n'a pas de juge : judge_id reste
-- nullable (déjà appliqué précédemment — le Crieur affiche "Le Tribunal").
create table if not exists public.icarus_scores (
user_id uuid primary key references public.profiles (id) on delete cascade,
best_score integer not null check (best_score between 0 and 1000000),
updated_at timestamptz not null default now()
);
alter table public.icarus_scores enable row level security;
-- Même modèle que points_log/chariot_runs : verrouillée en écriture, seule
-- la RPC submit_icarus_score() (SECURITY DEFINER) peut écrire.
revoke insert, update, delete on public.icarus_scores from authenticated, anon;
grant select on public.icarus_scores to authenticated;
drop policy if exists "icarus_scores readable by authenticated" on public.icarus_scores;
create policy "icarus_scores readable by authenticated"
on public.icarus_scores for select
to authenticated
using (true);
-- Marqueur d'idempotence pour l'attribution automatique des gloires (un seul
-- événement, pas de notion de jour comme pour la Course du Char).
alter table public.settings add column if not exists icarus_points_awarded boolean not null default false;
-- Seul point d'entrée pour soumettre un score. Une fois la date du Tribunal
-- atteinte, les scores sont figés : la RPC ne fait plus rien (retourne le
-- record existant sans le modifier) plutôt que d'échouer bruyamment.
create or replace function public.submit_icarus_score(p_score integer)
returns public.icarus_scores
language plpgsql
security definer
set search_path = public
as $$
declare
v_tribunal_date timestamptz;
v_row public.icarus_scores;
begin
if auth.uid() is null then
raise exception 'authentication required';
end if;
if p_score is null or p_score < 0 or p_score > 1000000 then
raise exception 'invalid score';
end if;
select tribunal_date into v_tribunal_date from public.settings where id = true;
if v_tribunal_date is not null and now() >= v_tribunal_date then
select * into v_row from public.icarus_scores where user_id = auth.uid();
return v_row;
end if;
insert into public.icarus_scores (user_id, best_score, updated_at)
values (auth.uid(), p_score, now())
on conflict (user_id) do update
set best_score = excluded.best_score,
updated_at = now()
where excluded.best_score > public.icarus_scores.best_score;
select * into v_row from public.icarus_scores where user_id = auth.uid();
return v_row;
end;
$$;
grant execute on function public.submit_icarus_score(integer) to authenticated;
do $$
begin
alter publication supabase_realtime add table public.icarus_scores;
exception
when duplicate_object then null;
end $$;
-- Attribue les gloires du top 3 (ex-aequo inclus au même rang) dès que la
-- date du Tribunal est atteinte ; no-op tant qu'elle n'est pas encore
-- passée, et no-op définitif une fois déjà fait (icarus_points_awarded).
-- Volontairement pas de grant execute à authenticated : uniquement appelée
-- par pg_cron ou depuis le SQL Editor.
create or replace function public.award_icarus_points_if_due()
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
v_tribunal_date timestamptz;
v_already_awarded boolean;
r record;
v_points int;
begin
select tribunal_date, icarus_points_awarded into v_tribunal_date, v_already_awarded
from public.settings where id = true;
if v_tribunal_date is null or now() < v_tribunal_date or v_already_awarded then
return;
end if;
for r in
with ranked as (
select user_id, best_score,
rank() over (order by best_score desc) as rnk
from public.icarus_scores
)
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, 'Le Vol d''Icare — rang ' || r.rnk || ' au Tribunal');
end loop;
update public.settings set icarus_points_awarded = true where id = true;
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('award-icarus-points');
exception
when others then null; -- la tâche n'existe pas encore, ou pg_cron pas activé
end $$;
do $$
begin
perform cron.schedule(
'award-icarus-points',
'*/15 * * * *',
$cron$select public.award_icarus_points_if_due();$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 l''attribution automatique des gloires du Vol d''Icare.';
end $$;