From a28072be7f5f7fc754ef518a0b1d6f377e7612ca Mon Sep 17 00:00:00 2001
From: trisua <tri@swmff.org>
Date: Fri, 25 Apr 2025 16:22:38 -0400
Subject: [PATCH] add: channels/messages database functions chore: bump
 contrasted

---
 Cargo.lock                                    |   4 +-
 crates/app/Cargo.toml                         |   2 +-
 crates/core/src/database/channels.rs          | 134 +++++++++++++
 crates/core/src/database/common.rs            |   2 +
 crates/core/src/database/drivers/common.rs    |   2 +
 .../database/drivers/sql/create_channels.sql  |   9 +
 .../database/drivers/sql/create_messages.sql  |   9 +
 crates/core/src/database/messages.rs          | 189 ++++++++++++++++++
 crates/core/src/database/mod.rs               |   5 +
 crates/core/src/model/channels.rs             |   6 +-
 crates/core/src/model/permissions.rs          |   2 +
 11 files changed, 358 insertions(+), 6 deletions(-)
 create mode 100644 crates/core/src/database/channels.rs
 create mode 100644 crates/core/src/database/drivers/sql/create_channels.sql
 create mode 100644 crates/core/src/database/drivers/sql/create_messages.sql
 create mode 100644 crates/core/src/database/messages.rs

diff --git a/Cargo.lock b/Cargo.lock
index 5ad7dc3..39b066f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -648,9 +648,9 @@ checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
 
 [[package]]
 name = "contrasted"
-version = "0.1.1"
+version = "0.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a3b6cc9d477fe7331eca62e0b39b559a2cda428d9cf2292b5ad99fc9d950fca1"
+checksum = "0ade94f58b0b9d71868d3dc34d065e9d027dc0d293f43d478616f7ef1160838f"
 
 [[package]]
 name = "cookie"
diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml
index 73e5a1c..a9edbe5 100644
--- a/crates/app/Cargo.toml
+++ b/crates/app/Cargo.toml
@@ -32,4 +32,4 @@ regex = "1.11.1"
 serde_json = "1.0.140"
 mime_guess = "2.0.5"
 cf-turnstile = "0.2.0"
