Add MineGeo gameplay systems and interface
This commit is contained in:
119
supabase/migrations/20261009123642_admin_permissions.sql
Normal file
119
supabase/migrations/20261009123642_admin_permissions.sql
Normal file
@@ -0,0 +1,119 @@
|
||||
-- Admin accounts manage settings and enforcement. Moderators review map submissions.
|
||||
alter table public.profiles drop constraint profiles_role_check;
|
||||
alter table public.profiles add constraint profiles_role_check
|
||||
check (role in ('contributor','moderator','admin'));
|
||||
|
||||
create or replace function public.publish_dataset(p_dataset uuid,p_reviewer uuid,p_decision text,p_reason text default null) returns void
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare d public.datasets; m jsonb; rev uuid; first jsonb; count integer;
|
||||
begin
|
||||
if not exists(select 1 from public.profiles where id=p_reviewer and role in ('moderator','admin')) then raise exception 'Moderator permission required'; end if;
|
||||
if p_decision='unpublish' and not exists(select 1 from public.profiles where id=p_reviewer and role='admin') then raise exception 'Admin permission required'; end if;
|
||||
select * into d from public.datasets where id=p_dataset and parent_dataset_id is null for update;
|
||||
if not found then raise exception 'Map not found'; end if;
|
||||
if p_decision='approve' then
|
||||
if not(d.state='review' or (d.state='published' and coalesce(d.update_state,'')='review')) then raise exception 'Map is not ready for publication'; end if;
|
||||
select manifest into m from public.dataset_inputs where dataset_id=p_dataset and validated;
|
||||
count:=jsonb_array_length(m->'viewpoints'); first:=m->'viewpoints'->0;
|
||||
if m is null or count<5 or count>24 then raise exception 'Map requires 5–24 validated panoramas'; end if;
|
||||
insert into public.revisions(dataset_id,manifest,asset_root) values(p_dataset,m,p_dataset::text) returning id into rev;
|
||||
update public.datasets set state='published',revision_id=rev,update_state=null,name=m->>'name',description=m->>'description',panorama_count=count,
|
||||
map_path=coalesce(m->>'mapPath',d.map_path),
|
||||
cover_path=coalesce(first->>'assetRoot',id::text)||'/'||coalesce(first->>'assetId',first->>'id')||'/preview.webp',rejection_reason=null,updated_at=now() where id=p_dataset;
|
||||
elsif p_decision='reject' and (d.state='review' or (d.state='published' and d.update_state='review')) then
|
||||
if coalesce(length(trim(p_reason)),0)=0 then raise exception 'Provide a rejection reason'; end if;
|
||||
update public.datasets set state=case when state='published' then state else 'rejected' end,update_state=case when state='published' then 'rejected' end,rejection_reason=p_reason,updated_at=now() where id=p_dataset;
|
||||
elsif p_decision='unpublish' and d.state='published' then
|
||||
update public.datasets set state='unpublished',update_state=null,updated_at=now() where id=p_dataset;
|
||||
else raise exception 'Invalid review transition'; end if;
|
||||
insert into public.review_decisions(dataset_id,reviewer_id,decision,reason) values(p_dataset,p_reviewer,p_decision,p_reason);
|
||||
end; $$;
|
||||
|
||||
create or replace function public.review_content_report(p_report uuid,p_reviewer uuid,p_decision text,p_reason text) returns void
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare r public.reports; d public.datasets; map_id uuid; strikes integer;
|
||||
begin
|
||||
if not exists(select 1 from public.profiles where id=p_reviewer and role = 'admin') then raise exception 'Admin permission required'; end if;
|
||||
select dataset_id into map_id from public.reports where id=p_report;
|
||||
select * into d from public.datasets where id=map_id for update;
|
||||
select * into r from public.reports where id=p_report for update;
|
||||
if not found or r.state<>'open' or r.report_cycle<>d.report_cycle then raise exception 'Report is no longer awaiting review'; end if;
|
||||
if p_decision is null or p_decision not in ('confirm','dismiss') or coalesce(length(trim(p_reason)),0) not between 10 and 2000 then raise exception 'Provide a review decision and feedback in 10–2000 characters'; end if;
|
||||
if p_decision='confirm' then
|
||||
if d.banned_at is not null then raise exception 'This map is already banned; dismiss the remaining report or review its appeal'; end if;
|
||||
if p_reviewer=r.owner_id or p_reviewer=d.owner_id then raise exception 'An independent moderator must confirm this report'; end if;
|
||||
-- Distinct reporters provide each strike, including legacy reports.
|
||||
if exists(select 1 from public.reports where dataset_id=d.id and owner_id=r.owner_id and report_cycle=d.report_cycle and state='confirmed') then raise exception 'This reporter already contributed a strike'; end if;
|
||||
strikes:=d.report_strikes+1;
|
||||
update public.datasets set report_strikes=strikes,
|
||||
state=case when strikes>=3 then 'unpublished' else state end,
|
||||
update_state=case when strikes>=3 then null else update_state end,
|
||||
banned_at=case when strikes>=3 then now() end,
|
||||
ban_reason=case when strikes>=3 then trim(p_reason) end,updated_at=now() where id=d.id;
|
||||
end if;
|
||||
update public.reports set state=case when p_decision='confirm' then 'confirmed' else 'dismissed' end,
|
||||
reviewer_id=p_reviewer,reviewed_at=now(),review_reason=trim(p_reason) where id=r.id;
|
||||
end; $$;
|
||||
|
||||
create or replace function public.review_map_appeal(p_appeal uuid,p_reviewer uuid,p_decision text,p_reason text) returns void
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare a public.map_appeals; d public.datasets; map_id uuid;
|
||||
begin
|
||||
if not exists(select 1 from public.profiles where id=p_reviewer and role = 'admin') then raise exception 'Admin permission required'; end if;
|
||||
select dataset_id into map_id from public.map_appeals where id=p_appeal;
|
||||
select * into d from public.datasets where id=map_id for update;
|
||||
select * into a from public.map_appeals where id=p_appeal for update;
|
||||
if not found or a.state<>'open' or d.banned_at is null or a.report_cycle<>d.report_cycle then raise exception 'Appeal is no longer awaiting review'; end if;
|
||||
if p_reviewer=d.owner_id then raise exception 'An independent moderator must review your appeal'; end if;
|
||||
if p_decision is null or p_decision not in ('approve','reject') or coalesce(length(trim(p_reason)),0) not between 10 and 2000 then raise exception 'Provide an appeal decision and feedback in 10–2000 characters'; end if;
|
||||
if p_decision='approve' then
|
||||
if d.revision_id is null then raise exception 'Map has no approved version to restore'; end if;
|
||||
update public.datasets set report_strikes=0,report_cycle=report_cycle+1,banned_at=null,ban_reason=null,state='published',updated_at=now() where id=d.id;
|
||||
update public.reports set state='dismissed',reviewer_id=p_reviewer,reviewed_at=now(),review_reason='Closed when the map ban appeal was approved.' where dataset_id=d.id and report_cycle=d.report_cycle and state='open';
|
||||
end if;
|
||||
update public.map_appeals set state=case when p_decision='approve' then 'approved' else 'rejected' end,reviewer_id=p_reviewer,reviewed_at=now(),review_reason=trim(p_reason) where id=a.id;
|
||||
end; $$;
|
||||
|
||||
create or replace function public.set_beta_badge(p_reviewer uuid, p_enabled boolean)
|
||||
returns boolean language plpgsql security invoker set search_path = '' as $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from public.profiles where id = p_reviewer and role = 'admin'
|
||||
) then
|
||||
raise exception 'Admin permission required';
|
||||
end if;
|
||||
if p_enabled is null then
|
||||
raise exception 'Beta badge visibility is required';
|
||||
end if;
|
||||
update public.site_settings
|
||||
set beta_badge_enabled = p_enabled, updated_at = now(), updated_by = p_reviewer
|
||||
where id = true;
|
||||
if not found then raise exception 'Site settings not found'; end if;
|
||||
return p_enabled;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.set_user_prefix(p_reviewer uuid,p_user uuid,p_prefix text,p_icon text) returns void
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
begin
|
||||
if not exists(select 1 from public.profiles where id=p_reviewer and role = 'admin') then raise exception 'Admin permission required'; end if;
|
||||
if p_icon not in ('star','crown','heart','flame','gem','trophy','sparkles') or p_icon is null then raise exception 'Invalid prefix icon'; end if;
|
||||
if p_prefix is not null and (length(btrim(p_prefix)) not between 1 and 24 or p_prefix ~ '[[:cntrl:]]') then raise exception 'Invalid custom prefix'; end if;
|
||||
update public.profiles set custom_prefix=case when p_prefix is null then null else btrim(p_prefix) end,custom_prefix_icon=p_icon where id=p_user;
|
||||
if not found then raise exception 'Player not found'; end if;
|
||||
end; $$;
|
||||
|
||||
revoke all on function public.publish_dataset(uuid,uuid,text,text) from public,anon,authenticated;
|
||||
grant execute on function public.publish_dataset(uuid,uuid,text,text) to service_role;
|
||||
|
||||
revoke all on function public.review_content_report(uuid,uuid,text,text) from public,anon,authenticated;
|
||||
grant execute on function public.review_content_report(uuid,uuid,text,text) to service_role;
|
||||
|
||||
revoke all on function public.review_map_appeal(uuid,uuid,text,text) from public,anon,authenticated;
|
||||
grant execute on function public.review_map_appeal(uuid,uuid,text,text) to service_role;
|
||||
|
||||
revoke all on function public.set_beta_badge(uuid,boolean) from public,anon,authenticated;
|
||||
grant execute on function public.set_beta_badge(uuid,boolean) to service_role;
|
||||
|
||||
revoke all on function public.set_user_prefix(uuid,uuid,text,text) from public,anon,authenticated;
|
||||
grant execute on function public.set_user_prefix(uuid,uuid,text,text) to service_role;
|
||||
100
supabase/migrations/20261009133431_discord_announcements.sql
Normal file
100
supabase/migrations/20261009133431_discord_announcements.sql
Normal file
@@ -0,0 +1,100 @@
|
||||
-- Durable, server-only announcements. One event per public map or shared party.
|
||||
create table public.discord_announcements (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
kind text not null check (kind in ('map','party')),
|
||||
target_id uuid not null,
|
||||
state text not null default 'pending' check (state in ('pending','sending','sent','skipped','failed')),
|
||||
attempts integer not null default 0 check (attempts>=0),
|
||||
next_attempt_at timestamptz not null default clock_timestamp(),
|
||||
lease_owner text,
|
||||
lease_until timestamptz,
|
||||
channel_id text,
|
||||
message_id text,
|
||||
last_error text,
|
||||
created_at timestamptz not null default clock_timestamp(),
|
||||
sent_at timestamptz,
|
||||
unique(kind,target_id)
|
||||
);
|
||||
create index discord_announcements_due on public.discord_announcements(next_attempt_at,created_at)
|
||||
where state in ('pending','sending');
|
||||
alter table public.discord_announcements enable row level security;
|
||||
revoke all on public.discord_announcements from public,anon,authenticated;
|
||||
grant all on public.discord_announcements to service_role;
|
||||
create policy "Server manages Discord delivery" on public.discord_announcements
|
||||
for all to service_role using (true) with check (true);
|
||||
|
||||
create function public.queue_new_map_discord() returns trigger
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
begin
|
||||
if new.state='published' and old.state<>'published' and old.revision_id is null
|
||||
and new.parent_dataset_id is null and new.imported_from_id is null then
|
||||
insert into public.discord_announcements(kind,target_id) values('map',new.id)
|
||||
on conflict(kind,target_id) do nothing;
|
||||
end if;
|
||||
return new;
|
||||
end; $$;
|
||||
create trigger queue_new_map_discord after update of state on public.datasets
|
||||
for each row execute function public.queue_new_map_discord();
|
||||
|
||||
create function public.publish_discord_party(p_room uuid,p_access_hash text) returns uuid
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare room public.multiplayer_rooms; event_id uuid; active integer;
|
||||
begin
|
||||
select * into room from public.multiplayer_rooms where id=p_room for update;
|
||||
if not found then raise exception 'Room not found'; end if;
|
||||
if not exists(select 1 from public.multiplayer_players where room_id=p_room
|
||||
and id=room.host_player_id and access_hash=p_access_hash and not has_left) then
|
||||
raise exception 'Only the room owner can publish this party';
|
||||
end if;
|
||||
if room.status<>'lobby' or room.created_at<clock_timestamp()-interval '24 hours' then
|
||||
raise exception 'Only open lobbies can be published';
|
||||
end if;
|
||||
select count(*) into active from public.multiplayer_players where room_id=p_room and not has_left;
|
||||
if active>=room.player_limit then raise exception 'This room is full'; end if;
|
||||
if not exists(select 1 from public.revisions r join public.datasets d on d.id=r.dataset_id
|
||||
where r.id=room.revision_id and d.state='published' and d.parent_dataset_id is null and d.imported_from_id is null) then
|
||||
raise exception 'Only parties on published maps can be shared on Discord';
|
||||
end if;
|
||||
insert into public.discord_announcements(kind,target_id) values('party',p_room)
|
||||
on conflict(kind,target_id) do nothing;
|
||||
select id into event_id from public.discord_announcements where kind='party' and target_id=p_room;
|
||||
return event_id;
|
||||
end; $$;
|
||||
|
||||
create function public.claim_discord_announcements(p_worker text,p_kinds text[],p_target uuid default null)
|
||||
returns setof public.discord_announcements language sql security invoker set search_path='' as $$
|
||||
with exhausted as (
|
||||
update public.discord_announcements set state='failed',lease_owner=null,lease_until=null,
|
||||
last_error='Discord delivery could not be confirmed. Check the channel before retrying.'
|
||||
where state='sending' and attempts>=8 and lease_until<clock_timestamp() returning id
|
||||
)
|
||||
update public.discord_announcements e set state='sending',attempts=e.attempts+1,
|
||||
lease_owner=p_worker,lease_until=clock_timestamp()+interval '3 minutes'
|
||||
where e.id in (
|
||||
select id from public.discord_announcements where kind=any(p_kinds)
|
||||
and (p_target is null or target_id=p_target) and state in ('pending','sending')
|
||||
and attempts<8 and next_attempt_at<=clock_timestamp()
|
||||
and (lease_until is null or lease_until<clock_timestamp())
|
||||
order by created_at,id limit 5 for update skip locked
|
||||
) returning e.*;
|
||||
$$;
|
||||
|
||||
create function public.retry_discord_announcements(p_reviewer uuid) returns integer
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
declare changed integer;
|
||||
begin
|
||||
if not exists(select 1 from public.profiles where id=p_reviewer and role='admin') then
|
||||
raise exception 'Admin permission required';
|
||||
end if;
|
||||
update public.discord_announcements set state='pending',attempts=0,next_attempt_at=clock_timestamp(),last_error=null,
|
||||
lease_owner=null,lease_until=null where state='failed' or (state='pending' and lease_owner is null)
|
||||
or (state='sending' and lease_until<clock_timestamp());
|
||||
get diagnostics changed=row_count;
|
||||
return changed;
|
||||
end; $$;
|
||||
|
||||
revoke all on function public.queue_new_map_discord(),public.publish_discord_party(uuid,text),
|
||||
public.claim_discord_announcements(text,text[],uuid),public.retry_discord_announcements(uuid)
|
||||
from public,anon,authenticated;
|
||||
grant execute on function public.queue_new_map_discord(),public.publish_discord_party(uuid,text),
|
||||
public.claim_discord_announcements(text,text[],uuid),public.retry_discord_announcements(uuid) to service_role;
|
||||
82
supabase/migrations/20261009153708_discord_party_cleanup.sql
Normal file
82
supabase/migrations/20261009153708_discord_party_cleanup.sql
Normal file
@@ -0,0 +1,82 @@
|
||||
-- Preserve the original Discord destination even if environment settings change.
|
||||
alter table public.discord_announcements
|
||||
add column closed_at timestamptz,
|
||||
add column delete_after timestamptz,
|
||||
add column deleted_at timestamptz,
|
||||
add column cleanup_run_id text,
|
||||
add column cleanup_lease_until timestamptz,
|
||||
add column cleanup_error text;
|
||||
create index discord_cleanup_due on public.discord_announcements(closed_at)
|
||||
where kind='party' and state='sent' and deleted_at is null;
|
||||
|
||||
create function public.close_party_discord() returns trigger
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
begin
|
||||
if tg_op='DELETE' then
|
||||
update public.discord_announcements set closed_at=coalesce(closed_at,clock_timestamp())
|
||||
where kind='party' and target_id=old.id and state='sent';
|
||||
return old;
|
||||
end if;
|
||||
if new.status='closed' then
|
||||
update public.discord_announcements set closed_at=coalesce(closed_at,clock_timestamp())
|
||||
where kind='party' and target_id=new.id and state='sent';
|
||||
end if;
|
||||
return new;
|
||||
end; $$;
|
||||
create trigger close_party_discord after update of status or delete on public.multiplayer_rooms
|
||||
for each row execute function public.close_party_discord();
|
||||
|
||||
-- Handle the race where closing a room happens while Discord is acknowledging a send.
|
||||
create function public.capture_closed_discord_party() returns trigger
|
||||
language plpgsql security invoker set search_path='' as $$
|
||||
begin
|
||||
if new.kind='party' and new.state='sent' and new.closed_at is null
|
||||
and not exists(select 1 from public.multiplayer_rooms where id=new.target_id and status<>'closed') then
|
||||
new.closed_at=clock_timestamp();
|
||||
end if;
|
||||
return new;
|
||||
end; $$;
|
||||
create trigger capture_closed_discord_party before insert or update of state,channel_id,message_id
|
||||
on public.discord_announcements for each row execute function public.capture_closed_discord_party();
|
||||
|
||||
-- Older closed announcements receive the same red notice and five-minute grace period.
|
||||
update public.discord_announcements e set closed_at=clock_timestamp()
|
||||
where kind='party' and state='sent' and not exists(
|
||||
select 1 from public.multiplayer_rooms r where r.id=e.target_id and r.status<>'closed');
|
||||
|
||||
create function public.claim_discord_cleanup(p_worker text,p_target uuid default null)
|
||||
returns setof public.discord_announcements language plpgsql security invoker set search_path='' as $$
|
||||
begin
|
||||
-- Reconcile overlapping close/send transactions before claiming cleanup.
|
||||
update public.discord_announcements e set closed_at=clock_timestamp()
|
||||
where kind='party' and state='sent' and closed_at is null
|
||||
and (p_target is null or target_id=p_target)
|
||||
and not exists(select 1 from public.multiplayer_rooms r where r.id=e.target_id and r.status<>'closed');
|
||||
return query
|
||||
update public.discord_announcements e set cleanup_run_id=p_worker,
|
||||
cleanup_lease_until=clock_timestamp()+interval '3 minutes'
|
||||
where e.id in (
|
||||
select id from public.discord_announcements where kind='party' and state='sent'
|
||||
and closed_at is not null and deleted_at is null and channel_id is not null and message_id is not null
|
||||
and (p_target is null or target_id=p_target)
|
||||
and (cleanup_run_id is null or cleanup_run_id like 'starting:%')
|
||||
and (cleanup_lease_until is null or cleanup_lease_until<clock_timestamp())
|
||||
order by closed_at,id limit 20 for update skip locked
|
||||
) returning e.*;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- Only start the grace period after Discord has acknowledged the red notice.
|
||||
-- Replayed workflows and retries keep the original deadline.
|
||||
create function public.mark_discord_party_closed(p_event uuid) returns timestamptz
|
||||
language sql security invoker set search_path='' as $$
|
||||
update public.discord_announcements set delete_after=coalesce(delete_after,clock_timestamp()+interval '5 minutes'),
|
||||
cleanup_error=null
|
||||
where id=p_event and kind='party' and state='sent' and closed_at is not null and deleted_at is null
|
||||
returning delete_after;
|
||||
$$;
|
||||
|
||||
revoke all on function public.close_party_discord(),public.capture_closed_discord_party(),
|
||||
public.claim_discord_cleanup(text,uuid),public.mark_discord_party_closed(uuid) from public,anon,authenticated;
|
||||
grant execute on function public.close_party_discord(),public.capture_closed_discord_party(),
|
||||
public.claim_discord_cleanup(text,uuid),public.mark_discord_party_closed(uuid) to service_role;
|
||||
Reference in New Issue
Block a user