create table public.party_moderators ( room_id uuid not null references public.multiplayer_rooms(id) on delete cascade, player_id uuid not null references public.multiplayer_players(id) on delete cascade, primary key(room_id,player_id) ); create table public.party_bans ( id uuid primary key default gen_random_uuid(), room_id uuid not null references public.multiplayer_rooms(id) on delete cascade, access_hash text not null, account_id uuid references public.profiles(id) on delete cascade, display_name text not null, unique(room_id,access_hash) ); create index party_bans_account on public.party_bans(room_id,account_id) where account_id is not null; create table public.party_tags ( room_id uuid not null references public.multiplayer_rooms(id) on delete cascade, player_id uuid not null references public.multiplayer_players(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(room_id,player_id) ); alter table public.multiplayer_rooms add column chat_slow_seconds integer not null default 0 check(chat_slow_seconds between 0 and 300); alter table public.party_moderators enable row level security; alter table public.party_bans enable row level security; alter table public.party_tags enable row level security; revoke all on public.party_moderators,public.party_bans,public.party_tags from public,anon,authenticated; grant all on public.party_moderators,public.party_bans,public.party_tags to service_role; create or replace function public.join_multiplayer_room(p_room uuid,p_access_hash text,p_owner uuid,p_name text) returns jsonb language plpgsql security invoker set search_path='' as $$ declare account_name text; result jsonb; begin perform 1 from public.multiplayer_rooms where id=p_room for update; if exists(select 1 from public.party_bans where room_id=p_room and (access_hash=p_access_hash or (p_owner is not null and account_id=p_owner))) then raise exception 'You are banned from this party'; end if; if p_owner is not null then select display_name into account_name from public.profiles where id=p_owner; if not found then raise exception 'Account not found'; end if; else account_name:=p_name; end if; result:=public.multiplayer_action(p_room,p_access_hash,'join',jsonb_build_object('name',account_name)); if p_owner is not null then update public.multiplayer_players set account_id=p_owner where room_id=p_room and access_hash=p_access_hash and account_id is null and not has_left; end if; return result; end; $$; create function public.party_community(p_room uuid,p_access_hash text,p_action text,p_payload jsonb default '{}') returns jsonb language plpgsql security invoker set search_path='' as $$ declare room public.multiplayer_rooms; actor public.multiplayer_players; target public.multiplayer_players; mod boolean; owner boolean; target_id uuid; seconds integer; label text; begin select * into room from public.multiplayer_rooms where id=p_room for update; if not found or room.tournament_id is not null then raise exception 'Party not found'; end if; select * into actor from public.multiplayer_players where room_id=p_room and access_hash=p_access_hash and not has_left; if not found then raise exception 'Room membership permission required'; end if; owner:=actor.id=room.host_player_id; mod:=owner or exists(select 1 from public.party_moderators where room_id=p_room and player_id=actor.id); if p_action='view' then null; elsif not mod then raise exception 'Party moderator permission required'; elsif room.status in('finished','closed') then raise exception 'Party 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.multiplayer_rooms set chat_slow_seconds=seconds where id=p_room returning * into room; elsif p_action='unban' then target_id:=(p_payload->>'banId')::uuid; delete from public.party_bans where room_id=p_room and id=target_id; elsif p_action in('admin','unadmin','kick','ban','tag') then target_id:=(p_payload->>'playerId')::uuid; select * into target from public.multiplayer_players where room_id=p_room and id=target_id and not has_left; if not found or target.id=actor.id or target.id=room.host_player_id 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.party_moderators where room_id=p_room and player_id=target.id) then raise exception 'Only the owner can moderate a moderator'; end if; if p_action='admin' then insert into public.party_moderators values(p_room,target.id) on conflict do nothing; elsif p_action='unadmin' then delete from public.party_moderators where room_id=p_room and player_id=target.id; elsif p_action='tag' then 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.party_tags where room_id=p_room and player_id=target.id; else insert into public.party_tags values(p_room,target.id,label) on conflict(room_id,player_id) do update set tag=excluded.tag; end if; else if p_action='ban' then insert into public.party_bans(room_id,access_hash,account_id,display_name) values(p_room,target.access_hash,target.account_id,target.display_name) on conflict(room_id,access_hash) do nothing; end if; delete from public.party_moderators where room_id=p_room and player_id=target.id; if room.status='lobby' then delete from public.multiplayer_players where id=target.id; else update public.multiplayer_players set has_left=true where id=target.id; end if; end if; else raise exception 'Invalid party action'; end if; return jsonb_build_object('owner',owner,'moderator',mod,'slowSeconds',room.chat_slow_seconds, 'moderators',coalesce((select jsonb_agg(player_id) from public.party_moderators where room_id=p_room),'[]'::jsonb), 'tags',coalesce((select jsonb_object_agg(player_id::text,tag) from public.party_tags where room_id=p_room),'{}'::jsonb), 'bans',case when mod then coalesce((select jsonb_agg(jsonb_build_object('id',id,'name',display_name)) from public.party_bans where room_id=p_room),'[]'::jsonb) else '[]'::jsonb end); end; $$; -- The existing function remains the only writer; serialize slow-mode checks under the same room lock. alter function public.multiplayer_chat(uuid,text,text,uuid) rename to multiplayer_chat_unchecked; create function public.multiplayer_chat(p_room uuid,p_access_hash text,p_text text default null,p_client_id uuid default null) returns jsonb language plpgsql security invoker set search_path='' as $$ declare room public.multiplayer_rooms; player public.multiplayer_players; result jsonb; begin select * into room from public.multiplayer_rooms where id=p_room for update; select * into player from public.multiplayer_players where room_id=p_room and access_hash=p_access_hash and not has_left; if player.id is not null and p_text is not null and room.chat_slow_seconds>0 and player.id<>room.host_player_id and not exists(select 1 from public.party_moderators where room_id=p_room and player_id=player.id) and not exists(select 1 from public.multiplayer_messages where room_id=p_room and player_id=player.id and client_id=p_client_id) and exists(select 1 from public.multiplayer_messages where room_id=p_room and player_id=player.id and created_at>clock_timestamp()-make_interval(secs=>room.chat_slow_seconds)) then raise exception 'Chat slow mode: wait before sending another message'; end if; result:=public.multiplayer_chat_unchecked(p_room,p_access_hash,p_text,p_client_id); return jsonb_set(result,'{messages}',coalesce((select jsonb_agg(msg || jsonb_build_object('tag',tag.tag) order by ord) from jsonb_array_elements(result->'messages') with ordinality x(msg,ord) left join public.party_tags tag on tag.room_id=p_room and tag.player_id=(msg->>'playerId')::uuid),'[]'::jsonb)); end; $$; revoke all on function public.party_community(uuid,text,text,jsonb),public.multiplayer_chat(uuid,text,text,uuid),public.multiplayer_chat_unchecked(uuid,text,text,uuid) from public,anon,authenticated; grant execute on function public.party_community(uuid,text,text,jsonb),public.multiplayer_chat(uuid,text,text,uuid),public.multiplayer_chat_unchecked(uuid,text,text,uuid) to service_role; notify pgrst,'reload schema';