add: community topic endpoints

This commit is contained in:
trisua 2025-08-03 11:45:57 -04:00
parent 12fa80c7c0
commit ef029c59b3
7 changed files with 201 additions and 28 deletions

View file

@ -3,12 +3,14 @@ use axum::{
extract::Path, extract::Path,
response::{IntoResponse, Redirect}, response::{IntoResponse, Redirect},
}; };
use crate::cookie::CookieJar; use crate::{cookie::CookieJar, routes::api::v1::AddTopic};
use tetratto_core::model::{ use tetratto_core::model::{
auth::Notification, auth::Notification,
communities::{Community, CommunityMembership}, communities::{Community, CommunityMembership, ForumTopic},
communities_permissions::CommunityPermission, communities_permissions::CommunityPermission,
oauth, ApiReturn, Error, oauth,
permissions::FinePermission,
ApiReturn, Error,
}; };
use crate::{ use crate::{
@ -269,7 +271,7 @@ pub async fn get_membership(
Err(e) => return Json(e.into()), 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 // only the owner can select community memberships
return Json(Error::NotAllowed.into()); return Json(Error::NotAllowed.into());
} }
@ -523,3 +525,117 @@ pub async fn get_communities_request(
payload: Some(communities), payload: Some(communities),
}) })
} }
pub async fn add_topic_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<AddTopic>,
) -> 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<State>,
Path((id, topic_id)): Path<(usize, usize)>,
Json(req): Json<AddTopic>,
) -> 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<State>,
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()),
}
}

View file

