package postgres import ( "context" "errors" "fmt" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "telesrv/internal/domain" "telesrv/internal/store" "telesrv/internal/store/postgres/sqlcgen" ) // AccountRatingStore is the PostgreSQL implementation of the composite account // rating read model and its contribution ledger. // // account_rating is derived state: it is always rebuildable from the contributing // tables plus the 'manual' rows of account_rating_events, which is exactly what // AccountRatingSignals gathers. Writes use optimistic concurrency on the stored // version so a background recompute and an admin adjustment cannot silently // overwrite each other. type AccountRatingStore struct { db sqlcgen.DBTX } // NewAccountRatingStore builds the store on a pgx pool or transaction. func NewAccountRatingStore(db sqlcgen.DBTX) *AccountRatingStore { return &AccountRatingStore{db: db} } var _ store.AccountRatingStore = (*AccountRatingStore)(nil) const ( defaultAccountRatingListLimit = 50 maxAccountRatingListLimit = 200 ) const accountRatingColumns = `user_id, level, stars, current_level_stars, next_level_stars, stars_component, activity_component, penalty_component, manual_component, pending_stars, pending_date, computed_at, updated_at, version` // accountRatingColumnsQualified is the same projection for queries that join, so // the shared column names stay unambiguous. const accountRatingColumnsQualified = `r.user_id, r.level, r.stars, r.current_level_stars, r.next_level_stars, r.stars_component, r.activity_component, r.penalty_component, r.manual_component, r.pending_stars, r.pending_date, r.computed_at, r.updated_at, r.version` // AccountRating returns the stored projection, distinguishing "never computed" // from "computed as zero". func (s *AccountRatingStore) AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error) { if s == nil || s.db == nil { return domain.AccountRating{}, fmt.Errorf("account rating store is not configured") } if userID <= 0 { return domain.AccountRating{}, domain.ErrAccountRatingNotFound } rating, err := scanAccountRating(s.db.QueryRow(ctx, ` SELECT `+accountRatingColumns+` FROM account_rating WHERE user_id = $1`, userID)) if errors.Is(err, pgx.ErrNoRows) { return domain.AccountRating{}, domain.ErrAccountRatingNotFound } if err != nil { return domain.AccountRating{}, fmt.Errorf("get account rating: %w", err) } return rating, nil } // AccountRatingBatch resolves several users in one round trip. func (s *AccountRatingStore) AccountRatingBatch(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRating, error) { if s == nil || s.db == nil { return nil, fmt.Errorf("account rating store is not configured") } out := make(map[int64]domain.AccountRating, len(userIDs)) filtered := make([]int64, 0, len(userIDs)) for _, userID := range userIDs { if userID > 0 { filtered = append(filtered, userID) } } if len(filtered) == 0 { return out, nil } rows, err := s.db.Query(ctx, ` SELECT `+accountRatingColumns+` FROM account_rating WHERE user_id = ANY($1::bigint[])`, filtered) if err != nil { return nil, fmt.Errorf("list account ratings batch: %w", err) } defer rows.Close() for rows.Next() { rating, err := scanAccountRating(rows) if err != nil { return nil, fmt.Errorf("scan account rating batch: %w", err) } out[rating.UserID] = rating } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate account ratings batch: %w", err) } return out, nil } // SaveAccountRating upserts the projection under optimistic concurrency: the // caller submits the version it intends to write (prev.Version + 1, which is what // domain.ResolveAccountRatingPending produces), and the update only lands when the // stored row is still one version behind. A stale write reports changed=false and // returns the row that won, so the caller can recompute instead of retrying blind. func (s *AccountRatingStore) SaveAccountRating(ctx context.Context, rating domain.AccountRating) (domain.AccountRating, bool, error) { if s == nil || s.db == nil { return domain.AccountRating{}, false, fmt.Errorf("account rating store is not configured") } if rating.UserID <= 0 || rating.Level < 0 || rating.Level > domain.MaxAccountRatingLevel || rating.CurrentLevelStars < 0 || rating.StarsComponent < 0 || rating.ActivityComponent < 0 || rating.PenaltyComponent < 0 { return domain.AccountRating{}, false, domain.ErrAccountRatingAdjustmentInvalid } if rating.Version <= 0 { rating.Version = 1 } now := time.Now().UTC() if rating.ComputedAt.IsZero() { rating.ComputedAt = now } if rating.UpdatedAt.IsZero() { rating.UpdatedAt = now } // The schema pairs pending_stars with pending_date; a half-filled pending // record is normalised away rather than rejected by the CHECK at runtime. if rating.PendingStars == 0 || rating.PendingDate.IsZero() { rating.PendingStars = 0 rating.PendingDate = time.Time{} } var nextLevelStars any if rating.HasNextLevel && rating.NextLevelStars > rating.CurrentLevelStars { nextLevelStars = rating.NextLevelStars } var pendingDate any if rating.PendingStars != 0 { pendingDate = rating.PendingDate.UTC() } stored, err := scanAccountRating(s.db.QueryRow(ctx, ` INSERT INTO account_rating ( user_id, level, stars, current_level_stars, next_level_stars, stars_component, activity_component, penalty_component, manual_component, pending_stars, pending_date, computed_at, updated_at, version ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT (user_id) DO UPDATE SET level = EXCLUDED.level, stars = EXCLUDED.stars, current_level_stars = EXCLUDED.current_level_stars, next_level_stars = EXCLUDED.next_level_stars, stars_component = EXCLUDED.stars_component, activity_component = EXCLUDED.activity_component, penalty_component = EXCLUDED.penalty_component, manual_component = EXCLUDED.manual_component, pending_stars = EXCLUDED.pending_stars, pending_date = EXCLUDED.pending_date, computed_at = EXCLUDED.computed_at, updated_at = EXCLUDED.updated_at, version = EXCLUDED.version WHERE account_rating.version = EXCLUDED.version - 1 RETURNING `+accountRatingColumns, rating.UserID, rating.Level, rating.Stars, rating.CurrentLevelStars, nextLevelStars, rating.StarsComponent, rating.ActivityComponent, rating.PenaltyComponent, rating.ManualComponent, rating.PendingStars, pendingDate, rating.ComputedAt.UTC(), rating.UpdatedAt.UTC(), rating.Version, )) if errors.Is(err, pgx.ErrNoRows) { // The guard rejected the write; report the row that is actually stored. current, getErr := s.AccountRating(ctx, rating.UserID) if getErr != nil { return domain.AccountRating{}, false, getErr } return current, false, nil } if err != nil { return domain.AccountRating{}, false, fmt.Errorf("save account rating: %w", err) } return stored, true, nil } // AccountRatingSignals gathers the raw contribution snapshot for one user. // // Sources, and why each one: // // stars received / spent stars_transactions, split by the sign of amount: that // ledger is the single authoritative record of Stars // movement for a user, and stars_transactions_user_id_idx // (user_id, id DESC) bounds the scan to the user's rows. // gifts received peer_star_gifts for the user peer, restricted to // lifecycle_status = 'active' -- the weight rewards gifts // actually held, not ones converted, burned or exported // away. peer_star_gifts_owner_profile_order_idx is the // partial index on exactly that predicate and leads with // (owner_peer_type, owner_peer_id). // moderation cases moderation_cases against this user peer, restricted to // the statuses that follow a *violation* decision: // 'action_pending' (violation decided, actions running), // 'action_failed' (violation decided, action delivery // broke) and 'resolved' (violation decided and actions // applied). 'dismissed' covers both no_violation and a // granted appeal, and 'open'/'in_review'/'appeal_review' // are undecided, so none of them penalise the account. // scam / fake users.scam / users.fake, the peer flags 0136 added. // account age users.created_at, floored to whole days. // messages sent message_boxes, counted through // message_boxes_private_sender_live_idx // (message_sender_id, private_message_id) WHERE NOT // deleted. This is the cheapest trustworthy source: the // index leads with the sender and is coverable, so the // count needs no heap access. Every private message // materialises one box per participant, hence // count(DISTINCT private_message_id) rather than // count(*). Channel posts are deliberately excluded -- // channel_messages has no sender-leading index, so // attributing them would cost a full table scan. // manual sum of account_rating_events.amount where // kind = 'manual', which is the part of the score that // must survive a full recompute. func (s *AccountRatingStore) AccountRatingSignals(ctx context.Context, userID int64) (domain.AccountRatingSignals, error) { if s == nil || s.db == nil { return domain.AccountRatingSignals{}, fmt.Errorf("account rating store is not configured") } if userID <= 0 { return domain.AccountRatingSignals{}, domain.ErrUserNotFound } signals := domain.AccountRatingSignals{UserID: userID} err := s.db.QueryRow(ctx, ` SELECT COALESCE((SELECT sum(amount) FROM stars_transactions WHERE user_id = u.id AND amount > 0), 0), COALESCE((SELECT -sum(amount) FROM stars_transactions WHERE user_id = u.id AND amount < 0), 0), COALESCE(( SELECT count(DISTINCT private_message_id) FROM message_boxes WHERE message_sender_id = u.id AND NOT deleted ), 0), GREATEST(0, FLOOR(EXTRACT(EPOCH FROM ($2::timestamptz - u.created_at)) / 86400))::bigint, COALESCE(( SELECT count(*) FROM peer_star_gifts WHERE owner_peer_type = 'user' AND owner_peer_id = u.id AND lifecycle_status = 'active' ), 0), COALESCE(( SELECT count(*) FROM moderation_cases WHERE target_peer_type = 'user' AND target_peer_id = u.id AND status IN ('action_pending', 'action_failed', 'resolved') ), 0), u.scam, u.fake, COALESCE(( SELECT sum(amount) FROM account_rating_events WHERE user_id = u.id AND kind = 'manual' ), 0) FROM users u WHERE u.id = $1`, userID, time.Now().UTC()).Scan( &signals.StarsReceived, &signals.StarsSpent, &signals.MessagesSent, &signals.AccountAgeDays, &signals.GiftsReceived, &signals.ModerationCases, &signals.Scam, &signals.Fake, &signals.Manual, ) if errors.Is(err, pgx.ErrNoRows) { return domain.AccountRatingSignals{}, domain.ErrUserNotFound } if err != nil { return domain.AccountRatingSignals{}, fmt.Errorf("gather account rating signals: %w", err) } return signals, nil } // AdjustAccountRating appends a manual adjustment to the ledger. It does not // recompute the projection: the caller pairs it with SaveAccountRating so the new // manual total is folded in through the same formula as every other signal. // Replaying the same CommandKey returns the recorded event and applied=false. func (s *AccountRatingStore) AdjustAccountRating(ctx context.Context, req domain.AdjustAccountRatingRequest) (domain.AccountRatingEvent, bool, error) { if s == nil || s.db == nil { return domain.AccountRatingEvent{}, false, fmt.Errorf("account rating store is not configured") } req.Reason = strings.TrimSpace(req.Reason) req.Actor = strings.TrimSpace(req.Actor) req.CommandKey = strings.TrimSpace(req.CommandKey) if err := req.Validate(); err != nil { return domain.AccountRatingEvent{}, false, err } var event domain.AccountRatingEvent applied := false err := withTx(ctx, s.db, "adjust account rating", func(tx pgx.Tx) error { if existing, found, err := accountRatingEventByCommandKey(ctx, tx, req.CommandKey); err != nil { return err } else if found { event = existing return nil } event = domain.AccountRatingEvent{ UserID: req.UserID, Kind: domain.AccountRatingEventManual, Amount: req.Amount, Reason: req.Reason, Actor: req.Actor, CommandKey: req.CommandKey, CreatedAt: time.Now().UTC(), } // DO NOTHING on the partial command_key index closes the window between the // replay lookup and the insert: a concurrent retry of the same command // records nothing and falls back to reading the row that won. err := tx.QueryRow(ctx, ` INSERT INTO account_rating_events (user_id, kind, amount, reason, actor, command_key, created_at) VALUES ($1,'manual',$2,$3,$4,NULLIF($5,''),$6) ON CONFLICT (command_key) WHERE command_key IS NOT NULL DO NOTHING RETURNING id`, event.UserID, event.Amount, event.Reason, event.Actor, event.CommandKey, event.CreatedAt). Scan(&event.ID) if errors.Is(err, pgx.ErrNoRows) { existing, found, lookupErr := accountRatingEventByCommandKey(ctx, tx, req.CommandKey) if lookupErr != nil { return lookupErr } if !found { return fmt.Errorf("insert account rating adjustment: conflicting command %q vanished", req.CommandKey) } event = existing return nil } if err != nil { return fmt.Errorf("insert account rating adjustment: %w", err) } applied = true return nil }) if err != nil { return domain.AccountRatingEvent{}, false, err } return event, applied, nil } // ListAccountRatings is the admin leaderboard query. The order matches // account_rating_leaderboard_idx (level DESC, stars DESC, user_id) and BeforeID is // a keyset cursor: the cursor row's own (level, stars) are read back so paging // stays consistent across the compound order instead of only over user ids. func (s *AccountRatingStore) ListAccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error) { if s == nil || s.db == nil { return nil, fmt.Errorf("account rating store is not configured") } if filter.MinLevel < 0 || filter.MinLevel > domain.MaxAccountRatingLevel { return nil, domain.ErrAccountRatingAdjustmentInvalid } limit := filter.Limit if limit <= 0 { limit = defaultAccountRatingListLimit } if limit > maxAccountRatingListLimit { limit = maxAccountRatingListLimit } rows, err := s.db.Query(ctx, ` WITH cursor_row AS ( SELECT level AS c_level, stars AS c_stars, user_id AS c_user_id FROM account_rating WHERE $3 <> 0 AND user_id = $3 ) SELECT `+accountRatingColumnsQualified+` FROM account_rating r LEFT JOIN cursor_row c ON true WHERE r.level >= $1 AND ($2 = 0 OR r.user_id = $2) AND ( c.c_user_id IS NULL OR r.level < c.c_level OR (r.level = c.c_level AND r.stars < c.c_stars) OR (r.level = c.c_level AND r.stars = c.c_stars AND r.user_id > c.c_user_id) ) ORDER BY r.level DESC, r.stars DESC, r.user_id LIMIT $4`, filter.MinLevel, filter.UserID, filter.BeforeID, limit) if err != nil { return nil, fmt.Errorf("list account ratings: %w", err) } defer rows.Close() out := make([]domain.AccountRating, 0, limit) for rows.Next() { rating, err := scanAccountRating(rows) if err != nil { return nil, fmt.Errorf("scan account rating: %w", err) } out = append(out, rating) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate account ratings: %w", err) } return out, nil } // AccountRatingEvents returns the ledger for one user, newest first, over // account_rating_events_user_idx (user_id, id DESC). func (s *AccountRatingStore) AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error) { if s == nil || s.db == nil { return nil, fmt.Errorf("account rating store is not configured") } if userID <= 0 { return nil, domain.ErrAccountRatingNotFound } if limit <= 0 { limit = defaultAccountRatingListLimit } if limit > maxAccountRatingListLimit { limit = maxAccountRatingListLimit } rows, err := s.db.Query(ctx, ` SELECT id, user_id, kind, amount, reason, actor, COALESCE(command_key, ''), created_at FROM account_rating_events WHERE user_id = $1 ORDER BY id DESC LIMIT $2`, userID, limit) if err != nil { return nil, fmt.Errorf("list account rating events: %w", err) } defer rows.Close() out := make([]domain.AccountRatingEvent, 0, limit) for rows.Next() { event, err := scanAccountRatingEvent(rows) if err != nil { return nil, fmt.Errorf("scan account rating event: %w", err) } out = append(out, event) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate account rating events: %w", err) } return out, nil } // StaleAccountRatings returns the users whose projection is older than the // horizon, ordered so the walk follows account_rating_stale_idx // (computed_at, user_id) exactly. func (s *AccountRatingStore) StaleAccountRatings(ctx context.Context, olderThanUnix int64, limit int) ([]int64, error) { if s == nil || s.db == nil { return nil, fmt.Errorf("account rating store is not configured") } if olderThanUnix <= 0 { return nil, nil } if limit <= 0 { limit = defaultAccountRatingListLimit } if limit > maxAccountRatingListLimit { limit = maxAccountRatingListLimit } rows, err := s.db.Query(ctx, ` SELECT user_id FROM account_rating WHERE computed_at < to_timestamp($1) ORDER BY computed_at, user_id LIMIT $2`, olderThanUnix, limit) if err != nil { return nil, fmt.Errorf("list stale account ratings: %w", err) } defer rows.Close() out := make([]int64, 0, limit) for rows.Next() { var userID int64 if err := rows.Scan(&userID); err != nil { return nil, fmt.Errorf("scan stale account rating: %w", err) } out = append(out, userID) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate stale account ratings: %w", err) } return out, nil } // UnratedAccounts returns accounts that have no projection yet, oldest account // first so the walk is stable and every account is eventually reached. // // Three kinds of account are skipped, per domain.RatableAccount: bots, which do // not transact on their own behalf; the built-in service accounts, which are // infrastructure -- and note that the platform account is not flagged is_bot, so // excluding bots alone would still have seeded it; and deleted accounts, which are // tombstones whose every profile field has already been cleared. func (s *AccountRatingStore) UnratedAccounts(ctx context.Context, limit int) ([]int64, error) { if s == nil || s.db == nil { return nil, fmt.Errorf("account rating store is not configured") } if limit <= 0 { limit = defaultAccountRatingListLimit } if limit > maxAccountRatingListLimit { limit = maxAccountRatingListLimit } rows, err := s.db.Query(ctx, ` SELECT u.id FROM users u WHERE NOT u.is_bot AND u.deleted_at IS NULL AND u.id <> ALL($2::bigint[]) AND NOT EXISTS (SELECT 1 FROM account_rating r WHERE r.user_id = u.id) ORDER BY u.created_at, u.id LIMIT $1`, limit, domain.SystemUserIDs()) if err != nil { return nil, fmt.Errorf("list unrated accounts: %w", err) } defer rows.Close() out := make([]int64, 0, limit) for rows.Next() { var userID int64 if err := rows.Scan(&userID); err != nil { return nil, fmt.Errorf("scan unrated account: %w", err) } out = append(out, userID) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate unrated accounts: %w", err) } return out, nil } func accountRatingEventByCommandKey(ctx context.Context, db sqlcgen.DBTX, commandKey string) (domain.AccountRatingEvent, bool, error) { if commandKey == "" { return domain.AccountRatingEvent{}, false, nil } event, err := scanAccountRatingEvent(db.QueryRow(ctx, ` SELECT id, user_id, kind, amount, reason, actor, COALESCE(command_key, ''), created_at FROM account_rating_events WHERE command_key = $1`, commandKey)) if errors.Is(err, pgx.ErrNoRows) { return domain.AccountRatingEvent{}, false, nil } if err != nil { return domain.AccountRatingEvent{}, false, fmt.Errorf("lookup account rating command: %w", err) } return event, true, nil } func scanAccountRating(row pgx.Row) (domain.AccountRating, error) { var rating domain.AccountRating var nextLevelStars pgtype.Int8 var pendingDate pgtype.Timestamptz if err := row.Scan(&rating.UserID, &rating.Level, &rating.Stars, &rating.CurrentLevelStars, &nextLevelStars, &rating.StarsComponent, &rating.ActivityComponent, &rating.PenaltyComponent, &rating.ManualComponent, &rating.PendingStars, &pendingDate, &rating.ComputedAt, &rating.UpdatedAt, &rating.Version); err != nil { return domain.AccountRating{}, err } if nextLevelStars.Valid { rating.NextLevelStars = nextLevelStars.Int64 rating.HasNextLevel = true } if pendingDate.Valid { rating.PendingDate = pendingDate.Time.UTC() } rating.ComputedAt = rating.ComputedAt.UTC() rating.UpdatedAt = rating.UpdatedAt.UTC() return rating, nil } func scanAccountRatingEvent(row pgx.Row) (domain.AccountRatingEvent, error) { var event domain.AccountRatingEvent var kind string if err := row.Scan(&event.ID, &event.UserID, &kind, &event.Amount, &event.Reason, &event.Actor, &event.CommandKey, &event.CreatedAt); err != nil { return domain.AccountRatingEvent{}, err } event.Kind = domain.AccountRatingEventKind(kind) event.CreatedAt = event.CreatedAt.UTC() return event, nil }