owpengram-server/internal/store/postgres/account_lifecycle.go
2026-07-24 21:39:00 +08:00

741 lines
32 KiB
Go

package postgres
import (
"context"
"encoding/binary"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
// AccountLifecycleStore is the PostgreSQL implementation of the unified
// account tombstone, delayed deletion and deletion notification boundary.
type AccountLifecycleStore struct {
pool *pgxpool.Pool
}
func NewAccountLifecycleStore(pool *pgxpool.Pool) *AccountLifecycleStore {
return &AccountLifecycleStore{pool: pool}
}
func (s *AccountLifecycleStore) AccountDeletionSnapshot(ctx context.Context, userID int64) (domain.AccountDeletionSnapshot, bool, error) {
if s == nil || s.pool == nil || userID == 0 {
return domain.AccountDeletionSnapshot{}, false, nil
}
u, found, err := NewUserStore(s.pool).ByID(ctx, userID)
if err != nil || !found {
return domain.AccountDeletionSnapshot{}, found, err
}
var snapshot = domain.AccountDeletionSnapshot{User: u}
if err := s.pool.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM account_passwords WHERE user_id = $1 AND has_password),
COALESCE((SELECT password_changed_at FROM account_passwords WHERE user_id = $1), u.created_at)
FROM users u WHERE u.id = $1`, userID).Scan(&snapshot.HasPassword, &snapshot.PasswordUpdatedAt); err != nil {
return domain.AccountDeletionSnapshot{}, false, fmt.Errorf("load account deletion password facts: %w", err)
}
pending, ok, err := pendingAccountDeletion(ctx, s.pool, userID, nil)
if err != nil {
return domain.AccountDeletionSnapshot{}, false, err
}
if ok {
snapshot.Pending = &pending
}
return snapshot, true, nil
}
func (s *AccountLifecycleStore) ScheduleAccountDeletion(ctx context.Context, req domain.ScheduleAccountDeletion) (domain.AccountDeletionRequest, bool, error) {
if s == nil || s.pool == nil || req.UserID == 0 || req.RequestedAt.IsZero() || !req.ExecuteAt.After(req.RequestedAt) {
return domain.AccountDeletionRequest{}, false, domain.ErrAccountDeletionForbidden
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("begin schedule account deletion: %w", err)
}
defer tx.Rollback(ctx)
if err := lockUsersForUpdate(ctx, tx, req.UserID, domain.OfficialSystemUserID); err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("lock schedule account deletion users: %w", err)
}
var deletedAt *time.Time
if err := tx.QueryRow(ctx, `SELECT deleted_at FROM users WHERE id = $1 FOR UPDATE`, req.UserID).Scan(&deletedAt); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountDeletionRequest{}, false, domain.ErrUserNotFound
}
return domain.AccountDeletionRequest{}, false, fmt.Errorf("lock account deletion user: %w", err)
}
if deletedAt != nil {
return domain.AccountDeletionRequest{}, false, domain.ErrAccountDeleted
}
if existing, ok, err := pendingAccountDeletion(ctx, tx, req.UserID, nil); err != nil {
return domain.AccountDeletionRequest{}, false, err
} else if ok {
if err := tx.Commit(ctx); err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("commit existing account deletion: %w", err)
}
return existing, false, nil
}
row := tx.QueryRow(ctx, `
INSERT INTO account_deletion_requests (
user_id, requester_auth_key_id, reason, confirm_hash_digest, requested_at, execute_at
) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, user_id, requester_auth_key_id, state, reason, confirm_hash_digest,
requested_at, execute_at, completed_at`,
req.UserID, authKeyIDToInt64(req.RequesterAuthKeyID), req.Reason,
req.ConfirmHashDigest[:], req.RequestedAt, req.ExecuteAt)
pending, err := scanAccountDeletionRequest(row)
if err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("insert account deletion request: %w", err)
}
randomID := int64(binary.LittleEndian.Uint64(req.ConfirmHashDigest[:8]))
if randomID == 0 {
randomID = pending.ID
}
if _, err := NewMessageStore(tx).SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: domain.OfficialSystemUserID,
RecipientUserID: req.UserID,
RandomID: randomID,
Message: req.ServiceMessage,
Date: int(req.RequestedAt.Unix()),
}); err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("send account deletion confirmation message: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("commit schedule account deletion: %w", err)
}
return pending, true, nil
}
func (s *AccountLifecycleStore) PendingAccountDeletionByHash(ctx context.Context, userID int64, digest [32]byte) (domain.AccountDeletionRequest, bool, error) {
if s == nil || s.pool == nil || userID == 0 {
return domain.AccountDeletionRequest{}, false, nil
}
return pendingAccountDeletion(ctx, s.pool, userID, digest[:])
}
func (s *AccountLifecycleStore) ExecuteAccountDeletion(ctx context.Context, userID int64, source domain.AccountDeletionSource, reason string, now time.Time) (domain.AccountDeletionResult, error) {
if s == nil || s.pool == nil || userID == 0 || now.IsZero() || !validAccountDeletionSource(source) {
return domain.AccountDeletionResult{}, domain.ErrAccountDeletionForbidden
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("begin execute account deletion: %w", err)
}
defer tx.Rollback(ctx)
if err := lockUsersForUpdate(ctx, tx, userID); err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("lock account deletion user: %w", err)
}
var lockedID int64
if err := tx.QueryRow(ctx, `SELECT id FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&lockedID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountDeletionResult{}, domain.ErrUserNotFound
}
return domain.AccountDeletionResult{}, fmt.Errorf("lock account deletion row: %w", err)
}
u, found, err := NewUserStore(tx).ByID(ctx, userID)
if err != nil {
return domain.AccountDeletionResult{}, err
}
if !found {
return domain.AccountDeletionResult{}, domain.ErrUserNotFound
}
if u.Deleted {
return domain.AccountDeletionResult{User: u, Changed: false}, nil
}
if u.Bot || domain.IsSystemUserID(u.ID) {
return domain.AccountDeletionResult{}, domain.ErrAccountDeletionForbidden
}
due, err := accountDeletionStillDue(ctx, tx, u, source, now)
if err != nil {
return domain.AccountDeletionResult{}, err
}
if !due {
return domain.AccountDeletionResult{User: u, Changed: false}, nil
}
if err := enqueueAccountDeletionNotifications(ctx, tx, userID); err != nil {
return domain.AccountDeletionResult{}, err
}
if err := settleDeletedAccountFinancialState(ctx, tx, userID, now); err != nil {
return domain.AccountDeletionResult{}, err
}
revoked, err := revokeByUserExceptTx(ctx, tx, userID, 0)
if err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("revoke deleted account authorizations: %w", err)
}
if err := purgeDeletedAccountPrivateState(ctx, tx, userID, now); err != nil {
return domain.AccountDeletionResult{}, err
}
if err := replacePeerUsernameTx(ctx, tx, peerUsernameTypeUser, userID, ""); err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("release deleted account username: %w", err)
}
reason = strings.TrimSpace(reason)
reason = truncateUTF8Bytes(reason, 1024)
if _, err := tx.Exec(ctx, `
UPDATE users SET
phone = '', first_name = '', last_name = '', username = '', country_code = '', about = '',
verified = false, support = false, last_seen_at = 0,
premium_expires_at = NULL, emoji_status_document_id = 0, emoji_status_until = 0,
emoji_status_collectible_id = NULL, emoji_status_collectible = '{}'::jsonb,
color_set = false, color = 0, color_background_emoji_id = 0,
profile_color_set = false, profile_color = 0, profile_color_background_emoji_id = 0,
birthday_day = 0, birthday_month = 0, birthday_year = 0, personal_channel_id = 0,
deleted_at = $2, deletion_source = $3, deletion_reason = $4,
account_delete_at = NULL, updated_at = $2
WHERE id = $1 AND deleted_at IS NULL`, userID, now, string(source), reason); err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("write deleted account tombstone: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE account_deletion_requests
SET state = 'executed', completed_at = $2, updated_at = $2
WHERE user_id = $1 AND state = 'pending'`, userID, now); err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("complete account deletion request: %w", err)
}
u, found, err = NewUserStore(tx).ByID(ctx, userID)
if err != nil || !found {
if err == nil {
err = domain.ErrUserNotFound
}
return domain.AccountDeletionResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.AccountDeletionResult{}, fmt.Errorf("commit execute account deletion: %w", err)
}
return domain.AccountDeletionResult{User: u, Changed: true, RevokedAuthorizations: revoked}, nil
}
func (s *AccountLifecycleStore) CancelAccountDeletion(ctx context.Context, userID int64, digest [32]byte, now time.Time) ([]domain.Authorization, error) {
if s == nil || s.pool == nil || userID == 0 || now.IsZero() {
return nil, domain.ErrAccountDeletionHashInvalid
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, fmt.Errorf("begin cancel account deletion: %w", err)
}
defer tx.Rollback(ctx)
if err := lockUsersForUpdate(ctx, tx, userID); err != nil {
return nil, fmt.Errorf("lock cancel account deletion: %w", err)
}
pending, ok, err := pendingAccountDeletionForUpdate(ctx, tx, userID, digest[:])
if err != nil {
return nil, err
}
if !ok {
return nil, domain.ErrAccountDeletionHashInvalid
}
revoked, err := revokeOneAuthorizationTx(ctx, tx, userID, pending.RequesterAuthKeyID)
if err != nil {
return nil, err
}
if _, err := tx.Exec(ctx, `
UPDATE account_deletion_requests
SET state = 'cancelled', completed_at = $2, updated_at = $2
WHERE id = $1 AND state = 'pending'`, pending.ID, now); err != nil {
return nil, fmt.Errorf("cancel account deletion request: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("commit cancel account deletion: %w", err)
}
return revoked, nil
}
func (s *AccountLifecycleStore) DueAccountDeletions(ctx context.Context, now time.Time, limit int) ([]domain.AccountDeletionCandidate, error) {
if s == nil || s.pool == nil || limit <= 0 {
return nil, nil
}
rows, err := s.pool.Query(ctx, `
WITH candidates AS (
SELECT user_id, 'password_reset_expiry'::text AS source, execute_at AS due_at, 1 AS priority
FROM account_deletion_requests WHERE state = 'pending' AND execute_at <= $1
UNION ALL
SELECT id, 'account_ttl', account_delete_at, 2
FROM users WHERE deleted_at IS NULL AND is_bot = false AND account_delete_at <= $1
UNION ALL
SELECT r.user_id, 'freeze_expiry', r.frozen_until, 3
FROM account_restrictions r JOIN users u ON u.id = r.user_id
WHERE r.frozen = true AND r.frozen_until IS NOT NULL AND r.frozen_until <= $1
AND u.deleted_at IS NULL AND u.is_bot = false
), dedup AS (
SELECT DISTINCT ON (user_id) user_id, source, due_at
FROM candidates ORDER BY user_id, priority, due_at
)
SELECT user_id, source, due_at FROM dedup ORDER BY due_at, user_id LIMIT $2`, now, limit)
if err != nil {
return nil, fmt.Errorf("list due account deletions: %w", err)
}
defer rows.Close()
out := make([]domain.AccountDeletionCandidate, 0)
for rows.Next() {
var c domain.AccountDeletionCandidate
var source string
if err := rows.Scan(&c.UserID, &source, &c.DueAt); err != nil {
return nil, fmt.Errorf("scan due account deletion: %w", err)
}
c.Source = domain.AccountDeletionSource(source)
out = append(out, c)
}
return out, rows.Err()
}
func (s *AccountLifecycleStore) ClaimAccountDeletionNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountDeletionNotification, error) {
if s == nil || s.pool == nil || limit <= 0 || lease <= 0 {
return nil, nil
}
rows, err := s.pool.Query(ctx, `
WITH claim AS (
SELECT id FROM account_deletion_notifications
WHERE (status = 'pending' AND next_attempt_at <= $1)
OR (status = 'dispatching' AND lease_until <= $1)
ORDER BY next_attempt_at, id FOR UPDATE SKIP LOCKED LIMIT $2
)
UPDATE account_deletion_notifications n
SET status = 'dispatching', attempts = attempts + 1, lease_until = $3, updated_at = $1
FROM claim WHERE n.id = claim.id
RETURNING n.id, n.target_user_id, n.deleted_user_id, n.attempts`, now, limit, now.Add(lease))
if err != nil {
return nil, fmt.Errorf("claim account deletion notifications: %w", err)
}
defer rows.Close()
out := make([]domain.AccountDeletionNotification, 0)
for rows.Next() {
var n domain.AccountDeletionNotification
if err := rows.Scan(&n.ID, &n.TargetUserID, &n.DeletedUserID, &n.Attempts); err != nil {
return nil, fmt.Errorf("scan account deletion notification: %w", err)
}
out = append(out, n)
}
return out, rows.Err()
}
func (s *AccountLifecycleStore) CompleteAccountDeletionNotification(ctx context.Context, id int64, now time.Time) error {
_, err := s.pool.Exec(ctx, `UPDATE account_deletion_notifications
SET status = 'delivered', lease_until = NULL, last_error = '', updated_at = $2 WHERE id = $1`, id, now)
if err != nil {
return fmt.Errorf("complete account deletion notification: %w", err)
}
return nil
}
type accountDeletionRowScanner interface {
Scan(dest ...any) error
}
func scanAccountDeletionRequest(row accountDeletionRowScanner) (domain.AccountDeletionRequest, error) {
var (
r domain.AccountDeletionRequest
authKey int64
state string
digest []byte
completedAt *time.Time
)
if err := row.Scan(&r.ID, &r.UserID, &authKey, &state, &r.Reason, &digest,
&r.RequestedAt, &r.ExecuteAt, &completedAt); err != nil {
return domain.AccountDeletionRequest{}, err
}
if len(digest) != len(r.ConfirmHashDigest) {
return domain.AccountDeletionRequest{}, fmt.Errorf("invalid account deletion digest length %d", len(digest))
}
copy(r.ConfirmHashDigest[:], digest)
r.RequesterAuthKeyID = authKeyIDFromInt64(authKey)
r.State = domain.AccountDeletionRequestState(state)
if completedAt != nil {
r.CompletedAt = *completedAt
}
return r, nil
}
func pendingAccountDeletion(ctx context.Context, db interface {
QueryRow(context.Context, string, ...any) pgx.Row
}, userID int64, digest []byte) (domain.AccountDeletionRequest, bool, error) {
query := `SELECT id, user_id, requester_auth_key_id, state, reason, confirm_hash_digest,
requested_at, execute_at, completed_at FROM account_deletion_requests
WHERE user_id = $1 AND state = 'pending'`
args := []any{userID}
if digest != nil {
query += ` AND confirm_hash_digest = $2`
args = append(args, digest)
}
r, err := scanAccountDeletionRequest(db.QueryRow(ctx, query, args...))
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountDeletionRequest{}, false, nil
}
if err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("load pending account deletion: %w", err)
}
return r, true, nil
}
func pendingAccountDeletionForUpdate(ctx context.Context, tx pgx.Tx, userID int64, digest []byte) (domain.AccountDeletionRequest, bool, error) {
row := tx.QueryRow(ctx, `SELECT id, user_id, requester_auth_key_id, state, reason, confirm_hash_digest,
requested_at, execute_at, completed_at FROM account_deletion_requests
WHERE user_id = $1 AND confirm_hash_digest = $2 AND state = 'pending' FOR UPDATE`, userID, digest)
r, err := scanAccountDeletionRequest(row)
if errors.Is(err, pgx.ErrNoRows) {
return domain.AccountDeletionRequest{}, false, nil
}
if err != nil {
return domain.AccountDeletionRequest{}, false, fmt.Errorf("lock pending account deletion: %w", err)
}
return r, true, nil
}
func validAccountDeletionSource(source domain.AccountDeletionSource) bool {
switch source {
case domain.AccountDeletionManual, domain.AccountDeletionForgotPassword, domain.AccountDeletionTOSDecline,
domain.AccountDeletionPasswordResetExpiry, domain.AccountDeletionAccountTTL, domain.AccountDeletionFreezeExpiry:
return true
default:
return false
}
}
// accountDeletionStillDue closes the list-then-execute race for every scheduled
// source. The user row is already locked; source-specific facts are read and,
// where applicable, locked again immediately before destructive work begins.
// Manual sources are admission decisions made by the caller and have no
// independently mutable deadline to revalidate.
func accountDeletionStillDue(ctx context.Context, tx pgx.Tx, user domain.User, source domain.AccountDeletionSource, now time.Time) (bool, error) {
switch source {
case domain.AccountDeletionManual, domain.AccountDeletionForgotPassword, domain.AccountDeletionTOSDecline:
return true, nil
case domain.AccountDeletionPasswordResetExpiry:
var requestID int64
err := tx.QueryRow(ctx, `
SELECT id FROM account_deletion_requests
WHERE user_id = $1 AND state = 'pending' AND execute_at <= $2
ORDER BY execute_at, id LIMIT 1 FOR UPDATE`, user.ID, now).Scan(&requestID)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("revalidate pending account deletion: %w", err)
}
return true, nil
case domain.AccountDeletionAccountTTL:
return !user.AccountDeleteAt.IsZero() && !user.AccountDeleteAt.After(now), nil
case domain.AccountDeletionFreezeExpiry:
var frozen bool
var frozenUntil *time.Time
err := tx.QueryRow(ctx, `
SELECT frozen, frozen_until FROM account_restrictions WHERE user_id = $1 FOR UPDATE`, user.ID).Scan(&frozen, &frozenUntil)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("revalidate frozen account deletion: %w", err)
}
return frozen && frozenUntil != nil && !frozenUntil.After(now), nil
default:
return false, domain.ErrAccountDeletionForbidden
}
}
func truncateUTF8Bytes(value string, maxBytes int) string {
if maxBytes < 1 {
return ""
}
if len(value) <= maxBytes {
return value
}
cut := maxBytes
for cut > 0 && !utf8.ValidString(value[:cut]) {
cut--
}
return value[:cut]
}
func enqueueAccountDeletionNotifications(ctx context.Context, tx pgx.Tx, userID int64) error {
const maxAccountDeletionNotificationAudience = 4096
_, err := tx.Exec(ctx, `
INSERT INTO account_deletion_notifications (target_user_id, deleted_user_id)
SELECT audience.user_id, $1
FROM (
SELECT user_id
FROM (
SELECT contact_user_id AS user_id, 0 AS priority, 0 AS activity
FROM contacts WHERE user_id = $1
UNION ALL
SELECT user_id, 0, 0 FROM contacts WHERE contact_user_id = $1
UNION ALL
SELECT peer_id, 1, top_message_date
FROM dialogs WHERE user_id = $1 AND peer_type = 'user'
UNION ALL
SELECT user_id, 1, top_message_date
FROM dialogs WHERE peer_type = 'user' AND peer_id = $1
) candidates
GROUP BY user_id
ORDER BY min(priority), max(activity) DESC, user_id
LIMIT $2
) audience
JOIN users u ON u.id = audience.user_id
WHERE audience.user_id <> $1 AND u.deleted_at IS NULL
ON CONFLICT (target_user_id, deleted_user_id) DO NOTHING`, userID, maxAccountDeletionNotificationAudience)
if err != nil {
return fmt.Errorf("enqueue account deletion notifications: %w", err)
}
return nil
}
func revokeOneAuthorizationTx(ctx context.Context, tx pgx.Tx, userID int64, authKeyID [8]byte) ([]domain.Authorization, error) {
id := authKeyIDToInt64(authKeyID)
if id == 0 {
return nil, nil
}
if err := lockPermanentAuthIdentities(ctx, tx, []int64{id}); err != nil {
return nil, err
}
var locked int64
if err := tx.QueryRow(ctx, `SELECT auth_key_id FROM auth_keys WHERE auth_key_id = $1 FOR UPDATE`, id).Scan(&locked); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("lock password reset auth key: %w", err)
}
a, found, err := scanRevokedAuthorization(tx.QueryRow(ctx, `
SELECT auth_key_id, user_id, hash, layer, device_model, platform, system_version,
api_id, app_version, ip, password_pending, created_at, active_at
FROM authorizations WHERE auth_key_id = $1 AND user_id = $2 FOR UPDATE`, id, userID))
if err != nil {
return nil, fmt.Errorf("load password reset authorization: %w", err)
}
if !found {
return nil, nil
}
// Cancelling a pending account deletion deliberately retires the requester
// protocol identity; unlike remote device revocation, this path does not need
// to preserve the key for a client-visible RPC 401 transition.
if err := deleteProtocolAuthIdentitiesTx(ctx, tx, []int64{id}); err != nil {
return nil, err
}
return []domain.Authorization{a}, nil
}
func purgeDeletedAccountPrivateState(ctx context.Context, tx pgx.Tx, userID int64, now time.Time) error {
// Leave shared private_messages/channel_messages and immutable transaction
// ledgers intact. Only the deleted user's private projections and settings are
// removed; other users continue to reference the tombstone sender.
statements := []string{
`DELETE FROM account_privacy_rules WHERE owner_user_id = $1`,
`DELETE FROM account_reaction_settings WHERE user_id = $1`,
`DELETE FROM account_restrictions WHERE user_id = $1`,
`DELETE FROM account_settings WHERE user_id = $1`,
`DELETE FROM account_passwords WHERE user_id = $1`,
`DELETE FROM notify_settings WHERE owner_user_id = $1`,
`DELETE FROM passkey_credentials WHERE user_id = $1`,
`DELETE FROM contacts WHERE user_id = $1 OR contact_user_id = $1`,
`DELETE FROM contact_blocks WHERE owner_user_id = $1 OR blocked_user_id = $1`,
`DELETE FROM dialog_drafts WHERE user_id = $1`,
`DELETE FROM dialog_filter_settings WHERE user_id = $1`,
`DELETE FROM dialog_filters WHERE user_id = $1`,
`DELETE FROM chatlist_memberships WHERE user_id = $1 OR owner_user_id = $1`,
`DELETE FROM chatlist_invites WHERE owner_user_id = $1`,
`DELETE FROM saved_dialog_pins WHERE user_id = $1`,
`DELETE FROM message_box_media WHERE owner_user_id = $1`,
`DELETE FROM private_media_category_counts WHERE owner_user_id = $1`,
`DELETE FROM message_boxes WHERE owner_user_id = $1`,
`DELETE FROM dialogs WHERE user_id = $1`,
`DELETE FROM dispatch_outbox WHERE target_user_id = $1`,
`DELETE FROM dispatch_outbox_user_heads WHERE target_user_id = $1`,
`DELETE FROM user_update_events WHERE user_id = $1`,
`DELETE FROM user_update_retention WHERE user_id = $1`,
`DELETE FROM user_update_watermarks WHERE user_id = $1`,
`DELETE FROM update_states WHERE user_id = $1`,
`DELETE FROM bootstrap_update_jobs WHERE user_id = $1`,
`DELETE FROM scheduled_messages WHERE owner_user_id = $1`,
`DELETE FROM quick_reply_messages WHERE owner_user_id = $1`,
`DELETE FROM quick_replies WHERE owner_user_id = $1`,
`DELETE FROM saved_music WHERE user_id = $1`,
`DELETE FROM user_sticker_collections WHERE owner_user_id = $1`,
`DELETE FROM user_sticker_sets WHERE owner_user_id = $1`,
`DELETE FROM user_recent_reactions WHERE user_id = $1`,
`DELETE FROM user_saved_reaction_tags WHERE user_id = $1`,
`DELETE FROM user_top_reactions WHERE user_id = $1`,
`DELETE FROM theme_user_installs WHERE user_id = $1`,
`DELETE FROM peer_translation_settings WHERE user_id = $1`,
`DELETE FROM ai_compose_tone_saves WHERE user_id = $1`,
`DELETE FROM ai_compose_tones WHERE owner_user_id = $1`,
`DELETE FROM business_automation_deliveries WHERE owner_user_id = $1 OR peer_user_id = $1`,
`DELETE FROM business_connected_bot_peer_states WHERE owner_user_id = $1 OR peer_user_id = $1`,
`DELETE FROM business_connected_bots WHERE owner_user_id = $1`,
`DELETE FROM business_chat_links WHERE owner_user_id = $1`,
`DELETE FROM user_business_profiles WHERE user_id = $1`,
`DELETE FROM attach_menu_user_states WHERE user_id = $1`,
`DELETE FROM bot_emoji_status_permissions WHERE user_id = $1`,
`DELETE FROM bot_user_permissions WHERE user_id = $1`,
`DELETE FROM login_code_message_deliveries WHERE user_id = $1`,
`DELETE FROM webview_custom_method_queries WHERE user_id = $1`,
`DELETE FROM webview_requested_buttons WHERE user_id = $1`,
`DELETE FROM profile_photos WHERE owner_peer_type = 'user' AND owner_peer_id = $1`,
`DELETE FROM story_views WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`,
`DELETE FROM story_exposures WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`,
`DELETE FROM story_read_states WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`,
`DELETE FROM story_hidden_peers WHERE viewer_user_id = $1 OR (owner_peer_type = 'user' AND owner_peer_id = $1)`,
`DELETE FROM stories WHERE owner_peer_type = 'user' AND owner_peer_id = $1`,
`DELETE FROM group_call_schedule_subscribers WHERE user_id = $1`,
`DELETE FROM group_call_participants WHERE user_id = $1`,
`DELETE FROM group_call_invites WHERE inviter_user_id = $1 OR invitee_user_id = $1`,
`DELETE FROM channel_boost_slots WHERE user_id = $1`,
`DELETE FROM channel_invite_importers WHERE user_id = $1`,
`DELETE FROM channel_topic_read WHERE user_id = $1`,
`DELETE FROM channel_unread_mentions WHERE user_id = $1`,
`DELETE FROM channel_unread_mention_index WHERE user_id = $1`,
`DELETE FROM channel_dialogs WHERE user_id = $1`,
`DELETE FROM user_channel_member_index WHERE user_id = $1`,
`DELETE FROM account_deletion_notifications WHERE target_user_id = $1`,
`DELETE FROM uploaded_media_receipts WHERE owner_user_id = $1`,
`DELETE FROM upload_parts WHERE owner_user_id = $1`,
`DELETE FROM encrypted_files WHERE owner_user_id = $1`,
}
for _, statement := range statements {
if _, err := tx.Exec(ctx, statement, userID); err != nil {
return fmt.Errorf("purge deleted account private state (%s): %w", statement, err)
}
}
if _, err := tx.Exec(ctx, `
WITH changed AS (
UPDATE channel_members
SET status = 'left', left_at = $2, unread_mark = false, updated_at = $3
WHERE user_id = $1 AND status = 'active'
RETURNING channel_id, role
), counts AS (
SELECT channel_id, count(*) AS participants,
count(*) FILTER (WHERE role IN ('creator', 'admin')) AS admins
FROM changed GROUP BY channel_id
)
UPDATE channels c
SET participants_count = GREATEST(0, c.participants_count - counts.participants::int),
admins_count = GREATEST(0, c.admins_count - counts.admins::int),
updated_at = $3
FROM counts WHERE c.id = counts.channel_id`, userID, int(now.Unix()), now); err != nil {
return fmt.Errorf("leave deleted account channels: %w", err)
}
if _, err := tx.Exec(ctx, `
UPDATE secret_chats SET state = 'discarded', history_deleted = true,
g_a = ''::bytea, g_b = ''::bytea, key_fingerprint = 0
WHERE admin_user_id = $1 OR participant_user_id = $1`, userID); err != nil {
return fmt.Errorf("discard deleted account secret chats: %w", err)
}
return nil
}
func settleDeletedAccountFinancialState(ctx context.Context, tx pgx.Tx, userID int64, now time.Time) error {
nowUnix := int(now.Unix())
rows, err := tx.Query(ctx, `
SELECT id, buyer_user_id, currency, amount
FROM star_gift_offers
WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND status = 'pending'
ORDER BY id FOR UPDATE`, userID)
if err != nil {
return fmt.Errorf("lock deleted account gift offers: %w", err)
}
type offer struct {
id, buyer, amount int64
currency string
}
offers := make([]offer, 0)
for rows.Next() {
var o offer
if err := rows.Scan(&o.id, &o.buyer, &o.currency, &o.amount); err != nil {
rows.Close()
return fmt.Errorf("scan deleted account gift offer: %w", err)
}
offers = append(offers, o)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
for _, o := range offers {
var balance int64
if o.currency == "XTR" {
if err := tx.QueryRow(ctx, `
INSERT INTO stars_balances (user_id, balance) VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET balance = stars_balances.balance + EXCLUDED.balance, updated_at = now()
RETURNING balance`, o.buyer, o.amount).Scan(&balance); err != nil {
return fmt.Errorf("refund deleted account stars offer: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO stars_transactions
(user_id, peer_type, peer_id, amount, reason, title, description, date)
VALUES ($1, 'user', $2, $3, 'gift_offer_refund_account_deleted', 'Gift offer refunded', '', $4)`, o.buyer, userID, o.amount, nowUnix); err != nil {
return fmt.Errorf("record deleted account stars refund: %w", err)
}
} else {
if err := tx.QueryRow(ctx, `
INSERT INTO ton_balances (user_id, balance_nanoton) VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET balance_nanoton = ton_balances.balance_nanoton + EXCLUDED.balance_nanoton, updated_at = now()
RETURNING balance_nanoton`, o.buyer, o.amount).Scan(&balance); err != nil {
return fmt.Errorf("refund deleted account TON offer: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions
(user_id, amount_nanoton, reason, peer_type, peer_id, date)
VALUES ($1, $2, 'gift_offer_refund_account_deleted', 'user', $3, $4)`, o.buyer, o.amount, userID, nowUnix); err != nil {
return fmt.Errorf("record deleted account TON refund: %w", err)
}
}
if _, err := tx.Exec(ctx, `UPDATE star_gift_offers
SET status = 'cancelled', resolved_at = $2, balance_after = $3
WHERE id = $1 AND status = 'pending'`, o.id, nowUnix, balance); err != nil {
return fmt.Errorf("cancel deleted account gift offer: %w", err)
}
}
if _, err := tx.Exec(ctx, `UPDATE star_gift_offers
SET status = 'cancelled', resolved_at = $2, balance_after = 0
WHERE buyer_user_id = $1 AND status = 'pending'`, userID, nowUnix); err != nil {
return fmt.Errorf("cancel deleted buyer gift offers: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE star_gift_withdrawal_requests
SET status = 'failed', completed_at = $2 WHERE owner_user_id = $1 AND status = 'pending'`, userID, nowUnix); err != nil {
return fmt.Errorf("fail deleted account withdrawals: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE star_gift_auction_bids SET active = false, version = version + 1
WHERE bidder_user_id = $1 AND active = true`, userID); err != nil {
return fmt.Errorf("deactivate deleted account auction bids: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE unique_star_gifts
SET burned = true, owner_name = '', updated_at = $2
WHERE owner_peer_type = 'user' AND owner_peer_id = $1`, userID, now); err != nil {
return fmt.Errorf("burn deleted account unique gifts: %w", err)
}
if _, err := tx.Exec(ctx, `UPDATE peer_star_gifts
SET lifecycle_status = 'burned', unsaved = true, pinned_order = 0
WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND unique_gift_id IS NOT NULL`, userID); err != nil {
return fmt.Errorf("burn deleted account saved gifts: %w", err)
}
if _, err := tx.Exec(ctx, `DELETE FROM peer_star_gifts
WHERE owner_peer_type = 'user' AND owner_peer_id = $1 AND unique_gift_id IS NULL`, userID); err != nil {
return fmt.Errorf("delete deleted account regular gifts: %w", err)
}
var stars int64
if err := tx.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id = $1 FOR UPDATE`, userID).Scan(&stars); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("lock deleted account stars balance: %w", err)
}
if stars != 0 {
if _, err := tx.Exec(ctx, `UPDATE stars_balances SET balance = 0, updated_at = $2 WHERE user_id = $1`, userID, now); err != nil {
return fmt.Errorf("zero deleted account stars: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO stars_transactions
(user_id, peer_type, peer_id, amount, reason, title, description, date)
VALUES ($1, 'user', $1, $2, 'account_deleted', 'Account deleted', '', $3)`, userID, -stars, nowUnix); err != nil {
return fmt.Errorf("record deleted account stars clearing: %w", err)
}
}
var ton int64
if err := tx.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id = $1 FOR UPDATE`, userID).Scan(&ton); err != nil && !errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("lock deleted account TON balance: %w", err)
}
if ton != 0 {
if _, err := tx.Exec(ctx, `UPDATE ton_balances SET balance_nanoton = 0, updated_at = $2 WHERE user_id = $1`, userID, now); err != nil {
return fmt.Errorf("zero deleted account TON: %w", err)
}
if _, err := tx.Exec(ctx, `INSERT INTO ton_transactions
(user_id, amount_nanoton, reason, date) VALUES ($1, $2, 'account_deleted', $3)`, userID, -ton, nowUnix); err != nil {
return fmt.Errorf("record deleted account TON clearing: %w", err)
}
}
return nil
}