Skip to content

Commit

Permalink
feat: add leaderboard endpoint (#44)
Browse files Browse the repository at this point in the history
* feat: add leaderboard endpoint

* feat: create new leaderboard on signup

* feat: add get leaderboard endpoint

* feat: init leaderboard for all users before server starts
  • Loading branch information
Extheoisah authored Nov 21, 2024
1 parent b8f7d70 commit bc83fed
Show file tree
Hide file tree
Showing 13 changed files with 225 additions and 30 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
ALTER TABLE leaderboard DROP COLUMN IF EXISTS expected_total_score;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Your SQL goes here
ALTER TABLE leaderboard ADD COLUMN IF NOT EXISTS expected_total_score INTEGER NOT NULL DEFAULT 0;
51 changes: 51 additions & 0 deletions src/app/init/mod.rs
Original file line number Diff line number Diff line change
@@ -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<dyn std::error::Error>> {
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(())
}
1 change: 1 addition & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod auth;
pub mod init;
pub mod routes;
pub mod progress;
pub mod repo;
Expand Down
25 changes: 25 additions & 0 deletions src/app/routes/leaderboard.rs
Original file line number Diff line number Diff line change
@@ -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<DbPool>) -> Result<HttpResponse, RepositoryError> {
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))
}
2 changes: 2 additions & 0 deletions src/app/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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());
}
30 changes: 28 additions & 2 deletions src/app/routes/repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
},
};
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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<ConnectionManager<PgConnection>>| {
if let Err(e) = Repository::create_repo(conn, repo) {
Expand All @@ -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,
Expand Down
16 changes: 13 additions & 3 deletions src/app/routes/signup.rs
Original file line number Diff line number Diff line change
@@ -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,
},
Expand All @@ -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,
Expand Down Expand Up @@ -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()))?;
Expand Down
16 changes: 16 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand Down
1 change: 1 addition & 0 deletions src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ diesel::table! {
score -> Int4,
created_at -> Timestamp,
updated_at -> Timestamp,
expected_total_score -> Int4,
}
}

Expand Down
4 changes: 3 additions & 1 deletion src/service/database/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,15 @@ 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)]
pub struct Leaderboard {
pub id: i32,
pub user_id: Uuid,
pub score: i32,
pub expected_total_score: i32,
pub achievements: Option<serde_json::Value>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
Expand All @@ -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<serde_json::Value>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
Expand Down
Loading

0 comments on commit bc83fed

Please sign in to comment.