Files
MineGeo/supabase/migrations/20261006074343_content_reports_and_appeals.sql

122 lines
9.6 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 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;