-contrasted = "0.1.1"
+contrasted = "0.1.2"
diff --git a/crates/core/src/database/channels.rs b/crates/core/src/database/channels.rs
new file mode 100644
index 0000000..a73146a
--- /dev/null
+++ b/crates/core/src/database/channels.rs
@@ -0,0 +1,134 @@
+use super::*;
+use crate::cache::Cache;
+use crate::model::{
+    Error, Result, auth::User, permissions::FinePermission,
+    communities_permissions::CommunityPermission, channels::Channel,
+};
+use crate::{auto_method, execute, get, query_row, query_rows, params};
+
+#[cfg(feature = "sqlite")]
+use rusqlite::Row;
+
+#[cfg(feature = "postgres")]
+use tokio_postgres::Row;
+
+impl DataManager {
+    /// Get a [`Channel`] from an SQL row.
+    pub(crate) fn get_channel_from_row(
+        #[cfg(feature = "sqlite")] x: &Row<'_>,
+        #[cfg(feature = "postgres")] x: &Row,
+    ) -> Channel {
+        Channel {
+            id: get!(x->0(i64)) as usize,
+            community: get!(x->1(i64)) as usize,
+            owner: get!(x->2(i64)) as usize,
+            created: get!(x->3(i64)) as usize,
+            minimum_role_read: get!(x->4(i32)) as u32,
+            minimum_role_write: get!(x->5(i32)) as u32,
+            position: get!(x->6(i32)) as usize,
+        }
+    }
+
+    auto_method!(get_channel_by_id(usize)@get_channel_from_row -> "SELECT * FROM channels WHERE id = $1" --name="channel" --returns=Channel --cache-key-tmpl="atto.channel:{}");
+
+    /// Get all channels by user.
+    ///
+    /// # Arguments
+    /// * `community` - the ID of the community to fetch channels for
+    pub async fn get_channels_by_community(&self, community: usize) -> Result<Vec<Channel>> {
+        let conn = match self.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = query_rows!(
+            &conn,
+            "SELECT * FROM channels WHERE community = $1 ORDER BY position DESC",
+            &[&(community as i64)],
+            |x| { Self::get_channel_from_row(x) }
+        );
+
+        if res.is_err() {
+            return Err(Error::GeneralNotFound("channel".to_string()));
+        }
+
+        Ok(res.unwrap())
+    }
+
+    /// Create a new channel in the database.
+    ///
+    /// # Arguments
+    /// * `data` - a mock [`Channel`] object to insert
+    pub async fn create_channel(&self, data: Channel) -> Result<()> {
+        let user = self.get_user_by_id(data.owner).await?;
+
+        // check user permission in community
+        let membership = self
+            .get_membership_by_owner_community(user.id, data.community)
+            .await?;
+
+        if !membership.role.check(CommunityPermission::MANAGE_CHANNELS)
+            && !user.permissions.check(FinePermission::MANAGE_CHANNELS)
+        {
+            return Err(Error::NotAllowed);
+        }
+
+        // ...
+        let conn = match self.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = execute!(
+            &conn,
+            "INSERT INTO channels VALUES ($1, $2, $3, $4, $5, $6, $7)",
+            params![
+                &(data.id as i64),
+                &(data.community as i64),
+                &(data.owner as i64),
+                &(data.created as i64),
+                &(data.minimum_role_read as i32),
+                &(data.minimum_role_write as i32),
+                &(data.position as i32)
+            ]
+        );
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        Ok(())
+    }
+
+    pub async fn delete_channel(&self, id: usize, user: User) -> Result<()> {
+        let channel = self.get_channel_by_id(id).await?;
+
+        // check user permission in community
+        let membership = self
+            .get_membership_by_owner_community(user.id, channel.community)
+            .await?;
+
+        if !membership.role.check(CommunityPermission::MANAGE_CHANNELS) {
+            return Err(Error::NotAllowed);
+        }
+
+        // ...
+        let conn = match self.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = execute!(&conn, "DELETE FROM channels WHERE id = $1", &[&(id as i64)]);
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        self.2.remove(format!("atto.channel:{}", id)).await;
+        Ok(())
+    }
+
+    auto_method!(update_channel_position(i32)@get_channel_by_id:MANAGE_COMMUNITIES -> "UPDATE channels SET position = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}");
+    auto_method!(update_channel_minimum_role_read(i32)@get_channel_by_id:MANAGE_COMMUNITIES -> "UPDATE channels SET minimum_role_read = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}");
+    auto_method!(update_channel_minimum_role_write(i32)@get_channel_by_id:MANAGE_COMMUNITIES -> "UPDATE channels SET minimum_role_write = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}");
+}
diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs
index 6926f2b..2137092 100644
--- a/crates/core/src/database/common.rs
+++ b/crates/core/src/database/common.rs
@@ -28,6 +28,8 @@ impl DataManager {
         execute!(&conn, common::CREATE_TABLE_REQUESTS).unwrap();
         execute!(&conn, common::CREATE_TABLE_QUESTIONS).unwrap();
         execute!(&conn, common::CREATE_TABLE_IPBLOCKS).unwrap();
+        execute!(&conn, common::CREATE_TABLE_CHANNELS).unwrap();
+        execute!(&conn, common::CREATE_TABLE_MESSAGES).unwrap();
 
         Ok(())
     }
diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs
index cb16682..67d5bb0 100644
--- a/crates/core/src/database/drivers/common.rs
+++ b/crates/core/src/database/drivers/common.rs
@@ -13,3 +13,5 @@ pub const CREATE_TABLE_USER_WARNINGS: &str = include_str!("./sql/create_user_war
 pub const CREATE_TABLE_REQUESTS: &str = include_str!("./sql/create_requests.sql");
 pub const CREATE_TABLE_QUESTIONS: &str = include_str!("./sql/create_questions.sql");
 pub const CREATE_TABLE_IPBLOCKS: &str = include_str!("./sql/create_ipblocks.sql");
+pub const CREATE_TABLE_CHANNELS: &str = include_str!("./sql/create_channels.sql");
+pub const CREATE_TABLE_MESSAGES: &str = include_str!("./sql/create_messages.sql");
diff --git a/crates/core/src/database/drivers/sql/create_channels.sql b/crates/core/src/database/drivers/sql/create_channels.sql
new file mode 100644
index 0000000..2198c71
--- /dev/null
+++ b/crates/core/src/database/drivers/sql/create_channels.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS channels (
+    id BIGINT NOT NULL PRIMARY KEY,
+    community BIGINT NOT NULL,
+    owner BIGINT NOT NULL,
+    created BIGINT NOT NULL,
+    minimum_role_read INT NOT NULL,
+    minimum_role_write INT NOT NULL,
+    position INT NOT NULL
+)
diff --git a/crates/core/src/database/drivers/sql/create_messages.sql b/crates/core/src/database/drivers/sql/create_messages.sql
new file mode 100644
index 0000000..24096b6
--- /dev/null
+++ b/crates/core/src/database/drivers/sql/create_messages.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS messages (
+    id BIGINT NOT NULL PRIMARY KEY,
+    channel BIGINT NOT NULL,
+    owner BIGINT NOT NULL,
+    created BIGINT NOT NULL,
+    edited BIGINT NOT NULL,
+    content TEXT NOT NULL,
+    context TEXT NOT NULL
+)
diff --git a/crates/core/src/database/messages.rs b/crates/core/src/database/messages.rs
new file mode 100644
index 0000000..3dc00bd
--- /dev/null
+++ b/crates/core/src/database/messages.rs
@@ -0,0 +1,189 @@
+use super::*;
+use crate::cache::Cache;
+use crate::model::moderation::AuditLogEntry;
+use crate::model::{
+    Error, Result, auth::User, permissions::FinePermission,
+    communities_permissions::CommunityPermission, channels::Message,
+};
+use crate::{auto_method, execute, get, query_row, query_rows, params};
+
+#[cfg(feature = "sqlite")]
+use rusqlite::Row;
+
+use tetratto_shared::unix_epoch_timestamp;
+#[cfg(feature = "postgres")]
+use tokio_postgres::Row;
+
+impl DataManager {
+    /// Get a [`Message`] from an SQL row.
+    pub(crate) fn get_message_from_row(
+        #[cfg(feature = "sqlite")] x: &Row<'_>,
+        #[cfg(feature = "postgres")] x: &Row,
+    ) -> Message {
+        Message {
+            id: get!(x->0(i64)) as usize,
+            channel: get!(x->1(i64)) as usize,
+            owner: get!(x->2(i64)) as usize,
+            created: get!(x->3(i64)) as usize,
+            edited: get!(x->4(i64)) as usize,
+            content: get!(x->5(String)),
+            context: serde_json::from_str(&get!(x->6(String))).unwrap(),
+        }
+    }
+
+    auto_method!(get_message_by_id(usize)@get_message_from_row -> "SELECT * FROM messages WHERE id = $1" --name="message" --returns=Message --cache-key-tmpl="atto.message:{}");
+
+    /// Get all messages by channel (paginated).
+    ///
+    /// # Arguments
+    /// * `channel` - the ID of the community to fetch channels for
+    /// * `batch` - the limit of items in each page
+    /// * `page` - the page number
+    pub async fn get_messages_by_channel(
+        &self,
+        channel: usize,
+        batch: usize,
+        page: usize,
+    ) -> Result<Vec<Message>> {
+        let conn = match self.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = query_rows!(
+            &conn,
+            "SELECT * FROM messages WHERE channel = $1 ORDER BY created DESC LIMIT $1 OFFSET $2",
+            &[&(channel as i64), &(batch as i64), &((page * batch) as i64)],
+            |x| { Self::get_message_from_row(x) }
+        );
+
+        if res.is_err() {
+            return Err(Error::GeneralNotFound("message".to_string()));
+        }
+
+        Ok(res.unwrap())
+    }
+
+    /// Create a new message in the database.
+    ///
+    /// # Arguments
+    /// * `data` - a mock [`Message`] object to insert
+    pub async fn create_message(&self, data: Message) -> Result<()> {
+        let user = self.get_user_by_id(data.owner).await?;
+        let channel = self.get_channel_by_id(data.channel).await?;
+
+        // check user permission in community
+        let membership = self
+            .get_membership_by_owner_community(user.id, channel.community)
+            .await?;
+
+        if !membership.role.check_member() {
+            return Err(Error::NotAllowed);
+        }
+
+        // check user permission to post in channel
+        let role = membership.role.bits();
+
+        if role < channel.minimum_role_write {
+            return Err(Error::NotAllowed);
+        }
+
+        // ...
+        let conn = match self.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = execute!(
+            &conn,
+            "INSERT INTO messages VALUES ($1, $2, $3, $4, $5, $6, $7)",
+            params![
+                &(data.id as i64),
+                &(data.channel as i64),
+                &(data.owner as i64),
+                &(data.created as i64),
+                &(data.edited as i64),
+                &data.content,
+                &serde_json::to_string(&data.context).unwrap()
+            ]
+        );
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        Ok(())
+    }
+
+    pub async fn delete_message(&self, id: usize, user: User) -> Result<()> {
+        let message = self.get_message_by_id(id).await?;
+        let channel = self.get_channel_by_id(message.channel).await?;
+
+        // check user permission in community
+        let membership = self
+            .get_membership_by_owner_community(user.id, channel.community)
+            .await?;
+
+        if !membership.role.check(CommunityPermission::MANAGE_MESSAGES)
+            && !user.permissions.check(FinePermission::MANAGE_MESSAGES)
+        {
+            return Err(Error::NotAllowed);
+        } else if user.permissions.check(FinePermission::MANAGE_MESSAGES) {
+            self.create_audit_log_entry(AuditLogEntry::new(
+                user.id,
+                format!("invoked `delete_message` with x value `{id}`"),
+            ))
+            .await?
+        }
+
+        // ...
+        let conn = match self.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = execute!(&conn, "DELETE FROM messages WHERE id = $1", &[&(id as i64)]);
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        self.2.remove(format!("atto.message:{}", id)).await;
+        Ok(())
+    }
+
+    pub async fn update_message_content(&self, id: usize, user: User, x: String) -> Result<()> {
+        let y = self.get_message_by_id(id).await?;
+
+        if user.id != y.owner {
+            if !user.permissions.check(FinePermission::MANAGE_MESSAGES) {
+                return Err(Error::NotAllowed);
+            } else {
+                self.create_audit_log_entry(AuditLogEntry::new(
+                    user.id,
+                    format!("invoked `update_message_content` with x value `{id}`"),
+                ))
+                .await?
+            }
+        }
+
+        // ...
+        let conn = match self.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = execute!(
+            &conn,
+            "UPDATE messages SET content = $1, edited = $2 WHERE id = $2",
+            params![&x, &(unix_epoch_timestamp() as i64), &(id as i64)]
+        );
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        // return
+        Ok(())
+    }
+}
diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs
index b43d53d..a1d16b5 100644
--- a/crates/core/src/database/mod.rs
+++ b/crates/core/src/database/mod.rs
@@ -16,6 +16,11 @@ mod user_warnings;
 mod userblocks;
 mod userfollows;
 
+#[cfg(feature = "redis")]
+pub mod channels;
+#[cfg(feature = "redis")]
+pub mod messages;
+
 #[cfg(feature = "sqlite")]
 pub use drivers::sqlite::*;
 
diff --git a/crates/core/src/model/channels.rs b/crates/core/src/model/channels.rs
index d1c6e34..0c3c523 100644
--- a/crates/core/src/model/channels.rs
+++ b/crates/core/src/model/channels.rs
@@ -17,12 +17,12 @@ pub struct Channel {
     /// The position of this channel in the UI.
     ///
     /// Top (0) to bottom.
-    pub order: usize,
+    pub position: usize,
 }
 
 impl Channel {
     /// Create a new [`Channel`].
-    pub fn new(community: usize, owner: usize, order: usize) -> Self {
+    pub fn new(community: usize, owner: usize, position: usize) -> Self {
         Self {
             id: AlmostSnowflake::new(1234567890)
                 .to_string()
@@ -33,7 +33,7 @@ impl Channel {
             created: unix_epoch_timestamp() as usize,
             minimum_role_read: (CommunityPermission::DEFAULT | CommunityPermission::MEMBER).bits(),
             minimum_role_write: (CommunityPermission::DEFAULT | CommunityPermission::MEMBER).bits(),
-            order,
+            position,
         }
     }
 }
diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs
index c051ee0..0073e60 100644
--- a/crates/core/src/model/permissions.rs
+++ b/crates/core/src/model/permissions.rs
@@ -30,6 +30,8 @@ bitflags! {
         const SUPPORTER = 1 << 19;
         const MANAGE_REQUESTS = 1 << 20;
         const MANAGE_QUESTIONS = 1 << 21;
+        const MANAGE_CHANNELS = 1 << 22;
+        const MANAGE_MESSAGES = 1 << 23;
 
         const _ = !0;
     }