Ctrl K
ComunidadGuías…

internal/store/postgres/account.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package postgres

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"strings"

	"github.com/jackc/pgerrcode"
	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgconn"

	"telesrv/internal/domain"
	"telesrv/internal/store/postgres/sqlcgen"
)

const accountPasswordsLoginEmailUniqueIdx = "account_passwords_login_email_lower_unique_idx"

// PasswordStore implements store.PasswordStore with PostgreSQL.
type PasswordStore struct {
	db sqlcgen.DBTX
	q  *sqlcgen.Queries
}

// NewPasswordStore creates a PasswordStore backed by a pgx pool (or transaction).
func NewPasswordStore(db sqlcgen.DBTX) *PasswordStore {
	return &PasswordStore{db: db, q: sqlcgen.New(db)}
}

func (s *PasswordStore) GetByUser(ctx context.Context, userID int64) (domain.PasswordSettings, bool, error) {
	row := s.db.QueryRow(ctx, `
SELECT
  has_recovery, has_secure_values, has_password, hint,
  email_unconfirmed_pattern, login_email_pattern, secure_random,
  current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p,
  srp_id, srp_verifier, srp_b_secret, srp_b,
  recovery_email, login_email
FROM account_passwords
WHERE user_id = $1`, userID)
	var settings domain.PasswordSettings
	var salt1, salt2, p []byte
	if err := row.Scan(
		&settings.HasRecovery, &settings.HasSecureValues, &settings.HasPassword, &settings.Hint,
		&settings.EmailUnconfirmedPattern, &settings.LoginEmailPattern, &settings.SecureRandom,
		&salt1, &salt2, &settings.NewAlgo.G, &p,
		&settings.SRPID, &settings.SRPVerifier, &settings.SRPBSecret, &settings.SRPB,
		&settings.RecoveryEmail, &settings.LoginEmail,
	); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.PasswordSettings{}, false, nil
		}
		return domain.PasswordSettings{}, false, fmt.Errorf("get account password: %w", err)
	}
	if len(salt1) > 0 || len(salt2) > 0 || len(p) > 0 || settings.NewAlgo.G != 0 {
		settings.CurrentAlgo = &domain.PasswordKDFAlgo{
			Salt1: append([]byte(nil), salt1...),
			Salt2: append([]byte(nil), salt2...),
			G:     settings.NewAlgo.G,
			P:     append([]byte(nil), p...),
		}
	}
	settings.NewAlgo.Salt1 = append([]byte(nil), salt1...)
	settings.NewAlgo.Salt2 = append([]byte(nil), salt2...)
	settings.NewAlgo.P = append([]byte(nil), p...)
	settings.SecureRandom = append([]byte(nil), settings.SecureRandom...)
	settings.SRPVerifier = append([]byte(nil), settings.SRPVerifier...)
	settings.SRPBSecret = append([]byte(nil), settings.SRPBSecret...)
	settings.SRPB = append([]byte(nil), settings.SRPB...)
	return settings, true, nil
}

func (s *PasswordStore) LoginEmailOwner(ctx context.Context, email string) (int64, bool, error) {
	email = normalizeStoredLoginEmail(email)
	if email == "" {
		return 0, false, nil
	}
	row := s.db.QueryRow(ctx, `
SELECT user_id
FROM account_passwords
WHERE login_email <> '' AND lower(login_email) = $1
LIMIT 1`, email)
	var userID int64
	if err := row.Scan(&userID); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return 0, false, nil
		}
		return 0, false, fmt.Errorf("get login email owner: %w", err)
	}
	return userID, true, nil
}

func (s *PasswordStore) Save(ctx context.Context, userID int64, settings domain.PasswordSettings) error {
	settings.LoginEmail = normalizeStoredLoginEmail(settings.LoginEmail)
	settings.LoginEmailPattern = domain.MaskEmail(settings.LoginEmail)
	algo := settings.NewAlgo
	if settings.CurrentAlgo != nil {
		algo = *settings.CurrentAlgo
	}
	_, err := s.db.Exec(ctx, `
INSERT INTO account_passwords (
  user_id, has_recovery, has_secure_values, has_password, hint,
  email_unconfirmed_pattern, login_email_pattern, secure_random,
  current_algo_salt1, current_algo_salt2, current_algo_g, current_algo_p,
  srp_id, srp_verifier, srp_b_secret, srp_b,
  recovery_email, login_email
)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
ON CONFLICT (user_id) DO UPDATE SET
  has_recovery = EXCLUDED.has_recovery,
  has_secure_values = EXCLUDED.has_secure_values,
  has_password = EXCLUDED.has_password,
  hint = EXCLUDED.hint,
  email_unconfirmed_pattern = EXCLUDED.email_unconfirmed_pattern,
  login_email_pattern = EXCLUDED.login_email_pattern,
  secure_random = EXCLUDED.secure_random,
  current_algo_salt1 = EXCLUDED.current_algo_salt1,
  current_algo_salt2 = EXCLUDED.current_algo_salt2,
  current_algo_g = EXCLUDED.current_algo_g,
  current_algo_p = EXCLUDED.current_algo_p,
  srp_id = EXCLUDED.srp_id,
  srp_verifier = EXCLUDED.srp_verifier,
  srp_b_secret = EXCLUDED.srp_b_secret,
  srp_b = EXCLUDED.srp_b,
  recovery_email = EXCLUDED.recovery_email,
  login_email = EXCLUDED.login_email,
  updated_at = now()`,
		userID,
		settings.HasRecovery, settings.HasSecureValues, settings.HasPassword, settings.Hint,
		settings.EmailUnconfirmedPattern, settings.LoginEmailPattern, nonNilBytea(settings.SecureRandom),
		nonNilBytea(algo.Salt1), nonNilBytea(algo.Salt2), algo.G, nonNilBytea(algo.P),
		settings.SRPID, nonNilBytea(settings.SRPVerifier), nonNilBytea(settings.SRPBSecret), nonNilBytea(settings.SRPB),
		settings.RecoveryEmail, settings.LoginEmail,
	)
	if err != nil {
		if isAccountPasswordLoginEmailUnique(err) {
			return domain.ErrEmailOccupied
		}
		return fmt.Errorf("upsert account password: %w", err)
	}
	return nil
}

