Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877
This commit is contained in:
commit
ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions
|
|
@ -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])))
|
||||
);
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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[])
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
112
deploy/migrations/20260714003098_moderation_reports.up.sql
Normal file
112
deploy/migrations/20260714003098_moderation_reports.up.sql
Normal 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
|
||||
);
|
||||
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS public.auth_delivery_reports;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
202
deploy/migrations/20260714003100_moderation_cases.up.sql
Normal file
202
deploy/migrations/20260714003100_moderation_cases.up.sql
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
-- Target-grouped moderation work queue. Reports stay immutable; cases,
|
||||
-- decisions, actions and appeals form a separate optimistic-concurrency state
|
||||
-- machine.
|
||||
CREATE TABLE public.moderation_cases (
|
||||
id bigserial PRIMARY KEY,
|
||||
target_peer_type text NOT NULL CHECK (target_peer_type IN ('user', 'channel')),
|
||||
target_peer_id bigint NOT NULL CHECK (target_peer_id > 0),
|
||||
status text NOT NULL CHECK (status IN (
|
||||
'open', 'in_review', 'action_pending', 'action_failed', 'resolved',
|
||||
'dismissed', 'appeal_review'
|
||||
)),
|
||||
severity smallint NOT NULL CHECK (severity BETWEEN 1 AND 4),
|
||||
assigned_to text NOT NULL DEFAULT '' CHECK (octet_length(assigned_to) <= 128),
|
||||
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
|
||||
report_count integer NOT NULL CHECK (report_count > 0),
|
||||
distinct_reporter_count integer NOT NULL CHECK (
|
||||
distinct_reporter_count > 0
|
||||
AND distinct_reporter_count <= report_count
|
||||
),
|
||||
first_report_at timestamptz NOT NULL,
|
||||
last_report_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
CHECK (last_report_at >= first_report_at),
|
||||
CHECK (updated_at >= created_at)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX moderation_cases_one_active_target_idx
|
||||
ON public.moderation_cases (target_peer_type, target_peer_id)
|
||||
WHERE status IN ('open', 'in_review');
|
||||
|
||||
CREATE INDEX moderation_cases_queue_idx
|
||||
ON public.moderation_cases (status, severity DESC, updated_at DESC, id DESC);
|
||||
|
||||
CREATE INDEX moderation_cases_assignee_idx
|
||||
ON public.moderation_cases (assigned_to, status, updated_at DESC, id DESC)
|
||||
WHERE assigned_to <> '';
|
||||
|
||||
CREATE TABLE public.moderation_case_reports (
|
||||
case_id bigint NOT NULL REFERENCES public.moderation_cases(id)
|
||||
ON DELETE RESTRICT,
|
||||
report_id bigint NOT NULL UNIQUE REFERENCES public.moderation_reports(id)
|
||||
ON DELETE RESTRICT,
|
||||
attached_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (case_id, report_id)
|
||||
);
|
||||
|
||||
CREATE INDEX moderation_case_reports_case_idx
|
||||
ON public.moderation_case_reports (case_id, report_id);
|
||||
|
||||
CREATE TABLE public.moderation_decisions (
|
||||
id bigserial PRIMARY KEY,
|
||||
case_id bigint NOT NULL REFERENCES public.moderation_cases(id)
|
||||
ON DELETE RESTRICT,
|
||||
appeal_id bigint,
|
||||
kind text NOT NULL CHECK (kind IN (
|
||||
'no_violation', 'violation', 'appeal_granted', 'appeal_denied'
|
||||
)),
|
||||
actor text NOT NULL CHECK (octet_length(actor) BETWEEN 1 AND 128),
|
||||
reason text NOT NULL CHECK (char_length(reason) BETWEEN 1 AND 2000),
|
||||
command_id text NOT NULL UNIQUE CHECK (octet_length(command_id) BETWEEN 1 AND 120),
|
||||
fingerprint bytea NOT NULL CHECK (octet_length(fingerprint) = 32),
|
||||
created_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX moderation_decisions_case_idx
|
||||
ON public.moderation_decisions (case_id, created_at, id);
|
||||
|
||||
CREATE TABLE public.moderation_actions (
|
||||
id bigserial PRIMARY KEY,
|
||||
case_id bigint NOT NULL REFERENCES public.moderation_cases(id)
|
||||
ON DELETE RESTRICT,
|
||||
decision_id bigint NOT NULL REFERENCES public.moderation_decisions(id)
|
||||
ON DELETE RESTRICT,
|
||||
kind text NOT NULL CHECK (kind IN (
|
||||
'mark_scam', 'mark_fake', 'clear_peer_flags', 'freeze_account',
|
||||
'unfreeze_account', 'delete_private_message',
|
||||
'delete_channel_message', 'delete_account'
|
||||
)),
|
||||
payload jsonb NOT NULL CHECK (
|
||||
jsonb_typeof(payload) = 'object'
|
||||
AND octet_length(payload::text) <= 65536
|
||||
),
|
||||
status text NOT NULL CHECK (status IN (
|
||||
'pending', 'processing', 'succeeded', 'superseded', 'retry', 'failed'
|
||||
)),
|
||||
attempts integer NOT NULL DEFAULT 0 CHECK (attempts BETWEEN 0 AND 20),
|
||||
available_at timestamptz NOT NULL,
|
||||
lease_until timestamptz,
|
||||
last_error text NOT NULL DEFAULT '' CHECK (char_length(last_error) <= 4000),
|
||||
command_id text NOT NULL UNIQUE CHECK (octet_length(command_id) BETWEEN 1 AND 160),
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
CHECK (updated_at >= created_at)
|
||||
);
|
||||
|
||||
CREATE INDEX moderation_actions_claim_idx
|
||||
ON public.moderation_actions (available_at, id)
|
||||
WHERE status IN ('pending', 'retry', 'processing');
|
||||
|
||||
CREATE INDEX moderation_actions_case_idx
|
||||
ON public.moderation_actions (case_id, id);
|
||||
|
||||
CREATE TABLE public.moderation_appeals (
|
||||
id bigserial PRIMARY KEY,
|
||||
case_id bigint NOT NULL REFERENCES public.moderation_cases(id)
|
||||
ON DELETE RESTRICT,
|
||||
appellant_user_id bigint NOT NULL CHECK (appellant_user_id > 0),
|
||||
appeal_text text NOT NULL CHECK (char_length(appeal_text) BETWEEN 1 AND 4000),
|
||||
text_hash bytea NOT NULL CHECK (octet_length(text_hash) = 32),
|
||||
fingerprint bytea NOT NULL UNIQUE CHECK (octet_length(fingerprint) = 32),
|
||||
status text NOT NULL CHECK (status IN ('pending', 'granted', 'rejected')),
|
||||
previous_case_status text NOT NULL CHECK (
|
||||
previous_case_status IN ('resolved', 'dismissed')
|
||||
),
|
||||
reviewer text NOT NULL DEFAULT '' CHECK (octet_length(reviewer) <= 128),
|
||||
review_reason text NOT NULL DEFAULT '' CHECK (char_length(review_reason) <= 2000),
|
||||
created_at timestamptz NOT NULL,
|
||||
reviewed_at timestamptz
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX moderation_appeals_one_pending_case_actor_idx
|
||||
ON public.moderation_appeals (case_id, appellant_user_id)
|
||||
WHERE status = 'pending';
|
||||
|
||||
CREATE INDEX moderation_appeals_queue_idx
|
||||
ON public.moderation_appeals (status, created_at, id);
|
||||
|
||||
CREATE TABLE public.moderation_appeal_links (
|
||||
id bigserial PRIMARY KEY,
|
||||
case_id bigint NOT NULL REFERENCES public.moderation_cases(id)
|
||||
ON DELETE RESTRICT,
|
||||
appellant_user_id bigint NOT NULL CHECK (appellant_user_id > 0),
|
||||
token_hash bytea NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32),
|
||||
expires_at timestamptz NOT NULL,
|
||||
appeal_id bigint REFERENCES public.moderation_appeals(id)
|
||||
ON DELETE RESTRICT,
|
||||
created_at timestamptz NOT NULL,
|
||||
consumed_at timestamptz,
|
||||
CHECK (expires_at > created_at),
|
||||
CHECK (expires_at <= created_at + interval '90 days'),
|
||||
CHECK (
|
||||
(appeal_id IS NULL AND consumed_at IS NULL)
|
||||
OR (appeal_id IS NOT NULL AND consumed_at IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX moderation_appeal_links_expiry_idx
|
||||
ON public.moderation_appeal_links (expires_at, id)
|
||||
WHERE consumed_at IS NULL;
|
||||
|
||||
CREATE INDEX moderation_appeal_links_case_idx
|
||||
ON public.moderation_appeal_links (case_id, id);
|
||||
|
||||
ALTER TABLE public.moderation_decisions
|
||||
ADD CONSTRAINT moderation_decisions_appeal_fk
|
||||
FOREIGN KEY (appeal_id) REFERENCES public.moderation_appeals(id)
|
||||
ON DELETE RESTRICT;
|
||||
|
||||
CREATE UNIQUE INDEX moderation_decisions_one_per_appeal_idx
|
||||
ON public.moderation_decisions (appeal_id)
|
||||
WHERE appeal_id IS NOT NULL;
|
||||
|
||||
-- Existing unified reports become one open case per target. This backfill is
|
||||
-- deterministic and keeps every report linked exactly once.
|
||||
INSERT INTO public.moderation_cases (
|
||||
target_peer_type, target_peer_id, status, severity, assigned_to,
|
||||
version, report_count, distinct_reporter_count, first_report_at,
|
||||
last_report_at, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
target_peer_type,
|
||||
target_peer_id,
|
||||
'open',
|
||||
max(CASE reason
|
||||
WHEN 'child_abuse' THEN 4
|
||||
WHEN 'violence' THEN 3
|
||||
WHEN 'pornography' THEN 3
|
||||
WHEN 'illegal_drugs' THEN 3
|
||||
WHEN 'personal_details' THEN 3
|
||||
WHEN 'fake' THEN 2
|
||||
WHEN 'copyright' THEN 2
|
||||
ELSE 1
|
||||
END)::smallint,
|
||||
'',
|
||||
1,
|
||||
count(*)::integer,
|
||||
count(DISTINCT reporter_user_id)::integer,
|
||||
min(created_at),
|
||||
max(created_at),
|
||||
min(created_at),
|
||||
max(created_at)
|
||||
FROM public.moderation_reports
|
||||
GROUP BY target_peer_type, target_peer_id;
|
||||
|
||||
INSERT INTO public.moderation_case_reports (case_id, report_id, attached_at)
|
||||
SELECT c.id, r.id, r.created_at
|
||||
FROM public.moderation_reports r
|
||||
JOIN public.moderation_cases c
|
||||
ON c.target_peer_type = r.target_peer_type
|
||||
AND c.target_peer_id = r.target_peer_id
|
||||
AND c.status = 'open';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS public.client_telemetry_events;
|
||||
30
deploy/migrations/20260714003102_client_telemetry.up.sql
Normal file
30
deploy/migrations/20260714003102_client_telemetry.up.sql
Normal 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);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
DROP TABLE IF EXISTS public.channel_antispam_decisions;
|
||||
DROP TABLE IF EXISTS public.sponsored_message_impressions;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
-- Reserved development migration version; up is intentionally a no-op.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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[])
|
||||
);
|
||||
|
|
@ -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[])
|
||||
);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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.';
|
||||
|
|
@ -0,0 +1 @@
|
|||
DROP INDEX CONCURRENTLY IF EXISTS public.channel_messages_live_pinned_idx;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
194
deploy/migrations/20260714003110_collectible_usernames.up.sql
Normal file
194
deploy/migrations/20260714003110_collectible_usernames.up.sql
Normal 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;
|
||||
$$;
|
||||
3
deploy/migrations/20260714003111_account_rating.down.sql
Normal file
3
deploy/migrations/20260714003111_account_rating.down.sql
Normal 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;
|
||||
72
deploy/migrations/20260714003111_account_rating.up.sql
Normal file
72
deploy/migrations/20260714003111_account_rating.up.sql
Normal 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');
|
||||
|
|
@ -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;
|
||||
89
deploy/migrations/20260714003112_verify_service_bot.up.sql
Normal file
89
deploy/migrations/20260714003112_verify_service_bot.up.sql
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
140
deploy/migrations/20260714003114_bot_verification.up.sql
Normal file
140
deploy/migrations/20260714003114_bot_verification.up.sql
Normal 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);
|
||||
|
|
@ -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;
|
||||
103
deploy/migrations/20260714003115_verifier_service_bot.up.sql
Normal file
103
deploy/migrations/20260714003115_verifier_service_bot.up.sql
Normal 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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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]));
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
DROP TABLE IF EXISTS private_no_forwards_requests;
|
||||
DROP TABLE IF EXISTS private_no_forwards_chats;
|
||||
36
deploy/migrations/20260714003118_private_no_forwards.up.sql
Normal file
36
deploy/migrations/20260714003118_private_no_forwards.up.sql
Normal 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;
|
||||
|
|
@ -0,0 +1 @@
|
|||
DROP INDEX IF EXISTS public.peer_usernames_active_search_idx;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
)
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
DROP TABLE IF EXISTS public.stars_gift_purchase_commands;
|
||||
DROP TABLE IF EXISTS public.stars_gift_purchase_forms;
|
||||
|
|
@ -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)
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue