owpengram-server/internal/store/postgres/channel_boosts.go

543 lines
18 KiB
Go

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)
}