owpengram-server/deploy/migrations/20260714003100_moderation_cases.up.sql

202 lines
7.8 KiB
SQL

-- Target-grouped moderation work queue. Reports stay immutable; cases,
-- decisions, actions and appeals form a separate optimistic-concurrency state
-- machine.
CREATE TABLE public.moderation_cases (
id bigserial PRIMARY KEY,
target_peer_type text NOT NULL CHECK (target_peer_type IN ('user', 'channel')),
target_peer_id bigint NOT NULL CHECK (target_peer_id > 0),
status text NOT NULL CHECK (status IN (
'open', 'in_review', 'action_pending', 'action_failed', 'resolved',
'dismissed', 'appeal_review'
)),
severity smallint NOT NULL CHECK (severity BETWEEN 1 AND 4),
assigned_to text NOT NULL DEFAULT '' CHECK (octet_length(assigned_to) <= 128),
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
report_count integer NOT NULL CHECK (report_count > 0),
distinct_reporter_count integer NOT NULL CHECK (
distinct_reporter_count > 0
AND distinct_reporter_count <= report_count
),
first_report_at timestamptz NOT NULL,
last_report_at timestamptz NOT NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
CHECK (last_report_at >= first_report_at),
CHECK (updated_at >= created_at)
);
CREATE UNIQUE INDEX moderation_cases_one_active_target_idx
ON public.moderation_cases (target_peer_type, target_peer_id)
WHERE status IN ('open', 'in_review');
CREATE INDEX moderation_cases_queue_idx
ON public.moderation_cases (status, severity DESC, updated_at DESC, id DESC);
CREATE INDEX moderation_cases_assignee_idx
ON public.moderation_cases (assigned_to, status, updated_at DESC, id DESC)
WHERE assigned_to <> '';
CREATE TABLE public.moderation_case_reports (
case_id bigint NOT NULL REFERENCES public.moderation_cases(id)
ON DELETE RESTRICT,
report_id bigint NOT NULL UNIQUE REFERENCES public.moderation_reports(id)
ON DELETE RESTRICT,
attached_at timestamptz NOT NULL,
PRIMARY KEY (case_id, report_id)
);
CREATE INDEX moderation_case_reports_case_idx
ON public.moderation_case_reports (case_id, report_id);
CREATE TABLE public.moderation_decisions (
id bigserial PRIMARY KEY,
case_id bigint NOT NULL REFERENCES public.moderation_cases(id)
ON DELETE RESTRICT,
appeal_id bigint,
kind text NOT NULL CHECK (kind IN (
'no_violation', 'violation', 'appeal_granted', 'appeal_denied'
)),
actor text NOT NULL CHECK (octet_length(actor) BETWEEN 1 AND 128),
reason text NOT NULL CHECK (char_length(reason) BETWEEN 1 AND 2000),
command_id text NOT NULL UNIQUE CHECK (octet_length(command_id) BETWEEN 1 AND 120),
fingerprint bytea NOT NULL CHECK (octet_length(fingerprint) = 32),
created_at timestamptz NOT NULL
);
CREATE INDEX moderation_decisions_case_idx
ON public.moderation_decisions (case_id, created_at, id);
CREATE TABLE public.moderation_actions (
id bigserial PRIMARY KEY,
case_id bigint NOT NULL REFERENCES public.moderation_cases(id)
ON DELETE RESTRICT,
decision_id bigint NOT NULL REFERENCES public.moderation_decisions(id)
ON DELETE RESTRICT,
kind text NOT NULL CHECK (kind IN (
'mark_scam', 'mark_fake', 'clear_peer_flags', 'freeze_account',
'unfreeze_account', 'delete_private_message',
'delete_channel_message', 'delete_account'
)),
payload jsonb NOT NULL CHECK (
jsonb_typeof(payload) = 'object'
AND octet_length(payload::text) <= 65536
),
status text NOT NULL CHECK (status IN (
'pending', 'processing', 'succeeded', 'superseded', 'retry', 'failed'
)),
attempts integer NOT NULL DEFAULT 0 CHECK (attempts BETWEEN 0 AND 20),
available_at timestamptz NOT NULL,
lease_until timestamptz,
last_error text NOT NULL DEFAULT '' CHECK (char_length(last_error) <= 4000),
command_id text NOT NULL UNIQUE CHECK (octet_length(command_id) BETWEEN 1 AND 160),
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
CHECK (updated_at >= created_at)
);
CREATE INDEX moderation_actions_claim_idx
ON public.moderation_actions (available_at, id)
WHERE status IN ('pending', 'retry', 'processing');
CREATE INDEX moderation_actions_case_idx
ON public.moderation_actions (case_id, id);
CREATE TABLE public.moderation_appeals (
id bigserial PRIMARY KEY,
case_id bigint NOT NULL REFERENCES public.moderation_cases(id)
ON DELETE RESTRICT,
appellant_user_id bigint NOT NULL CHECK (appellant_user_id > 0),
appeal_text text NOT NULL CHECK (char_length(appeal_text) BETWEEN 1 AND 4000),
text_hash bytea NOT NULL CHECK (octet_length(text_hash) = 32),
fingerprint bytea NOT NULL UNIQUE CHECK (octet_length(fingerprint) = 32),
status text NOT NULL CHECK (status IN ('pending', 'granted', 'rejected')),
previous_case_status text NOT NULL CHECK (
previous_case_status IN ('resolved', 'dismissed')
),
reviewer text NOT NULL DEFAULT '' CHECK (octet_length(reviewer) <= 128),
review_reason text NOT NULL DEFAULT '' CHECK (char_length(review_reason) <= 2000),
created_at timestamptz NOT NULL,
reviewed_at timestamptz
);
CREATE UNIQUE INDEX moderation_appeals_one_pending_case_actor_idx
ON public.moderation_appeals (case_id, appellant_user_id)
WHERE status = 'pending';
CREATE INDEX moderation_appeals_queue_idx
ON public.moderation_appeals (status, created_at, id);
CREATE TABLE public.moderation_appeal_links (
id bigserial PRIMARY KEY,
case_id bigint NOT NULL REFERENCES public.moderation_cases(id)
ON DELETE RESTRICT,
appellant_user_id bigint NOT NULL CHECK (appellant_user_id > 0),
token_hash bytea NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32),
expires_at timestamptz NOT NULL,
appeal_id bigint REFERENCES public.moderation_appeals(id)
ON DELETE RESTRICT,
created_at timestamptz NOT NULL,
consumed_at timestamptz,
CHECK (expires_at > created_at),
CHECK (expires_at <= created_at + interval '90 days'),
CHECK (
(appeal_id IS NULL AND consumed_at IS NULL)
OR (appeal_id IS NOT NULL AND consumed_at IS NOT NULL)
)
);
CREATE INDEX moderation_appeal_links_expiry_idx
ON public.moderation_appeal_links (expires_at, id)
WHERE consumed_at IS NULL;
CREATE INDEX moderation_appeal_links_case_idx
ON public.moderation_appeal_links (case_id, id);
ALTER TABLE public.moderation_decisions
ADD CONSTRAINT moderation_decisions_appeal_fk
FOREIGN KEY (appeal_id) REFERENCES public.moderation_appeals(id)
ON DELETE RESTRICT;
CREATE UNIQUE INDEX moderation_decisions_one_per_appeal_idx
ON public.moderation_decisions (appeal_id)
WHERE appeal_id IS NOT NULL;
-- Existing unified reports become one open case per target. This backfill is
-- deterministic and keeps every report linked exactly once.
INSERT INTO public.moderation_cases (
target_peer_type, target_peer_id, status, severity, assigned_to,
version, report_count, distinct_reporter_count, first_report_at,
last_report_at, created_at, updated_at
)
SELECT
target_peer_type,
target_peer_id,
'open',
max(CASE reason
WHEN 'child_abuse' THEN 4
WHEN 'violence' THEN 3
WHEN 'pornography' THEN 3
WHEN 'illegal_drugs' THEN 3
WHEN 'personal_details' THEN 3
WHEN 'fake' THEN 2
WHEN 'copyright' THEN 2
ELSE 1
END)::smallint,
'',
1,
count(*)::integer,
count(DISTINCT reporter_user_id)::integer,
min(created_at),
max(created_at),
min(created_at),
max(created_at)
FROM public.moderation_reports
GROUP BY target_peer_type, target_peer_id;
INSERT INTO public.moderation_case_reports (case_id, report_id, attached_at)
SELECT c.id, r.id, r.created_at
FROM public.moderation_reports r
JOIN public.moderation_cases c
ON c.target_peer_type = r.target_peer_type
AND c.target_peer_id = r.target_peer_id
AND c.status = 'open';