0eaebb7a05
Build and deploy / deploy (push) Successful in 36s
wall_notes_today() devient wall_notes_for_date(p_date default null) — même garantie d'anonymat qu'avant, juste paramétrée par jour plutôt que figée sur aujourd'hui. Nouvelle RPC wall_note_days() (dates ayant au moins une note, sans contenu ni auteur) peuple un sélecteur de jour au-dessus de la grille, visible par tous — pas seulement les Archontes via l'historique complet, qui lui révèle l'auteur. wall_note_reaction_counts() gagne le même paramètre p_date. Le compteur "X/12 aujourd'hui" et l'éligibilité à publier restent toujours basés sur aujourd'hui, peu importe le jour consulté. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2249 lines
89 KiB
PL/PgSQL
2249 lines
89 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;
|
|
|
|
-- 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;
|
|
|
|
-- Anti-triche par signalement (Alexandre, appliqué directement en base —
|
|
-- récupéré ici via pg_get_functiondef le 2026-08-25 pour que ce fichier
|
|
-- redevienne la vraie source de vérité, voir game_cheat_flags en section
|
|
-- 17). Remplace la couche par rejet serveur strict qui vivait ici
|
|
-- (icarus_runs/start_icarus_run(), voir §3bis/V12 dans CLAUDE.md) : jamais
|
|
-- de blocage, un score dont la télémétrie de partie (durée, nombre de
|
|
-- battements d'aile — envoyée par le client dans p_run, voir
|
|
-- IcarusRunTelemetry) est manquante, invalide, ou incohérente avec le
|
|
-- rythme maximal du jeu déclenche seulement un signalement dans
|
|
-- game_cheat_flags pour vérification manuelle par un Archonte sur /admin.
|
|
-- Nettoyage de l'ancienne couche, devenue orpheline (plus aucun code
|
|
-- client ne l'appelle) :
|
|
drop function if exists public.submit_icarus_score(integer, uuid);
|
|
drop function if exists public.start_icarus_run();
|
|
drop table if exists public.icarus_runs;
|
|
|
|
-- 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;
|
|
|
|
-- Surcharge à un seul paramètre : conservée pour compat (ancien client,
|
|
-- appel RPC direct sans télémétrie), délègue à la version avec p_run à
|
|
-- null — systématiquement signalée comme suspecte ci-dessous, mais jamais
|
|
-- bloquée.
|
|
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_row public.icarus_scores;
|
|
begin
|
|
select * into v_row
|
|
from public.submit_icarus_score(p_score, null::jsonb);
|
|
|
|
return v_row;
|
|
end;
|
|
$$;
|
|
|
|
grant execute on function public.submit_icarus_score(integer) to authenticated;
|
|
|
|
-- Seul vrai point d'entrée utilisé par le client actuel. 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 — mais le signalement, lui, a déjà eu lieu avant ce point.
|
|
create or replace function public.submit_icarus_score(p_score integer, p_run jsonb)
|
|
returns public.icarus_scores
|
|
language plpgsql
|
|
security definer
|
|
set search_path = public
|
|
as $$
|
|
declare
|
|
v_tribunal_date timestamptz;
|
|
v_row public.icarus_scores;
|
|
v_previous_best integer;
|
|
v_duration_ms numeric;
|
|
v_actions numeric;
|
|
v_details jsonb;
|
|
-- Ping régulier envoyé par le client PENDANT la partie (ping_game_run(),
|
|
-- section 18) : preuve horodatée par le SERVEUR, contrairement à
|
|
-- duration_ms/actions ci-dessus qui restent entièrement déclaratifs
|
|
-- (calculés et envoyés par le client en une seule fois à la fin, donc
|
|
-- falsifiables par un appel RPC direct sans avoir vraiment joué).
|
|
v_session_token uuid;
|
|
v_ping_count integer;
|
|
v_first_ping_at timestamptz;
|
|
v_last_ping_at timestamptz;
|
|
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 best_score into v_previous_best
|
|
from public.icarus_scores
|
|
where user_id = auth.uid();
|
|
|
|
if p_run is null
|
|
or jsonb_typeof(p_run) is distinct from 'object'
|
|
or (p_run ->> 'v') is distinct from '1' then
|
|
|
|
insert into public.game_cheat_flags
|
|
(user_id, game, score, severity, trigger_code, reason, details)
|
|
values (
|
|
auth.uid(),
|
|
'icarus',
|
|
p_score,
|
|
'high',
|
|
'missing_run_telemetry',
|
|
'Score soumis sans télémétrie de partie (appel RPC direct ou ancien client).',
|
|
jsonb_build_object('previous_best', v_previous_best)
|
|
);
|
|
|
|
else
|
|
if jsonb_typeof(p_run -> 'duration_ms') = 'number' then
|
|
v_duration_ms := (p_run ->> 'duration_ms')::numeric;
|
|
end if;
|
|
|
|
if jsonb_typeof(p_run -> 'actions') = 'number' then
|
|
v_actions := (p_run ->> 'actions')::numeric;
|
|
end if;
|
|
|
|
-- Extraction protégée (begin/exception imbriqué) : une valeur
|
|
-- malformée ne doit jamais faire échouer toute la soumission, juste
|
|
-- être traitée comme une absence de token (donc 0 ping trouvé,
|
|
-- signalé plus bas comme les autres incohérences).
|
|
begin
|
|
v_session_token := nullif(p_run ->> 'session_token', '')::uuid;
|
|
exception
|
|
when invalid_text_representation then
|
|
v_session_token := null;
|
|
end;
|
|
|
|
v_details := jsonb_build_object(
|
|
'duration_ms', v_duration_ms,
|
|
'actions', v_actions,
|
|
'previous_best', v_previous_best
|
|
);
|
|
|
|
if v_duration_ms is null
|
|
or v_actions is null
|
|
or v_duration_ms < 0
|
|
or v_actions < 0 then
|
|
|
|
insert into public.game_cheat_flags
|
|
(user_id, game, score, severity, trigger_code, reason, details)
|
|
values (
|
|
auth.uid(),
|
|
'icarus',
|
|
p_score,
|
|
'high',
|
|
'invalid_run_telemetry',
|
|
'La télémétrie de la partie est absente ou invalide.',
|
|
v_details
|
|
);
|
|
|
|
else
|
|
if p_score >= 3 and v_duration_ms < p_score * 450 then
|
|
insert into public.game_cheat_flags
|
|
(user_id, game, score, severity, trigger_code, reason, details)
|
|
values (
|
|
auth.uid(),
|
|
'icarus',
|
|
p_score,
|
|
'high',
|
|
'impossible_score_pace',
|
|
'Le score a progressé plus vite que la vitesse maximale du jeu ne le permet.',
|
|
v_details
|
|
);
|
|
end if;
|
|
|
|
if p_score > 0 and v_actions < 1 then
|
|
insert into public.game_cheat_flags
|
|
(user_id, game, score, severity, trigger_code, reason, details)
|
|
values (
|
|
auth.uid(),
|
|
'icarus',
|
|
p_score,
|
|
'high',
|
|
'score_without_input',
|
|
'Un score positif a été soumis sans aucun battement d''aile enregistré.',
|
|
v_details
|
|
);
|
|
end if;
|
|
|
|
if v_duration_ms > 0
|
|
and v_actions > (v_duration_ms / 1000) * 20 + 10 then
|
|
|
|
insert into public.game_cheat_flags
|
|
(user_id, game, score, severity, trigger_code, reason, details)
|
|
values (
|
|
auth.uid(),
|
|
'icarus',
|
|
p_score,
|
|
'warning',
|
|
'extreme_input_rate',
|
|
'Cadence de battements anormalement élevée sur la partie.',
|
|
v_details
|
|
);
|
|
end if;
|
|
|
|
-- En dessous de 15s, aucun ping n'est attendu (le premier n'arrive
|
|
-- qu'après un cycle d'intervalle côté client, ~5s) : pas de
|
|
-- signalement pour une partie courte, y compris un échec immédiat.
|
|
if v_duration_ms >= 15000 then
|
|
select count(*), min(created_at), max(created_at)
|
|
into v_ping_count, v_first_ping_at, v_last_ping_at
|
|
from public.game_run_pings
|
|
where user_id = auth.uid() and game = 'icarus' and session_token = v_session_token;
|
|
|
|
if v_ping_count = 0 then
|
|
insert into public.game_cheat_flags
|
|
(user_id, game, score, severity, trigger_code, reason, details)
|
|
values (
|
|
auth.uid(),
|
|
'icarus',
|
|
p_score,
|
|
'high',
|
|
'missing_pings',
|
|
'Aucun ping serveur reçu pendant une partie annoncée assez longue pour en attendre.',
|
|
v_details || jsonb_build_object('ping_count', v_ping_count)
|
|
);
|
|
elsif extract(epoch from (v_last_ping_at - v_first_ping_at)) * 1000 < v_duration_ms * 0.4 then
|
|
insert into public.game_cheat_flags
|
|
(user_id, game, score, severity, trigger_code, reason, details)
|
|
values (
|
|
auth.uid(),
|
|
'icarus',
|
|
p_score,
|
|
'high',
|
|
'ping_span_mismatch',
|
|
'L''écart réel entre les pings reçus est bien plus court que la durée de partie annoncée.',
|
|
v_details || jsonb_build_object(
|
|
'ping_count', v_ping_count,
|
|
'ping_span_ms', extract(epoch from (v_last_ping_at - v_first_ping_at)) * 1000
|
|
)
|
|
);
|
|
end if;
|
|
end if;
|
|
end if;
|
|
|
|
-- Nettoyage : les pings de cette partie ont été consommés (signalés ou
|
|
-- non), inutile de les garder — évite aussi qu'un même session_token
|
|
-- réutilisé par erreur ne corrèle avec une partie précédente.
|
|
if v_session_token is not null then
|
|
delete from public.game_run_pings
|
|
where user_id = auth.uid() and session_token = v_session_token;
|
|
end if;
|
|
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, jsonb) 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. Paramétrée par jour
|
|
-- (p_date, Europe/Paris) plutôt que hardcodée sur aujourd'hui — remplace
|
|
-- wall_notes_today(), pour que n'importe qui (pas seulement les Archontes
|
|
-- via « Historique complet » plus bas, qui révèle lui l'auteur) puisse
|
|
-- consulter les jours précédents, toujours sans jamais exposer qui a
|
|
-- écrit quoi. p_date à null (par défaut) = aujourd'hui, pour que l'appel
|
|
-- de chargement initial de la page reste un simple appel sans argument.
|
|
drop function if exists public.wall_notes_today();
|
|
|
|
create or replace function public.wall_notes_for_date(p_date date default null)
|
|
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 = coalesce(p_date, (now() at time zone 'Europe/Paris')::date)
|
|
order by w.created_at asc;
|
|
$$;
|
|
|
|
grant execute on function public.wall_notes_for_date(date) to authenticated;
|
|
|
|
-- Liste des jours ayant au moins une note (jamais le contenu ni l'auteur,
|
|
-- juste la date) : peuple le sélecteur de jour de wall_notes_for_date()
|
|
-- côté client, pour tout le monde — contrairement à l'historique complet
|
|
-- avec auteur plus bas, réservé aux Archontes.
|
|
create or replace function public.wall_note_days()
|
|
returns table (note_date date)
|
|
language sql
|
|
security definer
|
|
set search_path = public
|
|
as $$
|
|
select distinct (w.created_at at time zone 'Europe/Paris')::date as note_date
|
|
from public.wall_notes w
|
|
order by note_date desc;
|
|
$$;
|
|
|
|
grant execute on function public.wall_note_days() 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 à 13) — acceptée sciemment, comme d'autres arbitrages similaires
|
|
-- déjà faits dans le projet pour un groupe de ~12 amis. Ouvert aux Archontes
|
|
-- en plus des Citoyens (plus de vérification de rôle) : plafond relevé à 12
|
|
-- (au lieu de 10) pour couvrir tout le groupe, pas seulement les Citoyens.
|
|
-- 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_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;
|
|
|
|
-- Les Archontes peuvent désormais publier eux aussi (plus réservé aux
|
|
-- Citoyens) : plus de vérification de rôle ici, tout authentifié peut
|
|
-- poster une note anonyme — la limite de 12 (au lieu de 10) plus bas
|
|
-- suit ce changement pour couvrir tout le groupe.
|
|
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 >= 12 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');
|
|
|
|
-- Les Archontes peuvent désormais publier sur le Mur eux aussi (voir
|
|
-- post_wall_note plus haut, qui n'a plus de restriction de rôle) : plus de
|
|
-- filtre par rôle ici non plus, sinon un Archonte pourrait poster une note
|
|
-- mais pas y joindre d'image.
|
|
drop policy if exists "Citizens can upload wall images" on storage.objects;
|
|
drop policy if exists "Members can upload wall images" on storage.objects;
|
|
create policy "Members can upload wall images"
|
|
on storage.objects for insert
|
|
to authenticated
|
|
with check (bucket_id = 'wall-images');
|
|
|
|
-- 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 $$;
|
|
|
|
-- Paramétrée par jour (comme wall_notes_for_date ci-dessus, même p_date à
|
|
-- null = aujourd'hui, donc les anciens appels sans argument continuent de
|
|
-- fonctionner à l'identique). L'ancienne surcharge à 0 argument est
|
|
-- explicitement supprimée : create or replace ne remplace que la même
|
|
-- signature, sinon les deux versions coexisteraient (même remarque que
|
|
-- pour post_wall_note plus haut).
|
|
drop function if exists public.wall_note_reaction_counts();
|
|
|
|
create or replace function public.wall_note_reaction_counts(p_date date default null)
|
|
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 = coalesce(p_date, (now() at time zone 'Europe/Paris')::date)
|
|
group by r.note_id, r.emoji;
|
|
$$;
|
|
|
|
grant execute on function public.wall_note_reaction_counts(date) 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;
|
|
|
|
-- Anti-triche par signalement (Alexandre, appliqué directement en base —
|
|
-- récupéré ici via pg_get_functiondef le 2026-08-25, même mécanisme que
|
|
-- submit_icarus_score plus haut, voir §3bis/V12 dans CLAUDE.md et
|
|
-- game_cheat_flags en section 17) : jamais de blocage, une partie dont la
|
|
-- télémétrie (durée, nombre de lâchers, nombre de fusions — envoyée par le
|
|
-- client dans p_run, voir MelonRunTelemetry) est manquante, invalide, ou
|
|
-- incohérente avec ce que la physique du jeu permet (cooldown de lâcher,
|
|
-- nombre de fusions borné par le nombre de lâchers, score borné par le
|
|
-- gain maximal théorique par fusion) déclenche un signalement dans
|
|
-- game_cheat_flags pour vérification manuelle par un Archonte sur /admin.
|
|
--
|
|
-- Surcharge à un seul paramètre : conservée pour compat, délègue à la
|
|
-- version avec p_run à null — systématiquement signalée comme suspecte
|
|
-- ci-dessous, mais jamais bloquée.
|
|
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_row public.melon_scores;
|
|
begin
|
|
select * into v_row
|
|
from public.submit_melon_score(p_score, null::jsonb);
|
|
|
|
return v_row;
|
|
end;
|
|
$$;
|
|
|
|
grant execute on function public.submit_melon_score(integer) to authenticated;
|
|
|
|
-- Seul vrai point d'entrée utilisé par le client actuel. 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 — mais le signalement, lui, a déjà eu lieu avant ce point.
|
|
create or replace function public.submit_melon_score(p_score integer, p_run jsonb)
|
|
returns public.melon_scores
|
|
language plpgsql
|
|
security definer
|
|
set search_path = public
|
|
as $$
|
|
declare
|
|
v_tribunal_date timestamptz;
|
|
v_row public.melon_scores;
|
|
v_previous_best integer;
|
|
v_duration_ms numeric;
|
|
v_actions numeric;
|
|
v_merges numeric;
|
|
v_citizen_count integer;
|
|
v_max_tier integer;
|
|
v_details jsonb;
|
|
-- Ping régulier envoyé par le client PENDANT la partie (ping_game_run(),
|
|
-- section 18) : preuve horodatée par le SERVEUR, contrairement à
|
|
-- duration_ms/actions/merges ci-dessus qui restent entièrement
|
|
-- déclaratifs — voir la même remarque sur submit_icarus_score.
|
|
v_session_token uuid;
|
|
v_ping_count integer;
|
|
v_first_ping_at timestamptz;
|
|
v_last_ping_at timestamptz;
|
|
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 best_score into v_previous_best
|
|
from public.melon_scores
|
|
where user_id = auth.uid();
|
|
|
|
select count(*)::integer into v_citizen_count
|
|
from public.profiles
|
|
where role = 'public';
|
|
|
|
v_max_tier := greatest(v_citizen_count - 1, 0);
|
|
|
|
if p_run is null
|
|
or jsonb_typeof(p_run) is distinct from 'object'
|
|
or (p_run ->> 'v') is distinct from '1' then
|
|
|
|
insert into public.game_cheat_flags
|
|
(user_id, game, score, severity, trigger_code, reason, details)
|
|
values (
|
|
auth.uid(),
|
|
'melon',
|
|
p_score,
|
|
'high',
|
|
'missing_run_telemetry',
|
|
'Score soumis sans télémétrie de partie (appel RPC direct ou ancien client).',
|
|
jsonb_build_object('previous_best', v_previous_best)
|
|
);
|
|
|
|
else
|
|
if jsonb_typeof(p_run -> 'duration_ms') = 'number' then
|
|
v_duration_ms := (p_run ->> 'duration_ms')::numeric;
|
|
end if;
|
|
|
|
if jsonb_typeof(p_run -> 'actions') = 'number' then
|
|
v_actions := (p_run ->> 'actions')::numeric;
|
|
end if;
|
|
|
|
if jsonb_typeof(p_run -> 'merges') = 'number' then
|
|
v_merges := (p_run ->> 'merges')::numeric;
|
|
end if;
|
|
|
|
-- Extraction protégée (begin/exception imbriqué) : une valeur
|
|
-- malformée ne doit jamais faire échouer toute la soumission, juste
|
|
-- être traitée comme une absence de token (donc 0 ping trouvé,
|
|
-- signalé plus bas comme les autres incohérences).
|
|
begin
|
|
v_session_token := nullif(p_run ->> 'session_token', '')::uuid;
|
|
exception
|
|
when invalid_text_representation then
|
|
v_session_token := null;
|
|
end;
|
|
|
|
v_details := jsonb_build_object(
|
|
'duration_ms', v_duration_ms,
|
|
'actions', v_actions,
|
|
'merges', v_merges,
|
|
'previous_best', v_previous_best
|
|
);
|
|
|
|
if v_duration_ms is null
|
|
or v_actions is null
|
|
or v_merges is null
|
|
or v_duration_ms < 0
|
|
or v_actions < 0
|
|
or v_merges < 0 then
|
|
|
|
insert into public.game_cheat_flags
|
|
(user_id, game, score, severity, trigger_code, reason, details)
|
|
values (
|
|
auth.uid(),
|
|
'melon',
|
|
p_score,
|
|
'high',
|
|
'invalid_run_telemetry',
|
|
'La télémétrie de la partie est absente ou invalide.',
|
|
v_details
|
|
);
|
|
|
|
else
|
|
if p_score > 0 and (v_actions < 2 or v_merges < 1) then
|
|
insert into public.game_cheat_flags
|
|
(user_id, game, score, severity, trigger_code, reason, details)
|
|
values (
|
|
auth.uid(),
|
|
'melon',
|
|
p_score,
|
|
'high',
|
|
'score_without_required_moves',
|
|
'Un score positif nécessite au moins deux lâchers et une fusion.',
|
|
v_details
|
|
);
|
|
end if;
|
|
|
|
if v_actions >= 2
|
|
and v_duration_ms < (v_actions - 1) * 400 then
|
|
|
|
insert into public.game_cheat_flags
|
|
(user_id, game, score, severity, trigger_code, reason, details)
|
|
values (
|
|
auth.uid(),
|
|
'melon',
|
|
p_score,
|
|
'high',
|
|
'impossible_drop_rate',
|
|
'Les pièces ont été lâchées plus vite que le délai minimal du jeu ne le permet.',
|
|
v_details
|
|
);
|
|
end if;
|
|
|
|
if v_merges > greatest(v_actions - 1, 0) then
|
|
insert into public.game_cheat_flags
|
|
(user_id, game, score, severity, trigger_code, reason, details)
|
|
values (
|
|
auth.uid(),
|
|
'melon',
|
|
p_score,
|
|
'high',
|
|
'impossible_merge_count',
|
|
'Le nombre de fusions dépasse ce que le nombre de pièces lâchées permet.',
|
|
v_details
|
|
);
|
|
end if;
|
|
|
|
if v_merges > 0
|
|
and p_score > v_merges * greatest(v_max_tier * 2, 2) then
|
|
|
|
insert into public.game_cheat_flags
|
|
(user_id, game, score, severity, trigger_code, reason, details)
|
|
values (
|
|
auth.uid(),
|
|
'melon',
|
|
p_score,
|
|
'high',
|
|
'impossible_score_from_merges',
|
|
'Le score dépasse le maximum théorique permis par les fusions enregistrées.',
|
|
v_details
|
|
);
|
|
end if;
|
|
|
|
-- En dessous de 15s, aucun ping n'est attendu (le premier n'arrive
|
|
-- qu'après un cycle d'intervalle côté client, ~5s) : pas de
|
|
-- signalement pour une partie courte, y compris un échec immédiat.
|
|
if v_duration_ms >= 15000 then
|
|
select count(*), min(created_at), max(created_at)
|
|
into v_ping_count, v_first_ping_at, v_last_ping_at
|
|
from public.game_run_pings
|
|
where user_id = auth.uid() and game = 'melon' and session_token = v_session_token;
|
|
|
|
if v_ping_count = 0 then
|
|
insert into public.game_cheat_flags
|
|
(user_id, game, score, severity, trigger_code, reason, details)
|
|
values (
|
|
auth.uid(),
|
|
'melon',
|
|
p_score,
|
|
'high',
|
|
'missing_pings',
|
|
'Aucun ping serveur reçu pendant une partie annoncée assez longue pour en attendre.',
|
|
v_details || jsonb_build_object('ping_count', v_ping_count)
|
|
);
|
|
elsif extract(epoch from (v_last_ping_at - v_first_ping_at)) * 1000 < v_duration_ms * 0.4 then
|
|
insert into public.game_cheat_flags
|
|
(user_id, game, score, severity, trigger_code, reason, details)
|
|
values (
|
|
auth.uid(),
|
|
'melon',
|
|
p_score,
|
|
'high',
|
|
'ping_span_mismatch',
|
|
'L''écart réel entre les pings reçus est bien plus court que la durée de partie annoncée.',
|
|
v_details || jsonb_build_object(
|
|
'ping_count', v_ping_count,
|
|
'ping_span_ms', extract(epoch from (v_last_ping_at - v_first_ping_at)) * 1000
|
|
)
|
|
);
|
|
end if;
|
|
end if;
|
|
end if;
|
|
|
|
-- Nettoyage : les pings de cette partie ont été consommés (signalés ou
|
|
-- non), inutile de les garder — évite aussi qu'un même session_token
|
|
-- réutilisé par erreur ne corrèle avec une partie précédente.
|
|
if v_session_token is not null then
|
|
delete from public.game_run_pings
|
|
where user_id = auth.uid() and session_token = v_session_token;
|
|
end if;
|
|
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, jsonb) 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 $$;
|
|
|
|
-- 17. Anti-triche par signalement (Alexandre) --------------------------------
|
|
-- Remplace icarus_runs (ancienne section 11, supprimée) côté client : plutôt
|
|
-- qu'un rejet serveur strict basé sur le temps réellement écoulé, cette
|
|
-- table journalise les parties suspectes (durée/nombre d'actions/fusions
|
|
-- incohérents avec le score, calculés côté client et envoyés dans p_run à
|
|
-- submit_icarus_score/submit_melon_score, section 11/16) pour qu'un
|
|
-- Archonte les vérifie à la main sur /admin (AdminCheatFlags) — les scores
|
|
-- concernés restent acceptés, rien n'est jamais bloqué automatiquement.
|
|
--
|
|
-- Table, policy RLS et logique d'écriture (submit_icarus_score/
|
|
-- submit_melon_score) toutes récupérées directement depuis Supabase
|
|
-- (dump de schéma + pg_policies + pg_get_functiondef, 2026-08-25) — ce
|
|
-- fichier est de nouveau la vraie source de vérité pour cette
|
|
-- fonctionnalité, contrairement à l'état précédent.
|
|
create table if not exists public.game_cheat_flags (
|
|
id bigint generated always as identity primary key,
|
|
user_id uuid not null references public.profiles (id),
|
|
game text not null check (game in ('icarus', 'melon')),
|
|
score integer not null,
|
|
severity text not null check (severity in ('warning', 'high')),
|
|
trigger_code text not null,
|
|
reason text not null,
|
|
details jsonb not null default '{}'::jsonb,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
alter table public.game_cheat_flags enable row level security;
|
|
|
|
-- Pas de grant insert/update/delete à authenticated : comme points_log/
|
|
-- icarus_scores, seules les fonctions SECURITY DEFINER submit_icarus_score/
|
|
-- submit_melon_score (section 11/16) peuvent y écrire.
|
|
revoke insert, update, delete on public.game_cheat_flags from authenticated, anon;
|
|
grant select on public.game_cheat_flags to authenticated;
|
|
|
|
drop policy if exists "game_cheat_flags readable by judges" on public.game_cheat_flags;
|
|
create policy "game_cheat_flags readable by judges"
|
|
on public.game_cheat_flags for select
|
|
to authenticated
|
|
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
|
|
|
-- 18. Anti-triche : ping régulier pendant la partie --------------------------
|
|
-- Complète le signalement (section 17) : duration_ms/actions/merges dans
|
|
-- p_run sont entièrement déclaratifs (calculés et envoyés par le client en
|
|
-- une seule fois à la fin), donc falsifiables par quelqu'un qui appelle
|
|
-- submit_icarus_score/submit_melon_score directement sans avoir vraiment
|
|
-- joué. Le client envoie un ping régulier PENDANT la partie (toutes les
|
|
-- ~5s tant qu'elle est en cours), horodaté ici par l'horloge du SERVEUR —
|
|
-- submit_icarus_score/submit_melon_score (section 11/16) corrèlent ensuite
|
|
-- le nombre de pings et l'écart réel entre le premier et le dernier avec
|
|
-- la durée annoncée, et signalent (jamais ne bloquent, même philosophie
|
|
-- que le reste de l'anti-triche) toute incohérence.
|
|
create table if not exists public.game_run_pings (
|
|
id bigint generated always as identity primary key,
|
|
user_id uuid not null references public.profiles (id) on delete cascade,
|
|
game text not null check (game in ('icarus', 'melon')),
|
|
session_token uuid not null,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
alter table public.game_run_pings enable row level security;
|
|
-- Aucun grant à authenticated/anon (même principe que l'ancien icarus_runs,
|
|
-- section 11) : seules ping_game_run() (insert) et submit_icarus_score/
|
|
-- submit_melon_score (lecture + nettoyage), toutes SECURITY DEFINER,
|
|
-- contournent la RLS en tant que propriétaires des fonctions.
|
|
|
|
create or replace function public.ping_game_run(p_game text, p_session_token uuid)
|
|
returns void
|
|
language plpgsql
|
|
security definer
|
|
set search_path = public
|
|
as $$
|
|
begin
|
|
if auth.uid() is null then
|
|
raise exception 'authentication required';
|
|
end if;
|
|
|
|
if p_game not in ('icarus', 'melon') then
|
|
raise exception 'invalid game';
|
|
end if;
|
|
|
|
insert into public.game_run_pings (user_id, game, session_token)
|
|
values (auth.uid(), p_game, p_session_token);
|
|
end;
|
|
$$;
|
|
|
|
grant execute on function public.ping_game_run(text, uuid) to authenticated;
|