-- Decisions and strikes are serialized on the map; only the server can mutate them. alter table public.datasets add column report_strikes integer not null default 0 check(report_strikes between 0 and 3), add column report_cycle integer not null default 0, add column banned_at timestamptz, add column ban_reason text, add constraint map_ban_state check(banned_at is null or (state='unpublished' and update_state is null)), add constraint map_ban_threshold check(report_strikes<3 or banned_at is not null); alter table public.reports add column category text not null default 'other' check(category in ('inappropriate','privacy','wrong_world','broken','spam','other')), add column revision_id uuid references public.revisions(id), add column panorama_id text, add column evidence_url text check(evidence_url is null or (length(evidence_url)<=1000 and evidence_url ~* '^https?://')), add column report_cycle integer not null default 0, add column reviewer_id uuid references public.profiles(id), add column reviewed_at timestamptz, add column review_reason text; -- Legacy rows keep their resolved status; new reports use confirmed/dismissed. alter table public.reports add constraint report_state check(state in ('open','resolved','confirmed','dismissed')); create index reports_review_queue on public.reports(created_at) where state='open'; -- The row lock in submit_content_report handles legacy duplicate rows too. create index reports_reporter_cycle on public.reports(dataset_id,owner_id,report_cycle); create index contributions_recent on public.datasets(parent_dataset_id,created_at) where parent_dataset_id is not null; create table public.map_appeals ( id uuid primary key default gen_random_uuid(), dataset_id uuid not null references public.datasets(id), owner_id uuid not null references auth.users(id), report_cycle integer not null, reason text not null check(length(trim(reason)) between 10 and 2000), state text not null default 'open' check(state in ('open','approved','rejected')), created_at timestamptz not null default now(), reviewer_id uuid references public.profiles(id), reviewed_at timestamptz, review_reason text ); create unique index map_appeal_pending on public.map_appeals(dataset_id) where state='open'; alter table public.map_appeals enable row level security; revoke all on public.map_appeals from anon,authenticated; grant all on public.map_appeals to service_role; create function public.submit_content_report(p_dataset uuid,p_owner uuid,p_revision uuid,p_panorama text,p_category text,p_reason text,p_evidence text default null) returns uuid language plpgsql security invoker set search_path='' as $$ declare d public.datasets; m jsonb; report uuid; begin select * into d from public.datasets where id=p_dataset and parent_dataset_id is null for update; if not found or d.state<>'published' or d.banned_at is not null then raise exception 'Only published maps can be reported'; end if; if p_owner is null or not exists(select 1 from public.profiles where id=p_owner) then raise exception 'Sign in to report content'; end if; if d.owner_id=p_owner then raise exception 'You cannot report your own map'; end if; if exists(select 1 from public.reports where dataset_id=d.id and owner_id=p_owner and report_cycle=d.report_cycle and state in ('open','confirmed')) then raise exception 'You already reported this map. A moderator will review your report'; end if; if p_category is null or p_category not in ('inappropriate','privacy','wrong_world','broken','spam','other') or coalesce(length(trim(p_reason)),0) not between 10 and 2000 then raise exception 'Choose a reason and describe the problem in 10–2000 characters'; end if; if p_evidence is not null and (length(p_evidence)>1000 or p_evidence !~* '^https?://') then raise exception 'Use an HTTP or HTTPS evidence link'; end if; select manifest into m from public.revisions where id=p_revision and dataset_id=d.id; if not found then raise exception 'Reported map version not found'; end if; if p_panorama is not null and not exists(select 1 from jsonb_array_elements(m->'viewpoints') p where p->>'id'=p_panorama) then raise exception 'Reported panorama not found'; end if; insert into public.reports(dataset_id,owner_id,revision_id,panorama_id,category,reason,evidence_url,report_cycle) values(d.id,p_owner,p_revision,p_panorama,p_category,trim(p_reason),p_evidence,d.report_cycle) returning id into report; return report; end; $$; create 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='moderator') then raise exception 'Moderator 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 function public.appeal_map_ban(p_dataset uuid,p_owner uuid,p_reason text) returns uuid language plpgsql security invoker set search_path='' as $$ declare d public.datasets; appeal uuid; begin select * into d from public.datasets where id=p_dataset and owner_id=p_owner and parent_dataset_id is null for update; if not found then raise exception 'Map owner permission required'; end if; if d.banned_at is null then raise exception 'This map has no ban to appeal'; end if; if coalesce(length(trim(p_reason)),0) not between 10 and 2000 then raise exception 'Explain your appeal in 10–2000 characters'; end if; if exists(select 1 from public.map_appeals where dataset_id=d.id and state='open') then raise exception 'An appeal is already awaiting review'; end if; insert into public.map_appeals(dataset_id,owner_id,report_cycle,reason) values(d.id,p_owner,d.report_cycle,trim(p_reason)) returning id into appeal; return appeal; end; $$; create 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='moderator') then raise exception 'Moderator 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; $$; revoke execute on function public.submit_content_report(uuid,uuid,uuid,text,text,text,text),public.review_content_report(uuid,uuid,text,text),public.appeal_map_ban(uuid,uuid,text),public.review_map_appeal(uuid,uuid,text,text) from public,anon,authenticated; grant execute on function public.submit_content_report(uuid,uuid,uuid,text,text,text,text),public.review_content_report(uuid,uuid,text,text),public.appeal_map_ban(uuid,uuid,text),public.review_map_appeal(uuid,uuid,text,text) to service_role;