first commit

This commit is contained in:
Blake Ridgway
2026-04-11 14:06:59 -05:00
commit ba1770b493
21 changed files with 2027 additions and 0 deletions

540
db/db.go Normal file
View File

@@ -0,0 +1,540 @@
package db
import (
"context"
"database/sql"
"fmt"
"time"
_ "modernc.org/sqlite"
)
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(path string) (*DB, error) {
conn, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open 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 INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT NOT NULL DEFAULT '',
user_id TEXT NOT NULL,
username TEXT NOT NULL,
km REAL NOT NULL,
message_id TEXT NOT NULL UNIQUE,
channel_id TEXT NOT NULL,
logged_at DATETIME 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 INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
total_km REAL NOT NULL,
riders INTEGER NOT NULL,
start_date DATETIME NOT NULL,
end_date DATETIME 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 INTEGER PRIMARY KEY AUTOINCREMENT,
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 DATETIME DEFAULT CURRENT_TIMESTAMP
);
`)
if err != nil {
return err
}
// Non-fatal migration for existing databases
_, _ = d.conn.Exec(`ALTER TABLE distance_logs ADD COLUMN 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 = ? AND key = ?`, 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 (?, ?, ?)
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 = ? AND key = ?`, guildID, key)
return err
}
// ── Core Logging ──────────────────────────────────────────────────────────────
// AddLog records a distance entry. Returns false if the message was already processed.
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 (?, ?, ?, ?, ?, ?)
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
}
// RemoveLog deletes a log by message ID. Returns the removed KM (0 if not found).
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 = ?`, 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 = ?`, messageID)
return km, err
}
// AdjustKM manually adds or subtracts KM for a user.
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 (?, ?, ?, ?, 'manual-' || hex(randomblob(8)), 'admin')
`, guildID, userID, username, km)
return err
}
// ── Stats Queries ─────────────────────────────────────────────────────────────
// GetLeaderboard returns top N users by total KM within the optional since time.
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 = ?`
args := []interface{}{guildID}
if !since.IsZero() {
q += ` AND logged_at >= ?`
args = append(args, since)
}
q += ` GROUP BY user_id ORDER BY SUM(km) DESC LIMIT ?`
args = append(args, limit)
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{}
if err := rows.Scan(&s.UserID, &s.Username, &s.TotalKM, &s.LogCount, &s.LastUpdated); err != nil {
return nil, err
}
results = append(results, s)
}
return results, rows.Err()
}
// GetTotalKM returns combined KM for a guild within the optional since time.
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 = ?`
args := []interface{}{guildID}
if !since.IsZero() {
q += ` AND logged_at >= ?`
args = append(args, since)
}
var total float64
err := d.conn.QueryRowContext(ctx, q, args...).Scan(&total)
return total, err
}
// GetUserStats returns cumulative stats for a single user within the optional since time.
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), '')
FROM distance_logs WHERE guild_id = ? AND user_id = ?`
args := []interface{}{guildID, userID}
if !since.IsZero() {
q += ` AND logged_at >= ?`
args = append(args, since)
}
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
}
// GetStatsInRange returns user stats for a specific date range (for weekly/monthly reports).
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 = ? AND logged_at >= ? AND logged_at <= ?
GROUP BY user_id
ORDER BY SUM(km) DESC
LIMIT ?
`, guildID, from, to, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var results []*UserStats
for rows.Next() {
s := &UserStats{}
if err := rows.Scan(&s.UserID, &s.Username, &s.TotalKM, &s.LogCount, &s.LastUpdated); err != nil {
return nil, err
}
results = append(results, s)
}
return results, rows.Err()
}
// GetYearTotals returns total KM per calendar year for a guild, newest first.
func (d *DB) GetYearTotals(ctx context.Context, guildID string) ([]YearTotal, error) {
rows, err := d.conn.QueryContext(ctx, `
SELECT CAST(strftime('%Y', logged_at) AS INTEGER) as yr, SUM(km)
FROM distance_logs
WHERE guild_id = ?
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
}
// GetYearlyLeaderboard returns the top N users for a given calendar year.
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 = ? AND strftime('%Y', logged_at) = ?
GROUP BY user_id
ORDER BY SUM(km) DESC
LIMIT ?
`, guildID, fmt.Sprintf("%d", year), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var results []*UserStats
for rows.Next() {
s := &UserStats{}
if err := rows.Scan(&s.UserID, &s.Username, &s.TotalKM, &s.LogCount, &s.LastUpdated); err != nil {
return nil, err
}
results = append(results, s)
}
return results, rows.Err()
}
// GetUserYearlyStats returns a user's stats for a given calendar year.
func (d *DB) GetUserYearlyStats(ctx context.Context, guildID, userID string, year int) (*UserStats, error) {
s := &UserStats{UserID: userID}
err := d.conn.QueryRowContext(ctx, `
SELECT user_id, username, COALESCE(SUM(km), 0), COUNT(*), COALESCE(MAX(logged_at), '')
FROM distance_logs
WHERE guild_id = ? AND user_id = ? AND strftime('%Y', logged_at) = ?
GROUP BY user_id, username
`, guildID, userID, fmt.Sprintf("%d", year)).Scan(
&s.UserID, &s.Username, &s.TotalKM, &s.LogCount, &s.LastUpdated)
if err == sql.ErrNoRows {
return s, nil
}
return s, err
}
// ── Personal Stats ────────────────────────────────────────────────────────────
// GetUserHistory returns the last N ride logs for a user.
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 = ? AND user_id = ?
ORDER BY logged_at DESC
LIMIT ?
`, guildID, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var logs []*RideLog
for rows.Next() {
l := &RideLog{}
var loggedAt string
if err := rows.Scan(&l.ID, &l.KM, &l.MessageID, &loggedAt); err != nil {
return nil, err
}
l.LoggedAt, _ = time.Parse("2006-01-02 15:04:05", loggedAt)
logs = append(logs, l)
}
return logs, rows.Err()
}
// GetUserPB returns a user's personal best single-ride distance.
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 = ? AND user_id = ?`,
guildID, userID).Scan(&pb)
return pb, err
}
// GetUserStreak returns the number of consecutive days a user has logged a ride.
func (d *DB) GetUserStreak(ctx context.Context, userID string) (int, error) {
rows, err := d.conn.QueryContext(ctx, `
SELECT DISTINCT DATE(logged_at) as ride_date
FROM distance_logs
WHERE user_id = ?
ORDER BY ride_date DESC
`, userID)
if err != nil {
return 0, err
}
defer rows.Close()
var dates []time.Time
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
return 0, err
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
continue
}
dates = append(dates, t)
}
if len(dates) == 0 {
return 0, nil
}
today := time.Now().UTC().Truncate(24 * time.Hour)
// Streak must include today or yesterday
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 = ? AND guild_id = ?`,
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 (?, ?, ?)
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 (?, ?, ?, ?, ?)
`, 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 = ? AND to_user_id = ?`,
guildID, toUserID).Scan(&count)
return count, err
}
// ── Challenge Management ──────────────────────────────────────────────────────
// GetChallengeStart returns the start time of the current challenge period.
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
}
// ResetChallenge archives current stats and starts a new challenge period.
func (d *DB) ResetChallenge(ctx context.Context, guildID, name string) error {
challengeStart, hasPrev, _ := d.GetChallengeStart(ctx, guildID)
// Calculate current totals before reset
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
}
// Archive if there was a previous period with data
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 (?, ?, ?, ?, ?)
`, guildID, archiveName, total, len(entries), challengeStart)
if err != nil {
return err
}
}
// Set new challenge start
return d.SetSetting(ctx, guildID, "challenge_start", time.Now().UTC().Format(time.RFC3339))
}
// GetChallengeArchive returns past challenge records for a guild.
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 = ?
ORDER BY end_date DESC
`, guildID)
if err != nil {
return nil, err
}
defer rows.Close()
var results []*ChallengeArchive
for rows.Next() {
a := &ChallengeArchive{}
var start, end string
if err := rows.Scan(&a.Name, &a.TotalKM, &a.Riders, &start, &end); err != nil {
return nil, err
}
a.StartDate, _ = time.Parse("2006-01-02 15:04:05", start)
a.EndDate, _ = time.Parse("2006-01-02 15:04:05", end)
results = append(results, a)
}
return results, rows.Err()
}
// ── Admin ─────────────────────────────────────────────────────────────────────
// GetUserLogs returns all ride logs for a user (for audit).
func (d *DB) GetUserLogs(ctx context.Context, guildID, userID string, limit int) ([]*RideLog, error) {
return d.GetUserHistory(ctx, guildID, userID, limit)
}