diff --git a/migrations/2024-11-21-133933_add-expected-module-total-score/down.sql b/migrations/2024-11-21-133933_add-expected-module-total-score/down.sql new file mode 100644 index 0000000..4e3bb16 --- /dev/null +++ b/migrations/2024-11-21-133933_add-expected-module-total-score/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE leaderboard DROP COLUMN IF EXISTS expected_total_score; \ No newline at end of file diff --git a/migrations/2024-11-21-133933_add-expected-module-total-score/up.sql b/migrations/2024-11-21-133933_add-expected-module-total-score/up.sql new file mode 100644 index 0000000..5e79f82 --- /dev/null +++ b/migrations/2024-11-21-133933_add-expected-module-total-score/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +ALTER TABLE leaderboard ADD COLUMN IF NOT EXISTS expected_total_score INTEGER NOT NULL DEFAULT 0; diff --git a/src/app/init/mod.rs b/src/app/init/mod.rs new file mode 100644 index 0000000..5f57d86 --- /dev/null +++ b/src/app/init/mod.rs @@ -0,0 +1,51 @@ +use log::{error, info}; + +use crate::service::database::{ + conn::DbPool, + models::{Leaderboard, User}, +}; + +pub async fn initialize_leaderboards(pool: &DbPool) -> Result<(), Box> { + let mut conn = pool.get()?; + + // Get all users + let users = User::get_all_users(&mut conn)?; + + if users.is_empty() { + info!("No users found in database - skipping leaderboard initialization"); + return Ok(()); + } + + let mut initialized_count = 0; + for user in users { + // Check if user has a leaderboard entry + match Leaderboard::get_leaderboard(&mut conn, Some(&user.id)) { + Ok(leaderboard) => { + if leaderboard.is_empty() { + // Create new leaderboard for user + let new_leaderboard = Leaderboard::new(&user.id, None, 0, 0); + Leaderboard::create(&mut conn, new_leaderboard)?; + initialized_count += 1; + } + } + Err(e) => { + error!("Error checking leaderboard for user {}: {}", user.id, e); + // Create new leaderboard for user + let new_leaderboard = Leaderboard::new(&user.id, None, 0, 0); + Leaderboard::create(&mut conn, new_leaderboard)?; + initialized_count += 1; + } + } + } + + if initialized_count > 0 { + info!( + "Initialized {} missing leaderboard records", + initialized_count + ); + } else { + info!("All users have leaderboard records - no initialization needed"); + } + + Ok(()) +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 08c1216..3517a12 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod init; pub mod routes; pub mod progress; pub mod repo; diff --git a/src/app/routes/leaderboard.rs b/src/app/routes/leaderboard.rs new file mode 100644 index 0000000..0f4cd09 --- /dev/null +++ b/src/app/routes/leaderboard.rs @@ -0,0 +1,25 @@ +use actix_web::{web, HttpResponse, Scope}; +use log::error; + +use crate::{ + service::database::{conn::DbPool, models::Leaderboard}, + shared::errors::RepositoryError, +}; + +pub fn init() -> Scope { + web::scope("/leaderboard").route("", web::get().to(get_leaderboard)) +} + +async fn get_leaderboard(pool: web::Data) -> Result { + let mut conn = pool.get().map_err(|e| { + error!("Error getting db connection from pool: {}", e); + RepositoryError::DatabaseError(e.to_string()) + })?; + + let leaderboard = Leaderboard::get_leaderboard(&mut conn, None).map_err(|e| { + error!("Error getting leaderboard: {}", e); + RepositoryError::DatabaseError(e.to_string()) + })?; + + Ok(HttpResponse::Ok().json(leaderboard)) +} diff --git a/src/app/routes/mod.rs b/src/app/routes/mod.rs index 653d49e..8d3d561 100644 --- a/src/app/routes/mod.rs +++ b/src/app/routes/mod.rs @@ -8,6 +8,7 @@ pub mod users; pub mod repo; pub mod challenge; pub mod progress; +pub mod leaderboard; pub fn init(cfg: &mut web::ServiceConfig) { cfg.service(health::init()); @@ -17,4 +18,5 @@ pub fn init(cfg: &mut web::ServiceConfig) { cfg.service(repo::init()); cfg.service(challenge::init()); cfg.service(progress::init()); + cfg.service(leaderboard::init()); } diff --git a/src/app/routes/repo.rs b/src/app/routes/repo.rs index 5d0d047..9365feb 100644 --- a/src/app/routes/repo.rs +++ b/src/app/routes/repo.rs @@ -2,10 +2,12 @@ use crate::{ app::auth::middleware::SessionInfo, service::database::{ conn::DbPool, - models::{Challenge, Progress, Repository, User}, + models::{Challenge, Leaderboard, Progress, Repository, User}, }, shared::{ - errors::{CreateProgressError, CreateRepositoryError, RepositoryError}, + errors::{ + CreateProgressError, CreateRepositoryError, RepositoryError, UpdateLeaderboardError, + }, primitives::{PaginationParams, Status}, }, }; @@ -105,6 +107,14 @@ async fn create_repo( } }; + let leaderboard = match Leaderboard::get_leaderboard(&mut conn, Some(&user_id)) { + Ok(leaderboard) => leaderboard[0].clone(), + Err(e) => { + error!("Error getting leaderboard: {}", e); + return Err(RepositoryError::DatabaseError(e.to_string())); + } + }; + let repo_name = repo_url .rsplit("/") .next() @@ -185,6 +195,10 @@ async fn create_repo( "current_step": 1, })), ); + + // update leaderboard with expected total score + let expected_total_score = leaderboard.score + challenge.module_count; + let result = conn.transaction::<_, RepositoryError, _>( |conn: &mut PooledConnection>| { if let Err(e) = Repository::create_repo(conn, repo) { @@ -207,6 +221,18 @@ async fn create_repo( )); } + if let Err(e) = + Leaderboard::update(conn, &user_id, None, Some(expected_total_score), None) + { + error!("Error updating leaderboard in database: {:#?}", e); + return Err(RepositoryError::FailedToUpdateLeaderboard( + UpdateLeaderboardError(diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::Unknown, + Box::new(e.to_string()), + )), + )); + } + Ok(HttpResponse::Ok().json(json!({ "repo_name": &create_repo_response.repo_name, "repo_url": &create_repo_response.repo_url, diff --git a/src/app/routes/signup.rs b/src/app/routes/signup.rs index 5361c6d..166c36a 100644 --- a/src/app/routes/signup.rs +++ b/src/app/routes/signup.rs @@ -1,10 +1,10 @@ use crate::{ service::database::{ conn::DbPool, - models::{Session, User}, + models::{Leaderboard, Session, User}, }, shared::{ - errors::{CreateUserError, RepositoryError}, + errors::{CreateLeaderboardError, CreateUserError, RepositoryError}, primitives::UserRole, utils::generate_session_token, }, @@ -14,8 +14,8 @@ use diesel::{ r2d2::{ConnectionManager, PooledConnection}, Connection, PgConnection, }; -use serde_json::json; use log::error; +use serde_json::json; #[derive(serde::Deserialize)] struct NewUser { username: String, @@ -79,6 +79,16 @@ async fn signup( let token = generate_session_token(); let user_id = created_user.id; + let leaderboard = Leaderboard::new(&user_id, None, 0, 0); + Leaderboard::create(conn, leaderboard).map_err(|e| { + RepositoryError::FailedToCreateLeaderboard(CreateLeaderboardError( + diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::Unknown, + Box::new(e.to_string()), + ), + )) + })?; + let session = Session::new(&user_id, &token, &user.provider.to_lowercase()); Session::create(conn, session) .map_err(|e| RepositoryError::BadRequest(e.to_string()))?; diff --git a/src/main.rs b/src/main.rs index 2b97e70..3cd5018 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,11 +2,13 @@ use actix_cors::Cors; use actix_web::{middleware::Logger, web, App, HttpServer}; use app::{ auth::middleware::AuthMiddleware, + init::initialize_leaderboards, routes, websockets::{handler::websocket_handler, manager::WebSocketManagerHandle}, }; use dotenvy::dotenv; use env_logger::Env; +use log::error; use service::{database::conn::get_connection_pool, queue::consume_queue}; mod app; @@ -37,6 +39,20 @@ async fn main() -> std::io::Result<()> { let connection_url = std::env::var("CONNECTION_URL").unwrap_or_else(|_| "127.0.0.1:4925".to_string()); let pool = get_connection_pool(); + + // Initialize leaderboards before starting server + // This is done to ensure that leaderboards are created for all users + // since we have some users created before the leaderboard service was + // added to signup. + // TODO: Remove this once all users have leaderboard records + if let Err(e) = initialize_leaderboards(&pool).await { + error!("Failed to initialize leaderboards: {}", e); + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to initialize leaderboards: {}", e), + )); + } + let manager_handle = WebSocketManagerHandle::new(); let manager_handle_clone = manager_handle.clone(); diff --git a/src/schema.rs b/src/schema.rs index 1fd4d36..4c046af 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -55,6 +55,7 @@ diesel::table! { score -> Int4, created_at -> Timestamp, updated_at -> Timestamp, + expected_total_score -> Int4, } } diff --git a/src/service/database/models.rs b/src/service/database/models.rs index a678738..e3e285c 100644 --- a/src/service/database/models.rs +++ b/src/service/database/models.rs @@ -107,7 +107,7 @@ pub struct Session { pub expires_at: NaiveDateTime, } -#[derive(Queryable, Insertable, Selectable, Debug)] +#[derive(Queryable, Insertable, Selectable, Debug, Clone, Serialize)] #[diesel(table_name = crate::schema::leaderboard)] #[diesel(check_for_backend(diesel::pg::Pg))] #[allow(dead_code)] @@ -115,6 +115,7 @@ pub struct Leaderboard { pub id: i32, pub user_id: Uuid, pub score: i32, + pub expected_total_score: i32, pub achievements: Option, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, @@ -125,6 +126,7 @@ pub struct Leaderboard { pub struct NewLeaderboard { pub user_id: Uuid, pub score: i32, + pub expected_total_score: i32, pub achievements: Option, pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, diff --git a/src/service/repository/leaderboard.rs b/src/service/repository/leaderboard.rs index f1ee320..0ed8a63 100644 --- a/src/service/repository/leaderboard.rs +++ b/src/service/repository/leaderboard.rs @@ -7,20 +7,22 @@ use crate::shared::errors::{ }, UpdateLeaderboardError, }; -use crate::shared::utils::string_to_uuid; use anyhow::Result; use diesel::prelude::*; use log::error; +use uuid::Uuid; impl Leaderboard { pub fn new( - user_id: &str, + user_id: &Uuid, achievements: Option, score: i32, + expected_total_score: i32, ) -> NewLeaderboard { NewLeaderboard { - user_id: string_to_uuid(user_id).unwrap(), + user_id: user_id.clone(), score, + expected_total_score, achievements, created_at: chrono::Utc::now().naive_utc(), updated_at: chrono::Utc::now().naive_utc(), @@ -45,18 +47,14 @@ impl Leaderboard { pub fn get_leaderboard( connection: &mut PgConnection, - user_id: Option, + user_id: Option<&Uuid>, ) -> Result> { use crate::schema::leaderboard::dsl::user_id as user_id_col; match user_id { Some(user_id) => { - let user_id_uuid = string_to_uuid(&user_id).map_err(|e| { - error!("Error parsing UUID: {}", e); - anyhow::anyhow!("User ID is not valid") - })?; let leaderboard = leaderboard_table - .filter(user_id_col.eq(user_id_uuid)) + .filter(user_id_col.eq(user_id)) .select(Leaderboard::as_select()) .first::(connection) .map_err(|e| { @@ -80,23 +78,20 @@ impl Leaderboard { pub fn update( connection: &mut PgConnection, - user_id: &str, + user_id: &Uuid, new_score: Option, + new_expected_total_score: Option, new_achievements: Option, ) -> Result { use crate::schema::leaderboard::dsl::{ - achievements as achievements_col, score as score_col, user_id as user_id_col, + achievements as achievements_col, expected_total_score as expected_total_score_col, + score as score_col, user_id as user_id_col, }; - let user_id_uuid = string_to_uuid(user_id).map_err(|e| { - error!("Error parsing UUID: {}", e); - anyhow::anyhow!("User ID is not valid") - })?; - - match (new_score, new_achievements) { - (Some(new_score), Some(new_achievements)) => { + match (new_score, new_achievements, new_expected_total_score) { + (Some(new_score), Some(new_achievements), None) => { let updated_leaderboard = - diesel::update(leaderboard_table.filter(user_id_col.eq(user_id_uuid))) + diesel::update(leaderboard_table.filter(user_id_col.eq(user_id))) .set(( score_col.eq(new_score), achievements_col.eq(new_achievements), @@ -109,9 +104,9 @@ impl Leaderboard { })?; Ok(updated_leaderboard) } - (Some(new_score), None) => { + (Some(new_score), None, None) => { let updated_leaderboard = - diesel::update(leaderboard_table.filter(user_id_col.eq(user_id_uuid))) + diesel::update(leaderboard_table.filter(user_id_col.eq(user_id))) .set((score_col.eq(new_score),)) .returning(Leaderboard::as_returning()) .get_result(connection) @@ -121,9 +116,9 @@ impl Leaderboard { })?; Ok(updated_leaderboard) } - (None, Some(new_achievements)) => { + (None, Some(new_achievements), None) => { let updated_leaderboard = - diesel::update(leaderboard_table.filter(user_id_col.eq(user_id_uuid))) + diesel::update(leaderboard_table.filter(user_id_col.eq(user_id))) .set((achievements_col.eq(new_achievements),)) .returning(Leaderboard::as_returning()) .get_result(connection) @@ -133,7 +128,65 @@ impl Leaderboard { })?; Ok(updated_leaderboard) } - (None, None) => Err(anyhow::anyhow!("No new score or achievements provided")), + (None, None, Some(new_expected_total_score)) => { + let updated_leaderboard = + diesel::update(leaderboard_table.filter(user_id_col.eq(user_id))) + .set((expected_total_score_col.eq(new_expected_total_score),)) + .returning(Leaderboard::as_returning()) + .get_result(connection) + .map_err(|e| { + error!("Error updating leaderboard: {}", e); + FailedToUpdateLeaderboard(UpdateLeaderboardError(e)) + })?; + Ok(updated_leaderboard) + } + (Some(score), Some(achievements), Some(expected_total_score)) => { + let updated_leaderboard = + diesel::update(leaderboard_table.filter(user_id_col.eq(user_id))) + .set(( + score_col.eq(score), + achievements_col.eq(achievements), + expected_total_score_col.eq(expected_total_score), + )) + .returning(Leaderboard::as_returning()) + .get_result(connection) + .map_err(|e| { + error!("Error updating leaderboard: {}", e); + FailedToUpdateLeaderboard(UpdateLeaderboardError(e)) + })?; + Ok(updated_leaderboard) + } + (Some(score), None, Some(expected_total_score)) => { + let updated_leaderboard = + diesel::update(leaderboard_table.filter(user_id_col.eq(user_id))) + .set(( + score_col.eq(score), + expected_total_score_col.eq(expected_total_score), + )) + .returning(Leaderboard::as_returning()) + .get_result(connection) + .map_err(|e| { + error!("Error updating leaderboard: {}", e); + FailedToUpdateLeaderboard(UpdateLeaderboardError(e)) + })?; + Ok(updated_leaderboard) + } + (None, Some(achievements), Some(expected_total_score)) => { + let updated_leaderboard = + diesel::update(leaderboard_table.filter(user_id_col.eq(user_id))) + .set(( + achievements_col.eq(achievements), + expected_total_score_col.eq(expected_total_score), + )) + .returning(Leaderboard::as_returning()) + .get_result(connection) + .map_err(|e| { + error!("Error updating leaderboard: {}", e); + FailedToUpdateLeaderboard(UpdateLeaderboardError(e)) + })?; + Ok(updated_leaderboard) + } + (None, None, None) => Err(anyhow::anyhow!("No new score or achievements provided")), } } } diff --git a/src/service/repository/users.rs b/src/service/repository/users.rs index 8283699..8c575f6 100644 --- a/src/service/repository/users.rs +++ b/src/service/repository/users.rs @@ -132,4 +132,8 @@ impl User { _ => Err(anyhow::anyhow!("No input provided")), } } + + pub fn get_all_users(conn: &mut PgConnection) -> QueryResult> { + users::table.select(User::as_select()).load(conn) + } }