owpengram-server/deploy/migrations/0154_verification_applications.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

138 lines
7.1 KiB
SQL

-- Official platform verification applications.
--
-- The application is the durable audit subject: it is never deleted, only moved
-- through its status machine, and every transition appends an immutable row to
-- verification_application_events. Decisions additionally go through the shared
-- admin command journal (admin_commands / admin_audit_logs), so the panel keeps
-- one audit story for all operator actions.
--
-- The target is addressed by its stable peer id. target_title / target_username
-- are a submission-time snapshot for the review queue and the audit trail,
-- because a username can move between peers and a title can change after filing.
CREATE TABLE public.verification_applications (
id bigserial PRIMARY KEY,
applicant_user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
target_type text NOT NULL CHECK (target_type IN ('bot', 'channel', 'supergroup', 'user')),
target_id bigint NOT NULL CHECK (target_id > 0),
target_title text NOT NULL DEFAULT '' CHECK (octet_length(target_title) <= 1024),
target_username text NOT NULL DEFAULT '' CHECK (octet_length(target_username) <= 64),
target_access_hash bigint NOT NULL DEFAULT 0,
category text NOT NULL DEFAULT '' CHECK (octet_length(category) <= 64),
description text NOT NULL DEFAULT '' CHECK (octet_length(description) <= 4096),
official_website text NOT NULL DEFAULT '' CHECK (octet_length(official_website) <= 512),
-- Links are stored as arrays rather than a child table: they are read and
-- written as one whole, are bounded, and never need to be queried across
-- applications.
social_links text[] NOT NULL DEFAULT '{}' CHECK (cardinality(social_links) <= 10),
press_links text[] NOT NULL DEFAULT '{}' CHECK (cardinality(press_links) <= 10),
additional_note text NOT NULL DEFAULT '' CHECK (octet_length(additional_note) <= 4096),
status text NOT NULL CHECK (status IN (
'draft', 'submitted', 'in_review', 'approved', 'rejected', 'cancelled'
)),
reviewer_admin_id text NOT NULL DEFAULT '' CHECK (octet_length(reviewer_admin_id) <= 128),
decision_reason text NOT NULL DEFAULT '' CHECK (octet_length(decision_reason) <= 4096),
-- internal_note is operator-only and must never be projected to the applicant.
internal_note text NOT NULL DEFAULT '' CHECK (octet_length(internal_note) <= 8192),
correlation_id text NOT NULL DEFAULT '' CHECK (octet_length(correlation_id) <= 128),
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
submitted_at timestamptz,
reviewed_at timestamptz,
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
CHECK (updated_at >= created_at),
-- A decided application always carries its reviewer and timestamp; a rejected
-- one additionally carries the reason the applicant is told.
CHECK (
(status IN ('approved', 'rejected')) =
(reviewed_at IS NOT NULL AND reviewer_admin_id <> '')
),
CHECK (status <> 'rejected' OR decision_reason <> ''),
CHECK (status = 'draft' OR submitted_at IS NOT NULL)
);
-- Exactly one live application per target. Draft, submitted and in_review are
-- the occupying states; decided and cancelled ones are history and do not block
-- a fresh attempt.
CREATE UNIQUE INDEX verification_applications_active_target_idx
ON public.verification_applications (target_type, target_id)
WHERE status IN ('draft', 'submitted', 'in_review');
-- One draft per applicant: the bot dialog is a single conversation, so a second
-- draft would have no way to be addressed.
CREATE UNIQUE INDEX verification_applications_applicant_draft_idx
ON public.verification_applications (applicant_user_id)
WHERE status = 'draft';
CREATE INDEX verification_applications_queue_idx
ON public.verification_applications (status, created_at DESC, id DESC);
CREATE INDEX verification_applications_applicant_idx
ON public.verification_applications (applicant_user_id, id DESC);
CREATE INDEX verification_applications_target_idx
ON public.verification_applications (target_type, target_id, id DESC);
CREATE INDEX verification_applications_reviewer_idx
ON public.verification_applications (reviewer_admin_id, reviewed_at DESC, id DESC)
WHERE reviewer_admin_id <> '';
-- Username search in the review queue is a prefix match on the snapshot.
CREATE INDEX verification_applications_username_idx
ON public.verification_applications (lower(target_username))
WHERE target_username <> '';
-- Cooldown lookups after a rejection: newest decision per applicant+target.
CREATE INDEX verification_applications_cooldown_idx
ON public.verification_applications (applicant_user_id, target_type, target_id, reviewed_at DESC)
WHERE status = 'rejected';
-- Immutable per-application history. Rows are append-only: there is no UPDATE or
-- DELETE path in the store, and the panel renders this as the application
-- timeline.
CREATE TABLE public.verification_application_events (
id bigserial PRIMARY KEY,
application_id bigint NOT NULL REFERENCES public.verification_applications(id)
ON DELETE RESTRICT,
kind text NOT NULL CHECK (kind IN (
'created', 'updated', 'submitted', 'claimed', 'approved', 'rejected',
'cancelled', 'revoked', 'notified'
)),
from_status text NOT NULL DEFAULT '' CHECK (octet_length(from_status) <= 32),
to_status text NOT NULL DEFAULT '' CHECK (octet_length(to_status) <= 32),
actor text NOT NULL DEFAULT '' CHECK (octet_length(actor) <= 128),
reason text NOT NULL DEFAULT '' CHECK (octet_length(reason) <= 4096),
note text NOT NULL DEFAULT '' CHECK (octet_length(note) <= 8192),
correlation_id text NOT NULL DEFAULT '' CHECK (octet_length(correlation_id) <= 128),
created_at timestamptz NOT NULL
);
CREATE INDEX verification_application_events_app_idx
ON public.verification_application_events (application_id, id DESC);
CREATE INDEX verification_application_events_actor_idx
ON public.verification_application_events (actor, created_at DESC, id DESC)
WHERE actor <> '';
-- Applicant notifications are delivered by @verifybot after the decision commits.
-- The outbox keeps that delivery exactly-once across restarts and makes a
-- repeated approve idempotent: the unique key is the decision, not the attempt.
CREATE TABLE public.verification_notification_outbox (
id bigserial PRIMARY KEY,
application_id bigint NOT NULL REFERENCES public.verification_applications(id)
ON DELETE CASCADE,
recipient_user_id bigint NOT NULL CHECK (recipient_user_id > 0),
kind text NOT NULL CHECK (kind IN ('approved', 'rejected', 'revoked')),
payload jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (
jsonb_typeof(payload) = 'object' AND octet_length(payload::text) <= 8192
),
attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
delivered_at timestamptz,
last_error text NOT NULL DEFAULT '' CHECK (octet_length(last_error) <= 1024),
created_at timestamptz NOT NULL,
CONSTRAINT verification_notification_once UNIQUE (application_id, kind)
);
CREATE INDEX verification_notification_pending_idx
ON public.verification_notification_outbox (created_at, id)
WHERE delivered_at IS NULL;