func normalizeStoredLoginEmail(email string) string {
	return strings.ToLower(strings.TrimSpace(email))
}

func isAccountPasswordLoginEmailUnique(err error) bool {
	var pgErr *pgconn.PgError
	return errors.As(err, &pgErr) &&
		pgErr.Code == pgerrcode.UniqueViolation &&
		pgErr.ConstraintName == accountPasswordsLoginEmailUniqueIdx
}

func nonNilBytea(in []byte) []byte {
	if in != nil {
		return in
	}
	return []byte{}
}

func (s *PasswordStore) GetReactionSettings(ctx context.Context, userID int64) (domain.AccountReactionSettings, bool, error) {
	row := s.db.QueryRow(ctx, `
SELECT messages_notify_from, stories_notify_from, poll_votes_notify_from, show_previews,
       default_reaction_type, default_reaction_value,
       paid_privacy_kind, paid_privacy_peer_type, paid_privacy_peer_id
FROM account_reaction_settings
WHERE user_id = $1`, userID)
	var messagesFrom, storiesFrom, pollVotesFrom string
	var defaultType, defaultValue string
	var paidKind string
	var paidPeerType sql.NullString
	var paidPeerID sql.NullInt64
	settings := domain.DefaultAccountReactionSettings()
	if err := row.Scan(
		&messagesFrom, &storiesFrom, &pollVotesFrom, &settings.Notify.ShowPreviews,
		&defaultType, &defaultValue, &paidKind, &paidPeerType, &paidPeerID,
	); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.AccountReactionSettings{}, false, nil
		}
		return domain.AccountReactionSettings{}, false, fmt.Errorf("get account reaction settings: %w", err)
	}
	settings.Notify.MessagesFrom = domain.ReactionNotifyFrom(messagesFrom)
	settings.Notify.StoriesFrom = domain.ReactionNotifyFrom(storiesFrom)
	settings.Notify.PollVotesFrom = domain.ReactionNotifyFrom(pollVotesFrom)
	if reaction, ok := domain.MessageReactionFromValue(domain.MessageReactionType(defaultType), defaultValue); ok {
		settings.DefaultReaction = reaction
	}
	settings.PaidPrivacy = domain.PaidReactionPrivacy{Kind: domain.PaidReactionPrivacyKind(paidKind)}
	if settings.PaidPrivacy.Kind == domain.PaidReactionPrivacyPeer && paidPeerType.Valid && paidPeerID.Valid {
		peer := domain.Peer{Type: domain.PeerType(paidPeerType.String), ID: paidPeerID.Int64}
		settings.PaidPrivacy.Peer = &peer
	}
	return settings, true, nil
}

func (s *PasswordStore) SaveReactionSettings(ctx context.Context, userID int64, settings domain.AccountReactionSettings) error {
	var paidPeerType any
	var paidPeerID any
	if settings.PaidPrivacy.Kind == domain.PaidReactionPrivacyPeer && settings.PaidPrivacy.Peer != nil {
		paidPeerType = string(settings.PaidPrivacy.Peer.Type)
		paidPeerID = settings.PaidPrivacy.Peer.ID
	}
	if _, err := s.db.Exec(ctx, `
INSERT INTO account_reaction_settings (
    user_id, messages_notify_from, stories_notify_from, poll_votes_notify_from, show_previews,
    default_reaction_type, default_reaction_value, paid_privacy_kind, paid_privacy_peer_type, paid_privacy_peer_id
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (user_id) DO UPDATE SET
    messages_notify_from = EXCLUDED.messages_notify_from,
    stories_notify_from = EXCLUDED.stories_notify_from,
    poll_votes_notify_from = EXCLUDED.poll_votes_notify_from,
    show_previews = EXCLUDED.show_previews,
    default_reaction_type = EXCLUDED.default_reaction_type,
    default_reaction_value = EXCLUDED.default_reaction_value,
    paid_privacy_kind = EXCLUDED.paid_privacy_kind,
    paid_privacy_peer_type = EXCLUDED.paid_privacy_peer_type,
    paid_privacy_peer_id = EXCLUDED.paid_privacy_peer_id,
    updated_at = now()`,
		userID,
		string(settings.Notify.MessagesFrom), string(settings.Notify.StoriesFrom), string(settings.Notify.PollVotesFrom), settings.Notify.ShowPreviews,
		string(settings.DefaultReaction.Type), settings.DefaultReaction.Value(),
		string(settings.PaidPrivacy.Kind), paidPeerType, paidPeerID,
	); err != nil {
		return fmt.Errorf("save account reaction settings: %w", err)
	}
	return nil
}

func (s *PasswordStore) GetAccountSettings(ctx context.Context, userID int64) (domain.AccountSettings, bool, error) {
	row := s.db.QueryRow(ctx, `
SELECT archive_and_mute_new_noncontact_peers, keep_archived_unmuted, keep_archived_folders,
       hide_read_marks, new_noncontact_peers_require_premium, display_gifts_button,
       noncontact_peers_paid_stars, disallow_unlimited_stargifts, disallow_limited_stargifts,
       disallow_unique_stargifts, disallow_premium_gifts, disallow_stargifts_from_channels,
       account_ttl_days, sensitive_content_enabled, contact_signup_silent
FROM account_settings
WHERE user_id = $1`, userID)
	settings := domain.DefaultAccountSettings()
	gp := &settings.GlobalPrivacy
	if err := row.Scan(
		&gp.ArchiveAndMuteNewNoncontactPeers, &gp.KeepArchivedUnmuted, &gp.KeepArchivedFolders,
		&gp.HideReadMarks, &gp.NewNoncontactPeersRequirePremium, &gp.DisplayGiftsButton,
		&gp.NoncontactPeersPaidStars,
		&gp.DisallowedGifts.UnlimitedStargifts, &gp.DisallowedGifts.LimitedStargifts,
		&gp.DisallowedGifts.UniqueStargifts, &gp.DisallowedGifts.PremiumGifts,
		&gp.DisallowedGifts.StargiftsFromChannel,
		&settings.AccountTTLDays, &settings.SensitiveContentEnabled, &settings.ContactSignUpSilent,
	); err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.AccountSettings{}, false, nil
		}
		return domain.AccountSettings{}, false, fmt.Errorf("get account settings: %w", err)
	}
	return settings, true, nil
}

func (s *PasswordStore) GetAccountSettingsBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountSettings, error) {
	out := make(map[int64]domain.AccountSettings, len(userIDs))
	if len(userIDs) == 0 {
		return out, nil
	}
	rows, err := s.db.Query(ctx, `
SELECT user_id, archive_and_mute_new_noncontact_peers, keep_archived_unmuted, keep_archived_folders,
       hide_read_marks, new_noncontact_peers_require_premium, display_gifts_button,
       noncontact_peers_paid_stars, disallow_unlimited_stargifts, disallow_limited_stargifts,
       disallow_unique_stargifts, disallow_premium_gifts, disallow_stargifts_from_channels,
       account_ttl_days, sensitive_content_enabled, contact_signup_silent
FROM account_settings
WHERE user_id = ANY($1::bigint[])`, userIDs)
	if err != nil {
		return nil, fmt.Errorf("get account settings batch: %w", err)
	}
	defer rows.Close()
	for rows.Next() {
		var userID int64
		settings := domain.DefaultAccountSettings()
		gp := &settings.GlobalPrivacy
		if err := rows.Scan(
			&userID,
			&gp.ArchiveAndMuteNewNoncontactPeers, &gp.KeepArchivedUnmuted, &gp.KeepArchivedFolders,
			&gp.HideReadMarks, &gp.NewNoncontactPeersRequirePremium, &gp.DisplayGiftsButton,
			&gp.NoncontactPeersPaidStars,
			&gp.DisallowedGifts.UnlimitedStargifts, &gp.DisallowedGifts.LimitedStargifts,
			&gp.DisallowedGifts.UniqueStargifts, &gp.DisallowedGifts.PremiumGifts,
			&gp.DisallowedGifts.StargiftsFromChannel,
			&settings.AccountTTLDays, &settings.SensitiveContentEnabled, &settings.ContactSignUpSilent,
		); err != nil {
			return nil, fmt.Errorf("scan account settings batch: %w", err)
		}
		out[userID] = settings
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate account settings batch: %w", err)
	}
	return out, nil
}

func (s *PasswordStore) SaveAccountSettings(ctx context.Context, userID int64, settings domain.AccountSettings) error {
	gp := settings.GlobalPrivacy
	if _, err := s.db.Exec(ctx, `
INSERT INTO account_settings (
    user_id, archive_and_mute_new_noncontact_peers, keep_archived_unmuted, keep_archived_folders,
    hide_read_marks, new_noncontact_peers_require_premium, display_gifts_button,
    noncontact_peers_paid_stars, disallow_unlimited_stargifts, disallow_limited_stargifts,
    disallow_unique_stargifts, disallow_premium_gifts, disallow_stargifts_from_channels,
    account_ttl_days, sensitive_content_enabled, contact_signup_silent
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
ON CONFLICT (user_id) DO UPDATE SET
    archive_and_mute_new_noncontact_peers = EXCLUDED.archive_and_mute_new_noncontact_peers,
    keep_archived_unmuted = EXCLUDED.keep_archived_unmuted,
    keep_archived_folders = EXCLUDED.keep_archived_folders,
    hide_read_marks = EXCLUDED.hide_read_marks,
    new_noncontact_peers_require_premium = EXCLUDED.new_noncontact_peers_require_premium,
    display_gifts_button = EXCLUDED.display_gifts_button,
    noncontact_peers_paid_stars = EXCLUDED.noncontact_peers_paid_stars,
    disallow_unlimited_stargifts = EXCLUDED.disallow_unlimited_stargifts,
    disallow_limited_stargifts = EXCLUDED.disallow_limited_stargifts,
    disallow_unique_stargifts = EXCLUDED.disallow_unique_stargifts,
    disallow_premium_gifts = EXCLUDED.disallow_premium_gifts,
    disallow_stargifts_from_channels = EXCLUDED.disallow_stargifts_from_channels,
    account_ttl_days = EXCLUDED.account_ttl_days,
    sensitive_content_enabled = EXCLUDED.sensitive_content_enabled,
    contact_signup_silent = EXCLUDED.contact_signup_silent,
    updated_at = now()`,
		userID,
		gp.ArchiveAndMuteNewNoncontactPeers, gp.KeepArchivedUnmuted, gp.KeepArchivedFolders,
		gp.HideReadMarks, gp.NewNoncontactPeersRequirePremium, gp.DisplayGiftsButton,
		gp.NoncontactPeersPaidStars,
		gp.DisallowedGifts.UnlimitedStargifts, gp.DisallowedGifts.LimitedStargifts,
		gp.DisallowedGifts.UniqueStargifts, gp.DisallowedGifts.PremiumGifts,
		gp.DisallowedGifts.StargiftsFromChannel,
		settings.NormalizedTTLDays(), settings.SensitiveContentEnabled, settings.ContactSignUpSilent,
	); err != nil {
		return fmt.Errorf("save account settings: %w", err)
	}
	return nil
}

func notifyScopeColumns(scope domain.NotifyScope) (kind, peerType string, peerID int64, topicID int) {
	kind = string(scope.Kind)
	if scope.Kind == domain.NotifyScopePeer {
		return kind, string(scope.Peer.Type), scope.Peer.ID, scope.TopicID
	}
	return kind, "", 0, 0
}

func scanNotifySettings(row pgx.Row, prefix ...any) (domain.PeerNotifySettings, error) {
	var showPreviews, silent, storiesMuted, storiesHideSender sql.NullBool
	var muteUntil sql.NullInt32
	if err := row.Scan(append(prefix, &showPreviews, &silent, &muteUntil, &storiesMuted, &storiesHideSender)...); err != nil {
		return domain.PeerNotifySettings{}, err
	}
	out := domain.PeerNotifySettings{}
	if showPreviews.Valid {
		out.ShowPreviews = &showPreviews.Bool
	}
	if silent.Valid {
		out.Silent = &silent.Bool
	}
	if muteUntil.Valid {
		v := int(muteUntil.Int32)
		out.MuteUntil = &v
	}
	if storiesMuted.Valid {
		out.StoriesMuted = &storiesMuted.Bool
	}
	if storiesHideSender.Valid {
		out.StoriesHideSender = &storiesHideSender.Bool
	}
	return out, nil
}

func (s *PasswordStore) GetForumTopicNotifySettings(ctx context.Context, ownerUserID int64, peer domain.Peer, topicIDs []int) (map[int]domain.PeerNotifySettings, error) {
	out := make(map[int]domain.PeerNotifySettings, len(topicIDs))
	if len(topicIDs) == 0 {
		return out, nil
	}
	rows, err := s.db.Query(ctx, `SELECT topic_id, show_previews, silent, mute_until, stories_muted, stories_hide_sender
FROM notify_settings
WHERE owner_user_id = $1 AND scope_kind = 'peer' AND peer_type = $2 AND peer_id = $3 AND topic_id = ANY($4::int[])`,
		ownerUserID, string(peer.Type), peer.ID, topicIDs)
	if err != nil {
		return nil, fmt.Errorf("batch get forum notification settings: %w", err)
	}
	defer rows.Close()
	for rows.Next() {
		var id int
		settings, err := scanNotifySettings(rows, &id)
		if err != nil {
			return nil, fmt.Errorf("scan forum notification settings: %w", err)
		}
		out[id] = settings
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate forum notification settings: %w", err)
	}
	return out, nil
}

func (s *PasswordStore) GetNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope) (domain.PeerNotifySettings, bool, error) {
	kind, peerType, peerID, topicID := notifyScopeColumns(scope)
	row := s.db.QueryRow(ctx, `
SELECT show_previews, silent, mute_until, stories_muted, stories_hide_sender
FROM notify_settings
WHERE owner_user_id = $1 AND scope_kind = $2 AND peer_type = $3 AND peer_id = $4 AND topic_id = $5`,
		ownerUserID, kind, peerType, peerID, topicID)
	settings, err := scanNotifySettings(row)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.PeerNotifySettings{}, false, nil
		}
		return domain.PeerNotifySettings{}, false, fmt.Errorf("get notify settings: %w", err)
	}
	return settings, true, nil
}

func (s *PasswordStore) SaveNotifySettings(ctx context.Context, ownerUserID int64, scope domain.NotifyScope, settings domain.PeerNotifySettings) error {
	kind, peerType, peerID, topicID := notifyScopeColumns(scope)
	if _, err := s.db.Exec(ctx, `
INSERT INTO notify_settings (
    owner_user_id, scope_kind, peer_type, peer_id, topic_id,
    show_previews, silent, mute_until, stories_muted, stories_hide_sender
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (owner_user_id, scope_kind, peer_type, peer_id, topic_id) DO UPDATE SET
    show_previews = EXCLUDED.show_previews,
    silent = EXCLUDED.silent,
    mute_until = EXCLUDED.mute_until,
    stories_muted = EXCLUDED.stories_muted,
    stories_hide_sender = EXCLUDED.stories_hide_sender,
    updated_at = now()`,
		ownerUserID, kind, peerType, peerID, topicID,
		nullableBool(settings.ShowPreviews), nullableBool(settings.Silent), nullableInt(settings.MuteUntil),
		nullableBool(settings.StoriesMuted), nullableBool(settings.StoriesHideSender),
	); err != nil {
		return fmt.Errorf("save notify settings: %w", err)
	}
	return nil
}

func (s *PasswordStore) ResetNotifySettings(ctx context.Context, ownerUserID int64) error {
	if _, err := s.db.Exec(ctx, `DELETE FROM notify_settings WHERE owner_user_id = $1`, ownerUserID); err != nil {
		return fmt.Errorf("reset notify settings: %w", err)
	}
	return nil
}

