diff --git a/.gitignore b/.gitignore index c12db7a..05a99e6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ Secrets*.toml backups/ .env *.log + diff --git a/.sqlx/query-00eed700b05fd13ddbbe88daed394e408acdc61d36618361f302bf25ca3f15a4.json b/.sqlx/query-00eed700b05fd13ddbbe88daed394e408acdc61d36618361f302bf25ca3f15a4.json new file mode 100644 index 0000000..8a96e85 --- /dev/null +++ b/.sqlx/query-00eed700b05fd13ddbbe88daed394e408acdc61d36618361f302bf25ca3f15a4.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT member_id, codeforces_rating, max_rating, contests_participated\n FROM codeforces_stats", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "member_id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "codeforces_rating", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "max_rating", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "contests_participated", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "00eed700b05fd13ddbbe88daed394e408acdc61d36618361f302bf25ca3f15a4" +} diff --git a/.sqlx/query-1697cc6c5888d46b40bde260b125576e3b31d93275b267b5f2c8f39e4b7fe2ba.json b/.sqlx/query-1697cc6c5888d46b40bde260b125576e3b31d93275b267b5f2c8f39e4b7fe2ba.json new file mode 100644 index 0000000..21a0a0f --- /dev/null +++ b/.sqlx/query-1697cc6c5888d46b40bde260b125576e3b31d93275b267b5f2c8f39e4b7fe2ba.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO leetcode_stats (\n member_id, leetcode_username, problems_solved, easy_solved, medium_solved,\n hard_solved, contests_participated, best_rank, total_contests\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n ON CONFLICT (member_id) DO UPDATE SET\n leetcode_username = EXCLUDED.leetcode_username,\n problems_solved = EXCLUDED.problems_solved,\n easy_solved = EXCLUDED.easy_solved,\n medium_solved = EXCLUDED.medium_solved,\n hard_solved = EXCLUDED.hard_solved,\n contests_participated = EXCLUDED.contests_participated,\n best_rank = EXCLUDED.best_rank,\n total_contests = EXCLUDED.total_contests\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Varchar", + "Int4", + "Int4", + "Int4", + "Int4", + "Int4", + "Int4", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "1697cc6c5888d46b40bde260b125576e3b31d93275b267b5f2c8f39e4b7fe2ba" +} diff --git a/.sqlx/query-8fe3e023fde759a78e0c4071f658cc4ed4cf29158360ce3f17b87b403dcf9f15.json b/.sqlx/query-8fe3e023fde759a78e0c4071f658cc4ed4cf29158360ce3f17b87b403dcf9f15.json new file mode 100644 index 0000000..525dcf6 --- /dev/null +++ b/.sqlx/query-8fe3e023fde759a78e0c4071f658cc4ed4cf29158360ce3f17b87b403dcf9f15.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO leaderboard (member_id, leetcode_score, codeforces_score, unified_score, last_updated)\n VALUES ($1, $2, $3, $4, NOW())\n ON CONFLICT (member_id) DO UPDATE SET\n leetcode_score = EXCLUDED.leetcode_score,\n codeforces_score = EXCLUDED.codeforces_score,\n unified_score = EXCLUDED.unified_score,\n last_updated = NOW()", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Int4", + "Int4", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "8fe3e023fde759a78e0c4071f658cc4ed4cf29158360ce3f17b87b403dcf9f15" +} diff --git a/.sqlx/query-d1a8d44f4608a7ca73e7e96d88678b0afdd72f2b91332a47f7f41627230dd989.json b/.sqlx/query-d1a8d44f4608a7ca73e7e96d88678b0afdd72f2b91332a47f7f41627230dd989.json new file mode 100644 index 0000000..64e12d6 --- /dev/null +++ b/.sqlx/query-d1a8d44f4608a7ca73e7e96d88678b0afdd72f2b91332a47f7f41627230dd989.json @@ -0,0 +1,56 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT member_id, problems_solved, easy_solved, medium_solved, hard_solved,\n contests_participated, best_rank\n FROM leetcode_stats", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "member_id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "problems_solved", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "easy_solved", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "medium_solved", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "hard_solved", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "contests_participated", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "best_rank", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "d1a8d44f4608a7ca73e7e96d88678b0afdd72f2b91332a47f7f41627230dd989" +} diff --git a/migrations/20250312124630_add_leaderboard_tables.sql b/migrations/20250312124630_add_leaderboard_tables.sql new file mode 100644 index 0000000..a170cb1 --- /dev/null +++ b/migrations/20250312124630_add_leaderboard_tables.sql @@ -0,0 +1,36 @@ +-- Add migration script here + +CREATE TABLE IF NOT EXISTS leaderboard ( + id SERIAL PRIMARY KEY, + member_id INT UNIQUE NOT NULL, + leetcode_score INT, + codeforces_score INT, + unified_score INT NOT NULL, + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (member_id) REFERENCES member(member_id) +); + +CREATE TABLE IF NOT EXISTS leetcode_stats ( + id SERIAL PRIMARY KEY, + member_id INT UNIQUE NOT NULL, + leetcode_username VARCHAR(255) NOT NULL, + problems_solved INT NOT NULL, + easy_solved INT NOT NULL, + medium_solved INT NOT NULL, + hard_solved INT NOT NULL, + contests_participated INT NOT NULL, + best_rank INT NOT NULL, + total_contests INT NOT NULL, + FOREIGN KEY (member_id) REFERENCES member(member_id) +); + +CREATE TABLE IF NOT EXISTS codeforces_stats ( + id SERIAL PRIMARY KEY, + member_id INT UNIQUE NOT NULL, + codeforces_handle VARCHAR(255) NOT NULL, + codeforces_rating INT NOT NULL, + max_rating INT NOT NULL, + contests_participated INT NOT NULL, + FOREIGN KEY (member_id) REFERENCES member(member_id) +); + diff --git a/package.json b/package.json new file mode 100644 index 0000000..9858870 --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "dependencies": { + "@apollo/client": "^3.13.4", + "graphiql": "^3.8.3", + "graphql": "^16.10.0" + } +} diff --git a/src/daily_task/mod.rs b/src/daily_task/mod.rs index be21e1d..b6c7ea0 100644 --- a/src/daily_task/mod.rs +++ b/src/daily_task/mod.rs @@ -1,3 +1,6 @@ +use crate::graphql::api::{ + fetch_and_update_codeforces_stats, fetch_and_update_leetcode, update_leaderboard_scores, +}; use chrono::NaiveTime; use chrono_tz::Asia::Kolkata; use sqlx::PgPool; @@ -5,7 +8,10 @@ use std::sync::Arc; use tokio::time::sleep_until; use tracing::{debug, error, info}; -use crate::models::member::Member; +use crate::models::{ + leaderboard::{CodeforcesStats, LeetCodeStats}, + member::Member, +}; pub async fn run_daily_task_at_midnight(pool: Arc) { loop { @@ -47,12 +53,68 @@ async fn execute_daily_task(pool: Arc) { Ok(members) => { update_attendance(&members, &pool).await; update_status_history(&members, &pool).await; + update_leaderboard_task(pool.clone()).await; } // TODO: Handle this Err(e) => error!("Failed to fetch members: {:?}", e), }; } +pub async fn update_leaderboard_task(pool: Arc) { + #[allow(deprecated)] + let today = chrono::Utc::now() + .with_timezone(&Kolkata) + .date() + .naive_local(); + debug!("Updating leaderboard on {}", today); + + let members: Result, sqlx::Error> = + sqlx::query_as::<_, Member>("SELECT * FROM Member") + .fetch_all(pool.as_ref()) + .await; + + match members { + Ok(members) => { + for member in &members { + // Update LeetCode stats + if let Ok(Some(leetcode_stats)) = sqlx::query_as::<_, LeetCodeStats>( + "SELECT leetcode_username FROM leetcode_stats WHERE member_id = $1 AND leetcode_username IS NOT NULL AND leetcode_username != ''", + ) + .bind(member.member_id) + .fetch_optional(pool.as_ref()) + .await { + let username = leetcode_stats.leetcode_username.clone(); + + match fetch_and_update_leetcode(pool.clone(), member.member_id, &username).await { + Ok(_) => debug!("LeetCode stats updated for member ID: {}", member.member_id), + Err(e) => error!("Failed to update LeetCode stats for member ID {}: {:?}", member.member_id, e), + } + } + + if let Ok(Some(codeforces_stats)) = sqlx::query_as::<_, CodeforcesStats>( + "SELECT codeforces_handle FROM codeforces_stats WHERE member_id = $1 AND codeforces_handle IS NOT NULL AND codeforces_handle != ''", + ) + .bind(member.member_id) + .fetch_optional(pool.as_ref()) + .await { + let username = codeforces_stats.codeforces_handle.clone(); + + match fetch_and_update_codeforces_stats(pool.clone(), member.member_id, &username).await { + Ok(_) => debug!("Codeforces stats updated for member ID: {}", member.member_id), + Err(e) => error!("Failed to update Codeforces stats for member ID {}: {:?}", member.member_id, e), + } + } + } + + match update_leaderboard_scores(pool.clone()).await { + Ok(_) => debug!("Leaderboard updated successfully."), + Err(e) => error!("Failed to update leaderboard: {e:?}"), + } + } + Err(e) => error!("Failed to fetch members: {e:?}"), + } +} + async fn update_attendance(members: &Vec, pool: &PgPool) { #[allow(deprecated)] let today = chrono::Utc::now() @@ -104,7 +166,7 @@ async fn update_status_history(members: &Vec, pool: &PgPool) { for member in members { let status_update = sqlx::query( - "INSERT INTO StatusUpdateHistory (member_id, date, is_updated) + "INSERT INTO StatusUpdateHistory (member_id, date, is_updated) VALUES ($1, $2, $3) ON CONFLICT (member_id, date) DO NOTHING", ) diff --git a/src/database_seeder/seed.sql b/src/database_seeder/seed.sql index 6b8930f..18e5f51 100644 --- a/src/database_seeder/seed.sql +++ b/src/database_seeder/seed.sql @@ -1,4 +1,4 @@ --- Member +-- Member INSERT INTO member ( roll_no, name, email, sex, year, hostel, mac_address, discord_id, group_id ) @@ -30,8 +30,7 @@ SELECT FROM generate_series(1, 60) AS i ON CONFLICT (roll_no) DO NOTHING; - --- Attendance +-- Attendance (Original code - UNCHANGED) INSERT INTO Attendance ( member_id, date, is_present, time_in, time_out ) @@ -58,24 +57,8 @@ WHERE (random() < 0.75) ON CONFLICT (member_id, date) DO NOTHING; --- AttendanceSummary -INSERT INTO AttendanceSummary ( - member_id, year, month, days_attended -) -SELECT - m.member_id, - 2025, - (i % 12) + 1, - FLOOR(random() * 26 + 3)::INT -FROM generate_series(1, 400) AS i -JOIN ( - SELECT generate_series(1, 60) AS idx, member_id - FROM member -) AS m ON (i % 60) + 1 = m.idx -ON CONFLICT (member_id, year, month) DO NOTHING; - --- StatusUpdateStreak +-- StatusUpdateStreak (Original code - UNCHANGED) INSERT INTO StatusUpdateStreak ( member_id, current_streak, max_streak ) @@ -86,30 +69,24 @@ SELECT FROM member ON CONFLICT (member_id) DO NOTHING; - --- Project -INSERT INTO Project ( - member_id, title -) +-- Project (Original code - UNCHANGED) +INSERT INTO Project (member_id, title) SELECT - (i % 60) + 1, + m.member_id, CASE - WHEN i % 3 = 0 THEN 'Machine Learning Project ' || i - WHEN i % 3 = 1 THEN 'Web Development Project ' || i - ELSE 'Data Analysis Project ' || i + WHEN row_number() OVER (PARTITION BY m.member_id) % 3 = 0 THEN 'Machine Learning Project ' || m.member_id || '_' || row_number() OVER (PARTITION BY m.member_id) + WHEN row_number() OVER (PARTITION BY m.member_id) % 3 = 1 THEN 'Web Development Project ' || m.member_id || '_' || row_number() OVER (PARTITION BY m.member_id) + ELSE 'Data Analysis Project ' || m.member_id || '_' || row_number() OVER (PARTITION BY m.member_id) END -FROM generate_series(1, 200) AS i +FROM member m +CROSS JOIN generate_series(1, 3) AS i -- Create up to 3 projects per member WHERE NOT EXISTS ( - SELECT 1 FROM Project - WHERE member_id = (i % 60) + 1 AND title = CASE - WHEN i % 3 = 0 THEN 'Machine Learning Project ' || i - WHEN i % 3 = 1 THEN 'Web Development Project ' || i - ELSE 'Data Analysis Project ' || i - END + SELECT 1 FROM Project p + WHERE p.member_id = m.member_id + AND p.title LIKE '%Project ' || m.member_id || '_%' ); - --- StatusUpdateHistory +-- StatusUpdateHistory (Original code - UNCHANGED) INSERT INTO StatusUpdateHistory ( member_id, date, is_updated ) @@ -123,3 +100,133 @@ JOIN ( FROM member ) AS m ON (i % 60) + 1 = m.idx ON CONFLICT (member_id, date) DO NOTHING; + + +INSERT INTO member ( + roll_no, name, email, sex, year, hostel, mac_address, discord_id, group_id +) VALUES + ('R001', 'Rihaan B H', 'rihaan@example.com', 'M', 3, 'Hostel A', '00:14:22:01:01:01', 'rihaan_discord', 1), + ('R002', 'Abhinav M', 'abhinav@example.com', 'M', 3, 'Hostel B', '00:14:22:01:01:02', 'abhinav_discord', 1), + ('R003', 'Shrivaths S Nair', 'shrivaths@example.com', 'M', 3, 'Hostel C', '00:14:22:01:01:03', 'shrivaths_discord', 2), + ('R004', 'Hridesh MG', 'hridesh@example.com', 'M', 3, 'Hostel D', '00:14:22:01:01:04', 'hridesh_discord', 2), + ('R005', 'Manas Varma K', 'manas@example.com', 'M', 3, 'Hostel E', '00:14:22:01:01:05', 'manas_discord', 3), + ('R006', 'Chinmay Ajith', 'chinmay@example.com', 'M', 3, 'Hostel F', '00:14:22:01:01:06', 'chinmay_discord', 3), + ('R008', 'Shravya K Suresh', 'shravya@example.com', 'F', 3, 'Hostel H', '00:14:22:01:01:08', 'shravya_discord', 4), + ('R009', 'Swayam Agrahari', 'swayam@example.com', 'M', 3, 'Hostel I', '00:14:22:01:01:09', 'swayam_discord', 5), + ('R010', 'Anamika V Menon', 'anamika@example.com', 'F', 3, 'Hostel J', '00:14:22:01:01:10', 'anamika_discord', 5) +ON CONFLICT (roll_no) DO NOTHING; + +-- LeetCode statistics for specific members +WITH leetcode_members AS ( + SELECT + m.member_id, + m.name, + CASE + WHEN m.name = 'Rihaan B H' THEN 'rihaan1810' + WHEN m.name = 'Abhinav M' THEN 'abhinavmohandas' + WHEN m.name = 'Shrivaths S Nair' THEN 'Jatayu_2005' + WHEN m.name = 'Hridesh MG' THEN 'hrideshmg' + WHEN m.name = 'Shravya K Suresh' THEN 'shraavv' + WHEN m.name = 'Swayam Agrahari' THEN 'swayam-agrahari' + WHEN m.name = 'Anamika V Menon' THEN 'anamika_12' + WHEN m.name = 'Souri S' THEN 'souri008_s' + WHEN m.name = 'Keerthan K K' THEN 'keerthankk' + WHEN m.name = 'Dheeraj M' THEN 'CrownDestro' + END AS leetcode_username + FROM member m + WHERE m.name IN ( + 'Rihaan B H', 'Abhinav M', 'Shrivaths S Nair', 'Hridesh MG', + 'Shravya K Suresh', 'Swayam Agrahari', 'Anamika V Menon' + ) +) +INSERT INTO leetcode_stats ( + member_id, leetcode_username, problems_solved, easy_solved, + medium_solved, hard_solved, contests_participated, best_rank, total_contests +) +SELECT + member_id, + leetcode_username, + FLOOR(random() * 500 + 50)::INT, + FLOOR(random() * 200 + 30)::INT, + FLOOR(random() * 250 + 20)::INT, + FLOOR(random() * 100 + 5)::INT, + FLOOR(random() * 20 + 1)::INT, + FLOOR(random() * 5000 + 100)::INT, + FLOOR(random() * 30 + 5)::INT +FROM leetcode_members +WHERE leetcode_username IS NOT NULL +ON CONFLICT (member_id) DO NOTHING; + +-- Codeforces statistics for specific members +WITH codeforces_members AS ( + SELECT + m.member_id, + m.name, + CASE + WHEN m.name = 'Atharva Unnikrishnan Nair' THEN 'atharva_04' + WHEN m.name = 'Navaneeth' THEN 'navaneeth0041' + WHEN m.name = 'Hridesh MG' THEN 'hrideshmg' + WHEN m.name = 'Manas Varma K' THEN 'xX_Elektro_Xx' + WHEN m.name = 'Chinmay Ajith' THEN 'chimnayyyy' + WHEN m.name = 'Harikrishna TP' THEN 'harikrishna05' + WHEN m.name = 'Vishnu Mohandas' THEN 'VishnuM_24' + WHEN m.name = 'Mukund Menon' THEN 'CR1T1KAL16' + WHEN m.name = 'G O Ashwin Praveen' THEN 'ashwinpraveengo' + WHEN m.name = 'Aman V Shafeeq' THEN 'amansxcalibur' + WHEN m.name = 'Gautham Mohanraj' THEN 'gauthammohanraj' + WHEN m.name = 'Sabarinath J' THEN 'e_clipw_ze' + WHEN m.name = 'Vishnu Tejas E' THEN 'he1senbrg' + END AS codeforces_handle + FROM member m + WHERE m.name IN ( + 'Hridesh MG', 'Manas Varma K', 'Chinmay Ajith', 'Harikrishna TP', + 'Vishnu Mohandas', 'Mukund Menon', 'G O Ashwin Praveen', 'Aman V Shafeeq', + 'Gautham Mohanraj', 'Sabarinath J', 'Vishnu Tejas E' + ) +) +INSERT INTO codeforces_stats ( + member_id, codeforces_handle, codeforces_rating, max_rating, contests_participated +) +SELECT + member_id, + codeforces_handle, + FLOOR(random() * 2000 + 800)::INT, + FLOOR(random() * 500 + 1800)::INT, + FLOOR(random() * 50 + 5)::INT +FROM codeforces_members +WHERE codeforces_handle IS NOT NULL +ON CONFLICT (member_id) DO NOTHING; + +-- Leaderboard calculation (refactored for better readability) +INSERT INTO leaderboard ( + member_id, leetcode_score, codeforces_score, unified_score +) +SELECT + m.member_id, + -- LeetCode score calculation + COALESCE(ls.problems_solved * 2 + ls.contests_participated * 10, 0) AS leetcode_score, + -- Codeforces score calculation with rating tiers + COALESCE( + CASE + WHEN cf.codeforces_rating < 1200 THEN (cf.codeforces_rating * 0.5 + cf.contests_participated * 5)::INT + WHEN cf.codeforces_rating < 1600 THEN (cf.codeforces_rating * 0.7 + cf.contests_participated * 8)::INT + WHEN cf.codeforces_rating < 1900 THEN (cf.codeforces_rating * 0.9 + cf.contests_participated * 12)::INT + ELSE (cf.codeforces_rating * 1.1 + cf.contests_participated * 15)::INT + END, + 0 + ) AS codeforces_score, + -- Combined unified score + COALESCE(ls.problems_solved * 2 + ls.contests_participated * 10, 0) + + COALESCE( + CASE + WHEN cf.codeforces_rating < 1200 THEN (cf.codeforces_rating * 0.5 + cf.contests_participated * 5)::INT + WHEN cf.codeforces_rating < 1600 THEN (cf.codeforces_rating * 0.7 + cf.contests_participated * 8)::INT + WHEN cf.codeforces_rating < 1900 THEN (cf.codeforces_rating * 0.9 + cf.contests_participated * 12)::INT + ELSE (cf.codeforces_rating * 1.1 + cf.contests_participated * 15)::INT + END, + 0 + ) AS unified_score +FROM member m +LEFT JOIN leetcode_stats ls ON m.member_id = ls.member_id +LEFT JOIN codeforces_stats cf ON m.member_id = cf.member_id +ON CONFLICT (member_id) DO NOTHING; \ No newline at end of file diff --git a/src/graphql/api/leaderboard_api.rs b/src/graphql/api/leaderboard_api.rs new file mode 100644 index 0000000..e06d483 --- /dev/null +++ b/src/graphql/api/leaderboard_api.rs @@ -0,0 +1,303 @@ +use reqwest; +use reqwest::Client; +use serde_json::Value; +use sqlx::PgPool; +use std::collections::HashMap; +use std::sync::Arc; +use tracing::{debug, error}; + +pub async fn fetch_and_update_codeforces_stats( + pool: Arc, + member_id: i32, + username: &str, +) -> Result<(), Box> { + let url = format!("https://codeforces.com/api/user.rating?handle={username}"); + let response = reqwest::get(&url).await?.text().await?; + let data: Value = serde_json::from_str(&response)?; + + if data["status"] == "OK" { + if let Some(results) = data["result"].as_array() { + let contests_participated = results.len() as i32; + + // Calculate the user's current and max ratings + let mut max_rating = 0; + let mut codeforces_rating = 0; + + for contest in results { + if let Some(new_rating) = contest["newRating"].as_i64() { + codeforces_rating = new_rating as i32; + max_rating = max_rating.max(codeforces_rating); + } + } + + let update_result = sqlx::query( + r#" + INSERT INTO codeforces_stats ( + member_id, codeforces_handle, codeforces_rating, max_rating, contests_participated + ) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (member_id) DO UPDATE SET + codeforces_handle = EXCLUDED.codeforces_handle, + codeforces_rating = EXCLUDED.codeforces_rating, + max_rating = EXCLUDED.max_rating, + contests_participated = EXCLUDED.contests_participated + "#, + ) + .bind(member_id) + .bind(username) + .bind(codeforces_rating) + .bind(max_rating) + .bind(contests_participated) + .execute(pool.as_ref()) + .await; + + match update_result { + Ok(_) => debug!("Codeforces stats updated for member ID: {member_id}"), + Err(e) => { + error!("Failed to update Codeforces stats for member ID {member_id}: {e:?}") + } + } + + return Ok(()); + } + } + + Err(format!("Failed to fetch stats for Codeforces handle: {username}").into()) +} + +pub async fn update_leaderboard_scores(pool: Arc) -> Result<(), sqlx::Error> { + let leetcode_stats = sqlx::query!( + "SELECT member_id, problems_solved, easy_solved, medium_solved, hard_solved, + contests_participated, best_rank + FROM leetcode_stats" + ) + .fetch_all(pool.as_ref()) + .await?; + + let codeforces_stats = sqlx::query!( + "SELECT member_id, codeforces_rating, max_rating, contests_participated + FROM codeforces_stats" + ) + .fetch_all(pool.as_ref()) + .await?; + + let cf_lookup: HashMap = codeforces_stats + .iter() + .map(|row| { + ( + row.member_id, + ( + row.codeforces_rating, + row.max_rating, + row.contests_participated, + ), + ) + }) + .collect(); + + for row in &leetcode_stats { + let easy = row.easy_solved.max(0); + let medium = row.medium_solved.max(0); + let hard = row.hard_solved.max(0); + let contests = row.contests_participated.max(0); + let best_rank = row.best_rank.max(0); + + // LeetCode scoring + let leetcode_score = (easy * 2) + (medium * 5) + (hard * 10); + let contest_performance = if best_rank > 0 { + (1000.0 / (best_rank as f64).sqrt()).round() as i32 + } else { + 0 + }; + let total_leetcode_score = leetcode_score + contest_performance + (contests * 3); + + let (codeforces_score, unified_score) = cf_lookup + .get(&row.member_id) + .map(|(rating, max_rating, contests)| { + let rating_val = rating.max(&0); + let max_rating_val = max_rating.max(&0); + let contests_val = contests.max(&0); + + // Codeforces scoring + let rating_points = (*rating_val as f64 * 0.8).round() as i32; + let peak_bonus = (max_rating_val - rating_val).max(0) / 2; + let codeforces_score = rating_points + peak_bonus + (contests_val * 8); + + // Unified scoring (normalized addition) + let normalized_lc = total_leetcode_score as f64 / 5.0; + let normalized_cf = codeforces_score as f64 / 3.0; + let unified_score = (normalized_lc + normalized_cf).round() as i32; + + (codeforces_score, unified_score) + }) + .unwrap_or((0, total_leetcode_score)); + + let result = sqlx::query!( + "INSERT INTO leaderboard (member_id, leetcode_score, codeforces_score, unified_score, last_updated) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (member_id) DO UPDATE SET + leetcode_score = EXCLUDED.leetcode_score, + codeforces_score = EXCLUDED.codeforces_score, + unified_score = EXCLUDED.unified_score, + last_updated = NOW()", + row.member_id, + total_leetcode_score, + codeforces_score, + unified_score + ) + .execute(pool.as_ref()) + .await; + + if let Err(e) = result { + debug!( + "Failed to update leaderboard for member ID {}: {:?}", + row.member_id, e + ); + } + } + + for row in &codeforces_stats { + if leetcode_stats + .iter() + .any(|lc| lc.member_id == row.member_id) + { + continue; + } + + // Codeforces-only scoring + let rating_val = row.codeforces_rating.max(0); + let max_rating_val = row.max_rating.max(0); + let contests_val = row.contests_participated.max(0); + + let rating_points = (rating_val as f64 * 0.8).round() as i32; + let peak_bonus = (max_rating_val - rating_val).max(0) / 2; + let codeforces_score = rating_points + peak_bonus + (contests_val * 8); + + let normalized_cf = codeforces_score as f64 / 3.0; + let unified_score = normalized_cf.round() as i32; + + let result = sqlx::query!( + "INSERT INTO leaderboard (member_id, leetcode_score, codeforces_score, unified_score, last_updated) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (member_id) DO UPDATE SET + leetcode_score = EXCLUDED.leetcode_score, + codeforces_score = EXCLUDED.codeforces_score, + unified_score = EXCLUDED.unified_score, + last_updated = NOW()", + row.member_id, + 0, + codeforces_score, + unified_score + ) + .execute(pool.as_ref()) + .await; + + if let Err(e) = result { + error!( + "Failed to update leaderboard for Codeforces-only member ID {}: {:?}", + row.member_id, e + ); + } + } + + Ok(()) +} + +pub async fn fetch_and_update_leetcode( + pool: Arc, + member_id: i32, + username: &str, +) -> Result<(), Box> { + let client = Client::new(); + let url = "https://leetcode.com/graphql"; + let query = r#" + query userProfile($username: String!) { + userContestRanking(username: $username) { + attendedContestsCount + } + matchedUser(username: $username) { + profile { + ranking + } + submitStats { + acSubmissionNum { + difficulty + count + } + } + } + } + "#; + + let response = client + .post(url) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "query": query, + "variables": { "username": username } + })) + .send() + .await?; + + let data: Value = response.json().await?; + + let empty_vec = vec![]; + let submissions = data["data"]["matchedUser"]["submitStats"]["acSubmissionNum"] + .as_array() + .unwrap_or(&empty_vec); + + let mut problems_solved = 0; + let mut easy_solved = 0; + let mut medium_solved = 0; + let mut hard_solved = 0; + + for stat in submissions { + let count = stat["count"].as_i64().unwrap_or(0) as i32; + match stat["difficulty"].as_str().unwrap_or("") { + "Easy" => easy_solved = count, + "Medium" => medium_solved = count, + "Hard" => hard_solved = count, + "All" => problems_solved = count, + _ => {} + } + } + + let contests_participated = data["data"]["userContestRanking"]["attendedContestsCount"] + .as_i64() + .unwrap_or(0) as i32; + let rank = data["data"]["matchedUser"]["profile"]["ranking"] + .as_i64() + .unwrap_or(0) as i32; + + sqlx::query!( + r#" + INSERT INTO leetcode_stats ( + member_id, leetcode_username, problems_solved, easy_solved, medium_solved, + hard_solved, contests_participated, best_rank, total_contests + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (member_id) DO UPDATE SET + leetcode_username = EXCLUDED.leetcode_username, + problems_solved = EXCLUDED.problems_solved, + easy_solved = EXCLUDED.easy_solved, + medium_solved = EXCLUDED.medium_solved, + hard_solved = EXCLUDED.hard_solved, + contests_participated = EXCLUDED.contests_participated, + best_rank = EXCLUDED.best_rank, + total_contests = EXCLUDED.total_contests + "#, + member_id, + username, + problems_solved, + easy_solved, + medium_solved, + hard_solved, + contests_participated, + rank, + contests_participated + ) + .execute(pool.as_ref()) + .await?; + + Ok(()) +} diff --git a/src/graphql/api/mod.rs b/src/graphql/api/mod.rs new file mode 100644 index 0000000..a1a6e2d --- /dev/null +++ b/src/graphql/api/mod.rs @@ -0,0 +1,5 @@ +pub mod leaderboard_api; + +pub use leaderboard_api::fetch_and_update_codeforces_stats; +pub use leaderboard_api::fetch_and_update_leetcode; +pub use leaderboard_api::update_leaderboard_scores; diff --git a/src/graphql/mod.rs b/src/graphql/mod.rs index 0d99324..e0bb9b9 100644 --- a/src/graphql/mod.rs +++ b/src/graphql/mod.rs @@ -1,7 +1,13 @@ use async_graphql::MergedObject; -use mutations::{AttendanceMutations, MemberMutations, ProjectMutations, StreakMutations}; -use queries::{AttendanceQueries, MemberQueries, ProjectQueries, StreakQueries}; +use mutations::{ + AttendanceMutations, FetchCodeForces, FetchLeetCode, MemberMutations, ProjectMutations, + StreakMutations, +}; +use queries::{ + AttendanceQueries, LeaderboardQueries, MemberQueries, ProjectQueries, StreakQueries, +}; +pub mod api; pub mod mutations; pub mod queries; @@ -11,6 +17,7 @@ pub struct Query( AttendanceQueries, StreakQueries, ProjectQueries, + LeaderboardQueries, ); #[derive(MergedObject, Default)] @@ -19,4 +26,6 @@ pub struct Mutation( AttendanceMutations, StreakMutations, ProjectMutations, + FetchLeetCode, + FetchCodeForces, ); diff --git a/src/graphql/mutations/codeforces_status.rs b/src/graphql/mutations/codeforces_status.rs new file mode 100644 index 0000000..5468029 --- /dev/null +++ b/src/graphql/mutations/codeforces_status.rs @@ -0,0 +1,21 @@ +use crate::graphql::api::leaderboard_api::fetch_and_update_codeforces_stats; +use async_graphql::{Context, Object, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +#[derive(Default)] +pub struct FetchCodeForces; + +#[Object] +impl FetchCodeForces { + pub async fn fetch_codeforces_stats( + &self, + ctx: &Context<'_>, + member_id: i32, + username: String, + ) -> Result { + let pool = ctx.data::>()?; + fetch_and_update_codeforces_stats(pool.clone(), member_id, &username).await?; + Ok(true) + } +} diff --git a/src/graphql/mutations/leaderboard_mutation.rs b/src/graphql/mutations/leaderboard_mutation.rs new file mode 100644 index 0000000..71e7905 --- /dev/null +++ b/src/graphql/mutations/leaderboard_mutation.rs @@ -0,0 +1,56 @@ +use async_graphql::{Context, Object}; +use sqlx::PgPool; +use std::sync::Arc; +use tracing::{debug, error}; +use crate::db::leaderboard::{CodeforcesStats, LeetCodeStats}; + +pub struct LeadMutation; + +#[Object] +impl LeadMutation { + pub async fn add_or_update_leetcode_username( + &self, + ctx: &Context<'_>, + member_id: i32, + username: String, + ) -> Result { + let pool = ctx.data::>()?; + + sqlx::query_as::<_, LeetCodeStats>( + " + INSERT INTO leetcode_stats (member_id, leetcode_username, problems_solved, easy_solved, medium_solved, hard_solved, contests_participated, best_rank, total_contests) + VALUES ($1, $2, 0, 0, 0, 0, 0, 0, 0) + ON CONFLICT (member_id) DO UPDATE + SET leetcode_username = EXCLUDED.leetcode_username + RETURNING * + ", + ) + .bind(member_id) + .bind(username) + .fetch_one(pool.as_ref()) + .await + } + + async fn add_or_update_codeforces_handle( + &self, + ctx: &Context<'_>, + member_id: i32, + handle: String, + ) -> Result { + let pool = ctx.data::>()?; + + sqlx::query_as::<_, CodeforcesStats>( + " + INSERT INTO codeforces_stats (member_id, codeforces_handle, codeforces_rating, max_rating, contests_participated) + VALUES ($1, $2, 0, 0, 0) + ON CONFLICT (member_id) DO UPDATE + SET codeforces_handle = EXCLUDED.codeforces_handle + RETURNING * + ", + ) + .bind(member_id) + .bind(handle) + .fetch_one(pool.as_ref()) + .await + } +} diff --git a/src/graphql/mutations/leetcode_status.rs b/src/graphql/mutations/leetcode_status.rs new file mode 100644 index 0000000..8a142a1 --- /dev/null +++ b/src/graphql/mutations/leetcode_status.rs @@ -0,0 +1,21 @@ +use crate::graphql::api::leaderboard_api::fetch_and_update_leetcode; +use async_graphql::{Context, Object, Result}; +use sqlx::PgPool; +use std::sync::Arc; + +#[derive(Default)] +pub struct FetchLeetCode; + +#[Object] +impl FetchLeetCode { + pub async fn fetch_leetcode_stats( + &self, + ctx: &Context<'_>, + member_id: i32, + username: String, + ) -> Result { + let pool = ctx.data::>()?; + fetch_and_update_leetcode(pool.clone(), member_id, &username).await?; + Ok(true) + } +} diff --git a/src/graphql/mutations/mod.rs b/src/graphql/mutations/mod.rs index 012ed2a..a0d93bf 100644 --- a/src/graphql/mutations/mod.rs +++ b/src/graphql/mutations/mod.rs @@ -1,9 +1,15 @@ pub mod attendance_mutations; +pub mod codeforces_status; +pub mod leetcode_status; pub mod member_mutations; pub mod project_mutations; pub mod streak_mutations; pub use attendance_mutations::AttendanceMutations; +pub use codeforces_status::FetchCodeForces; +pub use leetcode_status::FetchLeetCode; pub use member_mutations::MemberMutations; pub use project_mutations::ProjectMutations; pub use streak_mutations::StreakMutations; + +//use any mutations for leaderboard if needed diff --git a/src/graphql/queries/leaderboard_queries.rs b/src/graphql/queries/leaderboard_queries.rs new file mode 100644 index 0000000..b3d6891 --- /dev/null +++ b/src/graphql/queries/leaderboard_queries.rs @@ -0,0 +1,67 @@ +use async_graphql::{Context, Object}; +use sqlx::PgPool; +use std::sync::Arc; + +use crate::models::leaderboard::{ + CodeforcesStatsWithName, LeaderboardWithMember, LeetCodeStatsWithName, +}; + +#[derive(Default)] +pub struct LeaderboardQueries; + +#[Object] +impl LeaderboardQueries { + async fn get_unified_leaderboard( + &self, + ctx: &Context<'_>, + ) -> Result, sqlx::Error> { + let pool = ctx + .data::>() + .expect("Pool not found in context"); + let leaderboard = sqlx::query_as::<_, LeaderboardWithMember>( + "SELECT l.*, m.name AS member_name + FROM leaderboard l + JOIN member m ON l.member_id = m.member_id + ORDER BY unified_score DESC", + ) + .fetch_all(pool.as_ref()) + .await?; + Ok(leaderboard) + } + + async fn get_leetcode_stats( + &self, + ctx: &Context<'_>, + ) -> Result, sqlx::Error> { + let pool = ctx + .data::>() + .expect("Pool not found in context"); + let leetcode_stats = sqlx::query_as::<_, LeetCodeStatsWithName>( + "SELECT l.*, m.name AS member_name + FROM leetcode_stats l + JOIN member m ON l.member_id = m.member_id + ORDER BY best_rank", + ) + .fetch_all(pool.as_ref()) + .await?; + Ok(leetcode_stats) + } + + async fn get_codeforces_stats( + &self, + ctx: &Context<'_>, + ) -> Result, sqlx::Error> { + let pool = ctx + .data::>() + .expect("Pool not found in context"); + let codeforces_stats = sqlx::query_as::<_, CodeforcesStatsWithName>( + "SELECT c.*, m.name AS member_name + FROM codeforces_stats c + JOIN member m ON c.member_id = m.member_id + ORDER BY max_rating DESC", + ) + .fetch_all(pool.as_ref()) + .await?; + Ok(codeforces_stats) + } +} diff --git a/src/graphql/queries/mod.rs b/src/graphql/queries/mod.rs index 49a8263..0094344 100644 --- a/src/graphql/queries/mod.rs +++ b/src/graphql/queries/mod.rs @@ -1,9 +1,11 @@ pub mod attendance_queries; +pub mod leaderboard_queries; pub mod member_queries; pub mod project_queries; pub mod streak_queries; pub use attendance_queries::AttendanceQueries; +pub use leaderboard_queries::LeaderboardQueries; pub use member_queries::MemberQueries; pub use project_queries::ProjectQueries; pub use streak_queries::StreakQueries; diff --git a/src/models/leaderboard.rs b/src/models/leaderboard.rs new file mode 100644 index 0000000..e412c17 --- /dev/null +++ b/src/models/leaderboard.rs @@ -0,0 +1,71 @@ +use async_graphql::SimpleObject; +use sqlx::FromRow; + +#[derive(FromRow, SimpleObject)] +pub struct Leaderboard { + pub id: i32, + pub member_id: i32, + pub leetcode_score: Option, + pub codeforces_score: Option, + pub unified_score: i32, + pub last_updated: Option, +} + +#[derive(FromRow, SimpleObject)] +pub struct LeaderboardWithMember { + pub id: i32, + pub member_id: i32, + pub member_name: String, + pub leetcode_score: Option, + pub codeforces_score: Option, + pub unified_score: i32, + pub last_updated: Option, +} + +#[derive(FromRow, SimpleObject)] +pub struct LeetCodeStats { + pub id: i32, + pub member_id: i32, + pub leetcode_username: String, + pub problems_solved: i32, + pub easy_solved: i32, + pub medium_solved: i32, + pub hard_solved: i32, + pub contests_participated: i32, + pub best_rank: i32, + pub total_contests: i32, +} + +#[derive(FromRow, SimpleObject)] +pub struct LeetCodeStatsWithName { + pub id: i32, + pub member_id: i32, + pub member_name: String, + pub leetcode_username: String, + pub problems_solved: i32, + pub easy_solved: i32, + pub medium_solved: i32, + pub hard_solved: i32, + pub contests_participated: i32, + pub best_rank: i32, + pub total_contests: i32, +} + +#[derive(FromRow, SimpleObject)] +pub struct CodeforcesStats { + pub member_id: i32, + pub codeforces_handle: String, + pub codeforces_rating: i32, + pub max_rating: i32, + pub contests_participated: i32, +} + +#[derive(FromRow, SimpleObject)] +pub struct CodeforcesStatsWithName { + pub member_id: i32, + pub member_name: String, + pub codeforces_handle: String, + pub codeforces_rating: i32, + pub max_rating: i32, + pub contests_participated: i32, +} diff --git a/src/models/mod.rs b/src/models/mod.rs index b728230..5e8f0d6 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,4 +1,5 @@ pub mod attendance; +pub mod leaderboard; pub mod member; pub mod project; pub mod status_update;