Ctrl K
ComunidadGuías…

internal/store/postgres/channel_boosts.go

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

Archivo original ↗Preguntar a KiBot
package postgres

import (
	"context"
	"errors"
	"fmt"
	"strconv"
	"strings"

	"github.com/jackc/pgx/v5"

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

func (s *ChannelStore) GetPremiumBoostStatus(ctx context.Context, viewerUserID, channelID int64, now int) (domain.PremiumBoostStatus, error) {
	if viewerUserID == 0 || channelID == 0 {
		return domain.PremiumBoostStatus{}, domain.ErrChannelInvalid
	}
	if _, _, _, err := s.getChannelForViewer(ctx, s.db, viewerUserID, channelID); err != nil {
		return domain.PremiumBoostStatus{}, err
	}
	peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}
	total, err := s.countActiveBoostsForPeer(ctx, s.db, peer, now)
	if err != nil {
		return domain.PremiumBoostStatus{}, err
	}
	my, err := listActiveUserBoostsForPeer(ctx, s.db, viewerUserID, peer, now)
	if err != nil {
		return domain.PremiumBoostStatus{}, err
	}
	return domain.PremiumBoostStatusForCount(peer, total, my), nil
}

func (s *ChannelStore) ListPremiumBoosts(ctx context.Context, viewerUserID, channelID int64, gifts bool, offset string, limit, now int) (domain.PremiumBoostList, error) {
	if viewerUserID == 0 || channelID == 0 {
		return domain.PremiumBoostList{}, domain.ErrChannelInvalid
	}
	if limit <= 0 || limit > domain.MaxPremiumBoostsListLimit {
		limit = domain.MaxPremiumBoostsListLimit
	}
	_, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID)
	if err != nil {
		return domain.PremiumBoostList{}, err
	}
	if member.Role != domain.ChannelRoleCreator && member.Role != domain.ChannelRoleAdmin {
		return domain.PremiumBoostList{}, domain.ErrChannelAdminRequired
	}
	cursor, err := parseBoostCursor(offset)
	if err != nil {
		return domain.PremiumBoostList{}, domain.ErrChannelInvalid
	}
	return listPremiumBoostsPage(ctx, s.db, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, gifts, cursor, limit, now)
}

func (s *ChannelStore) GetPremiumMyBoosts(ctx context.Context, userID int64, now, premiumUntil int) (domain.PremiumMyBoosts, error) {
	if userID == 0 {
		return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid
	}
	return s.premiumMyBoosts(ctx, s.db, userID, now, premiumUntil)
}

func (s *ChannelStore) ApplyPremiumBoost(ctx context.Context, userID, channelID int64, slots []int, now, premiumUntil int) (domain.PremiumMyBoosts, error) {
	if userID == 0 || channelID == 0 || len(slots) == 0 {
		return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid
	}
	if premiumUntil <= now {
		return domain.PremiumMyBoosts{}, domain.ErrPremiumRequired
	}
	beginner, ok := s.db.(txBeginner)
	if !ok {
		return domain.PremiumMyBoosts{}, fmt.Errorf("apply premium boost: db does not support transactions")
	}
	tx, err := beginner.Begin(ctx)
	if err != nil {
		return domain.PremiumMyBoosts{}, fmt.Errorf("begin apply premium boost: %w", err)
	}
	committed := false
	defer func() {
		if !committed {
			_ = tx.Rollback(ctx)
		}
	}()
	if _, _, err := s.getChannelForMember(ctx, tx, userID, channelID); err != nil {
		return domain.PremiumMyBoosts{}, err
	}
	peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}
	invalidatedPeers := make(map[domain.Peer]struct{}, len(slots)+1)
	for _, slotID := range slots {
		if slotID != domain.DefaultPremiumBoostSlotID {
			return domain.PremiumMyBoosts{}, domain.ErrChannelInvalid
		}
		current, err := getPremiumBoostSlotForUpdate(ctx, tx, userID, slotID)
		if err != nil {
			return domain.PremiumMyBoosts{}, err
		}
		next, changed, err := domain.ApplyPremiumBoostSlot(
			current,
			userID,
			slotID,
			peer,
			now,
			premiumUntil,
			domain.DefaultPremiumBoostReassignCooldownSeconds,
		)
		if err != nil {
			return domain.PremiumMyBoosts{}, err
		}
		if !changed {
			continue
		}
		if current.Assigned(now) {
			invalidatedPeers[current.Peer] = struct{}{}
		}
		if next.Assigned(now) {
			invalidatedPeers[next.Peer] = struct{}{}
		}
		if err := upsertPremiumBoostSlot(ctx, tx, next); err != nil {
			return domain.PremiumMyBoosts{}, fmt.Errorf("upsert premium boost slot: %w", err)
		}
	}
	if err := tx.Commit(ctx); err != nil {
		return domain.PremiumMyBoosts{}, fmt.Errorf("commit apply premium boost: %w", err)
	}
	committed = true
	s.invalidateBoostCacheForUserAndPeers(userID, invalidatedPeers)
	return s.premiumMyBoosts(ctx, s.db, userID, now, premiumUntil)
}

