package db import ( "context" "database/sql" "fmt" "time" _ "github.com/lib/pq" ) type DB struct { conn *sql.DB } type UserStats struct { UserID string Username string TotalKM float64 LogCount int LastUpdated string } type RideLog struct { ID int64 KM float64 MessageID string LoggedAt time.Time } type ChallengeArchive struct { Name string TotalKM float64 Riders int StartDate time.Time EndDate time.Time } func Open(connStr string) (*DB, error) { conn, err := sql.Open("postgres", connStr) if err != nil { return nil, fmt.Errorf("open db: %w", err) } if err := conn.Ping(); err != nil { return nil, fmt.Errorf("ping db: %w", err) } d := &DB{conn: conn} if err := d.migrate(); err != nil { return nil, fmt.Errorf("migrate: %w", err) } return d, nil } func (d *DB) Close() error { return d.conn.Close() } func (d *DB) migrate() error { _, err := d.conn.Exec(` CREATE TABLE IF NOT EXISTS distance_logs ( id SERIAL PRIMARY KEY, guild_id TEXT NOT NULL DEFAULT '', user_id TEXT NOT NULL, username TEXT NOT NULL, km DOUBLE PRECISION NOT NULL, message_id TEXT NOT NULL UNIQUE, channel_id TEXT NOT NULL, logged_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS settings ( guild_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (guild_id, key) ); CREATE TABLE IF NOT EXISTS challenge_archive ( id SERIAL PRIMARY KEY, guild_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', total_km DOUBLE PRECISION NOT NULL, riders INTEGER NOT NULL, start_date TIMESTAMPTZ NOT NULL, end_date TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS user_preferences ( user_id TEXT NOT NULL, guild_id TEXT NOT NULL, unit TEXT NOT NULL DEFAULT 'km', PRIMARY KEY (user_id, guild_id) ); CREATE TABLE IF NOT EXISTS kudos ( id SERIAL PRIMARY KEY, guild_id TEXT NOT NULL, from_user_id TEXT NOT NULL, from_username TEXT NOT NULL, to_user_id TEXT NOT NULL, to_username TEXT NOT NULL, given_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP ); `) if err != nil { return err } _, _ = d.conn.Exec(`ALTER TABLE distance_logs ADD COLUMN IF NOT EXISTS guild_id TEXT NOT NULL DEFAULT ''`) return nil } // ── Settings ───────────────────────────────────────────────────────────────── func (d *DB) GetSetting(ctx context.Context, guildID, key string) (string, bool, error) { var value string err := d.conn.QueryRowContext(ctx, `SELECT value FROM settings WHERE guild_id = $1 AND key = $2`, guildID, key, ).Scan(&value) if err == sql.ErrNoRows { return "", false, nil } return value, err == nil, err } func (d *DB) SetSetting(ctx context.Context, guildID, key, value string) error { _, err := d.conn.ExecContext(ctx, ` INSERT INTO settings (guild_id, key, value) VALUES ($1, $2, $3) ON CONFLICT(guild_id, key) DO UPDATE SET value = EXCLUDED.value `, guildID, key, value) return err } func (d *DB) DeleteSetting(ctx context.Context, guildID, key string) error { _, err := d.conn.ExecContext(ctx, `DELETE FROM settings WHERE guild_id = $1 AND key = $2`, guildID, key) return err } // ── Core Logging ────────────────────────────────────────────────────────────── func (d *DB) AddLog(ctx context.Context, guildID, userID, username, messageID, channelID string, km float64) (bool, error) { res, err := d.conn.ExecContext(ctx, ` INSERT INTO distance_logs (guild_id, user_id, username, km, message_id, channel_id) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT(message_id) DO NOTHING `, guildID, userID, username, km, messageID, channelID) if err != nil { return false, fmt.Errorf("insert log: %w", err) } rows, _ := res.RowsAffected() return rows > 0, nil } func (d *DB) RemoveLog(ctx context.Context, messageID string) (float64, error) { var km float64 err := d.conn.QueryRowContext(ctx, `SELECT km FROM distance_logs WHERE message_id = $1`, messageID).Scan(&km) if err == sql.ErrNoRows { return 0, nil } if err != nil { return 0, err } _, err = d.conn.ExecContext(ctx, `DELETE FROM distance_logs WHERE message_id = $1`, messageID) return km, err } func (d *DB) AdjustKM(ctx context.Context, guildID, userID, username string, km float64) error { _, err := d.conn.ExecContext(ctx, ` INSERT INTO distance_logs (guild_id, user_id, username, km, message_id, channel_id) VALUES ($1, $2, $3, $4, 'manual-' || gen_random_uuid()::text, 'admin') `, guildID, userID, username, km) return err } // ── Stats Queries ───────────────────────────────────────────────────────────── func (d *DB) GetLeaderboard(ctx context.Context, guildID string, since time.Time, limit int) ([]*UserStats, error) { q := `SELECT user_id, username, SUM(km), COUNT(*), MAX(logged_at) FROM distance_logs WHERE guild_id = $1` args := []interface{}{guildID} if !since.IsZero() { args = append(args, since) q += fmt.Sprintf(` AND logged_at >= $%d`, len(args)) } args = append(args, limit) q += fmt.Sprintf(` GROUP BY user_id, username ORDER BY SUM(km) DESC LIMIT $%d`, len(args)) rows, err := d.conn.QueryContext(ctx, q, args...) if err != nil { return nil, err } defer rows.Close() var results []*UserStats for rows.Next() { s := &UserStats{} var lastUpdated time.Time if err := rows.Scan(&s.UserID, &s.Username, &s.TotalKM, &s.LogCount, &lastUpdated); err != nil { return nil, err } s.LastUpdated = lastUpdated.Format(time.RFC3339) results = append(results, s) } return results, rows.Err() } func (d *DB) GetTotalKM(ctx context.Context, guildID string, since time.Time) (float64, error) { q := `SELECT COALESCE(SUM(km), 0) FROM distance_logs WHERE guild_id = $1` args := []interface{}{guildID} if !since.IsZero() { args = append(args, since) q += fmt.Sprintf(` AND logged_at >= $%d`, len(args)) } var total float64 err := d.conn.QueryRowContext(ctx, q, args...).Scan(&total) return total, err } func (d *DB) GetUserStats(ctx context.Context, guildID, userID string, since time.Time) (*UserStats, error) { q := `SELECT user_id, username, COALESCE(SUM(km), 0), COUNT(*), COALESCE(MAX(logged_at)::text, '') FROM distance_logs WHERE guild_id = $1 AND user_id = $2` args := []interface{}{guildID, userID} if !since.IsZero() { args = append(args, since) q += fmt.Sprintf(` AND logged_at >= $%d`, len(args)) } q += ` GROUP BY user_id, username` s := &UserStats{} err := d.conn.QueryRowContext(ctx, q, args...).Scan( &s.UserID, &s.Username, &s.TotalKM, &s.LogCount, &s.LastUpdated) if err == sql.ErrNoRows { return &UserStats{UserID: userID}, nil } return s, err } func (d *DB) GetStatsInRange(ctx context.Context, guildID string, from, to time.Time, limit int) ([]*UserStats, error) { rows, err := d.conn.QueryContext(ctx, ` SELECT user_id, username, SUM(km), COUNT(*), MAX(logged_at) FROM distance_logs WHERE guild_id = $1 AND logged_at >= $2 AND logged_at <= $3 GROUP BY user_id, username ORDER BY SUM(km) DESC LIMIT $4 `, guildID, from, to, limit) if err != nil { return nil, err } defer rows.Close() var results []*UserStats for rows.Next() { s := &UserStats{} var lastUpdated time.Time if err := rows.Scan(&s.UserID, &s.Username, &s.TotalKM, &s.LogCount, &lastUpdated); err != nil { return nil, err } s.LastUpdated = lastUpdated.Format(time.RFC3339) results = append(results, s) } return results, rows.Err() } func (d *DB) GetYearTotals(ctx context.Context, guildID string) ([]YearTotal, error) { rows, err := d.conn.QueryContext(ctx, ` SELECT EXTRACT(YEAR FROM logged_at)::INTEGER as yr, SUM(km) FROM distance_logs WHERE guild_id = $1 GROUP BY yr ORDER BY yr DESC `, guildID) if err != nil { return nil, err } defer rows.Close() var results []YearTotal for rows.Next() { var yt YearTotal if err := rows.Scan(&yt.Year, &yt.TotalKM); err != nil { return nil, err } results = append(results, yt) } return results, rows.Err() } type YearTotal struct { Year int TotalKM float64 } func (d *DB) GetYearlyLeaderboard(ctx context.Context, guildID string, year, limit int) ([]*UserStats, error) { rows, err := d.conn.QueryContext(ctx, ` SELECT user_id, username, SUM(km), COUNT(*), MAX(logged_at) FROM distance_logs WHERE guild_id = $1 AND EXTRACT(YEAR FROM logged_at) = $2 GROUP BY user_id, username ORDER BY SUM(km) DESC LIMIT $3 `, guildID, year, limit) if err != nil { return nil, err } defer rows.Close() var results []*UserStats for rows.Next() { s := &UserStats{} var lastUpdated time.Time if err := rows.Scan(&s.UserID, &s.Username, &s.TotalKM, &s.LogCount, &lastUpdated); err != nil { return nil, err } s.LastUpdated = lastUpdated.Format(time.RFC3339) results = append(results, s) } return results, rows.Err() } func (d *DB) GetUserYearlyStats(ctx context.Context, guildID, userID string, year int) (*UserStats, error) { s := &UserStats{UserID: userID} var lastUpdated sql.NullTime err := d.conn.QueryRowContext(ctx, ` SELECT user_id, username, COALESCE(SUM(km), 0), COUNT(*), MAX(logged_at) FROM distance_logs WHERE guild_id = $1 AND user_id = $2 AND EXTRACT(YEAR FROM logged_at) = $3 GROUP BY user_id, username `, guildID, userID, year).Scan(&s.UserID, &s.Username, &s.TotalKM, &s.LogCount, &lastUpdated) if err == sql.ErrNoRows { return s, nil } if lastUpdated.Valid { s.LastUpdated = lastUpdated.Time.Format(time.RFC3339) } return s, err } // ── Personal Stats ──────────────────────────────────────────────────────────── func (d *DB) GetUserHistory(ctx context.Context, guildID, userID string, limit int) ([]*RideLog, error) { rows, err := d.conn.QueryContext(ctx, ` SELECT id, km, message_id, logged_at FROM distance_logs WHERE guild_id = $1 AND user_id = $2 ORDER BY logged_at DESC LIMIT $3 `, guildID, userID, limit) if err != nil { return nil, err } defer rows.Close() var logs []*RideLog for rows.Next() { l := &RideLog{} if err := rows.Scan(&l.ID, &l.KM, &l.MessageID, &l.LoggedAt); err != nil { return nil, err } logs = append(logs, l) } return logs, rows.Err() } func (d *DB) GetUserPB(ctx context.Context, guildID, userID string) (float64, error) { var pb float64 err := d.conn.QueryRowContext(ctx, `SELECT COALESCE(MAX(km), 0) FROM distance_logs WHERE guild_id = $1 AND user_id = $2`, guildID, userID).Scan(&pb) return pb, err } func (d *DB) GetUserStreak(ctx context.Context, userID string) (int, error) { rows, err := d.conn.QueryContext(ctx, ` SELECT DISTINCT logged_at::date as ride_date FROM distance_logs WHERE user_id = $1 ORDER BY ride_date DESC `, userID) if err != nil { return 0, err } defer rows.Close() var dates []time.Time for rows.Next() { var t time.Time if err := rows.Scan(&t); err != nil { return 0, err } dates = append(dates, t) } if len(dates) == 0 { return 0, nil } today := time.Now().UTC().Truncate(24 * time.Hour) if dates[0].Before(today.Add(-24 * time.Hour)) { return 0, nil } streak := 1 for i := 1; i < len(dates); i++ { if dates[i-1].Sub(dates[i]) == 24*time.Hour { streak++ } else { break } } return streak, nil } // ── User Preferences ────────────────────────────────────────────────────────── func (d *DB) GetUserPreference(ctx context.Context, userID, guildID string) (string, error) { var unit string err := d.conn.QueryRowContext(ctx, `SELECT unit FROM user_preferences WHERE user_id = $1 AND guild_id = $2`, userID, guildID).Scan(&unit) if err == sql.ErrNoRows { return "km", nil } return unit, err } func (d *DB) SetUserPreference(ctx context.Context, userID, guildID, unit string) error { _, err := d.conn.ExecContext(ctx, ` INSERT INTO user_preferences (user_id, guild_id, unit) VALUES ($1, $2, $3) ON CONFLICT(user_id, guild_id) DO UPDATE SET unit = EXCLUDED.unit `, userID, guildID, unit) return err } // ── Kudos ───────────────────────────────────────────────────────────────────── func (d *DB) GiveKudos(ctx context.Context, guildID, fromUserID, fromUsername, toUserID, toUsername string) error { _, err := d.conn.ExecContext(ctx, ` INSERT INTO kudos (guild_id, from_user_id, from_username, to_user_id, to_username) VALUES ($1, $2, $3, $4, $5) `, guildID, fromUserID, fromUsername, toUserID, toUsername) return err } func (d *DB) GetKudosReceived(ctx context.Context, guildID, toUserID string) (int, error) { var count int err := d.conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM kudos WHERE guild_id = $1 AND to_user_id = $2`, guildID, toUserID).Scan(&count) return count, err } // ── Challenge Management ────────────────────────────────────────────────────── func (d *DB) GetChallengeStart(ctx context.Context, guildID string) (time.Time, bool, error) { val, ok, err := d.GetSetting(ctx, guildID, "challenge_start") if !ok || err != nil { return time.Time{}, false, err } t, err := time.Parse(time.RFC3339, val) return t, err == nil, err } func (d *DB) ResetChallenge(ctx context.Context, guildID, name string) error { challengeStart, hasPrev, _ := d.GetChallengeStart(ctx, guildID) total, err := d.GetTotalKM(ctx, guildID, challengeStart) if err != nil { return err } entries, err := d.GetLeaderboard(ctx, guildID, challengeStart, 1000) if err != nil { return err } if hasPrev && total > 0 { archiveName := name if archiveName == "" { archiveName = "Challenge" } _, err = d.conn.ExecContext(ctx, ` INSERT INTO challenge_archive (guild_id, name, total_km, riders, start_date) VALUES ($1, $2, $3, $4, $5) `, guildID, archiveName, total, len(entries), challengeStart) if err != nil { return err } } return d.SetSetting(ctx, guildID, "challenge_start", time.Now().UTC().Format(time.RFC3339)) } func (d *DB) GetChallengeArchive(ctx context.Context, guildID string) ([]*ChallengeArchive, error) { rows, err := d.conn.QueryContext(ctx, ` SELECT name, total_km, riders, start_date, end_date FROM challenge_archive WHERE guild_id = $1 ORDER BY end_date DESC `, guildID) if err != nil { return nil, err } defer rows.Close() var results []*ChallengeArchive for rows.Next() { a := &ChallengeArchive{} if err := rows.Scan(&a.Name, &a.TotalKM, &a.Riders, &a.StartDate, &a.EndDate); err != nil { return nil, err } results = append(results, a) } return results, rows.Err() } // ── Admin ───────────────────────────────────────────────────────────────────── func (d *DB) GetUserLogs(ctx context.Context, guildID, userID string, limit int) ([]*RideLog, error) { return d.GetUserHistory(ctx, guildID, userID, limit) }