From ef029c59b3db5624e6ebeca591daaced3bb42e81 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 3 Aug 2025 11:45:57 -0400 Subject: [PATCH] add: community topic endpoints --- .../routes/api/v1/communities/communities.rs | 124 +++++++++++++++++- crates/app/src/routes/api/v1/mod.rs | 19 +++ crates/core/src/database/communities.rs | 35 ++--- .../drivers/sql/create_communities.sql | 8 +- .../drivers/sql/version_migrations.sql | 4 + crates/core/src/model/communities.rs | 37 +++++- crates/core/src/model/mod.rs | 2 + 7 files changed, 201 insertions(+), 28 deletions(-) diff --git a/crates/app/src/routes/api/v1/communities/communities.rs b/crates/app/src/routes/api/v1/communities/communities.rs index 81266ec..e749b90 100644 --- a/crates/app/src/routes/api/v1/communities/communities.rs +++ b/crates/app/src/routes/api/v1/communities/communities.rs @@ -3,12 +3,14 @@ use axum::{ extract::Path, response::{IntoResponse, Redirect}, }; -use crate::cookie::CookieJar; +use crate::{cookie::CookieJar, routes::api::v1::AddTopic}; use tetratto_core::model::{ auth::Notification, - communities::{Community, CommunityMembership}, + communities::{Community, CommunityMembership, ForumTopic}, communities_permissions::CommunityPermission, - oauth, ApiReturn, Error, + oauth, + permissions::FinePermission, + ApiReturn, Error, }; use crate::{ @@ -269,7 +271,7 @@ pub async fn get_membership( Err(e) => return Json(e.into()), }; - if user.id != community.owner { + if user.id != community.owner && !user.permissions.check(FinePermission::MANAGE_MEMBERSHIPS) { // only the owner can select community memberships return Json(Error::NotAllowed.into()); } @@ -523,3 +525,117 @@ pub async fn get_communities_request( payload: Some(communities), }) } + +pub async fn add_topic_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::CommunityManage) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let mut community = match data.get_community_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if !community.is_forum { + return Json(Error::DoesNotSupportField("community".to_string()).into()); + } + + let (id, topic) = ForumTopic::new(req.title, req.description, req.color); + community.topics.insert(id, topic); + + match data + .update_community_topics(id, &user, community.topics) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Community updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_topic_request( + jar: CookieJar, + Extension(data): Extension, + Path((id, topic_id)): Path<(usize, usize)>, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::CommunityManage) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let mut community = match data.get_community_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if !community.is_forum { + return Json(Error::DoesNotSupportField("community".to_string()).into()); + } + + let topic = ForumTopic { + title: req.title, + description: req.description, + color: req.color, + }; + + community.topics.insert(topic_id, topic); + + match data + .update_community_topics(id, &user, community.topics) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Community updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_topic_request( + jar: CookieJar, + Extension(data): Extension, + Path((id, topic_id)): Path<(usize, usize)>, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::CommunityManage) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let mut community = match data.get_community_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if !community.is_forum { + return Json(Error::DoesNotSupportField("community".to_string()).into()); + } + + community.topics.remove(&topic_id); + + match data + .update_community_topics(id, &user, community.topics) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Community updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 4f883fb..ce36f99 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -131,6 +131,18 @@ pub fn routes() -> Router { "/communities/{id}/supports_titles", get(communities::communities::supports_titles_request), ) + .route( + "/communities/{id}/topics", + post(communities::communities::add_topic_request), + ) + .route( + "/communities/{id}/topics/{id}", + post(communities::communities::update_topic_request), + ) + .route( + "/communities/{id}/topics/{id}", + delete(communities::communities::delete_topic_request), + ) // posts .route("/posts", post(communities::posts::create_request)) .route("/posts/{id}", delete(communities::posts::delete_request)) @@ -778,6 +790,13 @@ pub struct UpdateCommunityJoinAccess { pub access: CommunityJoinAccess, } +#[derive(Deserialize)] +pub struct AddTopic { + pub title: String, + pub description: String, + pub color: String, +} + #[derive(Deserialize)] pub struct CreatePostPoll { pub option_a: String, diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs index 5b6236b..c67f700 100644 --- a/crates/core/src/database/communities.rs +++ b/crates/core/src/database/communities.rs @@ -1,19 +1,23 @@ use super::common::NAME_REGEX; - use oiseau::cache::Cache; -use crate::model::communities::{CommunityContext, CommunityJoinAccess, CommunityMembership}; -use crate::model::communities_permissions::CommunityPermission; -use crate::model::permissions::SecondaryPermission; -use crate::model::{ - Error, Result, - auth::User, - communities::Community, - communities::{CommunityReadAccess, CommunityWriteAccess}, - permissions::FinePermission, +use crate::{ + auto_method, DataManager, + model::{ + Error, Result, + auth::User, + communities::{ + CommunityReadAccess, CommunityWriteAccess, ForumTopic, Community, CommunityContext, + CommunityJoinAccess, CommunityMembership, + }, + permissions::{FinePermission, SecondaryPermission}, + communities_permissions::CommunityPermission, + }, }; use pathbufd::PathBufD; -use std::fs::{exists, remove_file}; -use crate::{auto_method, DataManager}; +use std::{ + fs::{exists, remove_file}, + collections::HashMap, +}; use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; @@ -29,14 +33,13 @@ impl DataManager { read_access: serde_json::from_str(&get!(x->5(String))).unwrap(), write_access: serde_json::from_str(&get!(x->6(String))).unwrap(), join_access: serde_json::from_str(&get!(x->7(String))).unwrap(), - // likes likes: get!(x->8(i32)) as isize, dislikes: get!(x->9(i32)) as isize, - // ... member_count: get!(x->10(i32)) as usize, is_forge: get!(x->11(i32)) as i8 == 1, post_count: get!(x->12(i32)) as usize, is_forum: get!(x->13(i32)) as i8 == 1, + topics: serde_json::from_str(&get!(x->14(String))).unwrap(), } } @@ -282,7 +285,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO communities VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)", + "INSERT INTO communities VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)", params![ &(data.id as i64), &(data.created as i64), @@ -298,6 +301,7 @@ impl DataManager { &{ if data.is_forge { 1 } else { 0 } }, &0_i32, &{ if data.is_forum { 1 } else { 0 } }, + &serde_json::to_string(&data.topics).unwrap().as_str(), ] ); @@ -532,6 +536,7 @@ impl DataManager { auto_method!(update_community_read_access(CommunityReadAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET read_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); auto_method!(update_community_write_access(CommunityWriteAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); auto_method!(update_community_join_access(CommunityJoinAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET join_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_topics(HashMap)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET topics = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); auto_method!(incr_community_likes()@get_community_by_id_no_void -> "UPDATE communities SET likes = likes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr); auto_method!(incr_community_dislikes()@get_community_by_id_no_void -> "UPDATE communities SET dislikes = dislikes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr); diff --git a/crates/core/src/database/drivers/sql/create_communities.sql b/crates/core/src/database/drivers/sql/create_communities.sql index e245eef..e0328f5 100644 --- a/crates/core/src/database/drivers/sql/create_communities.sql +++ b/crates/core/src/database/drivers/sql/create_communities.sql @@ -7,9 +7,11 @@ CREATE TABLE IF NOT EXISTS communities ( read_access TEXT NOT NULL, write_access TEXT NOT NULL, join_access TEXT NOT NULL, - -- likes likes INT NOT NULL, dislikes INT NOT NULL, - -- counts - member_count INT NOT NULL + member_count INT NOT NULL, + is_forge INT NOT NULL, + post_count INT NOT NULL, + is_forum INT NOT NULL, + topics TEXT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/version_migrations.sql b/crates/core/src/database/drivers/sql/version_migrations.sql index 20266fb..46e97a4 100644 --- a/crates/core/src/database/drivers/sql/version_migrations.sql +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -17,3 +17,7 @@ ADD COLUMN IF NOT EXISTS replying_to BIGINT DEFAULT 0; -- communities is_forum ALTER TABLE communities ADD COLUMN IF NOT EXISTS is_forum INT DEFAULT 0; + +-- communities topics +ALTER TABLE communities +ADD COLUMN IF NOT EXISTS topics TEXT DEFAULT '{}'; diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index 4b9213b..8dc9c70 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use serde::{Deserialize, Serialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; use super::communities_permissions::CommunityPermission; @@ -19,14 +21,20 @@ pub struct Community { pub write_access: CommunityWriteAccess, /// Who can join the community. pub join_access: CommunityJoinAccess, - // likes pub likes: isize, pub dislikes: isize, - // ... pub member_count: usize, pub is_forge: bool, pub post_count: usize, pub is_forum: bool, + /// The topics of a community if the community has `is_forum` enabled. + /// + /// Since topics are given a unique ID (the key of the hashmap), a removal of a topic + /// should be done through a specific DELETE endpoint which ALSO deletes all posts + /// within the topic. + /// + /// Communities should be limited to 10 topics per community. + pub topics: HashMap, } impl Community { @@ -50,6 +58,7 @@ impl Community { is_forge: false, post_count: 0, is_forum: false, + topics: HashMap::new(), } } @@ -71,6 +80,7 @@ impl Community { is_forge: false, post_count: 0, is_forum: false, + topics: HashMap::new(), } } } @@ -521,10 +531,25 @@ impl PollVote { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ForumTopic { - pub id: usize, - pub created: usize, - pub owner: usize, - pub community: usize, pub title: String, pub description: String, + pub color: String, +} + +impl ForumTopic { + /// Create a new [`ForumTopic`]. + /// + /// # Returns + /// * ID for [`Community`] hashmap + /// * [`ForumTopic`] + pub fn new(title: String, description: String, color: String) -> (usize, Self) { + ( + Snowflake::new().to_string().parse::().unwrap(), + Self { + title, + description, + color, + }, + ) + } } diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index 06c4149..5bf8c52 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -53,6 +53,7 @@ pub enum Error { RequiresSupporter, DrawingsDisabled, AppHitStorageLimit, + DoesNotSupportField(String), Unknown, } @@ -78,6 +79,7 @@ impl Display for Error { Self::RequiresSupporter => "Only site supporters can do this".to_string(), Self::DrawingsDisabled => "You are not allowed to submit drawings there".to_string(), Self::AppHitStorageLimit => "This app has already hit its storage limit, or will do so if this data is processed.".to_string(), + Self::DoesNotSupportField(name) => format!("{name} does not support this field"), _ => format!("An unknown error as occurred: ({:?})", self), }) }