func getPremiumBoostSlotForUpdate(ctx context.Context, db sqlcgen.DBTX, userID int64, slotID int) (domain.PremiumBoostSlot, error) {
	row := db.QueryRow(ctx, `
SELECT user_id, slot, peer_type, peer_id, assigned_at, expires_at, cooldown_until, multiplier,
       gift, giveaway, unclaimed, giveaway_msg_id, used_gift_slug, stars
FROM channel_boost_slots
WHERE user_id = $1 AND slot = $2
FOR UPDATE`, userID, slotID)
	slot, err := scanPremiumBoostSlot(row)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return domain.PremiumBoostSlot{}, nil
		}
		return domain.PremiumBoostSlot{}, fmt.Errorf("lock premium boost slot: %w", err)
	}
	return slot, nil
}

func upsertPremiumBoostSlot(ctx context.Context, db sqlcgen.DBTX, slot domain.PremiumBoostSlot) error {
	peerType := string(slot.Peer.Type)
	peerID := slot.Peer.ID
	if peerID == 0 {
		peerType = ""
	}
	_, err := db.Exec(ctx, `
INSERT INTO channel_boost_slots (
    user_id, slot, peer_type, peer_id, assigned_at, expires_at, cooldown_until, multiplier,
    gift, giveaway, unclaimed, giveaway_msg_id, used_gift_slug, stars
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
ON CONFLICT (user_id, slot) DO UPDATE SET
    peer_type = EXCLUDED.peer_type,
    peer_id = EXCLUDED.peer_id,
    assigned_at = EXCLUDED.assigned_at,
    expires_at = EXCLUDED.expires_at,
    cooldown_until = EXCLUDED.cooldown_until,
    multiplier = EXCLUDED.multiplier,
    gift = EXCLUDED.gift,
    giveaway = EXCLUDED.giveaway,
    unclaimed = EXCLUDED.unclaimed,
    giveaway_msg_id = EXCLUDED.giveaway_msg_id,
    used_gift_slug = EXCLUDED.used_gift_slug,
    stars = EXCLUDED.stars,
    updated_at = now()`,
		slot.UserID,
		slot.Slot,
		peerType,
		peerID,
		slot.Date,
		slot.Expires,
		slot.CooldownUntil,
		slot.Multiplier,
		slot.Gift,
		slot.Giveaway,
		slot.Unclaimed,
		slot.GiveawayMsgID,
		slot.UsedGiftSlug,
		slot.Stars,
	)
	return err
}

func (s *ChannelStore) GetPremiumUserBoosts(ctx context.Context, viewerUserID, channelID, targetUserID int64, now int) (domain.PremiumBoostList, error) {
	if viewerUserID == 0 || channelID == 0 || targetUserID == 0 {
		return domain.PremiumBoostList{}, domain.ErrChannelInvalid
	}
	_, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID)
	if err != nil {
		return domain.PremiumBoostList{}, err
	}
	if member.Role != domain.ChannelRoleCreator && member.Role != domain.ChannelRoleAdmin {
		return domain.PremiumBoostList{}, domain.ErrChannelAdminRequired
	}
	peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}
	items, err := listActiveUserBoostsForPeer(ctx, s.db, targetUserID, peer, now)
	if err != nil {
		return domain.PremiumBoostList{}, err
	}
	users, err := listUsersByIDs(ctx, s.db, []int64{targetUserID})
	if err != nil {
		return domain.PremiumBoostList{}, err
	}
	return domain.PremiumBoostList{Count: len(items), Boosts: items, Users: users}, nil
}

func (s *ChannelStore) SetBoostsToUnblockRestrictions(ctx context.Context, userID, channelID int64, boosts int) (domain.Channel, error) {
	if userID == 0 || channelID == 0 || boosts < 0 || boosts > domain.MaxChannelBoostsToUnblockRestrictions {
		return domain.Channel{}, domain.ErrChannelInvalid
	}
	channel, member, err := s.getChannelForMember(ctx, s.db, userID, channelID)
	if err != nil {
		return domain.Channel{}, err
	}
	if !canChangeChannelInfo(member) {
		return domain.Channel{}, domain.ErrChannelAdminRequired
	}
	if _, err := s.db.Exec(ctx, `UPDATE channels SET boosts_unrestrict = $2, updated_at = now() WHERE id = $1`, channelID, boosts); err != nil {
		return domain.Channel{}, fmt.Errorf("set boosts unrestrict: %w", err)
	}
	channel.BoostsUnrestrict = boosts
	return channel, nil
}

func (s *ChannelStore) premiumMyBoosts(ctx context.Context, db sqlcgen.DBTX, userID int64, now, premiumUntil int) (domain.PremiumMyBoosts, error) {
	out := domain.PremiumMyBoosts{}
	if premiumUntil <= now {
		return out, nil
	}
	rows, err := db.Query(ctx, `
SELECT user_id, slot, peer_type, peer_id, assigned_at, expires_at, cooldown_until, multiplier,
       gift, giveaway, unclaimed, giveaway_msg_id, used_gift_slug, stars
FROM channel_boost_slots
WHERE user_id = $1
  AND (expires_at = 0 OR expires_at > $2)
ORDER BY slot ASC`, userID, now)
	if err != nil {
		return domain.PremiumMyBoosts{}, fmt.Errorf("list my boost slots: %w", err)
	}
	defer rows.Close()
	foundBase := false
	channelRefs := make(map[int64]struct{})
	for rows.Next() {
		slot, err := scanPremiumBoostSlot(rows)
		if err != nil {
			return domain.PremiumMyBoosts{}, err
		}
		if slot.Slot == domain.DefaultPremiumBoostSlotID {
			foundBase = true
			if slot.Expires < premiumUntil {
				slot.Expires = premiumUntil
			}
		}
		out.Slots = append(out.Slots, slot)
		if slot.Peer.Type == domain.PeerTypeChannel && slot.Peer.ID != 0 {
			channelRefs[slot.Peer.ID] = struct{}{}
		}
	}
	if err := rows.Err(); err != nil {
		return domain.PremiumMyBoosts{}, err
	}
	if !foundBase {
		out.Slots = append(out.Slots, domain.PremiumBoostSlot{
			UserID:     userID,
			Slot:       domain.DefaultPremiumBoostSlotID,
			Multiplier: 1,
		})
	}
	if len(channelRefs) > 0 {
		channels, err := listChannelsByIDsInOrder(ctx, db, mapKeysInt64(channelRefs))
		if err != nil {
			return domain.PremiumMyBoosts{}, err
		}
		out.Channels = channels
	}
	return out, nil
}

func countActiveBoostsForPeer(ctx context.Context, db sqlcgen.DBTX, peer domain.Peer, now int) (int, error) {
	if peer.Type != domain.PeerTypeChannel || peer.ID == 0 {
		return 0, domain.ErrChannelInvalid
	}
	var total int
	if err := db.QueryRow(ctx, `
SELECT COALESCE(SUM(multiplier), 0)::int
FROM channel_boost_slots
WHERE peer_type = $1
  AND peer_id = $2
  AND (expires_at = 0 OR expires_at > $3)`, string(peer.Type), peer.ID, now).Scan(&total); err != nil {
		return 0, fmt.Errorf("count peer boosts: %w", err)
	}
	return total, nil
}

func (s *ChannelStore) countActiveBoostsForPeer(ctx context.Context, db sqlcgen.DBTX, peer domain.Peer, now int) (int, error) {
	if !s.boostCacheActive(db) {
		return countActiveBoostsForPeer(ctx, db, peer, now)
	}
	return s.boostCache.getPeerTotalOrLoad(ctx, peer, func() (int, error) {
		return countActiveBoostsForPeer(ctx, db, peer, now)
	})
}

func countActiveUserBoostsForPeer(ctx context.Context, db sqlcgen.DBTX, userID int64, peer domain.Peer, now int) (int, error) {
	if userID == 0 || peer.Type != domain.PeerTypeChannel || peer.ID == 0 {
		return 0, domain.ErrChannelInvalid
	}
	var total int
	if err := db.QueryRow(ctx, `
SELECT COALESCE(SUM(multiplier), 0)::int
FROM channel_boost_slots
WHERE user_id = $1
  AND peer_type = $2
  AND peer_id = $3
  AND (expires_at = 0 OR expires_at > $4)`, userID, string(peer.Type), peer.ID, now).Scan(&total); err != nil {
		return 0, fmt.Errorf("count user peer boosts: %w", err)
	}
	return total, nil
}

