feat: sync durable moderation and appeals
This commit is contained in:
parent
e1a95c7318
commit
9f467f4be7
140 changed files with 13730 additions and 316 deletions
202
deploy/migrations/0141_moderation_cases.up.sql
Normal file
202
deploy/migrations/0141_moderation_cases.up.sql
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
-- 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';
|
||||
Loading…
Add table
Add a link
Reference in a new issue