diff options
| author | salmonik <salmonik@tuta.io> | 2026-08-19 19:28:39 +0200 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-08-19 19:28:39 +0200 |
| commit | 11507d00fb774e59afe2c304a6922f875865caee (patch) | |
| tree | edb7350e9cb89bc37501bf1248770d9384c787b1 /src/database | |
| parent | fix: clear voice_activity upon starting (diff) | |
| download | senator-sperma-11507d00fb774e59afe2c304a6922f875865caee.tar.gz senator-sperma-11507d00fb774e59afe2c304a6922f875865caee.zip | |
feat: leaderboads
Diffstat (limited to 'src/database')
| -rw-r--r-- | src/database/mod.rs | 145 |
1 files changed, 145 insertions, 0 deletions
diff --git a/src/database/mod.rs b/src/database/mod.rs index f2185fb..b68d927 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs | |||
| @@ -101,6 +101,18 @@ async fn run_migrations(pool: &DbPool) -> Result<()> { | |||
| 101 | .execute(pool) | 101 | .execute(pool) |
| 102 | .await?; | 102 | .await?; |
| 103 | 103 | ||
| 104 | sqlx::query( | ||
| 105 | "CREATE TABLE IF NOT EXISTS user_stats ( | ||
| 106 | user_id TEXT PRIMARY KEY, | ||
| 107 | message_count INTEGER NOT NULL DEFAULT 0, | ||
| 108 | total_voice_active REAL NOT NULL DEFAULT 0, | ||
| 109 | total_voice_afk REAL NOT NULL DEFAULT 0, | ||
| 110 | last_message_at TIMESTAMP | ||
| 111 | )", | ||
| 112 | ) | ||
| 113 | .execute(pool) | ||
| 114 | .await?; | ||
| 115 | |||
| 104 | Ok(()) | 116 | Ok(()) |
| 105 | } | 117 | } |
| 106 | 118 | ||
| @@ -156,6 +168,139 @@ pub async fn upsert_voice_activity(pool: &DbPool, va: &VoiceActivity) -> Result< | |||
| 156 | Ok(()) | 168 | Ok(()) |
| 157 | } | 169 | } |
| 158 | 170 | ||
| 171 | // -- User Stats (leaderboard) -- | ||
| 172 | #[derive(Debug, Clone, FromRow)] | ||
| 173 | pub struct UserStats { | ||
| 174 | pub user_id: String, | ||
| 175 | pub message_count: i64, | ||
| 176 | pub total_voice_active: f64, | ||
| 177 | pub total_voice_afk: f64, | ||
| 178 | } | ||
| 179 | |||
| 180 | /// Increment message count for a user. | ||
| 181 | pub async fn increment_message_count(pool: &DbPool, user_id: &str) -> Result<()> { | ||
| 182 | sqlx::query( | ||
| 183 | "INSERT INTO user_stats (user_id, message_count, last_message_at) | ||
| 184 | VALUES (?, 1, CURRENT_TIMESTAMP) | ||
| 185 | ON CONFLICT(user_id) DO UPDATE SET | ||
| 186 | message_count = message_count + 1, | ||
| 187 | last_message_at = CURRENT_TIMESTAMP", | ||
| 188 | ) | ||
| 189 | .bind(user_id) | ||
| 190 | .execute(pool) | ||
| 191 | .await?; | ||
| 192 | Ok(()) | ||
| 193 | } | ||
| 194 | |||
| 195 | /// Add voice time to a user's cumulative stats. | ||
| 196 | pub async fn add_voice_time( | ||
| 197 | pool: &DbPool, | ||
| 198 | user_id: &str, | ||
| 199 | active_seconds: f64, | ||
| 200 | afk_seconds: f64, | ||
| 201 | ) -> Result<()> { | ||
| 202 | sqlx::query( | ||
| 203 | "INSERT INTO user_stats (user_id, total_voice_active, total_voice_afk) | ||
| 204 | VALUES (?, ?, ?) | ||
| 205 | ON CONFLICT(user_id) DO UPDATE SET | ||
| 206 | total_voice_active = total_voice_active + ?, | ||
| 207 | total_voice_afk = total_voice_afk + ?", | ||
| 208 | ) | ||
| 209 | .bind(user_id) | ||
| 210 | .bind(active_seconds) | ||
| 211 | .bind(afk_seconds) | ||
| 212 | .bind(active_seconds) | ||
| 213 | .bind(afk_seconds) | ||
| 214 | .execute(pool) | ||
| 215 | .await?; | ||
| 216 | Ok(()) | ||
| 217 | } | ||
| 218 | |||
| 219 | /// Get stats for a single user. | ||
| 220 | pub async fn get_user_stats(pool: &DbPool, user_id: &str) -> Result<Option<UserStats>> { | ||
| 221 | Ok( | ||
| 222 | sqlx::query_as("SELECT * FROM user_stats WHERE user_id = ?") | ||
| 223 | .bind(user_id) | ||
| 224 | .fetch_optional(pool) | ||
| 225 | .await?, | ||
| 226 | ) | ||
| 227 | } | ||
| 228 | |||
| 229 | /// Get leaderboard entries sorted by a metric. | ||
| 230 | /// Returns (entries, total_count) where total_count is used for pagination. | ||
| 231 | /// SAFETY: `order_clause` and `order` are derived from fixed match arms, not user input. | ||
| 232 | pub async fn get_leaderboard( | ||
| 233 | pool: &DbPool, | ||
| 234 | metric: &str, | ||
| 235 | ascending: bool, | ||
| 236 | limit: i64, | ||
| 237 | offset: i64, | ||
| 238 | ) -> Result<(Vec<UserStats>, i64)> { | ||
| 239 | let order = if ascending { "ASC" } else { "DESC" }; | ||
| 240 | // SAFETY: all arms are compile-time constant strings | ||
| 241 | #[allow(clippy::disallowed_methods)] | ||
| 242 | let order_clause = match metric { | ||
| 243 | "messages" => "message_count", | ||
| 244 | "active" => "total_voice_active", | ||
| 245 | "afk" => "total_voice_afk", | ||
| 246 | "total" => "total_voice_active + total_voice_afk", | ||
| 247 | _ => "message_count", | ||
| 248 | }; | ||
| 249 | |||
| 250 | let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM user_stats") | ||
| 251 | .fetch_one(pool) | ||
| 252 | .await?; | ||
| 253 | |||
| 254 | // SAFETY: order_clause and order are not user-controlled | ||
| 255 | let query = format!( | ||
| 256 | "SELECT * FROM user_stats ORDER BY {} {} LIMIT ? OFFSET ?", | ||
| 257 | order_clause, order | ||
| 258 | ); | ||
| 259 | let entries: Vec<UserStats> = sqlx::query_as(sqlx::AssertSqlSafe(query)) | ||
| 260 | .bind(limit) | ||
| 261 | .bind(offset) | ||
| 262 | .fetch_all(pool) | ||
| 263 | .await?; | ||
| 264 | |||
| 265 | Ok((entries, total.0)) | ||
| 266 | } | ||
| 267 | |||
| 268 | /// Get a user's rank for a metric (1-indexed, 0 if not found). | ||
| 269 | /// SAFETY: `order_clause` is derived from fixed match arms, not user input. | ||
| 270 | pub async fn get_user_rank(pool: &DbPool, user_id: &str, metric: &str) -> Result<i64> { | ||
| 271 | // SAFETY: all arms are compile-time constant strings | ||
| 272 | #[allow(clippy::disallowed_methods)] | ||
| 273 | let order_clause = match metric { | ||
| 274 | "messages" => "message_count", | ||
| 275 | "active" => "total_voice_active", | ||
| 276 | "afk" => "total_voice_afk", | ||
| 277 | "total" => "total_voice_active + total_voice_afk", | ||
| 278 | _ => "message_count", | ||
| 279 | }; | ||
| 280 | |||
| 281 | // SAFETY: order_clause is not user-controlled | ||
| 282 | let query = format!( | ||
| 283 | "SELECT COUNT(*) + 1 FROM user_stats WHERE {} > ( | ||
| 284 | SELECT {} FROM user_stats WHERE user_id = ? | ||
| 285 | )", | ||
| 286 | order_clause, order_clause | ||
| 287 | ); | ||
| 288 | let result: (i64,) = sqlx::query_as(sqlx::AssertSqlSafe(query)) | ||
| 289 | .bind(user_id) | ||
| 290 | .fetch_one(pool) | ||
| 291 | .await?; | ||
| 292 | |||
| 293 | // If user doesn't exist, return 0 | ||
| 294 | let user_exists: Option<(i64,)> = sqlx::query_as( | ||
| 295 | "SELECT 1 FROM user_stats WHERE user_id = ?" | ||
| 296 | ) | ||
| 297 | .bind(user_id) | ||
| 298 | .fetch_optional(pool) | ||
| 299 | .await?; | ||
| 300 | |||
| 301 | Ok(if user_exists.is_some() { result.0 } else { 0 }) | ||
| 302 | } | ||
| 303 | |||
| 159 | // -- Cron Jobs -- | 304 | // -- Cron Jobs -- |
| 160 | #[derive(Debug, Clone, FromRow)] | 305 | #[derive(Debug, Clone, FromRow)] |
| 161 | pub struct CronJob { | 306 | pub struct CronJob { |
