777d9ebaea
- chariot_runs : meilleur temps + tracé fantôme par joueur et par jour, verrouillée comme points_log (écriture uniquement via RPC). - submit_chariot_run() : calcule la date côté serveur, borne le temps, n'écrase que si strictement meilleur. - close_daily_chariot_race() + chariot_race_closes : clôture la journée précédente et attribue les gloires du top 3 automatiquement, sans intervention d'un Archonte — planifiée via pg_cron (à activer une fois côté Dashboard Supabase). - points_log.judge_id devient nullable (un point auto n'a pas de juge) ; Le Crieur affiche "Le Tribunal" pour ces décrets-là.
654 lines
26 KiB
PL/PgSQL
654 lines
26 KiB
PL/PgSQL
-- Le Tribunal — schéma V2 + V3 (auth par email, rôles, verrou de pseudo,
|
|
-- points/journal/podium/indicateurs de progression)
|
|
-- À exécuter dans l'éditeur SQL du projet Supabase.
|
|
|
|
-- ============================================================
|
|
-- Migration depuis V1 (authentification pseudo + email interne)
|
|
-- ============================================================
|
|
-- V1 utilisait un email fictif dérivé du pseudo (slug@letribunal.test).
|
|
-- V2 exige un vrai email par utilisateur (unique, confirmé). Les comptes
|
|
-- créés sous V1 sont donc invalides pour V2. Comme il ne s'agit que de
|
|
-- données de test, on repart de zéro :
|
|
--
|
|
-- 1. Dashboard Supabase → Authentication → Providers → Email
|
|
-- → activer "Confirm email".
|
|
-- 2. SQL Editor → exécuter la ligne ci-dessous pour supprimer tous les
|
|
-- comptes de test (cascade automatiquement sur public.profiles) :
|
|
--
|
|
-- delete from auth.users;
|
|
--
|
|
-- 3. Exécuter tout le script ci-dessous.
|
|
-- 4. Storage → bucket "avatars" → vider les fichiers de test existants
|
|
-- (les anciens noms de fichiers restent valides, ils ne gênent pas,
|
|
-- mais autant repartir propre).
|
|
-- ============================================================
|
|
|
|
-- 1. Table profiles ----------------------------------------------------
|
|
|
|
create table if not exists public.profiles (
|
|
id uuid primary key references auth.users (id) on delete cascade,
|
|
pseudo text not null,
|
|
avatar_url text,
|
|
points integer not null default 0,
|
|
role text not null default 'public' check (role in ('public', 'judge')),
|
|
pseudo_locked boolean not null default false,
|
|
previous_rank integer,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
-- Colonnes ajoutées si la table existait déjà depuis une version antérieure
|
|
-- (no-op si déjà présentes).
|
|
alter table public.profiles add column if not exists role text not null default 'public';
|
|
alter table public.profiles add column if not exists pseudo_locked boolean not null default false;
|
|
alter table public.profiles add column if not exists previous_rank integer;
|
|
alter table public.profiles drop column if exists slug;
|
|
|
|
alter table public.profiles drop constraint if exists profiles_role_check;
|
|
alter table public.profiles add constraint profiles_role_check check (role in ('public', 'judge'));
|
|
|
|
-- Unicité du pseudo affiché (insensible à la casse).
|
|
drop index if exists profiles_slug_key;
|
|
create unique index if not exists profiles_pseudo_lower_key on public.profiles (lower(pseudo));
|
|
|
|
alter table public.profiles enable row level security;
|
|
|
|
-- Pas de grant insert : la création du profil passe exclusivement par le
|
|
-- trigger handle_new_user() (SECURITY DEFINER, contourne la RLS).
|
|
grant select, update on public.profiles to authenticated;
|
|
|
|
-- 2. Fonctions & triggers -------------------------------------------------
|
|
-- La RLS est au niveau ligne : elle ne peut pas exprimer "cette colonne
|
|
-- seulement si tel rôle". On verrouille donc les colonnes sensibles
|
|
-- (role, points, pseudo_locked, pseudo figé) via des triggers, qui ne
|
|
-- font jamais confiance à ce que le client envoie.
|
|
|
|
-- À la création d'un profil : on ignore ce que le client a pu envoyer
|
|
-- pour role/pseudo_locked/points et on force les valeurs par défaut.
|
|
-- Le pseudo initial est repris depuis auth.users si non fourni explicitement.
|
|
create or replace function public.enforce_profile_insert()
|
|
returns trigger
|
|
language plpgsql
|
|
as $$
|
|
begin
|
|
new.role := 'public';
|
|
new.pseudo_locked := false;
|
|
new.points := 0;
|
|
return new;
|
|
end;
|
|
$$;
|
|
|
|
drop trigger if exists profiles_before_insert on public.profiles;
|
|
create trigger profiles_before_insert
|
|
before insert on public.profiles
|
|
for each row execute function public.enforce_profile_insert();
|
|
|
|
-- À la mise à jour : selon le rôle de l'appelant (auth.uid()), on
|
|
-- autorise ou rejette les changements de colonnes sensibles.
|
|
create or replace function public.enforce_profile_update()
|
|
returns trigger
|
|
language plpgsql
|
|
as $$
|
|
declare
|
|
caller_role text;
|
|
points_bypass boolean := coalesce(current_setting('app.bypass_points_lock', true), 'off') = 'on';
|
|
begin
|
|
-- auth.uid() est NULL hors contexte d'une requête utilisateur (SQL Editor,
|
|
-- migrations, clé service_role). Ces accès sont déjà pleinement fiables
|
|
-- par construction (accès direct à la base), donc on les laisse passer.
|
|
if auth.uid() is null then
|
|
return new;
|
|
end if;
|
|
|
|
-- `points` ne peut être modifié que via la RPC award_points(), jamais
|
|
-- directement par le client — même un juge ne peut pas l'écrire à la main.
|
|
if new.points is distinct from old.points and not points_bypass then
|
|
raise exception 'points must be modified via award_points()';
|
|
end if;
|
|
|
|
select role into caller_role from public.profiles where id = auth.uid();
|
|
|
|
if caller_role = 'judge' then
|
|
-- Un juge peut changer pseudo / pseudo_locked / role / avatar_url /
|
|
-- previous_rank de n'importe qui.
|
|
return new;
|
|
end if;
|
|
|
|
-- Appelant non-juge : ne peut modifier que sa propre ligne (déjà imposé
|
|
-- par la RLS), et seulement pseudo / avatar_url, sous conditions.
|
|
if new.role is distinct from old.role then
|
|
raise exception 'only judges can change role';
|
|
end if;
|
|
|
|
if new.previous_rank is distinct from old.previous_rank then
|
|
raise exception 'only judges can reset the rank reference';
|
|
end if;
|
|
|
|
if new.pseudo_locked is distinct from old.pseudo_locked and new.pseudo_locked = false then
|
|
raise exception 'only judges can unlock a pseudo';
|
|
end if;
|
|
|
|
if new.pseudo is distinct from old.pseudo then
|
|
if old.pseudo_locked then
|
|
raise exception 'pseudo is locked, ask a judge';
|
|
end if;
|
|
-- Premier changement de pseudo : on le fige automatiquement.
|
|
new.pseudo_locked := true;
|
|
end if;
|
|
|
|
return new;
|
|
end;
|
|
$$;
|
|
|
|
drop trigger if exists profiles_before_update on public.profiles;
|
|
create trigger profiles_before_update
|
|
before update on public.profiles
|
|
for each row execute function public.enforce_profile_update();
|
|
|
|
-- Création automatique du profil à l'inscription (auth.users → profiles),
|
|
-- indépendamment de l'état de la session (fonctionne même si l'email
|
|
-- n'est pas encore confirmé, contrairement à un insert fait depuis le client).
|
|
create or replace function public.handle_new_user()
|
|
returns trigger
|
|
language plpgsql
|
|
security definer set search_path = public
|
|
as $$
|
|
begin
|
|
insert into public.profiles (id, pseudo)
|
|
values (new.id, coalesce(new.raw_user_meta_data ->> 'pseudo', 'Nouveau membre'));
|
|
return new;
|
|
end;
|
|
$$;
|
|
|
|
drop trigger if exists on_auth_user_created on auth.users;
|
|
create trigger on_auth_user_created
|
|
after insert on auth.users
|
|
for each row execute function public.handle_new_user();
|
|
|
|
-- 3. Policies RLS ----------------------------------------------------------
|
|
|
|
drop policy if exists "Profiles are viewable by authenticated users" on public.profiles;
|
|
create policy "Profiles are viewable by authenticated users"
|
|
on public.profiles for select
|
|
to authenticated
|
|
using (true);
|
|
|
|
-- Plus de policy INSERT côté client : la création du profil passe
|
|
-- exclusivement par le trigger handle_new_user() (SECURITY DEFINER).
|
|
drop policy if exists "Users can insert their own profile" on public.profiles;
|
|
|
|
drop policy if exists "Users can update their own profile" on public.profiles;
|
|
create policy "Users can update their own profile"
|
|
on public.profiles for update
|
|
to authenticated
|
|
using (auth.uid() = id)
|
|
with check (auth.uid() = id);
|
|
|
|
drop policy if exists "Judges can update any profile" on public.profiles;
|
|
create policy "Judges can update any profile"
|
|
on public.profiles for update
|
|
to authenticated
|
|
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'))
|
|
with check (true);
|
|
|
|
-- Aucune policy delete => suppression interdite en V2.
|
|
|
|
-- 4. Bucket avatars -------------------------------------------------------
|
|
|
|
insert into storage.buckets (id, name, public)
|
|
values ('avatars', 'avatars', true)
|
|
on conflict (id) do nothing;
|
|
|
|
-- Le bucket public sert la lecture via l'URL publique (hors RLS), mais une
|
|
-- policy SELECT reste nécessaire : pour un upload en upsert (remplacement
|
|
-- d'une photo existante), Postgres doit pouvoir lire la ligne existante en
|
|
-- interne pour évaluer la clause USING de l'UPDATE — sans SELECT, l'upsert
|
|
-- échoue avec "new row violates row-level security policy" même si les
|
|
-- policies INSERT/UPDATE sont correctes.
|
|
drop policy if exists "Avatars are publicly viewable" on storage.objects;
|
|
create policy "Avatars are publicly viewable"
|
|
on storage.objects for select
|
|
using (bucket_id = 'avatars');
|
|
|
|
drop policy if exists "Users can upload their own avatar" on storage.objects;
|
|
create policy "Users can upload their own avatar"
|
|
on storage.objects for insert
|
|
to authenticated
|
|
with check (
|
|
bucket_id = 'avatars'
|
|
and split_part(name, '.', 1) = auth.uid()::text
|
|
);
|
|
|
|
drop policy if exists "Users can update their own avatar" on storage.objects;
|
|
create policy "Users can update their own avatar"
|
|
on storage.objects for update
|
|
to authenticated
|
|
using (
|
|
bucket_id = 'avatars'
|
|
and split_part(name, '.', 1) = auth.uid()::text
|
|
)
|
|
with check (
|
|
bucket_id = 'avatars'
|
|
and split_part(name, '.', 1) = auth.uid()::text
|
|
);
|
|
|
|
-- 5. Vérification du pseudo avant inscription -----------------------------
|
|
-- Un visiteur non connecté (rôle anon) n'a pas le droit de lire `profiles`
|
|
-- (RLS réservée aux authentifiés). Pour afficher "ce pseudo est déjà pris"
|
|
-- avant même de créer le compte, on expose une RPC qui ne renvoie qu'un
|
|
-- booléen, sans jamais exposer le contenu de la table aux anonymes.
|
|
create or replace function public.is_pseudo_taken(p_pseudo text)
|
|
returns boolean
|
|
language sql
|
|
security definer
|
|
set search_path = public
|
|
as $$
|
|
select exists (select 1 from public.profiles where lower(pseudo) = lower(p_pseudo));
|
|
$$;
|
|
|
|
grant execute on function public.is_pseudo_taken(text) to anon, authenticated;
|
|
|
|
-- 6. Points, journal ("le crieur") ------------------------------------------
|
|
|
|
create table if not exists public.points_log (
|
|
id bigint generated always as identity primary key,
|
|
target_id uuid not null references public.profiles (id) on delete cascade,
|
|
judge_id uuid not null references public.profiles (id) on delete cascade,
|
|
delta integer not null,
|
|
reason text,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
alter table public.points_log enable row level security;
|
|
|
|
-- Lecture ouverte à tous les authentifiés (transparence du journal).
|
|
-- Aucune policy insert/update/delete : seule la RPC award_points()
|
|
-- (SECURITY DEFINER, propriétaire de la table) peut écrire.
|
|
revoke insert, update, delete on public.points_log from authenticated, anon;
|
|
grant select on public.points_log to authenticated;
|
|
|
|
drop policy if exists "points_log readable by authenticated" on public.points_log;
|
|
create policy "points_log readable by authenticated"
|
|
on public.points_log for select
|
|
to authenticated
|
|
using (true);
|
|
|
|
-- Seul point d'entrée pour modifier les points : vérifie le rôle juge
|
|
-- côté serveur, applique le delta (négatif autorisé, pas de plancher),
|
|
-- et trace l'opération dans points_log. Le flag app.bypass_points_lock
|
|
-- autorise le trigger enforce_profile_update() à laisser passer CETTE
|
|
-- écriture précise sur la colonne points.
|
|
create or replace function public.award_points(p_target_id uuid, p_delta int, p_reason text default null)
|
|
returns void
|
|
language plpgsql
|
|
security definer
|
|
set search_path = public
|
|
as $$
|
|
declare
|
|
caller_role text;
|
|
begin
|
|
select role into caller_role from public.profiles where id = auth.uid();
|
|
if caller_role is distinct from 'judge' then
|
|
raise exception 'only judges can award points';
|
|
end if;
|
|
|
|
if not exists (select 1 from public.profiles where id = p_target_id) then
|
|
raise exception 'target member not found';
|
|
end if;
|
|
|
|
perform set_config('app.bypass_points_lock', 'on', true);
|
|
update public.profiles set points = points + p_delta where id = p_target_id;
|
|
perform set_config('app.bypass_points_lock', 'off', true);
|
|
|
|
insert into public.points_log (target_id, judge_id, delta, reason)
|
|
values (p_target_id, auth.uid(), p_delta, p_reason);
|
|
end;
|
|
$$;
|
|
|
|
grant execute on function public.award_points(uuid, int, text) to authenticated;
|
|
|
|
-- 7. Repère de classement (flèches de progression) --------------------------
|
|
-- Fige le rang actuel de chacun dans previous_rank ; les flèches côté
|
|
-- client comparent le rang courant à cette référence.
|
|
|
|
create or replace function public.reset_rank_reference()
|
|
returns void
|
|
language plpgsql
|
|
security definer
|
|
set search_path = public
|
|
as $$
|
|
declare
|
|
caller_role text;
|
|
begin
|
|
select role into caller_role from public.profiles where id = auth.uid();
|
|
if caller_role is distinct from 'judge' then
|
|
raise exception 'only judges can reset the rank reference';
|
|
end if;
|
|
|
|
with ranked as (
|
|
select id, rank() over (order by points desc) as r
|
|
from public.profiles
|
|
)
|
|
update public.profiles p
|
|
set previous_rank = ranked.r
|
|
from ranked
|
|
where p.id = ranked.id;
|
|
end;
|
|
$$;
|
|
|
|
grant execute on function public.reset_rank_reference() to authenticated;
|
|
|
|
-- 8. Realtime ----------------------------------------------------------------
|
|
-- Permet au leaderboard et au journal de s'actualiser en direct pour tout
|
|
-- le monde (Dashboard → Database → Replication fait la même chose).
|
|
|
|
do $$
|
|
begin
|
|
alter publication supabase_realtime add table public.profiles;
|
|
exception
|
|
when duplicate_object then null;
|
|
end $$;
|
|
|
|
do $$
|
|
begin
|
|
alter publication supabase_realtime add table public.points_log;
|
|
exception
|
|
when duplicate_object then null;
|
|
end $$;
|
|
|
|
-- 9. Le Calendrier des Dieux --------------------------------------------------
|
|
-- Agenda de la semaine : chaque journée est placée sous le patronage d'une
|
|
-- divinité (nom/domaine en texte libre, l'Archonte choisit un préréglage
|
|
-- côté client ou saisit le sien). Lecture ouverte à tous, écriture réservée
|
|
-- aux juges (Archontes), vérifiée côté serveur par RLS — jamais par le
|
|
-- masquage des boutons en front.
|
|
|
|
create table if not exists public.days (
|
|
id uuid primary key default gen_random_uuid(),
|
|
date date not null unique,
|
|
god_name text not null,
|
|
god_domain text not null,
|
|
description text,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
create table if not exists public.events (
|
|
id uuid primary key default gen_random_uuid(),
|
|
day_id uuid not null references public.days (id) on delete cascade,
|
|
title text not null,
|
|
description text,
|
|
type text not null default 'activite' check (type in ('activite', 'defi', 'epreuve', 'tribunal')),
|
|
start_time time,
|
|
location text,
|
|
created_by uuid references public.profiles (id) on delete set null,
|
|
created_at timestamptz not null default now()
|
|
);
|
|
|
|
-- Ligne de configuration unique (id toujours = true) : date du Tribunal.
|
|
create table if not exists public.settings (
|
|
id boolean primary key default true,
|
|
tribunal_date timestamptz,
|
|
constraint settings_singleton check (id)
|
|
);
|
|
insert into public.settings (id) values (true) on conflict (id) do nothing;
|
|
|
|
alter table public.days enable row level security;
|
|
alter table public.events enable row level security;
|
|
alter table public.settings enable row level security;
|
|
|
|
grant select, insert, update, delete on public.days, public.events to authenticated;
|
|
grant select, update on public.settings to authenticated;
|
|
|
|
-- Lecture : tout utilisateur authentifié.
|
|
drop policy if exists "days viewable by authenticated" on public.days;
|
|
create policy "days viewable by authenticated" on public.days for select to authenticated using (true);
|
|
|
|
drop policy if exists "events viewable by authenticated" on public.events;
|
|
create policy "events viewable by authenticated" on public.events for select to authenticated using (true);
|
|
|
|
drop policy if exists "settings viewable by authenticated" on public.settings;
|
|
create policy "settings viewable by authenticated" on public.settings for select to authenticated using (true);
|
|
|
|
-- Écriture : réservée aux juges.
|
|
drop policy if exists "days insert by judges" on public.days;
|
|
create policy "days insert by judges" on public.days for insert to authenticated
|
|
with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
|
|
|
drop policy if exists "days update by judges" on public.days;
|
|
create policy "days update by judges" on public.days for update to authenticated
|
|
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'))
|
|
with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
|
|
|
drop policy if exists "days delete by judges" on public.days;
|
|
create policy "days delete by judges" on public.days for delete to authenticated
|
|
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
|
|
|
drop policy if exists "events insert by judges" on public.events;
|
|
create policy "events insert by judges" on public.events for insert to authenticated
|
|
with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
|
|
|
drop policy if exists "events update by judges" on public.events;
|
|
create policy "events update by judges" on public.events for update to authenticated
|
|
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'))
|
|
with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
|
|
|
drop policy if exists "events delete by judges" on public.events;
|
|
create policy "events delete by judges" on public.events for delete to authenticated
|
|
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
|
|
|
drop policy if exists "settings update by judges" on public.settings;
|
|
create policy "settings update by judges" on public.settings for update to authenticated
|
|
using (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'))
|
|
with check (exists (select 1 from public.profiles p where p.id = auth.uid() and p.role = 'judge'));
|
|
|
|
-- created_by ne fait jamais confiance au client : toujours l'auteur réel.
|
|
create or replace function public.enforce_event_insert()
|
|
returns trigger
|
|
language plpgsql
|
|
as $$
|
|
begin
|
|
new.created_by := auth.uid();
|
|
return new;
|
|
end;
|
|
$$;
|
|
|
|
drop trigger if exists events_before_insert on public.events;
|
|
create trigger events_before_insert
|
|
before insert on public.events
|
|
for each row execute function public.enforce_event_insert();
|
|
|
|
do $$
|
|
begin
|
|
alter publication supabase_realtime add table public.days;
|
|
exception
|
|
when duplicate_object then null;
|
|
end $$;
|
|
|
|
do $$
|
|
begin
|
|
alter publication supabase_realtime add table public.events;
|
|
exception
|
|
when duplicate_object then null;
|
|
end $$;
|
|
|
|
do $$
|
|
begin
|
|
alter publication supabase_realtime add table public.settings;
|
|
exception
|
|
when duplicate_object then null;
|
|
end $$;
|
|
|
|
-- 10. Désigner les premiers juges ---------------------------------------------
|
|
-- À faire une fois les comptes créés (SQL Editor) :
|
|
--
|
|
-- update public.profiles set role = 'judge' where id = '<uuid-du-membre>';
|
|
--
|
|
-- (Récupérer l'uuid via Authentication → Users, ou :
|
|
-- select id, pseudo from public.profiles;)
|
|
|
|
-- 11. La Course du Char --------------------------------------------------------
|
|
-- Mini-jeu : course de char en vue du dessus, une piste générée aléatoirement
|
|
-- par jour (calculée côté client à partir de la date Europe/Paris, jamais
|
|
-- stockée ici), chacun rejoue autant qu'il veut, seul le meilleur temps du
|
|
-- jour compte. Les points du jour sont attribués automatiquement (pas
|
|
-- d'Archonte impliqué) par une tâche planifiée pg_cron — seul mécanisme de
|
|
-- points de tout le projet qui n'attend pas de décision d'un juge.
|
|
|
|
-- Un point attribué automatiquement n'a pas de juge : judge_id devient
|
|
-- optionnel (le Crieur affiche alors "Le Tribunal" à la place d'un pseudo).
|
|
alter table public.points_log alter column judge_id drop not null;
|
|
|
|
create table if not exists public.chariot_runs (
|
|
user_id uuid not null references public.profiles (id) on delete cascade,
|
|
race_date date not null,
|
|
best_time_ms integer not null check (best_time_ms between 2000 and 300000),
|
|
ghost_path jsonb not null default '[]'::jsonb,
|
|
updated_at timestamptz not null default now(),
|
|
primary key (user_id, race_date)
|
|
);
|
|
|
|
create index if not exists chariot_runs_race_date_idx
|
|
on public.chariot_runs (race_date, best_time_ms);
|
|
|
|
alter table public.chariot_runs enable row level security;
|
|
|
|
-- Même modèle que points_log : verrouillée en écriture, seule la RPC
|
|
-- submit_chariot_run() (SECURITY DEFINER) peut écrire.
|
|
revoke insert, update, delete on public.chariot_runs from authenticated, anon;
|
|
grant select on public.chariot_runs to authenticated;
|
|
|
|
drop policy if exists "chariot_runs readable by authenticated" on public.chariot_runs;
|
|
create policy "chariot_runs readable by authenticated"
|
|
on public.chariot_runs for select
|
|
to authenticated
|
|
using (true);
|
|
|
|
-- Seul point d'entrée pour soumettre un temps : la date du jour est calculée
|
|
-- côté serveur (jamais fournie par le client, pour ne pas pouvoir soumettre
|
|
-- "pour" un autre jour), le temps est borné grossièrement (anti-triche
|
|
-- minimal, suffisant pour un jeu entre amis — pas de vérification fine par
|
|
-- piste), et on n'écrase le temps existant que s'il est strictement meilleur.
|
|
create or replace function public.submit_chariot_run(p_time_ms integer, p_ghost_path jsonb default '[]'::jsonb)
|
|
returns public.chariot_runs
|
|
language plpgsql
|
|
security definer
|
|
set search_path = public
|
|
as $$
|
|
declare
|
|
v_race_date date;
|
|
v_row public.chariot_runs;
|
|
begin
|
|
if auth.uid() is null then
|
|
raise exception 'authentication required';
|
|
end if;
|
|
|
|
if p_time_ms is null or p_time_ms < 2000 or p_time_ms > 300000 then
|
|
raise exception 'invalid time_ms';
|
|
end if;
|
|
|
|
v_race_date := (now() at time zone 'Europe/Paris')::date;
|
|
|
|
insert into public.chariot_runs (user_id, race_date, best_time_ms, ghost_path, updated_at)
|
|
values (auth.uid(), v_race_date, p_time_ms, coalesce(p_ghost_path, '[]'::jsonb), now())
|
|
on conflict (user_id, race_date) do update
|
|
set best_time_ms = excluded.best_time_ms,
|
|
ghost_path = excluded.ghost_path,
|
|
updated_at = now()
|
|
where excluded.best_time_ms < public.chariot_runs.best_time_ms;
|
|
|
|
-- On resélectionne toujours plutôt que de dépendre du RETURNING de
|
|
-- l'upsert : si la clause WHERE bloque la mise à jour (temps pas
|
|
-- meilleur), l'INSERT ne retourne aucune ligne. On renvoie dans tous les
|
|
-- cas le meilleur temps courant du joueur, qu'il vienne d'être battu ou non.
|
|
select * into v_row from public.chariot_runs
|
|
where user_id = auth.uid() and race_date = v_race_date;
|
|
|
|
return v_row;
|
|
end;
|
|
$$;
|
|
|
|
grant execute on function public.submit_chariot_run(integer, jsonb) to authenticated;
|
|
|
|
do $$
|
|
begin
|
|
alter publication supabase_realtime add table public.chariot_runs;
|
|
exception
|
|
when duplicate_object then null;
|
|
end $$;
|
|
|
|
-- Marqueur interne : quelles journées ont déjà été clôturées (idempotence de
|
|
-- la tâche planifiée, qui tourne toutes les 15 minutes plutôt qu'une seule
|
|
-- fois à minuit pile — le changement d'heure CET/CEST décalerait un horaire
|
|
-- UTC fixe). Table strictement interne : aucune policy, jamais lue/écrite
|
|
-- par le client.
|
|
create table if not exists public.chariot_race_closes (
|
|
race_date date primary key,
|
|
closed_at timestamptz not null default now()
|
|
);
|
|
alter table public.chariot_race_closes enable row level security;
|
|
revoke all on public.chariot_race_closes from authenticated, anon;
|
|
|
|
-- Clôture de la journée précédente (Europe/Paris) : classe chariot_runs,
|
|
-- attribue les gloires du top 3 (ex-aequo inclus au même rang), journalise
|
|
-- dans points_log avec judge_id = null, puis marque la date comme close.
|
|
-- No-op si déjà clôturée. Volontairement pas de grant execute à
|
|
-- authenticated : uniquement appelée par pg_cron ou depuis le SQL Editor.
|
|
create or replace function public.close_daily_chariot_race()
|
|
returns void
|
|
language plpgsql
|
|
security definer
|
|
set search_path = public
|
|
as $$
|
|
declare
|
|
v_target_date date := ((now() at time zone 'Europe/Paris')::date - 1);
|
|
r record;
|
|
v_points int;
|
|
begin
|
|
if exists (select 1 from public.chariot_race_closes where race_date = v_target_date) then
|
|
return;
|
|
end if;
|
|
|
|
for r in
|
|
with ranked as (
|
|
select user_id, best_time_ms,
|
|
rank() over (order by best_time_ms asc) as rnk
|
|
from public.chariot_runs
|
|
where race_date = v_target_date
|
|
)
|
|
select * from ranked where rnk <= 3
|
|
loop
|
|
v_points := case r.rnk when 1 then 3 when 2 then 2 when 3 then 1 else 0 end;
|
|
update public.profiles set points = points + v_points where id = r.user_id;
|
|
insert into public.points_log (target_id, judge_id, delta, reason)
|
|
values (r.user_id, null, v_points,
|
|
'Course du Char — ' || to_char(v_target_date, 'DD/MM') || ' (rang ' || r.rnk || ')');
|
|
end loop;
|
|
|
|
insert into public.chariot_race_closes (race_date) values (v_target_date);
|
|
end;
|
|
$$;
|
|
|
|
-- ⚠️ pg_cron doit être activé une fois pour toutes via le Dashboard Supabase
|
|
-- (Database → Extensions → "pg_cron" → Enable) — pas scriptable depuis ce
|
|
-- fichier, et pas garanti self-service selon le plan/la région du projet.
|
|
-- Les deux blocs ci-dessous n'échouent jamais bruyamment si l'extension n'est
|
|
-- pas encore activée (schéma "cron" inexistant) : le reste de ce script,
|
|
-- ré-exécuté en entier à chaque changement, doit toujours pouvoir passer.
|
|
do $$
|
|
begin
|
|
perform cron.unschedule('close-daily-chariot-race');
|
|
exception
|
|
when others then null; -- la tâche n'existe pas encore, ou pg_cron pas activé
|
|
end $$;
|
|
|
|
do $$
|
|
begin
|
|
perform cron.schedule(
|
|
'close-daily-chariot-race',
|
|
'*/15 * * * *',
|
|
$cron$select public.close_daily_chariot_race();$cron$
|
|
);
|
|
exception
|
|
when others then
|
|
raise notice 'pg_cron indisponible : active l''extension via le Dashboard Supabase (Database → Extensions → pg_cron), puis ré-exécute ce script pour planifier la clôture automatique de la Course du Char.';
|
|
end $$;
|