func (s *ChannelStore) countActiveUserBoostsForPeer(ctx context.Context, db sqlcgen.DBTX, userID int64, peer domain.Peer, now int) (int, error) {
	if !s.boostCacheActive(db) {
		return countActiveUserBoostsForPeer(ctx, db, userID, peer, now)
	}
	return s.boostCache.getOrLoad(ctx, userID, peer, func() (int, error) {
		return countActiveUserBoostsForPeer(ctx, db, userID, peer, now)
	})
}

func countActiveUserBoostsForChannels(ctx context.Context, db sqlcgen.DBTX, userID int64, channelIDs []int64, now int) (map[int64]int, error) {
	out := make(map[int64]int)
	ids := uniqueNonZeroInt64s(channelIDs...)
	if userID == 0 || len(ids) == 0 {
		return out, nil
	}
	rows, err := db.Query(ctx, `
SELECT peer_id, COALESCE(SUM(multiplier), 0)::int
FROM channel_boost_slots
WHERE user_id = $1
  AND peer_type = 'channel'
  AND peer_id = ANY($2::bigint[])
  AND (expires_at = 0 OR expires_at > $3)
GROUP BY peer_id`, userID, ids, now)
	if err != nil {
		return nil, fmt.Errorf("count user channel boosts: %w", err)
	}
	defer rows.Close()
	for rows.Next() {
		var channelID int64
		var boosts int
		if err := rows.Scan(&channelID, &boosts); err != nil {
			return nil, err
		}
		out[channelID] = boosts
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return out, nil
}

func (s *ChannelStore) countActiveUserBoostsForChannels(ctx context.Context, db sqlcgen.DBTX, userID int64, channelIDs []int64, now int) (map[int64]int, error) {
	if !s.boostCacheActive(db) {
		return countActiveUserBoostsForChannels(ctx, db, userID, channelIDs, now)
	}
	out := make(map[int64]int)
	misses := make([]int64, 0, len(channelIDs))
	for _, channelID := range uniqueNonZeroInt64s(channelIDs...) {
		peer := domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}
		if boosts, ok := s.boostCache.get(userID, peer); ok {
			out[channelID] = boosts
			continue
		}
		misses = append(misses, channelID)
	}
	if len(misses) == 0 {
		return out, nil
	}
	loaded, err := countActiveUserBoostsForChannels(ctx, db, userID, misses, now)
	if err != nil {
		return nil, err
	}
	for _, channelID := range misses {
		boosts := loaded[channelID]
		out[channelID] = boosts
		s.boostCache.put(userID, domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, boosts)
	}
	return out, nil
}

func (s *ChannelStore) invalidateBoostCacheForUserAndPeers(userID int64, peers map[domain.Peer]struct{}) {
	if s.boostCache == nil {
		return
	}
	s.boostCache.deleteUser(userID)
	for peer := range peers {
		s.boostCache.deletePeerTotal(peer)
	}
}

func listActiveUserBoostsForPeer(ctx context.Context, db sqlcgen.DBTX, userID int64, peer domain.Peer, now int) ([]domain.PremiumBoostSlot, error) {
	rows, err := db.Query(ctx, `
SELECT user_id, slot, peer_type, peer_id, assigned_at, expires_at, cooldown_until, multiplier,
       gift, giveaway, unclaimed, giveaway_msg_id, used_gift_slug, stars
FROM channel_boost_slots
WHERE user_id = $1
  AND peer_type = $2
  AND peer_id = $3
  AND (expires_at = 0 OR expires_at > $4)
ORDER BY assigned_at DESC, user_id ASC, slot ASC`, userID, string(peer.Type), peer.ID, now)
	if err != nil {
		return nil, fmt.Errorf("list user boosts for peer: %w", err)
	}
	defer rows.Close()
	return scanPremiumBoostSlots(rows)
}

type boostCursor struct {
	assignedAt int
	userID     int64
	slot       int
	set        bool
}

