Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877

This commit is contained in:
onysd 2026-08-03 23:29:20 +03:00
commit ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions

View file

@ -1237,9 +1237,7 @@ CREATE TABLE public.account_passwords (
srp_verifier bytea DEFAULT '\x'::bytea NOT NULL,
srp_b_secret bytea DEFAULT '\x'::bytea NOT NULL,
srp_b bytea DEFAULT '\x'::bytea NOT NULL,
recovery_email character varying(256) DEFAULT ''::character varying NOT NULL,
recovery_code character varying(32) DEFAULT ''::character varying NOT NULL,
recovery_code_expires_at timestamp with time zone
recovery_email character varying(256) DEFAULT ''::character varying NOT NULL
);
@ -2892,7 +2890,7 @@ CREATE TABLE public.user_saved_reaction_tags (
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT user_saved_reaction_tags_reaction_count_check CHECK ((reaction_count >= 0)),
CONSTRAINT user_saved_reaction_tags_reaction_type_check CHECK (((reaction_type)::text = 'emoji'::text)),
CONSTRAINT user_saved_reaction_tags_reaction_type_check CHECK (((reaction_type)::text = ANY (ARRAY['emoji'::text, 'custom_emoji'::text]))),
CONSTRAINT user_saved_reaction_tags_reaction_value_check CHECK ((reaction_value <> ''::text)),
CONSTRAINT user_saved_reaction_tags_title_check CHECK ((char_length(title) <= 12))
);
@ -2948,7 +2946,7 @@ CREATE TABLE public.user_update_events (
story_payload jsonb DEFAULT '{}'::jsonb NOT NULL,
reaction_payload jsonb DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT user_update_events_peer_type_check CHECK (((peer_type IS NULL) OR ((peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text])))),
CONSTRAINT user_update_events_type_check CHECK (((event_type)::text = ANY (ARRAY[('new_message'::character varying)::text, ('read_history_inbox'::character varying)::text, ('read_history_outbox'::character varying)::text, ('read_message_contents'::character varying)::text, ('edit_message'::character varying)::text, ('message_reactions'::character varying)::text, ('message_poll'::character varying)::text, ('draft_message'::character varying)::text, ('quick_replies'::character varying)::text, ('new_quick_reply'::character varying)::text, ('delete_quick_reply'::character varying)::text, ('quick_reply_message'::character varying)::text, ('delete_quick_reply_messages'::character varying)::text, ('contacts_reset'::character varying)::text, ('dialog_pinned'::character varying)::text, ('pinned_dialogs'::character varying)::text, ('pinned_messages'::character varying)::text, ('dialog_unread_mark'::character varying)::text, ('peer_settings'::character varying)::text, ('peer_story_blocked'::character varying)::text, ('delete_messages'::character varying)::text, ('dialog_filter'::character varying)::text, ('dialog_filter_order'::character varying)::text, ('dialog_filters'::character varying)::text, ('folder_peers'::character varying)::text, ('channel_available_messages'::character varying)::text, ('channel_view_forum_as_messages'::character varying)::text, ('channel_state'::character varying)::text, ('saved_dialog_pinned'::character varying)::text, ('pinned_saved_dialogs'::character varying)::text, ('story'::character varying)::text, ('read_stories'::character varying)::text, ('sent_story_reaction'::character varying)::text, ('new_story_reaction'::character varying)::text, ('noop'::character varying)::text])))
CONSTRAINT user_update_events_type_check CHECK (((event_type)::text = ANY (ARRAY[('new_message'::character varying)::text, ('read_history_inbox'::character varying)::text, ('read_history_outbox'::character varying)::text, ('read_message_contents'::character varying)::text, ('edit_message'::character varying)::text, ('message_poll'::character varying)::text, ('draft_message'::character varying)::text, ('quick_replies'::character varying)::text, ('new_quick_reply'::character varying)::text, ('delete_quick_reply'::character varying)::text, ('quick_reply_message'::character varying)::text, ('delete_quick_reply_messages'::character varying)::text, ('contacts_reset'::character varying)::text, ('dialog_pinned'::character varying)::text, ('pinned_dialogs'::character varying)::text, ('pinned_messages'::character varying)::text, ('dialog_unread_mark'::character varying)::text, ('peer_settings'::character varying)::text, ('peer_story_blocked'::character varying)::text, ('delete_messages'::character varying)::text, ('dialog_filter'::character varying)::text, ('dialog_filter_order'::character varying)::text, ('dialog_filters'::character varying)::text, ('folder_peers'::character varying)::text, ('channel_view_forum_as_messages'::character varying)::text, ('channel_state'::character varying)::text, ('saved_dialog_pinned'::character varying)::text, ('pinned_saved_dialogs'::character varying)::text, ('story'::character varying)::text, ('read_stories'::character varying)::text, ('sent_story_reaction'::character varying)::text, ('new_story_reaction'::character varying)::text, ('noop'::character varying)::text])))
);

View file

@ -7,7 +7,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order',
'dialog_filters', 'folder_peers', 'channel_available_messages', 'channel_view_forum_as_messages',
'dialog_filters', 'folder_peers', 'channel_view_forum_as_messages',
'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop'
]::text[])

View file

@ -12,7 +12,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order',
'dialog_filters', 'folder_peers', 'channel_available_messages', 'channel_view_forum_as_messages',
'dialog_filters', 'folder_peers', 'channel_view_forum_as_messages',
'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop',
'read_channel_discussion_inbox', 'read_channel_discussion_outbox'

View file

@ -7,7 +7,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order',
'dialog_filters', 'folder_peers', 'channel_available_messages', 'channel_view_forum_as_messages',
'dialog_filters', 'folder_peers', 'channel_view_forum_as_messages',
'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop',
'read_channel_discussion_inbox', 'read_channel_discussion_outbox'

View file

@ -10,7 +10,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order',
'dialog_filters', 'folder_peers', 'channel_available_messages', 'channel_view_forum_as_messages',
'dialog_filters', 'folder_peers', 'channel_view_forum_as_messages',
'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop',
'read_channel_discussion_inbox', 'read_channel_discussion_outbox'

View file

@ -6,7 +6,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order',
'dialog_filters', 'folder_peers', 'channel_available_messages', 'channel_view_forum_as_messages',
'dialog_filters', 'folder_peers', 'channel_view_forum_as_messages',
'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop', 'read_channel_discussion_inbox',
'read_channel_discussion_outbox'

View file

@ -9,7 +9,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'user_phone', 'delete_messages', 'dialog_filter',
'dialog_filter_order', 'dialog_filters', 'folder_peers', 'channel_available_messages',
'dialog_filter_order', 'dialog_filters', 'folder_peers',
'channel_view_forum_as_messages', 'channel_state', 'saved_dialog_pinned',
'pinned_saved_dialogs', 'story', 'read_stories', 'sent_story_reaction',
'new_story_reaction', 'noop', 'read_channel_discussion_inbox',

View file

@ -23,7 +23,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'user_phone', 'delete_messages', 'dialog_filter',
'dialog_filter_order', 'dialog_filters', 'folder_peers', 'channel_available_messages',
'dialog_filter_order', 'dialog_filters', 'folder_peers',
'channel_view_forum_as_messages', 'channel_state', 'saved_dialog_pinned',
'pinned_saved_dialogs', 'story', 'read_stories', 'sent_story_reaction',
'new_story_reaction', 'noop', 'read_channel_discussion_inbox',

View file

@ -50,7 +50,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status', 'delete_messages',
'dialog_filter', 'dialog_filter_order', 'dialog_filters', 'folder_peers',
'channel_available_messages', 'channel_view_forum_as_messages', 'channel_state',
'channel_view_forum_as_messages', 'channel_state',
'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop',
'read_channel_discussion_inbox', 'read_channel_discussion_outbox'

View file

@ -0,0 +1,4 @@
DROP TABLE IF EXISTS public.moderation_legacy_ephemeral_migrations;
DROP TABLE IF EXISTS public.moderation_media_holds;
DROP TABLE IF EXISTS public.moderation_report_items;
DROP TABLE IF EXISTS public.moderation_reports;

View file

@ -0,0 +1,112 @@
-- Unified immutable abuse-report submissions. Operational delivery/read/music
-- telemetry and auth-code delivery diagnostics intentionally use separate
-- tables and retention policies.
CREATE TABLE public.moderation_reports (
id bigserial PRIMARY KEY,
reporter_user_id bigint NOT NULL CHECK (reporter_user_id > 0),
source text NOT NULL CHECK (source IN (
'account_peer', 'profile_photo', 'messages_spam', 'messages',
'encrypted_spam', 'reaction', 'channel_spam', 'story', 'ephemeral',
'sponsored', 'antispam_false_positive'
)),
target_peer_type text NOT NULL CHECK (target_peer_type IN ('user', 'channel')),
target_peer_id bigint NOT NULL CHECK (target_peer_id > 0),
reason text NOT NULL CHECK (reason IN (
'spam', 'violence', 'pornography', 'child_abuse', 'other',
'copyright', 'geo_irrelevant', 'fake', 'illegal_drugs',
'personal_details'
)),
report_option text NOT NULL CHECK (
octet_length(report_option) BETWEEN 1 AND 32
),
report_comment text NOT NULL DEFAULT '' CHECK (
char_length(report_comment) <= 512
),
comment_hash bytea NOT NULL CHECK (octet_length(comment_hash) = 32),
fingerprint bytea NOT NULL CHECK (octet_length(fingerprint) = 32),
taxonomy_version smallint NOT NULL CHECK (taxonomy_version > 0),
created_at timestamptz NOT NULL,
CONSTRAINT moderation_reports_idempotency
UNIQUE (reporter_user_id, fingerprint)
);
CREATE INDEX moderation_reports_target_created_idx
ON public.moderation_reports (
target_peer_type, target_peer_id, created_at DESC, id DESC
);
CREATE INDEX moderation_reports_reporter_created_idx
ON public.moderation_reports (
reporter_user_id, created_at DESC, id DESC
);
CREATE TABLE public.moderation_report_items (
report_id bigint NOT NULL REFERENCES public.moderation_reports(id)
ON DELETE CASCADE,
ordinal smallint NOT NULL CHECK (ordinal BETWEEN 0 AND 99),
item_kind text NOT NULL CHECK (item_kind IN (
'peer', 'message', 'profile_photo', 'reaction', 'story',
'encrypted_chat', 'ephemeral', 'sponsored', 'antispam_decision'
)),
peer_type text NOT NULL CHECK (peer_type IN ('user', 'channel')),
peer_id bigint NOT NULL CHECK (peer_id > 0),
item_id bigint NOT NULL CHECK (item_id > 0),
secondary_id bigint NOT NULL DEFAULT 0 CHECK (secondary_id >= 0),
author_user_id bigint NOT NULL DEFAULT 0 CHECK (author_user_id >= 0),
evidence_schema_version smallint NOT NULL CHECK (
evidence_schema_version > 0
),
evidence jsonb NOT NULL CHECK (
jsonb_typeof(evidence) = 'object'
AND octet_length(evidence::text) <= 1048576
),
evidence_hash bytea NOT NULL CHECK (octet_length(evidence_hash) = 32),
PRIMARY KEY (report_id, ordinal),
CONSTRAINT moderation_report_items_identity
UNIQUE (
report_id, item_kind, peer_type, peer_id, item_id, secondary_id
)
);
CREATE INDEX moderation_report_items_lookup_idx
ON public.moderation_report_items (
item_kind, peer_type, peer_id, item_id, report_id
);
CREATE INDEX moderation_report_items_author_idx
ON public.moderation_report_items (
author_user_id, report_id
)
WHERE author_user_id > 0;
CREATE TABLE public.moderation_media_holds (
report_id bigint NOT NULL,
item_ordinal smallint NOT NULL,
media_kind text NOT NULL CHECK (media_kind IN ('photo', 'document', 'blob')),
storage_key text NOT NULL CHECK (
octet_length(storage_key) BETWEEN 1 AND 512
),
created_at timestamptz NOT NULL,
released_at timestamptz,
PRIMARY KEY (report_id, item_ordinal, media_kind, storage_key),
FOREIGN KEY (report_id, item_ordinal)
REFERENCES public.moderation_report_items(report_id, ordinal)
ON DELETE CASCADE,
CHECK (released_at IS NULL OR released_at >= created_at)
);
CREATE INDEX moderation_media_holds_active_key_idx
ON public.moderation_media_holds (media_kind, storage_key, report_id)
WHERE released_at IS NULL;
-- Crash-safe, one-way provenance for rows written by the pre-unified
-- ephemeral.reportMessage implementation. The legacy table remains immutable
-- until every deployed database has completed the application-level evidence
-- conversion; all new writes go exclusively to moderation_reports.
CREATE TABLE public.moderation_legacy_ephemeral_migrations (
legacy_report_id bigint PRIMARY KEY
REFERENCES public.ephemeral_abuse_reports(id) ON DELETE RESTRICT,
moderation_report_id bigint NOT NULL
REFERENCES public.moderation_reports(id) ON DELETE RESTRICT,
migrated_at timestamptz NOT NULL
);

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.auth_delivery_reports;

View file

@ -0,0 +1,27 @@
-- Authentication-code delivery diagnostics have a separate privacy and
-- retention boundary from abuse moderation. Raw phone numbers, raw
-- phone_code_hash values and authentication codes are never stored here.
CREATE TABLE public.auth_delivery_reports (
id bigserial PRIMARY KEY,
auth_key_id bytea NOT NULL CHECK (octet_length(auth_key_id) = 8),
session_id bigint NOT NULL CHECK (session_id <> 0),
client_type text NOT NULL CHECK (octet_length(client_type) <= 32),
phone_hash bytea NOT NULL CHECK (octet_length(phone_hash) = 32),
code_hash bytea NOT NULL CHECK (octet_length(code_hash) = 32),
issued_user_id bigint NOT NULL CHECK (issued_user_id >= 0),
delivery_id text NOT NULL CHECK (octet_length(delivery_id) <= 128),
channel text NOT NULL CHECK (channel IN ('phone', 'sms')),
mnc text NOT NULL CHECK (
octet_length(mnc) <= 8 AND mnc !~ '[^0-9]'
),
fingerprint bytea NOT NULL CHECK (octet_length(fingerprint) = 32),
created_at timestamptz NOT NULL,
CONSTRAINT auth_delivery_reports_idempotency
UNIQUE (auth_key_id, fingerprint)
);
CREATE INDEX auth_delivery_reports_auth_key_created_idx
ON public.auth_delivery_reports (auth_key_id, created_at DESC, id DESC);
CREATE INDEX auth_delivery_reports_phone_created_idx
ON public.auth_delivery_reports (phone_hash, created_at DESC, id DESC);

View file

@ -0,0 +1,6 @@
DROP TABLE IF EXISTS public.moderation_actions;
DROP TABLE IF EXISTS public.moderation_decisions;
DROP TABLE IF EXISTS public.moderation_appeal_links;
DROP TABLE IF EXISTS public.moderation_appeals;
DROP TABLE IF EXISTS public.moderation_case_reports;
DROP TABLE IF EXISTS public.moderation_cases;

View 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';

View file

@ -0,0 +1,4 @@
-- Irreversible privacy cleanup: restoring users.phone here would recreate the
-- disclosure this migration removes. Contact relations and all non-phone
-- owner-scoped fields are preserved by the up migration.
SELECT 1;

View file

@ -0,0 +1,15 @@
-- contacts.addContact historically replaced an omitted phone with users.phone.
-- Those rows are indistinguishable from a client-supplied copy of the same
-- number, so privacy-safe repair must treat every exact account-phone copy as
-- ambiguous. The contact relationship and owner-scoped names/notes remain; a
-- later contacts.importContacts sync can explicitly restore a known phone.
--
-- This is a one-time write-path repair. Runtime reads must not normalize or
-- second-guess the bad shape.
UPDATE contacts AS c
SET contact_phone = '',
updated_at = now()
FROM users AS u
WHERE u.id = c.contact_user_id
AND c.contact_phone <> ''
AND c.contact_phone = u.phone;

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.client_telemetry_events;

View file

@ -0,0 +1,30 @@
-- Operational client telemetry is not an abuse-report source. It has its own
-- idempotency/rate-limit indexes and TTL retention boundary.
CREATE TABLE public.client_telemetry_events (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL CHECK (user_id > 0),
kind text NOT NULL CHECK (
kind IN ('message_delivery', 'read_metrics', 'music_listen')
),
peer_type text NOT NULL CHECK (peer_type IN ('', 'user', 'channel')),
peer_id bigint NOT NULL CHECK (
(peer_type = '' AND peer_id = 0)
OR (peer_type <> '' AND peer_id > 0)
),
subject_ids bigint[] NOT NULL CHECK (
cardinality(subject_ids) BETWEEN 1 AND 100
),
payload jsonb NOT NULL CHECK (
jsonb_typeof(payload) = 'object'
AND octet_length(payload::text) <= 65536
),
fingerprint bytea NOT NULL CHECK (octet_length(fingerprint) = 32),
created_at timestamptz NOT NULL,
CONSTRAINT client_telemetry_idempotency UNIQUE (user_id, fingerprint)
);
CREATE INDEX client_telemetry_user_created_idx
ON public.client_telemetry_events (user_id, created_at DESC, id DESC);
CREATE INDEX client_telemetry_retention_idx
ON public.client_telemetry_events (created_at, id);

View file

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS public.channel_antispam_decisions;
DROP TABLE IF EXISTS public.sponsored_message_impressions;

View file

@ -0,0 +1,47 @@
-- Server-issued evidence registries. These prevent arbitrary sponsored IDs or
-- ordinary deleted messages from being accepted as human reports.
CREATE TABLE public.sponsored_message_impressions (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL CHECK (user_id > 0),
random_id_hash bytea NOT NULL CHECK (octet_length(random_id_hash) = 32),
target_peer_type text NOT NULL CHECK (target_peer_type IN ('user', 'channel')),
target_peer_id bigint NOT NULL CHECK (target_peer_id > 0),
author_user_id bigint NOT NULL CHECK (author_user_id >= 0),
evidence_schema_version smallint NOT NULL CHECK (evidence_schema_version > 0),
evidence jsonb NOT NULL CHECK (
jsonb_typeof(evidence) = 'object'
AND octet_length(evidence::text) <= 1048576
),
evidence_hash bytea NOT NULL CHECK (octet_length(evidence_hash) = 32),
report_id bigint UNIQUE REFERENCES public.moderation_reports(id) ON DELETE RESTRICT,
created_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL,
CHECK (expires_at > created_at),
CHECK (expires_at <= created_at + interval '30 days'),
CONSTRAINT sponsored_message_impressions_identity
UNIQUE (user_id, random_id_hash)
);
CREATE INDEX sponsored_message_impressions_expiry_idx
ON public.sponsored_message_impressions (expires_at, id);
CREATE TABLE public.channel_antispam_decisions (
id bigserial PRIMARY KEY,
channel_id bigint NOT NULL CHECK (channel_id > 0),
message_id integer NOT NULL CHECK (message_id > 0),
author_user_id bigint NOT NULL CHECK (author_user_id > 0),
evidence_schema_version smallint NOT NULL CHECK (evidence_schema_version > 0),
evidence jsonb NOT NULL CHECK (
jsonb_typeof(evidence) = 'object'
AND octet_length(evidence::text) <= 1048576
),
evidence_hash bytea NOT NULL CHECK (octet_length(evidence_hash) = 32),
report_id bigint UNIQUE REFERENCES public.moderation_reports(id) ON DELETE RESTRICT,
created_at timestamptz NOT NULL,
CONSTRAINT channel_antispam_decisions_identity
UNIQUE (channel_id, message_id)
);
CREATE INDEX channel_antispam_decisions_unreported_idx
ON public.channel_antispam_decisions (channel_id, created_at DESC, id DESC)
WHERE report_id IS NULL;

View file

@ -0,0 +1 @@
-- Reserved development migration version; up is intentionally a no-op.

View file

