owpengram-server/deploy/migrations/20260714003067_account_lifecycle.up.sql
2026-07-21 00:18:12 +03:00

216 lines
8.2 KiB
PL/PgSQL

-- Unified account deletion lifecycle. A deleted account remains as a minimal
-- user tombstone so historical messages keep a stable sender id, while all
-- reusable identity and profile fields are released atomically.
ALTER TABLE public.users
ADD COLUMN deleted_at timestamp with time zone,
ADD COLUMN deletion_source text DEFAULT '' NOT NULL,
ADD COLUMN deletion_reason text DEFAULT '' NOT NULL,
ADD COLUMN account_delete_at timestamp with time zone;
ALTER TABLE public.account_passwords
ADD COLUMN password_changed_at timestamp with time zone;
ALTER TABLE public.account_settings
DROP CONSTRAINT account_settings_account_ttl_days_check,
ADD CONSTRAINT account_settings_account_ttl_days_check
CHECK (account_ttl_days BETWEEN 1 AND 3650);
CREATE OR REPLACE FUNCTION public.telesrv_password_changed_at_trigger()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
NEW.password_changed_at := CASE WHEN NEW.has_password THEN now() ELSE NULL END;
ELSIF NEW.has_password IS DISTINCT FROM OLD.has_password
OR NEW.srp_verifier IS DISTINCT FROM OLD.srp_verifier
OR NEW.current_algo_salt1 IS DISTINCT FROM OLD.current_algo_salt1
OR NEW.current_algo_salt2 IS DISTINCT FROM OLD.current_algo_salt2
OR NEW.current_algo_g IS DISTINCT FROM OLD.current_algo_g
OR NEW.current_algo_p IS DISTINCT FROM OLD.current_algo_p THEN
NEW.password_changed_at := CASE WHEN NEW.has_password THEN now() ELSE NULL END;
ELSE
NEW.password_changed_at := OLD.password_changed_at;
END IF;
RETURN NEW;
END
$$;
UPDATE public.account_passwords
SET password_changed_at = updated_at
WHERE has_password = true AND password_changed_at IS NULL;
CREATE TRIGGER account_passwords_changed_at_trigger
BEFORE INSERT OR UPDATE ON public.account_passwords
FOR EACH ROW EXECUTE FUNCTION public.telesrv_password_changed_at_trigger();
ALTER TABLE public.users
ADD CONSTRAINT users_deletion_state_check CHECK (
(deleted_at IS NULL AND deletion_source = '' AND deletion_reason = '')
OR
(deleted_at IS NOT NULL
AND deletion_source IN (
'manual',
'forgot_password',
'tos_decline',
'password_reset_expiry',
'account_ttl',
'freeze_expiry'
)
AND account_delete_at IS NULL
AND phone = ''
AND first_name = ''
AND last_name = ''
AND username = ''
AND country_code = ''
AND about = ''
AND verified = false
AND support = false
AND premium_expires_at IS NULL
AND emoji_status_document_id = 0
AND emoji_status_until = 0
AND color_set = false
AND color = 0
AND color_background_emoji_id = 0
AND profile_color_set = false
AND profile_color = 0
AND profile_color_background_emoji_id = 0
AND birthday_day = 0
AND birthday_month = 0
AND birthday_year = 0
AND personal_channel_id = 0
AND last_seen_at = 0
AND octet_length(deletion_reason) <= 1024)
);
CREATE INDEX users_account_delete_due_idx
ON public.users (account_delete_at, id)
WHERE deleted_at IS NULL AND is_bot = false AND account_delete_at IS NOT NULL;
CREATE TABLE public.account_deletion_requests (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL REFERENCES public.users(id),
requester_auth_key_id bigint NOT NULL,
state text DEFAULT 'pending' NOT NULL,
reason text DEFAULT '' NOT NULL,
confirm_hash_digest bytea NOT NULL,
requested_at timestamp with time zone DEFAULT now() NOT NULL,
execute_at timestamp with time zone NOT NULL,
completed_at timestamp with time zone,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT account_deletion_requests_state_check CHECK (
(state = 'pending' AND completed_at IS NULL)
OR (state IN ('cancelled', 'executed') AND completed_at IS NOT NULL)
),
CONSTRAINT account_deletion_requests_reason_check CHECK (octet_length(reason) <= 1024),
CONSTRAINT account_deletion_requests_hash_check CHECK (octet_length(confirm_hash_digest) = 32)
);
CREATE UNIQUE INDEX account_deletion_requests_one_pending_user_idx
ON public.account_deletion_requests(user_id) WHERE state = 'pending';
CREATE UNIQUE INDEX account_deletion_requests_pending_hash_idx
ON public.account_deletion_requests(confirm_hash_digest) WHERE state = 'pending';
CREATE INDEX account_deletion_requests_due_idx
ON public.account_deletion_requests(execute_at, id) WHERE state = 'pending';
-- updateUser is not a pts-bearing update. Keep a dedicated durable online-nudge
-- queue so a crash between tombstone commit and best-effort fan-out is recovered.
-- Offline clients converge from authoritative dialog/history hydration instead
-- of an immortal retry queue or invented user pts events.
CREATE TABLE public.account_deletion_notifications (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
target_user_id bigint NOT NULL REFERENCES public.users(id),
deleted_user_id bigint NOT NULL REFERENCES public.users(id),
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_deletion_notifications_status_check
CHECK (status IN ('pending', 'dispatching', 'delivered')),
CONSTRAINT account_deletion_notifications_attempts_check CHECK (attempts >= 0),
CONSTRAINT account_deletion_notifications_not_self_check CHECK (target_user_id <> deleted_user_id),
UNIQUE (target_user_id, deleted_user_id)
);
CREATE INDEX account_deletion_notifications_ready_idx
ON public.account_deletion_notifications(next_attempt_at, id)
WHERE status = 'pending';
-- Account deletion discovers peers that have an inbound dialog row pointing at
-- the deleted user. The ordinary dialog PK only covers the owner direction;
-- keep the reverse lookup indexed so tombstoning one account never scans every
-- user's dialog table.
CREATE INDEX dialogs_user_peer_reverse_idx
ON public.dialogs(peer_id, user_id)
WHERE peer_type = 'user';
CREATE OR REPLACE FUNCTION public.telesrv_account_delete_at(
p_created_at timestamp with time zone,
p_last_seen_at bigint,
p_ttl_days integer
) RETURNS timestamp with time zone
LANGUAGE sql IMMUTABLE AS $$
SELECT GREATEST(
p_created_at,
CASE WHEN p_last_seen_at > 0 THEN to_timestamp(p_last_seen_at) ELSE p_created_at END
) + make_interval(days => p_ttl_days)
$$;
CREATE OR REPLACE FUNCTION public.telesrv_users_account_delete_at_trigger()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
v_ttl_days integer;
BEGIN
IF NEW.deleted_at IS NOT NULL OR NEW.is_bot THEN
NEW.account_delete_at := NULL;
RETURN NEW;
END IF;
SELECT account_ttl_days INTO v_ttl_days
FROM public.account_settings WHERE user_id = NEW.id;
NEW.account_delete_at := public.telesrv_account_delete_at(
NEW.created_at,
NEW.last_seen_at,
COALESCE(v_ttl_days, 365)
);
RETURN NEW;
END
$$;
CREATE TRIGGER users_account_delete_at_trigger
BEFORE INSERT OR UPDATE OF last_seen_at, deleted_at, is_bot
ON public.users
FOR EACH ROW EXECUTE FUNCTION public.telesrv_users_account_delete_at_trigger();
CREATE OR REPLACE FUNCTION public.telesrv_account_settings_ttl_trigger()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
v_user_id bigint;
v_ttl_days integer;
BEGIN
v_user_id := COALESCE(NEW.user_id, OLD.user_id);
v_ttl_days := CASE WHEN TG_OP = 'DELETE' THEN 365 ELSE NEW.account_ttl_days END;
UPDATE public.users
SET account_delete_at = public.telesrv_account_delete_at(created_at, last_seen_at, v_ttl_days),
updated_at = now()
WHERE id = v_user_id AND deleted_at IS NULL AND is_bot = false;
RETURN COALESCE(NEW, OLD);
END
$$;
CREATE TRIGGER account_settings_ttl_trigger
AFTER INSERT OR UPDATE OF account_ttl_days OR DELETE
ON public.account_settings
FOR EACH ROW EXECUTE FUNCTION public.telesrv_account_settings_ttl_trigger();
UPDATE public.users u
SET account_delete_at = public.telesrv_account_delete_at(
u.created_at,
u.last_seen_at,
COALESCE((
SELECT s.account_ttl_days
FROM public.account_settings s
WHERE s.user_id = u.id
), 365)
)
WHERE u.deleted_at IS NULL AND u.is_bot = false;