func (s *PasswordStore) GetPeerNotifySettings(ctx context.Context, ownerUserID int64, peers []domain.Peer) (map[domain.Peer]domain.PeerNotifySettings, error) {
	out := make(map[domain.Peer]domain.PeerNotifySettings, len(peers))
	if len(peers) == 0 {
		return out, nil
	}
	types := make([]string, 0, len(peers))
	ids := make([]int64, 0, len(peers))
	for _, p := range peers {
		types = append(types, string(p.Type))
		ids = append(ids, p.ID)
	}
	rows, err := s.db.Query(ctx, `
SELECT peer_type, peer_id, show_previews, silent, mute_until, stories_muted, stories_hide_sender
FROM notify_settings
WHERE owner_user_id = $1 AND scope_kind = 'peer' AND topic_id = 0
  AND (peer_type, peer_id) IN (SELECT * FROM unnest($2::text[], $3::bigint[]))`,
		ownerUserID, types, ids)
	if err != nil {
		return nil, fmt.Errorf("batch get notify settings: %w", err)
	}
	defer rows.Close()
	for rows.Next() {
		var peerType string
		var peerID int64
		var showPreviews, silent, storiesMuted, storiesHideSender sql.NullBool
		var muteUntil sql.NullInt32
		if err := rows.Scan(&peerType, &peerID, &showPreviews, &silent, &muteUntil, &storiesMuted, &storiesHideSender); err != nil {
			return nil, fmt.Errorf("scan notify settings: %w", err)
		}
		settings := domain.PeerNotifySettings{}
		if showPreviews.Valid {
			settings.ShowPreviews = &showPreviews.Bool
		}
		if silent.Valid {
			settings.Silent = &silent.Bool
		}
		if muteUntil.Valid {
			v := int(muteUntil.Int32)
			settings.MuteUntil = &v
		}
		if storiesMuted.Valid {
			settings.StoriesMuted = &storiesMuted.Bool
		}
		if storiesHideSender.Valid {
			settings.StoriesHideSender = &storiesHideSender.Bool
		}
		out[domain.Peer{Type: domain.PeerType(peerType), ID: peerID}] = settings
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate notify settings: %w", err)
	}
	return out, nil
}

func (s *PasswordStore) AllPeerNotifySettings(ctx context.Context, ownerUserID int64) (map[domain.Peer]domain.PeerNotifySettings, error) {
	// owner-scoped single query using the partial index notify_settings_owner_peer_idx (scope_kind='peer' AND topic_id=0).
	rows, err := s.db.Query(ctx, `
SELECT peer_type, peer_id, show_previews, silent, mute_until, stories_muted, stories_hide_sender
FROM notify_settings
WHERE owner_user_id = $1 AND scope_kind = 'peer' AND topic_id = 0`, ownerUserID)
	if err != nil {
		return nil, fmt.Errorf("all peer notify settings: %w", err)
	}
	defer rows.Close()
	out := make(map[domain.Peer]domain.PeerNotifySettings)
	for rows.Next() {
		var peerType string
		var peerID int64
		var showPreviews, silent, storiesMuted, storiesHideSender sql.NullBool
		var muteUntil sql.NullInt32
		if err := rows.Scan(&peerType, &peerID, &showPreviews, &silent, &muteUntil, &storiesMuted, &storiesHideSender); err != nil {
			return nil, fmt.Errorf("scan all peer notify settings: %w", err)
		}
		settings := domain.PeerNotifySettings{}
		if showPreviews.Valid {
			settings.ShowPreviews = &showPreviews.Bool
		}
		if silent.Valid {
			settings.Silent = &silent.Bool
		}
		if muteUntil.Valid {
			v := int(muteUntil.Int32)
			settings.MuteUntil = &v
		}
		if storiesMuted.Valid {
			settings.StoriesMuted = &storiesMuted.Bool
		}
		if storiesHideSender.Valid {
			settings.StoriesHideSender = &storiesHideSender.Bool
		}
		out[domain.Peer{Type: domain.PeerType(peerType), ID: peerID}] = settings
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate all peer notify settings: %w", err)
	}
	return out, nil
}

func (s *PasswordStore) ListNotifyExceptions(ctx context.Context, ownerUserID int64) ([]domain.NotifyException, error) {
	rows, err := s.db.Query(ctx, `
SELECT peer_type, peer_id, topic_id, show_previews, silent, mute_until, stories_muted, stories_hide_sender
FROM notify_settings
WHERE owner_user_id = $1 AND scope_kind = 'peer'
ORDER BY peer_type, peer_id, topic_id`, ownerUserID)
	if err != nil {
		return nil, fmt.Errorf("list notify exceptions: %w", err)
	}
	defer rows.Close()
	out := make([]domain.NotifyException, 0)
	for rows.Next() {
		var peerType string
		var peerID int64
		var topicID int
		var showPreviews, silent, storiesMuted, storiesHideSender sql.NullBool
		var muteUntil sql.NullInt32
		if err := rows.Scan(&peerType, &peerID, &topicID, &showPreviews, &silent, &muteUntil, &storiesMuted, &storiesHideSender); err != nil {
			return nil, fmt.Errorf("scan notify exception: %w", err)
		}
		settings := domain.PeerNotifySettings{}
		if showPreviews.Valid {
			settings.ShowPreviews = &showPreviews.Bool
		}
		if silent.Valid {
			settings.Silent = &silent.Bool
		}
		if muteUntil.Valid {
			v := int(muteUntil.Int32)
			settings.MuteUntil = &v
		}
		if storiesMuted.Valid {
			settings.StoriesMuted = &storiesMuted.Bool
		}
		if storiesHideSender.Valid {
			settings.StoriesHideSender = &storiesHideSender.Bool
		}
		if settings.IsZero() {
			continue // an all-default row (e.g. muted then unmuted) is not an exception
		}
		out = append(out, domain.NotifyException{
			Peer:     domain.Peer{Type: domain.PeerType(peerType), ID: peerID},
			TopicID:  topicID,
			Settings: settings,
		})
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate notify exceptions: %w", err)
	}
	return out, nil
}

