-- Keep old snapshots unchanged; missing new switches retain their client defaults. alter table public.difficulty_presets add column map_pan boolean not null default true, add column timer_visible boolean not null default true; alter table public.difficulty_presets drop constraint difficulty_presets_seconds_check; alter table public.difficulty_presets add constraint difficulty_presets_seconds_check check (seconds between 5 and 600 and seconds % 5 = 0); alter table public.game_sessions drop constraint game_sessions_difficulty_check; alter table public.game_sessions add constraint game_sessions_difficulty_check check ( difficulty in ('easy','normal','hard','void') or difficulty ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' ); alter table public.multiplayer_rooms drop constraint multiplayer_rooms_difficulty_check; alter table public.multiplayer_rooms add constraint multiplayer_rooms_difficulty_check check ( difficulty in ('easy','normal','hard','void') or difficulty ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' ); create or replace function public.resolve_difficulty_preset(p_id text) returns jsonb language plpgsql stable security invoker set search_path = '' as $$ declare preset jsonb; begin if p_id = 'void' then return '{"id":"void","name":"Void","seconds":10,"zoom":false,"pan":false,"compass":false,"coordinates":false,"mapZoom":false,"mapPan":false,"timerVisible":true,"scoring":"strict"}'::jsonb; end if; if p_id in ('easy','normal','hard') then return jsonb_build_object('id',p_id,'name',initcap(p_id), 'seconds',case p_id when 'easy' then 180 when 'hard' then 30 else 90 end, 'zoom',p_id <> 'hard','pan',true,'compass',true,'coordinates',true, 'mapZoom',true,'mapPan',true,'timerVisible',true,'scoring','normal'); end if; select jsonb_build_object('id',p.id::text,'name',p.name,'seconds',p.seconds,'zoom',p.zoom, 'pan',p.pan,'compass',p.compass,'coordinates',p.coordinates,'mapZoom',p.map_zoom, 'mapPan',p.map_pan,'timerVisible',p.timer_visible,'scoring',p.scoring) into preset from public.difficulty_presets p where p.id::text=lower(p_id); if not found then raise exception 'Choose a saved difficulty'; end if; return preset; end; $$; -- A Void tournament must keep the same ten-second limit for every mini-round. alter table public.tournaments drop constraint tournaments_round_time_seconds_check; alter table public.tournaments add constraint tournaments_round_time_seconds_check check ( (difficulty='void' and timer_mode='perMini' and round_time_seconds=10) or (difficulty<>'void' and round_time_seconds between 15 and 3000 and round_time_seconds%15=0) ); create or replace function public.create_tournament(p_owner uuid,p_payload jsonb) returns uuid language plpgsql security invoker set search_path='' as $$ declare tid uuid; rev public.revisions; settings jsonb; start_time timestamptz; zone text; seconds integer; cap integer; rounds integer; title text; mode text; begin if not exists(select 1 from public.profiles where id=p_owner and account_status='active') then raise exception 'Active account required'; end if; select r.* into rev from public.datasets d join public.revisions r on r.id=d.revision_id where d.id=(p_payload->>'datasetId')::uuid and d.state='published'; if not found then raise exception 'Choose a published map'; end if; if jsonb_array_length(public.game_viewpoint_pool(rev.manifest,rev.asset_root))<5 then raise exception 'Map needs at least five distinct panorama locations'; end if; start_time:=(p_payload->>'startsAt')::timestamptz; if start_time is null or start_time<=clock_timestamp() or start_time>clock_timestamp()+interval '90 days' then raise exception 'Choose a future start within 90 days'; end if; zone:=p_payload->>'serverTimezone'; if zone is null or not exists(select 1 from pg_catalog.pg_timezone_names where name=zone) then raise exception 'Invalid server timezone'; end if; title:=btrim(p_payload->>'name'); cap:=(p_payload->>'playerLimit')::integer; rounds:=(p_payload->>'roundCount')::integer; seconds:=(p_payload->>'roundTimeSeconds')::integer; if cap is null or cap not between 2 and 32 then raise exception 'Choose between 2 and 32 players'; end if; if rounds is null or rounds not between 1 and 10 then raise exception 'Choose between 1 and 10 tournament rounds'; end if; mode:=coalesce(p_payload->>'timerMode','shared'); if mode not in('shared','perMini') then raise exception 'Invalid tournament timer mode'; end if; if p_payload->>'difficulty'='void' then if mode<>'perMini' or seconds is distinct from 10 then raise exception 'Void requires a separate 10-second timer'; end if; elsif seconds is null or seconds not between 15 and (case when mode='shared' then 3000 else 600 end) or seconds%15<>0 then raise exception 'Choose a valid tournament time in steps of 15 seconds'; end if; settings:=public.resolve_difficulty_preset(p_payload->>'difficulty')||jsonb_build_object('seconds',least(seconds,600),'roundCount',5); insert into public.tournaments(name,owner_id,revision_id,starts_at,server_timezone,player_limit,round_count,round_time_seconds,timer_mode,difficulty,difficulty_settings,announce_discord) values(title,p_owner,rev.id,start_time,zone,cap,rounds,seconds,mode,p_payload->>'difficulty',settings,coalesce((p_payload->>'announceDiscord')::boolean,false)) returning id into tid; insert into public.tournament_members(tournament_id,account_id) values(tid,p_owner); return tid; end; $$; create or replace function public.read_prometheus_metrics(p_token_hash text) returns jsonb language plpgsql security invoker set search_path = '' set statement_timeout = '15s' as $$ declare settings public.prometheus_settings; collected_at timestamptz := clock_timestamp(); result jsonb; begin -- The token grants only access to aggregate metrics. Demoting/deleting its -- issuing Admin also invalidates it. Lock prevents concurrent scrape stampedes. select s.* into settings from public.prometheus_settings s where s.id and s.token_hash=p_token_hash and exists(select 1 from public.profiles p where p.id=s.updated_by and p.role='admin') for update; if not found then return null; end if; if settings.snapshot_at > collected_at - interval '30 seconds' then update public.prometheus_settings set last_scraped_at=collected_at where id; return settings.snapshot; end if; with samples(metric, labels, value) as ( select 'minegeo_accounts', '{}'::jsonb, count(*)::double precision from public.profiles union all select 'minegeo_published_panoramas', '{}'::jsonb, coalesce(sum(panorama_count),0)::double precision from public.datasets where state='published' union all select 'minegeo_games_started_24h', '{}'::jsonb, count(*)::double precision from public.game_sessions where created_at >= collected_at - interval '24 hours' union all select 'minegeo_rooms_created_24h', '{}'::jsonb, count(*)::double precision from public.multiplayer_rooms where created_at >= collected_at - interval '24 hours' union all select 'minegeo_snapshot_timestamp_seconds', '{}'::jsonb, extract(epoch from collected_at)::double precision union all select 'minegeo_maps', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision from (values('uploading'),('processing'),('draft'),('review'),('published'),('rejected'),('unpublished'),('failed')) v(state) left join (select state,count(*) n from public.datasets group by state) c using(state) union all select 'minegeo_games', jsonb_build_object('difficulty',d.difficulty,'status',v.status), coalesce(c.n,0)::double precision from (values('easy'),('normal'),('hard'),('void')) d(difficulty) cross join (values('incomplete'),('complete')) v(status) left join (select difficulty,case when current_round=5 then 'complete' else 'incomplete' end status,count(*) n from public.game_sessions group by 1,2) c using(difficulty,status) union all select 'minegeo_rooms', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision from (values('lobby'),('loading'),('playing'),('results'),('finished'),('closed')) v(state) left join (select status state,count(*) n from public.multiplayer_rooms group by status) c using(state) union all select 'minegeo_upload_jobs', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision from (values('queued'),('running'),('complete'),('failed'),('cancelled')) v(state) left join (select state,count(*) n from public.upload_jobs group by state) c using(state) union all select 'minegeo_bug_reports', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision from (values('draft'),('pending'),('open'),('done')) v(state) left join (select state,count(*) n from public.bug_reports group by state) c using(state) union all select 'minegeo_content_reports', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision from (values('open'),('resolved'),('confirmed'),('dismissed')) v(state) left join (select state,count(*) n from public.reports group by state) c using(state) union all select 'minegeo_discord_announcements', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision from (values('pending'),('sending'),('sent'),('skipped'),('failed')) v(state) left join (select state,count(*) n from public.discord_announcements group by state) c using(state) ) select jsonb_agg(jsonb_build_object('metric',metric,'labels',labels,'value',value) order by metric,labels::text) into result from samples; update public.prometheus_settings set last_scraped_at=collected_at, snapshot_at=collected_at, snapshot=result where id; return result; end; $$; -- Discard the cached metrics shape when adding the Void dimension. update public.prometheus_settings set snapshot=null,snapshot_at=null;