diff --git a/.gitignore b/.gitignore index 62c627fd4..191e2a310 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ frontend/src/assets/logo.svg target .env .vscode +.idea # ergo irc server ergo/ergo-conf.yaml diff --git a/backend/api/src/api_doc.rs b/backend/api/src/api_doc.rs index 4f4c209a3..22f40fd67 100644 --- a/backend/api/src/api_doc.rs +++ b/backend/api/src/api_doc.rs @@ -211,6 +211,8 @@ use arcadia_storage::models::user_application::UserApplicationHierarchy; crate::handlers::forum::edit_forum_thread::exec, crate::handlers::forum::pin_forum_thread::exec, crate::handlers::forum::create_forum_post::exec, + crate::handlers::forum::set_forum_post_reaction::exec, + crate::handlers::forum::delete_forum_post_reaction::exec, crate::handlers::forum::edit_forum_post::exec, crate::handlers::forum::delete_forum_category::exec, crate::handlers::forum::delete_forum_sub_category::exec, diff --git a/backend/api/src/handlers/forum/delete_forum_post_reaction.rs b/backend/api/src/handlers/forum/delete_forum_post_reaction.rs new file mode 100644 index 000000000..c5b755b23 --- /dev/null +++ b/backend/api/src/handlers/forum/delete_forum_post_reaction.rs @@ -0,0 +1,43 @@ +use crate::{middlewares::auth_middleware::Authdata, Arcadia}; +use actix_web::{ + web::{Data, Path}, + HttpRequest, HttpResponse, +}; +use arcadia_common::error::Result; +use arcadia_storage::{ + models::{forum::ForumPostHierarchy, user::UserPermission}, + redis::RedisPoolInterface, +}; + +#[utoipa::path( + delete, + operation_id = "Delete forum post reaction", + tag = "Forum", + path = "/api/forum/post/{id}/reaction", + security( + ("http" = ["Bearer"]) + ), + params( + ("id" = i64, Path, description = "Forum post id") + ), + responses( + (status = 200, description = "Successfully delete the forum post reaction", body = ForumPostHierarchy), + ) +)] +pub async fn exec( + post_id: Path, + arc: Data>, + user: Authdata, + req: HttpRequest, +) -> Result { + arc.pool + .require_permission(user.sub, &UserPermission::CreateForumPost, req.path()) + .await?; + + let forum_post = arc + .pool + .delete_forum_post_reaction_and_get_post(*post_id, user.sub) + .await?; + + Ok(HttpResponse::Ok().json(forum_post)) +} diff --git a/backend/api/src/handlers/forum/mod.rs b/backend/api/src/handlers/forum/mod.rs index 58ea68951..4095856b9 100644 --- a/backend/api/src/handlers/forum/mod.rs +++ b/backend/api/src/handlers/forum/mod.rs @@ -5,6 +5,7 @@ pub mod create_forum_sub_category; pub mod create_forum_thread; pub mod delete_forum_category; pub mod delete_forum_post; +pub mod delete_forum_post_reaction; pub mod delete_forum_sub_category; pub mod delete_forum_thread; pub mod edit_forum_category; @@ -20,6 +21,7 @@ pub mod pin_forum_thread; pub mod remove_forum_sub_category_allowed_poster; pub mod reorder_forum_category; pub mod reorder_forum_sub_category; +pub mod set_forum_post_reaction; use actix_web::web::{delete, get, post, put, resource, ServiceConfig}; use arcadia_storage::redis::RedisPoolInterface; @@ -50,6 +52,11 @@ pub fn config(cfg: &mut ServiceConfig) { .route(put().to(self::edit_forum_post::exec::)) .route(delete().to(self::delete_forum_post::exec::)), ); + cfg.service( + resource("/post/{id}/reaction") + .route(put().to(self::set_forum_post_reaction::exec::)) + .route(delete().to(self::delete_forum_post_reaction::exec::)), + ); cfg.service( resource("/sub-category") .route(get().to(self::get_forum_sub_category_threads::exec::)) diff --git a/backend/api/src/handlers/forum/set_forum_post_reaction.rs b/backend/api/src/handlers/forum/set_forum_post_reaction.rs new file mode 100644 index 000000000..6da5ee617 --- /dev/null +++ b/backend/api/src/handlers/forum/set_forum_post_reaction.rs @@ -0,0 +1,48 @@ +use crate::{middlewares::auth_middleware::Authdata, Arcadia}; +use actix_web::{ + web::{Data, Json, Path}, + HttpRequest, HttpResponse, +}; +use arcadia_common::error::Result; +use arcadia_storage::{ + models::{ + forum::{ForumPostHierarchy, UserCreatedForumPostReaction}, + user::UserPermission, + }, + redis::RedisPoolInterface, +}; + +#[utoipa::path( + put, + operation_id = "Set forum post reaction", + tag = "Forum", + path = "/api/forum/post/{id}/reaction", + security( + ("http" = ["Bearer"]) + ), + params( + ("id" = i64, Path, description = "Forum post id") + ), + request_body = UserCreatedForumPostReaction, + responses( + (status = 200, description = "Successfully set the forum post reaction", body = ForumPostHierarchy), + ) +)] +pub async fn exec( + post_id: Path, + reaction: Json, + arc: Data>, + user: Authdata, + req: HttpRequest, +) -> Result { + arc.pool + .require_permission(user.sub, &UserPermission::CreateForumPost, req.path()) + .await?; + + let forum_post = arc + .pool + .set_forum_post_reaction_and_get_post(*post_id, reaction.into_inner(), user.sub) + .await?; + + Ok(HttpResponse::Ok().json(forum_post)) +} diff --git a/backend/api/tests/fixtures/with_test_forum_post.sql b/backend/api/tests/fixtures/with_test_forum_post.sql index 122165ad1..ff79e8cab 100644 --- a/backend/api/tests/fixtures/with_test_forum_post.sql +++ b/backend/api/tests/fixtures/with_test_forum_post.sql @@ -4,4 +4,5 @@ VALUES (100, 100, 'This is the first post in the test thread', '2025-01-01 10:00:00+00', 100), (101, 101, 'This is the first post in the locked thread', '2025-01-01 11:00:00+00', 100), (102, 102, 'This is the first post in the pinned thread', '2025-01-01 12:00:00+00', 100), - (103, 103, 'This is the first post in the thread in different sub category', '2025-01-01 13:00:00+00', 100); + (103, 103, 'This is the first post in the thread in different sub category', '2025-01-01 13:00:00+00', 100), + (104, 100, 'This is the second post in the test thread', '2025-01-01 13:00:00+00', 100); diff --git a/backend/api/tests/fixtures/with_test_forum_reaction.sql b/backend/api/tests/fixtures/with_test_forum_reaction.sql new file mode 100644 index 000000000..9bf9faca0 --- /dev/null +++ b/backend/api/tests/fixtures/with_test_forum_reaction.sql @@ -0,0 +1,4 @@ +INSERT INTO + forum_post_reactions (id, forum_post_id, user_id, emoji) +VALUES + (100, 100, 100, 'πŸ₯°'); diff --git a/backend/api/tests/test_forum_post.rs b/backend/api/tests/test_forum_post.rs new file mode 100644 index 000000000..904d65497 --- /dev/null +++ b/backend/api/tests/test_forum_post.rs @@ -0,0 +1,243 @@ +pub mod common; +pub mod mocks; + +use actix_web::http::StatusCode; +use actix_web::test; + +use arcadia_storage::models::forum::{ForumPostHierarchy, UserCreatedForumPostReaction}; +use arcadia_storage::{connection_pool::ConnectionPool, models::common::PaginatedResults}; +use common::{auth_header, create_test_app_and_login, TestUser}; +use mocks::mock_redis::MockRedisPool; +use sqlx::PgPool; +use std::sync::Arc; + +// ============================================================================ +// GET FORUM POST REACTIONS TESTS +// ============================================================================ +#[sqlx::test( + fixtures( + "with_test_users", + "with_test_forum_category", + "with_test_forum_sub_category", + "with_test_forum_thread", + "with_test_forum_post", + "with_test_forum_reaction", + ), + migrations = "../storage/migrations" +)] +async fn test_get_forum_post_with_one_reaction(pool: PgPool) { + let pool = Arc::new(ConnectionPool::with_pg_pool(pool)); + let (service, user) = + create_test_app_and_login(pool, MockRedisPool::default(), TestUser::Standard).await; + + // We retrieve the thread + let req = test::TestRequest::get() + .uri("/api/forum/thread/posts?thread_id=100&page_size=10") + .insert_header(auth_header(&user.token)) + .to_request(); + + let posts: PaginatedResults = + common::call_and_read_body_json_with_status(&service, req, StatusCode::OK).await; + + // We got 2 posts for this thread from this user + // The first post has a reaction, the second none + assert_eq!(posts.total_items, 2); + assert_eq!(posts.results[0].created_by.id, 100); + assert!(posts.results[0].reaction.is_some()); + assert_eq!(posts.results[0].reaction.as_ref().unwrap().emoji, "πŸ₯°"); + assert_eq!( + posts.results[0].reaction.as_ref().unwrap().forum_post_id, + 100 + ); + assert!(posts.results[1].reaction.is_none()); +} + +// ============================================================================ +// POST FORUM POST REACTIONS TESTS +// ============================================================================ +#[sqlx::test( + fixtures( + "with_test_users", + "with_test_forum_category", + "with_test_forum_sub_category", + "with_test_forum_thread", + "with_test_forum_post", + "with_test_forum_reaction" + ), + migrations = "../storage/migrations" +)] +async fn test_post_forum_post_reaction(pool: PgPool) { + let pool = Arc::new(ConnectionPool::with_pg_pool(pool)); + let (service, user) = + create_test_app_and_login(pool, MockRedisPool::default(), TestUser::Standard).await; + + // We get the post and check if not reaction is bound to it + let req_get = test::TestRequest::get() + .uri("/api/forum/thread/posts?thread_id=103&page_size=10") + .insert_header(auth_header(&user.token)) + .to_request(); + + let posts: PaginatedResults = + common::call_and_read_body_json_with_status(&service, req_get, StatusCode::OK).await; + + assert_eq!(posts.total_items, 1); + assert_eq!(posts.results[0].id, 103); + assert!(posts.results[0].reaction.is_none()); + + // We create the reaction and test that it was created + let create_body = UserCreatedForumPostReaction { + emoji: "😺".to_string(), + }; + + let req = test::TestRequest::put() + .uri("/api/forum/post/103/reaction") + .insert_header(auth_header(&user.token)) + .set_json(&create_body) + .to_request(); + + let post: ForumPostHierarchy = + common::call_and_read_body_json_with_status(&service, req, StatusCode::OK).await; + + assert_eq!(post.id, 103); + assert_eq!(post.reaction.as_ref().unwrap().forum_post_id, 103); + assert_eq!(post.reaction.as_ref().unwrap().emoji, "😺"); +} + +#[sqlx::test( + fixtures( + "with_test_users", + "with_test_forum_category", + "with_test_forum_sub_category", + "with_test_forum_thread", + "with_test_forum_post", + "with_test_forum_reaction" + ), + migrations = "../storage/migrations" +)] +async fn test_post_forum_post_reaction_should_return_error(pool: PgPool) { + let pool = Arc::new(ConnectionPool::with_pg_pool(pool)); + let (service, user) = + create_test_app_and_login(pool, MockRedisPool::default(), TestUser::Standard).await; + + // We get the post and check if not reaction is bound to it + let req_get = test::TestRequest::get() + .uri("/api/forum/thread/posts?thread_id=103&page_size=10") + .insert_header(auth_header(&user.token)) + .to_request(); + + let posts: PaginatedResults = + common::call_and_read_body_json_with_status(&service, req_get, StatusCode::OK).await; + + assert_eq!(posts.total_items, 1); + assert_eq!(posts.results[0].id, 103); + assert!(posts.results[0].reaction.is_none()); + + // We create the reaction with 2 emojis, it will not work since the column is a VARCHAR(1) + let create_body = UserCreatedForumPostReaction { + emoji: "😺😺".to_string(), + }; + + let req = test::TestRequest::put() + .uri("/api/forum/post/103/reaction") + .insert_header(auth_header(&user.token)) + .set_json(&create_body) + .to_request(); + + let resp = test::call_service(&service, req).await; + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + let body = test::read_body(resp).await; + let body = String::from_utf8(body.to_vec()).unwrap(); + + assert!(body.contains("could not update forum post reaction")); +} + +// ============================================================================ +// PUT FORUM POST REACTIONS TESTS +// ============================================================================ +#[sqlx::test( + fixtures( + "with_test_users", + "with_test_forum_category", + "with_test_forum_sub_category", + "with_test_forum_thread", + "with_test_forum_post", + "with_test_forum_reaction", + ), + migrations = "../storage/migrations" +)] +async fn test_put_forum_post_reaction(pool: PgPool) { + let pool = Arc::new(ConnectionPool::with_pg_pool(pool)); + let (service, user) = + create_test_app_and_login(pool, MockRedisPool::default(), TestUser::Standard).await; + + // We retrieve the old reaction to check it's already created and has the emoji πŸ₯° + let req_get = test::TestRequest::get() + .uri("/api/forum/thread/posts?thread_id=100&page_size=10") + .insert_header(auth_header(&user.token)) + .to_request(); + + let posts: PaginatedResults = + common::call_and_read_body_json_with_status(&service, req_get, StatusCode::OK).await; + + assert!(posts.results[0].reaction.is_some()); + assert_eq!(posts.results[0].id, 100); + assert_eq!(posts.results[0].reaction.as_ref().unwrap().emoji, "πŸ₯°"); + + // We modify the reaction (the emoji) + let create_body = UserCreatedForumPostReaction { + emoji: "😺".to_string(), + }; + + let req = test::TestRequest::put() + .uri("/api/forum/post/100/reaction") + .insert_header(auth_header(&user.token)) + .set_json(&create_body) + .to_request(); + + let post: ForumPostHierarchy = + common::call_and_read_body_json_with_status(&service, req, StatusCode::OK).await; + + assert_eq!(post.reaction.as_ref().unwrap().id, 100); + assert_eq!(post.reaction.as_ref().unwrap().emoji, "😺"); +} + +// ============================================================================ +// DELETE FORUM POST REACTIONS TESTS +// ============================================================================ +#[sqlx::test( + fixtures( + "with_test_users", + "with_test_forum_category", + "with_test_forum_sub_category", + "with_test_forum_thread", + "with_test_forum_post", + "with_test_forum_reaction", + ), + migrations = "../storage/migrations" +)] +async fn test_delete_forum_post_reaction(pool: PgPool) { + let pool = Arc::new(ConnectionPool::with_pg_pool(pool)); + let (service, user) = + create_test_app_and_login(pool, MockRedisPool::default(), TestUser::Standard).await; + + let req = test::TestRequest::delete() + .uri("/api/forum/post/100/reaction") + .insert_header(auth_header(&user.token)) + .to_request(); + + let res = test::call_service(&service, req).await; + assert_eq!(res.status(), StatusCode::OK); + + // Verify delete by getting the post and checking that reaction is null + let req_get = test::TestRequest::get() + .uri("/api/forum/thread/posts?thread_id=100&page_size=10&post_id=100") + .insert_header(auth_header(&user.token)) + .to_request(); + + let posts: PaginatedResults = + common::call_and_read_body_json_with_status(&service, req_get, StatusCode::OK).await; + + assert!(posts.results[0].reaction.is_none()); +} diff --git a/backend/common/src/error/mod.rs b/backend/common/src/error/mod.rs index f24f8fd9d..50847e65d 100644 --- a/backend/common/src/error/mod.rs +++ b/backend/common/src/error/mod.rs @@ -326,6 +326,9 @@ pub enum Error { #[error("could not update forum post")] CouldNotUpdateForumPost(#[source] sqlx::Error), + #[error("could not update forum post reaction")] + CouldNotUpdateForumPostReaction(#[source] sqlx::Error), + #[error("could not update forum thread")] CouldNotUpdateForumThread(#[source] sqlx::Error), @@ -344,6 +347,9 @@ pub enum Error { #[error("could not find forum post")] CouldNotFindForumPost(#[source] sqlx::Error), + #[error("could not find forum post")] + CouldNotFindForumPostReaction(#[source] sqlx::Error), + #[error("could not create forum thread")] CouldNotCreateForumThread(#[source] sqlx::Error), @@ -413,6 +419,9 @@ pub enum Error { #[error("could not delete forum post")] CouldNotDeleteForumPost(#[source] sqlx::Error), + #[error("could not delete forum post reaction")] + CouldNotDeleteForumPostReaction(#[source] sqlx::Error), + #[error("could not upsert forum thread read")] CouldNotUpsertForumThreadRead(#[source] sqlx::Error), @@ -649,6 +658,7 @@ impl actix_web::ResponseError for Error { | Error::InvalidTagExpression(_) | Error::TitleGroupTagDeleted(..) | Error::EditionGroupsNotInSameTitleGroup + | Error::CouldNotUpdateForumPostReaction(_) | Error::WikiArticleCannotBeLinkedToItself => StatusCode::BAD_REQUEST, // 401 Unauthorized @@ -682,6 +692,7 @@ impl actix_web::ResponseError for Error { | Error::CouldNotFindForumThread(_) | Error::CouldNotFindForumSubCategory(_) | Error::CouldNotFindForumPost(_) + | Error::CouldNotFindForumPostReaction(_) | Error::CssSheetNotFound(_) | Error::ForumCategoryNotFound | Error::ForumSubCategoryNotFound diff --git a/backend/storage/.sqlx/query-07435792b9b25324453fb323c5edc0f0a3765f2b32969f229d8675f08ac0a006.json b/backend/storage/.sqlx/query-07435792b9b25324453fb323c5edc0f0a3765f2b32969f229d8675f08ac0a006.json new file mode 100644 index 000000000..38bf459fd --- /dev/null +++ b/backend/storage/.sqlx/query-07435792b9b25324453fb323c5edc0f0a3765f2b32969f229d8675f08ac0a006.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM forum_post_reactions WHERE forum_post_id = $1 AND user_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "07435792b9b25324453fb323c5edc0f0a3765f2b32969f229d8675f08ac0a006" +} diff --git a/backend/storage/.sqlx/query-428a678919b1a16a2614dee0d093279f3235e39a250ce5a3ca2ed260c8f71d54.json b/backend/storage/.sqlx/query-428a678919b1a16a2614dee0d093279f3235e39a250ce5a3ca2ed260c8f71d54.json new file mode 100644 index 000000000..a340f6cd0 --- /dev/null +++ b/backend/storage/.sqlx/query-428a678919b1a16a2614dee0d093279f3235e39a250ce5a3ca2ed260c8f71d54.json @@ -0,0 +1,126 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH upserted AS (\n INSERT INTO forum_post_reactions (forum_post_id, user_id, emoji)\n VALUES ($1, $2, $3)\n ON CONFLICT (forum_post_id, user_id)\n DO UPDATE SET\n emoji = EXCLUDED.emoji\n WHERE forum_post_reactions.emoji IS DISTINCT FROM EXCLUDED.emoji\n RETURNING\n id AS reaction_id,\n forum_post_id AS reaction_forum_post_id,\n user_id AS reaction_user_id,\n emoji AS reaction_emoji\n )\n SELECT\n fp.id,\n fp.content,\n fp.created_at,\n fp.updated_at,\n fp.sticky,\n fp.locked,\n fp.forum_thread_id,\n u.id AS created_by_user_id,\n u.username AS created_by_user_username,\n u.class_name AS created_by_user_class_name,\n u.avatar AS created_by_user_avatar,\n u.banned AS created_by_user_banned,\n u.warned AS created_by_user_warned,\n u.custom_title AS created_by_user_custom_title,\n up.reaction_id AS \"reaction_id?\",\n up.reaction_forum_post_id AS \"reaction_forum_post_id?\",\n up.reaction_user_id AS \"reaction_user_id?\",\n up.reaction_emoji AS \"reaction_emoji?\"\n FROM forum_posts fp\n JOIN users u ON fp.created_by_id = u.id\n LEFT JOIN upserted up\n ON up.reaction_forum_post_id = fp.id\n WHERE fp.id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "content", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "sticky", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "locked", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "forum_thread_id", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "created_by_user_id", + "type_info": "Int4" + }, + { + "ordinal": 8, + "name": "created_by_user_username", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "created_by_user_class_name", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "created_by_user_avatar", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "created_by_user_banned", + "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "created_by_user_warned", + "type_info": "Bool" + }, + { + "ordinal": 13, + "name": "created_by_user_custom_title", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "reaction_id?", + "type_info": "Int8" + }, + { + "ordinal": 15, + "name": "reaction_forum_post_id?", + "type_info": "Int8" + }, + { + "ordinal": 16, + "name": "reaction_user_id?", + "type_info": "Int4" + }, + { + "ordinal": 17, + "name": "reaction_emoji?", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int4", + "Varchar" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + true, + false, + false, + false, + false + ] + }, + "hash": "428a678919b1a16a2614dee0d093279f3235e39a250ce5a3ca2ed260c8f71d54" +} diff --git a/backend/storage/.sqlx/query-43ca35fea526b25dffb69e86700c0d3a1bdebdb973df5059ff7eb3e9a933eb33.json b/backend/storage/.sqlx/query-43ca35fea526b25dffb69e86700c0d3a1bdebdb973df5059ff7eb3e9a933eb33.json deleted file mode 100644 index 5080b7e19..000000000 --- a/backend/storage/.sqlx/query-43ca35fea526b25dffb69e86700c0d3a1bdebdb973df5059ff7eb3e9a933eb33.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO torrent_activities (\n torrent_id,\n user_id,\n completed_at,\n first_seen_seeding_at,\n last_seen_seeding_at,\n total_seed_time,\n uploaded,\n real_uploaded,\n downloaded,\n real_downloaded\n )\n SELECT\n agg.torrent_id,\n agg.user_id,\n agg.completed_at,\n agg.first_seen_seeding_at,\n agg.last_seen_seeding_at,\n 0,\n agg.uploaded_delta,\n agg.real_uploaded_delta,\n agg.downloaded_delta,\n agg.real_downloaded_delta\n FROM (\n SELECT\n t.torrent_id,\n t.user_id,\n MIN(t.completed_at) AS completed_at,\n MIN(CASE WHEN t.seeder THEN t.updated_at END) AS first_seen_seeding_at,\n MAX(CASE WHEN t.seeder THEN t.updated_at END) AS last_seen_seeding_at,\n SUM(t.uploaded_delta) AS uploaded_delta,\n SUM(t.real_uploaded_delta) AS real_uploaded_delta,\n SUM(t.downloaded_delta) AS downloaded_delta,\n SUM(t.real_downloaded_delta) AS real_downloaded_delta\n FROM unnest(\n $1::int[],\n $2::int[],\n $3::timestamptz[],\n $4::bigint[],\n $5::bigint[],\n $6::bigint[],\n $7::bigint[],\n $8::boolean[],\n $9::timestamptz[]\n ) AS t(\n torrent_id,\n user_id,\n completed_at,\n uploaded_delta,\n downloaded_delta,\n real_uploaded_delta,\n real_downloaded_delta,\n seeder,\n updated_at\n )\n GROUP BY t.torrent_id, t.user_id\n ) AS agg\n ON CONFLICT (torrent_id, user_id) DO UPDATE SET\n completed_at = COALESCE(torrent_activities.completed_at, EXCLUDED.completed_at),\n first_seen_seeding_at = COALESCE(torrent_activities.first_seen_seeding_at, EXCLUDED.first_seen_seeding_at),\n last_seen_seeding_at = GREATEST(torrent_activities.last_seen_seeding_at, EXCLUDED.last_seen_seeding_at),\n total_seed_time = torrent_activities.total_seed_time,\n uploaded = torrent_activities.uploaded + COALESCE(EXCLUDED.uploaded, 0),\n real_uploaded = torrent_activities.real_uploaded + COALESCE(EXCLUDED.real_uploaded, 0),\n downloaded = torrent_activities.downloaded + COALESCE(EXCLUDED.downloaded, 0),\n real_downloaded = torrent_activities.real_downloaded + COALESCE(EXCLUDED.real_downloaded, 0)\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int4Array", - "Int4Array", - "TimestamptzArray", - "Int8Array", - "Int8Array", - "Int8Array", - "Int8Array", - "BoolArray", - "TimestamptzArray" - ] - }, - "nullable": [] - }, - "hash": "43ca35fea526b25dffb69e86700c0d3a1bdebdb973df5059ff7eb3e9a933eb33" -} diff --git a/backend/storage/.sqlx/query-46d7eee133e0653d9f11ab67f5d6faec7050c9b4c6a8c78e2097015d3e0fb7fb.json b/backend/storage/.sqlx/query-46d7eee133e0653d9f11ab67f5d6faec7050c9b4c6a8c78e2097015d3e0fb7fb.json deleted file mode 100644 index a66010e1a..000000000 --- a/backend/storage/.sqlx/query-46d7eee133e0653d9f11ab67f5d6faec7050c9b4c6a8c78e2097015d3e0fb7fb.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n id,\n upload_factor,\n download_factor,\n seeders,\n leechers,\n times_completed,\n CASE\n WHEN deleted_at IS NOT NULL THEN TRUE\n ELSE FALSE\n END AS \"is_deleted!\"\n FROM torrents\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "upload_factor", - "type_info": "Int2" - }, - { - "ordinal": 2, - "name": "download_factor", - "type_info": "Int2" - }, - { - "ordinal": 3, - "name": "seeders", - "type_info": "Int8" - }, - { - "ordinal": 4, - "name": "leechers", - "type_info": "Int8" - }, - { - "ordinal": 5, - "name": "times_completed", - "type_info": "Int4" - }, - { - "ordinal": 6, - "name": "is_deleted!", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - null - ] - }, - "hash": "46d7eee133e0653d9f11ab67f5d6faec7050c9b4c6a8c78e2097015d3e0fb7fb" -} diff --git a/backend/storage/.sqlx/query-4edda78ffd766d9ec15eb015fe5b985755924b0f0b44d5cf9411059cfbc5c757.json b/backend/storage/.sqlx/query-4edda78ffd766d9ec15eb015fe5b985755924b0f0b44d5cf9411059cfbc5c757.json deleted file mode 100644 index e68111c07..000000000 --- a/backend/storage/.sqlx/query-4edda78ffd766d9ec15eb015fe5b985755924b0f0b44d5cf9411059cfbc5c757.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n id,\n passkey as \"passkey: Passkey\"\n FROM users\n WHERE banned = FALSE\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "passkey: Passkey", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false - ] - }, - "hash": "4edda78ffd766d9ec15eb015fe5b985755924b0f0b44d5cf9411059cfbc5c757" -} diff --git a/backend/storage/.sqlx/query-599587c7ce69b090843274603171c411af859ae256fc01eaf66af2aa2a922900.json b/backend/storage/.sqlx/query-599587c7ce69b090843274603171c411af859ae256fc01eaf66af2aa2a922900.json deleted file mode 100644 index 81bcb3eb9..000000000 --- a/backend/storage/.sqlx/query-599587c7ce69b090843274603171c411af859ae256fc01eaf66af2aa2a922900.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO peers (\n peer_id,\n ip,\n port,\n agent,\n uploaded,\n downloaded,\n \"left\",\n active,\n seeder,\n created_at,\n updated_at,\n torrent_id,\n user_id\n )\n SELECT\n t.peer_id,\n t.ip,\n t.port,\n t.agent,\n t.uploaded,\n t.downloaded,\n t.\"left\",\n t.active,\n t.seeder,\n -- stored as timestamp without time zone in DB\n (t.created_at AT TIME ZONE 'UTC')::timestamp,\n (t.updated_at AT TIME ZONE 'UTC')::timestamp,\n t.torrent_id,\n t.user_id\n FROM (\n SELECT * FROM unnest(\n $1::bytea[],\n $2::inet[],\n $3::int[],\n $4::varchar[],\n $5::bigint[],\n $6::bigint[],\n $7::bigint[],\n $8::boolean[],\n $9::boolean[],\n $10::timestamptz[],\n $11::timestamptz[],\n $12::int[],\n $13::int[]\n ) AS t(\n peer_id,\n ip,\n port,\n agent,\n uploaded,\n downloaded,\n \"left\",\n active,\n seeder,\n created_at,\n updated_at,\n torrent_id,\n user_id\n )\n ) AS t\n ON CONFLICT (user_id, torrent_id, peer_id) DO UPDATE SET\n ip = EXCLUDED.ip,\n port = EXCLUDED.port,\n agent = EXCLUDED.agent,\n uploaded = EXCLUDED.uploaded,\n downloaded = EXCLUDED.downloaded,\n \"left\" = EXCLUDED.\"left\",\n active = EXCLUDED.active,\n seeder = EXCLUDED.seeder,\n updated_at = EXCLUDED.updated_at\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "ByteaArray", - "InetArray", - "Int4Array", - "VarcharArray", - "Int8Array", - "Int8Array", - "Int8Array", - "BoolArray", - "BoolArray", - "TimestamptzArray", - "TimestamptzArray", - "Int4Array", - "Int4Array" - ] - }, - "nullable": [] - }, - "hash": "599587c7ce69b090843274603171c411af859ae256fc01eaf66af2aa2a922900" -} diff --git a/backend/storage/.sqlx/query-6c4a5f875d86d837493c8bf2a8e4f6fb93f4e50dce73ca6d07d42df32a80308a.json b/backend/storage/.sqlx/query-6c4a5f875d86d837493c8bf2a8e4f6fb93f4e50dce73ca6d07d42df32a80308a.json deleted file mode 100644 index 1f6ed8285..000000000 --- a/backend/storage/.sqlx/query-6c4a5f875d86d837493c8bf2a8e4f6fb93f4e50dce73ca6d07d42df32a80308a.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT\n global_upload_factor,\n global_download_factor,\n snatched_torrent_bonus_points_transferred_to as \"snatched_torrent_bonus_points_transferred_to: _\"\n FROM arcadia_settings LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "global_upload_factor", - "type_info": "Int2" - }, - { - "ordinal": 1, - "name": "global_download_factor", - "type_info": "Int2" - }, - { - "ordinal": 2, - "name": "snatched_torrent_bonus_points_transferred_to: _", - "type_info": { - "Custom": { - "name": "snatched_torrent_bonus_points_transferred_to_enum", - "kind": { - "Enum": [ - "uploader", - "current_seeders" - ] - } - } - } - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - true - ] - }, - "hash": "6c4a5f875d86d837493c8bf2a8e4f6fb93f4e50dce73ca6d07d42df32a80308a" -} diff --git a/backend/storage/.sqlx/query-7da73662a96a68e239d011598ace3bc5b287a82c5b0c34ce9543842a1bed0ea4.json b/backend/storage/.sqlx/query-7da73662a96a68e239d011598ace3bc5b287a82c5b0c34ce9543842a1bed0ea4.json deleted file mode 100644 index e51435ea1..000000000 --- a/backend/storage/.sqlx/query-7da73662a96a68e239d011598ace3bc5b287a82c5b0c34ce9543842a1bed0ea4.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM peers\n WHERE (user_id, torrent_id, peer_id) IN (\n SELECT t.user_id, t.torrent_id, t.peer_id\n FROM (\n SELECT * FROM unnest(\n $1::int[],\n $2::int[],\n $3::bytea[]\n ) AS t(user_id, torrent_id, peer_id)\n ) AS t\n )\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int4Array", - "Int4Array", - "ByteaArray" - ] - }, - "nullable": [] - }, - "hash": "7da73662a96a68e239d011598ace3bc5b287a82c5b0c34ce9543842a1bed0ea4" -} diff --git a/backend/storage/.sqlx/query-9b42210d7c01c72c57238e1b0984cbfee280f3d5dcc6592a58eec33562760504.json b/backend/storage/.sqlx/query-9b42210d7c01c72c57238e1b0984cbfee280f3d5dcc6592a58eec33562760504.json new file mode 100644 index 000000000..e9b554025 --- /dev/null +++ b/backend/storage/.sqlx/query-9b42210d7c01c72c57238e1b0984cbfee280f3d5dcc6592a58eec33562760504.json @@ -0,0 +1,127 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n fp.id,\n fp.content,\n fp.created_at,\n fp.updated_at,\n fp.sticky,\n fp.locked,\n fp.forum_thread_id,\n u.id AS created_by_user_id,\n u.username AS created_by_user_username,\n u.class_name AS created_by_user_class_name,\n u.avatar AS created_by_user_avatar,\n u.banned AS created_by_user_banned,\n u.warned AS created_by_user_warned,\n u.custom_title AS created_by_user_custom_title,\n r.id AS \"reaction_id?\",\n r.forum_post_id AS \"reaction_forum_post_id?\",\n r.user_id AS \"reaction_user_id?\",\n r.emoji AS \"reaction_emoji?\"\n FROM forum_posts fp\n JOIN users u ON fp.created_by_id = u.id\n LEFT JOIN forum_post_reactions r on fp.id = r.forum_post_id AND r.user_id = $4\n WHERE fp.forum_thread_id = $1\n ORDER BY fp.created_at ASC\n OFFSET $2\n LIMIT $3\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "content", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "sticky", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "locked", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "forum_thread_id", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "created_by_user_id", + "type_info": "Int4" + }, + { + "ordinal": 8, + "name": "created_by_user_username", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "created_by_user_class_name", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "created_by_user_avatar", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "created_by_user_banned", + "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "created_by_user_warned", + "type_info": "Bool" + }, + { + "ordinal": 13, + "name": "created_by_user_custom_title", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "reaction_id?", + "type_info": "Int8" + }, + { + "ordinal": 15, + "name": "reaction_forum_post_id?", + "type_info": "Int8" + }, + { + "ordinal": 16, + "name": "reaction_user_id?", + "type_info": "Int4" + }, + { + "ordinal": 17, + "name": "reaction_emoji?", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false, + true, + false, + false, + false, + false + ] + }, + "hash": "9b42210d7c01c72c57238e1b0984cbfee280f3d5dcc6592a58eec33562760504" +} diff --git a/backend/storage/.sqlx/query-5ea8bf3f0ae84dc5ba49023d19c20264ba329e1f0978e5116be82aa9ec30cc54.json b/backend/storage/.sqlx/query-af3da3d706d7dfb3a66c7bb2a3f9747f2717ea16a0a01de22630c87f42bec1b6.json similarity index 68% rename from backend/storage/.sqlx/query-5ea8bf3f0ae84dc5ba49023d19c20264ba329e1f0978e5116be82aa9ec30cc54.json rename to backend/storage/.sqlx/query-af3da3d706d7dfb3a66c7bb2a3f9747f2717ea16a0a01de22630c87f42bec1b6.json index 5028334fa..11f29a74d 100644 --- a/backend/storage/.sqlx/query-5ea8bf3f0ae84dc5ba49023d19c20264ba329e1f0978e5116be82aa9ec30cc54.json +++ b/backend/storage/.sqlx/query-af3da3d706d7dfb3a66c7bb2a3f9747f2717ea16a0a01de22630c87f42bec1b6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n fp.id,\n fp.content,\n fp.created_at,\n fp.updated_at,\n fp.sticky,\n fp.locked,\n fp.forum_thread_id,\n u.id AS created_by_user_id,\n u.username AS created_by_user_username,\n u.class_name AS created_by_user_class_name,\n u.avatar AS created_by_user_avatar,\n u.banned AS created_by_user_banned,\n u.warned AS created_by_user_warned,\n u.custom_title AS created_by_user_custom_title\n FROM forum_posts fp\n JOIN users u ON fp.created_by_id = u.id\n WHERE fp.forum_thread_id = $1\n ORDER BY fp.created_at ASC\n OFFSET $2\n LIMIT $3\n ", + "query": "\n SELECT\n fp.id,\n fp.content,\n fp.created_at,\n fp.updated_at,\n fp.sticky,\n fp.locked,\n fp.forum_thread_id,\n u.id AS created_by_user_id,\n u.username AS created_by_user_username,\n u.class_name AS created_by_user_class_name,\n u.avatar AS created_by_user_avatar,\n u.banned AS created_by_user_banned,\n u.warned AS created_by_user_warned,\n u.custom_title AS created_by_user_custom_title,\n r.id AS \"reaction_id?\",\n r.forum_post_id AS \"reaction_forum_post_id?\",\n r.user_id AS \"reaction_user_id?\",\n r.emoji AS \"reaction_emoji?\"\n FROM forum_posts fp\n JOIN users u ON fp.created_by_id = u.id\n LEFT JOIN forum_post_reactions r on fp.id = r.forum_post_id AND r.user_id = $1\n WHERE fp.id = $2\n ", "describe": { "columns": [ { @@ -72,12 +72,31 @@ "ordinal": 13, "name": "created_by_user_custom_title", "type_info": "Text" + }, + { + "ordinal": 14, + "name": "reaction_id?", + "type_info": "Int8" + }, + { + "ordinal": 15, + "name": "reaction_forum_post_id?", + "type_info": "Int8" + }, + { + "ordinal": 16, + "name": "reaction_user_id?", + "type_info": "Int4" + }, + { + "ordinal": 17, + "name": "reaction_emoji?", + "type_info": "Varchar" } ], "parameters": { "Left": [ - "Int8", - "Int8", + "Int4", "Int8" ] }, @@ -95,8 +114,12 @@ true, false, false, - true + true, + false, + false, + false, + false ] }, - "hash": "5ea8bf3f0ae84dc5ba49023d19c20264ba329e1f0978e5116be82aa9ec30cc54" + "hash": "af3da3d706d7dfb3a66c7bb2a3f9747f2717ea16a0a01de22630c87f42bec1b6" } diff --git a/backend/storage/.sqlx/query-c45f235654a1b2aa8c849c5644443fe34ea7a4dd976fe6b4405e7b4a585a1325.json b/backend/storage/.sqlx/query-c45f235654a1b2aa8c849c5644443fe34ea7a4dd976fe6b4405e7b4a585a1325.json deleted file mode 100644 index 6ae0326e0..000000000 --- a/backend/storage/.sqlx/query-c45f235654a1b2aa8c849c5644443fe34ea7a4dd976fe6b4405e7b4a585a1325.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE torrents\n SET\n seeders = seeders + updates.seeder_delta,\n leechers = leechers + updates.leecher_delta,\n times_completed = times_completed + updates.times_completed_delta\n FROM (\n SELECT * FROM unnest($1::int[], $2::bigint[], $3::bigint[], $4::bigint[]) AS\n t(torrent_id, seeder_delta, leecher_delta, times_completed_delta)\n ) AS updates\n WHERE torrents.id = updates.torrent_id\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int4Array", - "Int8Array", - "Int8Array", - "Int8Array" - ] - }, - "nullable": [] - }, - "hash": "c45f235654a1b2aa8c849c5644443fe34ea7a4dd976fe6b4405e7b4a585a1325" -} diff --git a/backend/storage/.sqlx/query-c4e55538610671cfcf982e49c58425223e356c29912d19d4e62cf7b576637c0a.json b/backend/storage/.sqlx/query-c4e55538610671cfcf982e49c58425223e356c29912d19d4e62cf7b576637c0a.json deleted file mode 100644 index 7c2cf9c52..000000000 --- a/backend/storage/.sqlx/query-c4e55538610671cfcf982e49c58425223e356c29912d19d4e62cf7b576637c0a.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE users\n SET\n uploaded = uploaded + updates.uploaded_delta,\n downloaded = downloaded + updates.downloaded_delta,\n real_uploaded = real_uploaded + updates.real_uploaded_delta,\n real_downloaded = real_downloaded + updates.real_downloaded_delta\n FROM (\n SELECT * FROM unnest($1::int[], $2::bigint[], $3::bigint[], $4::bigint[], $5::bigint[]) AS\n t(user_id, uploaded_delta, downloaded_delta, real_uploaded_delta, real_downloaded_delta)\n ) AS updates\n WHERE users.id = updates.user_id\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int4Array", - "Int8Array", - "Int8Array", - "Int8Array", - "Int8Array" - ] - }, - "nullable": [] - }, - "hash": "c4e55538610671cfcf982e49c58425223e356c29912d19d4e62cf7b576637c0a" -} diff --git a/backend/storage/.sqlx/query-d94c7cf9c02a4f060345d02ac4bd2434069fc46d43e6f3e7e3618737c2dcd547.json b/backend/storage/.sqlx/query-d94c7cf9c02a4f060345d02ac4bd2434069fc46d43e6f3e7e3618737c2dcd547.json deleted file mode 100644 index 62b6ebc97..000000000 --- a/backend/storage/.sqlx/query-d94c7cf9c02a4f060345d02ac4bd2434069fc46d43e6f3e7e3618737c2dcd547.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n id,\n info_hash as \"info_hash: InfoHash\"\n FROM torrents\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "info_hash: InfoHash", - "type_info": "Bytea" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false - ] - }, - "hash": "d94c7cf9c02a4f060345d02ac4bd2434069fc46d43e6f3e7e3618737c2dcd547" -} diff --git a/backend/storage/.sqlx/query-f4de9d3dad0a4229f75304798debf8ab4a602081f6e429658262bc9e58f7320b.json b/backend/storage/.sqlx/query-f4de9d3dad0a4229f75304798debf8ab4a602081f6e429658262bc9e58f7320b.json deleted file mode 100644 index a88a69be6..000000000 --- a/backend/storage/.sqlx/query-f4de9d3dad0a4229f75304798debf8ab4a602081f6e429658262bc9e58f7320b.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n id,\n passkey as \"passkey: Passkey\",\n max_snatches_per_day,\n 0::INT AS \"num_seeding!\",\n 0::INT AS \"num_leeching!\"\n FROM users\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "passkey: Passkey", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "max_snatches_per_day", - "type_info": "Int4" - }, - { - "ordinal": 3, - "name": "num_seeding!", - "type_info": "Int4" - }, - { - "ordinal": 4, - "name": "num_leeching!", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - true, - null, - null - ] - }, - "hash": "f4de9d3dad0a4229f75304798debf8ab4a602081f6e429658262bc9e58f7320b" -} diff --git a/backend/storage/.sqlx/query-f6d849721ff84614c129c14455d9a6adbe0ad29b7876963d5bd9015c0f73ba9d.json b/backend/storage/.sqlx/query-f6d849721ff84614c129c14455d9a6adbe0ad29b7876963d5bd9015c0f73ba9d.json deleted file mode 100644 index 6baf779d9..000000000 --- a/backend/storage/.sqlx/query-f6d849721ff84614c129c14455d9a6adbe0ad29b7876963d5bd9015c0f73ba9d.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n peers.ip AS \"ip_address: IpAddr\",\n peers.user_id AS \"user_id\",\n peers.torrent_id AS \"torrent_id\",\n peers.port AS \"port\",\n peers.seeder AS \"is_seeder: bool\",\n peers.active AS \"is_active: bool\",\n peers.updated_at AS \"updated_at: DateTime\",\n peers.uploaded AS \"uploaded\",\n peers.downloaded AS \"downloaded\",\n peers.peer_id AS \"peer_id: PeerId\"\n FROM peers\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "ip_address: IpAddr", - "type_info": "Inet" - }, - { - "ordinal": 1, - "name": "user_id", - "type_info": "Int4" - }, - { - "ordinal": 2, - "name": "torrent_id", - "type_info": "Int4" - }, - { - "ordinal": 3, - "name": "port", - "type_info": "Int4" - }, - { - "ordinal": 4, - "name": "is_seeder: bool", - "type_info": "Bool" - }, - { - "ordinal": 5, - "name": "is_active: bool", - "type_info": "Bool" - }, - { - "ordinal": 6, - "name": "updated_at: DateTime", - "type_info": "Timestamp" - }, - { - "ordinal": 7, - "name": "uploaded", - "type_info": "Int8" - }, - { - "ordinal": 8, - "name": "downloaded", - "type_info": "Int8" - }, - { - "ordinal": 9, - "name": "peer_id: PeerId", - "type_info": "Bytea" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - false, - false, - false, - false, - true, - false, - false, - false - ] - }, - "hash": "f6d849721ff84614c129c14455d9a6adbe0ad29b7876963d5bd9015c0f73ba9d" -} diff --git a/backend/storage/migrations/20250312215600_initdb.sql b/backend/storage/migrations/20250312215600_initdb.sql index f7fd8d98e..820889160 100644 --- a/backend/storage/migrations/20250312215600_initdb.sql +++ b/backend/storage/migrations/20250312215600_initdb.sql @@ -1,6 +1,7 @@ CREATE EXTENSION IF NOT EXISTS unaccent; CREATE TYPE user_permissions_enum AS ENUM ( + 'create_user_class', 'edit_user_class', 'delete_user_class', @@ -34,6 +35,7 @@ CREATE TYPE user_permissions_enum AS ENUM ( 'create_forum_sub_category', 'create_forum_thread', 'create_forum_post', + 'set_forum_post_reaction', 'send_pm', 'create_css_sheet', 'edit_css_sheet', @@ -1050,6 +1052,14 @@ CREATE TABLE forum_thread_reads ( FOREIGN KEY (forum_thread_id) REFERENCES forum_threads(id) ON DELETE CASCADE, FOREIGN KEY (last_read_post_id) REFERENCES forum_posts(id) ON DELETE CASCADE ); +CREATE TABLE forum_post_reactions ( + id BIGSERIAL PRIMARY KEY, + forum_post_id BIGINT NOT NULL REFERENCES forum_posts(id) ON DELETE CASCADE, + user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji VARCHAR(1) NOT NULL, + CONSTRAINT forum_post_reactions_unique_user_post + UNIQUE (forum_post_id, user_id) +); CREATE TABLE wiki_articles ( id BIGSERIAL PRIMARY KEY, title TEXT NOT NULL, diff --git a/backend/storage/migrations/fixtures/fixtures.sql b/backend/storage/migrations/fixtures/fixtures.sql index 94d86aa09..0f2ab33e4 100644 --- a/backend/storage/migrations/fixtures/fixtures.sql +++ b/backend/storage/migrations/fixtures/fixtures.sql @@ -229,6 +229,15 @@ INSERT INTO public.forum_threads VALUES (5, 7, 'Favorite OS for daily driving', INSERT INTO public.forum_posts VALUES (1, 1, '2025-09-17 12:42:13.702455+00', '2025-09-17 12:42:13.702455+00', 1, 'Welcome!', false); INSERT INTO public.forum_posts VALUES (3, 3, '2025-05-22 08:03:48.66391+00', '2025-05-22 08:03:48.66391+00', 1, 'Hello there, I just joined!', false); INSERT INTO public.forum_posts VALUES (4, 5, '2025-05-22 08:06:36.225458+00', '2025-05-22 08:06:36.225458+00', 2, 'I use arch btw :)', false); +INSERT INTO public.forum_posts VALUES (5, 1, '2025-09-17 12:42:13.702455+00', '2025-09-17 12:42:13.702455+00', 1, 'Welcome back!', false); +INSERT INTO public.forum_posts VALUES (6, 1, '2025-09-17 12:42:13.702455+00', '2025-09-17 12:42:13.702455+00', 1, 'Beautiful movie, I recommend it!', false); + + +-- +-- Data for Name: forum_post_reactions; Type: TABLE DATA; Schema: public; Owner: arcadia +-- +INSERT INTO forum_post_reactions values(1, 1, 1, 'πŸ‘Ή'); +INSERT INTO forum_post_reactions values(2, 1, 2, 'πŸ™‚β€β†•οΈ'); -- @@ -1591,7 +1600,14 @@ SELECT pg_catalog.setval('public.forum_categories_id_seq', 3, true); -- Name: forum_posts_id_seq; Type: SEQUENCE SET; Schema: public; Owner: arcadia -- -SELECT pg_catalog.setval('public.forum_posts_id_seq', 4, true); +SELECT pg_catalog.setval('public.forum_posts_id_seq', 7, true); + + +-- +-- Name: forum_post_reactions_id_seq; Type: SEQUENCE SET; Schema: public; Owner: arcadia +-- + +SELECT pg_catalog.setval('public.forum_post_reactions_id_seq', 3, true); -- diff --git a/backend/storage/src/models/forum.rs b/backend/storage/src/models/forum.rs index c24f3eaa8..9a30b600e 100644 --- a/backend/storage/src/models/forum.rs +++ b/backend/storage/src/models/forum.rs @@ -236,6 +236,7 @@ pub struct ForumPostHierarchy { pub content: String, pub sticky: bool, pub locked: bool, + pub reaction: Option, } #[derive(Debug, Deserialize, Serialize, FromRow, ToSchema)] @@ -323,6 +324,19 @@ pub struct ReorderForumSubCategories { pub sub_categories: Vec, } +#[derive(Debug, Serialize, Deserialize, FromRow, ToSchema)] +pub struct ForumPostReaction { + pub id: i64, + pub forum_post_id: i64, + pub user_id: i32, + pub emoji: String, +} + +#[derive(Debug, Serialize, Deserialize, FromRow, ToSchema)] +pub struct UserCreatedForumPostReaction { + pub emoji: String, +} + impl ForumCategory { pub fn diff(&self, edited: &EditedForumCategory) -> Option { compute_diff(self, edited, &["id"]) diff --git a/backend/storage/src/repositories/forum_repository.rs b/backend/storage/src/repositories/forum_repository.rs index 09c295acc..188f67117 100644 --- a/backend/storage/src/repositories/forum_repository.rs +++ b/backend/storage/src/repositories/forum_repository.rs @@ -5,11 +5,12 @@ use crate::{ forum::{ EditedForumCategory, EditedForumPost, EditedForumSubCategory, EditedForumThread, ForumCategory, ForumCategoryHierarchy, ForumCategoryLite, ForumPost, - ForumPostAndThreadName, ForumPostHierarchy, ForumSearchQuery, ForumSearchResult, - ForumSubCategory, ForumSubCategoryHierarchy, ForumThread, ForumThreadEnriched, - ForumThreadPostLite, GetForumThreadPostsQuery, PinForumThread, ReorderForumCategories, - ReorderForumSubCategories, UserCreatedForumCategory, UserCreatedForumPost, - UserCreatedForumSubCategory, UserCreatedForumThread, + ForumPostAndThreadName, ForumPostHierarchy, ForumPostReaction, ForumSearchQuery, + ForumSearchResult, ForumSubCategory, ForumSubCategoryHierarchy, ForumThread, + ForumThreadEnriched, ForumThreadPostLite, GetForumThreadPostsQuery, PinForumThread, + ReorderForumCategories, ReorderForumSubCategories, UserCreatedForumCategory, + UserCreatedForumPost, UserCreatedForumPostReaction, UserCreatedForumSubCategory, + UserCreatedForumThread, }, notification::NotificationEvent, user::{UserLite, UserLiteAvatar}, @@ -60,6 +61,10 @@ struct DBImportForumPost { created_by_user_banned: bool, created_by_user_warned: bool, created_by_user_custom_title: Option, + reaction_id: Option, + reaction_forum_post_id: Option, + reaction_user_id: Option, + reaction_emoji: Option, } impl ConnectionPool { @@ -810,9 +815,14 @@ impl ConnectionPool { u.avatar AS created_by_user_avatar, u.banned AS created_by_user_banned, u.warned AS created_by_user_warned, - u.custom_title AS created_by_user_custom_title - FROM forum_posts fp + u.custom_title AS created_by_user_custom_title, + r.id AS "reaction_id?", + r.forum_post_id AS "reaction_forum_post_id?", + r.user_id AS "reaction_user_id?", + r.emoji AS "reaction_emoji?" + FROM forum_posts fp JOIN users u ON fp.created_by_id = u.id + LEFT JOIN forum_post_reactions r on fp.id = r.forum_post_id AND r.user_id = $4 WHERE fp.forum_thread_id = $1 ORDER BY fp.created_at ASC OFFSET $2 @@ -820,7 +830,8 @@ impl ConnectionPool { "#, form.thread_id, offset, - page_size + page_size, + user_id ) .fetch_all(self.borrow()) .await @@ -863,6 +874,12 @@ impl ConnectionPool { warned: r.created_by_user_warned, custom_title: r.created_by_user_custom_title, }, + reaction: r.reaction_id.map(|reaction_id| ForumPostReaction { + id: reaction_id, + forum_post_id: r.reaction_forum_post_id.unwrap(), + user_id: r.reaction_user_id.unwrap(), + emoji: r.reaction_emoji.clone().unwrap(), + }), }) .collect(); @@ -1498,4 +1515,171 @@ impl ConnectionPool { Ok(()) } + + pub async fn set_forum_post_reaction_and_get_post( + &self, + forum_post_id: i64, + reaction: UserCreatedForumPostReaction, + current_user_id: i32, + ) -> Result { + let row = sqlx::query_as!( + DBImportForumPost, + r#" + WITH upserted AS ( + INSERT INTO forum_post_reactions (forum_post_id, user_id, emoji) + VALUES ($1, $2, $3) + ON CONFLICT (forum_post_id, user_id) + DO UPDATE SET + emoji = EXCLUDED.emoji + WHERE forum_post_reactions.emoji IS DISTINCT FROM EXCLUDED.emoji + RETURNING + id AS reaction_id, + forum_post_id AS reaction_forum_post_id, + user_id AS reaction_user_id, + emoji AS reaction_emoji + ) + SELECT + fp.id, + fp.content, + fp.created_at, + fp.updated_at, + fp.sticky, + fp.locked, + fp.forum_thread_id, + u.id AS created_by_user_id, + u.username AS created_by_user_username, + u.class_name AS created_by_user_class_name, + u.avatar AS created_by_user_avatar, + u.banned AS created_by_user_banned, + u.warned AS created_by_user_warned, + u.custom_title AS created_by_user_custom_title, + up.reaction_id AS "reaction_id?", + up.reaction_forum_post_id AS "reaction_forum_post_id?", + up.reaction_user_id AS "reaction_user_id?", + up.reaction_emoji AS "reaction_emoji?" + FROM forum_posts fp + JOIN users u ON fp.created_by_id = u.id + LEFT JOIN upserted up + ON up.reaction_forum_post_id = fp.id + WHERE fp.id = $1 + "#, + forum_post_id, + current_user_id, + reaction.emoji + ) + .fetch_one(self.borrow()) + .await + .map_err(Error::CouldNotUpdateForumPostReaction)?; + + Ok(ForumPostHierarchy { + id: row.id, + content: row.content, + created_at: row.created_at, + updated_at: row.updated_at, + sticky: row.sticky, + locked: row.locked, + forum_thread_id: row.forum_thread_id, + created_by: UserLiteAvatar { + id: row.created_by_user_id, + username: row.created_by_user_username, + class_name: row.created_by_user_class_name, + avatar: row.created_by_user_avatar, + banned: row.created_by_user_banned, + warned: row.created_by_user_warned, + custom_title: row.created_by_user_custom_title, + }, + reaction: row.reaction_id.map(|reaction_id| ForumPostReaction { + id: reaction_id, + forum_post_id: row.reaction_forum_post_id.unwrap(), + user_id: row.reaction_user_id.unwrap(), + emoji: row.reaction_emoji.unwrap(), + }), + }) + } + + pub async fn delete_forum_post_reaction_and_get_post( + &self, + post_id: i64, + current_user_id: i32, + ) -> Result { + log::debug!( + "Deleting reaction forum_post_id={}, user_id={}", + post_id, + current_user_id + ); + + let deleted = sqlx::query!( + r#"DELETE FROM forum_post_reactions WHERE forum_post_id = $1 AND user_id = $2"#, + post_id, + current_user_id + ) + .execute(self.borrow()) + .await + .map_err(Error::CouldNotDeleteForumPostReaction)?; + + if deleted.rows_affected() == 0 { + return Err(Error::CouldNotFindForumPostReaction( + sqlx::Error::RowNotFound, + )); + } + + let row = sqlx::query_as!( + DBImportForumPost, + r#" + SELECT + fp.id, + fp.content, + fp.created_at, + fp.updated_at, + fp.sticky, + fp.locked, + fp.forum_thread_id, + u.id AS created_by_user_id, + u.username AS created_by_user_username, + u.class_name AS created_by_user_class_name, + u.avatar AS created_by_user_avatar, + u.banned AS created_by_user_banned, + u.warned AS created_by_user_warned, + u.custom_title AS created_by_user_custom_title, + r.id AS "reaction_id?", + r.forum_post_id AS "reaction_forum_post_id?", + r.user_id AS "reaction_user_id?", + r.emoji AS "reaction_emoji?" + FROM forum_posts fp + JOIN users u ON fp.created_by_id = u.id + LEFT JOIN forum_post_reactions r on fp.id = r.forum_post_id AND r.user_id = $1 + WHERE fp.id = $2 + "#, + current_user_id, + post_id + ) + .fetch_one(self.borrow()) + .await + .map_err(Error::CouldNotFindForumPostReaction)?; + + Ok(ForumPostHierarchy { + id: row.id, + content: row.content, + created_at: row.created_at, + updated_at: row.updated_at, + sticky: row.sticky, + locked: row.locked, + forum_thread_id: row.forum_thread_id, + created_by: UserLiteAvatar { + id: row.created_by_user_id, + username: row.created_by_user_username, + class_name: row.created_by_user_class_name, + avatar: row.created_by_user_avatar, + banned: row.created_by_user_banned, + warned: row.created_by_user_warned, + custom_title: row.created_by_user_custom_title, + }, + reaction: row.reaction_id.map(|reaction_id| ForumPostReaction { + id: reaction_id, + forum_post_id: row.reaction_forum_post_id.unwrap(), + user_id: row.reaction_user_id.unwrap(), + emoji: row.reaction_emoji.unwrap(), + }), + }) + } }