func (s *PasswordStore) SaveStickerCollectionItem(ctx context.Context, userID int64, kind domain.StickerCollectionKind, documentID int64, unsave bool, now, max int) error {
	if userID == 0 || documentID == 0 {
		return domain.ErrStickerInvalid
	}
	if unsave {
		if _, err := s.db.Exec(ctx, `DELETE FROM user_sticker_collections WHERE owner_user_id = $1 AND kind = $2 AND document_id = $3`,
			userID, string(kind), documentID); err != nil {
			return fmt.Errorf("unsave sticker collection item: %w", err)
		}
		return nil
	}
	if max <= 0 {
		max = domain.MaxStickerCollectionItems(kind)
	}
	return withTx(ctx, s.db, "save sticker collection item", func(tx pgx.Tx) error {
		if _, err := tx.Exec(ctx, `
INSERT INTO user_sticker_collections (owner_user_id, kind, document_id, used_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (owner_user_id, kind, document_id) DO UPDATE
SET used_at = EXCLUDED.used_at,
    order_key = nextval('user_sticker_collections_order_key_seq')`,
			userID, string(kind), documentID, now); err != nil {
			return fmt.Errorf("upsert sticker collection item: %w", err)
		}
		// Trim overflow: a single ordered window scan (the index user_sticker_collections_order_idx
		// serves the order_key DESC ordering) deletes old items ranked > max by ctid, avoiding a NOT IN double scan of the whole set.
		if _, err := tx.Exec(ctx, `
DELETE FROM user_sticker_collections
WHERE ctid IN (
  SELECT ctid FROM (
    SELECT ctid, ROW_NUMBER() OVER (ORDER BY order_key DESC) AS rn
    FROM user_sticker_collections
    WHERE owner_user_id = $1 AND kind = $2
  ) t WHERE rn > $3
)`, userID, string(kind), max); err != nil {
			return fmt.Errorf("trim sticker collection: %w", err)
		}
		return nil
	})
}

func (s *PasswordStore) ListStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind, limit int) ([]domain.StickerCollectionItem, error) {
	if userID == 0 {
		return nil, nil
	}
	if limit <= 0 || limit > domain.MaxStickerCollectionItems(kind) {
		limit = domain.MaxStickerCollectionItems(kind)
	}
	rows, err := s.db.Query(ctx, `
SELECT document_id, used_at
FROM user_sticker_collections
WHERE owner_user_id = $1 AND kind = $2
ORDER BY order_key DESC
LIMIT $3`, userID, string(kind), limit)
	if err != nil {
		return nil, fmt.Errorf("list sticker collection: %w", err)
	}
	defer rows.Close()
	out := make([]domain.StickerCollectionItem, 0, limit)
	for rows.Next() {
		var item domain.StickerCollectionItem
		if err := rows.Scan(&item.DocumentID, &item.Date); err != nil {
			return nil, fmt.Errorf("scan sticker collection item: %w", err)
		}
		out = append(out, item)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate sticker collection: %w", err)
	}
	return out, nil
}

func (s *PasswordStore) ClearStickerCollection(ctx context.Context, userID int64, kind domain.StickerCollectionKind) error {
	if _, err := s.db.Exec(ctx, `DELETE FROM user_sticker_collections WHERE owner_user_id = $1 AND kind = $2`,
		userID, string(kind)); err != nil {
		return fmt.Errorf("clear sticker collection: %w", err)
	}
	return nil
}

func (s *PasswordStore) InstallUserStickerSet(ctx context.Context, userID int64, setID int64, kind domain.StickerSetKind, archived bool, installedDate int) error {
	if userID == 0 || setID == 0 {
		return domain.ErrStickerInvalid
	}
	orderValue := int64(installedDate) << 32
	_, err := s.db.Exec(ctx, `
INSERT INTO user_sticker_sets (owner_user_id, sticker_set_id, set_kind, archived, installed_date, order_value, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, now())
ON CONFLICT (owner_user_id, sticker_set_id) DO UPDATE SET
  set_kind = EXCLUDED.set_kind,
  archived = EXCLUDED.archived,
  installed_date = CASE
    WHEN user_sticker_sets.installed_date = 0 THEN EXCLUDED.installed_date
    ELSE user_sticker_sets.installed_date
  END,
  order_value = EXCLUDED.order_value,
  updated_at = now()`,
		userID, setID, string(kind), archived, installedDate, orderValue)
	if err != nil {
		return fmt.Errorf("install user sticker set: %w", err)
	}
	return nil
}

func (s *PasswordStore) UninstallUserStickerSet(ctx context.Context, userID int64, setID int64) error {
	if _, err := s.db.Exec(ctx, `
DELETE FROM user_sticker_sets
WHERE owner_user_id = $1 AND sticker_set_id = $2`, userID, setID); err != nil {
		return fmt.Errorf("uninstall user sticker set: %w", err)
	}
	return nil
}

