Files
tribunal-app/supabase/schema.sql
T
Valentin ROBIN 35704e50fc
Build and deploy / deploy (push) Successful in 36s
Resserre les plafonds de score, ajoute une vérif par temps réel écoulé sur Icare, corrige le contraste de l'Urne
5000 restait trop haut pour être un vrai plafond réaliste : Icare abaissé à 500 (calculé sur le rythme le plus rapide théoriquement atteignable dans le jeu), Corne à 2000 (estimation plus prudente, économie de score plus dure à borner). Ajoute icarus_runs/start_icarus_run() : le serveur enregistre l'instant réel de début de partie et rejette un score incohérent avec le temps écoulé, sans jamais faire confiance à une durée envoyée par le client. Corrige aussi le nombre de jetons de /urne, peu lisible en gold-bright sur fond marbre (passe à text-sea, même convention qu'Icare/Corne).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 11:28:02 +02:00

1741 lines
70 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 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;
-- 1bis. Durcissement de sécurité (audit) --------------------------------------
-- PostgreSQL accorde EXECUTE à PUBLIC par défaut sur toute fonction créée,
-- sauf révocation explicite. Plusieurs RPC de ce fichier n'étaient
-- "protégées" que par un commentaire ("pas de grant à authenticated") sans
-- REVOKE réel — donc en réalité appelables par n'importe quel utilisateur
-- authentifié via l'API REST, malgré l'intention. Cette ligne change le
-- comportement par défaut pour TOUTE fonction créée après elle dans ce
-- script (donc pour toutes les fonctions ci-dessous) : plus aucune RPC
-- n'est exécutable sans un `grant execute` explicite. Les RPC déjà
-- existantes avant cette ligne conservent leurs anciens privilèges tant
-- qu'on ne les révoque pas explicitement — voir les REVOKE ciblés plus bas
-- pour award_icarus_points_if_due()/award_melon_points_if_due(), qui en
-- avaient besoin rétroactivement.
alter default privileges in schema public revoke execute on functions from public;
-- 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).
-- Le pseudo temporaire inclut les 8 premiers caractères de l'uuid : un
-- littéral fixe "Nouveau membre" pour tout le monde entrait en collision
-- avec profiles_pseudo_lower_key (unique) dès qu'une 2e personne était
-- invitée avant que la 1re ait fini son inscription — ce qui n'était
-- jamais arrivé avant d'inviter plusieurs personnes d'affilée.
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 ' || substr(new.id::text, 1, 8))
);
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 -------------------------------------------------------
-- file_size_limit/allowed_mime_types ajoutés (audit de sécurité) : sans
-- ça, un appel direct à l'API de Storage (hors de l'app, où l'upload est
-- toujours compressé côté client via cropImageToBlob) pouvait uploader un
-- fichier arbitrairement gros ou non-image comme "avatar" — même
-- principe que sur le bucket wall-images.
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values ('avatars', 'avatars', true, 3145728, array['image/webp', 'image/jpeg', 'image/png'])
on conflict (id) do update set
public = excluded.public,
file_size_limit = excluded.file_size_limit,
allowed_mime_types = excluded.allowed_mime_types;
-- 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()
);
-- Plafond abaissé (audit de sécurité) : 1 000 000 était un score
-- absurdement irréaliste, ce qui rendait triviale la soumission d'un
-- score inventé directement via l'API pour se faire attribuer des gloires
-- au Tribunal — voir la contrainte identique sur melon_scores. La
-- contrainte inline de create table ne s'applique qu'à la création ; sur
-- une table déjà existante il faut explicitement la remplacer. Encore
-- resserré de 5000 à 500 (toujours généreux — largement au-dessus de ce
-- qu'une partie sans faute atteint en pratique, voir le calcul détaillé
-- au niveau de submit_icarus_score) après un deuxième passage d'audit :
-- 5000 restait un ordre de grandeur trop haut pour être vraiment un
-- plafond réaliste plutôt qu'une simple borne anti-débordement.
alter table public.icarus_scores drop constraint if exists icarus_scores_best_score_check;
alter table public.icarus_scores add constraint icarus_scores_best_score_check
check (best_score between 0 and 500);
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;
-- Deuxième couche, orthogonale à la borne fixe ci-dessus (audit de
-- sécurité) : le jeu n'a aucune vérification de gameplay (génération
-- procédurale entièrement côté client, voir V6/§3bis) — un score
-- jusqu'à 500 reste soumettable tel quel sans avoir vraiment joué. Cette
-- table fait tenir un compte du temps RÉEL écoulé (horloge du serveur,
-- jamais une durée envoyée par le client — sinon aussi falsifiable que le
-- score lui-même) entre le début d'une partie et sa soumission, pour
-- rejeter un score incohérent avec le temps réellement passé. Aucun accès
-- direct côté client (pas de grant) : seules les deux RPC ci-dessous
-- (SECURITY DEFINER) la lisent/écrivent.
create table if not exists public.icarus_runs (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.profiles (id) on delete cascade,
started_at timestamptz not null default now()
);
alter table public.icarus_runs enable row level security;
create or replace function public.start_icarus_run()
returns uuid
language plpgsql
security definer
set search_path = public
as $$
declare
v_id uuid;
begin
if auth.uid() is null then
raise exception 'authentication required';
end if;
insert into public.icarus_runs (user_id) values (auth.uid()) returning id into v_id;
return v_id;
end;
$$;
grant execute on function public.start_icarus_run() to authenticated;
-- Signature changée (ajout de p_run_id) : l'ancienne (integer) est
-- explicitement supprimée, sinon create or replace créerait une 2e
-- surcharge au lieu de remplacer (même remarque que post_wall_note).
drop function if exists public.submit_icarus_score(integer);
-- 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, p_run_id uuid)
returns public.icarus_scores
language plpgsql
security definer
set search_path = public
as $$
declare
v_tribunal_date timestamptz;
v_row public.icarus_scores;
v_started_at timestamptz;
v_elapsed_seconds double precision;
-- Temps minimal réel pour passer une colonne, au rythme le plus rapide
-- jamais atteignable dans le jeu : vitesse de défilement pleinement
-- montée en difficulté (FORWARD_SPEED × SPEED_MAX_MULTIPLIER) ET
-- bouclier du boost actif en permanence (× BOOST_SPEED_MULTIPLIER en
-- plus) — un cas déjà irréaliste en soi (le bouclier n'est ni continu
-- ni permanent), donc une marge de sécurité généreuse avant même le
-- ×0.9 ci-dessous. Doit rester aligné avec COLUMN_SPACING/FORWARD_SPEED/
-- SPEED_MAX_MULTIPLIER/BOOST_SPEED_MULTIPLIER (src/lib/icarus/constants.ts).
-- 210 / (130 × 1.6 × 1.7) ≈ 0.594s, encore réduit de 10% (marge contre
-- le jitter d'arrondi de la boucle de jeu) : jamais assez strict pour
-- rejeter un score légitime, seulement pour rejeter l'impossible.
c_min_seconds_per_column constant double precision := 0.53;
begin
if auth.uid() is null then
raise exception 'authentication required';
end if;
-- Plafond réaliste (500, largement au-delà de ce qu'une vraie partie
-- sans faute atteint), pas une simple borne anti-débordement — voir la
-- contrainte de table associée (best_score_check), la vraie garantie ;
-- cette vérification donne juste un message d'erreur clair côté client.
if p_score is null or p_score < 0 or p_score > 500 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;
if p_score > 0 then
select started_at into v_started_at
from public.icarus_runs
where id = p_run_id and user_id = auth.uid();
if v_started_at is null then
raise exception 'invalid run';
end if;
v_elapsed_seconds := extract(epoch from (now() - v_started_at));
if p_score > floor(v_elapsed_seconds / c_min_seconds_per_column) then
raise exception 'score incohérent avec le temps de jeu écoulé';
end if;
delete from public.icarus_runs where id = p_run_id;
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, uuid) 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).
-- Uniquement appelée par pg_cron ou depuis le SQL Editor : REVOKE explicite
-- ci-dessous (ne pas se contenter de "ne pas accorder à authenticated" —
-- PostgreSQL accorde EXECUTE à PUBLIC par défaut, donc sans révocation
-- explicite cette fonction restait appelable par n'importe qui via l'API
-- REST malgré l'intention).
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;
$$;
revoke execute on function public.award_icarus_points_if_due() from public, anon, authenticated;
-- ⚠️ 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 $$;
-- 12. Le Jeu de l'Agora — Le Char et Le Gardien du Silence ---------------------
-- Outils pour la soirée du Tribunal elle-même (jeu physique/social, pas un
-- mini-jeu numérique) : l'app affiche/synchronise un état que les Archontes
-- pilotent à la main, elle ne calcule/arbitre jamais le déroulé du jeu.
-- Préfixe chariot_* (pas char_*) : sans rapport avec l'ancienne Course du
-- Char digitale déjà supprimée (section 11), qui utilisait déjà ce préfixe —
-- le reprendre ici évite de laisser croire à une résurrection de ce mini-jeu.
-- La route reste /char.
create table if not exists public.chariot_questions (
id uuid primary key default gen_random_uuid(),
text text not null,
position integer not null default 0,
created_at timestamptz not null default now()
);
alter table public.chariot_questions enable row level security;
grant select, insert, update, delete on public.chariot_questions to authenticated;
-- Lecture réservée aux juges (audit de sécurité) : la banque complète est un
-- spoiler du jeu en cours pour les Citoyens tant qu'une question n'a pas été
-- révélée par un Archonte. /char (accessible à tous) n'a plus le droit de
-- lire cette table directement — il passe par la RPC
-- chariot_revealed_question() ci-dessous, qui ne renvoie que la question
-- actuellement révélée. Seul /char/questions (déjà réservé aux juges) lit
-- encore cette table directement.
drop policy if exists "chariot_questions viewable by authenticated" on public.chariot_questions;
drop policy if exists "chariot_questions viewable by judges" on public.chariot_questions;
create policy "chariot_questions viewable by judges"
on public.chariot_questions for select to authenticated
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
drop policy if exists "chariot_questions insert by judges" on public.chariot_questions;
create policy "chariot_questions insert by judges" on public.chariot_questions 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 "chariot_questions update by judges" on public.chariot_questions;
create policy "chariot_questions update by judges" on public.chariot_questions 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 "chariot_questions delete by judges" on public.chariot_questions;
create policy "chariot_questions delete by judges" on public.chariot_questions for delete to authenticated
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
do $$
begin
alter publication supabase_realtime add table public.chariot_questions;
exception
when duplicate_object then null;
end $$;
-- Seul moyen pour un non-juge de savoir quelle question est actuellement
-- révélée sans jamais lire la banque complète (RLS ci-dessus, juges
-- uniquement) : renvoie zéro ligne tant qu'aucune question n'est révélée.
-- question_number est calculé sur l'ordre complet (position, created_at)
-- pour afficher "Question N" sur /char sans exposer les autres lignes.
create or replace function public.chariot_revealed_question()
returns table (id uuid, text text, question_number integer)
language sql
security definer
set search_path = public
as $$
select numbered.id, numbered.text, numbered.question_number
from (
select q.id, q.text,
row_number() over (order by q.position, q.created_at)::integer as question_number
from public.chariot_questions q
) numbered
where numbered.id = (select s.chariot_revealed_question_id from public.settings s where s.id = true);
$$;
grant execute on function public.chariot_revealed_question() to authenticated;
-- Nettoyage de l'ancien modèle par statut (team_a/team_b/out), remplacé par
-- un modèle par emplacement fixe ci-dessous — schéma jamais appliqué en
-- prod (retour utilisateur avant la première vraie migration), DROP direct.
drop table if exists public.chariot_roster;
-- Char : 6 emplacements fixes (1-3 = Colonne A, 4-6 = Colonne B), vides
-- (user_id null) ou occupés. Pré-remplis une fois pour toutes ci-dessous —
-- on ne fait jamais que les UPDATE, jamais d'insert/delete côté client.
create table if not exists public.chariot_slots (
slot integer primary key check (slot between 1 and 6),
user_id uuid references public.profiles (id) on delete set null
);
insert into public.chariot_slots (slot)
select generate_series(1, 6)
on conflict (slot) do nothing;
alter table public.chariot_slots enable row level security;
grant select, update on public.chariot_slots to authenticated;
drop policy if exists "chariot_slots viewable by authenticated" on public.chariot_slots;
create policy "chariot_slots viewable by authenticated"
on public.chariot_slots for select to authenticated using (true);
drop policy if exists "chariot_slots update by judges" on public.chariot_slots;
create policy "chariot_slots update by judges" on public.chariot_slots 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'));
do $$
begin
alter publication supabase_realtime add table public.chariot_slots;
exception
when duplicate_object then null;
end $$;
-- Journal d'entrées dans le char (append-only, comme points_log) : sert
-- uniquement à compter combien de fois chaque personne est montée —
-- une ligne ajoutée à chaque affectation d'un emplacement, jamais modifiée.
create table if not exists public.chariot_entries (
id bigint generated always as identity primary key,
user_id uuid not null references public.profiles (id) on delete cascade,
created_at timestamptz not null default now()
);
alter table public.chariot_entries enable row level security;
grant select, insert on public.chariot_entries to authenticated;
revoke update, delete on public.chariot_entries from authenticated, anon;
drop policy if exists "chariot_entries viewable by authenticated" on public.chariot_entries;
create policy "chariot_entries viewable by authenticated"
on public.chariot_entries for select to authenticated using (true);
drop policy if exists "chariot_entries insert by judges" on public.chariot_entries;
create policy "chariot_entries insert by judges" on public.chariot_entries for insert to authenticated
with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
do $$
begin
alter publication supabase_realtime add table public.chariot_entries;
exception
when duplicate_object then null;
end $$;
-- Question actuellement révélée sur la page publique (bascule afficher/
-- masquer), et état du Gardien du Silence.
alter table public.settings add column if not exists chariot_revealed_question_id uuid references public.chariot_questions (id) on delete set null;
alter table public.settings add column if not exists gardien_holder_id uuid references public.profiles (id) on delete set null;
alter table public.settings add column if not exists gardien_expires_at timestamptz;
-- Verrou de colonne pour le Gardien (même pattern que profiles.points) :
-- gardien_holder_id/gardien_expires_at ne doivent changer que via un vrai
-- tirage aléatoire (reroll_gardien), jamais par une écriture directe même
-- par un Archonte — chariot_revealed_question_id reste, lui, directement
-- modifiable par les juges (rien à protéger dans cette colonne-là).
create or replace function public.enforce_settings_update()
returns trigger
language plpgsql
as $$
declare
bypass boolean := coalesce(current_setting('app.bypass_gardien_lock', true), 'off') = 'on';
begin
if auth.uid() is null then
return new;
end if;
if not bypass and (
new.gardien_holder_id is distinct from old.gardien_holder_id
or new.gardien_expires_at is distinct from old.gardien_expires_at
) then
raise exception 'gardien_holder_id/gardien_expires_at can only change via reroll_gardien()';
end if;
return new;
end;
$$;
drop trigger if exists settings_before_update on public.settings;
create trigger settings_before_update
before update on public.settings
for each row execute function public.enforce_settings_update();
-- Tire un nouveau Gardien (uniquement parmi les Citoyens, jamais le
-- détenteur actuel si possible) et repousse l'expiration de 5 minutes.
-- p_force=true (réservé aux juges) reroll immédiatement ; p_force=false
-- (utilisé par le minuteur côté client, et par pg_cron en filet) ne fait
-- rien tant que gardien_expires_at n'est pas atteint.
--
-- Un seul UPDATE ... WHERE atomique (pas de SELECT puis UPDATE séparés) :
-- avec plusieurs téléphones ouverts, tous les minuteurs locaux arrivent à
-- zéro dans la même seconde et appellent cette RPC en même temps — la garde
-- dans le WHERE fait que Postgres sérialise naturellement, un seul appel
-- modifie réellement la ligne, les autres ne touchent aucune ligne.
create or replace function public.reroll_gardien(p_force boolean default false)
returns void
language plpgsql
security definer
set search_path = public
as $$
declare
caller_role text;
begin
if p_force then
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 force a reroll';
end if;
end if;
perform set_config('app.bypass_gardien_lock', 'on', true);
update public.settings
set gardien_holder_id = coalesce(
(select id from public.profiles
where role = 'public' and id is distinct from settings.gardien_holder_id
order by random() limit 1),
(select id from public.profiles where role = 'public' order by random() limit 1)
),
gardien_expires_at = now() + interval '5 minutes'
where id = true
and (p_force or gardien_expires_at is null or now() >= gardien_expires_at);
perform set_config('app.bypass_gardien_lock', 'off', true);
end;
$$;
grant execute on function public.reroll_gardien(boolean) to authenticated;
-- ⚠️ Même remarque que pour pg_cron plus haut : à activer une fois via le
-- Dashboard Supabase si ce n'est pas déjà fait. Cadence 1 minute : filet
-- large par rapport au minuteur de 5 minutes, le déclenchement réel se fait
-- côté client dès qu'un onglet est ouvert.
do $$
begin
perform cron.unschedule('reroll-gardien');
exception
when others then null;
end $$;
do $$
begin
perform cron.schedule(
'reroll-gardien',
'*/1 * * * *',
$cron$select public.reroll_gardien();$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 le reroll automatique du Gardien.';
end $$;
-- 13. Suggestions de questions par les Citoyens + emails sur Admin
-- -----------------------------------------------------------------
-- Une proposition par Citoyen (une ligne par user_id, comme icarus_scores).
-- Verrouillée en écriture comme points_log/icarus_scores : seule la RPC
-- submit_chariot_question() (SECURITY DEFINER) peut insert/update ; les
-- juges gardent un accès direct en lecture (modération) et en suppression
-- (rejet), sans passer par une RPC pour ce cas simple.
create table if not exists public.chariot_submissions (
user_id uuid primary key references public.profiles (id) on delete cascade,
text text not null,
updated_at timestamptz not null default now()
);
alter table public.chariot_submissions enable row level security;
revoke insert, update on public.chariot_submissions from authenticated, anon;
grant select, delete on public.chariot_submissions to authenticated;
drop policy if exists "chariot_submissions readable by owner or judges" on public.chariot_submissions;
create policy "chariot_submissions readable by owner or judges"
on public.chariot_submissions for select to authenticated
using (
user_id = auth.uid()
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')
);
drop policy if exists "chariot_submissions delete by judges" on public.chariot_submissions;
create policy "chariot_submissions delete by judges" on public.chariot_submissions for delete to authenticated
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
do $$
begin
alter publication supabase_realtime add table public.chariot_submissions;
exception
when duplicate_object then null;
end $$;
-- Historique append-only (comme points_log) des versions successives d'une
-- proposition : chariot_submissions ne garde que la valeur courante (upsert),
-- donc sans ce journal les Archontes ne verraient jamais les versions
-- précédentes d'une question modifiée plusieurs fois. Écrit uniquement par
-- submit_chariot_question() (aucune policy insert, comme points_log).
create table if not exists public.chariot_submission_history (
id bigint generated always as identity primary key,
user_id uuid not null references public.profiles (id) on delete cascade,
text text not null,
created_at timestamptz not null default now()
);
alter table public.chariot_submission_history enable row level security;
revoke insert, update, delete on public.chariot_submission_history from authenticated, anon;
grant select on public.chariot_submission_history to authenticated;
drop policy if exists "chariot_submission_history readable by owner or judges" on public.chariot_submission_history;
create policy "chariot_submission_history readable by owner or judges"
on public.chariot_submission_history for select to authenticated
using (
user_id = auth.uid()
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')
);
do $$
begin
alter publication supabase_realtime add table public.chariot_submission_history;
exception
when duplicate_object then null;
end $$;
-- Seul point d'entrée pour proposer/modifier sa question. Réutilise
-- settings.tribunal_date comme déclencheur de gel — même philosophie que
-- submit_icarus_score() : une fois l'Agora commencée, no-op silencieux
-- (retourne la ligne existante sans la modifier) plutôt qu'une erreur.
create or replace function public.submit_chariot_question(p_text text)
returns public.chariot_submissions
language plpgsql
security definer
set search_path = public
as $$
declare
v_caller_role text;
v_tribunal_date timestamptz;
v_row public.chariot_submissions;
begin
if auth.uid() is null then
raise exception 'authentication required';
end if;
select role into v_caller_role from public.profiles where id = auth.uid();
if v_caller_role = 'judge' then
raise exception 'only citizens can submit a chariot question';
end if;
if p_text is null or length(trim(p_text)) = 0 then
raise exception 'question vide';
end if;
if length(p_text) > 300 then
raise exception 'question trop longue';
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.chariot_submissions where user_id = auth.uid();
return v_row;
end if;
insert into public.chariot_submissions (user_id, text, updated_at)
values (auth.uid(), trim(p_text), now())
on conflict (user_id) do update
set text = excluded.text,
updated_at = now();
insert into public.chariot_submission_history (user_id, text)
values (auth.uid(), trim(p_text));
select * into v_row from public.chariot_submissions where user_id = auth.uid();
return v_row;
end;
$$;
grant execute on function public.submit_chariot_question(text) to authenticated;
-- Émails des membres pour /admin : jamais stockés dans profiles (voir §4 du
-- doc — le pseudo est le seul nom montré aux autres), donc invisibles côté
-- client sans passer par auth.users. Plutôt que d'exposer la service_role
-- key côté client, une RPC SECURITY DEFINER fait la jointure, gardée par la
-- même vérification de rôle que les autres RPC juge-only du projet.
create or replace function public.admin_list_members()
returns table (
id uuid,
pseudo text,
avatar_url text,
role text,
pseudo_locked boolean,
email text
)
language plpgsql
security definer
set search_path = public
as $$
begin
-- Colonnes de sortie qualifiées par un alias partout : returns table(...)
-- déclare id/role comme variables PL/pgSQL, qui masqueraient sinon les
-- colonnes de même nom dans profiles (erreur 42702 "ambiguous").
if not exists (select 1 from public.profiles pr where pr.id = auth.uid() and pr.role = 'judge') then
raise exception 'only judges can list members';
end if;
return query
select p.id, p.pseudo, p.avatar_url, p.role, p.pseudo_locked, u.email::text
from public.profiles p
join auth.users u on u.id = p.id
order by p.pseudo asc;
end;
$$;
grant execute on function public.admin_list_members() to authenticated;
-- 14. Le Mur de la Honte -------------------------------------------------------
-- Une note anonyme par Citoyen par jour, max 10 notes visibles par jour
-- (remise à zéro chaque jour — filtrage par date, pas de suppression), pour
-- donner une raison de rouvrir l'app tous les jours de la semaine, pas
-- seulement pour le classement. L'anonymat n'est qu'à moitié réel : les
-- Archontes voient l'historique complet avec l'auteur de chaque note.
-- Chaque note peut porter une image facultative (bucket wall-images,
-- compressée côté client, limite de taille côté serveur) et des réactions
-- emoji, elles aussi anonymes (voir wall_note_reactions plus bas).
create table if not exists public.wall_notes (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.profiles (id) on delete cascade,
text text not null,
created_at timestamptz not null default now()
);
-- URL publique dans le bucket wall-images (voir plus bas), facultative.
alter table public.wall_notes add column if not exists image_url text;
alter table public.wall_notes enable row level security;
revoke insert, update, delete on public.wall_notes from authenticated, anon;
grant select on public.wall_notes to authenticated;
drop policy if exists "wall_notes readable by owner or judges" on public.wall_notes;
create policy "wall_notes readable by owner or judges"
on public.wall_notes for select to authenticated
using (
user_id = auth.uid()
or exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge')
);
do $$
begin
alter publication supabase_realtime add table public.wall_notes;
exception
when duplicate_object then null;
end $$;
-- Seul moyen pour un Citoyen de voir les notes des autres sans jamais
-- exposer qui les a écrites côté client : la RLS ne peut pas masquer une
-- colonne pour certaines lignes, donc cette RPC (SECURITY DEFINER) fait le
-- filtre elle-même et ne renvoie jamais user_id. Le type de retour change
-- (ajout d'image_url) : drop explicite requis, create or replace ne peut
-- pas changer le type de retour d'une fonction existante.
drop function if exists public.wall_notes_today();
create or replace function public.wall_notes_today()
returns table (id uuid, text text, image_url text, created_at timestamptz)
language sql
security definer
set search_path = public
as $$
select w.id, w.text, w.image_url, w.created_at
from public.wall_notes w
where (w.created_at at time zone 'Europe/Paris')::date = (now() at time zone 'Europe/Paris')::date
order by w.created_at asc;
$$;
grant execute on function public.wall_notes_today() to authenticated;
-- Seul point d'entrée pour publier une note. Pas de gel via tribunal_date
-- (contrairement au Char) : contrairement à la soirée de clôture, c'est une
-- mécanique de toute la semaine. Petite fenêtre de course possible si deux
-- personnes postent la même seconde alors qu'il reste 1 place (double
-- insertion à 11) — acceptée sciemment, comme d'autres arbitrages similaires
-- déjà faits dans le projet pour un groupe de ~12 amis. La signature change
-- (ajout de p_image_url) : l'ancienne (text) est explicitement supprimée,
-- sinon create or replace créerait une 2e surcharge au lieu de remplacer.
drop function if exists public.post_wall_note(text);
create or replace function public.post_wall_note(p_text text, p_image_url text default null)
returns public.wall_notes
language plpgsql
security definer
set search_path = public
as $$
declare
v_caller_role text;
v_today date := (now() at time zone 'Europe/Paris')::date;
v_already_posted boolean;
v_count_today int;
v_image_url text := nullif(trim(coalesce(p_image_url, '')), '');
v_row public.wall_notes;
begin
if auth.uid() is null then
raise exception 'authentication required';
end if;
select role into v_caller_role from public.profiles where id = auth.uid();
if v_caller_role = 'judge' then
raise exception 'only citizens can post on the wall';
end if;
if p_text is null or length(trim(p_text)) = 0 then
raise exception 'note vide';
end if;
if length(p_text) > 200 then
raise exception 'note trop longue';
end if;
-- Doit venir du bucket wall-images géré par l'app, jamais une URL
-- arbitraire (le bucket a lui-même une limite de taille/mime, voir plus
-- bas — cette vérification empêche seulement d'y stocker n'importe quoi).
if v_image_url is not null and v_image_url not like '%/wall-images/%' then
raise exception 'image invalide';
end if;
select exists (
select 1 from public.wall_notes
where user_id = auth.uid()
and (created_at at time zone 'Europe/Paris')::date = v_today
) into v_already_posted;
if v_already_posted then
raise exception 'already posted today';
end if;
select count(*) into v_count_today
from public.wall_notes
where (created_at at time zone 'Europe/Paris')::date = v_today;
if v_count_today >= 10 then
raise exception 'wall full today';
end if;
insert into public.wall_notes (user_id, text, image_url)
values (auth.uid(), trim(p_text), v_image_url)
returning * into v_row;
return v_row;
end;
$$;
grant execute on function public.post_wall_note(text, text) to authenticated;
-- Bucket dédié aux images du Mur (pas "avatars") : chemin d'objet
-- volontairement SANS le user_id (contrairement aux avatars) pour ne
-- jamais faire fuiter l'auteur via l'URL publique de l'image — cohérent
-- avec l'anonymat de wall_notes_today. file_size_limit + allowed_mime_types
-- bornent le stockage côté Supabase (coût) ; le client compresse déjà
-- l'image bien en dessous de cette limite avant l'upload
-- (resizeImageToBlob), qui n'est qu'un filet de sécurité, pas la seule
-- garantie.
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values ('wall-images', 'wall-images', true, 3145728, array['image/webp', 'image/jpeg', 'image/png'])
on conflict (id) do update set
public = excluded.public,
file_size_limit = excluded.file_size_limit,
allowed_mime_types = excluded.allowed_mime_types;
drop policy if exists "Wall images are publicly viewable" on storage.objects;
create policy "Wall images are publicly viewable"
on storage.objects for select
using (bucket_id = 'wall-images');
drop policy if exists "Citizens can upload wall images" on storage.objects;
create policy "Citizens can upload wall images"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'wall-images'
and exists (select 1 from public.profiles p where p.id = auth.uid() and p.role <> 'judge')
);
-- Réactions sur les notes -------------------------------------------------
-- Même principe d'anonymat que les notes elles-mêmes : chacun ne voit que
-- ses propres réactions (pour savoir lesquelles sont déjà activées), le
-- compte agrégé par note et par emoji est exposé séparément par
-- wall_note_reaction_counts(), sans jamais révéler qui a réagi.
create table if not exists public.wall_note_reactions (
id bigint generated always as identity primary key,
note_id uuid not null references public.wall_notes (id) on delete cascade,
user_id uuid not null references public.profiles (id) on delete cascade,
emoji text not null check (emoji in ('👍', '😂', '😱', '❤️', '🔥')),
created_at timestamptz not null default now(),
unique (note_id, user_id, emoji)
);
alter table public.wall_note_reactions enable row level security;
revoke insert, update, delete on public.wall_note_reactions from authenticated, anon;
grant select on public.wall_note_reactions to authenticated;
drop policy if exists "wall_note_reactions readable by reactor only" on public.wall_note_reactions;
create policy "wall_note_reactions readable by reactor only"
on public.wall_note_reactions for select to authenticated
using (user_id = auth.uid());
do $$
begin
alter publication supabase_realtime add table public.wall_note_reactions;
exception
when duplicate_object then null;
end $$;
create or replace function public.wall_note_reaction_counts()
returns table (note_id uuid, emoji text, count bigint)
language sql
security definer
set search_path = public
as $$
select r.note_id, r.emoji, count(*) as count
from public.wall_note_reactions r
join public.wall_notes w on w.id = r.note_id
where (w.created_at at time zone 'Europe/Paris')::date = (now() at time zone 'Europe/Paris')::date
group by r.note_id, r.emoji;
$$;
grant execute on function public.wall_note_reaction_counts() to authenticated;
-- Bascule une réaction (ajoute si absente, retire si déjà posée) en un seul
-- aller-retour : un delete puis, si rien n'a été supprimé, un insert —
-- évite une lecture préalable puis un choix côté client entre 2 RPC.
create or replace function public.toggle_wall_note_reaction(p_note_id uuid, p_emoji text)
returns boolean
language plpgsql
security definer
set search_path = public
as $$
declare
v_allowed constant text[] := array['👍', '😂', '😱', '❤️', '🔥'];
v_deleted int;
begin
if auth.uid() is null then
raise exception 'authentication required';
end if;
if not (p_emoji = any(v_allowed)) then
raise exception 'emoji non autorisé';
end if;
if not exists (select 1 from public.wall_notes where id = p_note_id) then
raise exception 'note introuvable';
end if;
delete from public.wall_note_reactions
where note_id = p_note_id and user_id = auth.uid() and emoji = p_emoji;
get diagnostics v_deleted = row_count;
if v_deleted > 0 then
return false;
end if;
insert into public.wall_note_reactions (note_id, user_id, emoji)
values (p_note_id, auth.uid(), p_emoji);
return true;
end;
$$;
grant execute on function public.toggle_wall_note_reaction(uuid, text) to authenticated;
-- 15. L'Urne de l'Agora -------------------------------------------------------
-- Vote quotidien inspiré du vote à l'urne de l'Athènes antique : chaque jour
-- chaque Citoyen dépose un jeton sur une personne de son choix. Contrairement
-- au Mur de la Honte, les jetons s'accumulent sur toute la semaine dans un
-- classement public et cumulatif — identique pour tout le monde, 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
-- par personne est public. Les Archontes ne peuvent ni voter ni recevoir de
-- 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 (
id bigint generated always as identity primary key,
voter_id uuid not null references public.profiles (id) on delete cascade,
target_id uuid not null references public.profiles (id) on delete cascade,
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;
revoke insert, update, delete on public.urn_votes from authenticated, anon;
grant select on public.urn_votes to authenticated;
-- Pas d'exception juge ici (contrairement à chariot_submissions/wall_notes) :
-- même les Archontes ne voient que leur propre ligne (pour savoir "j'ai déjà
-- voté aujourd'hui") — qui a voté pour qui ne regarde personne d'autre.
drop policy if exists "urn_votes readable by voter only" on public.urn_votes;
create policy "urn_votes readable by voter only"
on public.urn_votes for select to authenticated
using (voter_id = auth.uid());
do $$
begin
alter publication supabase_realtime add table public.urn_votes;
exception
when duplicate_object then null;
end $$;
-- Seul moyen de calculer un total public par personne sans jamais exposer
-- une ligne individuelle (qui a voté pour qui) à qui que ce soit. Agrégé sur
-- tout l'historique (pas de filtre de date, contrairement à wall_notes_today
-- qui ne montre que le jour courant) : les jetons s'accumulent toute la
-- semaine.
create or replace function public.urn_vote_counts()
returns table (target_id uuid, votes bigint)
language sql
security definer
set search_path = public
as $$
select v.target_id, count(*) as votes
from public.urn_votes v
group by v.target_id;
$$;
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
-- (aucune RPC de modification/suppression). La signature change (ajout de
-- 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
language plpgsql
security definer
set search_path = public
as $$
declare
v_caller_role text;
v_target_role text;
v_today date := (now() at time zone 'Europe/Paris')::date;
v_already_voted boolean;
v_reason text := nullif(trim(coalesce(p_reason, '')), '');
v_row public.urn_votes;
begin
if auth.uid() is null then
raise exception 'authentication required';
end if;
select role into v_caller_role from public.profiles where id = auth.uid();
if v_caller_role = 'judge' then
raise exception 'only citizens can vote';
end if;
if p_target_id = auth.uid() then
raise exception 'cannot vote for yourself';
end if;
select role into v_target_role from public.profiles where id = p_target_id;
if v_target_role is null then
raise exception 'target member not found';
end if;
if v_target_role = 'judge' then
raise exception 'cannot vote for a judge';
end if;
if v_reason is not null and length(v_reason) > 200 then
raise exception 'justification trop longue';
end if;
select exists (
select 1 from public.urn_votes
where voter_id = auth.uid()
and (created_at at time zone 'Europe/Paris')::date = v_today
) into v_already_voted;
if v_already_voted then
raise exception 'already voted today';
end if;
insert into public.urn_votes (voter_id, target_id, reason)
values (auth.uid(), p_target_id, v_reason)
returning * into v_row;
return v_row;
end;
$$;
grant execute on function public.cast_urn_vote(uuid, text) to authenticated;
-- 16. La Corne d'Abondance -------------------------------------------------------
-- Mini-jeu de fusion façon "Watermelon Game" : des cercles tombent dans un
-- bac, deux cercles de même palier qui se touchent fusionnent en un cercle
-- plus gros, jusqu'à débordement. Au lieu de fruits, chaque cercle affiche
-- l'avatar d'un membre tiré au hasard (décoratif, indépendant du palier).
-- Même mécanique de gloires que Le Vol d'Icare : record personnel all-time,
-- scores figés à la date du Tribunal, gloires du top 3 attribuées
-- automatiquement (pg_cron, sans intervention d'un Archonte).
create table if not exists public.melon_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()
);
-- Plafond abaissé (audit de sécurité) : voir la remarque identique sur
-- icarus_scores. Resserré une deuxième fois à 2000 (au lieu de 500 comme
-- Icare) : l'économie de score est différente ici (pas de vitesse de
-- défilement fixe à borner dans le temps — le score dépend du nombre de
-- fusions enchaînées, beaucoup plus dur à borner analytiquement sans
-- données de vraies parties) donc une estimation plus prudente plutôt
-- qu'un calcul aussi précis que pour Icare ; pas de deuxième couche par
-- temps réel écoulé pour Corne dans cette passe, contrairement à Icare
-- (icarus_runs) — à ajouter plus tard si des scores encore trop hauts sont
-- observés en pratique.
alter table public.melon_scores drop constraint if exists melon_scores_best_score_check;
alter table public.melon_scores add constraint melon_scores_best_score_check
check (best_score between 0 and 2000);
alter table public.melon_scores enable row level security;
-- Même modèle que icarus_scores : verrouillée en écriture, seule la RPC
-- submit_melon_score() (SECURITY DEFINER) peut écrire.
revoke insert, update, delete on public.melon_scores from authenticated, anon;
grant select on public.melon_scores to authenticated;
drop policy if exists "melon_scores readable by authenticated" on public.melon_scores;
create policy "melon_scores readable by authenticated"
on public.melon_scores for select
to authenticated
using (true);
-- Marqueur d'idempotence pour l'attribution automatique des gloires (un seul
-- événement, comme icarus_points_awarded).
alter table public.settings add column if not exists melon_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_melon_score(p_score integer)
returns public.melon_scores
language plpgsql
security definer
set search_path = public
as $$
declare
v_tribunal_date timestamptz;
v_row public.melon_scores;
begin
if auth.uid() is null then
raise exception 'authentication required';
end if;
-- Plafond réaliste (2000, largement au-delà de ce qu'une vraie partie
-- peut atteindre), pas juste borné à 1 000 000 — voir la contrainte de
-- table associée (best_score_check), la vraie garantie ; cette
-- vérification donne juste un message d'erreur clair côté client.
if p_score is null or p_score < 0 or p_score > 2000 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.melon_scores where user_id = auth.uid();
return v_row;
end if;
insert into public.melon_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.melon_scores.best_score;
select * into v_row from public.melon_scores where user_id = auth.uid();
return v_row;
end;
$$;
grant execute on function public.submit_melon_score(integer) to authenticated;
do $$
begin
alter publication supabase_realtime add table public.melon_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 (melon_points_awarded).
-- Uniquement appelée par pg_cron ou depuis le SQL Editor : REVOKE explicite
-- ci-dessous (voir la même remarque pour award_icarus_points_if_due —
-- PostgreSQL accorde EXECUTE à PUBLIC par défaut sans révocation explicite).
create or replace function public.award_melon_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, melon_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.melon_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, 'La Corne d''Abondance — rang ' || r.rnk || ' au Tribunal');
end loop;
update public.settings set melon_points_awarded = true where id = true;
end;
$$;
revoke execute on function public.award_melon_points_if_due() from public, anon, authenticated;
-- ⚠️ pg_cron doit être activé une fois pour toutes via le Dashboard Supabase
-- (Database → Extensions → "pg_cron" → Enable) — voir la même remarque à la
-- section Icare. Les deux blocs ci-dessous n'échouent jamais bruyamment si
-- l'extension n'est pas encore activée.
do $$
begin
perform cron.unschedule('award-melon-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-melon-points',
'*/15 * * * *',
$cron$select public.award_melon_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 de La Corne d''Abondance.';
end $$;