aboutsummaryrefslogtreecommitdiffstats
path: root/src/database
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/database/mod.rs145
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)]
173pub 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.
181pub 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.
196pub 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.
220pub 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.
232pub 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.
270pub 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)]
161pub struct CronJob { 306pub struct CronJob {