feat: sync account deletion lifecycle

Sync telesrv 73f5c91 (feat(account): implement unified account deletion lifecycle).

Skipped telesrv docs changes per public sync rules.
This commit is contained in:
A 2026-07-19 20:35:58 +08:00
parent 96a419b565
commit edb7057757
32 changed files with 3236 additions and 88 deletions

View file

@ -0,0 +1,737 @@
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,
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
}
if err := deleteRevocationTargetsTx(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
}

View file

@ -0,0 +1,289 @@
package postgres
import (
"context"
"crypto/rand"
"crypto/sha256"
"fmt"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
"telesrv/internal/store"
)
func TestAccountLifecycleScheduleCancelAndTombstonePostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
nonce := time.Now().UnixNano()
users := NewUserStore(pool)
deleted := createTestUser(t, ctx, users, fmt.Sprintf("15571%d", nonce), "Delete", "Me")
peer := createTestUser(t, ctx, users, fmt.Sprintf("15572%d", nonce), "Keep", "Peer")
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM stars_transactions WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM ton_transactions WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM stars_balances WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM ton_balances WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_notifications WHERE target_user_id = ANY($1) OR deleted_user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_requests WHERE user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM private_messages WHERE sender_user_id = ANY($1) OR recipient_user_id = ANY($1)`, []int64{deleted.ID, peer.ID})
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = ANY($1)`, []int64{deleted.ID, peer.ID})
})
authOne := saveLifecycleTestAuthorization(t, ctx, pool, deleted.ID, 1)
authTwo := saveLifecycleTestAuthorization(t, ctx, pool, deleted.ID, 2)
if _, err := pool.Exec(ctx, `INSERT INTO contacts
(user_id, contact_user_id, contact_phone, contact_first_name, contact_last_name)
VALUES ($1, $2, 'stale-phone', 'Stale', 'Alias')`, peer.ID, deleted.ID); err != nil {
t.Fatalf("insert reverse contact: %v", err)
}
if _, err := NewMessageStore(pool).SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: deleted.ID, RecipientUserID: peer.ID, RandomID: nonce, Message: "keep shared history",
}); err != nil {
t.Fatalf("send shared message: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO account_settings (user_id, account_ttl_days) VALUES ($1, 30)`, deleted.ID); err != nil {
t.Fatalf("insert account settings: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO stars_balances (user_id, balance) VALUES ($1, 50)`, deleted.ID); err != nil {
t.Fatalf("insert stars balance: %v", err)
}
if _, err := pool.Exec(ctx, `INSERT INTO ton_balances (user_id, balance_nanoton) VALUES ($1, 100)`, deleted.ID); err != nil {
t.Fatalf("insert TON balance: %v", err)
}
lifecycle := NewAccountLifecycleStore(pool)
now := time.Now().UTC().Truncate(time.Second)
digestOne := sha256.Sum256([]byte("confirm-one"))
pending, created, err := lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{
UserID: deleted.ID, RequesterAuthKeyID: authOne, Reason: "Forgot password",
ConfirmHashDigest: digestOne, ServiceMessage: "tg://confirmphone?phone=hidden&hash=confirm-one",
RequestedAt: now, ExecuteAt: now.Add(7 * 24 * time.Hour),
})
if err != nil || !created || pending.UserID != deleted.ID {
t.Fatalf("schedule deletion = %+v created=%v err=%v", pending, created, err)
}
if got, found, err := lifecycle.PendingAccountDeletionByHash(ctx, deleted.ID, digestOne); err != nil || !found || got.ID != pending.ID {
t.Fatalf("pending deletion by hash = %+v found=%v err=%v", got, found, err)
}
revoked, err := lifecycle.CancelAccountDeletion(ctx, deleted.ID, digestOne, now.Add(time.Minute))
if err != nil || len(revoked) != 1 || revoked[0].AuthKeyID != authOne {
t.Fatalf("cancel deletion revoked=%+v err=%v", revoked, err)
}
if _, found, err := NewAuthKeyStore(pool).Get(ctx, authOne); err != nil || found {
t.Fatalf("requester auth key after cancel found=%v err=%v, want revoked", found, err)
}
if _, found, err := NewAuthKeyStore(pool).Get(ctx, authTwo); err != nil || !found {
t.Fatalf("other auth key after cancel found=%v err=%v, want retained", found, err)
}
result, err := lifecycle.ExecuteAccountDeletion(ctx, deleted.ID, domain.AccountDeletionManual, "manual", now.Add(2*time.Minute))
if err != nil {
t.Fatalf("execute account deletion: %v", err)
}
if !result.Changed || !result.User.Deleted || result.User.Phone != "" || result.User.FirstName != "" || len(result.RevokedAuthorizations) != 1 {
t.Fatalf("deletion result = %+v", result)
}
if _, found, err := users.ByPhone(ctx, deleted.Phone); err != nil || found {
t.Fatalf("released phone found=%v err=%v", found, err)
}
if tombstone, found, err := users.ByID(ctx, deleted.ID); err != nil || !found || !tombstone.Deleted || tombstone.FirstName != "" {
t.Fatalf("tombstone = %+v found=%v err=%v", tombstone, found, err)
}
if _, err := users.UpdateProfile(ctx, deleted.ID, "Resurrected", "", ""); err == nil {
t.Fatal("deleted account profile mutation unexpectedly succeeded")
}
history, err := NewMessageStore(pool).ListByUser(ctx, peer.ID, domain.MessageFilter{
HasPeer: true,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: deleted.ID},
Limit: 10,
})
if err != nil || len(history.Messages) != 1 || history.Messages[0].Body != "keep shared history" || history.Messages[0].From.ID != deleted.ID {
t.Fatalf("peer history after deletion = %+v err=%v", history, err)
}
var peerBoxes, settings, contacts, notifications int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM message_boxes WHERE owner_user_id = $1 AND from_user_id = $2`, peer.ID, deleted.ID).Scan(&peerBoxes); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM account_settings WHERE user_id = $1`, deleted.ID).Scan(&settings); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM contacts WHERE user_id = $1 OR contact_user_id = $1`, deleted.ID).Scan(&contacts); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM account_deletion_notifications WHERE target_user_id = $1 AND deleted_user_id = $2`, peer.ID, deleted.ID).Scan(&notifications); err != nil {
t.Fatal(err)
}
if peerBoxes != 1 || settings != 0 || contacts != 0 || notifications != 1 {
t.Fatalf("post-delete state peerBoxes=%d settings=%d contacts=%d notifications=%d", peerBoxes, settings, contacts, notifications)
}
var stars, ton, starClear, tonClear int64
if err := pool.QueryRow(ctx, `SELECT balance FROM stars_balances WHERE user_id = $1`, deleted.ID).Scan(&stars); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT balance_nanoton FROM ton_balances WHERE user_id = $1`, deleted.ID).Scan(&ton); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount), 0) FROM stars_transactions WHERE user_id = $1 AND reason = 'account_deleted'`, deleted.ID).Scan(&starClear); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT COALESCE(sum(amount_nanoton), 0) FROM ton_transactions WHERE user_id = $1 AND reason = 'account_deleted'`, deleted.ID).Scan(&tonClear); err != nil {
t.Fatal(err)
}
if stars != 0 || ton != 0 || starClear != -50 || tonClear != -100 {
t.Fatalf("financial clearing stars=%d ton=%d star_tx=%d ton_tx=%d", stars, ton, starClear, tonClear)
}
}
func TestAccountLifecycleDueSourcesAndTTLWatermarkPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
nonce := time.Now().UnixNano()
users := NewUserStore(pool)
ttlUser := createTestUser(t, ctx, users, fmt.Sprintf("15671%d", nonce), "TTL", "User")
freezeUser := createTestUser(t, ctx, users, fmt.Sprintf("15672%d", nonce), "Frozen", "User")
pendingUser := createTestUser(t, ctx, users, fmt.Sprintf("15673%d", nonce), "Pending", "User")
ids := []int64{ttlUser.ID, freezeUser.ID, pendingUser.ID}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_notifications WHERE target_user_id = ANY($1) OR deleted_user_id = ANY($1)`, ids)
_, _ = pool.Exec(ctx, `DELETE FROM account_deletion_requests WHERE user_id = ANY($1)`, ids)
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = ANY($1)`, ids)
})
now := time.Now().UTC().Truncate(time.Second)
if _, err := pool.Exec(ctx, `UPDATE users SET account_delete_at = $2 WHERE id = $1`, ttlUser.ID, now.Add(-time.Minute)); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `INSERT INTO account_restrictions
(user_id, frozen, reason, actor, command_id, frozen_since, frozen_until, appeal_url)
VALUES ($1, true, 'abuse', 'test', 'freeze-test', $2, $3, 'https://example.test/appeal')`, freezeUser.ID, now.Add(-time.Hour), now.Add(-time.Minute)); err != nil {
t.Fatal(err)
}
digest := sha256.Sum256([]byte("due-pending"))
if _, err := pool.Exec(ctx, `INSERT INTO account_deletion_requests
(user_id, requester_auth_key_id, reason, confirm_hash_digest, requested_at, execute_at)
VALUES ($1, 123, 'forgot', $2, $3, $4)`, pendingUser.ID, digest[:], now.Add(-8*24*time.Hour), now.Add(-time.Minute)); err != nil {
t.Fatal(err)
}
candidates, err := NewAccountLifecycleStore(pool).DueAccountDeletions(ctx, now, 10)
if err != nil {
t.Fatal(err)
}
sources := make(map[int64]domain.AccountDeletionSource, len(candidates))
for _, candidate := range candidates {
sources[candidate.UserID] = candidate.Source
}
if sources[ttlUser.ID] != domain.AccountDeletionAccountTTL || sources[freezeUser.ID] != domain.AccountDeletionFreezeExpiry || sources[pendingUser.ID] != domain.AccountDeletionPasswordResetExpiry {
t.Fatalf("due sources = %+v", sources)
}
seen := now.Add(time.Hour)
if err := users.UpdateLastSeen(ctx, ttlUser.ID, int(seen.Unix())); err != nil {
t.Fatal(err)
}
lifecycle := NewAccountLifecycleStore(pool)
if stale, err := lifecycle.ExecuteAccountDeletion(ctx, ttlUser.ID, domain.AccountDeletionAccountTTL, "", now); err != nil || stale.Changed {
t.Fatalf("stale TTL candidate changed=%v err=%v", stale.Changed, err)
}
if _, err := pool.Exec(ctx, `UPDATE account_restrictions SET frozen_until = $2, updated_at = $3 WHERE user_id = $1`, freezeUser.ID, now.Add(24*time.Hour), now); err != nil {
t.Fatal(err)
}
if stale, err := lifecycle.ExecuteAccountDeletion(ctx, freezeUser.ID, domain.AccountDeletionFreezeExpiry, "", now); err != nil || stale.Changed {
t.Fatalf("extended freeze candidate changed=%v err=%v", stale.Changed, err)
}
if _, err := pool.Exec(ctx, `UPDATE account_deletion_requests SET state = 'cancelled', completed_at = $2, updated_at = $2 WHERE user_id = $1`, pendingUser.ID, now); err != nil {
t.Fatal(err)
}
if stale, err := lifecycle.ExecuteAccountDeletion(ctx, pendingUser.ID, domain.AccountDeletionPasswordResetExpiry, "", now); err != nil || stale.Changed {
t.Fatalf("cancelled pending candidate changed=%v err=%v", stale.Changed, err)
}
var deadline time.Time
if err := pool.QueryRow(ctx, `SELECT account_delete_at FROM users WHERE id = $1`, ttlUser.ID).Scan(&deadline); err != nil {
t.Fatal(err)
}
if want := seen.Add(365 * 24 * time.Hour); deadline.Sub(want) > time.Second || want.Sub(deadline) > time.Second {
t.Fatalf("TTL watermark deadline=%v want=%v", deadline, want)
}
if _, err := pool.Exec(ctx, `INSERT INTO account_settings (user_id, account_ttl_days) VALUES ($1, 30)
ON CONFLICT (user_id) DO UPDATE SET account_ttl_days = EXCLUDED.account_ttl_days`, ttlUser.ID); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT account_delete_at FROM users WHERE id = $1`, ttlUser.ID).Scan(&deadline); err != nil {
t.Fatal(err)
}
if want := seen.Add(30 * 24 * time.Hour); deadline.Sub(want) > time.Second || want.Sub(deadline) > time.Second {
t.Fatalf("custom TTL deadline=%v want=%v", deadline, want)
}
}
func TestAccountPasswordChangedAtIgnoresSRPChallengeRotationPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
user := createTestUser(t, ctx, NewUserStore(pool), fmt.Sprintf("15771%d", time.Now().UnixNano()), "Password", "Clock")
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM users WHERE id = $1`, user.ID) })
if _, err := pool.Exec(ctx, `INSERT INTO account_passwords
(user_id, has_password, current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p, srp_verifier, srp_id, srp_b)
VALUES ($1, true, '\x01', '\x02', 3, '\x03', '\x04', 10, '\x05')`, user.ID); err != nil {
t.Fatal(err)
}
var initial, afterChallenge, afterPassword time.Time
if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&initial); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `SELECT pg_sleep(0.02)`); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `UPDATE account_passwords SET srp_id = 11, srp_b = '\x06', updated_at = now() WHERE user_id = $1`, user.ID); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&afterChallenge); err != nil {
t.Fatal(err)
}
if !afterChallenge.Equal(initial) {
t.Fatalf("SRP challenge rotation changed password clock: initial=%v after=%v", initial, afterChallenge)
}
if _, err := pool.Exec(ctx, `SELECT pg_sleep(0.02)`); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `UPDATE account_passwords SET srp_verifier = '\x07', updated_at = now() WHERE user_id = $1`, user.ID); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT password_changed_at FROM account_passwords WHERE user_id = $1`, user.ID).Scan(&afterPassword); err != nil {
t.Fatal(err)
}
if !afterPassword.After(afterChallenge) {
t.Fatalf("password verifier change did not advance clock: before=%v after=%v", afterChallenge, afterPassword)
}
}
func TestTruncateAccountDeletionReasonUTF8(t *testing.T) {
got := truncateUTF8Bytes(strings.Repeat("界", 400), 1024)
if !utf8.ValidString(got) || len(got) > 1024 {
t.Fatalf("truncateUTF8Bytes returned invalid result: valid=%v bytes=%d", utf8.ValidString(got), len(got))
}
if got == "" {
t.Fatal("truncateUTF8Bytes unexpectedly removed the whole reason")
}
}
func saveLifecycleTestAuthorization(t *testing.T, ctx context.Context, db *pgxpool.Pool, userID int64, marker byte) [8]byte {
t.Helper()
var id [8]byte
var value [256]byte
if _, err := rand.Read(id[:]); err != nil {
t.Fatal(err)
}
id[0] = marker
if _, err := rand.Read(value[:]); err != nil {
t.Fatal(err)
}
if err := NewAuthKeyStore(db).Save(ctx, store.AuthKeyData{ID: id, Value: value}); err != nil {
t.Fatalf("save lifecycle auth key: %v", err)
}
if err := NewAuthorizationStore(db).Bind(ctx, domain.Authorization{AuthKeyID: id, UserID: userID, Hash: int64(marker)}); err != nil {
t.Fatalf("bind lifecycle authorization: %v", err)
}
return id
}

