From 7d8a91afc21e2b8e85425904b920e68ec7e60684 Mon Sep 17 00:00:00 2001 From: Ben Kerllenevich Date: Sun, 2 Oct 2022 19:03:09 -0400 Subject: [PATCH 1/2] User following --- migrations/20221002212953_user_follows.sql | 9 + src/routes/mod.rs | 5 +- src/routes/projects.rs | 52 ++++- src/routes/users.rs | 209 ++++++++++++++++++++- 4 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 migrations/20221002212953_user_follows.sql diff --git a/migrations/20221002212953_user_follows.sql b/migrations/20221002212953_user_follows.sql new file mode 100644 index 00000000..84fcbc64 --- /dev/null +++ b/migrations/20221002212953_user_follows.sql @@ -0,0 +1,9 @@ +CREATE TABLE user_follows( + follower_id bigint REFERENCES users NOT NULL, + user_id bigint REFERENCES mods NOT NULL, + created timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + PRIMARY KEY (follower_id, user_id) +); + +ALTER TABLE users + ADD COLUMN follows integer NOT NULL default 0; \ No newline at end of file diff --git a/src/routes/mod.rs b/src/routes/mod.rs index cd4b08ef..8dc0966c 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -121,7 +121,10 @@ pub fn users_config(cfg: &mut web::ServiceConfig) { .service(users::user_edit) .service(users::user_icon_edit) .service(users::user_notifications) - .service(users::user_follows), + .service(users::user_follows) + .service(users::user_following) + .service(users::follow_user) + .service(users::unfollow_user), ); } diff --git a/src/routes/projects.rs b/src/routes/projects.rs index d8c123d5..557ed76d 100644 --- a/src/routes/projects.rs +++ b/src/routes/projects.rs @@ -1,4 +1,5 @@ use crate::database; +use crate::database::models::notification_item::NotificationBuilder; use crate::file_hosting::FileHost; use crate::models; use crate::models::projects::{ @@ -12,7 +13,7 @@ use crate::util::routes::read_from_payload; use crate::util::validate::validation_errors_to_string; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use chrono::Utc; -use futures::StreamExt; +use futures::{TryStreamExt, StreamExt}; use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::{PgPool, Row}; @@ -979,6 +980,55 @@ pub async fn project_edit( .await?; } + if new_project.status.as_ref().unwrap_or(&project_item.status) == &ProjectStatus::Approved { + let user = database::models::TeamMember::get_from_team(project_item.inner.team_id, &mut *transaction) + .await? + .into_iter() + .filter(|user| user.role == models::teams::OWNER_ROLE) + .collect::>(); + + let user = user + .first() + .ok_or_else(|| ApiError::InvalidInput("This project has no owner".to_string()))?; + + let users = sqlx::query!( + " + SELECT follower_id FROM user_follows + WHERE user_id = $1 + ", + user.user_id as crate::database::models::ids::UserId + ) + .fetch_many(&mut *transaction) + .try_filter_map(|e| async { + Ok(e.right().map(|m| database::models::ids::UserId(m.follower_id))) + }) + .try_collect::>() + .await?; + + let user = database::models::User::get(user.user_id, &mut *transaction) + .await? + .ok_or_else(|| ApiError::InvalidInput("This project has no owner".to_string()))?; + + let project_id: ProjectId = project_item.inner.id.into(); + + NotificationBuilder { + notification_type: Some("project_created".to_string()), + title: format!("**{}** has been created!", new_project.title.as_ref().unwrap_or(&project_item.inner.title)), + text: format!( + "{}, has released a new project: {}", + user.name.unwrap_or(user.username), + new_project.title.as_ref().unwrap_or(&project_item.inner.title) + ), + link: format!( + "/{}/{}", + project_item.project_type, project_id + ), + actions: vec![], + } + .insert_many(users, &mut transaction) + .await?; + } + transaction.commit().await?; Ok(HttpResponse::NoContent().body("")) } else { diff --git a/src/routes/users.rs b/src/routes/users.rs index 43c4e33a..86bca341 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -7,7 +7,7 @@ use crate::routes::ApiError; use crate::util::auth::get_user_from_headers; use crate::util::routes::read_from_payload; use crate::util::validate::validation_errors_to_string; -use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse}; +use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse, post}; use lazy_static::lazy_static; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -510,6 +510,213 @@ pub async fn user_follows( } } +#[derive(Serialize)] +pub struct UserFollowing { + projects: Vec, + users: Vec +} + +#[get("{id}/following")] +pub async fn user_following( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, +) -> Result { + let user = get_user_from_headers(req.headers(), &**pool).await?; + let id_option = crate::database::models::User::get_id_from_username_or_id( + &*info.into_inner().0, + &**pool, + ) + .await?; + + if let Some(id) = id_option { + if !user.role.is_admin() && user.id != id.into() { + return Err(ApiError::CustomAuthentication( + "You do not have permission to see the projects this user follows!".to_string(), + )); + } + + use futures::TryStreamExt; + + let project_ids = sqlx::query!( + " + SELECT mf.mod_id FROM mod_follows mf + WHERE mf.follower_id = $1 + ", + id as crate::database::models::ids::UserId, + ) + .fetch_many(&**pool) + .try_filter_map(|e| async { + Ok(e.right() + .map(|m| crate::database::models::ProjectId(m.mod_id))) + }) + .try_collect::>() + .await?; + + let projects: Vec<_> = + crate::database::Project::get_many_full(project_ids, &**pool) + .await? + .into_iter() + .map(Project::from) + .collect(); + + let user_ids = sqlx::query!( + " + SELECT user_id FROM user_follows + WHERE follower_id = $1 + ", + id as crate::database::models::ids::UserId, + ) + .fetch_many(&**pool) + .try_filter_map(|e| async { + Ok(e.right() + .map(|m| crate::database::models::UserId(m.user_id))) + }) + .try_collect::>() + .await?; + + let users: Vec<_> = + User::get_many(user_ids, &**pool) + .await? + .into_iter() + .map(crate::models::users::User::from) + .collect(); + + Ok(HttpResponse::Ok().json(UserFollowing { + projects: vec![], + users + })) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +#[post("{id}/follow")] +pub async fn follow_user( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, +) -> Result { + let user = get_user_from_headers(req.headers(), &**pool).await?; + let id_option = User::get_id_from_username_or_id( + &*info.into_inner().0, + &**pool, + ) + .await?; + + if let Some(id) = id_option { + let user_id: crate::database::models::ids::UserId = user.id.into(); + if user_id == id { + return Err(ApiError::InvalidInput("You cannot follow yourself".to_string())) + } + + let following = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM user_follows WHERE follower_id = $1 AND user_id = $2)", + id as crate::database::models::ids::UserId, + user_id as crate::database::models::ids::UserId + ) + .fetch_one(&**pool) + .await? + .exists + .unwrap_or(false); + + if !following { + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE users + SET follows = follows + 1 + WHERE id = $1 + ", + user_id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + INSERT INTO user_follows (follower_id, user_id) + VALUES ($1, $2) + ", + id as crate::database::models::ids::UserId, + user_id as crate::database::models::ids::UserId + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::InvalidInput("You are already following this user!".to_string(), )) + } + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +#[delete("{id}/follow")] +pub async fn unfollow_user( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, +) -> Result { + let user = get_user_from_headers(req.headers(), &**pool).await?; + let id_option = User::get_id_from_username_or_id( + &*info.into_inner().0, + &**pool, + ) + .await?; + + if let Some(id) = id_option { + let user_id: crate::database::models::ids::UserId = user.id.into(); + let following = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM user_follows WHERE follower_id = $1 AND user_id = $2)", + id as crate::database::models::ids::UserId, + user_id as crate::database::models::ids::UserId + ) + .fetch_one(&**pool) + .await? + .exists + .unwrap_or(false); + + if following { + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE users + SET follows = follows - 1 + WHERE id = $1 + ", + user_id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + DELETE FROM user_follows + WHERE follower_id = $1 AND user_id = $2 + ", + id as crate::database::models::ids::UserId, + user_id as crate::database::models::ids::UserId + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::InvalidInput("You are not already following this user!".to_string(), )) + } + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + #[get("{id}/notifications")] pub async fn user_notifications( req: HttpRequest, From b8ec11e35714ae209efa3cdc2e914b0950642438 Mon Sep 17 00:00:00 2001 From: Ben Kerllenevich Date: Mon, 3 Oct 2022 07:34:30 -0400 Subject: [PATCH 2/2] fmt & prepare --- sqlx-data.json | 111 +++++++++++++++++++++++++++++++++++++++++ src/routes/projects.rs | 76 ++++++++++++++++++---------- src/routes/users.rs | 94 +++++++++++++++++----------------- 3 files changed, 207 insertions(+), 74 deletions(-) diff --git a/sqlx-data.json b/sqlx-data.json index 658257d4..161e933e 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -180,6 +180,18 @@ }, "query": "\n SELECT f.version_id version_id\n FROM hashes h\n INNER JOIN files f ON h.file_id = f.id\n INNER JOIN versions v on f.version_id = v.id\n INNER JOIN mods m on v.mod_id = m.id\n INNER JOIN statuses s on m.status = s.id\n WHERE h.algorithm = $2 AND h.hash = $1 AND s.status != $3\n " }, + "09a8f55cd4c82c180d06a25638bc024f803f66afe5caa710934a6397dd8eea62": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n UPDATE users\n SET follows = follows - 1\n WHERE id = $1\n " + }, "0a1a470c12b84c7e171f0f51e8e541e9abe8bbee17fc441a5054e1dfd5607c05": { "describe": { "columns": [], @@ -1570,6 +1582,19 @@ }, "query": "\n DELETE FROM mods_categories\n WHERE joining_mod_id = $1 AND is_additional = TRUE\n " }, + "42695e1cf12cdeafc37674b362a2e0a415a25bfbed798ee7faf992da42205602": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "\n INSERT INTO user_follows (follower_id, user_id)\n VALUES ($1, $2)\n " + }, "4298552497a48adb9ace61c8dcf989c4d35866866b61c0cc4d45909b1d31c660": { "describe": { "columns": [ @@ -2806,6 +2831,19 @@ }, "query": "\n INSERT INTO loaders_versions (loader_id, version_id)\n VALUES ($1, $2)\n " }, + "6d09d3af4c7db4a631a3902d7e4c96996b62a6b2a23870f49920cec46811f823": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "\n DELETE FROM user_follows\n WHERE follower_id = $1 AND user_id = $2\n " + }, "6d883ea05aead20f571a0f63bfd63f1d432717ec7a0fb9ab29e01fcb061b3afc": { "describe": { "columns": [], @@ -3550,6 +3588,27 @@ }, "query": "\n UPDATE mods\n SET icon_url = $1\n WHERE (id = $2)\n " }, + "9128932fe8689883b1ba6feeaf338f04e5d106e33d9c0e3ae49af6622039695d": { + "describe": { + "columns": [ + { + "name": "exists", + "ordinal": 0, + "type_info": "Bool" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "SELECT EXISTS(SELECT 1 FROM user_follows WHERE follower_id = $1 AND user_id = $2)" + }, "9348309884811e8b22f33786ae7c0f259f37f3c90e545f00761a641570107160": { "describe": { "columns": [ @@ -5049,6 +5108,26 @@ }, "query": "\n DELETE FROM reports\n WHERE mod_id = $1\n " }, + "cc3942fd0a640bd19720cab5b21d6f144d630ff0295cbee228822fc57aa59ba9": { + "describe": { + "columns": [ + { + "name": "user_id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n SELECT user_id FROM user_follows\n WHERE follower_id = $1\n " + }, "ccd913bb2f3006ffe881ce2fc4ef1e721d18fe2eed6ac62627046c955129610c": { "describe": { "columns": [ @@ -5670,6 +5749,26 @@ }, "query": "\n DELETE FROM files\n WHERE files.id = $1\n " }, + "e44bc4fb044b6043eb29ea7c9d65d223ffa3c1732e04e7669d766448d24c9ca5": { + "describe": { + "columns": [ + { + "name": "follower_id", + "ordinal": 0, + "type_info": "Int8" + } + ], + "nullable": [ + false + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n SELECT follower_id FROM user_follows\n WHERE user_id = $1\n " + }, "e48c85a2b2e11691afae3799aa126bdd8b7338a973308bbab2760c18bb9cb0b7": { "describe": { "columns": [], @@ -6178,6 +6277,18 @@ }, "query": "\n SELECT version FROM game_versions\n WHERE id = $1\n " }, + "f192786daf210522fd584d16a404b893ce37e6b3d4b6ddcc438f89b5f499aa82": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n UPDATE users\n SET follows = follows + 1\n WHERE id = $1\n " + }, "f22e9aee090f9952cf795a3540c03b0a5036dab0b740847d05e03d4565756283": { "describe": { "columns": [], diff --git a/src/routes/projects.rs b/src/routes/projects.rs index 557ed76d..5485b8db 100644 --- a/src/routes/projects.rs +++ b/src/routes/projects.rs @@ -13,7 +13,7 @@ use crate::util::routes::read_from_payload; use crate::util::validate::validation_errors_to_string; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use chrono::Utc; -use futures::{TryStreamExt, StreamExt}; +use futures::{StreamExt, TryStreamExt}; use serde::{Deserialize, Serialize}; use serde_json::json; use sqlx::{PgPool, Row}; @@ -980,44 +980,68 @@ pub async fn project_edit( .await?; } - if new_project.status.as_ref().unwrap_or(&project_item.status) == &ProjectStatus::Approved { - let user = database::models::TeamMember::get_from_team(project_item.inner.team_id, &mut *transaction) - .await? - .into_iter() - .filter(|user| user.role == models::teams::OWNER_ROLE) - .collect::>(); + if new_project.status.as_ref().unwrap_or(&project_item.status) + == &ProjectStatus::Approved + { + let user = database::models::TeamMember::get_from_team( + project_item.inner.team_id, + &mut *transaction, + ) + .await? + .into_iter() + .filter(|user| user.role == models::teams::OWNER_ROLE) + .collect::>(); - let user = user - .first() - .ok_or_else(|| ApiError::InvalidInput("This project has no owner".to_string()))?; + let user = user.first().ok_or_else(|| { + ApiError::InvalidInput( + "This project has no owner".to_string(), + ) + })?; let users = sqlx::query!( - " + " SELECT follower_id FROM user_follows WHERE user_id = $1 ", - user.user_id as crate::database::models::ids::UserId - ) - .fetch_many(&mut *transaction) - .try_filter_map(|e| async { - Ok(e.right().map(|m| database::models::ids::UserId(m.follower_id))) - }) - .try_collect::>() - .await?; + user.user_id as crate::database::models::ids::UserId + ) + .fetch_many(&mut *transaction) + .try_filter_map(|e| async { + Ok(e.right() + .map(|m| database::models::ids::UserId(m.follower_id))) + }) + .try_collect::>() + .await?; - let user = database::models::User::get(user.user_id, &mut *transaction) - .await? - .ok_or_else(|| ApiError::InvalidInput("This project has no owner".to_string()))?; + let user = database::models::User::get( + user.user_id, + &mut *transaction, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "This project has no owner".to_string(), + ) + })?; let project_id: ProjectId = project_item.inner.id.into(); NotificationBuilder { notification_type: Some("project_created".to_string()), - title: format!("**{}** has been created!", new_project.title.as_ref().unwrap_or(&project_item.inner.title)), + title: format!( + "**{}** has been created!", + new_project + .title + .as_ref() + .unwrap_or(&project_item.inner.title) + ), text: format!( "{}, has released a new project: {}", user.name.unwrap_or(user.username), - new_project.title.as_ref().unwrap_or(&project_item.inner.title) + new_project + .title + .as_ref() + .unwrap_or(&project_item.inner.title) ), link: format!( "/{}/{}", @@ -1025,8 +1049,8 @@ pub async fn project_edit( ), actions: vec![], } - .insert_many(users, &mut transaction) - .await?; + .insert_many(users, &mut transaction) + .await?; } transaction.commit().await?; diff --git a/src/routes/users.rs b/src/routes/users.rs index 86bca341..c9686d19 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -7,7 +7,7 @@ use crate::routes::ApiError; use crate::util::auth::get_user_from_headers; use crate::util::routes::read_from_payload; use crate::util::validate::validation_errors_to_string; -use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse, post}; +use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use lazy_static::lazy_static; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -513,7 +513,7 @@ pub async fn user_follows( #[derive(Serialize)] pub struct UserFollowing { projects: Vec, - users: Vec + users: Vec, } #[get("{id}/following")] @@ -527,7 +527,7 @@ pub async fn user_following( &*info.into_inner().0, &**pool, ) - .await?; + .await?; if let Some(id) = id_option { if !user.role.is_admin() && user.id != id.into() { @@ -545,13 +545,13 @@ pub async fn user_following( ", id as crate::database::models::ids::UserId, ) - .fetch_many(&**pool) - .try_filter_map(|e| async { - Ok(e.right() - .map(|m| crate::database::models::ProjectId(m.mod_id))) - }) - .try_collect::>() - .await?; + .fetch_many(&**pool) + .try_filter_map(|e| async { + Ok(e.right() + .map(|m| crate::database::models::ProjectId(m.mod_id))) + }) + .try_collect::>() + .await?; let projects: Vec<_> = crate::database::Project::get_many_full(project_ids, &**pool) @@ -567,25 +567,21 @@ pub async fn user_following( ", id as crate::database::models::ids::UserId, ) - .fetch_many(&**pool) - .try_filter_map(|e| async { - Ok(e.right() - .map(|m| crate::database::models::UserId(m.user_id))) - }) - .try_collect::>() - .await?; + .fetch_many(&**pool) + .try_filter_map(|e| async { + Ok(e.right() + .map(|m| crate::database::models::UserId(m.user_id))) + }) + .try_collect::>() + .await?; - let users: Vec<_> = - User::get_many(user_ids, &**pool) - .await? - .into_iter() - .map(crate::models::users::User::from) - .collect(); + let users: Vec<_> = User::get_many(user_ids, &**pool) + .await? + .into_iter() + .map(crate::models::users::User::from) + .collect(); - Ok(HttpResponse::Ok().json(UserFollowing { - projects: vec![], - users - })) + Ok(HttpResponse::Ok().json(UserFollowing { projects, users })) } else { Ok(HttpResponse::NotFound().body("")) } @@ -598,16 +594,16 @@ pub async fn follow_user( pool: web::Data, ) -> Result { let user = get_user_from_headers(req.headers(), &**pool).await?; - let id_option = User::get_id_from_username_or_id( - &*info.into_inner().0, - &**pool, - ) - .await?; + let id_option = + User::get_id_from_username_or_id(&*info.into_inner().0, &**pool) + .await?; if let Some(id) = id_option { let user_id: crate::database::models::ids::UserId = user.id.into(); if user_id == id { - return Err(ApiError::InvalidInput("You cannot follow yourself".to_string())) + return Err(ApiError::InvalidInput( + "You cannot follow yourself".to_string(), + )); } let following = sqlx::query!( @@ -631,8 +627,8 @@ pub async fn follow_user( ", user_id as crate::database::models::ids::UserId, ) - .execute(&mut *transaction) - .await?; + .execute(&mut *transaction) + .await?; sqlx::query!( " @@ -642,14 +638,16 @@ pub async fn follow_user( id as crate::database::models::ids::UserId, user_id as crate::database::models::ids::UserId ) - .execute(&mut *transaction) - .await?; + .execute(&mut *transaction) + .await?; transaction.commit().await?; Ok(HttpResponse::NoContent().body("")) } else { - Err(ApiError::InvalidInput("You are already following this user!".to_string(), )) + Err(ApiError::InvalidInput( + "You are already following this user!".to_string(), + )) } } else { Ok(HttpResponse::NotFound().body("")) @@ -663,11 +661,9 @@ pub async fn unfollow_user( pool: web::Data, ) -> Result { let user = get_user_from_headers(req.headers(), &**pool).await?; - let id_option = User::get_id_from_username_or_id( - &*info.into_inner().0, - &**pool, - ) - .await?; + let id_option = + User::get_id_from_username_or_id(&*info.into_inner().0, &**pool) + .await?; if let Some(id) = id_option { let user_id: crate::database::models::ids::UserId = user.id.into(); @@ -692,8 +688,8 @@ pub async fn unfollow_user( ", user_id as crate::database::models::ids::UserId, ) - .execute(&mut *transaction) - .await?; + .execute(&mut *transaction) + .await?; sqlx::query!( " @@ -703,14 +699,16 @@ pub async fn unfollow_user( id as crate::database::models::ids::UserId, user_id as crate::database::models::ids::UserId ) - .execute(&mut *transaction) - .await?; + .execute(&mut *transaction) + .await?; transaction.commit().await?; Ok(HttpResponse::NoContent().body("")) } else { - Err(ApiError::InvalidInput("You are not already following this user!".to_string(), )) + Err(ApiError::InvalidInput( + "You are not already following this user!".to_string(), + )) } } else { Ok(HttpResponse::NotFound().body(""))