@ -0,0 +1,5 @@
-- Reserved development migration version.
-- account privacy is authoritative absolute state: updatePrivacy has no
-- pts/pts_count, so this migration must not add a privacy event type or payload
-- table. The earlier development-only definition was corrected in place
-- because no user/production database can contain that unpublished shape.

View file

@ -0,0 +1,2 @@
DROP TRIGGER IF EXISTS account_settings_read_model_changed ON account_settings;
DROP FUNCTION IF EXISTS telesrv_notify_account_settings_read_model();

View file

@ -0,0 +1,25 @@
CREATE OR REPLACE FUNCTION telesrv_notify_account_settings_read_model()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
owner_id bigint;
BEGIN
owner_id := CASE WHEN TG_OP = 'DELETE' THEN OLD.user_id ELSE NEW.user_id END;
IF owner_id IS NOT NULL AND owner_id > 0 THEN
PERFORM telesrv_bump_read_model_version(
'account_settings',
owner_id,
'user',
owner_id
);
END IF;
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
END;
$$;
DROP TRIGGER IF EXISTS account_settings_read_model_changed ON account_settings;
CREATE TRIGGER account_settings_read_model_changed
AFTER INSERT OR UPDATE OR DELETE ON account_settings
FOR EACH ROW
EXECUTE FUNCTION telesrv_notify_account_settings_read_model();

View file

@ -0,0 +1,17 @@
-- The corrected up migration does not add schema, payloads or event types.
-- Keep the constraint normalized to the same non-moderation event set.
ALTER TABLE public.user_update_events DROP CONSTRAINT IF EXISTS user_update_events_type_check;
ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_check CHECK (
(event_type)::text = ANY (ARRAY[
'new_message', 'read_history_inbox', 'read_history_outbox', 'read_message_contents',
'edit_message', 'web_page', 'message_reactions', 'message_poll', 'draft_message', 'quick_replies',
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status', 'delete_messages',
'dialog_filter', 'dialog_filter_order', 'dialog_filters', 'folder_peers',
'channel_view_forum_as_messages', 'channel_state',
'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop',
'read_channel_discussion_inbox', 'read_channel_discussion_outbox'
]::text[])
);

View file

@ -0,0 +1,20 @@
-- Reserved development migration version.
-- SCAM/FAKE is authoritative peer profile state. updateUser/updateChannel have
-- no pts/pts_count, so this migration must not admit a moderation-only account
-- PTS event type. The earlier development definition was corrected in place
-- because no user/production database can contain that unpublished shape.
ALTER TABLE public.user_update_events DROP CONSTRAINT IF EXISTS user_update_events_type_check;
ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_check CHECK (
(event_type)::text = ANY (ARRAY[
'new_message', 'read_history_inbox', 'read_history_outbox', 'read_message_contents',
'edit_message', 'web_page', 'message_poll', 'draft_message', 'quick_replies',
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status',
'delete_messages', 'dialog_filter', 'dialog_filter_order', 'dialog_filters', 'folder_peers',
'channel_view_forum_as_messages', 'channel_state',
'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop',
'read_channel_discussion_inbox', 'read_channel_discussion_outbox'
]::text[])
);

View file

@ -0,0 +1,9 @@
DROP TABLE IF EXISTS public.saved_message_reaction_tags;
ALTER TABLE public.user_saved_reaction_tags
DROP CONSTRAINT IF EXISTS user_saved_reaction_tags_reaction_type_check;
DELETE FROM public.user_saved_reaction_tags
WHERE (reaction_type)::text <> 'emoji'::text;
ALTER TABLE public.user_saved_reaction_tags
ADD CONSTRAINT user_saved_reaction_tags_reaction_type_check
CHECK ((reaction_type)::text = 'emoji'::text);

View file

@ -0,0 +1,33 @@
CREATE TABLE public.saved_message_reaction_tags (
user_id bigint NOT NULL,
message_box_id integer NOT NULL,
reaction_type character varying(16) NOT NULL,
reaction_value text NOT NULL,
chosen_order integer NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT saved_message_reaction_tags_pkey
PRIMARY KEY (user_id, message_box_id, reaction_type, reaction_value),
CONSTRAINT saved_message_reaction_tags_order_check CHECK (chosen_order > 0),
CONSTRAINT saved_message_reaction_tags_type_check
CHECK ((reaction_type)::text = ANY (ARRAY['emoji'::text, 'custom_emoji'::text])),
CONSTRAINT saved_message_reaction_tags_value_check CHECK (reaction_value <> ''),
CONSTRAINT saved_message_reaction_tags_user_id_fkey
FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE,
CONSTRAINT saved_message_reaction_tags_message_box_fkey
FOREIGN KEY (user_id, message_box_id)
REFERENCES public.message_boxes(owner_user_id, box_id) ON DELETE CASCADE
);
CREATE INDEX saved_message_reaction_tags_reaction_message_idx
ON public.saved_message_reaction_tags
(user_id, ((reaction_type)::text || ':' || reaction_value), message_box_id DESC);
ALTER TABLE public.user_saved_reaction_tags
DROP CONSTRAINT user_saved_reaction_tags_reaction_type_check;
ALTER TABLE public.user_saved_reaction_tags
ADD CONSTRAINT user_saved_reaction_tags_reaction_type_check
CHECK ((reaction_type)::text = ANY (ARRAY['emoji'::text, 'custom_emoji'::text]));
COMMENT ON COLUMN public.user_saved_reaction_tags.reaction_count IS
'Legacy unused column; visible counts are aggregated from saved_message_reaction_tags.';

View file

@ -0,0 +1 @@
DROP INDEX CONCURRENTLY IF EXISTS public.channel_messages_live_pinned_idx;

View file

@ -0,0 +1,3 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS channel_messages_live_pinned_idx
ON public.channel_messages (channel_id, id DESC)
WHERE pinned AND NOT deleted;

View file

@ -0,0 +1,9 @@
DELETE FROM star_gift_user_message_refs refs
USING peer_star_gifts gift
WHERE gift.id = refs.saved_gift_id
AND gift.owner_peer_type = 'channel';
COMMENT ON TABLE star_gift_user_message_refs IS
'Owner-local private service-message aliases to user-owned saved gift aggregates.';
DROP TABLE IF EXISTS star_gift_channel_notification_jobs;

View file