View file

@ -46,7 +46,7 @@ func (s *MessageStore) DefaultHistoryTTL(ctx context.Context, userID int64) (int
return 0, nil
}
var period int
err := s.db.QueryRow(ctx, `SELECT COALESCE(default_history_ttl_period, 0)::int FROM users WHERE id = $1`, userID).Scan(&period)
err := s.db.QueryRow(ctx, `SELECT COALESCE(default_history_ttl_period, 0)::int FROM users WHERE id = $1 AND deleted_at IS NULL`, userID).Scan(&period)
if errors.Is(err, pgx.ErrNoRows) {
return 0, nil
}
@ -70,7 +70,7 @@ func (s *MessageStore) SetDefaultHistoryTTL(ctx context.Context, userID int64, p
UPDATE users
SET default_history_ttl_period = $2,
updated_at = now()
WHERE id = $1
WHERE id = $1 AND deleted_at IS NULL
`, userID, period)
if err != nil {
return fmt.Errorf("set default history ttl: %w", err)

View file

@ -8,16 +8,16 @@ WHERE id = ANY(sqlc.arg(ids)::bigint[])
ORDER BY id;
-- name: GetUserByPhone :one
SELECT * FROM users WHERE phone = $1;
SELECT * FROM users WHERE phone = $1 AND deleted_at IS NULL;
-- name: GetUsersByPhones :many
SELECT *
FROM users
WHERE phone = ANY(sqlc.arg(phones)::text[])
WHERE phone = ANY(sqlc.arg(phones)::text[]) AND deleted_at IS NULL
ORDER BY id;
-- name: GetUserByUsername :one
SELECT * FROM users WHERE lower(username) = lower($1) AND username <> '';
SELECT * FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL;
-- name: SearchUsers :many
WITH matched AS (
@ -57,6 +57,7 @@ WITH matched AS (
FROM users u
LEFT JOIN contacts c ON c.user_id = sqlc.arg(current_user_id)::bigint AND c.contact_user_id = u.id
WHERE u.id <> sqlc.arg(current_user_id)::bigint
AND u.deleted_at IS NULL
AND sqlc.arg(query_lower)::text <> ''
AND (
(sqlc.arg(phone_query)::text <> '' AND u.phone LIKE sqlc.arg(phone_query)::text || '%')
@ -107,14 +108,14 @@ RETURNING *;
UPDATE users
SET username = $2,
updated_at = now()
WHERE id = $1
WHERE id = $1 AND deleted_at IS NULL
RETURNING *;
-- name: UpdateUserLastSeen :exec
UPDATE users
SET last_seen_at = GREATEST(last_seen_at, sqlc.arg(last_seen_at)::bigint),
updated_at = now()
WHERE id = sqlc.arg(id)::bigint;
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL;
-- name: UpdateUserProfile :one
UPDATE users
@ -122,28 +123,28 @@ SET first_name = $2,
last_name = $3,
about = $4,
updated_at = now()
WHERE id = $1
WHERE id = $1 AND deleted_at IS NULL
RETURNING *;
-- name: UpdateUserPhone :one
UPDATE users
SET phone = sqlc.arg(phone)::text,
updated_at = now()
WHERE id = sqlc.arg(id)::bigint
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;
-- name: SetUserPremiumUntil :one
UPDATE users
SET premium_expires_at = sqlc.narg(premium_expires_at)::timestamptz,
updated_at = now()
WHERE id = sqlc.arg(id)::bigint
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;
-- name: SetUserVerified :one
UPDATE users
SET verified = sqlc.arg(verified)::boolean,
updated_at = now()
WHERE id = sqlc.arg(id)::bigint
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;
-- name: SweepExpiredPremium :many
@ -153,6 +154,7 @@ SET premium_expires_at = NULL,
WHERE id IN (
SELECT id FROM users
WHERE premium_expires_at IS NOT NULL
AND deleted_at IS NULL
AND premium_expires_at <= sqlc.arg(now)::timestamptz
ORDER BY premium_expires_at
LIMIT sqlc.arg(limit_count)::int
@ -164,7 +166,7 @@ UPDATE users
SET emoji_status_document_id = sqlc.arg(emoji_status_document_id)::bigint,
emoji_status_until = sqlc.arg(emoji_status_until)::bigint,
updated_at = now()
WHERE id = sqlc.arg(id)::bigint
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;
-- name: UpdateUserBirthday :one
@ -173,14 +175,14 @@ SET birthday_day = sqlc.arg(birthday_day)::int,
birthday_month = sqlc.arg(birthday_month)::int,
birthday_year = sqlc.arg(birthday_year)::int,
updated_at = now()
WHERE id = sqlc.arg(id)::bigint
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;
-- name: UpdateUserPersonalChannel :one
UPDATE users
SET personal_channel_id = sqlc.arg(personal_channel_id)::bigint,
updated_at = now()
WHERE id = sqlc.arg(id)::bigint
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;
-- name: UpdateUserColor :one
@ -189,7 +191,7 @@ SET color_set = sqlc.arg(color_set)::boolean,
color = sqlc.arg(color)::int,
color_background_emoji_id = sqlc.arg(background_emoji_id)::bigint,
updated_at = now()
WHERE id = sqlc.arg(id)::bigint
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;
-- name: UpdateUserProfileColor :one
@ -198,5 +200,5 @@ SET profile_color_set = sqlc.arg(color_set)::boolean,
profile_color = sqlc.arg(color)::int,
profile_color_background_emoji_id = sqlc.arg(background_emoji_id)::bigint,
updated_at = now()
WHERE id = sqlc.arg(id)::bigint
WHERE id = sqlc.arg(id)::bigint AND deleted_at IS NULL
RETURNING *;

View file

@ -167,7 +167,7 @@ func (q *Queries) InsertBot(ctx context.Context, arg InsertBotParams) error {
const insertBotUser = `-- name: InsertBotUser :one
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, is_bot, bot_info_version)
VALUES ($1, '', $2, '', $3, '', TRUE, 1)
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
`
type InsertBotUserParams struct {
@ -209,6 +209,10 @@ func (q *Queries) InsertBotUser(ctx context.Context, arg InsertBotUserParams) (U
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
)
return i, err
}

View file

@ -8,6 +8,32 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
type AccountDeletionNotification struct {
ID int64
TargetUserID int64
DeletedUserID int64
Status string
Attempts int32
NextAttemptAt pgtype.Timestamptz
LeaseUntil pgtype.Timestamptz
LastError string
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type AccountDeletionRequest struct {
ID int64
UserID int64
RequesterAuthKeyID int64
State string
Reason string
ConfirmHashDigest []byte
RequestedAt pgtype.Timestamptz
ExecuteAt pgtype.Timestamptz
CompletedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type AccountPassword struct {
UserID int64
HasRecovery bool
@ -30,6 +56,7 @@ type AccountPassword struct {
RecoveryCode string
RecoveryCodeExpiresAt pgtype.Timestamptz
LoginEmail string
PasswordChangedAt pgtype.Timestamptz
}
type AccountPrivacyRule struct {
@ -694,6 +721,42 @@ type ChannelMessageViewer struct {
CreatedAt pgtype.Timestamptz
}
type ChannelStarsBalance struct {
ChannelID int64
Balance int64
UpdatedAt pgtype.Timestamptz
}
type ChannelStarsTransaction struct {
ID int64
ChannelID int64
ActorUserID int64
Amount int64
Reason string
PeerType string
PeerID int64
GiftID *int64
Date int32
}
type ChannelTonBalance struct {
ChannelID int64
BalanceNanoton int64
UpdatedAt pgtype.Timestamptz
}
type ChannelTonTransaction struct {
ID int64
ChannelID int64
ActorUserID int64
AmountNanoton int64
Reason string
PeerType string
PeerID int64
GiftID *int64
Date int32
}
type ChannelTopicRead struct {
ChannelID int64
UserID int64
@ -1156,19 +1219,33 @@ type PasskeyCredential struct {
}
type PeerStarGift struct {
ID int64
OwnerPeerID int64
FromUserID int64
GiftID int64
MsgID int32
GiftDate int32
NameHidden bool
Unsaved bool
Converted bool
ConvertStars int64
Message string
OwnerPeerType string
SavedID int64
ID int64
OwnerPeerID int64
FromUserID int64
GiftID int64
MsgID int32
GiftDate int32
NameHidden bool
Unsaved bool
Converted bool
ConvertStars int64
Message string
OwnerPeerType string
SavedID int64
CatalogRevisionID int64
UniqueGiftID *int64
UpgradeMsgID int32
PinnedOrder int32
PrepaidUpgradeStars int64
LifecycleStatus string
TransferStars int64
PrepaidUpgradeHash string
GiftNum int32
CanExportAt int32
CanTransferAt int32
CanResellAt int32
DropOriginalDetailsStars int64
CanCraftAt int32
}
type PeerTranslationSetting struct {
@ -1417,6 +1494,382 @@ type SeedState struct {
UpdatedAt pgtype.Timestamptz
}
type StarGiftAuction struct {
GiftID int64
Slug string
Version int32
StartDate int32
EndDate int32
RoundDuration int32
GiftsPerRound int32
TotalRounds int32
CurrentRound int32
NextRoundAt int32
LastGiftNum int32
GiftsLeft int32
MinBidAmount int64
Status string
UpdatedAt pgtype.Timestamptz
}
type StarGiftAuctionAcquired struct {
ID int64
GiftID int64
BidderUserID int64
RecipientPeerType string
RecipientPeerID int64
SavedGiftID *int64
BidAmount int64
Round int32
Pos int32
GiftNum *int32
AcquiredAt int32
HideName bool
Message string
}
type StarGiftAuctionBid struct {
GiftID int64
BidderUserID int64
RecipientPeerType string
RecipientPeerID int64
Amount int64
BidDate int32
HideName bool
Message string
Returned bool
AcquiredCount int32
Active bool
Version int64
}
type StarGiftAuctionBidPayment struct {
UserID int64
FormID int64
GiftID int64
BidAmount int64
BalanceAfter int64
CreatedAt int32
}
type StarGiftCatalog struct {
GiftID int64
ActiveRevisionID int64
Enabled bool
SortOrder int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
CollectibleRevisionID *int64
AvailabilityRemains int32
AvailabilityResale int64
ResellMinStars int64
FirstSaleDate int32
LastSaleDate int32
}
type StarGiftCatalogRevision struct {
ID int64
GiftID int64
Revision int32
Title string
Stars int64
ConvertStars int64
DocumentID int64
AnimationJson []byte
AnimationSha256 []byte
SourceName string
SourceFormat string
Width int32
Height int32
FrameRate float64
InPoint float64
OutPoint float64
CreatedBy string
CommandID string
CreatedAt pgtype.Timestamptz
OfficialGiftID *int64
SourceManifestSha256 []byte
OfficialSource []byte
Limited bool
SoldOut bool
Birthday bool
RequirePremium bool
LimitedPerUser bool
PeerColorAvailable bool
Auction bool
AvailabilityTotal int32
ReleasedByPeerType *string
ReleasedByPeerID *int64
PerUserTotal int32
LockedUntilDate int32
AuctionSlug string
GiftsPerRound int32
AuctionStartDate int32
UpgradeVariants int32
BackgroundCenterColor *int32
BackgroundEdgeColor *int32
BackgroundTextColor *int32
}
type StarGiftCollectibleBackdrop struct {
ID int64
CollectibleRevisionID int64
Name string
BackdropID int32
CenterColor int32
EdgeColor int32
PatternColor int32
TextColor int32
RarityPermille *int32
SortOrder int32
RarityKind string
}
type StarGiftCollectibleModel struct {
ID int64
CollectibleRevisionID int64
Name string
DocumentID int64
AnimationJson []byte
AnimationSha256 []byte
SourceName string
SourceFormat string
Width int32
Height int32
FrameRate float64
InPoint float64
OutPoint float64
RarityPermille *int32
SortOrder int32
RarityKind string
Crafted bool
OfficialDocumentID *int64
}
type StarGiftCollectiblePattern struct {
ID int64
CollectibleRevisionID int64
Name string
DocumentID int64
AnimationJson []byte
AnimationSha256 []byte
SourceName string
SourceFormat string
Width int32
Height int32
FrameRate float64
InPoint float64
OutPoint float64
RarityPermille *int32
SortOrder int32
RarityKind string
OfficialDocumentID *int64
}
type StarGiftCollectibleRevision struct {
ID int64
GiftID int64
Revision int32
UpgradeStars int64
SupplyTotal int32
Issued int32
SlugPrefix string
Status string
CreatedBy string
CommandID string
CreatedAt pgtype.Timestamptz
PublishedAt pgtype.Timestamptz
OfficialGiftID *int64
SourceManifestSha256 []byte
}
type StarGiftCollection struct {
CollectionID int32
OwnerPeerType string
OwnerPeerID int64
Title string
SortOrder int32
Hash int64
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
type StarGiftCollectionItem struct {
CollectionID int32
SavedGiftID int64
SortOrder int32
CreatedAt pgtype.Timestamptz
}
type StarGiftConversion struct {
SavedGiftID int64
ActorUserID int64
OwnerPeerType string
OwnerPeerID int64
Amount int64
BalanceAfter int64
ConvertedAt int32
}
type StarGiftCraftCommand struct {
UserID int64
CommandKey string
InputUniqueGiftIds []int64
GiftID int64
Success bool
ResultUniqueGiftID *int64
ChancePermille int32
CreatedAt int32
SourceEditPts []int32
}
type StarGiftDropDetailsCommand struct {
UserID int64
CommandKey string
SavedGiftID int64
UniqueGiftID int64
FormID int64
ChargeStars int64
BalanceAfter int64
CreatedAt int32
}
type StarGiftListing struct {
UniqueGiftID int64
SellerPeerType string
SellerPeerID int64
Currency string
Amount int64
ListedAt int32
UpdatedAt int32
Version int64
}
type StarGiftNotificationSetting struct {
UserID int64
ChannelID int64
Enabled bool
UpdatedAt pgtype.Timestamptz
}
type StarGiftOffer struct {
ID int64
BuyerUserID int64
OwnerPeerType string
OwnerPeerID int64
UniqueGiftID int64
Currency string
Amount int64
RandomID int64
OfferMsgID int32
BuyerMsgID int32
Status string
CreatedAt int32
ExpiresAt int32
ResolvedAt int32
BalanceAfter int64
ResolutionNotified bool
}
type StarGiftPrepaidUpgradeCommand struct {
PayerUserID int64
CommandKey string
SavedGiftID int64
FormID int64
ChargeStars int64
BalanceAfter int64
CreatedAt int32
}
type StarGiftPurchaseCommand struct {
BuyerUserID int64
CommandKey string
GiftID int64
RecipientPeerType string
RecipientPeerID int64
SavedGiftID int64
FormID int64
ChargeStars int64
BalanceAfter int64
CreatedAt int32
}
type StarGiftPurchaseForm struct {
BuyerUserID int64
FormID int64
GiftID int64
RevisionID int64
RecipientPeerType string
RecipientPeerID int64
IncludeUpgrade bool
HideName bool
Message string
ChargeStars int64
IssuedAt int32
ExpiresAt int32
}
type StarGiftSale struct {
ID int64
UniqueGiftID int64
SellerPeerType string
SellerPeerID int64
BuyerPeerType string
BuyerPeerID int64
Currency string
Amount int64
CommissionAmount int64
SoldAt int32
CommandKey string
}
type StarGiftTransferCommand struct {
ActorUserID int64
CommandKey string
UniqueGiftID int64
FromPeerType string
FromPeerID int64
ToPeerType string
ToPeerID int64
ChargeStars int64
BalanceAfter int64
CreatedAt int32
}
type StarGiftUpgradeCommand struct {
UserID int64
CommandKey string
SourceSavedGiftID int64
FormID int64
UniqueGiftID int64
BalanceAfter int64
CreatedAt pgtype.Timestamptz
ChargeStars int64
RequirePrepaid bool
KeepOriginalDetails bool
SourceEditPts int32
}
type StarGiftUserPurchase struct {
UserID int64
GiftID int64
PurchasedCount int32
UpdatedAt pgtype.Timestamptz
}
type StarGiftWithdrawalRequest struct {
ID int64
UniqueGiftID int64
OwnerUserID int64
Provider string
ProviderRequestID string
Url string
Status string
CreatedAt int32
ExpiresAt int32
CompletedAt int32
}
type StarsBalance struct {
UserID int64
Balance int64
@ -1564,6 +2017,66 @@ type ThemeUserInstall struct {
InstalledAt pgtype.Timestamptz
}
type TonBalance struct {
UserID int64
BalanceNanoton int64
Granted bool
UpdatedAt pgtype.Timestamptz
}
type TonTransaction struct {
ID int64
UserID int64
AmountNanoton int64
Reason string
PeerType *string
PeerID *int64
GiftID *int64
Date int32
}
type UniqueStarGift struct {
ID int64
GiftID int64
CollectibleRevisionID int64
SourceSavedGiftID int64
Title string
Slug string
Num int32
OwnerPeerType *string
OwnerPeerID *int64
ModelAttributeID int64
PatternAttributeID int64
BackdropAttributeID int64
KeepOriginalDetails bool
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
RequirePremium bool
ResaleTonOnly bool
ThemeAvailable bool
Burned bool
Crafted bool
OriginalOwnerPeerType string
OriginalOwnerPeerID int64
OwnerName string
OwnerAddress string
GiftAddress string
ReleasedByPeerType *string
ReleasedByPeerID *int64
ValueAmount int64
ValueCurrency string
ValueUsdAmount int64
ThemePeerType *string
ThemePeerID *int64
HostPeerType *string
HostPeerID *int64
OfferMinStars int32
CraftChancePermille int32
LastSaleDate int32
LastSaleCurrency string
LastSaleAmount int64
}
type UpdateState struct {
AuthKeyID int64
Pts int32
@ -1627,6 +2140,10 @@ type User struct {
BirthdayMonth int32
BirthdayYear int32
PersonalChannelID int64
DeletedAt pgtype.Timestamptz
DeletionSource string
DeletionReason string
AccountDeleteAt pgtype.Timestamptz
}
type UserBusinessProfile struct {

View file

@ -14,7 +14,7 @@ import (
const createUser = `-- name: CreateUser :one
INSERT INTO users (access_hash, phone, first_name, last_name, username, country_code, premium_expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
`
type CreateUserParams struct {
@ -68,12 +68,16 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
)
return i, err
}
const getUserByID = `-- name: GetUserByID :one
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id FROM users WHERE id = $1
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at FROM users WHERE id = $1
`
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
@ -109,12 +113,16 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
)
return i, err
}
const getUserByPhone = `-- name: GetUserByPhone :one
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id FROM users WHERE phone = $1
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at FROM users WHERE phone = $1 AND deleted_at IS NULL
`
func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error) {
@ -150,12 +158,16 @@ func (q *Queries) GetUserByPhone(ctx context.Context, phone string) (User, error
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
)
return i, err
}
const getUserByUsername = `-- name: GetUserByUsername :one
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id FROM users WHERE lower(username) = lower($1) AND username <> ''
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at FROM users WHERE lower(username) = lower($1) AND username <> '' AND deleted_at IS NULL
`
func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, error) {
@ -191,12 +203,16 @@ func (q *Queries) GetUserByUsername(ctx context.Context, lower string) (User, er
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
)
return i, err
}
const getUsersByIDs = `-- name: GetUsersByIDs :many
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
FROM users
WHERE id = ANY($1::bigint[])
ORDER BY id
@ -241,6 +257,10 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
); err != nil {
return nil, err
}
@ -253,9 +273,9 @@ func (q *Queries) GetUsersByIDs(ctx context.Context, ids []int64) ([]User, error
}
const getUsersByPhones = `-- name: GetUsersByPhones :many
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
SELECT id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
FROM users
WHERE phone = ANY($1::text[])
WHERE phone = ANY($1::text[]) AND deleted_at IS NULL
ORDER BY id
`
@ -298,6 +318,10 @@ func (q *Queries) GetUsersByPhones(ctx context.Context, phones []string) ([]User
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
); err != nil {
return nil, err
}
@ -347,6 +371,7 @@ WITH matched AS (
FROM users u
LEFT JOIN contacts c ON c.user_id = $4::bigint AND c.contact_user_id = u.id
WHERE u.id <> $4::bigint
AND u.deleted_at IS NULL
AND $3::text <> ''
AND (
($2::text <> '' AND u.phone LIKE $2::text || '%')
@ -479,8 +504,8 @@ const setUserPremiumUntil = `-- name: SetUserPremiumUntil :one
UPDATE users
SET premium_expires_at = $1::timestamptz,
updated_at = now()
WHERE id = $2::bigint
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
`
type SetUserPremiumUntilParams struct {
@ -521,6 +546,10 @@ func (q *Queries) SetUserPremiumUntil(ctx context.Context, arg SetUserPremiumUnt
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
)
return i, err
}
@ -529,8 +558,8 @@ const setUserVerified = `-- name: SetUserVerified :one
UPDATE users
SET verified = $1::boolean,
updated_at = now()
WHERE id = $2::bigint
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
`
type SetUserVerifiedParams struct {
@ -571,6 +600,10 @@ func (q *Queries) SetUserVerified(ctx context.Context, arg SetUserVerifiedParams
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
)
return i, err
}
@ -582,11 +615,12 @@ SET premium_expires_at = NULL,
WHERE id IN (
SELECT id FROM users
WHERE premium_expires_at IS NOT NULL
AND deleted_at IS NULL
AND premium_expires_at <= $1::timestamptz
ORDER BY premium_expires_at
LIMIT $2::int
)
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
`
type SweepExpiredPremiumParams struct {
@ -633,6 +667,10 @@ func (q *Queries) SweepExpiredPremium(ctx context.Context, arg SweepExpiredPremi
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
); err != nil {
return nil, err
}
@ -650,8 +688,8 @@ SET birthday_day = $1::int,
birthday_month = $2::int,
birthday_year = $3::int,
updated_at = now()
WHERE id = $4::bigint
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
WHERE id = $4::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
`
type UpdateUserBirthdayParams struct {
@ -699,6 +737,10 @@ func (q *Queries) UpdateUserBirthday(ctx context.Context, arg UpdateUserBirthday
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
)
return i, err
}
@ -709,8 +751,8 @@ SET color_set = $1::boolean,
color = $2::int,
color_background_emoji_id = $3::bigint,
updated_at = now()
WHERE id = $4::bigint
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
WHERE id = $4::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
`
type UpdateUserColorParams struct {
@ -758,6 +800,10 @@ func (q *Queries) UpdateUserColor(ctx context.Context, arg UpdateUserColorParams
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
)
return i, err
}
@ -767,8 +813,8 @@ UPDATE users
SET emoji_status_document_id = $1::bigint,
emoji_status_until = $2::bigint,
updated_at = now()
WHERE id = $3::bigint
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
WHERE id = $3::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
`
type UpdateUserEmojiStatusParams struct {
@ -810,6 +856,10 @@ func (q *Queries) UpdateUserEmojiStatus(ctx context.Context, arg UpdateUserEmoji
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
)
return i, err
}
@ -818,7 +868,7 @@ const updateUserLastSeen = `-- name: UpdateUserLastSeen :exec
UPDATE users
SET last_seen_at = GREATEST(last_seen_at, $1::bigint),
updated_at = now()
WHERE id = $2::bigint
WHERE id = $2::bigint AND deleted_at IS NULL
`
type UpdateUserLastSeenParams struct {
@ -835,8 +885,8 @@ const updateUserPersonalChannel = `-- name: UpdateUserPersonalChannel :one
UPDATE users
SET personal_channel_id = $1::bigint,
updated_at = now()
WHERE id = $2::bigint
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
`
type UpdateUserPersonalChannelParams struct {
@ -877,6 +927,10 @@ func (q *Queries) UpdateUserPersonalChannel(ctx context.Context, arg UpdateUserP
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
)
return i, err
}
@ -885,8 +939,8 @@ const updateUserPhone = `-- name: UpdateUserPhone :one
UPDATE users
SET phone = $1::text,
updated_at = now()
WHERE id = $2::bigint
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
WHERE id = $2::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
`
type UpdateUserPhoneParams struct {
@ -927,6 +981,10 @@ func (q *Queries) UpdateUserPhone(ctx context.Context, arg UpdateUserPhoneParams
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
)
return i, err
}
@ -937,8 +995,8 @@ SET first_name = $2,
last_name = $3,
about = $4,
updated_at = now()
WHERE id = $1
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
WHERE id = $1 AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
`
type UpdateUserProfileParams struct {
@ -986,6 +1044,10 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
)
return i, err
}
@ -996,8 +1058,8 @@ SET profile_color_set = $1::boolean,
profile_color = $2::int,
profile_color_background_emoji_id = $3::bigint,
updated_at = now()
WHERE id = $4::bigint
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
WHERE id = $4::bigint AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
`
type UpdateUserProfileColorParams struct {
@ -1045,6 +1107,10 @@ func (q *Queries) UpdateUserProfileColor(ctx context.Context, arg UpdateUserProf
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
)
return i, err
}
@ -1053,8 +1119,8 @@ const updateUserUsername = `-- name: UpdateUserUsername :one
UPDATE users
SET username = $2,
updated_at = now()
WHERE id = $1
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id
WHERE id = $1 AND deleted_at IS NULL
RETURNING id, access_hash, phone, first_name, last_name, username, country_code, created_at, updated_at, verified, support, about, last_seen_at, default_history_ttl_period, is_bot, bot_info_version, premium_expires_at, emoji_status_document_id, emoji_status_until, color_set, color, color_background_emoji_id, profile_color_set, profile_color, profile_color_background_emoji_id, birthday_day, birthday_month, birthday_year, personal_channel_id, deleted_at, deletion_source, deletion_reason, account_delete_at
`
type UpdateUserUsernameParams struct {
@ -1095,6 +1161,10 @@ func (q *Queries) UpdateUserUsername(ctx context.Context, arg UpdateUserUsername
&i.BirthdayMonth,
&i.BirthdayYear,
&i.PersonalChannelID,
&i.DeletedAt,
&i.DeletionSource,
&i.DeletionReason,
&i.AccountDeleteAt,
)
return i, err
}

View file

@ -14,7 +14,7 @@ func TestStarGiftLifecycleMigrationsApply(t *testing.T) {
if err != nil {
t.Fatalf("migrate star gift lifecycle schema: %v", err)
}
if status.Dirty || status.Empty || status.Version != 106 {
t.Fatalf("migration status = %+v, want clean version 106", status)
if status.Dirty || status.Empty || status.Version != 107 {
t.Fatalf("migration status = %+v, want clean version 107", status)
}
}

View file

@ -200,7 +200,7 @@ func (s *UserStore) UpdateUsername(ctx context.Context, userID int64, username s
}()
qtx := s.q.WithTx(tx)
var lockedUserID int64
if err := tx.QueryRow(ctx, `SELECT id FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&lockedUserID); err != nil {
if err := tx.QueryRow(ctx, `SELECT id FROM users WHERE id = $1 AND deleted_at IS NULL FOR UPDATE`, userID).Scan(&lockedUserID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.User{}, domain.ErrUsernameNotOccupied
}
@ -444,7 +444,7 @@ func escapeLike(s string) string {
}
func userFromModel(r sqlcgen.User) domain.User {
return domain.User{
u := domain.User{
ID: r.ID,
AccessHash: r.AccessHash,
Phone: r.Phone,
@ -465,7 +465,17 @@ func userFromModel(r sqlcgen.User) domain.User {
Color: peerColorFromModel(r.ColorSet, r.Color, r.ColorBackgroundEmojiID),
ProfileColor: peerColorFromModel(r.ProfileColorSet, r.ProfileColor, r.ProfileColorBackgroundEmojiID),
LastSeenAt: int(r.LastSeenAt),
Deleted: r.DeletedAt.Valid,
DeletionSource: domain.AccountDeletionSource(r.DeletionSource),
DeletionReason: r.DeletionReason,
CreatedAt: r.CreatedAt.Time,
AccountDeleteAt: r.AccountDeleteAt.Time,
}
if r.DeletedAt.Valid {
u.DeletedAt = r.DeletedAt.Time.Unix()
return u.DeletedTombstone()
}
return u
}
func peerColorFromModel(hasColor bool, color int32, backgroundEmojiID int64) domain.PeerColor {