diff --git a/migrations/2025-07-12-135941_add_channel_categories/down.sql b/migrations/2025-07-12-135941_add_channel_categories/down.sql new file mode 100644 index 0000000..1bc51d2 --- /dev/null +++ b/migrations/2025-07-12-135941_add_channel_categories/down.sql @@ -0,0 +1,4 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE channels DROP COLUMN in_category; + +DROP TABLE categories; diff --git a/migrations/2025-07-12-135941_add_channel_categories/up.sql b/migrations/2025-07-12-135941_add_channel_categories/up.sql new file mode 100644 index 0000000..a2855b6 --- /dev/null +++ b/migrations/2025-07-12-135941_add_channel_categories/up.sql @@ -0,0 +1,10 @@ +-- Your SQL goes here +CREATE TABLE categories ( + uuid UUID PRIMARY KEY NOT NULL, + guild_uuid UUID NOT NULL REFERENCES guilds(uuid), + name VARCHAR(32) NOT NULL, + description VARCHAR(500) DEFAULT NULL, + is_above UUID UNIQUE REFERENCES categories(uuid) DEFAULT NULL +); + +ALTER TABLE channels ADD COLUMN in_category UUID REFERENCES categories(uuid) DEFAULT NULL; diff --git a/src/api/v1/guilds/uuid/categories.rs b/src/api/v1/guilds/uuid/categories.rs new file mode 100644 index 0000000..3c52925 --- /dev/null +++ b/src/api/v1/guilds/uuid/categories.rs @@ -0,0 +1,92 @@ +use crate::{ + Data, + api::v1::auth::check_access_token, + error::Error, + objects::{Channel, Member, Permissions}, + utils::{get_auth_header, global_checks, order_by_is_above}, +}; +use ::uuid::Uuid; +use actix_web::{HttpRequest, HttpResponse, get, post, web}; +use serde::Deserialize; + +#[derive(Deserialize)] +struct ChannelInfo { + name: String, + description: Option, +} + +#[get("{uuid}/categories")] +pub async fn get( + req: HttpRequest, + path: web::Path<(Uuid,)>, + data: web::Data, +) -> Result { + let headers = req.headers(); + + let auth_header = get_auth_header(headers)?; + + let guild_uuid = path.into_inner().0; + + let mut conn = data.pool.get().await?; + + let uuid = check_access_token(auth_header, &mut conn).await?; + + global_checks(&data, uuid).await?; + + Member::check_membership(&mut conn, uuid, guild_uuid).await?; + + if let Ok(cache_hit) = data.get_cache_key(format!("{guild_uuid}_channels")).await { + return Ok(HttpResponse::Ok() + .content_type("application/json") + .body(cache_hit)); + } + + let channels = Channel::fetch_all(&data.pool, guild_uuid).await?; + + let channels_ordered = order_by_is_above(channels).await?; + + data.set_cache_key( + format!("{guild_uuid}_channels"), + channels_ordered.clone(), + 1800, + ) + .await?; + + Ok(HttpResponse::Ok().json(channels_ordered)) +} + +#[post("{uuid}/categories")] +pub async fn create( + req: HttpRequest, + channel_info: web::Json, + path: web::Path<(Uuid,)>, + data: web::Data, +) -> Result { + let headers = req.headers(); + + let auth_header = get_auth_header(headers)?; + + let guild_uuid = path.into_inner().0; + + let mut conn = data.pool.get().await?; + + let uuid = check_access_token(auth_header, &mut conn).await?; + + global_checks(&data, uuid).await?; + + let member = Member::check_membership(&mut conn, uuid, guild_uuid).await?; + + member + .check_permission(&data, Permissions::CreateChannel) + .await?; + + let channel = Channel::new( + data.clone(), + guild_uuid, + channel_info.name.clone(), + channel_info.description.clone(), + ) + .await?; + + Ok(HttpResponse::Ok().json(channel)) +} diff --git a/src/api/v1/guilds/uuid/mod.rs b/src/api/v1/guilds/uuid/mod.rs index 4c88d7a..de89c2e 100644 --- a/src/api/v1/guilds/uuid/mod.rs +++ b/src/api/v1/guilds/uuid/mod.rs @@ -8,6 +8,7 @@ mod icon; mod invites; mod members; mod roles; +mod categories; use crate::{ Data, @@ -21,6 +22,9 @@ pub fn web() -> Scope { web::scope("") // Servers .service(get) + // Categories + .service(categories::get) + .service(categories::create) // Channels .service(channels::get) .service(channels::create) diff --git a/src/objects/categories.rs b/src/objects/categories.rs new file mode 100644 index 0000000..f2c6a5f --- /dev/null +++ b/src/objects/categories.rs @@ -0,0 +1,361 @@ +use diesel::{ + ExpressionMethods, Insertable, QueryDsl, Queryable, Selectable, SelectableHelper, delete, + insert_into, update, +}; +use diesel_async::{RunQueryDsl, pooled_connection::AsyncDieselConnectionManager}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + Conn, Data, + error::Error, + schema::categories, + utils::{CHANNEL_REGEX, order_by_is_above}, +}; + +use super::{HasIsAbove, HasUuid, Message, load_or_empty, message::MessageBuilder}; + +#[derive(Serialize, Deserialize, Queryable, Selectable, Insertable, Clone, Debug)] +#[diesel(table_name = categories)] +#[diesel(check_for_backend(diesel::pg::Pg))] +struct Category { + uuid: Uuid, + guild_uuid: Uuid, + name: String, + description: Option, + is_above: Option, +} + +impl HasUuid for Category { + fn uuid(&self) -> &Uuid { + self.uuid.as_ref() + } +} + +impl HasIsAbove for Category { + fn is_above(&self) -> Option<&Uuid> { + self.is_above.as_ref() + } +} + +impl Category { + pub async fn fetch_all( + pool: &deadpool::managed::Pool< + AsyncDieselConnectionManager, + Conn, + >, + guild_uuid: Uuid, + ) -> Result, Error> { + let mut conn = pool.get().await?; + + use channels::dsl; + let channel_builders: Vec = load_or_empty( + dsl::channels + .filter(dsl::guild_uuid.eq(guild_uuid)) + .select(ChannelBuilder::as_select()) + .load(&mut conn) + .await, + )?; + + let channel_futures = channel_builders.iter().map(async move |c| { + let mut conn = pool.get().await?; + c.clone().build(&mut conn).await + }); + + futures::future::try_join_all(channel_futures).await + } + + pub async fn fetch_one(data: &Data, channel_uuid: Uuid) -> Result { + if let Ok(cache_hit) = data.get_cache_key(channel_uuid.to_string()).await { + return Ok(serde_json::from_str(&cache_hit)?); + } + + let mut conn = data.pool.get().await?; + + use channels::dsl; + let channel_builder: ChannelBuilder = dsl::channels + .filter(dsl::uuid.eq(channel_uuid)) + .select(ChannelBuilder::as_select()) + .get_result(&mut conn) + .await?; + + let channel = channel_builder.build(&mut conn).await?; + + data.set_cache_key(channel_uuid.to_string(), channel.clone(), 60) + .await?; + + Ok(channel) + } + + pub async fn new( + data: actix_web::web::Data, + guild_uuid: Uuid, + name: String, + description: Option, + ) -> Result { + if !CHANNEL_REGEX.is_match(&name) { + return Err(Error::BadRequest("Channel name is invalid".to_string())); + } + + let mut conn = data.pool.get().await?; + + let channel_uuid = Uuid::now_v7(); + + let channels = Self::fetch_all(&data.pool, guild_uuid).await?; + + let channels_ordered = order_by_is_above(channels).await?; + + let last_channel = channels_ordered.last(); + + let new_channel = ChannelBuilder { + uuid: channel_uuid, + guild_uuid, + name: name.clone(), + description: description.clone(), + is_above: None, + }; + + insert_into(channels::table) + .values(new_channel.clone()) + .execute(&mut conn) + .await?; + + if let Some(old_last_channel) = last_channel { + use channels::dsl; + update(channels::table) + .filter(dsl::uuid.eq(old_last_channel.uuid)) + .set(dsl::is_above.eq(new_channel.uuid)) + .execute(&mut conn) + .await?; + } + + // returns different object because there's no reason to build the channelbuilder (wastes 1 database request) + let channel = Self { + uuid: channel_uuid, + guild_uuid, + name, + description, + is_above: None, + permissions: vec![], + }; + + data.set_cache_key(channel_uuid.to_string(), channel.clone(), 1800) + .await?; + + if data + .get_cache_key(format!("{guild_uuid}_channels")) + .await + .is_ok() + { + data.del_cache_key(format!("{guild_uuid}_channels")).await?; + } + + Ok(channel) + } + + pub async fn delete(self, data: &Data) -> Result<(), Error> { + let mut conn = data.pool.get().await?; + + use channels::dsl; + match update(channels::table) + .filter(dsl::is_above.eq(self.uuid)) + .set(dsl::is_above.eq(None::)) + .execute(&mut conn) + .await + { + Ok(r) => Ok(r), + Err(diesel::result::Error::NotFound) => Ok(0), + Err(e) => Err(e), + }?; + + delete(channels::table) + .filter(dsl::uuid.eq(self.uuid)) + .execute(&mut conn) + .await?; + + match update(channels::table) + .filter(dsl::is_above.eq(self.uuid)) + .set(dsl::is_above.eq(self.is_above)) + .execute(&mut conn) + .await + { + Ok(r) => Ok(r), + Err(diesel::result::Error::NotFound) => Ok(0), + Err(e) => Err(e), + }?; + + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await?; + } + + if data + .get_cache_key(format!("{}_channels", self.guild_uuid)) + .await + .is_ok() + { + data.del_cache_key(format!("{}_channels", self.guild_uuid)) + .await?; + } + + Ok(()) + } + + pub async fn fetch_messages( + &self, + data: &Data, + amount: i64, + offset: i64, + ) -> Result, Error> { + let mut conn = data.pool.get().await?; + + use messages::dsl; + let messages: Vec = load_or_empty( + dsl::messages + .filter(dsl::channel_uuid.eq(self.uuid)) + .select(MessageBuilder::as_select()) + .order(dsl::uuid.desc()) + .limit(amount) + .offset(offset) + .load(&mut conn) + .await, + )?; + + let message_futures = messages.iter().map(async move |b| b.build(data).await); + + futures::future::try_join_all(message_futures).await + } + + pub async fn new_message( + &self, + data: &Data, + user_uuid: Uuid, + message: String, + reply_to: Option, + ) -> Result { + let message_uuid = Uuid::now_v7(); + + let message = MessageBuilder { + uuid: message_uuid, + channel_uuid: self.uuid, + user_uuid, + message, + reply_to, + is_edited: false, + }; + + let mut conn = data.pool.get().await?; + + insert_into(messages::table) + .values(message.clone()) + .execute(&mut conn) + .await?; + + message.build(data).await + } + + /*pub async fn edit_message(&self, data: &Data, user_uuid: Uuid, message_uuid: Uuid, message: String) -> Result { + use messages::dsl; + + let mut conn = data.pool.get().await?; + + update(messages::table) + .filter(dsl::user_uuid.eq(user_uuid)) + .filter(dsl::uuid.eq(message_uuid)) + .set((dsl::is_edited.eq(true), dsl::message.eq(message))) + .execute(&mut conn) + .await?; + + Ok(()) + }*/ + + pub async fn set_name(&mut self, data: &Data, new_name: String) -> Result<(), Error> { + if !CHANNEL_REGEX.is_match(&new_name) { + return Err(Error::BadRequest("Channel name is invalid".to_string())); + } + + let mut conn = data.pool.get().await?; + + use channels::dsl; + update(channels::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::name.eq(&new_name)) + .execute(&mut conn) + .await?; + + self.name = new_name; + + Ok(()) + } + + pub async fn set_description( + &mut self, + data: &Data, + new_description: String, + ) -> Result<(), Error> { + let mut conn = data.pool.get().await?; + + use channels::dsl; + update(channels::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::description.eq(&new_description)) + .execute(&mut conn) + .await?; + + self.description = Some(new_description); + + Ok(()) + } + + pub async fn move_channel(&mut self, data: &Data, new_is_above: Uuid) -> Result<(), Error> { + let mut conn = data.pool.get().await?; + + use channels::dsl; + let old_above_uuid: Option = match dsl::channels + .filter(dsl::is_above.eq(self.uuid)) + .select(dsl::uuid) + .get_result(&mut conn) + .await + { + Ok(r) => Ok(Some(r)), + Err(diesel::result::Error::NotFound) => Ok(None), + Err(e) => Err(e), + }?; + + if let Some(uuid) = old_above_uuid { + update(channels::table) + .filter(dsl::uuid.eq(uuid)) + .set(dsl::is_above.eq(None::)) + .execute(&mut conn) + .await?; + } + + match update(channels::table) + .filter(dsl::is_above.eq(new_is_above)) + .set(dsl::is_above.eq(self.uuid)) + .execute(&mut conn) + .await + { + Ok(r) => Ok(r), + Err(diesel::result::Error::NotFound) => Ok(0), + Err(e) => Err(e), + }?; + + update(channels::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::is_above.eq(new_is_above)) + .execute(&mut conn) + .await?; + + if let Some(uuid) = old_above_uuid { + update(channels::table) + .filter(dsl::uuid.eq(uuid)) + .set(dsl::is_above.eq(self.is_above)) + .execute(&mut conn) + .await?; + } + + self.is_above = Some(new_is_above); + + Ok(()) + } +} diff --git a/src/objects/mod.rs b/src/objects/mod.rs index 9974410..6139eea 100644 --- a/src/objects/mod.rs +++ b/src/objects/mod.rs @@ -7,6 +7,7 @@ use log::debug; use serde::Deserialize; use uuid::Uuid; +mod categories; mod channel; mod email_token; mod friends; diff --git a/src/schema.rs b/src/schema.rs index ecbae6f..ed5e1a5 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -11,6 +11,18 @@ diesel::table! { } } +diesel::table! { + categories (uuid) { + uuid -> Uuid, + guild_uuid -> Uuid, + #[max_length = 32] + name -> Varchar, + #[max_length = 500] + description -> Nullable, + is_above -> Nullable, + } +} + diesel::table! { channel_permissions (channel_uuid, role_uuid) { channel_uuid -> Uuid, @@ -28,6 +40,7 @@ diesel::table! { #[max_length = 500] description -> Nullable, is_above -> Nullable, + in_category -> Nullable, } } @@ -153,7 +166,9 @@ diesel::table! { diesel::joinable!(access_tokens -> refresh_tokens (refresh_token)); diesel::joinable!(access_tokens -> users (uuid)); +diesel::joinable!(categories -> guilds (guild_uuid)); diesel::joinable!(channel_permissions -> channels (channel_uuid)); +diesel::joinable!(channels -> categories (in_category)); diesel::joinable!(channels -> guilds (guild_uuid)); diesel::joinable!(guild_members -> guilds (guild_uuid)); diesel::joinable!(guild_members -> users (user_uuid)); @@ -168,6 +183,7 @@ diesel::joinable!(roles -> guilds (guild_uuid)); diesel::allow_tables_to_appear_in_same_query!( access_tokens, + categories, channel_permissions, channels, friend_requests,