@ -0,0 +1,87 @@
-- Channel gift purchases commit a durable recipient snapshot before any
-- private notification is attempted. Delivery is idempotent and restart-safe:
-- the deterministic private-send replay is authoritative if a process stops
-- after writing the message but before marking this job delivered.
CREATE TABLE star_gift_channel_notification_jobs (
saved_gift_id bigint NOT NULL REFERENCES peer_star_gifts(id) ON DELETE CASCADE,
target_user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
gift_date integer NOT NULL,
action jsonb NOT NULL,
attempts integer NOT NULL DEFAULT 0,
next_attempt_at integer NOT NULL,
lease_until integer NOT NULL DEFAULT 0,
delivered_at integer NOT NULL DEFAULT 0,
message_id integer NOT NULL DEFAULT 0,
last_error text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (saved_gift_id, target_user_id),
CONSTRAINT star_gift_channel_notification_jobs_state_check CHECK (
saved_gift_id > 0
AND target_user_id > 0
AND gift_date > 0
AND attempts >= 0
AND next_attempt_at > 0
AND lease_until >= 0
AND delivered_at >= 0
AND message_id >= 0
AND ((delivered_at = 0 AND message_id = 0) OR (delivered_at > 0 AND message_id > 0))
)
);
CREATE INDEX star_gift_channel_notification_jobs_pending_idx
ON star_gift_channel_notification_jobs(next_attempt_at, lease_until, saved_gift_id, target_user_id)
WHERE delivered_at = 0;
COMMENT ON TABLE star_gift_channel_notification_jobs IS
'Purchase-time snapshot of per-admin channel gift notification intents; delivery uses deterministic private-message replay.';
COMMENT ON TABLE star_gift_user_message_refs IS
'Viewer-local private service-message aliases to saved gift aggregates; the saved gift owner may be that user or an authorized channel.';
-- Existing deployments/users already have channel prepaid/upgrade notifications.
-- Backfill only aliases that are cryptographically unnecessary to guess: the
-- persisted action itself names a channel and saved_id, and that exact pair
-- must resolve to one saved gift. This changes no message, PTS or outbox row.
WITH ordinary AS (
SELECT box.owner_user_id,
box.box_id,
gift.id AS saved_gift_id
FROM message_boxes box
JOIN peer_star_gifts gift
ON gift.owner_peer_type = 'channel'
AND (box.media #>> '{service_action,star_gift,peer_channel_id}') ~ '^[1-9][0-9]*$'
AND gift.owner_peer_id = (box.media #>> '{service_action,star_gift,peer_channel_id}')::bigint
AND (box.media #>> '{service_action,star_gift,saved_id}') ~ '^[1-9][0-9]*$'
AND gift.saved_id = (box.media #>> '{service_action,star_gift,saved_id}')::bigint
WHERE NOT box.deleted
AND box.media #>> '{service_action,kind}' = 'star_gift'
AND gift.lifecycle_status = 'active'
), collectible AS (
SELECT box.owner_user_id,
box.box_id,
gift.id AS saved_gift_id
FROM message_boxes box
JOIN peer_star_gifts gift
ON gift.owner_peer_type = 'channel'
AND box.media #>> '{service_action,star_gift_unique,peer,Type}' = 'channel'
AND (box.media #>> '{service_action,star_gift_unique,peer,ID}') ~ '^[1-9][0-9]*$'
AND gift.owner_peer_id = (box.media #>> '{service_action,star_gift_unique,peer,ID}')::bigint
AND (box.media #>> '{service_action,star_gift_unique,saved_id}') ~ '^[1-9][0-9]*$'
AND gift.saved_id = (box.media #>> '{service_action,star_gift_unique,saved_id}')::bigint
AND (box.media #>> '{service_action,star_gift_unique,gift,ID}') ~ '^[1-9][0-9]*$'
AND gift.unique_gift_id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint
WHERE NOT box.deleted
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
AND gift.lifecycle_status = 'active'
), aliases AS (
SELECT * FROM ordinary
UNION
SELECT * FROM collectible
)
INSERT INTO star_gift_user_message_refs(owner_user_id, msg_id, saved_gift_id)
SELECT owner_user_id, box_id, saved_gift_id
FROM aliases
ON CONFLICT(owner_user_id, msg_id) DO UPDATE
SET saved_gift_id = EXCLUDED.saved_gift_id
WHERE star_gift_user_message_refs.saved_gift_id = EXCLUDED.saved_gift_id;

View file

@ -0,0 +1,45 @@
-- Restore the single-username-per-peer invariant. Collectible registry rows are
-- dropped first so the old unique index can be recreated; the assets and their
-- provenance log are then removed with the tables.
DELETE FROM public.peer_usernames WHERE collectible_id IS NOT NULL OR NOT editable;
DROP INDEX IF EXISTS public.peer_usernames_collectible_idx;
DROP INDEX IF EXISTS public.peer_usernames_peer_order_idx;
DROP INDEX IF EXISTS public.peer_usernames_peer_editable_idx;
ALTER TABLE public.peer_usernames
DROP CONSTRAINT IF EXISTS peer_usernames_sort_order_check,
DROP CONSTRAINT IF EXISTS peer_usernames_collectible_not_editable_check,
DROP CONSTRAINT IF EXISTS peer_usernames_username_case_check;
ALTER TABLE public.peer_usernames
DROP COLUMN IF EXISTS collectible_id,
DROP COLUMN IF EXISTS sort_order,
DROP COLUMN IF EXISTS editable,
DROP COLUMN IF EXISTS active,
DROP COLUMN IF EXISTS username;
CREATE UNIQUE INDEX IF NOT EXISTS peer_usernames_peer_unique_idx
ON public.peer_usernames (peer_type, peer_id);
CREATE OR REPLACE FUNCTION public.delete_user_peer_username() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
DELETE FROM public.peer_usernames WHERE peer_type = 'user' AND peer_id = OLD.id;
RETURN OLD;
END;
$$;
CREATE OR REPLACE FUNCTION public.delete_channel_peer_username() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
DELETE FROM public.peer_usernames WHERE peer_type = 'channel' AND peer_id = OLD.id;
RETURN OLD;
END;
$$;
DROP TABLE IF EXISTS public.collectible_username_transfers;
DROP TABLE IF EXISTS public.collectible_usernames;

View file

@ -0,0 +1,194 @@
-- Collectible (Fragment-style) usernames.
--
-- A peer keeps exactly one editable username -- the slot that
-- account.updateUsername / channels.updateUsername owns -- plus any number of
-- collectible usernames it holds. peer_usernames stays the single authoritative
-- registry for global uniqueness across users and channels, so ResolveUsername,
-- public landing pages and occupancy checks keep working for collectible names
-- without a second lookup path.
--
-- The pre-existing one-row-per-peer unique index is replaced by a partial index
-- covering only editable rows: the old invariant is preserved exactly for the
-- editable slot while collectible rows are free to accumulate.
--
-- Ownership lives in collectible_usernames (the asset) and is projected into
-- peer_usernames (the registry). A collectible row in the registry always
-- carries collectible_id and is never editable, so client-driven username edits
-- cannot mutate or release an owned asset.
CREATE TABLE public.collectible_usernames (
id bigserial PRIMARY KEY,
username text NOT NULL,
username_lower text NOT NULL CHECK (
username_lower <> '' AND lower(username) = username_lower
),
status text NOT NULL CHECK (status IN ('vault', 'owned', 'burned')),
-- Empty owner is the vault/burned state; 'owned' always has a real peer.
owner_peer_type text NOT NULL CHECK (owner_peer_type IN ('', 'user', 'channel')),
owner_peer_id bigint NOT NULL CHECK (owner_peer_id >= 0),
CHECK (
(status = 'owned' AND owner_peer_type <> '' AND owner_peer_id > 0)
OR (status <> 'owned' AND owner_peer_type = '' AND owner_peer_id = 0)
),
-- fragment.collectibleInfo projection. Amounts are minor units for fiat and
-- nanotons for TON, matching the star gift lifecycle ledger convention.
purchase_date timestamptz NOT NULL,
currency text NOT NULL CHECK (currency IN ('XTR', 'TON', 'USD')),
amount bigint NOT NULL CHECK (amount >= 0),
crypto_currency text NOT NULL DEFAULT '' CHECK (crypto_currency IN ('', 'TON')),
crypto_amount bigint NOT NULL DEFAULT 0 CHECK (crypto_amount >= 0),
CHECK (
(crypto_currency = '' AND crypto_amount = 0)
OR (crypto_currency <> '' AND crypto_amount > 0)
),
url text NOT NULL DEFAULT '' CHECK (octet_length(url) <= 512),
-- Provenance: the first holder, kept even after transfers and burns.
original_owner_peer_type text NOT NULL DEFAULT '' CHECK (
original_owner_peer_type IN ('', 'user', 'channel')
),
original_owner_peer_id bigint NOT NULL DEFAULT 0 CHECK (original_owner_peer_id >= 0),
transfer_count integer NOT NULL DEFAULT 0 CHECK (transfer_count >= 0),
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
CHECK (updated_at >= created_at)
);
-- A burned asset releases the name for a fresh issue while retaining immutable
-- provenance. At most one live asset may own a name; any number of burned
-- historical rows may remain.
CREATE UNIQUE INDEX collectible_usernames_live_name_idx
ON public.collectible_usernames (username_lower)
WHERE status <> 'burned';
CREATE INDEX collectible_usernames_name_history_idx
ON public.collectible_usernames (username_lower, id DESC);
CREATE INDEX collectible_usernames_owner_idx
ON public.collectible_usernames (owner_peer_type, owner_peer_id, id DESC)
WHERE status = 'owned';
CREATE INDEX collectible_usernames_status_idx
ON public.collectible_usernames (status, id DESC);
-- Append-only provenance log. command_key makes admin mint/transfer/revoke
-- replay-safe the same way star_gift_admin_grant_commands does for gifts.
CREATE TABLE public.collectible_username_transfers (
id bigserial PRIMARY KEY,
collectible_id bigint NOT NULL REFERENCES public.collectible_usernames(id)
ON DELETE CASCADE,
kind text NOT NULL CHECK (kind IN ('mint', 'transfer', 'revoke', 'burn')),
from_peer_type text NOT NULL CHECK (from_peer_type IN ('', 'user', 'channel')),
from_peer_id bigint NOT NULL CHECK (from_peer_id >= 0),
to_peer_type text NOT NULL CHECK (to_peer_type IN ('', 'user', 'channel')),
to_peer_id bigint NOT NULL CHECK (to_peer_id >= 0),
currency text NOT NULL DEFAULT '' CHECK (currency IN ('', 'XTR', 'TON', 'USD')),
amount bigint NOT NULL DEFAULT 0 CHECK (amount >= 0),
actor text NOT NULL DEFAULT '' CHECK (octet_length(actor) <= 128),
reason text NOT NULL DEFAULT '' CHECK (octet_length(reason) <= 512),
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 collectible_username_transfers_command_idx
ON public.collectible_username_transfers (command_key)
WHERE command_key IS NOT NULL;
CREATE INDEX collectible_username_transfers_asset_idx
ON public.collectible_username_transfers (collectible_id, id DESC);
ALTER TABLE public.peer_usernames
ADD COLUMN username text NOT NULL DEFAULT '',
ADD COLUMN active boolean NOT NULL DEFAULT true,
ADD COLUMN editable boolean NOT NULL DEFAULT true,
ADD COLUMN sort_order integer NOT NULL DEFAULT 0,
ADD COLUMN collectible_id bigint REFERENCES public.collectible_usernames(id)
ON DELETE CASCADE;
-- Recover the original-case display form for rows written before the column
-- existed; fall back to the lowercase key when the peer row is already gone.
UPDATE public.peer_usernames pu
SET username = u.username
FROM public.users u
WHERE pu.peer_type = 'user'
AND pu.peer_id = u.id
AND pu.username = ''
AND lower(u.username) = pu.username_lower;
UPDATE public.peer_usernames pu
SET username = c.username
FROM public.channels c
WHERE pu.peer_type = 'channel'
AND pu.peer_id = c.id
AND pu.username = ''
AND lower(COALESCE(c.username, '')) = pu.username_lower;
UPDATE public.peer_usernames
SET username = username_lower
WHERE username = '';
ALTER TABLE public.peer_usernames
ALTER COLUMN username DROP DEFAULT,
ADD CONSTRAINT peer_usernames_username_case_check
CHECK (lower(username) = username_lower),
ADD CONSTRAINT peer_usernames_collectible_not_editable_check
CHECK (collectible_id IS NULL OR NOT editable),
ADD CONSTRAINT peer_usernames_sort_order_check
CHECK (sort_order >= 0 AND sort_order <= 1024);
DROP INDEX IF EXISTS public.peer_usernames_peer_unique_idx;
CREATE UNIQUE INDEX peer_usernames_peer_editable_idx
ON public.peer_usernames (peer_type, peer_id)
WHERE editable;
CREATE INDEX peer_usernames_peer_order_idx
ON public.peer_usernames (peer_type, peer_id, sort_order, username_lower);
CREATE UNIQUE INDEX peer_usernames_collectible_idx
ON public.peer_usernames (collectible_id)
WHERE collectible_id IS NOT NULL;
-- Peer deletion must not destroy a collectible asset: the registry row goes
-- away with the peer, the asset returns to the vault and keeps its provenance.
CREATE OR REPLACE FUNCTION public.delete_user_peer_username() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE public.collectible_usernames
SET status = 'vault',
owner_peer_type = '',
owner_peer_id = 0,
version = version + 1,
updated_at = now()
WHERE status = 'owned'
AND owner_peer_type = 'user'
AND owner_peer_id = OLD.id;
DELETE FROM public.peer_usernames
WHERE peer_type = 'user' AND peer_id = OLD.id;
RETURN OLD;
END;
$$;
CREATE OR REPLACE FUNCTION public.delete_channel_peer_username() RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
UPDATE public.collectible_usernames
SET status = 'vault',
owner_peer_type = '',
owner_peer_id = 0,
version = version + 1,
updated_at = now()
WHERE status = 'owned'
AND owner_peer_type = 'channel'
AND owner_peer_id = OLD.id;
DELETE FROM public.peer_usernames
WHERE peer_type = 'channel' AND peer_id = OLD.id;
RETURN OLD;
END;
$$;

View file

@ -0,0 +1,3 @@
DROP INDEX IF EXISTS public.moderation_cases_target_history_idx;
DROP TABLE IF EXISTS public.account_rating_events;
DROP TABLE IF EXISTS public.account_rating;

View file

@ -0,0 +1,72 @@
-- Server-local composite account rating for gramsrv clients and moderation /
-- operations. This uses gramsrv's own policy rather than claiming to reproduce
-- Telegram's private rating algorithm.
--
-- 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 gramsrv model, not a wallet
-- balance.
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 gramsrv 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');

View file

@ -0,0 +1,9 @@
-- Remove the built-in @verifybot seed. Its private history is left alone: chat
-- rows reference the account, and dropping them would rewrite users' dialogs.
DELETE FROM public.read_model_versions
WHERE owner_user_id = 1250000011 AND peer_type = 'user' AND peer_id = 1250000011;
DELETE FROM public.peer_usernames
WHERE peer_type = 'user' AND peer_id = 1250000011;
DELETE FROM public.bots WHERE bot_user_id = 1250000011;

View file

@ -0,0 +1,89 @@
-- Built-in @verifybot: the front door for official platform verification.
--
-- Seeded here rather than lazily on first message so the username is occupied
-- from the moment the schema is current: peer_usernames.username_lower is the
-- only thing standing between a reserved bot handle and an ordinary user
-- claiming it, and a lazily created account would leave that window open.
--
-- access_hash is double-written with domain.VerifyBotAccessHash; the two must
-- never drift, exactly as for the other service bots (0044, 0045, 0047).
--
-- The peer_usernames insert carries the multi-username registry columns added in
-- 0149, so the handle occupies the editable slot.
INSERT INTO public.users (
id, access_hash, phone, first_name, last_name, username, country_code,
created_at, updated_at, verified, support, about, last_seen_at,
default_history_ttl_period, is_bot, bot_info_version, premium_expires_at,
emoji_status_document_id, emoji_status_until, color_set, color,
color_background_emoji_id, profile_color_set, profile_color,
profile_color_background_emoji_id
) VALUES (
1250000011, 7802113947355620887, '', 'Verify Bot', '', 'verifybot', '',
now(), now(), true, false,
'Apply for official verification of a public channel, supergroup or bot.',
0, 0, true, 1, NULL, 0, 0, false, 0, 0, false, 0, 0
)
ON CONFLICT (id) DO UPDATE SET
access_hash = EXCLUDED.access_hash,
phone = EXCLUDED.phone,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
username = EXCLUDED.username,
verified = EXCLUDED.verified,
support = EXCLUDED.support,
about = EXCLUDED.about,
is_bot = EXCLUDED.is_bot,
bot_info_version = GREATEST(public.users.bot_info_version, EXCLUDED.bot_info_version),
updated_at = now();
INSERT INTO public.bots (
bot_user_id, owner_user_id, token_secret, description, commands,
bot_chat_history, bot_nochats, inline_placeholder, created_at, updated_at,
menu_button_type, menu_button_text, menu_button_url, bot_inline_geo
) VALUES (
1250000011, 1250000011, '',
'Apply for official verification of a public channel, supergroup or bot. The bot collects the application and reports the decision back to you.',
'[
{"command": "start", "description": "how verification works"},
{"command": "new", "description": "file a verification application"},
{"command": "status", "description": "check your applications"},
{"command": "cancel", "description": "cancel the current application"},
{"command": "help", "description": "show help"}
]'::jsonb,
false, true, '', now(), now(), 0, '', '', false
)
ON CONFLICT (bot_user_id) DO UPDATE SET
owner_user_id = EXCLUDED.owner_user_id,
token_secret = EXCLUDED.token_secret,
description = EXCLUDED.description,
commands = EXCLUDED.commands,
bot_chat_history = EXCLUDED.bot_chat_history,
bot_nochats = EXCLUDED.bot_nochats,
inline_placeholder = EXCLUDED.inline_placeholder,
menu_button_type = EXCLUDED.menu_button_type,
menu_button_text = EXCLUDED.menu_button_text,
menu_button_url = EXCLUDED.menu_button_url,
bot_inline_geo = EXCLUDED.bot_inline_geo,
updated_at = now();
INSERT INTO public.peer_usernames (
username_lower, username, peer_type, peer_id, active, editable, sort_order, updated_at
)
VALUES ('verifybot', 'verifybot', 'user', 1250000011, true, true, 0, now())
ON CONFLICT (username_lower) DO UPDATE SET
username = EXCLUDED.username,
peer_type = EXCLUDED.peer_type,
peer_id = EXCLUDED.peer_id,
active = EXCLUDED.active,
editable = EXCLUDED.editable,
updated_at = now();
INSERT INTO public.read_model_versions (model, owner_user_id, peer_type, peer_id, version, updated_at, hash)
VALUES
('contact_account', 1250000011, 'user', 1250000011, 1, now(), 2500001100001),
('channel_active_memberships', 1250000011, 'user', 1250000011, 1, now(), 2500001100002)
ON CONFLICT (model, owner_user_id, peer_type, peer_id) DO UPDATE SET
version = GREATEST(public.read_model_versions.version, EXCLUDED.version),
updated_at = now(),
hash = EXCLUDED.hash;

View file

@ -0,0 +1,3 @@
DROP TABLE IF EXISTS public.verification_notification_outbox;
DROP TABLE IF EXISTS public.verification_application_events;
DROP TABLE IF EXISTS public.verification_applications;

View file

@ -0,0 +1,138 @@
-- 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;

View file

@ -0,0 +1,4 @@
DROP TABLE IF EXISTS public.custom_verification_requests;
DROP TABLE IF EXISTS public.custom_verifications;
DROP TABLE IF EXISTS public.bot_verifier_settings;
DROP TABLE IF EXISTS public.verification_icons;

View file

@ -0,0 +1,140 @@
-- Third-party bot verification (core.telegram.org/api/bots/verification).
--
-- This is a SEPARATE mechanism from the official platform badge implemented in
-- 0153/0154. Official verification is a boolean on the peer that only the
-- operator can set and that clients render as the standard checkmark. Third-party
-- verification is an attributed mark granted by a verifier bot: it carries that
-- verifier's own custom-emoji icon and a human-readable description, renders
-- BEFORE the name, and never becomes the official checkmark. Both can coexist on
-- one peer, and neither reads the other's tables.
--
-- Layer 228 surfaces:
-- user#b1b8cc83 bot_verification_icon:flags2.14?long
-- channel#d49f34c6 bot_verification_icon:flags2.13?long
-- userFull#6cbe645 bot_verification:flags2.12?BotVerification
-- channelFull#a04e8d3a bot_verification:flags2.17?BotVerification
-- chatInvite#5c9d3702 bot_verification:flags.13?BotVerification
-- botInfo#4d8a0299 verifier_settings:flags.9?BotVerifierSettings
-- The icon catalogue. An icon is a custom emoji document the client resolves with
-- messages.getCustomEmojiDocuments, so document_id must name a real document:
-- clients render nothing for an id they cannot fetch.
CREATE TABLE public.verification_icons (
id bigserial PRIMARY KEY,
document_id bigint NOT NULL UNIQUE CHECK (document_id > 0),
-- owner_bot_id is 0 for a shared catalogue entry any verifier may use, and a
-- bot id when the operator reserved the icon for one verifier.
owner_bot_id bigint NOT NULL DEFAULT 0 CHECK (owner_bot_id >= 0),
name text NOT NULL CHECK (octet_length(name) BETWEEN 1 AND 512),
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
CHECK (updated_at >= created_at)
);
CREATE INDEX verification_icons_active_idx
ON public.verification_icons (active, id DESC);
CREATE INDEX verification_icons_owner_idx
ON public.verification_icons (owner_bot_id, id DESC)
WHERE owner_bot_id <> 0;
-- Verifier status. A row here is what makes a bot a verifier: it is projected as
-- botInfo.verifier_settings and is the only authority bots.setCustomVerification
-- consults, which is why granting it is an operator action and never a bot one.
CREATE TABLE public.bot_verifier_settings (
bot_id bigint PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE,
icon_document_id bigint NOT NULL CHECK (icon_document_id > 0),
company_name text NOT NULL CHECK (octet_length(company_name) BETWEEN 1 AND 512),
default_description text NOT NULL DEFAULT '' CHECK (octet_length(default_description) <= 280),
-- can_modify_custom_description mirrors the TL flag: when false the verifier
-- may only apply default_description, so a per-peer description cannot be
-- smuggled past the operator.
can_modify_custom_description boolean NOT NULL DEFAULT false,
enabled boolean NOT NULL DEFAULT true,
granted_by text NOT NULL DEFAULT '' CHECK (octet_length(granted_by) <= 128),
grant_reason text NOT NULL DEFAULT '' CHECK (octet_length(grant_reason) <= 4096),
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
CHECK (updated_at >= created_at)
);
CREATE INDEX bot_verifier_settings_enabled_idx
ON public.bot_verifier_settings (enabled, bot_id);
-- Granted marks. The wire model carries one BotVerification per peer, so a new
-- verifier replaces the previous mark instead of leaving hidden rows that could
-- reappear after a later revocation.
CREATE TABLE public.custom_verifications (
id bigserial PRIMARY KEY,
verifier_bot_id bigint NOT NULL REFERENCES public.bot_verifier_settings(bot_id)
ON DELETE CASCADE,
peer_type text NOT NULL CHECK (peer_type IN ('user', 'channel')),
peer_id bigint NOT NULL CHECK (peer_id > 0),
-- icon_document_id is denormalised from the verifier at grant time: the mark
-- must keep rendering the icon it was granted with even if the verifier later
-- changes its own.
icon_document_id bigint NOT NULL CHECK (icon_document_id > 0),
description text NOT NULL DEFAULT '' CHECK (octet_length(description) <= 4096),
granted_by_user_id bigint NOT NULL DEFAULT 0 CHECK (granted_by_user_id >= 0),
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
CONSTRAINT custom_verifications_peer_once UNIQUE (peer_type, peer_id),
CHECK (updated_at >= created_at)
);
-- Projection and ownership lookup by peer.
CREATE INDEX custom_verifications_peer_idx
ON public.custom_verifications (peer_type, peer_id, id DESC);
CREATE INDEX custom_verifications_verifier_idx
ON public.custom_verifications (verifier_bot_id, id DESC);
-- Applications a peer files with a verifier bot. The mark itself lives in
-- custom_verifications; this is the review queue in front of it, so a rejected or
-- revoked application stays as history without implying a mark.
CREATE TABLE public.custom_verification_requests (
id bigserial PRIMARY KEY,
verifier_bot_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
applicant_user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
peer_type text NOT NULL CHECK (peer_type IN ('user', 'channel')),
peer_id bigint NOT NULL CHECK (peer_id > 0),
peer_title text NOT NULL DEFAULT '' CHECK (octet_length(peer_title) <= 1024),
peer_username text NOT NULL DEFAULT '' CHECK (octet_length(peer_username) <= 64),
reason text NOT NULL DEFAULT '' CHECK (octet_length(reason) <= 16384),
requested_description text NOT NULL DEFAULT '' CHECK (octet_length(requested_description) <= 280),
status text NOT NULL CHECK (status IN ('pending', 'approved', 'rejected', 'revoked')),
decided_by text NOT NULL DEFAULT '' CHECK (octet_length(decided_by) <= 128),
decision_reason text NOT NULL DEFAULT '' CHECK (octet_length(decision_reason) <= 4096),
internal_note text NOT NULL DEFAULT '' CHECK (octet_length(internal_note) <= 32768),
correlation_id text NOT NULL DEFAULT '' CHECK (octet_length(correlation_id) <= 128),
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
approved_at timestamptz,
rejected_at timestamptz,
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
CHECK (updated_at >= created_at),
CHECK ((status = 'approved') = (approved_at IS NOT NULL)),
CHECK ((status = 'rejected') = (rejected_at IS NOT NULL)),
CHECK (status <> 'rejected' OR decision_reason <> '')
);
-- One live application per (verifier, peer): a second pending row would let two
-- decisions race for one mark.
CREATE UNIQUE INDEX custom_verification_requests_pending_idx
ON public.custom_verification_requests (verifier_bot_id, peer_type, peer_id)
WHERE status = 'pending';
CREATE INDEX custom_verification_requests_queue_idx
ON public.custom_verification_requests (status, created_at DESC, id DESC);
CREATE INDEX custom_verification_requests_verifier_idx
ON public.custom_verification_requests (verifier_bot_id, id DESC);
CREATE INDEX custom_verification_requests_applicant_idx
ON public.custom_verification_requests (applicant_user_id, id DESC);
CREATE INDEX custom_verification_requests_peer_idx
ON public.custom_verification_requests (peer_type, peer_id, id DESC);

View file

@ -0,0 +1,12 @@
-- Remove the built-in @verifierbot seed. Its private history is left alone: chat
-- rows reference the account, and dropping them would rewrite users' dialogs.
--
-- Any verifier status an operator granted this bot lives in bot_verifier_settings
-- (0155) and cascades when this account seed is removed.
DELETE FROM public.read_model_versions
WHERE owner_user_id = 1250000013 AND peer_type = 'user' AND peer_id = 1250000013;
DELETE FROM public.peer_usernames
WHERE peer_type = 'user' AND peer_id = 1250000013;
DELETE FROM public.bots WHERE bot_user_id = 1250000013;

View file

@ -0,0 +1,103 @@
-- Built-in @verifierbot: the applicant front door for THIRD-PARTY verification
-- (core.telegram.org/api/bots/verification).
--
-- This is the first verifier bot of a deployment, shipped so the feature has a
-- working reference: it collects applications for its own icon+description mark
-- and reports the operator's decision back to the applicant. It is deliberately
-- NOT a second way to get the platform checkmark -- @verifybot (0153) owns that
-- mechanism, and the two never read each other's state.
--
-- verified = false on purpose. The official flag is granted by the operator to
-- peers that passed platform review; a verifier bot carrying it would blur exactly
-- the distinction this bot has to explain to every applicant. What makes this
-- account a verifier is a row in bot_verifier_settings (0155), which an operator
-- grants by hand in the admin panel -- never this migration: seeding verifier
-- status here would hand out a badge printer with the schema.
--
-- Seeded rather than created lazily on first message so the username is occupied
-- from the moment the schema is current: peer_usernames.username_lower is the only
-- thing standing between a reserved bot handle and an ordinary user claiming it,
-- and a lazily created account would leave that window open.
--
-- access_hash is double-written with domain.VerifierBotAccessHash; the two must
-- never drift, exactly as for the other service bots (0044, 0045, 0047, 0153).
--
-- The peer_usernames insert carries the multi-username registry columns added in
-- 0149, so the handle occupies the editable slot.
INSERT INTO public.users (
id, access_hash, phone, first_name, last_name, username, country_code,
created_at, updated_at, verified, support, about, last_seen_at,
default_history_ttl_period, is_bot, bot_info_version, premium_expires_at,
emoji_status_document_id, emoji_status_until, color_set, color,
color_background_emoji_id, profile_color_set, profile_color,
profile_color_background_emoji_id
) VALUES (
1250000013, 6913402578811563729, '', 'Verifier Bot', '', 'verifierbot', '',
now(), now(), false, false,
'Third-party verification: my icon before your name and a line in your profile. Not the official checkmark.',
0, 0, true, 1, NULL, 0, 0, false, 0, 0, false, 0, 0
)
ON CONFLICT (id) DO UPDATE SET
access_hash = EXCLUDED.access_hash,
phone = EXCLUDED.phone,
first_name = EXCLUDED.first_name,
last_name = EXCLUDED.last_name,
username = EXCLUDED.username,
verified = EXCLUDED.verified,
support = EXCLUDED.support,
about = EXCLUDED.about,
is_bot = EXCLUDED.is_bot,
bot_info_version = GREATEST(public.users.bot_info_version, EXCLUDED.bot_info_version),
updated_at = now();
INSERT INTO public.bots (
bot_user_id, owner_user_id, token_secret, description, commands,
bot_chat_history, bot_nochats, inline_placeholder, created_at, updated_at,
menu_button_type, menu_button_text, menu_button_url, bot_inline_geo
) VALUES (
1250000013, 1250000013, '',
'I grant third-party verification marks: my own icon before the name of your bot, channel or account, plus a description in its profile. This is not the official platform checkmark. I collect the application, an operator decides, and I message you here with the outcome.',
'[
{"command": "start", "description": "what a third-party mark is"},
{"command": "verify", "description": "apply for the mark"},
{"command": "status", "description": "your applications and marks"},
{"command": "revoke", "description": "remove a mark from your peer"},
{"command": "help", "description": "show help"}
]'::jsonb,
false, true, '', now(), now(), 0, '', '', false
)
ON CONFLICT (bot_user_id) DO UPDATE SET
owner_user_id = EXCLUDED.owner_user_id,
token_secret = EXCLUDED.token_secret,
description = EXCLUDED.description,
commands = EXCLUDED.commands,
bot_chat_history = EXCLUDED.bot_chat_history,
bot_nochats = EXCLUDED.bot_nochats,
inline_placeholder = EXCLUDED.inline_placeholder,
menu_button_type = EXCLUDED.menu_button_type,
menu_button_text = EXCLUDED.menu_button_text,
menu_button_url = EXCLUDED.menu_button_url,
bot_inline_geo = EXCLUDED.bot_inline_geo,
updated_at = now();
INSERT INTO public.peer_usernames (
username_lower, username, peer_type, peer_id, active, editable, sort_order, updated_at
)
VALUES ('verifierbot', 'verifierbot', 'user', 1250000013, true, true, 0, now())
ON CONFLICT (username_lower) DO UPDATE SET
username = EXCLUDED.username,
peer_type = EXCLUDED.peer_type,
peer_id = EXCLUDED.peer_id,
active = EXCLUDED.active,
editable = EXCLUDED.editable,
updated_at = now();
INSERT INTO public.read_model_versions (model, owner_user_id, peer_type, peer_id, version, updated_at, hash)
VALUES
('contact_account', 1250000013, 'user', 1250000013, 1, now(), 2500001300001),
('channel_active_memberships', 1250000013, 'user', 1250000013, 1, now(), 2500001300002)
ON CONFLICT (model, owner_user_id, peer_type, peer_id) DO UPDATE SET
version = GREATEST(public.read_model_versions.version, EXCLUDED.version),
updated_at = now(),
hash = EXCLUDED.hash;

View file

@ -0,0 +1,23 @@
-- Narrowing back can only succeed once no custom-emoji reaction rows remain, so
-- drop them first: a CHECK that a stored row violates cannot be added.
DELETE FROM public.channel_message_reactions WHERE (reaction_type)::text <> 'emoji';
DELETE FROM public.user_top_reactions WHERE (reaction_type)::text <> 'emoji';
DELETE FROM public.user_recent_reactions WHERE (reaction_type)::text <> 'emoji';
ALTER TABLE public.channel_message_reactions
DROP CONSTRAINT IF EXISTS channel_message_reactions_type_check;
ALTER TABLE public.channel_message_reactions
ADD CONSTRAINT channel_message_reactions_type_check
CHECK ((reaction_type)::text = 'emoji'::text);
ALTER TABLE public.user_top_reactions
DROP CONSTRAINT IF EXISTS user_top_reactions_reaction_type_check;
ALTER TABLE public.user_top_reactions
ADD CONSTRAINT user_top_reactions_reaction_type_check
CHECK ((reaction_type)::text = 'emoji'::text);
ALTER TABLE public.user_recent_reactions
DROP CONSTRAINT IF EXISTS user_recent_reactions_reaction_type_check;
ALTER TABLE public.user_recent_reactions
ADD CONSTRAINT user_recent_reactions_reaction_type_check
CHECK ((reaction_type)::text = 'emoji'::text);

View file

@ -0,0 +1,28 @@
-- Custom-emoji reactions on channel messages.
--
-- The squashed 0001_init schema constrains channel_message_reactions.reaction_type,
-- user_top_reactions.reaction_type and user_recent_reactions.reaction_type to
-- 'emoji' only, while the saved-tag tables already allow 'custom_emoji' and the
-- store writes reactionCustomEmoji through all of them. On a database built from
-- these migrations a custom-emoji reaction in a channel therefore fails with
-- "violates check constraint ..._type_check"; upstream's own
-- TestChannelStoreCustomEmojiReactionPolicyRoundTrips fails for exactly this
-- reason. Widen the three CHECKs to the pair the saved-tag tables already use.
ALTER TABLE public.channel_message_reactions
DROP CONSTRAINT IF EXISTS channel_message_reactions_type_check;
ALTER TABLE public.channel_message_reactions
ADD CONSTRAINT channel_message_reactions_type_check
CHECK ((reaction_type)::text = ANY (ARRAY['emoji'::text, 'custom_emoji'::text]));
ALTER TABLE public.user_top_reactions
DROP CONSTRAINT IF EXISTS user_top_reactions_reaction_type_check;
ALTER TABLE public.user_top_reactions
ADD CONSTRAINT user_top_reactions_reaction_type_check
CHECK ((reaction_type)::text = ANY (ARRAY['emoji'::text, 'custom_emoji'::text]));
ALTER TABLE public.user_recent_reactions
DROP CONSTRAINT IF EXISTS user_recent_reactions_reaction_type_check;
ALTER TABLE public.user_recent_reactions
ADD CONSTRAINT user_recent_reactions_reaction_type_check
CHECK ((reaction_type)::text = ANY (ARRAY['emoji'::text, 'custom_emoji'::text]));

View file

@ -0,0 +1,5 @@
-- Nothing to restore: the rating read model is derived from live signals, so a
-- deleted projection is recomputed rather than recovered, and the seeding pass
-- deliberately never offers these accounts again. Down is a no-op rather than a
-- lie about being able to bring the rows back.
SELECT 1;

View file

@ -0,0 +1,20 @@
-- Service accounts carry no composite rating.
--
-- The rating measures what an account did with Stars, so a bot -- which does not
-- transact on its own behalf -- and the built-in service accounts -- which are
-- infrastructure rather than participants -- have no meaningful score. The seeding
-- pass now excludes both, but the platform account (777000) is not flagged is_bot,
-- so a deployment that already ran a recompute cycle has a projection row for it
-- and would show a level badge on the platform account's profile.
--
-- Drop those rows and their ledger. The read model is derived, so deleting a row
-- loses nothing that cannot be recomputed; the ledger rows go with it because a
-- manual adjustment for an account that can no longer be rated is unreachable
-- bookkeeping.
DELETE FROM public.account_rating_events
WHERE user_id IN (777000, 93372553, 1063110917, 1250000007, 1250000011, 1250000013)
OR user_id IN (SELECT id FROM public.users WHERE is_bot);
DELETE FROM public.account_rating
WHERE user_id IN (777000, 93372553, 1063110917, 1250000007, 1250000011, 1250000013)
OR user_id IN (SELECT id FROM public.users WHERE is_bot);

View file

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS private_no_forwards_requests;
DROP TABLE IF EXISTS private_no_forwards_chats;

View file

@ -0,0 +1,36 @@
CREATE TABLE private_no_forwards_chats (
user_low_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_high_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
enabled_by_user_id bigint REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_low_id, user_high_id),
CONSTRAINT private_no_forwards_distinct_users CHECK (user_low_id < user_high_id),
CONSTRAINT private_no_forwards_enabled_participant CHECK (
enabled_by_user_id IS NULL
OR enabled_by_user_id = user_low_id
OR enabled_by_user_id = user_high_id
)
);
CREATE TABLE private_no_forwards_requests (
private_message_sender_user_id bigint NOT NULL,
private_message_id bigint NOT NULL,
requester_user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
responder_user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at integer NOT NULL,
handled_at integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (private_message_sender_user_id, private_message_id),
CONSTRAINT private_no_forwards_request_message_fk
FOREIGN KEY (private_message_sender_user_id, private_message_id)
REFERENCES private_messages(sender_user_id, id) ON DELETE CASCADE,
CONSTRAINT private_no_forwards_request_distinct_users
CHECK (requester_user_id <> responder_user_id),
CONSTRAINT private_no_forwards_request_valid_expiry
CHECK (expires_at > 0 AND handled_at >= 0)
);
CREATE INDEX private_no_forwards_requests_responder_expiry_idx
ON private_no_forwards_requests (responder_user_id, expires_at)
WHERE handled_at = 0;

View file

@ -0,0 +1 @@
DROP INDEX IF EXISTS public.peer_usernames_active_search_idx;

View file

@ -0,0 +1,6 @@
-- Active usernames are searched by peer type and a case-normalized prefix.
-- text_pattern_ops keeps the lookup indexable even when the database collation
-- cannot use a regular btree index for LIKE 'prefix%'.
CREATE INDEX peer_usernames_active_search_idx
ON public.peer_usernames (peer_type, username_lower text_pattern_ops, peer_id)
WHERE active;

View file

@ -0,0 +1,4 @@
ALTER TABLE channel_members
DROP CONSTRAINT IF EXISTS channel_members_history_clear_anchor_check,
DROP COLUMN IF EXISTS history_clear_anchor_date,
DROP COLUMN IF EXISTS history_clear_anchor_id;

View file

@ -0,0 +1,14 @@
ALTER TABLE channel_members
ADD COLUMN history_clear_anchor_id integer DEFAULT 0 NOT NULL,
ADD COLUMN history_clear_anchor_date integer DEFAULT 0 NOT NULL,
ADD CONSTRAINT channel_members_history_clear_anchor_check CHECK (
(
history_clear_anchor_id = 0
AND history_clear_anchor_date = 0
)
OR (
history_clear_anchor_id > 0
AND history_clear_anchor_date > 0
AND history_clear_anchor_id <= available_min_id
)
);

View file

@ -0,0 +1,7 @@
DROP INDEX IF EXISTS user_channel_member_index_history_clear_idx;
ALTER TABLE user_channel_member_index
DROP CONSTRAINT IF EXISTS user_channel_member_index_history_clear_check,
DROP COLUMN IF EXISTS history_clear_updated_at,
DROP COLUMN IF EXISTS history_clear_anchor_id,
DROP COLUMN IF EXISTS available_min_id;

View file

@ -0,0 +1,22 @@
ALTER TABLE user_channel_member_index
ADD COLUMN available_min_id integer DEFAULT 0 NOT NULL,
ADD COLUMN history_clear_anchor_id integer DEFAULT 0 NOT NULL,
ADD COLUMN history_clear_updated_at integer DEFAULT 0 NOT NULL,
ADD CONSTRAINT user_channel_member_index_history_clear_check CHECK (
(
history_clear_anchor_id = 0
AND history_clear_updated_at = 0
)
OR (
history_clear_anchor_id > 0
AND history_clear_updated_at > 0
AND history_clear_anchor_id <= available_min_id
)
);
CREATE INDEX user_channel_member_index_history_clear_idx
ON user_channel_member_index (user_id, channel_id)
INCLUDE (available_min_id, history_clear_updated_at)
WHERE status = 'active'
AND NOT deleted
AND history_clear_anchor_id > 0;

View file

@ -0,0 +1,3 @@
-- Paired no-op for the retired 0163 version slot. Runtime write boundaries,
-- not migration history, enforce viewer-specific Star Gift capabilities.
SELECT 1;

View file

@ -0,0 +1,8 @@
-- Retired on 2026-08-01 before any reported deployment completed this version.
--
-- The original migration attempted to repair development-only message-box
-- projections and could abort server startup when an aggregate no longer had a
-- live owner box. Project policy forbids migrations for unpublished internal
-- shapes. Keep the version slot so a database at version 162 can advance, but
-- never inspect or mutate gift/message/PTS/outbox state here.
SELECT 1;

View file

@ -0,0 +1,8 @@
DROP INDEX IF EXISTS public.channel_ton_transactions_outgoing_idx;
DROP INDEX IF EXISTS public.channel_ton_transactions_incoming_idx;
DROP INDEX IF EXISTS public.channel_stars_transactions_outgoing_idx;
DROP INDEX IF EXISTS public.channel_stars_transactions_incoming_idx;
DROP INDEX IF EXISTS public.ton_transactions_user_outgoing_idx;
DROP INDEX IF EXISTS public.ton_transactions_user_incoming_idx;
DROP INDEX IF EXISTS public.stars_transactions_user_outgoing_idx;
DROP INDEX IF EXISTS public.stars_transactions_user_incoming_idx;

View file

@ -0,0 +1,22 @@
-- payments.getStarsTransactions applies the sign predicate before keyset LIMIT.
-- Partial owner/id indexes keep sparse inbound/outbound histories bounded even
-- when one account or channel has a long run of transactions in the other direction.
CREATE INDEX stars_transactions_user_incoming_idx
ON public.stars_transactions(user_id, id DESC) WHERE amount > 0;
CREATE INDEX stars_transactions_user_outgoing_idx
ON public.stars_transactions(user_id, id DESC) WHERE amount < 0;
CREATE INDEX ton_transactions_user_incoming_idx
ON public.ton_transactions(user_id, id DESC) WHERE amount_nanoton > 0;
CREATE INDEX ton_transactions_user_outgoing_idx
ON public.ton_transactions(user_id, id DESC) WHERE amount_nanoton < 0;
CREATE INDEX channel_stars_transactions_incoming_idx
ON public.channel_stars_transactions(channel_id, id DESC) WHERE amount > 0;
CREATE INDEX channel_stars_transactions_outgoing_idx
ON public.channel_stars_transactions(channel_id, id DESC) WHERE amount < 0;
CREATE INDEX channel_ton_transactions_incoming_idx
ON public.channel_ton_transactions(channel_id, id DESC) WHERE amount_nanoton > 0;
CREATE INDEX channel_ton_transactions_outgoing_idx
ON public.channel_ton_transactions(channel_id, id DESC) WHERE amount_nanoton < 0;

View file

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS public.stars_gift_purchase_commands;
DROP TABLE IF EXISTS public.stars_gift_purchase_forms;

View file

@ -0,0 +1,45 @@
CREATE TABLE public.stars_gift_purchase_forms (
buyer_user_id bigint NOT NULL,
form_id bigint NOT NULL,
recipient_user_id bigint NOT NULL,
stars bigint NOT NULL,
currency text NOT NULL,
amount bigint NOT NULL,
issued_at integer NOT NULL,
expires_at integer NOT NULL,
CONSTRAINT stars_gift_purchase_forms_pkey PRIMARY KEY (buyer_user_id, form_id),
CONSTRAINT stars_gift_purchase_forms_shape_check CHECK (
buyer_user_id > 0 AND recipient_user_id > 0 AND
buyer_user_id <> recipient_user_id AND form_id <> 0 AND
stars > 0 AND amount > 0 AND char_length(currency) = 3 AND
currency = upper(currency) AND issued_at > 0 AND
expires_at = issued_at + 600)
);
CREATE INDEX stars_gift_purchase_forms_expiry_idx
ON public.stars_gift_purchase_forms (expires_at, buyer_user_id, form_id);
CREATE TABLE public.stars_gift_purchase_commands (
buyer_user_id bigint NOT NULL,
form_id bigint NOT NULL,
request_fingerprint bytea NOT NULL,
recipient_user_id bigint NOT NULL,
stars bigint NOT NULL,
currency text NOT NULL,
amount bigint NOT NULL,
recipient_balance_after bigint NOT NULL,
transaction_id text NOT NULL,
created_at integer NOT NULL,
CONSTRAINT stars_gift_purchase_commands_pkey PRIMARY KEY (buyer_user_id, form_id),
CONSTRAINT stars_gift_purchase_commands_form_fkey
FOREIGN KEY (buyer_user_id, form_id)
REFERENCES public.stars_gift_purchase_forms (buyer_user_id, form_id)
ON DELETE RESTRICT,
CONSTRAINT stars_gift_purchase_commands_shape_check CHECK (
buyer_user_id > 0 AND recipient_user_id > 0 AND
buyer_user_id <> recipient_user_id AND form_id <> 0 AND
octet_length(request_fingerprint) = 32 AND stars > 0 AND amount > 0 AND
char_length(currency) = 3 AND recipient_balance_after >= 0 AND
transaction_id <> '' AND created_at > 0),
CONSTRAINT stars_gift_purchase_commands_transaction_id_key UNIQUE (transaction_id)
);

View file

@ -0,0 +1,41 @@
DROP TABLE IF EXISTS public.stars_giveaways;
DELETE FROM public.stars_purchase_commands WHERE kind <> 'gift';
DELETE FROM public.stars_purchase_forms WHERE kind <> 'gift';
ALTER TABLE public.stars_purchase_commands
DROP CONSTRAINT stars_purchase_commands_shape_check;
ALTER TABLE public.stars_purchase_commands
RENAME COLUMN balance_after TO recipient_balance_after;
ALTER TABLE public.stars_purchase_commands
DROP COLUMN spend_peer_id,
DROP COLUMN spend_peer_type,
DROP COLUMN purpose_json,
DROP COLUMN kind,
ALTER COLUMN recipient_user_id SET NOT NULL,
ADD CONSTRAINT stars_gift_purchase_commands_shape_check CHECK (
buyer_user_id > 0 AND recipient_user_id > 0 AND
buyer_user_id <> recipient_user_id AND form_id <> 0 AND
octet_length(request_fingerprint) = 32 AND stars > 0 AND amount > 0 AND
char_length(currency) = 3 AND recipient_balance_after >= 0 AND
transaction_id <> '' AND created_at > 0);
ALTER TABLE public.stars_purchase_commands
RENAME TO stars_gift_purchase_commands;
ALTER TABLE public.stars_purchase_forms
DROP CONSTRAINT stars_purchase_forms_shape_check,
DROP COLUMN spend_peer_id,
DROP COLUMN spend_peer_type,
DROP COLUMN purpose_json,
DROP COLUMN kind,
ALTER COLUMN recipient_user_id SET NOT NULL,
ADD CONSTRAINT stars_gift_purchase_forms_shape_check CHECK (
buyer_user_id > 0 AND recipient_user_id > 0 AND
buyer_user_id <> recipient_user_id AND form_id <> 0 AND
stars > 0 AND amount > 0 AND char_length(currency) = 3 AND
currency = upper(currency) AND issued_at > 0 AND
expires_at = issued_at + 600);
ALTER INDEX public.stars_purchase_forms_expiry_idx
RENAME TO stars_gift_purchase_forms_expiry_idx;
ALTER TABLE public.stars_purchase_forms
RENAME TO stars_gift_purchase_forms;

View file

@ -0,0 +1,81 @@
ALTER TABLE public.stars_gift_purchase_forms
RENAME TO stars_purchase_forms;
ALTER INDEX public.stars_gift_purchase_forms_expiry_idx
RENAME TO stars_purchase_forms_expiry_idx;
ALTER TABLE public.stars_purchase_forms
DROP CONSTRAINT stars_gift_purchase_forms_shape_check,
ADD COLUMN kind text NOT NULL DEFAULT 'gift',
ADD COLUMN spend_peer_type text,
ADD COLUMN spend_peer_id bigint,
ADD COLUMN purpose_json jsonb NOT NULL DEFAULT '{}'::jsonb,
ALTER COLUMN recipient_user_id DROP NOT NULL;
ALTER TABLE public.stars_purchase_forms
ALTER COLUMN kind DROP DEFAULT,
ADD CONSTRAINT stars_purchase_forms_shape_check CHECK (
kind IN ('topup', 'gift', 'giveaway') AND buyer_user_id > 0 AND form_id <> 0 AND
((kind IN ('topup', 'giveaway') AND recipient_user_id IS NULL) OR
(kind = 'gift' AND recipient_user_id > 0 AND buyer_user_id <> recipient_user_id)) AND
((spend_peer_type IS NULL AND spend_peer_id IS NULL) OR
(kind = 'topup' AND spend_peer_type IN ('user', 'channel') AND spend_peer_id > 0)) AND
((kind IN ('topup', 'gift') AND purpose_json = '{}'::jsonb) OR
(kind = 'giveaway' AND jsonb_typeof(purpose_json) = 'object' AND purpose_json <> '{}'::jsonb)) AND
stars > 0 AND amount > 0 AND char_length(currency) = 3 AND
currency = upper(currency) AND issued_at > 0 AND
expires_at = issued_at + 600);
ALTER TABLE public.stars_gift_purchase_commands
RENAME TO stars_purchase_commands;
ALTER TABLE public.stars_purchase_commands
DROP CONSTRAINT stars_gift_purchase_commands_shape_check,
ADD COLUMN kind text NOT NULL DEFAULT 'gift',
ADD COLUMN spend_peer_type text,
ADD COLUMN spend_peer_id bigint,
ADD COLUMN purpose_json jsonb NOT NULL DEFAULT '{}'::jsonb,
ALTER COLUMN recipient_user_id DROP NOT NULL;
ALTER TABLE public.stars_purchase_commands
RENAME COLUMN recipient_balance_after TO balance_after;
ALTER TABLE public.stars_purchase_commands
ALTER COLUMN kind DROP DEFAULT,
ADD CONSTRAINT stars_purchase_commands_shape_check CHECK (
kind IN ('topup', 'gift', 'giveaway') AND buyer_user_id > 0 AND form_id <> 0 AND
((kind IN ('topup', 'giveaway') AND recipient_user_id IS NULL) OR
(kind = 'gift' AND recipient_user_id > 0 AND buyer_user_id <> recipient_user_id)) AND
((spend_peer_type IS NULL AND spend_peer_id IS NULL) OR
(kind = 'topup' AND spend_peer_type IN ('user', 'channel') AND spend_peer_id > 0)) AND
((kind IN ('topup', 'gift') AND purpose_json = '{}'::jsonb) OR
(kind = 'giveaway' AND jsonb_typeof(purpose_json) = 'object' AND purpose_json <> '{}'::jsonb)) AND
octet_length(request_fingerprint) = 32 AND stars > 0 AND amount > 0 AND
char_length(currency) = 3 AND balance_after >= 0 AND
transaction_id <> '' AND created_at > 0);
CREATE TABLE public.stars_giveaways (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
buyer_user_id bigint NOT NULL,
form_id bigint NOT NULL,
channel_id bigint NOT NULL,
launch_message_id integer NOT NULL,
random_id bigint NOT NULL,
stars bigint NOT NULL,
users integer NOT NULL,
per_user_stars bigint NOT NULL,
yearly_boosts integer NOT NULL,
until_date integer NOT NULL,
purpose_json jsonb NOT NULL,
state text NOT NULL DEFAULT 'active',
created_at integer NOT NULL,
CONSTRAINT stars_giveaways_form_fk FOREIGN KEY (buyer_user_id, form_id)
REFERENCES public.stars_purchase_forms(buyer_user_id, form_id) ON DELETE RESTRICT,
CONSTRAINT stars_giveaways_form_unique UNIQUE (buyer_user_id, form_id),
CONSTRAINT stars_giveaways_random_unique UNIQUE (buyer_user_id, channel_id, random_id),
CONSTRAINT stars_giveaways_launch_unique UNIQUE (channel_id, launch_message_id),
CONSTRAINT stars_giveaways_shape_check CHECK (
buyer_user_id > 0 AND form_id <> 0 AND channel_id > 0 AND launch_message_id > 0 AND
random_id <> 0 AND stars > 0 AND users > 0 AND per_user_stars > 0 AND
users::bigint * per_user_stars = stars AND yearly_boosts >= 0 AND until_date > created_at AND
jsonb_typeof(purpose_json) = 'object' AND purpose_json <> '{}'::jsonb AND
state IN ('active', 'completed', 'cancelled') AND created_at > 0)
);
CREATE INDEX stars_giveaways_channel_state_until_idx
ON public.stars_giveaways(channel_id, state, until_date, id);