owpengram-server/deploy/migrations/0152_account_rating.up.sql
Egor Egorov fff8de783a
feat: add NFT usernames and bot verification (#22)
Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review.

The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation.

Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9
Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b

Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
2026-07-28 01:18:00 +08:00

73 lines
3.8 KiB
SQL

-- Server-local composite account rating for moderation/operations in the admin
-- panel. This is intentionally not projected into Telegram's userFull
-- stars_rating/stars_my_pending_rating fields: those fields describe official
-- Stars transaction-volume semantics, not this activity/moderation score.
--
-- account_rating is a derived read model: it can always be rebuilt from the
-- contributing sources (stars_transactions, message counts, moderation state)
-- plus the manual adjustments recorded in account_rating_events. Every stored
-- component is kept separately so the admin panel can show why a level was
-- reached, and so recomputing one signal never silently discards another.
--
-- 'stars' is the composite score used by the local admin model, not a wallet
-- balance and not an official Telegram Stars Rating value.
CREATE TABLE public.account_rating (
user_id bigint PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE,
level integer NOT NULL DEFAULT 0 CHECK (level >= 0),
stars bigint NOT NULL DEFAULT 0,
current_level_stars bigint NOT NULL DEFAULT 0 CHECK (current_level_stars >= 0),
-- NULL means the top local admin level has been reached.
next_level_stars bigint CHECK (next_level_stars IS NULL OR next_level_stars > 0),
CHECK (next_level_stars IS NULL OR next_level_stars > current_level_stars),
-- Signed contributions. penalty_component is stored as a non-negative
-- magnitude and subtracted, so an audit never has to guess the sign.
stars_component bigint NOT NULL DEFAULT 0 CHECK (stars_component >= 0),
activity_component bigint NOT NULL DEFAULT 0 CHECK (activity_component >= 0),
penalty_component bigint NOT NULL DEFAULT 0 CHECK (penalty_component >= 0),
manual_component bigint NOT NULL DEFAULT 0,
-- Rating earned but not yet applied to the visible level.
pending_stars bigint NOT NULL DEFAULT 0,
pending_date timestamptz,
CHECK ((pending_stars = 0 AND pending_date IS NULL) OR (pending_stars <> 0 AND pending_date IS NOT NULL)),
computed_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
version bigint NOT NULL DEFAULT 1 CHECK (version > 0)
);
CREATE INDEX account_rating_leaderboard_idx
ON public.account_rating (level DESC, stars DESC, user_id);
CREATE INDEX account_rating_stale_idx
ON public.account_rating (computed_at, user_id);
-- Append-only contribution log. 'manual' rows are admin adjustments and are the
-- only rows that survive a full recompute; command_key gives them the same
-- replay safety as other admin commands.
CREATE TABLE public.account_rating_events (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
kind text NOT NULL CHECK (kind IN ('stars', 'activity', 'moderation', 'manual', 'recompute')),
amount bigint NOT NULL,
reason text NOT NULL DEFAULT '' CHECK (octet_length(reason) <= 512),
actor text NOT NULL DEFAULT '' CHECK (octet_length(actor) <= 128),
command_key text CHECK (command_key IS NULL OR octet_length(command_key) BETWEEN 1 AND 128),
created_at timestamptz NOT NULL
);
CREATE UNIQUE INDEX account_rating_events_command_idx
ON public.account_rating_events (command_key)
WHERE command_key IS NOT NULL;
CREATE INDEX account_rating_events_user_idx
ON public.account_rating_events (user_id, id DESC);
CREATE INDEX account_rating_events_kind_idx
ON public.account_rating_events (kind, created_at DESC, id DESC);
-- Rating recompute counts upheld cases per target. The existing target index is
-- partial on the undecided states, so without this one the count degrades to a
-- sequential scan on every recompute.
CREATE INDEX moderation_cases_target_history_idx
ON public.moderation_cases (target_peer_type, target_peer_id)
WHERE status IN ('action_pending', 'action_failed', 'resolved');