feat: add NFT usernames and bot verification (#22)
Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review. The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation. Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9 Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
This commit is contained in:
parent
b0fd3976f1
commit
fff8de783a
169 changed files with 55769 additions and 282 deletions
557
internal/store/postgres/account_rating.go
Normal file
557
internal/store/postgres/account_rating.go
Normal file
|
|
@ -0,0 +1,557 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue