Merge remote-tracking branch 'upstream/main' into merge-gramsrv-0e2fcdf9
This commit is contained in:
commit
b443ff0c73
277 changed files with 30747 additions and 1551 deletions
|
|
@ -0,0 +1,4 @@
|
|||
-- This migration emits durable per-user edit_message events. Reverting the
|
||||
-- repaired ids or rewinding pts would reintroduce cross-account references and
|
||||
-- create holes in updates.getDifference, so rollback intentionally preserves
|
||||
-- both the corrected snapshots and their update facts.
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
-- Private message box ids are account-local. Repair user-owned Star Gift
|
||||
-- service actions that copied the owner's msg_id into both participants'
|
||||
-- message boxes, and publish durable edit_message facts for already-visible
|
||||
-- incorrect projections.
|
||||
|
||||
CREATE TEMP TABLE star_gift_box_media_repairs (
|
||||
owner_user_id bigint NOT NULL,
|
||||
box_id integer NOT NULL,
|
||||
peer_type text NOT NULL,
|
||||
peer_id bigint NOT NULL,
|
||||
repaired_media jsonb NOT NULL,
|
||||
PRIMARY KEY (owner_user_id, box_id)
|
||||
) ON COMMIT DROP;
|
||||
|
||||
-- An upgrade action points back to the original ordinary gift. user saved_id
|
||||
-- is a management identity, not a conversation message link: only the current
|
||||
-- gift owner's box may carry it. The other participant must omit the field.
|
||||
INSERT INTO star_gift_box_media_repairs (
|
||||
owner_user_id, box_id, peer_type, peer_id, repaired_media
|
||||
)
|
||||
SELECT unique_box.owner_user_id,
|
||||
unique_box.box_id,
|
||||
unique_box.peer_type,
|
||||
unique_box.peer_id,
|
||||
CASE
|
||||
WHEN unique_box.owner_user_id = gift.owner_peer_id THEN jsonb_set(
|
||||
unique_box.media,
|
||||
'{service_action,star_gift_unique,saved_id}',
|
||||
to_jsonb(gift.msg_id::bigint),
|
||||
true
|
||||
)
|
||||
ELSE unique_box.media #- '{service_action,star_gift_unique,saved_id}'
|
||||
END
|
||||
FROM peer_star_gifts gift
|
||||
JOIN message_boxes upgrade_owner
|
||||
ON upgrade_owner.owner_user_id = gift.owner_peer_id
|
||||
AND upgrade_owner.box_id = gift.upgrade_msg_id
|
||||
JOIN message_boxes unique_box
|
||||
ON unique_box.message_sender_id = upgrade_owner.message_sender_id
|
||||
AND unique_box.private_message_id = upgrade_owner.private_message_id
|
||||
WHERE gift.owner_peer_type = 'user'
|
||||
AND gift.unique_gift_id IS NOT NULL
|
||||
AND gift.msg_id > 0
|
||||
AND gift.upgrade_msg_id > 0
|
||||
AND NOT unique_box.deleted
|
||||
AND unique_box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND unique_box.media #>> '{service_action,star_gift_unique,upgrade}' = 'true'
|
||||
AND unique_box.media IS DISTINCT FROM CASE
|
||||
WHEN unique_box.owner_user_id = gift.owner_peer_id THEN jsonb_set(
|
||||
unique_box.media,
|
||||
'{service_action,star_gift_unique,saved_id}',
|
||||
to_jsonb(gift.msg_id::bigint),
|
||||
true
|
||||
)
|
||||
ELSE unique_box.media #- '{service_action,star_gift_unique,saved_id}'
|
||||
END
|
||||
ON CONFLICT (owner_user_id, box_id) DO UPDATE
|
||||
SET repaired_media = EXCLUDED.repaired_media;
|
||||
|
||||
-- For every other user-target unique action (transfer, resale, offer accept,
|
||||
-- craft), the action message itself is the new user saved-gift identity.
|
||||
-- saved_id is a channel-only field there and must be absent from every box.
|
||||
INSERT INTO star_gift_box_media_repairs (
|
||||
owner_user_id, box_id, peer_type, peer_id, repaired_media
|
||||
)
|
||||
SELECT box.owner_user_id,
|
||||
box.box_id,
|
||||
box.peer_type,
|
||||
box.peer_id,
|
||||
box.media #- '{service_action,star_gift_unique,saved_id}'
|
||||
FROM message_boxes box
|
||||
WHERE NOT box.deleted
|
||||
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND box.media #>> '{service_action,star_gift_unique,peer,Type}' = 'user'
|
||||
AND COALESCE((box.media #>> '{service_action,star_gift_unique,upgrade}')::boolean, false) = false
|
||||
AND box.media #> '{service_action,star_gift_unique,saved_id}' IS NOT NULL
|
||||
ON CONFLICT (owner_user_id, box_id) DO UPDATE
|
||||
SET repaired_media = EXCLUDED.repaired_media;
|
||||
|
||||
-- A separate prepaid-upgrade action points to the same ordinary gift.
|
||||
-- Telegram defines gift_msg_id as receiver-only, so retain it only in the
|
||||
-- owner's service-message box and remove it from the payer's outgoing copy.
|
||||
INSERT INTO star_gift_box_media_repairs (
|
||||
owner_user_id, box_id, peer_type, peer_id, repaired_media
|
||||
)
|
||||
SELECT prepay_box.owner_user_id,
|
||||
prepay_box.box_id,
|
||||
prepay_box.peer_type,
|
||||
prepay_box.peer_id,
|
||||
CASE
|
||||
WHEN prepay_box.owner_user_id = gift.owner_peer_id THEN jsonb_set(
|
||||
prepay_box.media,
|
||||
'{service_action,star_gift,gift_msg_id}',
|
||||
to_jsonb(gift.msg_id::bigint),
|
||||
true
|
||||
)
|
||||
ELSE prepay_box.media #- '{service_action,star_gift,gift_msg_id}'
|
||||
END
|
||||
FROM peer_star_gifts gift
|
||||
JOIN message_boxes prepay_owner
|
||||
ON prepay_owner.owner_user_id = gift.owner_peer_id
|
||||
AND prepay_owner.media #>> '{service_action,kind}' = 'star_gift'
|
||||
AND prepay_owner.media #>> '{service_action,star_gift,prepaid_upgrade}' = 'true'
|
||||
AND prepay_owner.media #>> '{service_action,star_gift,upgrade_separate}' = 'true'
|
||||
AND (prepay_owner.media #>> '{service_action,star_gift,gift_msg_id}')::integer = gift.msg_id
|
||||
AND (prepay_owner.media #>> '{service_action,star_gift,gift_id}')::bigint = gift.gift_id
|
||||
JOIN message_boxes prepay_box
|
||||
ON prepay_box.message_sender_id = prepay_owner.message_sender_id
|
||||
AND prepay_box.private_message_id = prepay_owner.private_message_id
|
||||
WHERE gift.owner_peer_type = 'user'
|
||||
AND gift.msg_id > 0
|
||||
AND NOT prepay_box.deleted
|
||||
AND prepay_box.media IS DISTINCT FROM CASE
|
||||
WHEN prepay_box.owner_user_id = gift.owner_peer_id THEN jsonb_set(
|
||||
prepay_box.media,
|
||||
'{service_action,star_gift,gift_msg_id}',
|
||||
to_jsonb(gift.msg_id::bigint),
|
||||
true
|
||||
)
|
||||
ELSE prepay_box.media #- '{service_action,star_gift,gift_msg_id}'
|
||||
END
|
||||
ON CONFLICT (owner_user_id, box_id) DO UPDATE
|
||||
SET repaired_media = EXCLUDED.repaired_media;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
repair record;
|
||||
next_pts integer;
|
||||
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
|
||||
BEGIN
|
||||
FOR repair IN
|
||||
SELECT owner_user_id, box_id, peer_type, peer_id, repaired_media
|
||||
FROM star_gift_box_media_repairs
|
||||
ORDER BY owner_user_id, box_id
|
||||
LOOP
|
||||
INSERT INTO user_update_watermarks (user_id, contiguous_pts)
|
||||
VALUES (repair.owner_user_id, 0)
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
UPDATE user_update_watermarks
|
||||
SET contiguous_pts = contiguous_pts + 1,
|
||||
updated_at = now()
|
||||
WHERE user_id = repair.owner_user_id
|
||||
RETURNING contiguous_pts INTO next_pts;
|
||||
|
||||
UPDATE message_boxes
|
||||
SET media = repair.repaired_media,
|
||||
pts = next_pts
|
||||
WHERE owner_user_id = repair.owner_user_id
|
||||
AND box_id = repair.box_id
|
||||
AND NOT deleted;
|
||||
|
||||
INSERT INTO user_update_events (
|
||||
user_id, pts, pts_count, date, event_type,
|
||||
message_box_id, peer_type, peer_id
|
||||
) VALUES (
|
||||
repair.owner_user_id, next_pts, 1, event_date, 'edit_message',
|
||||
repair.box_id, repair.peer_type, repair.peer_id
|
||||
);
|
||||
|
||||
INSERT INTO dispatch_outbox (
|
||||
target_user_id, pts, event_type,
|
||||
exclude_auth_key_id, exclude_session_id
|
||||
) VALUES (repair.owner_user_id, next_pts, 'edit_message', 0, 0);
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- private_messages is the logical shared envelope and cannot contain either
|
||||
-- participant's local message id. User-visible history/difference always reads
|
||||
-- the per-owner message_boxes snapshots repaired above.
|
||||
UPDATE private_messages
|
||||
SET media = media
|
||||
#- '{service_action,star_gift,saved_id}'
|
||||
#- '{service_action,star_gift,gift_msg_id}'
|
||||
#- '{service_action,star_gift,upgrade_msg_id}'
|
||||
WHERE media #>> '{service_action,kind}' = 'star_gift'
|
||||
AND (
|
||||
media #> '{service_action,star_gift,peer_user_id}' IS NOT NULL
|
||||
OR media #>> '{service_action,star_gift,to,Type}' = 'user'
|
||||
);
|
||||
|
||||
UPDATE private_messages
|
||||
SET media = media #- '{service_action,star_gift_unique,saved_id}'
|
||||
WHERE media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND media #>> '{service_action,star_gift_unique,peer,Type}' = 'user';
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
DROP TABLE IF EXISTS public.telegram_login_codes;
|
||||
DROP TABLE IF EXISTS public.web_authorizations;
|
||||
DROP TABLE IF EXISTS public.telegram_login_requests;
|
||||
DROP TABLE IF EXISTS public.bot_login_native_apps;
|
||||
DROP TABLE IF EXISTS public.bot_login_allowed_urls;
|
||||
DROP TABLE IF EXISTS public.bot_login_clients;
|
||||
UPDATE public.bots
|
||||
SET commands = COALESCE((
|
||||
SELECT jsonb_agg(command ORDER BY ordinal)
|
||||
FROM jsonb_array_elements(commands) WITH ORDINALITY AS item(command, ordinal)
|
||||
WHERE command->>'command' NOT IN ('setlogin','logininfo','resetloginsecret')
|
||||
), '[]'::jsonb),
|
||||
updated_at = now()
|
||||
WHERE bot_user_id = 93372553;
|
||||
253
deploy/migrations/20260714003084_telegram_login_oidc.up.sql
Normal file
253
deploy/migrations/20260714003084_telegram_login_oidc.up.sql
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
-- Telegram Login / OIDC is one durable authorization aggregate shared by the
|
||||
-- public HTTP provider and MTProto URL-auth RPCs. PostgreSQL is authoritative;
|
||||
-- Redis/NOTIFY may wake waiters but may not own any transition below.
|
||||
UPDATE public.bots
|
||||
SET commands = commands || '[
|
||||
{"command":"setlogin","description":"configure Telegram Login"},
|
||||
{"command":"logininfo","description":"show Telegram Login configuration"},
|
||||
{"command":"resetloginsecret","description":"rotate an OIDC Client Secret"}
|
||||
]'::jsonb,
|
||||
updated_at = now()
|
||||
WHERE bot_user_id = 93372553;
|
||||
|
||||
CREATE TABLE public.bot_login_clients (
|
||||
bot_user_id bigint PRIMARY KEY REFERENCES public.bots(bot_user_id) ON DELETE CASCADE,
|
||||
client_id text NOT NULL UNIQUE,
|
||||
client_secret_hash bytea NOT NULL,
|
||||
secret_version bigint DEFAULT 1 NOT NULL,
|
||||
signing_algorithm text DEFAULT 'RS256'::text NOT NULL,
|
||||
enabled boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT bot_login_clients_client_id_check
|
||||
CHECK (client_id = bot_user_id::text AND length(client_id) BETWEEN 1 AND 64),
|
||||
CONSTRAINT bot_login_clients_secret_hash_check CHECK (octet_length(client_secret_hash) = 32),
|
||||
CONSTRAINT bot_login_clients_secret_version_check CHECK (secret_version > 0),
|
||||
CONSTRAINT bot_login_clients_signing_algorithm_check
|
||||
CHECK (signing_algorithm IN ('RS256','ES256','EdDSA','ES256K'))
|
||||
);
|
||||
|
||||
CREATE TABLE public.bot_login_allowed_urls (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
bot_user_id bigint NOT NULL REFERENCES public.bot_login_clients(bot_user_id) ON DELETE CASCADE,
|
||||
kind text NOT NULL,
|
||||
normalized_url text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT bot_login_allowed_urls_kind_check CHECK (kind IN ('web_origin','redirect_uri')),
|
||||
CONSTRAINT bot_login_allowed_urls_value_check CHECK (length(normalized_url) BETWEEN 1 AND 4096),
|
||||
UNIQUE (bot_user_id, kind, normalized_url)
|
||||
);
|
||||
|
||||
CREATE INDEX bot_login_allowed_urls_bot_page_idx
|
||||
ON public.bot_login_allowed_urls(bot_user_id, kind, id);
|
||||
|
||||
CREATE TABLE public.bot_login_native_apps (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
bot_user_id bigint NOT NULL REFERENCES public.bot_login_clients(bot_user_id) ON DELETE CASCADE,
|
||||
platform text NOT NULL,
|
||||
application_id text NOT NULL,
|
||||
verification_id text NOT NULL,
|
||||
callback_uri text NOT NULL,
|
||||
verified_display_name text NOT NULL,
|
||||
enabled boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT bot_login_native_apps_platform_check CHECK (platform IN ('ios','android')),
|
||||
CONSTRAINT bot_login_native_apps_app_id_check
|
||||
CHECK (length(application_id) BETWEEN 3 AND 255 AND application_id ~ '^[A-Za-z0-9][A-Za-z0-9._-]*$'),
|
||||
CONSTRAINT bot_login_native_apps_verification_check CHECK (
|
||||
(platform = 'ios' AND verification_id ~ '^[A-Z0-9]{10}$')
|
||||
OR (platform = 'android' AND verification_id ~ '^[0-9A-F]{64}$')
|
||||
),
|
||||
CONSTRAINT bot_login_native_apps_callback_check CHECK (length(callback_uri) BETWEEN 1 AND 4096),
|
||||
CONSTRAINT bot_login_native_apps_name_check CHECK (length(btrim(verified_display_name)) BETWEEN 1 AND 128),
|
||||
UNIQUE (bot_user_id, platform, application_id, verification_id),
|
||||
UNIQUE (bot_user_id, callback_uri)
|
||||
);
|
||||
|
||||
CREATE INDEX bot_login_native_apps_bot_page_idx
|
||||
ON public.bot_login_native_apps(bot_user_id, id);
|
||||
CREATE INDEX bot_login_native_apps_callback_idx
|
||||
ON public.bot_login_native_apps(bot_user_id, callback_uri) WHERE enabled;
|
||||
|
||||
CREATE TABLE public.telegram_login_requests (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
request_token_hash bytea NOT NULL UNIQUE,
|
||||
browser_token_hash bytea NOT NULL UNIQUE,
|
||||
bot_user_id bigint NOT NULL REFERENCES public.bot_login_clients(bot_user_id) ON DELETE CASCADE,
|
||||
client_id text NOT NULL,
|
||||
signing_algorithm text NOT NULL,
|
||||
source text NOT NULL,
|
||||
response_type text NOT NULL,
|
||||
redirect_uri text NOT NULL,
|
||||
origin text DEFAULT ''::text NOT NULL,
|
||||
domain text NOT NULL,
|
||||
requested_scopes text[] NOT NULL,
|
||||
oauth_state text DEFAULT ''::text NOT NULL,
|
||||
nonce text DEFAULT ''::text NOT NULL,
|
||||
code_challenge text NOT NULL,
|
||||
code_challenge_method text NOT NULL,
|
||||
browser text NOT NULL,
|
||||
platform text NOT NULL,
|
||||
ip text NOT NULL,
|
||||
region text NOT NULL,
|
||||
in_app_origin text DEFAULT ''::text NOT NULL,
|
||||
is_app boolean DEFAULT false NOT NULL,
|
||||
verified_app_name text DEFAULT ''::text NOT NULL,
|
||||
match_codes text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
match_code text DEFAULT ''::text NOT NULL,
|
||||
match_codes_first boolean DEFAULT false NOT NULL,
|
||||
user_id_hint bigint DEFAULT 0 NOT NULL,
|
||||
peer_type text DEFAULT ''::text NOT NULL,
|
||||
peer_id bigint DEFAULT 0 NOT NULL,
|
||||
message_id integer DEFAULT 0 NOT NULL,
|
||||
button_id integer DEFAULT 0 NOT NULL,
|
||||
status text DEFAULT 'pending'::text NOT NULL,
|
||||
authorized_user_id bigint REFERENCES public.users(id),
|
||||
profile_name text DEFAULT ''::text NOT NULL,
|
||||
given_name text DEFAULT ''::text NOT NULL,
|
||||
family_name text DEFAULT ''::text NOT NULL,
|
||||
preferred_username text DEFAULT ''::text NOT NULL,
|
||||
picture text DEFAULT ''::text NOT NULL,
|
||||
phone_number text DEFAULT ''::text NOT NULL,
|
||||
write_allowed boolean DEFAULT false NOT NULL,
|
||||
phone_shared boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
expires_at timestamp with time zone NOT NULL,
|
||||
approved_at timestamp with time zone,
|
||||
declined_at timestamp with time zone,
|
||||
CONSTRAINT telegram_login_requests_hashes_check
|
||||
CHECK (octet_length(request_token_hash) = 32 AND octet_length(browser_token_hash) = 32),
|
||||
CONSTRAINT telegram_login_requests_signing_algorithm_check
|
||||
CHECK (signing_algorithm IN ('RS256','ES256','EdDSA','ES256K')),
|
||||
CONSTRAINT telegram_login_requests_source_check
|
||||
CHECK (source IN ('web','javascript','native','mini_app','message_button')),
|
||||
CONSTRAINT telegram_login_requests_response_type_check CHECK (response_type IN ('code','post_message','legacy_url')),
|
||||
CONSTRAINT telegram_login_requests_source_response_check CHECK (
|
||||
(source = 'web' AND response_type = 'code')
|
||||
OR (source = 'javascript' AND response_type = 'post_message')
|
||||
OR (source = 'native' AND response_type = 'code')
|
||||
OR (source = 'mini_app' AND response_type = 'post_message')
|
||||
OR (source = 'message_button' AND response_type = 'legacy_url')
|
||||
),
|
||||
CONSTRAINT telegram_login_requests_url_check
|
||||
CHECK (length(redirect_uri) BETWEEN 1 AND 4096 AND length(origin) <= 4096
|
||||
AND length(domain) BETWEEN 1 AND 255 AND length(in_app_origin) <= 4096),
|
||||
CONSTRAINT telegram_login_requests_scope_check
|
||||
CHECK (cardinality(requested_scopes) BETWEEN 1 AND 4 AND requested_scopes @> ARRAY['openid']::text[]),
|
||||
CONSTRAINT telegram_login_requests_oauth_value_check
|
||||
CHECK (length(oauth_state) <= 2048 AND length(nonce) <= 1024),
|
||||
CONSTRAINT telegram_login_requests_pkce_check CHECK (
|
||||
(response_type = 'code' AND code_challenge_method = 'S256' AND length(code_challenge) BETWEEN 43 AND 128)
|
||||
OR (response_type = 'post_message' AND (
|
||||
(code_challenge = '' AND code_challenge_method = '')
|
||||
OR (code_challenge_method = 'S256' AND length(code_challenge) BETWEEN 43 AND 128)))
|
||||
OR (response_type = 'legacy_url' AND code_challenge = '' AND code_challenge_method = '')
|
||||
),
|
||||
CONSTRAINT telegram_login_requests_device_check
|
||||
CHECK (length(browser) BETWEEN 1 AND 255 AND length(platform) BETWEEN 1 AND 255 AND length(ip) BETWEEN 1 AND 128 AND length(region) BETWEEN 1 AND 255),
|
||||
CONSTRAINT telegram_login_requests_match_codes_check
|
||||
CHECK (cardinality(match_codes) <= 8
|
||||
AND (cardinality(match_codes) = 0 OR (match_code <> '' AND match_code = ANY(match_codes)))
|
||||
AND (NOT match_codes_first OR cardinality(match_codes) > 0)),
|
||||
CONSTRAINT telegram_login_requests_context_check
|
||||
CHECK (user_id_hint >= 0 AND peer_id >= 0 AND message_id >= 0 AND button_id >= 0),
|
||||
CONSTRAINT telegram_login_requests_app_shape_check CHECK (
|
||||
(source = 'native' AND is_app AND verified_app_name <> '' AND origin = '')
|
||||
OR (source <> 'native' AND NOT is_app AND verified_app_name = '' AND origin <> '')
|
||||
),
|
||||
CONSTRAINT telegram_login_requests_in_app_shape_check CHECK (
|
||||
(source = 'mini_app' AND response_type = 'post_message'
|
||||
AND in_app_origin <> '' AND origin = in_app_origin)
|
||||
OR (source <> 'mini_app' AND in_app_origin = '')
|
||||
),
|
||||
CONSTRAINT telegram_login_requests_consent_scope_check CHECK (
|
||||
(NOT write_allowed OR 'telegram:bot_access' = ANY(requested_scopes))
|
||||
AND (NOT phone_shared OR 'phone' = ANY(requested_scopes))
|
||||
AND ((phone_shared AND phone_number <> '') OR (NOT phone_shared AND phone_number = ''))
|
||||
),
|
||||
CONSTRAINT telegram_login_requests_status_check CHECK (status IN ('pending','approved','declined','expired')),
|
||||
CONSTRAINT telegram_login_requests_claims_check CHECK (
|
||||
length(profile_name) <= 255 AND length(given_name) <= 255 AND length(family_name) <= 255
|
||||
AND length(preferred_username) <= 64 AND length(picture) <= 4096 AND length(phone_number) <= 32
|
||||
),
|
||||
CONSTRAINT telegram_login_requests_time_check CHECK (expires_at > created_at),
|
||||
CONSTRAINT telegram_login_requests_terminal_shape_check CHECK (
|
||||
(status = 'pending' AND authorized_user_id IS NULL AND profile_name = '' AND given_name = ''
|
||||
AND family_name = '' AND preferred_username = '' AND picture = '' AND phone_number = ''
|
||||
AND NOT write_allowed AND NOT phone_shared AND approved_at IS NULL AND declined_at IS NULL)
|
||||
OR (status = 'approved' AND authorized_user_id IS NOT NULL AND approved_at IS NOT NULL AND declined_at IS NULL
|
||||
AND (('profile' = ANY(requested_scopes) AND profile_name <> '' AND given_name <> '')
|
||||
OR (NOT ('profile' = ANY(requested_scopes)) AND profile_name = '' AND given_name = ''
|
||||
AND family_name = '' AND preferred_username = '' AND picture = '')))
|
||||
OR (status = 'declined' AND authorized_user_id IS NULL AND profile_name = '' AND given_name = ''
|
||||
AND family_name = '' AND preferred_username = '' AND picture = '' AND phone_number = ''
|
||||
AND NOT write_allowed AND NOT phone_shared AND approved_at IS NULL AND declined_at IS NOT NULL)
|
||||
OR (status = 'expired' AND authorized_user_id IS NULL AND profile_name = '' AND given_name = ''
|
||||
AND family_name = '' AND preferred_username = '' AND picture = '' AND phone_number = ''
|
||||
AND NOT write_allowed AND NOT phone_shared AND approved_at IS NULL AND declined_at IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX telegram_login_requests_expiry_idx
|
||||
ON public.telegram_login_requests(expires_at, id) WHERE status = 'pending';
|
||||
CREATE INDEX telegram_login_requests_user_active_idx
|
||||
ON public.telegram_login_requests(authorized_user_id, approved_at DESC, id DESC)
|
||||
WHERE status = 'approved';
|
||||
|
||||
CREATE TABLE public.web_authorizations (
|
||||
hash bigint PRIMARY KEY,
|
||||
request_id bigint NOT NULL UNIQUE REFERENCES public.telegram_login_requests(id) ON DELETE CASCADE,
|
||||
user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
bot_user_id bigint NOT NULL REFERENCES public.bots(bot_user_id) ON DELETE CASCADE,
|
||||
domain text NOT NULL,
|
||||
browser text NOT NULL,
|
||||
platform text NOT NULL,
|
||||
ip text NOT NULL,
|
||||
region text NOT NULL,
|
||||
granted_scopes text[] NOT NULL,
|
||||
phone_shared boolean DEFAULT false NOT NULL,
|
||||
bot_access_granted boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
last_active_at timestamp with time zone NOT NULL,
|
||||
revoked_at timestamp with time zone,
|
||||
CONSTRAINT web_authorizations_hash_check CHECK (hash <> 0),
|
||||
CONSTRAINT web_authorizations_identity_check CHECK (user_id > 0 AND bot_user_id > 0),
|
||||
CONSTRAINT web_authorizations_text_check
|
||||
CHECK (length(domain) BETWEEN 1 AND 255 AND length(browser) BETWEEN 1 AND 255
|
||||
AND length(platform) BETWEEN 1 AND 255 AND length(ip) BETWEEN 1 AND 128
|
||||
AND length(region) BETWEEN 1 AND 255),
|
||||
CONSTRAINT web_authorizations_scope_check
|
||||
CHECK (cardinality(granted_scopes) BETWEEN 1 AND 4 AND granted_scopes @> ARRAY['openid']::text[]
|
||||
AND (NOT phone_shared OR 'phone' = ANY(granted_scopes))
|
||||
AND (NOT bot_access_granted OR 'telegram:bot_access' = ANY(granted_scopes))),
|
||||
CONSTRAINT web_authorizations_time_check
|
||||
CHECK (last_active_at >= created_at AND (revoked_at IS NULL OR revoked_at >= created_at))
|
||||
);
|
||||
|
||||
CREATE INDEX web_authorizations_user_active_page_idx
|
||||
ON public.web_authorizations(user_id, last_active_at DESC, hash DESC)
|
||||
WHERE revoked_at IS NULL;
|
||||
CREATE INDEX web_authorizations_bot_active_idx
|
||||
ON public.web_authorizations(bot_user_id, user_id, hash)
|
||||
WHERE revoked_at IS NULL;
|
||||
|
||||
CREATE TABLE public.telegram_login_codes (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
request_id bigint NOT NULL UNIQUE REFERENCES public.telegram_login_requests(id) ON DELETE CASCADE,
|
||||
code_hash bytea NOT NULL UNIQUE,
|
||||
sealed_code bytea NOT NULL,
|
||||
seal_nonce bytea NOT NULL,
|
||||
seal_key_id text NOT NULL,
|
||||
issued_at timestamp with time zone NOT NULL,
|
||||
expires_at timestamp with time zone NOT NULL,
|
||||
consumed_at timestamp with time zone,
|
||||
CONSTRAINT telegram_login_codes_hash_check CHECK (octet_length(code_hash) = 32),
|
||||
CONSTRAINT telegram_login_codes_sealed_check
|
||||
CHECK (octet_length(sealed_code) >= 32 AND octet_length(seal_nonce) >= 12 AND length(seal_key_id) BETWEEN 1 AND 128),
|
||||
CONSTRAINT telegram_login_codes_time_check
|
||||
CHECK (expires_at > issued_at AND (consumed_at IS NULL OR (consumed_at >= issued_at AND consumed_at < expires_at)))
|
||||
);
|
||||
|
||||
CREATE INDEX telegram_login_codes_expiry_idx
|
||||
ON public.telegram_login_codes(expires_at, id) WHERE consumed_at IS NULL;
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
-- Durable edit events and repaired user gift projections are intentionally not
|
||||
-- rewound. Dropping the new lookup/index constraints is sufficient rollback.
|
||||
ALTER TABLE peer_star_gifts
|
||||
DROP CONSTRAINT IF EXISTS peer_star_gifts_hidden_unpinned_check;
|
||||
|
||||
DROP TABLE IF EXISTS star_gift_user_message_refs;
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
-- Official clients may continue lifecycle actions from a freshly emitted
|
||||
-- messageActionStarGiftUnique. Keep those user-local message ids as explicit
|
||||
-- durable references to the same saved gift aggregate.
|
||||
CREATE TABLE star_gift_user_message_refs (
|
||||
owner_user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
msg_id integer NOT NULL,
|
||||
saved_gift_id bigint NOT NULL REFERENCES peer_star_gifts(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (owner_user_id, msg_id),
|
||||
CONSTRAINT star_gift_user_message_refs_msg_check CHECK (owner_user_id > 0 AND msg_id > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX star_gift_user_message_refs_saved_idx
|
||||
ON star_gift_user_message_refs(saved_gift_id, owner_user_id, msg_id);
|
||||
|
||||
INSERT INTO star_gift_user_message_refs(owner_user_id,msg_id,saved_gift_id)
|
||||
SELECT box.owner_user_id, box.box_id, gift.id
|
||||
FROM message_boxes box
|
||||
JOIN unique_star_gifts unique_gift
|
||||
ON (box.media #>> '{service_action,star_gift_unique,gift,ID}') ~ '^[0-9]+$'
|
||||
AND unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint
|
||||
JOIN peer_star_gifts gift
|
||||
ON gift.id = unique_gift.source_saved_gift_id
|
||||
AND gift.owner_peer_type = 'user'
|
||||
AND gift.owner_peer_id = box.owner_user_id
|
||||
WHERE NOT box.deleted
|
||||
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND box.box_id <> gift.msg_id;
|
||||
|
||||
-- Hidden gifts cannot remain pinned. Compacting the whole owner vector here
|
||||
-- also repairs historical gaps before the invariant is constrained.
|
||||
ALTER TABLE peer_star_gifts
|
||||
ADD CONSTRAINT peer_star_gifts_hidden_unpinned_check
|
||||
CHECK (pinned_order<=6 AND (NOT unsaved OR pinned_order=0)) NOT VALID;
|
||||
|
||||
CREATE TEMP TABLE star_gift_pin_repairs ON COMMIT DROP AS
|
||||
SELECT id,new_order
|
||||
FROM (
|
||||
SELECT id,
|
||||
row_number() OVER (PARTITION BY owner_peer_type,owner_peer_id
|
||||
ORDER BY pinned_order,id)::integer AS new_order
|
||||
FROM peer_star_gifts
|
||||
WHERE lifecycle_status='active' AND NOT unsaved AND pinned_order>0
|
||||
) ranked
|
||||
WHERE new_order<=6;
|
||||
|
||||
UPDATE peer_star_gifts SET pinned_order=0 WHERE pinned_order<>0;
|
||||
|
||||
UPDATE peer_star_gifts gift
|
||||
SET pinned_order=repair.new_order
|
||||
FROM star_gift_pin_repairs repair
|
||||
WHERE gift.id=repair.id;
|
||||
|
||||
-- peer and saved_id share one TL flag and are channel-only. Earlier user gift
|
||||
-- projections set peer=user (and sometimes a user box id in saved_id), which
|
||||
-- made TDesktop select zero/stale ids instead of the emitted service message.
|
||||
CREATE TEMP TABLE star_gift_user_unique_media_repairs (
|
||||
owner_user_id bigint NOT NULL,
|
||||
box_id integer NOT NULL,
|
||||
peer_type text NOT NULL,
|
||||
peer_id bigint NOT NULL,
|
||||
repaired_media jsonb NOT NULL,
|
||||
PRIMARY KEY(owner_user_id,box_id)
|
||||
) ON COMMIT DROP;
|
||||
|
||||
INSERT INTO star_gift_user_unique_media_repairs(owner_user_id,box_id,peer_type,peer_id,repaired_media)
|
||||
SELECT box.owner_user_id,
|
||||
box.box_id,
|
||||
box.peer_type,
|
||||
box.peer_id,
|
||||
jsonb_set(
|
||||
box.media #- '{service_action,star_gift_unique,saved_id}',
|
||||
'{service_action,star_gift_unique,peer}',
|
||||
'{"ID":0,"Type":""}'::jsonb,
|
||||
true
|
||||
)
|
||||
FROM message_boxes box
|
||||
WHERE NOT box.deleted
|
||||
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND box.media #>> '{service_action,star_gift_unique,peer,Type}' = 'user';
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
repair record;
|
||||
next_pts integer;
|
||||
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
|
||||
BEGIN
|
||||
FOR repair IN
|
||||
SELECT owner_user_id,box_id,peer_type,peer_id,repaired_media
|
||||
FROM star_gift_user_unique_media_repairs
|
||||
ORDER BY owner_user_id,box_id
|
||||
LOOP
|
||||
INSERT INTO user_update_watermarks(user_id,contiguous_pts)
|
||||
VALUES(repair.owner_user_id,0)
|
||||
ON CONFLICT(user_id) DO NOTHING;
|
||||
|
||||
UPDATE user_update_watermarks
|
||||
SET contiguous_pts=contiguous_pts+1,updated_at=now()
|
||||
WHERE user_id=repair.owner_user_id
|
||||
RETURNING contiguous_pts INTO next_pts;
|
||||
|
||||
UPDATE message_boxes
|
||||
SET media=repair.repaired_media,pts=next_pts
|
||||
WHERE owner_user_id=repair.owner_user_id AND box_id=repair.box_id AND NOT deleted;
|
||||
|
||||
INSERT INTO user_update_events(
|
||||
user_id,pts,pts_count,date,event_type,message_box_id,peer_type,peer_id
|
||||
) VALUES(
|
||||
repair.owner_user_id,next_pts,1,event_date,'edit_message',repair.box_id,repair.peer_type,repair.peer_id
|
||||
);
|
||||
|
||||
INSERT INTO dispatch_outbox(
|
||||
target_user_id,pts,event_type,exclude_auth_key_id,exclude_session_id
|
||||
) VALUES(repair.owner_user_id,next_pts,'edit_message',0,0);
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
UPDATE private_messages
|
||||
SET media=jsonb_set(
|
||||
media #- '{service_action,star_gift_unique,saved_id}',
|
||||
'{service_action,star_gift_unique,peer}',
|
||||
'{"ID":0,"Type":""}'::jsonb,
|
||||
true
|
||||
)
|
||||
WHERE media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND media #>> '{service_action,star_gift_unique,peer,Type}' = 'user';
|
||||
|
|
@ -0,0 +1 @@
|
|||
-- Validation changes no data and the constraint belongs to migration 0126.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
-- 0126 repaired historical rows and installed the constraint as NOT VALID so
|
||||
-- it could coexist with the deferrable unique-gift owner trigger in one
|
||||
-- migration transaction. Validate after that transaction has committed.
|
||||
ALTER TABLE peer_star_gifts
|
||||
VALIDATE CONSTRAINT peer_star_gifts_hidden_unpinned_check;
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
-- Aggregate/message repairs and emitted edit events are authoritative business
|
||||
-- history and are intentionally not reversed. Restore only the pre-0128
|
||||
-- deferred owner guard shape.
|
||||
CREATE OR REPLACE FUNCTION public.telesrv_check_unique_star_gift_owner() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
unique_id bigint;
|
||||
gift_owner_type text;
|
||||
gift_owner_id bigint;
|
||||
gift_owner_address text;
|
||||
gift_burned boolean;
|
||||
saved_status text;
|
||||
saved_owner_type text;
|
||||
saved_owner_id bigint;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'unique_star_gifts' THEN
|
||||
unique_id := COALESCE(NEW.id, OLD.id);
|
||||
ELSE
|
||||
unique_id := COALESCE(NEW.unique_gift_id, OLD.unique_gift_id);
|
||||
END IF;
|
||||
IF unique_id IS NULL THEN RETURN NULL; END IF;
|
||||
SELECT owner_peer_type, owner_peer_id, owner_address, burned
|
||||
INTO gift_owner_type, gift_owner_id, gift_owner_address, gift_burned
|
||||
FROM public.unique_star_gifts WHERE id=unique_id;
|
||||
IF NOT FOUND THEN RETURN NULL; END IF;
|
||||
SELECT lifecycle_status, owner_peer_type, owner_peer_id
|
||||
INTO saved_status, saved_owner_type, saved_owner_id
|
||||
FROM public.peer_star_gifts WHERE unique_gift_id=unique_id;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'unique star gift missing saved aggregate'; END IF;
|
||||
IF gift_burned THEN
|
||||
IF saved_status <> 'burned' THEN RAISE EXCEPTION 'burned unique star gift has live saved aggregate'; END IF;
|
||||
ELSIF gift_owner_address <> '' THEN
|
||||
IF saved_status <> 'exported' THEN RAISE EXCEPTION 'exported unique star gift has non-exported saved aggregate'; END IF;
|
||||
ELSIF saved_status <> 'active' OR gift_owner_type IS DISTINCT FROM saved_owner_type OR gift_owner_id IS DISTINCT FROM saved_owner_id THEN
|
||||
RAISE EXCEPTION 'unique star gift owner mismatch';
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
|
@ -0,0 +1,498 @@
|
|||
-- Official Android clients use a positive can_craft_at both as the Craft
|
||||
-- capability marker and as the readiness boundary. Earlier zero-delay
|
||||
-- upgrades persisted 0 while retaining a positive craft chance, so TDesktop
|
||||
-- could Craft the gift but Android hid the entry entirely.
|
||||
--
|
||||
-- Block concurrent lifecycle/message writers while aggregate facts, message
|
||||
-- snapshots and durable edit edges are repaired in this migration transaction.
|
||||
LOCK TABLE public.peer_star_gifts, public.unique_star_gifts,
|
||||
public.message_boxes, public.private_messages IN SHARE ROW EXCLUSIVE MODE;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Craft capability remains an intrinsic collectible fact while ownership
|
||||
-- moves between users and channels. Terminal/external states cannot Craft.
|
||||
UPDATE public.unique_star_gifts unique_gift
|
||||
SET craft_chance_permille = 0,
|
||||
updated_at = now()
|
||||
FROM public.peer_star_gifts saved_gift
|
||||
WHERE saved_gift.unique_gift_id = unique_gift.id
|
||||
AND unique_gift.craft_chance_permille > 0
|
||||
AND (
|
||||
saved_gift.lifecycle_status <> 'active'
|
||||
OR unique_gift.owner_address <> ''
|
||||
OR unique_gift.burned
|
||||
OR unique_gift.crafted
|
||||
);
|
||||
|
||||
UPDATE public.peer_star_gifts saved_gift
|
||||
SET can_craft_at = 0
|
||||
FROM public.unique_star_gifts unique_gift
|
||||
WHERE unique_gift.id = saved_gift.unique_gift_id
|
||||
AND unique_gift.craft_chance_permille = 0
|
||||
AND saved_gift.can_craft_at <> 0;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.unique_star_gifts unique_gift
|
||||
JOIN public.peer_star_gifts saved_gift
|
||||
ON saved_gift.unique_gift_id = unique_gift.id
|
||||
WHERE unique_gift.craft_chance_permille > 0
|
||||
AND (
|
||||
saved_gift.owner_peer_type NOT IN ('user', 'channel')
|
||||
OR saved_gift.lifecycle_status <> 'active'
|
||||
OR unique_gift.owner_address <> ''
|
||||
OR unique_gift.burned
|
||||
OR unique_gift.crafted
|
||||
OR NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.star_gift_collectible_models model
|
||||
WHERE model.collectible_revision_id = unique_gift.collectible_revision_id
|
||||
AND model.crafted
|
||||
)
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'positive star gift craft chance has no valid owned aggregate';
|
||||
END IF;
|
||||
|
||||
-- created_at is the stable persisted proxy for the original upgrade
|
||||
-- transaction date on legacy rows. New writes use the exact request date.
|
||||
UPDATE public.peer_star_gifts saved_gift
|
||||
SET can_craft_at = GREATEST(
|
||||
1,
|
||||
LEAST(2147483647, FLOOR(EXTRACT(EPOCH FROM unique_gift.created_at))::bigint)::integer
|
||||
)
|
||||
FROM public.unique_star_gifts unique_gift
|
||||
WHERE unique_gift.id = saved_gift.unique_gift_id
|
||||
AND unique_gift.craft_chance_permille > 0
|
||||
AND saved_gift.can_craft_at = 0;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.peer_star_gifts saved_gift
|
||||
JOIN public.unique_star_gifts unique_gift
|
||||
ON unique_gift.id = saved_gift.unique_gift_id
|
||||
WHERE (unique_gift.craft_chance_permille > 0)
|
||||
IS DISTINCT FROM (saved_gift.can_craft_at > 0)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'star gift craft chance/readiness repair did not converge';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TEMP TABLE star_gift_craft_message_repairs (
|
||||
owner_user_id bigint NOT NULL,
|
||||
box_id integer NOT NULL,
|
||||
unique_gift_id bigint NOT NULL,
|
||||
desired_craft_chance integer NOT NULL,
|
||||
desired_can_craft_at integer NOT NULL,
|
||||
PRIMARY KEY (owner_user_id, box_id)
|
||||
) ON COMMIT DROP;
|
||||
|
||||
-- Adding capability is owner-scoped: repair only the current owner's
|
||||
-- authoritative unique action (upgrade_msg_id) and the other visible box of
|
||||
-- that same logical private message. Never add Craft back to an old owner's
|
||||
-- historical transfer/resale action.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.peer_star_gifts saved_gift
|
||||
JOIN public.unique_star_gifts unique_gift
|
||||
ON unique_gift.id = saved_gift.unique_gift_id
|
||||
WHERE saved_gift.owner_peer_type = 'user'
|
||||
AND saved_gift.lifecycle_status = 'active'
|
||||
AND saved_gift.can_craft_at > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.message_boxes owner_box
|
||||
WHERE owner_box.owner_user_id = saved_gift.owner_peer_id
|
||||
AND owner_box.box_id = saved_gift.upgrade_msg_id
|
||||
AND NOT owner_box.deleted
|
||||
AND owner_box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND owner_box.media #>> '{service_action,star_gift_unique,gift,ID}' = unique_gift.id::text
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'craftable star gift is missing its current owner action';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.message_boxes box
|
||||
WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND box.media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
|
||||
AND (
|
||||
jsonb_typeof(box.media #> '{service_action,star_gift_unique,can_craft_at}') <> 'number'
|
||||
OR COALESCE(box.media #>> '{service_action,star_gift_unique,can_craft_at}', '') !~ '^[0-9]+$'
|
||||
OR (box.media #>> '{service_action,star_gift_unique,can_craft_at}')::numeric > 2147483647
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'star gift message has malformed can_craft_at';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.message_boxes box
|
||||
WHERE box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL
|
||||
AND (
|
||||
jsonb_typeof(box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}') <> 'number'
|
||||
OR COALESCE(box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}', '') !~ '^[0-9]+$'
|
||||
OR (box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}')::numeric > 1000
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'star gift message has malformed craft chance';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
INSERT INTO star_gift_craft_message_repairs(
|
||||
owner_user_id, box_id, unique_gift_id,
|
||||
desired_craft_chance, desired_can_craft_at
|
||||
)
|
||||
SELECT visible_box.owner_user_id,
|
||||
visible_box.box_id,
|
||||
unique_gift.id,
|
||||
unique_gift.craft_chance_permille,
|
||||
saved_gift.can_craft_at
|
||||
FROM public.peer_star_gifts saved_gift
|
||||
JOIN public.unique_star_gifts unique_gift
|
||||
ON unique_gift.id = saved_gift.unique_gift_id
|
||||
JOIN public.message_boxes owner_box
|
||||
ON owner_box.owner_user_id = saved_gift.owner_peer_id
|
||||
AND owner_box.box_id = saved_gift.upgrade_msg_id
|
||||
AND NOT owner_box.deleted
|
||||
AND owner_box.media #>> '{service_action,star_gift_unique,gift,ID}' = unique_gift.id::text
|
||||
JOIN public.message_boxes visible_box
|
||||
ON visible_box.message_sender_id = owner_box.message_sender_id
|
||||
AND visible_box.private_message_id = owner_box.private_message_id
|
||||
AND NOT visible_box.deleted
|
||||
WHERE saved_gift.owner_peer_type = 'user'
|
||||
AND saved_gift.lifecycle_status = 'active'
|
||||
AND saved_gift.can_craft_at > 0
|
||||
AND (
|
||||
COALESCE(NULLIF(visible_box.media #>> '{service_action,star_gift_unique,can_craft_at}', '')::integer, 0)
|
||||
IS DISTINCT FROM saved_gift.can_craft_at
|
||||
OR COALESCE(NULLIF(visible_box.media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}', '')::integer, 0)
|
||||
IS DISTINCT FROM unique_gift.craft_chance_permille
|
||||
);
|
||||
|
||||
-- Only the current owner's authoritative logical message may expose Craft.
|
||||
-- Terminal gifts, channel-owned gifts (until channel Craft is implemented),
|
||||
-- and old-owner historical actions must have both wire markers removed.
|
||||
INSERT INTO star_gift_craft_message_repairs(
|
||||
owner_user_id, box_id, unique_gift_id,
|
||||
desired_craft_chance, desired_can_craft_at
|
||||
)
|
||||
SELECT box.owner_user_id, box.box_id, unique_gift.id, 0, 0
|
||||
FROM public.message_boxes box
|
||||
JOIN public.unique_star_gifts unique_gift
|
||||
ON (box.media #>> '{service_action,star_gift_unique,gift,ID}') ~ '^[0-9]+$'
|
||||
AND unique_gift.id = (box.media #>> '{service_action,star_gift_unique,gift,ID}')::bigint
|
||||
JOIN public.peer_star_gifts saved_gift
|
||||
ON saved_gift.unique_gift_id = unique_gift.id
|
||||
WHERE NOT box.deleted
|
||||
AND box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND (
|
||||
box.media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
|
||||
OR box.media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.message_boxes authority
|
||||
WHERE saved_gift.owner_peer_type = 'user'
|
||||
AND saved_gift.lifecycle_status = 'active'
|
||||
AND saved_gift.can_craft_at > 0
|
||||
AND unique_gift.craft_chance_permille > 0
|
||||
AND authority.owner_user_id = saved_gift.owner_peer_id
|
||||
AND authority.box_id = saved_gift.upgrade_msg_id
|
||||
AND NOT authority.deleted
|
||||
AND authority.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND authority.media #>> '{service_action,star_gift_unique,gift,ID}' = unique_gift.id::text
|
||||
AND authority.message_sender_id = box.message_sender_id
|
||||
AND authority.private_message_id = box.private_message_id
|
||||
)
|
||||
ON CONFLICT (owner_user_id, box_id) DO UPDATE
|
||||
SET unique_gift_id = EXCLUDED.unique_gift_id,
|
||||
desired_craft_chance = 0,
|
||||
desired_can_craft_at = 0;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_craft_message_repairs target
|
||||
JOIN public.message_boxes box
|
||||
ON box.owner_user_id = target.owner_user_id
|
||||
AND box.box_id = target.box_id
|
||||
WHERE box.deleted
|
||||
OR box.media #>> '{service_action,kind}' <> 'star_gift_unique'
|
||||
OR box.media #>> '{service_action,star_gift_unique,gift,ID}' <> target.unique_gift_id::text
|
||||
) THEN
|
||||
RAISE EXCEPTION 'craft readiness repair target is not the expected unique gift action';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_craft_message_repairs target
|
||||
JOIN public.message_boxes box
|
||||
ON box.owner_user_id = target.owner_user_id
|
||||
AND box.box_id = target.box_id
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.private_messages private_message
|
||||
WHERE private_message.sender_user_id = box.message_sender_id
|
||||
AND private_message.id = box.private_message_id
|
||||
AND private_message.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND private_message.media #>> '{service_action,star_gift_unique,gift,ID}' = target.unique_gift_id::text
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'craft readiness repair target has no matching private message';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_craft_message_repairs target
|
||||
JOIN public.message_boxes box
|
||||
ON box.owner_user_id = target.owner_user_id
|
||||
AND box.box_id = target.box_id
|
||||
GROUP BY box.message_sender_id, box.private_message_id
|
||||
HAVING COUNT(DISTINCT (
|
||||
target.unique_gift_id,
|
||||
target.desired_craft_chance,
|
||||
target.desired_can_craft_at
|
||||
)) <> 1
|
||||
) THEN
|
||||
RAISE EXCEPTION 'craft readiness repair has conflicting logical message targets';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
repair record;
|
||||
next_pts integer;
|
||||
event_date integer := LEAST(2147483647, EXTRACT(EPOCH FROM clock_timestamp())::bigint)::integer;
|
||||
repaired_media jsonb;
|
||||
repaired_private_media jsonb;
|
||||
affected_rows bigint;
|
||||
BEGIN
|
||||
FOR repair IN
|
||||
SELECT target.owner_user_id,
|
||||
target.box_id,
|
||||
target.unique_gift_id,
|
||||
target.desired_craft_chance,
|
||||
target.desired_can_craft_at,
|
||||
box.peer_type,
|
||||
box.peer_id,
|
||||
box.message_sender_id,
|
||||
box.private_message_id,
|
||||
box.media
|
||||
FROM star_gift_craft_message_repairs target
|
||||
JOIN public.message_boxes box
|
||||
ON box.owner_user_id = target.owner_user_id
|
||||
AND box.box_id = target.box_id
|
||||
AND NOT box.deleted
|
||||
ORDER BY target.owner_user_id, target.box_id
|
||||
FOR UPDATE OF box
|
||||
LOOP
|
||||
IF repair.desired_craft_chance > 0 THEN
|
||||
repaired_media := jsonb_set(
|
||||
jsonb_set(
|
||||
repair.media,
|
||||
'{service_action,star_gift_unique,gift,CraftChancePermille}',
|
||||
to_jsonb(repair.desired_craft_chance),
|
||||
true
|
||||
),
|
||||
'{service_action,star_gift_unique,can_craft_at}',
|
||||
to_jsonb(repair.desired_can_craft_at),
|
||||
true
|
||||
);
|
||||
ELSE
|
||||
repaired_media := repair.media
|
||||
#- '{service_action,star_gift_unique,can_craft_at}'
|
||||
#- '{service_action,star_gift_unique,gift,CraftChancePermille}';
|
||||
END IF;
|
||||
|
||||
IF repaired_media #>> '{service_action,kind}' <> 'star_gift_unique'
|
||||
OR repaired_media #>> '{service_action,star_gift_unique,gift,ID}' <> repair.unique_gift_id::text
|
||||
OR (
|
||||
repair.desired_craft_chance > 0
|
||||
AND (
|
||||
repaired_media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}'
|
||||
IS DISTINCT FROM repair.desired_craft_chance::text
|
||||
OR repaired_media #>> '{service_action,star_gift_unique,can_craft_at}'
|
||||
IS DISTINCT FROM repair.desired_can_craft_at::text
|
||||
)
|
||||
)
|
||||
OR (
|
||||
repair.desired_craft_chance = 0
|
||||
AND (
|
||||
repaired_media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL
|
||||
OR repaired_media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'craft readiness repair cannot project message box for user %, box %',
|
||||
repair.owner_user_id, repair.box_id;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.user_update_watermarks (user_id, contiguous_pts)
|
||||
VALUES (repair.owner_user_id, 0)
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
UPDATE public.user_update_watermarks
|
||||
SET contiguous_pts = contiguous_pts + 1,
|
||||
updated_at = now()
|
||||
WHERE user_id = repair.owner_user_id
|
||||
RETURNING contiguous_pts INTO next_pts;
|
||||
|
||||
UPDATE public.message_boxes
|
||||
SET media = repaired_media,
|
||||
pts = next_pts
|
||||
WHERE owner_user_id = repair.owner_user_id
|
||||
AND box_id = repair.box_id
|
||||
AND NOT deleted;
|
||||
GET DIAGNOSTICS affected_rows = ROW_COUNT;
|
||||
IF affected_rows <> 1 THEN
|
||||
RAISE EXCEPTION 'craft readiness repair lost user %, box %', repair.owner_user_id, repair.box_id;
|
||||
END IF;
|
||||
|
||||
SELECT media
|
||||
INTO repaired_private_media
|
||||
FROM public.private_messages
|
||||
WHERE sender_user_id = repair.message_sender_id
|
||||
AND id = repair.private_message_id
|
||||
FOR UPDATE;
|
||||
IF NOT FOUND THEN
|
||||
RAISE EXCEPTION 'craft readiness repair missing private message for user %, box %',
|
||||
repair.owner_user_id, repair.box_id;
|
||||
END IF;
|
||||
|
||||
IF repaired_private_media #>> '{service_action,kind}' <> 'star_gift_unique'
|
||||
OR repaired_private_media #>> '{service_action,star_gift_unique,gift,ID}' <> repair.unique_gift_id::text THEN
|
||||
RAISE EXCEPTION 'craft readiness repair found mismatched private message for user %, box %',
|
||||
repair.owner_user_id, repair.box_id;
|
||||
END IF;
|
||||
|
||||
IF repair.desired_craft_chance > 0 THEN
|
||||
repaired_private_media := jsonb_set(
|
||||
jsonb_set(
|
||||
repaired_private_media,
|
||||
'{service_action,star_gift_unique,gift,CraftChancePermille}',
|
||||
to_jsonb(repair.desired_craft_chance),
|
||||
true
|
||||
),
|
||||
'{service_action,star_gift_unique,can_craft_at}',
|
||||
to_jsonb(repair.desired_can_craft_at),
|
||||
true
|
||||
);
|
||||
IF repaired_private_media #>> '{service_action,star_gift_unique,can_craft_at}'
|
||||
IS DISTINCT FROM repair.desired_can_craft_at::text
|
||||
OR repaired_private_media #>> '{service_action,star_gift_unique,gift,CraftChancePermille}'
|
||||
IS DISTINCT FROM repair.desired_craft_chance::text THEN
|
||||
RAISE EXCEPTION 'craft readiness repair cannot project private message for user %, box %',
|
||||
repair.owner_user_id, repair.box_id;
|
||||
END IF;
|
||||
ELSE
|
||||
repaired_private_media := repaired_private_media
|
||||
#- '{service_action,star_gift_unique,can_craft_at}'
|
||||
#- '{service_action,star_gift_unique,gift,CraftChancePermille}';
|
||||
IF repaired_private_media #> '{service_action,star_gift_unique,can_craft_at}' IS NOT NULL
|
||||
OR repaired_private_media #> '{service_action,star_gift_unique,gift,CraftChancePermille}' IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'craft readiness repair cannot clear private message for user %, box %',
|
||||
repair.owner_user_id, repair.box_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
UPDATE public.private_messages
|
||||
SET media = repaired_private_media
|
||||
WHERE sender_user_id = repair.message_sender_id
|
||||
AND id = repair.private_message_id;
|
||||
GET DIAGNOSTICS affected_rows = ROW_COUNT;
|
||||
IF affected_rows <> 1 THEN
|
||||
RAISE EXCEPTION 'craft readiness repair lost private message for user %, box %',
|
||||
repair.owner_user_id, repair.box_id;
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.user_update_events (
|
||||
user_id, pts, pts_count, date, event_type,
|
||||
message_box_id, peer_type, peer_id
|
||||
) VALUES (
|
||||
repair.owner_user_id, next_pts, 1, event_date, 'edit_message',
|
||||
repair.box_id, repair.peer_type, repair.peer_id
|
||||
);
|
||||
|
||||
INSERT INTO public.dispatch_outbox (
|
||||
target_user_id, pts, event_type,
|
||||
exclude_auth_key_id, exclude_session_id
|
||||
) VALUES (
|
||||
repair.owner_user_id, next_pts, 'edit_message', 0, 0
|
||||
);
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Extend the existing deferred unique/saved aggregate guard. Upgrade, Craft
|
||||
-- and export update the two tables in separate statements, so commit-time
|
||||
-- validation observes the final atomic state without a read fallback.
|
||||
CREATE OR REPLACE FUNCTION public.telesrv_check_unique_star_gift_owner() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
unique_id bigint;
|
||||
gift_owner_type text;
|
||||
gift_owner_id bigint;
|
||||
gift_owner_address text;
|
||||
gift_burned boolean;
|
||||
gift_crafted boolean;
|
||||
gift_craft_chance integer;
|
||||
gift_revision_id bigint;
|
||||
saved_status text;
|
||||
saved_owner_type text;
|
||||
saved_owner_id bigint;
|
||||
saved_can_craft_at integer;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'unique_star_gifts' THEN
|
||||
unique_id := COALESCE(NEW.id, OLD.id);
|
||||
ELSE
|
||||
unique_id := COALESCE(NEW.unique_gift_id, OLD.unique_gift_id);
|
||||
END IF;
|
||||
IF unique_id IS NULL THEN RETURN NULL; END IF;
|
||||
SELECT owner_peer_type, owner_peer_id, owner_address, burned, crafted,
|
||||
craft_chance_permille, collectible_revision_id
|
||||
INTO gift_owner_type, gift_owner_id, gift_owner_address, gift_burned, gift_crafted,
|
||||
gift_craft_chance, gift_revision_id
|
||||
FROM public.unique_star_gifts WHERE id=unique_id;
|
||||
IF NOT FOUND THEN RETURN NULL; END IF;
|
||||
SELECT lifecycle_status, owner_peer_type, owner_peer_id, can_craft_at
|
||||
INTO saved_status, saved_owner_type, saved_owner_id, saved_can_craft_at
|
||||
FROM public.peer_star_gifts WHERE unique_gift_id=unique_id;
|
||||
IF NOT FOUND THEN RAISE EXCEPTION 'unique star gift missing saved aggregate'; END IF;
|
||||
IF gift_burned THEN
|
||||
IF saved_status <> 'burned' THEN RAISE EXCEPTION 'burned unique star gift has live saved aggregate'; END IF;
|
||||
ELSIF gift_owner_address <> '' THEN
|
||||
IF saved_status <> 'exported' THEN RAISE EXCEPTION 'exported unique star gift has non-exported saved aggregate'; END IF;
|
||||
ELSIF saved_status <> 'active' OR gift_owner_type IS DISTINCT FROM saved_owner_type OR gift_owner_id IS DISTINCT FROM saved_owner_id THEN
|
||||
RAISE EXCEPTION 'unique star gift owner mismatch';
|
||||
END IF;
|
||||
IF gift_craft_chance > 0 THEN
|
||||
IF saved_can_craft_at <= 0
|
||||
OR saved_status <> 'active'
|
||||
OR saved_owner_type NOT IN ('user', 'channel')
|
||||
OR gift_owner_address <> ''
|
||||
OR gift_burned
|
||||
OR gift_crafted THEN
|
||||
RAISE EXCEPTION 'unique star gift craft capability has invalid aggregate state';
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.star_gift_collectible_models model
|
||||
WHERE model.collectible_revision_id = gift_revision_id
|
||||
AND model.crafted
|
||||
) THEN
|
||||
RAISE EXCEPTION 'unique star gift craft chance has no crafted model';
|
||||
END IF;
|
||||
ELSIF saved_can_craft_at <> 0 THEN
|
||||
RAISE EXCEPTION 'unique star gift readiness exists without craft chance';
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
ALTER TABLE public.star_gift_craft_commands
|
||||
DROP CONSTRAINT IF EXISTS star_gift_craft_output_receipt_check,
|
||||
DROP COLUMN IF EXISTS output_fingerprint,
|
||||
DROP COLUMN IF EXISTS output_media;
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
-- A successful Craft outcome and its self-service message are separated by a
|
||||
-- process boundary. Freeze the exact output intent in the outcome receipt so
|
||||
-- retries never rebuild a different message from mutable gift/profile state.
|
||||
ALTER TABLE public.star_gift_craft_commands
|
||||
ADD COLUMN output_media jsonb,
|
||||
ADD COLUMN output_fingerprint bytea;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.star_gift_craft_commands command
|
||||
WHERE command.success
|
||||
AND 1 <> (
|
||||
SELECT COUNT(*)
|
||||
FROM public.private_messages message
|
||||
WHERE message.sender_user_id = command.user_id
|
||||
AND message.recipient_user_id = command.user_id
|
||||
AND message.sender_snapshot #>> '{message,Media,service_action,kind}' = 'star_gift_unique'
|
||||
AND message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,gift,ID}' = command.result_unique_gift_id::text
|
||||
AND COALESCE((message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,craft}')::boolean, false)
|
||||
AND octet_length(message.request_fingerprint) = 32
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'successful craft command is missing its exact immutable output receipt';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
WITH outputs AS (
|
||||
SELECT command.user_id,
|
||||
command.command_key,
|
||||
message.sender_snapshot #> '{message,Media}' AS media,
|
||||
message.request_fingerprint
|
||||
FROM public.star_gift_craft_commands command
|
||||
JOIN public.private_messages message
|
||||
ON message.sender_user_id = command.user_id
|
||||
AND message.recipient_user_id = command.user_id
|
||||
AND message.sender_snapshot #>> '{message,Media,service_action,kind}' = 'star_gift_unique'
|
||||
AND message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,gift,ID}' = command.result_unique_gift_id::text
|
||||
AND COALESCE((message.sender_snapshot #>> '{message,Media,service_action,star_gift_unique,craft}')::boolean, false)
|
||||
WHERE command.success
|
||||
)
|
||||
UPDATE public.star_gift_craft_commands command
|
||||
SET output_media = output.media,
|
||||
output_fingerprint = output.request_fingerprint
|
||||
FROM outputs output
|
||||
WHERE command.user_id = output.user_id
|
||||
AND command.command_key = output.command_key;
|
||||
|
||||
ALTER TABLE public.star_gift_craft_commands
|
||||
ADD CONSTRAINT star_gift_craft_output_receipt_check CHECK (
|
||||
(success
|
||||
AND result_unique_gift_id IS NOT NULL
|
||||
AND output_media IS NOT NULL
|
||||
AND output_media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND COALESCE((output_media #>> '{service_action,star_gift_unique,craft}')::boolean, false)
|
||||
AND output_media #>> '{service_action,star_gift_unique,gift,ID}' = result_unique_gift_id::text
|
||||
AND octet_length(output_fingerprint) = 32)
|
||||
OR
|
||||
(NOT success
|
||||
AND result_unique_gift_id IS NULL
|
||||
AND output_media IS NULL
|
||||
AND output_fingerprint IS NULL)
|
||||
);
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
UPDATE public.bots
|
||||
SET commands = COALESCE((
|
||||
SELECT jsonb_agg(command ORDER BY ordinal)
|
||||
FROM jsonb_array_elements(commands) WITH ORDINALITY AS item(command, ordinal)
|
||||
WHERE command->>'command' <> 'done'
|
||||
), '[]'::jsonb),
|
||||
updated_at = now()
|
||||
WHERE bot_user_id = 93372553;
|
||||
|
||||
UPDATE public.users
|
||||
SET bot_info_version = bot_info_version + 1,
|
||||
updated_at = now()
|
||||
WHERE id = 93372553;
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
-- /setlogin remains active across multiple configuration messages. Publish
|
||||
-- /done in BotFather's command menu so clients can discover the explicit
|
||||
-- finish action without reopening /help.
|
||||
UPDATE public.bots
|
||||
SET commands = commands || '[
|
||||
{"command":"done","description":"finish Telegram Login configuration"}
|
||||
]'::jsonb,
|
||||
updated_at = now()
|
||||
WHERE bot_user_id = 93372553
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_array_elements(commands) AS item(command)
|
||||
WHERE item.command->>'command' = 'done'
|
||||
);
|
||||
|
||||
-- Bot command menus are cached by bot_info_version. Bump it even when an
|
||||
-- operator already added /done manually, making the migration convergent and
|
||||
-- forcing connected clients to refresh the authoritative command list.
|
||||
UPDATE public.users
|
||||
SET bot_info_version = bot_info_version + 1,
|
||||
updated_at = now()
|
||||
WHERE id = 93372553;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
DROP TABLE IF EXISTS public.account_freeze_notifications;
|
||||
|
||||
ALTER TABLE public.account_restrictions
|
||||
DROP CONSTRAINT IF EXISTS account_restrictions_version_check,
|
||||
DROP COLUMN IF EXISTS version;
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
-- A freeze/unfreeze is a viewer-visible user projection change. Version the
|
||||
-- durable fact so a claimed old nudge can never acknowledge a newer state.
|
||||
ALTER TABLE public.account_restrictions
|
||||
ADD COLUMN version bigint DEFAULT 1 NOT NULL,
|
||||
ADD CONSTRAINT account_restrictions_version_check CHECK (version > 0);
|
||||
|
||||
-- updateUser has no pts. This coalesced queue is only a crash-safe online
|
||||
-- nudge; offline clients reconstruct the current restriction from the
|
||||
-- authoritative account_restrictions row during normal user hydration.
|
||||
CREATE TABLE public.account_freeze_notifications (
|
||||
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
target_user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
frozen_user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
|
||||
version bigint NOT NULL,
|
||||
frozen boolean NOT NULL,
|
||||
status text DEFAULT 'pending' NOT NULL,
|
||||
attempts integer DEFAULT 0 NOT NULL,
|
||||
next_attempt_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
lease_until timestamp with time zone,
|
||||
last_error text DEFAULT '' NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT account_freeze_notifications_status_check
|
||||
CHECK (status IN ('pending', 'dispatching', 'delivered')),
|
||||
CONSTRAINT account_freeze_notifications_attempts_check CHECK (attempts >= 0),
|
||||
CONSTRAINT account_freeze_notifications_version_check CHECK (version > 0),
|
||||
CONSTRAINT account_freeze_notifications_not_self_check CHECK (target_user_id <> frozen_user_id),
|
||||
UNIQUE (target_user_id, frozen_user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX account_freeze_notifications_ready_idx
|
||||
ON public.account_freeze_notifications(next_attempt_at, id)
|
||||
WHERE status = 'pending';
|
||||
|
||||
CREATE INDEX account_freeze_notifications_lease_idx
|
||||
ON public.account_freeze_notifications(lease_until, id)
|
||||
WHERE status = 'dispatching';
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
DROP TRIGGER IF EXISTS star_gift_catalog_collectible_preview_activation ON public.star_gift_catalog;
|
||||
DROP FUNCTION IF EXISTS public.telesrv_validate_collectible_preview_activation();
|
||||
|
||||
UPDATE public.star_gift_catalog c
|
||||
SET collectible_revision_id = repair.collectible_revision_id, updated_at = now()
|
||||
FROM public.star_gift_collectible_preview_repairs repair
|
||||
WHERE c.gift_id = repair.gift_id
|
||||
AND c.collectible_revision_id IS NULL;
|
||||
|
||||
DROP TABLE public.star_gift_collectible_preview_repairs;
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
-- TDesktop deduplicates upgrade-preview models/patterns by document identity and can only
|
||||
-- finish each attribute spinner after it has a non-target item. Detach previously published
|
||||
-- pools that cannot satisfy that client contract; the immutable revisions remain available
|
||||
-- for audit and for already-issued unique gifts.
|
||||
CREATE TABLE public.star_gift_collectible_preview_repairs (
|
||||
gift_id bigint PRIMARY KEY REFERENCES public.star_gift_catalog(gift_id) ON DELETE CASCADE,
|
||||
collectible_revision_id bigint UNIQUE NOT NULL
|
||||
REFERENCES public.star_gift_collectible_revisions(id) ON DELETE RESTRICT,
|
||||
reason text DEFAULT 'insufficient distinct upgrade preview attributes' NOT NULL,
|
||||
repaired_at timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO public.star_gift_collectible_preview_repairs (gift_id, collectible_revision_id)
|
||||
SELECT c.gift_id, c.collectible_revision_id
|
||||
FROM public.star_gift_catalog c
|
||||
JOIN public.star_gift_collectible_revisions r ON r.id = c.collectible_revision_id
|
||||
WHERE c.collectible_revision_id IS NOT NULL
|
||||
AND (
|
||||
r.status <> 'published' OR r.gift_id <> c.gift_id OR
|
||||
(SELECT count(DISTINCT m.document_id)
|
||||
FROM public.star_gift_collectible_models m
|
||||
WHERE m.collectible_revision_id = r.id
|
||||
AND m.rarity_kind = 'permille' AND NOT m.crafted) < 2 OR
|
||||
(SELECT count(DISTINCT p.document_id)
|
||||
FROM public.star_gift_collectible_patterns p
|
||||
WHERE p.collectible_revision_id = r.id
|
||||
AND p.rarity_kind = 'permille') < 2 OR
|
||||
(SELECT count(DISTINCT b.backdrop_id)
|
||||
FROM public.star_gift_collectible_backdrops b
|
||||
WHERE b.collectible_revision_id = r.id
|
||||
AND b.rarity_kind = 'permille') < 2
|
||||
);
|
||||
|
||||
UPDATE public.star_gift_catalog c
|
||||
SET collectible_revision_id = NULL, updated_at = now()
|
||||
FROM public.star_gift_collectible_preview_repairs repair
|
||||
WHERE c.gift_id = repair.gift_id
|
||||
AND c.collectible_revision_id = repair.collectible_revision_id;
|
||||
|
||||
-- Keep the same invariant at the final activation boundary. Application validation gives the
|
||||
-- operator a precise error first; this trigger also protects imports or maintenance SQL that
|
||||
-- attempts to expose a malformed published revision directly.
|
||||
CREATE FUNCTION public.telesrv_validate_collectible_preview_activation() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
revision_gift_id bigint;
|
||||
revision_status text;
|
||||
BEGIN
|
||||
IF NEW.collectible_revision_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT gift_id, status INTO revision_gift_id, revision_status
|
||||
FROM public.star_gift_collectible_revisions
|
||||
WHERE id = NEW.collectible_revision_id;
|
||||
|
||||
IF NOT FOUND OR revision_gift_id <> NEW.gift_id OR revision_status <> 'published' THEN
|
||||
RAISE EXCEPTION 'collectible preview revision must be published for the same gift'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF (SELECT count(DISTINCT document_id)
|
||||
FROM public.star_gift_collectible_models
|
||||
WHERE collectible_revision_id = NEW.collectible_revision_id
|
||||
AND rarity_kind = 'permille' AND NOT crafted) < 2 THEN
|
||||
RAISE EXCEPTION 'collectible model preview requires two distinct documents'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF (SELECT count(DISTINCT document_id)
|
||||
FROM public.star_gift_collectible_patterns
|
||||
WHERE collectible_revision_id = NEW.collectible_revision_id
|
||||
AND rarity_kind = 'permille') < 2 THEN
|
||||
RAISE EXCEPTION 'collectible pattern preview requires two distinct documents'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
IF (SELECT count(DISTINCT backdrop_id)
|
||||
FROM public.star_gift_collectible_backdrops
|
||||
WHERE collectible_revision_id = NEW.collectible_revision_id
|
||||
AND rarity_kind = 'permille') < 2 THEN
|
||||
RAISE EXCEPTION 'collectible backdrop preview requires two distinct IDs'
|
||||
USING ERRCODE = '23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER star_gift_catalog_collectible_preview_activation
|
||||
BEFORE INSERT OR UPDATE OF collectible_revision_id ON public.star_gift_catalog
|
||||
FOR EACH ROW EXECUTE FUNCTION public.telesrv_validate_collectible_preview_activation();
|
||||
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS public.suggested_post_approvals;
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
-- Durable suggested-post approval/payment/publication state. The row is the
|
||||
-- idempotency key for a monoforum suggestion; message/update rows remain the
|
||||
-- client-visible source of truth and are written in the same transaction.
|
||||
CREATE TABLE public.suggested_post_approvals (
|
||||
monoforum_id bigint NOT NULL,
|
||||
suggestion_message_id integer NOT NULL,
|
||||
parent_channel_id bigint NOT NULL,
|
||||
actor_user_id bigint NOT NULL,
|
||||
payer_user_id bigint NOT NULL,
|
||||
state text NOT NULL,
|
||||
price_kind text NOT NULL DEFAULT '',
|
||||
price_amount bigint NOT NULL DEFAULT 0,
|
||||
price_nanos integer NOT NULL DEFAULT 0,
|
||||
schedule_date integer NOT NULL DEFAULT 0,
|
||||
approval_service_message_id integer NOT NULL DEFAULT 0,
|
||||
published_message_id integer NOT NULL DEFAULT 0,
|
||||
settlement_due integer NOT NULL DEFAULT 0,
|
||||
final_service_message_id integer NOT NULL DEFAULT 0,
|
||||
created_at integer NOT NULL,
|
||||
updated_at integer NOT NULL,
|
||||
PRIMARY KEY (monoforum_id, suggestion_message_id),
|
||||
CONSTRAINT suggested_post_approvals_shape_check CHECK (
|
||||
monoforum_id>0 AND suggestion_message_id>0 AND parent_channel_id>0 AND
|
||||
actor_user_id>0 AND payer_user_id>0 AND created_at>0 AND updated_at>=created_at AND
|
||||
state IN ('balance_low','rejected','scheduled','published','completed','refunded') AND
|
||||
price_kind IN ('','stars','ton') AND price_amount>=0 AND price_nanos BETWEEN 0 AND 999999999 AND
|
||||
((price_kind='' AND price_amount=0 AND price_nanos=0) OR
|
||||
(price_kind='stars' AND price_amount>0) OR
|
||||
(price_kind='ton' AND price_amount>0 AND price_nanos=0)) AND
|
||||
schedule_date>=0 AND approval_service_message_id>=0 AND published_message_id>=0 AND
|
||||
settlement_due>=0 AND final_service_message_id>=0)
|
||||
);
|
||||
|
||||
CREATE INDEX suggested_post_approvals_schedule_idx
|
||||
ON public.suggested_post_approvals(schedule_date,monoforum_id,suggestion_message_id)
|
||||
WHERE state='scheduled';
|
||||
CREATE INDEX suggested_post_approvals_settlement_idx
|
||||
ON public.suggested_post_approvals(settlement_due,monoforum_id,suggestion_message_id)
|
||||
WHERE state='published';
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
-- The data backfill is intentionally retained on rollback. Restore only the
|
||||
-- pre-0134 shape constraint, which allowed zero schedule_date in every state.
|
||||
ALTER TABLE suggested_post_approvals
|
||||
DROP CONSTRAINT suggested_post_approvals_shape_check;
|
||||
|
||||
ALTER TABLE suggested_post_approvals
|
||||
ADD CONSTRAINT suggested_post_approvals_shape_check CHECK (
|
||||
monoforum_id>0 AND suggestion_message_id>0 AND parent_channel_id>0 AND
|
||||
actor_user_id>0 AND payer_user_id>0 AND created_at>0 AND updated_at>=created_at AND
|
||||
state IN ('balance_low','rejected','scheduled','published','completed','refunded') AND
|
||||
price_kind IN ('','stars','ton') AND price_amount>=0 AND price_nanos BETWEEN 0 AND 999999999 AND
|
||||
((price_kind='' AND price_amount=0 AND price_nanos=0) OR
|
||||
(price_kind='stars' AND price_amount>0) OR
|
||||
(price_kind='ton' AND price_amount>0 AND price_nanos=0)) AND
|
||||
schedule_date>=0 AND approval_service_message_id>=0 AND published_message_id>=0 AND
|
||||
settlement_due>=0 AND final_service_message_id>=0
|
||||
);
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
-- TDesktop omits schedule_date for "Publish Now", while the approval action
|
||||
-- renderer always formats an absolute publication date. Backfill rows written
|
||||
-- by the initial lifecycle implementation and keep current/history/difference
|
||||
-- projections on the same effective timestamp.
|
||||
UPDATE channel_messages m
|
||||
SET suggested_post = jsonb_set(
|
||||
m.suggested_post,
|
||||
'{ScheduleDate}',
|
||||
to_jsonb(a.created_at),
|
||||
true
|
||||
)
|
||||
FROM suggested_post_approvals a
|
||||
WHERE a.schedule_date = 0
|
||||
AND a.state IN ('scheduled', 'published', 'completed', 'refunded')
|
||||
AND m.channel_id = a.monoforum_id
|
||||
AND m.id = a.suggestion_message_id
|
||||
AND COALESCE((m.suggested_post->>'Accepted')::boolean, false)
|
||||
AND COALESCE((m.suggested_post->>'ScheduleDate')::integer, 0) = 0;
|
||||
|
||||
UPDATE channel_messages m
|
||||
SET action = jsonb_set(
|
||||
m.action,
|
||||
'{SuggestedPostScheduleDate}',
|
||||
to_jsonb(a.created_at),
|
||||
true
|
||||
)
|
||||
FROM suggested_post_approvals a
|
||||
WHERE a.schedule_date = 0
|
||||
AND a.state IN ('scheduled', 'published', 'completed', 'refunded')
|
||||
AND m.channel_id = a.monoforum_id
|
||||
AND m.id = a.approval_service_message_id
|
||||
AND m.action->>'Type' = 'suggested_post_approval'
|
||||
AND NOT COALESCE((m.action->>'SuggestedPostRejected')::boolean, false)
|
||||
AND NOT COALESCE((m.action->>'SuggestedPostBalanceTooLow')::boolean, false)
|
||||
AND COALESCE((m.action->>'SuggestedPostScheduleDate')::integer, 0) = 0;
|
||||
|
||||
UPDATE channel_update_events e
|
||||
SET payload = jsonb_set(
|
||||
e.payload,
|
||||
'{message,SuggestedPost,ScheduleDate}',
|
||||
to_jsonb(a.created_at),
|
||||
true
|
||||
)
|
||||
FROM suggested_post_approvals a
|
||||
WHERE a.schedule_date = 0
|
||||
AND a.state IN ('scheduled', 'published', 'completed', 'refunded')
|
||||
AND e.channel_id = a.monoforum_id
|
||||
AND e.message_id = a.suggestion_message_id
|
||||
AND e.event_type = 'edit_channel_message'
|
||||
AND COALESCE((e.payload #>> '{message,SuggestedPost,Accepted}')::boolean, false)
|
||||
AND COALESCE((e.payload #>> '{message,SuggestedPost,ScheduleDate}')::integer, 0) = 0;
|
||||
|
||||
UPDATE channel_update_events e
|
||||
SET payload = jsonb_set(
|
||||
e.payload,
|
||||
'{message,Action,SuggestedPostScheduleDate}',
|
||||
to_jsonb(a.created_at),
|
||||
true
|
||||
)
|
||||
FROM suggested_post_approvals a
|
||||
WHERE a.schedule_date = 0
|
||||
AND a.state IN ('scheduled', 'published', 'completed', 'refunded')
|
||||
AND e.channel_id = a.monoforum_id
|
||||
AND e.message_id = a.approval_service_message_id
|
||||
AND e.event_type = 'new_channel_message'
|
||||
AND e.payload #>> '{message,Action,Type}' = 'suggested_post_approval'
|
||||
AND NOT COALESCE((e.payload #>> '{message,Action,SuggestedPostRejected}')::boolean, false)
|
||||
AND NOT COALESCE((e.payload #>> '{message,Action,SuggestedPostBalanceTooLow}')::boolean, false)
|
||||
AND COALESCE((e.payload #>> '{message,Action,SuggestedPostScheduleDate}')::integer, 0) = 0;
|
||||
|
||||
UPDATE suggested_post_approvals
|
||||
SET schedule_date = created_at,
|
||||
updated_at = GREATEST(updated_at, created_at)
|
||||
WHERE schedule_date = 0
|
||||
AND state IN ('scheduled', 'published', 'completed', 'refunded');
|
||||
|
||||
ALTER TABLE suggested_post_approvals
|
||||
DROP CONSTRAINT suggested_post_approvals_shape_check;
|
||||
|
||||
ALTER TABLE suggested_post_approvals
|
||||
ADD CONSTRAINT suggested_post_approvals_shape_check CHECK (
|
||||
monoforum_id>0 AND suggestion_message_id>0 AND parent_channel_id>0 AND
|
||||
actor_user_id>0 AND payer_user_id>0 AND created_at>0 AND updated_at>=created_at AND
|
||||
state IN ('balance_low','rejected','scheduled','published','completed','refunded') AND
|
||||
price_kind IN ('','stars','ton') AND price_amount>=0 AND price_nanos BETWEEN 0 AND 999999999 AND
|
||||
((price_kind='' AND price_amount=0 AND price_nanos=0) OR
|
||||
(price_kind='stars' AND price_amount>0) OR
|
||||
(price_kind='ton' AND price_amount>0 AND price_nanos=0)) AND
|
||||
schedule_date>=0 AND
|
||||
(state IN ('balance_low','rejected') OR schedule_date>0) AND
|
||||
approval_service_message_id>=0 AND published_message_id>=0 AND
|
||||
settlement_due>=0 AND final_service_message_id>=0
|
||||
);
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
-- The up migration registers protocol identities and emits durable per-user
|
||||
-- edit_message events. Removing aliases, reverting snapshots or rewinding pts
|
||||
-- would invalidate messages already consumed by clients and create holes in
|
||||
-- updates.getDifference, so rollback intentionally preserves the repair.
|
||||
|
|
@ -0,0 +1,304 @@
|
|||
-- A separate prepaid-upgrade service message is another owner-local entry to
|
||||
-- the same saved-gift aggregate. Earlier writes persisted gift_msg_id in the
|
||||
-- receiver projection but did not register that message id, so clients that
|
||||
-- submitted the visible card id received STARGIFT_INVALID. If the gift was
|
||||
-- upgraded through the original id, the prepaid card also remained actionable.
|
||||
--
|
||||
-- Repair aliases and already-upgraded projections atomically. Durable edit
|
||||
-- events make history, online delivery and updates.getDifference converge on
|
||||
-- the same non-actionable snapshot. Invalid persisted shapes fail the migration
|
||||
-- instead of being normalized by a read path.
|
||||
|
||||
LOCK TABLE public.peer_star_gifts, public.star_gift_user_message_refs,
|
||||
public.message_boxes, public.private_messages IN SHARE ROW EXCLUSIVE MODE;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.message_boxes box
|
||||
WHERE NOT box.deleted
|
||||
AND box.media #>> '{service_action,kind}' = 'star_gift'
|
||||
AND box.media #>> '{service_action,star_gift,prepaid_upgrade}' = 'true'
|
||||
AND box.media #>> '{service_action,star_gift,upgrade_separate}' = 'true'
|
||||
AND (
|
||||
jsonb_typeof(box.media #> '{service_action,star_gift,gift_id}') IS DISTINCT FROM 'number'
|
||||
OR COALESCE(box.media #>> '{service_action,star_gift,gift_id}', '') !~ '^[0-9]+$'
|
||||
OR (box.media #>> '{service_action,star_gift,gift_id}')::numeric <= 0
|
||||
OR (box.media #>> '{service_action,star_gift,gift_id}')::numeric > 9223372036854775807
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'separate prepaid star gift message has malformed gift_id';
|
||||
END IF;
|
||||
|
||||
-- gift_msg_id is receiver-only, so absence is valid on the payer box. If
|
||||
-- present it must be a positive protocol int32 message id.
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.message_boxes box
|
||||
WHERE NOT box.deleted
|
||||
AND box.media #>> '{service_action,kind}' = 'star_gift'
|
||||
AND box.media #>> '{service_action,star_gift,prepaid_upgrade}' = 'true'
|
||||
AND box.media #>> '{service_action,star_gift,upgrade_separate}' = 'true'
|
||||
AND box.media #> '{service_action,star_gift,gift_msg_id}' IS NOT NULL
|
||||
AND (
|
||||
jsonb_typeof(box.media #> '{service_action,star_gift,gift_msg_id}') <> 'number'
|
||||
OR COALESCE(box.media #>> '{service_action,star_gift,gift_msg_id}', '') !~ '^[0-9]+$'
|
||||
OR (box.media #>> '{service_action,star_gift,gift_msg_id}')::numeric <= 0
|
||||
OR (box.media #>> '{service_action,star_gift,gift_msg_id}')::numeric > 2147483647
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'separate prepaid star gift message has malformed gift_msg_id';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TEMP TABLE star_gift_prepaid_message_aliases ON COMMIT DROP AS
|
||||
SELECT DISTINCT owner_box.owner_user_id,
|
||||
owner_box.box_id,
|
||||
gift.id AS saved_gift_id,
|
||||
owner_box.message_sender_id,
|
||||
owner_box.private_message_id
|
||||
FROM public.message_boxes owner_box
|
||||
JOIN public.peer_star_gifts gift
|
||||
ON gift.owner_peer_type = 'user'
|
||||
AND gift.owner_peer_id = owner_box.owner_user_id
|
||||
AND gift.lifecycle_status = 'active'
|
||||
AND gift.msg_id = (owner_box.media #>> '{service_action,star_gift,gift_msg_id}')::integer
|
||||
AND gift.gift_id = (owner_box.media #>> '{service_action,star_gift,gift_id}')::bigint
|
||||
WHERE NOT owner_box.deleted
|
||||
AND owner_box.media #>> '{service_action,kind}' = 'star_gift'
|
||||
AND owner_box.media #>> '{service_action,star_gift,prepaid_upgrade}' = 'true'
|
||||
AND owner_box.media #>> '{service_action,star_gift,upgrade_separate}' = 'true'
|
||||
AND owner_box.media #> '{service_action,star_gift,gift_msg_id}' IS NOT NULL;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_prepaid_message_aliases
|
||||
GROUP BY owner_user_id, box_id
|
||||
HAVING COUNT(DISTINCT saved_gift_id) <> 1
|
||||
) THEN
|
||||
RAISE EXCEPTION 'separate prepaid star gift message resolves to multiple aggregates';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_prepaid_message_aliases alias
|
||||
JOIN public.star_gift_user_message_refs ref
|
||||
ON ref.owner_user_id = alias.owner_user_id
|
||||
AND ref.msg_id = alias.box_id
|
||||
WHERE ref.saved_gift_id <> alias.saved_gift_id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'separate prepaid star gift message collides with another aggregate';
|
||||
END IF;
|
||||
|
||||
-- Both boxes of the logical private message must retain the same prepayment
|
||||
-- identity. The receiver-only gift_msg_id may differ by design.
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_prepaid_message_aliases alias
|
||||
JOIN public.peer_star_gifts gift ON gift.id = alias.saved_gift_id
|
||||
JOIN public.message_boxes visible_box
|
||||
ON visible_box.message_sender_id = alias.message_sender_id
|
||||
AND visible_box.private_message_id = alias.private_message_id
|
||||
AND NOT visible_box.deleted
|
||||
WHERE visible_box.media #>> '{service_action,kind}' IS DISTINCT FROM 'star_gift'
|
||||
OR visible_box.media #>> '{service_action,star_gift,prepaid_upgrade}' IS DISTINCT FROM 'true'
|
||||
OR visible_box.media #>> '{service_action,star_gift,upgrade_separate}' IS DISTINCT FROM 'true'
|
||||
OR visible_box.media #>> '{service_action,star_gift,gift_id}' IS DISTINCT FROM gift.gift_id::text
|
||||
) THEN
|
||||
RAISE EXCEPTION 'separate prepaid star gift private projections disagree';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE UNIQUE INDEX star_gift_prepaid_message_aliases_owner_msg_idx
|
||||
ON star_gift_prepaid_message_aliases(owner_user_id, box_id);
|
||||
|
||||
INSERT INTO public.star_gift_user_message_refs(owner_user_id, msg_id, saved_gift_id)
|
||||
SELECT owner_user_id, box_id, saved_gift_id
|
||||
FROM star_gift_prepaid_message_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;
|
||||
|
||||
COMMENT ON TABLE public.star_gift_user_message_refs IS
|
||||
'Owner-local service-message aliases (unique outputs and separate prepaid-upgrade notifications) for one saved gift aggregate.';
|
||||
|
||||
CREATE TEMP TABLE star_gift_prepaid_message_repairs (
|
||||
owner_user_id bigint NOT NULL,
|
||||
box_id integer NOT NULL,
|
||||
peer_type text NOT NULL,
|
||||
peer_id bigint NOT NULL,
|
||||
message_sender_id bigint NOT NULL,
|
||||
private_message_id bigint NOT NULL,
|
||||
repaired_media jsonb NOT NULL,
|
||||
PRIMARY KEY (owner_user_id, box_id)
|
||||
) ON COMMIT DROP;
|
||||
|
||||
-- Upgrade every visible copy of an already-consumed prepayment. A viewer gets
|
||||
-- upgrade_msg_id only when that same viewer owns a box for the emitted unique
|
||||
-- action. This covers the original sender while avoiding an owner-local link
|
||||
-- on an unrelated third-party payer's card.
|
||||
INSERT INTO star_gift_prepaid_message_repairs(
|
||||
owner_user_id, box_id, peer_type, peer_id,
|
||||
message_sender_id, private_message_id, repaired_media
|
||||
)
|
||||
SELECT visible_box.owner_user_id,
|
||||
visible_box.box_id,
|
||||
visible_box.peer_type,
|
||||
visible_box.peer_id,
|
||||
visible_box.message_sender_id,
|
||||
visible_box.private_message_id,
|
||||
CASE
|
||||
WHEN unique_box.box_id IS NULL THEN
|
||||
visible_box.media
|
||||
#- '{service_action,star_gift,can_upgrade}'
|
||||
#- '{service_action,star_gift,prepaid_upgrade_hash}'
|
||||
#- '{service_action,star_gift,upgrade_msg_id}'
|
||||
ELSE jsonb_set(
|
||||
visible_box.media
|
||||
#- '{service_action,star_gift,can_upgrade}'
|
||||
#- '{service_action,star_gift,prepaid_upgrade_hash}',
|
||||
'{service_action,star_gift,upgrade_msg_id}',
|
||||
to_jsonb(unique_box.box_id::bigint),
|
||||
true
|
||||
)
|
||||
END
|
||||
FROM star_gift_prepaid_message_aliases alias
|
||||
JOIN public.peer_star_gifts gift
|
||||
ON gift.id = alias.saved_gift_id
|
||||
AND gift.lifecycle_status = 'active'
|
||||
AND gift.unique_gift_id IS NOT NULL
|
||||
AND gift.upgrade_msg_id > 0
|
||||
JOIN public.message_boxes owner_unique_box
|
||||
ON owner_unique_box.owner_user_id = gift.owner_peer_id
|
||||
AND owner_unique_box.box_id = gift.upgrade_msg_id
|
||||
AND NOT owner_unique_box.deleted
|
||||
AND owner_unique_box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND owner_unique_box.media #>> '{service_action,star_gift_unique,gift,ID}' = gift.unique_gift_id::text
|
||||
JOIN public.message_boxes visible_box
|
||||
ON visible_box.message_sender_id = alias.message_sender_id
|
||||
AND visible_box.private_message_id = alias.private_message_id
|
||||
AND NOT visible_box.deleted
|
||||
LEFT JOIN public.message_boxes unique_box
|
||||
ON unique_box.owner_user_id = visible_box.owner_user_id
|
||||
AND unique_box.message_sender_id = owner_unique_box.message_sender_id
|
||||
AND unique_box.private_message_id = owner_unique_box.private_message_id
|
||||
AND NOT unique_box.deleted
|
||||
AND unique_box.media #>> '{service_action,kind}' = 'star_gift_unique'
|
||||
AND unique_box.media #>> '{service_action,star_gift_unique,gift,ID}' = gift.unique_gift_id::text;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
repair_row record;
|
||||
next_pts integer;
|
||||
event_date integer := EXTRACT(EPOCH FROM clock_timestamp())::integer;
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_prepaid_message_aliases alias
|
||||
JOIN public.peer_star_gifts gift
|
||||
ON gift.id = alias.saved_gift_id
|
||||
AND gift.lifecycle_status = 'active'
|
||||
AND gift.unique_gift_id IS NOT NULL
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_prepaid_message_repairs target_repair
|
||||
WHERE target_repair.owner_user_id = alias.owner_user_id
|
||||
AND target_repair.box_id = alias.box_id
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'upgraded star gift is missing its prepaid message repair';
|
||||
END IF;
|
||||
|
||||
FOR repair_row IN
|
||||
SELECT owner_user_id, box_id, peer_type, peer_id, repaired_media
|
||||
FROM star_gift_prepaid_message_repairs
|
||||
ORDER BY owner_user_id, box_id
|
||||
LOOP
|
||||
INSERT INTO public.user_update_watermarks(user_id, contiguous_pts)
|
||||
VALUES(repair_row.owner_user_id, 0)
|
||||
ON CONFLICT(user_id) DO NOTHING;
|
||||
|
||||
UPDATE public.user_update_watermarks
|
||||
SET contiguous_pts = contiguous_pts + 1,
|
||||
updated_at = now()
|
||||
WHERE user_id = repair_row.owner_user_id
|
||||
RETURNING contiguous_pts INTO next_pts;
|
||||
|
||||
UPDATE public.message_boxes
|
||||
SET media = repair_row.repaired_media,
|
||||
pts = next_pts
|
||||
WHERE owner_user_id = repair_row.owner_user_id
|
||||
AND box_id = repair_row.box_id
|
||||
AND NOT deleted;
|
||||
|
||||
INSERT INTO public.user_update_events(
|
||||
user_id, pts, pts_count, date, event_type,
|
||||
message_box_id, peer_type, peer_id
|
||||
) VALUES (
|
||||
repair_row.owner_user_id, next_pts, 1, event_date, 'edit_message',
|
||||
repair_row.box_id, repair_row.peer_type, repair_row.peer_id
|
||||
);
|
||||
|
||||
INSERT INTO public.dispatch_outbox(
|
||||
target_user_id, pts, event_type,
|
||||
exclude_auth_key_id, exclude_session_id
|
||||
) VALUES(repair_row.owner_user_id, next_pts, 'edit_message', 0, 0);
|
||||
END LOOP;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- private_messages is a shared logical envelope and cannot retain either
|
||||
-- participant's box-local gift_msg_id or upgrade_msg_id.
|
||||
WITH shared_repairs AS (
|
||||
SELECT DISTINCT ON (repair.message_sender_id, repair.private_message_id)
|
||||
repair.message_sender_id,
|
||||
repair.private_message_id,
|
||||
repair.repaired_media
|
||||
#- '{service_action,star_gift,saved_id}'
|
||||
#- '{service_action,star_gift,gift_msg_id}'
|
||||
#- '{service_action,star_gift,upgrade_msg_id}' AS shared_media
|
||||
FROM star_gift_prepaid_message_repairs repair
|
||||
ORDER BY repair.message_sender_id,
|
||||
repair.private_message_id,
|
||||
(repair.owner_user_id = repair.message_sender_id) DESC,
|
||||
repair.owner_user_id
|
||||
)
|
||||
UPDATE public.private_messages private_message
|
||||
SET media = repair.shared_media
|
||||
FROM shared_repairs repair
|
||||
WHERE private_message.sender_user_id = repair.message_sender_id
|
||||
AND private_message.id = repair.private_message_id;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_prepaid_message_aliases alias
|
||||
LEFT JOIN public.star_gift_user_message_refs ref
|
||||
ON ref.owner_user_id = alias.owner_user_id
|
||||
AND ref.msg_id = alias.box_id
|
||||
AND ref.saved_gift_id = alias.saved_gift_id
|
||||
WHERE ref.saved_gift_id IS NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION 'separate prepaid star gift alias repair did not converge';
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM star_gift_prepaid_message_repairs repair
|
||||
JOIN public.message_boxes box
|
||||
ON box.owner_user_id = repair.owner_user_id
|
||||
AND box.box_id = repair.box_id
|
||||
WHERE box.media IS DISTINCT FROM repair.repaired_media
|
||||
OR box.media #> '{service_action,star_gift,can_upgrade}' IS NOT NULL
|
||||
OR box.media #> '{service_action,star_gift,prepaid_upgrade_hash}' IS NOT NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION 'upgraded prepaid star gift projection repair did not converge';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
ALTER TABLE public.channels
|
||||
DROP CONSTRAINT IF EXISTS channels_scam_fake_mutually_exclusive,
|
||||
DROP COLUMN IF EXISTS scam,
|
||||
DROP COLUMN IF EXISTS fake;
|
||||
|
||||
ALTER TABLE public.users
|
||||
DROP CONSTRAINT IF EXISTS users_scam_fake_mutually_exclusive,
|
||||
DROP COLUMN IF EXISTS scam,
|
||||
DROP COLUMN IF EXISTS fake;
|
||||
19
deploy/migrations/20260714003095_scam_fake_flags.up.sql
Normal file
19
deploy/migrations/20260714003095_scam_fake_flags.up.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
-- SCAM / FAKE moderation flags for users (incl. bots) and channels.
|
||||
-- Mirrors the Layer 228 user.scam/user.fake and channel.scam/channel.fake TL flags.
|
||||
ALTER TABLE public.users
|
||||
ADD COLUMN IF NOT EXISTS scam boolean DEFAULT false NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS fake boolean DEFAULT false NOT NULL;
|
||||
|
||||
UPDATE public.users SET fake = false WHERE scam AND fake;
|
||||
ALTER TABLE public.users
|
||||
DROP CONSTRAINT IF EXISTS users_scam_fake_mutually_exclusive,
|
||||
ADD CONSTRAINT users_scam_fake_mutually_exclusive CHECK (NOT (scam AND fake));
|
||||
|
||||
ALTER TABLE public.channels
|
||||
ADD COLUMN IF NOT EXISTS scam boolean DEFAULT false NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS fake boolean DEFAULT false NOT NULL;
|
||||
|
||||
UPDATE public.channels SET fake = false WHERE scam AND fake;
|
||||
ALTER TABLE public.channels
|
||||
DROP CONSTRAINT IF EXISTS channels_scam_fake_mutually_exclusive,
|
||||
ADD CONSTRAINT channels_scam_fake_mutually_exclusive CHECK (NOT (scam AND fake));
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE public.channels
|
||||
DROP COLUMN IF EXISTS gigagroup;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
-- gigagroup flag for supergroups (Layer 228 channel.gigagroup).
|
||||
ALTER TABLE public.channels
|
||||
ADD COLUMN IF NOT EXISTS gigagroup boolean DEFAULT false NOT NULL;
|
||||
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS public.star_gift_admin_grant_commands;
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
-- Direct admin collectible grants are one idempotent aggregate: unique
|
||||
-- issuance, saved ownership, private message, pts/outbox and this receipt.
|
||||
CREATE TABLE public.star_gift_admin_grant_commands (
|
||||
recipient_user_id bigint NOT NULL,
|
||||
command_key text NOT NULL,
|
||||
request_fingerprint bytea NOT NULL,
|
||||
sender_user_id bigint NOT NULL,
|
||||
gift_id bigint NOT NULL,
|
||||
saved_gift_id bigint NOT NULL REFERENCES public.peer_star_gifts(id) ON DELETE RESTRICT,
|
||||
unique_gift_id bigint NOT NULL REFERENCES public.unique_star_gifts(id) ON DELETE RESTRICT,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT star_gift_admin_grant_commands_pkey PRIMARY KEY (recipient_user_id, command_key),
|
||||
CONSTRAINT star_gift_admin_grant_command_saved_uniq UNIQUE (saved_gift_id),
|
||||
CONSTRAINT star_gift_admin_grant_command_unique_uniq UNIQUE (unique_gift_id),
|
||||
CONSTRAINT star_gift_admin_grant_command_shape_check CHECK (
|
||||
recipient_user_id > 0
|
||||
AND sender_user_id = 777000
|
||||
AND gift_id > 0
|
||||
AND char_length(command_key) BETWEEN 1 AND 256
|
||||
AND octet_length(request_fingerprint) = 32
|
||||
)
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue