122 lines
9.6 KiB
PL/PgSQL
122 lines
9.6 KiB
PL/PgSQL
-- 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;
|