func (s *PasswordStore) SetUserStickerSetArchived(ctx context.Context, userID int64, setID int64, archived bool, now int) error {
	orderValue := int64(now) << 32
	_, err := s.db.Exec(ctx, `
UPDATE user_sticker_sets
SET archived = $3,
    order_value = CASE WHEN $3::boolean = false AND $4::bigint > 0 THEN $4::bigint ELSE order_value END,
    updated_at = now()
WHERE owner_user_id = $1 AND sticker_set_id = $2`, userID, setID, archived, orderValue)
	if err != nil {
		return fmt.Errorf("set user sticker set archived: %w", err)
	}
	return nil
}

func (s *PasswordStore) ReorderUserStickerSets(ctx context.Context, userID int64, kind domain.StickerSetKind, order []int64, now int) error {
	if len(order) == 0 {
		return nil
	}
	return withTx(ctx, s.db, "reorder user sticker sets", func(tx pgx.Tx) error {
		orderValue := int64(now) << 32
		for _, id := range order {
			if _, err := tx.Exec(ctx, `
UPDATE user_sticker_sets
SET order_value = $4, updated_at = now()
WHERE owner_user_id = $1 AND set_kind = $2 AND sticker_set_id = $3`,
				userID, string(kind), id, orderValue); err != nil {
				return fmt.Errorf("update user sticker set order: %w", err)
			}
			orderValue--
		}
		return nil
	})
}

func (s *PasswordStore) ListUserStickerSets(ctx context.Context, userID int64, kind domain.StickerSetKind, archived *bool, offsetID int64, limit int) ([]domain.UserStickerSet, int, error) {
	if userID == 0 {
		return nil, 0, nil
	}
	if limit <= 0 || limit > domain.MaxInstalledStickerSets {
		limit = domain.MaxInstalledStickerSets
	}
	var archivedFilter any
	if archived != nil {
		archivedFilter = *archived
	}
	rows, err := s.db.Query(ctx, `
WITH ordered AS (
  SELECT owner_user_id, sticker_set_id, set_kind, archived, installed_date, order_value,
         ROW_NUMBER() OVER (ORDER BY order_value DESC, sticker_set_id DESC) AS rn,
         COUNT(*) OVER () AS total
  FROM user_sticker_sets
  WHERE owner_user_id = $1
    AND set_kind = $2
    AND ($3::boolean IS NULL OR archived = $3::boolean)
),
page AS (
  SELECT COALESCE((SELECT rn FROM ordered WHERE sticker_set_id = $4), 0) AS offset_rn
)
SELECT owner_user_id, sticker_set_id, set_kind, archived, installed_date, order_value, total
FROM ordered, page
WHERE ordered.rn > page.offset_rn
ORDER BY ordered.rn ASC
LIMIT $5`, userID, string(kind), archivedFilter, offsetID, limit)
	if err != nil {
		return nil, 0, fmt.Errorf("list user sticker sets: %w", err)
	}
	defer rows.Close()
	out := make([]domain.UserStickerSet, 0, limit)
	total := 0
	for rows.Next() {
		var (
			item     domain.UserStickerSet
			kindText string
		)
		if err := rows.Scan(&item.OwnerUserID, &item.StickerSetID, &kindText, &item.Archived, &item.InstalledDate, &item.OrderValue, &total); err != nil {
			return nil, 0, fmt.Errorf("scan user sticker set: %w", err)
		}
		item.Kind = domain.StickerSetKind(kindText)
		out = append(out, item)
	}
	if err := rows.Err(); err != nil {
		return nil, 0, fmt.Errorf("iterate user sticker sets: %w", err)
	}
	return out, total, nil
}

func nullableBool(v *bool) any {
	if v == nil {
		return nil
	}
	return *v
}

func nullableInt(v *int) any {
	if v == nil {
		return nil
	}
	return *v
}

func (s *PasswordStore) SaveMusic(ctx context.Context, req domain.SaveMusicRequest) error {
	if req.UserID == 0 || req.Document.ID == 0 || !req.Document.IsMusic() {
		return domain.ErrDocumentInvalid
	}
	return withTx(ctx, s.db, "save account music", func(tx pgx.Tx) error {
		rows, err := tx.Query(ctx, `
SELECT document_id
FROM saved_music
WHERE user_id = $1
ORDER BY sort_order ASC, document_id ASC
FOR UPDATE`, req.UserID)
		if err != nil {
			return fmt.Errorf("lock saved music: %w", err)
		}
		defer rows.Close()
		current := make([]int64, 0, domain.MaxSavedMusicItems)
		for rows.Next() {
			var id int64
			if err := rows.Scan(&id); err != nil {
				return fmt.Errorf("scan saved music id: %w", err)
			}
			current = append(current, id)
		}
		if err := rows.Err(); err != nil {
			return fmt.Errorf("scan saved music ids: %w", err)
		}
		if req.Unsave {
			if _, err := tx.Exec(ctx, `DELETE FROM saved_music WHERE user_id = $1 AND document_id = $2`, req.UserID, req.Document.ID); err != nil {
				return fmt.Errorf("delete saved music: %w", err)
			}
			return nil
		}
		exists := false
		for _, id := range current {
			if id == req.Document.ID {
				exists = true
				break
			}
		}
		if req.AfterDocumentID == req.Document.ID {
			if exists {
				return nil
			}
			return domain.ErrDocumentInvalid
		}
		next := make([]int64, 0, len(current)+1)
		afterIndex := -1
		for _, id := range current {
			if id == req.Document.ID {
				continue
			}
			if id == req.AfterDocumentID {
				afterIndex = len(next)
			}
			next = append(next, id)
		}
		if req.AfterDocumentID != 0 {
			if afterIndex < 0 {
				return domain.ErrDocumentInvalid
			}
			next = append(next, 0)
			copy(next[afterIndex+2:], next[afterIndex+1:])
			next[afterIndex+1] = req.Document.ID
		} else {
			next = append([]int64{req.Document.ID}, next...)
		}
		if len(next) > domain.MaxSavedMusicItems {
			next = next[:domain.MaxSavedMusicItems]
		}
		if _, err := tx.Exec(ctx, `DELETE FROM saved_music WHERE user_id = $1`, req.UserID); err != nil {
			return fmt.Errorf("clear saved music order: %w", err)
		}
		for i, id := range next {
			if _, err := tx.Exec(ctx, `
INSERT INTO saved_music (user_id, document_id, sort_order, created_at, updated_at)
VALUES ($1, $2, $3, now(), now())`, req.UserID, id, i+1); err != nil {
				return fmt.Errorf("insert saved music: %w", err)
			}
		}
		return nil
	})
}

