202 lines
7.8 KiB
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';
|