func listPremiumBoostsPage(ctx context.Context, db sqlcgen.DBTX, peer domain.Peer, gifts bool, cursor boostCursor, limit, now int) (domain.PremiumBoostList, error) {
	var count int
	if err := db.QueryRow(ctx, `
SELECT COUNT(*)::int
FROM channel_boost_slots
WHERE peer_type = $1
  AND peer_id = $2
  AND (expires_at = 0 OR expires_at > $3)
  AND (NOT $4::boolean OR gift OR giveaway)`, string(peer.Type), peer.ID, now, gifts).Scan(&count); err != nil {
		return domain.PremiumBoostList{}, fmt.Errorf("count premium boosts: %w", err)
	}
	args := []any{string(peer.Type), peer.ID, now, gifts, limit + 1}
	cursorSQL := ""
	if cursor.set {
		args = append(args, cursor.assignedAt, cursor.userID, cursor.slot)
		cursorSQL = fmt.Sprintf(` AND (
    assigned_at < $%d
    OR (assigned_at = $%d AND user_id > $%d)
    OR (assigned_at = $%d AND user_id = $%d AND slot > $%d)
  )`, len(args)-2, len(args)-2, len(args)-1, len(args)-2, len(args)-1, len(args))
	}
	rows, err := db.Query(ctx, `
SELECT user_id, slot, peer_type, peer_id, assigned_at, expires_at, cooldown_until, multiplier,
       gift, giveaway, unclaimed, giveaway_msg_id, used_gift_slug, stars
FROM channel_boost_slots
WHERE peer_type = $1
  AND peer_id = $2
  AND (expires_at = 0 OR expires_at > $3)
  AND (NOT $4::boolean OR gift OR giveaway)`+cursorSQL+`
ORDER BY assigned_at DESC, user_id ASC, slot ASC
LIMIT $5`, args...)
	if err != nil {
		return domain.PremiumBoostList{}, fmt.Errorf("list premium boosts: %w", err)
	}
	defer rows.Close()
	items, err := scanPremiumBoostSlots(rows)
	if err != nil {
		return domain.PremiumBoostList{}, err
	}
	next := ""
	if len(items) > limit {
		last := items[limit-1]
		next = formatBoostCursor(last)
		items = items[:limit]
	}
	userIDs := make([]int64, 0, len(items))
	for _, item := range items {
		userIDs = append(userIDs, item.UserID)
	}
	users, err := listUsersByIDs(ctx, db, uniqueNonZeroInt64s(userIDs...))
	if err != nil {
		return domain.PremiumBoostList{}, err
	}
	return domain.PremiumBoostList{Count: count, Boosts: items, Users: users, NextOffset: next}, nil
}

func scanPremiumBoostSlots(rows pgx.Rows) ([]domain.PremiumBoostSlot, error) {
	items := make([]domain.PremiumBoostSlot, 0)
	for rows.Next() {
		slot, err := scanPremiumBoostSlot(rows)
		if err != nil {
			return nil, err
		}
		items = append(items, slot)
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	return items, nil
}

func scanPremiumBoostSlot(row rowScanner) (domain.PremiumBoostSlot, error) {
	var slot domain.PremiumBoostSlot
	var peerType string
	if err := row.Scan(
		&slot.UserID, &slot.Slot, &peerType, &slot.Peer.ID, &slot.Date, &slot.Expires, &slot.CooldownUntil, &slot.Multiplier,
		&slot.Gift, &slot.Giveaway, &slot.Unclaimed, &slot.GiveawayMsgID, &slot.UsedGiftSlug, &slot.Stars,
	); err != nil {
		return domain.PremiumBoostSlot{}, err
	}
	slot.Peer.Type = domain.PeerType(peerType)
	if slot.Peer.Type == "" || slot.Peer.ID == 0 {
		slot.Peer = domain.Peer{}
	}
	return slot, nil
}

func parseBoostCursor(raw string) (boostCursor, error) {
	if raw == "" {
		return boostCursor{}, nil
	}
	parts := strings.Split(raw, ":")
	if len(parts) != 3 {
		return boostCursor{}, fmt.Errorf("invalid boost cursor")
	}
	assignedAt, err := strconv.Atoi(parts[0])
	if err != nil || assignedAt < 0 {
		return boostCursor{}, fmt.Errorf("invalid boost cursor")
	}
	userID, err := strconv.ParseInt(parts[1], 10, 64)
	if err != nil || userID <= 0 {
		return boostCursor{}, fmt.Errorf("invalid boost cursor")
	}
	slot, err := strconv.Atoi(parts[2])
	if err != nil || slot <= 0 {
		return boostCursor{}, fmt.Errorf("invalid boost cursor")
	}
	return boostCursor{assignedAt: assignedAt, userID: userID, slot: slot, set: true}, nil
}

func formatBoostCursor(slot domain.PremiumBoostSlot) string {
	return fmt.Sprintf("%d:%d:%d", slot.Date, slot.UserID, slot.Slot)
}