func (s *PasswordStore) ListSavedMusicIDs(ctx context.Context, userID int64, limit int) ([]int64, error) {
	if userID == 0 {
		return nil, nil
	}
	if limit <= 0 || limit > domain.MaxSavedMusicItems {
		limit = domain.MaxSavedMusicItems
	}
	rows, err := s.db.Query(ctx, `
SELECT document_id
FROM saved_music
WHERE user_id = $1
ORDER BY sort_order ASC, document_id ASC
LIMIT $2`, userID, limit)
	if err != nil {
		return nil, fmt.Errorf("list saved music ids: %w", err)
	}
	defer rows.Close()
	ids := make([]int64, 0, limit)
	for rows.Next() {
		var id int64
		if err := rows.Scan(&id); err != nil {
			return nil, fmt.Errorf("scan saved music id: %w", err)
		}
		ids = append(ids, id)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("scan saved music ids: %w", err)
	}
	return ids, nil
}

func (s *PasswordStore) ListSavedMusic(ctx context.Context, userID int64, offset, limit int) (domain.SavedMusicList, error) {
	out := domain.SavedMusicList{UserID: userID}
	if userID == 0 {
		return out, nil
	}
	if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM saved_music WHERE user_id = $1`, userID).Scan(&out.Count); err != nil {
		return domain.SavedMusicList{}, fmt.Errorf("count saved music: %w", err)
	}
	if limit <= 0 || offset < 0 || offset >= out.Count {
		return out, nil
	}
	if limit > domain.MaxSavedMusicItems {
		limit = domain.MaxSavedMusicItems
	}
	docs, err := s.querySavedMusicDocuments(ctx, `
SELECT d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
       d.attributes::text AS attributes_json, d.thumbs::text AS thumbs_json
FROM saved_music sm
JOIN documents d ON d.id = sm.document_id
WHERE sm.user_id = $1
ORDER BY sm.sort_order ASC, sm.document_id ASC
OFFSET $2
LIMIT $3`, userID, offset, limit)
	if err != nil {
		return domain.SavedMusicList{}, err
	}
	out.Documents = docs
	return out, nil
}

func (s *PasswordStore) GetSavedMusicByIDs(ctx context.Context, userID int64, ids []int64) (domain.SavedMusicList, error) {
	out := domain.SavedMusicList{UserID: userID}
	if userID == 0 || len(ids) == 0 {
		return out, nil
	}
	if err := s.db.QueryRow(ctx, `SELECT COUNT(*)::int FROM saved_music WHERE user_id = $1`, userID).Scan(&out.Count); err != nil {
		return domain.SavedMusicList{}, fmt.Errorf("count saved music: %w", err)
	}
	docs, err := s.querySavedMusicDocuments(ctx, `
SELECT d.id, d.access_hash, d.file_reference, d.date, d.mime_type, d.size, d.dc_id,
       d.attributes::text AS attributes_json, d.thumbs::text AS thumbs_json
FROM saved_music sm
JOIN documents d ON d.id = sm.document_id
WHERE sm.user_id = $1
  AND sm.document_id = ANY($2::bigint[])
ORDER BY sm.sort_order ASC, sm.document_id ASC`, userID, ids)
	if err != nil {
		return domain.SavedMusicList{}, err
	}
	byID := make(map[int64]domain.Document, len(docs))
	for _, doc := range docs {
		byID[doc.ID] = doc
	}
	seen := make(map[int64]struct{}, len(ids))
	for _, id := range ids {
		if _, ok := seen[id]; ok {
			continue
		}
		seen[id] = struct{}{}
		if doc, ok := byID[id]; ok {
			out.Documents = append(out.Documents, doc)
		}
	}
	return out, nil
}

func (s *PasswordStore) querySavedMusicDocuments(ctx context.Context, sql string, args ...any) ([]domain.Document, error) {
	rows, err := s.db.Query(ctx, sql, args...)
	if err != nil {
		return nil, fmt.Errorf("query saved music documents: %w", err)
	}
	defer rows.Close()
	docs := make([]domain.Document, 0)
	for rows.Next() {
		var row sqlcgen.GetDocumentRow
		if err := rows.Scan(
			&row.ID,
			&row.AccessHash,
			&row.FileReference,
			&row.Date,
			&row.MimeType,
			&row.Size,
			&row.DcID,
			&row.AttributesJson,
			&row.ThumbsJson,
		); err != nil {
			return nil, fmt.Errorf("scan saved music document: %w", err)
		}
		doc, err := documentFromRow(row)
		if err != nil {
			return nil, err
		}
		if doc.IsMusic() {
			docs = append(docs, doc)
		}
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("scan saved music documents: %w", err)
	}
	return docs, nil
}