-- Event-scoped moderation and chat are private to the trusted server. create table public.tournament_moderators ( tournament_id uuid not null references public.tournaments(id) on delete cascade, account_id uuid not null references public.profiles(id) on delete cascade, primary key(tournament_id,account_id) ); create table public.tournament_bans ( tournament_id uuid not null references public.tournaments(id) on delete cascade, account_id uuid not null references public.profiles(id) on delete cascade, primary key(tournament_id,account_id) ); create table public.tournament_tags ( tournament_id uuid not null references public.tournaments(id) on delete cascade, account_id uuid not null references public.profiles(id) on delete cascade, tag text not null check(length(tag) between 1 and 32 and tag=btrim(tag) and tag !~ '[[:cntrl:]]' and tag !~ U&'[\0080-\009F\202A-\202E\2066-\2069]'), primary key(tournament_id,account_id) ); alter table public.tournaments add column chat_slow_seconds integer not null default 0 check(chat_slow_seconds between 0 and 300); create table public.tournament_messages ( id bigint generated always as identity primary key, tournament_id uuid not null references public.tournaments(id) on delete cascade, account_id uuid not null references public.profiles(id) on delete cascade, client_id uuid not null, body text not null check(length(btrim(body)) between 1 and 500 and body !~ '[[:cntrl:]]' and body !~ U&'[\0080-\009F\202A-\202E\2066-\2069]'), created_at timestamptz not null default clock_timestamp(), unique(tournament_id,account_id,client_id) ); create index tournament_messages_history on public.tournament_messages(tournament_id,id desc); create index tournament_messages_rate on public.tournament_messages(tournament_id,account_id,created_at desc); alter table public.tournament_moderators enable row level security; alter table public.tournament_bans enable row level security; alter table public.tournament_tags enable row level security; alter table public.tournament_messages enable row level security; revoke all on public.tournament_moderators,public.tournament_bans,public.tournament_tags,public.tournament_messages from public,anon,authenticated; revoke all on sequence public.tournament_messages_id_seq from public,anon,authenticated; grant all on public.tournament_moderators,public.tournament_bans,public.tournament_tags,public.tournament_messages to service_role; grant usage,select on sequence public.tournament_messages_id_seq to service_role; create function public.guard_tournament_ban() returns trigger language plpgsql security invoker set search_path='' as $$ begin if exists(select 1 from public.tournament_bans where tournament_id=new.tournament_id and account_id=new.account_id) then raise exception 'You are banned from this tournament'; end if; return new; end; $$; create trigger guard_tournament_ban before insert on public.tournament_members for each row execute function public.guard_tournament_ban(); create function public.clear_tournament_member_roles() returns trigger language plpgsql security invoker set search_path='' as $$ begin delete from public.tournament_moderators where tournament_id=old.tournament_id and account_id=old.account_id; delete from public.tournament_tags where tournament_id=old.tournament_id and account_id=old.account_id; return old; end; $$; create trigger clear_tournament_member_roles after delete on public.tournament_members for each row execute function public.clear_tournament_member_roles(); create function public.tournament_community(p_tournament uuid,p_account uuid,p_action text,p_payload jsonb default '{}') returns jsonb language plpgsql security invoker set search_path='' as $$ declare event public.tournaments; target uuid; moderator boolean; owner boolean; seconds integer; label text; begin select * into event from public.tournaments where id=p_tournament for update; if not found then raise exception 'Tournament not found'; end if; if not exists(select 1 from public.profiles where id=p_account and account_status='active') then raise exception 'Active account required'; end if; if not exists(select 1 from public.tournament_members where tournament_id=p_tournament and account_id=p_account) then raise exception 'Tournament membership required'; end if; owner := p_account=event.owner_id; moderator := owner or exists(select 1 from public.tournament_moderators where tournament_id=p_tournament and account_id=p_account); if p_action='view' then null; elsif not moderator then raise exception 'Tournament moderator permission required'; elsif event.status in('finished','cancelled') then raise exception 'Tournament has ended'; elsif p_action='slow' then seconds:=(p_payload->>'seconds')::integer; if seconds is null or seconds not between 0 and 300 then raise exception 'Invalid slow mode'; end if; update public.tournaments set chat_slow_seconds=seconds where id=p_tournament; elsif p_action in('admin','unadmin','kick','ban','unban','tag') then target:=(p_payload->>'accountId')::uuid; if target is null or target=event.owner_id or target=p_account then raise exception 'Cannot moderate the owner or yourself'; end if; if p_action in('admin','unadmin') and not owner then raise exception 'Only the owner can manage moderators'; end if; if not owner and exists(select 1 from public.tournament_moderators where tournament_id=p_tournament and account_id=target) then raise exception 'Only the owner can moderate a moderator'; end if; if p_action='admin' then if not exists(select 1 from public.tournament_members where tournament_id=p_tournament and account_id=target) then raise exception 'Player must join first'; end if; insert into public.tournament_moderators values(p_tournament,target) on conflict do nothing; elsif p_action='unadmin' then delete from public.tournament_moderators where tournament_id=p_tournament and account_id=target; elsif p_action='unban' then delete from public.tournament_bans where tournament_id=p_tournament and account_id=target; elsif p_action='tag' then if not exists(select 1 from public.tournament_members where tournament_id=p_tournament and account_id=target) then raise exception 'Player must join first'; end if; label:=btrim(p_payload->>'tag'); if label is null or length(label)>32 or label ~ '[[:cntrl:]]' or label ~ U&'[\0080-\009F\202A-\202E\2066-\2069]' then raise exception 'Invalid tag'; end if; if label='' then delete from public.tournament_tags where tournament_id=p_tournament and account_id=target; else insert into public.tournament_tags values(p_tournament,target,label) on conflict(tournament_id,account_id) do update set tag=excluded.tag; end if; else if not exists(select 1 from public.tournament_members where tournament_id=p_tournament and account_id=target) then raise exception 'Player must join first'; end if; if p_action='ban' then insert into public.tournament_bans values(p_tournament,target) on conflict do nothing; end if; delete from public.tournament_moderators where tournament_id=p_tournament and account_id=target; update public.multiplayer_players set has_left=true where account_id=target and room_id in(select room_id from public.tournament_rounds where tournament_id=p_tournament); delete from public.tournament_members where tournament_id=p_tournament and account_id=target; end if; else raise exception 'Invalid tournament action'; end if; return jsonb_build_object('owner',owner,'moderator',moderator,'slowSeconds',event.chat_slow_seconds, 'moderators',coalesce((select jsonb_agg(account_id) from public.tournament_moderators where tournament_id=p_tournament),'[]'::jsonb), 'tags',coalesce((select jsonb_object_agg(account_id::text,tag) from public.tournament_tags where tournament_id=p_tournament),'{}'::jsonb), 'bans',case when moderator then coalesce((select jsonb_agg(jsonb_build_object('accountId',b.account_id,'name',p.display_name)) from public.tournament_bans b join public.profiles p on p.id=b.account_id where b.tournament_id=p_tournament),'[]'::jsonb) else '[]'::jsonb end); end; $$; create function public.tournament_chat(p_tournament uuid,p_account uuid,p_text text default null,p_client_id uuid default null) returns jsonb language plpgsql security invoker set search_path='' as $$ declare event public.tournaments; player public.profiles; delay_seconds integer; begin select * into event from public.tournaments where id=p_tournament for update; if not found then raise exception 'Tournament not found'; end if; select * into player from public.profiles where id=p_account and account_status='active'; if not found or not exists(select 1 from public.tournament_members where tournament_id=p_tournament and account_id=p_account) then raise exception 'Tournament membership required'; end if; if event.status='cancelled' then raise exception 'Chat is unavailable'; end if; if p_text is not null or p_client_id is not null then if event.status='finished' then raise exception 'Tournament has ended'; end if; if p_text is null or p_client_id is null or length(btrim(p_text)) not between 1 and 500 or p_text ~ '[[:cntrl:]]' or p_text ~ U&'[\0080-\009F\202A-\202E\2066-\2069]' then raise exception 'Invalid chat message'; end if; if not exists(select 1 from public.tournament_messages where tournament_id=p_tournament and account_id=p_account and client_id=p_client_id) then delay_seconds:=case when p_account=event.owner_id or exists(select 1 from public.tournament_moderators where tournament_id=p_tournament and account_id=p_account) then 1 else greatest(1,event.chat_slow_seconds) end; if exists(select 1 from public.tournament_messages where tournament_id=p_tournament and account_id=p_account and created_at>clock_timestamp()-make_interval(secs=>delay_seconds)) or (select count(*) from public.tournament_messages where tournament_id=p_tournament and account_id=p_account and created_at>clock_timestamp()-interval '1 minute')>=30 then raise exception 'Chat slow mode: wait before sending another message'; end if; insert into public.tournament_messages(tournament_id,account_id,client_id,body) values(p_tournament,p_account,p_client_id,btrim(p_text)); end if; end if; return jsonb_build_object('messages',coalesce((select jsonb_agg(jsonb_build_object('id',m.id::text,'playerId',m.account_id,'name',p.display_name,'text',m.body,'createdAt',m.created_at,'tag',tag.tag) order by m.id) from (select * from public.tournament_messages where tournament_id=p_tournament order by id desc limit 100) m join public.profiles p on p.id=m.account_id left join public.tournament_tags tag on tag.tournament_id=m.tournament_id and tag.account_id=m.account_id),'[]'::jsonb)); end; $$; revoke all on function public.guard_tournament_ban(),public.clear_tournament_member_roles(),public.tournament_community(uuid,uuid,text,jsonb),public.tournament_chat(uuid,uuid,text,uuid) from public,anon,authenticated; grant execute on function public.guard_tournament_ban(),public.clear_tournament_member_roles(),public.tournament_community(uuid,uuid,text,jsonb),public.tournament_chat(uuid,uuid,text,uuid) to service_role; notify pgrst,'reload schema';