@ -131,6 +131,18 @@ pub fn routes() -> Router {
"/communities/{id}/supports_titles", "/communities/{id}/supports_titles",
get(communities::communities::supports_titles_request), 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 // posts
.route("/posts", post(communities::posts::create_request)) .route("/posts", post(communities::posts::create_request))
.route("/posts/{id}", delete(communities::posts::delete_request)) .route("/posts/{id}", delete(communities::posts::delete_request))
@ -778,6 +790,13 @@ pub struct UpdateCommunityJoinAccess {
pub access: CommunityJoinAccess, pub access: CommunityJoinAccess,
} }
#[derive(Deserialize)]
pub struct AddTopic {
pub title: String,
pub description: String,
pub color: String,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreatePostPoll { pub struct CreatePostPoll {
pub option_a: String, pub option_a: String,

View file

@ -1,19 +1,23 @@
use super::common::NAME_REGEX; use super::common::NAME_REGEX;
use oiseau::cache::Cache; use oiseau::cache::Cache;
use crate::model::communities::{CommunityContext, CommunityJoinAccess, CommunityMembership}; use crate::{
use crate::model::communities_permissions::CommunityPermission; auto_method, DataManager,
use crate::model::permissions::SecondaryPermission; model::{
use crate::model::{ Error, Result,
Error, Result, auth::User,
auth::User, communities::{
communities::Community, CommunityReadAccess, CommunityWriteAccess, ForumTopic, Community, CommunityContext,
communities::{CommunityReadAccess, CommunityWriteAccess}, CommunityJoinAccess, CommunityMembership,
permissions::FinePermission, },
permissions::{FinePermission, SecondaryPermission},
communities_permissions::CommunityPermission,
},
}; };
use pathbufd::PathBufD; use pathbufd::PathBufD;
use std::fs::{exists, remove_file}; use std::{
use crate::{auto_method, DataManager}; fs::{exists, remove_file},
collections::HashMap,
};
use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; 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(), read_access: serde_json::from_str(&get!(x->5(String))).unwrap(),
write_access: serde_json::from_str(&get!(x->6(String))).unwrap(), write_access: serde_json::from_str(&get!(x->6(String))).unwrap(),
join_access: serde_json::from_str(&get!(x->7(String))).unwrap(), join_access: serde_json::from_str(&get!(x->7(String))).unwrap(),
// likes
likes: get!(x->8(i32)) as isize, likes: get!(x->8(i32)) as isize,
dislikes: get!(x->9(i32)) as isize, dislikes: get!(x->9(i32)) as isize,
// ...
member_count: get!(x->10(i32)) as usize, member_count: get!(x->10(i32)) as usize,
is_forge: get!(x->11(i32)) as i8 == 1, is_forge: get!(x->11(i32)) as i8 == 1,
post_count: get!(x->12(i32)) as usize, post_count: get!(x->12(i32)) as usize,
is_forum: get!(x->13(i32)) as i8 == 1, 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!( let res = execute!(
&conn, &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![ params![
&(data.id as i64), &(data.id as i64),
&(data.created as i64), &(data.created as i64),
@ -298,6 +301,7 @@ impl DataManager {
&{ if data.is_forge { 1 } else { 0 } }, &{ if data.is_forge { 1 } else { 0 } },
&0_i32, &0_i32,
&{ if data.is_forum { 1 } else { 0 } }, &{ 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_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_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_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<usize, ForumTopic>)@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_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); 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);

View file

@ -7,9 +7,11 @@ CREATE TABLE IF NOT EXISTS communities (
read_access TEXT NOT NULL, read_access TEXT NOT NULL,
write_access TEXT NOT NULL, write_access TEXT NOT NULL,
join_access TEXT NOT NULL, join_access TEXT NOT NULL,
-- likes
likes INT NOT NULL, likes INT NOT NULL,
dislikes 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
) )

View file

@ -17,3 +17,7 @@ ADD COLUMN IF NOT EXISTS replying_to BIGINT DEFAULT 0;
-- communities is_forum -- communities is_forum
ALTER TABLE communities ALTER TABLE communities
ADD COLUMN IF NOT EXISTS is_forum INT DEFAULT 0; ADD COLUMN IF NOT EXISTS is_forum INT DEFAULT 0;
-- communities topics
ALTER TABLE communities
ADD COLUMN IF NOT EXISTS topics TEXT DEFAULT '{}';

View file

@ -1,3 +1,5 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp};
use super::communities_permissions::CommunityPermission; use super::communities_permissions::CommunityPermission;
@ -19,14 +21,20 @@ pub struct Community {
pub write_access: CommunityWriteAccess, pub write_access: CommunityWriteAccess,
/// Who can join the community. /// Who can join the community.
pub join_access: CommunityJoinAccess, pub join_access: CommunityJoinAccess,
// likes
pub likes: isize, pub likes: isize,
pub dislikes: isize, pub dislikes: isize,
// ...
pub member_count: usize, pub member_count: usize,
pub is_forge: bool, pub is_forge: bool,
pub post_count: usize, pub post_count: usize,
pub is_forum: bool, 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<usize, ForumTopic>,
} }
impl Community { impl Community {
@ -50,6 +58,7 @@ impl Community {
is_forge: false, is_forge: false,
post_count: 0, post_count: 0,
is_forum: false, is_forum: false,
topics: HashMap::new(),
} }
} }
@ -71,6 +80,7 @@ impl Community {
is_forge: false, is_forge: false,
post_count: 0, post_count: 0,
is_forum: false, is_forum: false,
topics: HashMap::new(),
} }
} }
} }
@ -521,10 +531,25 @@ impl PollVote {
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ForumTopic { pub struct ForumTopic {
pub id: usize,
pub created: usize,
pub owner: usize,
pub community: usize,
pub title: String, pub title: String,
pub description: 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::<usize>().unwrap(),
Self {
title,
description,
color,
},
)
}
} }

View file

@ -53,6 +53,7 @@ pub enum Error {
RequiresSupporter, RequiresSupporter,
DrawingsDisabled, DrawingsDisabled,
AppHitStorageLimit, AppHitStorageLimit,
DoesNotSupportField(String),
Unknown, Unknown,
} }
@ -78,6 +79,7 @@ impl Display for Error {
Self::RequiresSupporter => "Only site supporters can do this".to_string(), Self::RequiresSupporter => "Only site supporters can do this".to_string(),
Self::DrawingsDisabled => "You are not allowed to submit drawings there".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::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), _ => format!("An unknown error as occurred: ({:?})", self),
}) })
} }