From 102ea0ee3538e352ad8e2feadd6d3fa10c3e42af Mon Sep 17 00:00:00 2001
From: trisua <me@trisua.com>
Date: Wed, 18 Jun 2025 19:21:01 -0400
Subject: [PATCH] add: journals/notes database interfaces

---
 crates/app/src/public/html/macros.lisp        |  14 +-
 .../app/src/public/html/profile/settings.lisp |   4 +-
 crates/app/src/public/html/root.lisp          |   6 +-
 crates/core/src/database/common.rs            |   2 +
 crates/core/src/database/drivers/common.rs    |   2 +
 .../database/drivers/sql/create_journals.sql  |   7 +
 .../src/database/drivers/sql/create_notes.sql |   9 ++
 crates/core/src/database/journals.rs          | 141 ++++++++++++++++++
 crates/core/src/database/mod.rs               |   2 +
 crates/core/src/database/notes.rs             | 124 +++++++++++++++
 crates/core/src/database/posts.rs             |   9 +-
 crates/core/src/model/journals.rs             |  69 +++++++++
 crates/core/src/model/mod.rs                  |   1 +
 crates/core/src/model/permissions.rs          |   2 +
 14 files changed, 386 insertions(+), 6 deletions(-)
 create mode 100644 crates/core/src/database/drivers/sql/create_journals.sql
 create mode 100644 crates/core/src/database/drivers/sql/create_notes.sql
 create mode 100644 crates/core/src/database/journals.rs
 create mode 100644 crates/core/src/database/notes.rs
 create mode 100644 crates/core/src/model/journals.rs

diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp
index 80ed3ba..18820ce 100644
--- a/crates/app/src/public/html/macros.lisp
+++ b/crates/app/src/public/html/macros.lisp
@@ -112,7 +112,19 @@
                         ("class" "button")
                         ("data-turbo" "false")
                         (icon (text "rabbit"))
-                        (str (text "general:link.reference"))))))
+                        (str (text "general:link.reference")))
+
+                    (a
+                        ("href" "{{ config.policies.terms_of_service }}")
+                        ("class" "button")
+                        (icon (text "heart-handshake"))
+                        (text "Terms of service"))
+
+                    (a
+                        ("href" "{{ config.policies.privacy }}")
+                        ("class" "button")
+                        (icon (text "cookie"))
+                        (text "Privacy policy")))))
             (text "{%- endif %}")))
 (text "{%- endmacro %}")
 
diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index 07d24a1..bb0277d 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -585,7 +585,9 @@
                                 (li
                                     (text "Add unlimited users to stacks"))
                                 (li
-                                    (text "Increased proxied image size")))
+                                    (text "Increased proxied image size"))
+                                (li
+                                    (text "Create infinite journals")))
                             (a
                                 ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
                                 ("class" "button")
diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp
index a7cfb4a..d126e16 100644
--- a/crates/app/src/public/html/root.lisp
+++ b/crates/app/src/public/html/root.lisp
@@ -25,7 +25,7 @@
             globalThis.ns_config = {
                 root: \"/js/\",
                 verbose: globalThis.ns_verbose,
-                version: \"cache-breaker-{{ random_cache_breaker }}\",
+                version: \"tetratto-{{ random_cache_breaker }}\",
             };
 
             globalThis._app_base = {
@@ -38,8 +38,8 @@
             globalThis.BUILD_CODE = \"{{ random_cache_breaker }}\";
         </script>")
 
-        (script ("src" "/js/loader.js" ))
-        (script ("src" "/js/atto.js" ))
+        (script ("src" "/js/loader.js?v=tetratto-{{ random_cache_breaker }}" ))
+        (script ("src" "/js/atto.js?v=tetratto-{{ random_cache_breaker }}" ))
 
         (meta ("name" "theme-color") ("content" "{{ config.color }}"))
         (meta ("name" "description") ("content" "{{ config.description }}"))
diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs
index 7e7a7f6..36bbdb7 100644
--- a/crates/core/src/database/common.rs
+++ b/crates/core/src/database/common.rs
@@ -36,6 +36,8 @@ impl DataManager {
         execute!(&conn, common::CREATE_TABLE_POLLVOTES).unwrap();
         execute!(&conn, common::CREATE_TABLE_APPS).unwrap();
         execute!(&conn, common::CREATE_TABLE_STACKBLOCKS).unwrap();
+        execute!(&conn, common::CREATE_TABLE_JOURNALS).unwrap();
+        execute!(&conn, common::CREATE_TABLE_NOTES).unwrap();
 
         self.0
             .1
diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs
index 94cc123..64a9dfc 100644
--- a/crates/core/src/database/drivers/common.rs
+++ b/crates/core/src/database/drivers/common.rs
@@ -23,3 +23,5 @@ pub const CREATE_TABLE_POLLS: &str = include_str!("./sql/create_polls.sql");
 pub const CREATE_TABLE_POLLVOTES: &str = include_str!("./sql/create_pollvotes.sql");
 pub const CREATE_TABLE_APPS: &str = include_str!("./sql/create_apps.sql");
 pub const CREATE_TABLE_STACKBLOCKS: &str = include_str!("./sql/create_stackblocks.sql");
+pub const CREATE_TABLE_JOURNALS: &str = include_str!("./sql/create_journals.sql");
+pub const CREATE_TABLE_NOTES: &str = include_str!("./sql/create_notes.sql");
diff --git a/crates/core/src/database/drivers/sql/create_journals.sql b/crates/core/src/database/drivers/sql/create_journals.sql
new file mode 100644
index 0000000..01f49e5
--- /dev/null
+++ b/crates/core/src/database/drivers/sql/create_journals.sql
@@ -0,0 +1,7 @@
+CREATE TABLE IF NOT EXISTS channels (
+    id BIGINT NOT NULL PRIMARY KEY,
+    created BIGINT NOT NULL,
+    owner BIGINT NOT NULL,
+    title TEXT NOT NULL,
+    view TEXT NOT NULL
+)
diff --git a/crates/core/src/database/drivers/sql/create_notes.sql b/crates/core/src/database/drivers/sql/create_notes.sql
new file mode 100644
index 0000000..0ee4686
--- /dev/null
+++ b/crates/core/src/database/drivers/sql/create_notes.sql
@@ -0,0 +1,9 @@
+CREATE TABLE IF NOT EXISTS channels (
+    id BIGINT NOT NULL PRIMARY KEY,
+    created BIGINT NOT NULL,
+    owner BIGINT NOT NULL,
+    title TEXT NOT NULL,
+    journal BIGINT NOT NULL,
+    content TEXT NOT NULL,
+    edited BIGINT NOT NULL
+)
diff --git a/crates/core/src/database/journals.rs b/crates/core/src/database/journals.rs
new file mode 100644
index 0000000..0bc5ded
--- /dev/null
+++ b/crates/core/src/database/journals.rs
@@ -0,0 +1,141 @@
+use oiseau::cache::Cache;
+use crate::{
+    model::{
+        auth::User,
+        permissions::FinePermission,
+        journals::{Journal, JournalViewPermission},
+        Error, Result,
+    },
+};
+use crate::{auto_method, DataManager};
+use oiseau::{PostgresRow, execute, get, query_rows, params};
+
+impl DataManager {
+    /// Get a [`Journal`] from an SQL row.
+    pub(crate) fn get_journal_from_row(x: &PostgresRow) -> Journal {
+        Journal {
+            id: get!(x->0(i64)) as usize,
+            created: get!(x->1(i64)) as usize,
+            owner: get!(x->2(i64)) as usize,
+            title: get!(x->3(String)),
+            view: serde_json::from_str(&get!(x->4(String))).unwrap(),
+        }
+    }
+
+    auto_method!(get_journal_by_id(usize as i64)@get_journal_from_row -> "SELECT * FROM journals WHERE id = $1" --name="journal" --returns=Journal --cache-key-tmpl="atto.journal:{}");
+
+    /// Get all journals by user.
+    ///
+    /// # Arguments
+    /// * `id` - the ID of the user to fetch journals for
+    pub async fn get_journals_by_user(&self, id: usize) -> Result<Vec<Journal>> {
+        let conn = match self.0.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = query_rows!(
+            &conn,
+            "SELECT * FROM journals WHERE owner = $1 ORDER BY name ASC",
+            &[&(id as i64)],
+            |x| { Self::get_journal_from_row(x) }
+        );
+
+        if res.is_err() {
+            return Err(Error::GeneralNotFound("journal".to_string()));
+        }
+
+        Ok(res.unwrap())
+    }
+
+    const MAXIMUM_FREE_JOURNALS: usize = 15;
+
+    /// Create a new journal in the database.
+    ///
+    /// # Arguments
+    /// * `data` - a mock [`Journal`] object to insert
+    pub async fn create_journal(&self, data: Journal) -> Result<Journal> {
+        // check values
+        if data.title.len() < 2 {
+            return Err(Error::DataTooShort("title".to_string()));
+        } else if data.title.len() > 32 {
+            return Err(Error::DataTooLong("title".to_string()));
+        }
+
+        // check number of journals
+        let owner = self.get_user_by_id(data.owner).await?;
+
+        if !owner.permissions.check(FinePermission::SUPPORTER) {
+            let journals = self.get_journals_by_user(data.owner).await?;
+
+            if journals.len() >= Self::MAXIMUM_FREE_JOURNALS {
+                return Err(Error::MiscError(
+                    "You already have the maximum number of journals you can have".to_string(),
+                ));
+            }
+        }
+
+        // ...
+        let conn = match self.0.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = execute!(
+            &conn,
+            "INSERT INTO journals VALUES ($1, $2, $3, $4, $5)",
+            params![
+                &(data.id as i64),
+                &(data.created as i64),
+                &(data.owner as i64),
+                &data.title,
+                &serde_json::to_string(&data.view).unwrap(),
+            ]
+        );
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        Ok(data)
+    }
+
+    pub async fn delete_journal(&self, id: usize, user: &User) -> Result<()> {
+        let journal = self.get_journal_by_id(id).await?;
+
+        // check user permission
+        if user.id != journal.owner && !user.permissions.check(FinePermission::MANAGE_JOURNALS) {
+            return Err(Error::NotAllowed);
+        }
+
+        // ...
+        let conn = match self.0.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = execute!(&conn, "DELETE FROM journals WHERE id = $1", &[&(id as i64)]);
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        // delete notes
+        let res = execute!(
+            &conn,
+            "DELETE FROM notes WHERE journal = $1",
+            &[&(id as i64)]
+        );
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        // ...
+        self.0.1.remove(format!("atto.journal:{}", id)).await;
+        Ok(())
+    }
+
+    auto_method!(update_journal_title(&str)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.journal:{}");
+    auto_method!(update_journal_view(JournalViewPermission)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}");
+}
diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs
index b26afbf..e56bc93 100644
--- a/crates/core/src/database/mod.rs
+++ b/crates/core/src/database/mod.rs
@@ -10,8 +10,10 @@ mod drivers;
 mod emojis;
 mod ipbans;
 mod ipblocks;
+mod journals;
 mod memberships;
 mod messages;
+mod notes;
 mod notifications;
 mod polls;
 mod pollvotes;
diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs
new file mode 100644
index 0000000..f7afc46
--- /dev/null
+++ b/crates/core/src/database/notes.rs
@@ -0,0 +1,124 @@
+use oiseau::cache::Cache;
+use crate::model::{auth::User, journals::Note, permissions::FinePermission, Error, Result};
+use crate::{auto_method, DataManager};
+use oiseau::{PostgresRow, execute, get, query_rows, params};
+
+impl DataManager {
+    /// Get a [`Note`] from an SQL row.
+    pub(crate) fn get_note_from_row(x: &PostgresRow) -> Note {
+        Note {
+            id: get!(x->0(i64)) as usize,
+            created: get!(x->1(i64)) as usize,
+            owner: get!(x->2(i64)) as usize,
+            title: get!(x->3(String)),
+            journal: get!(x->4(i64)) as usize,
+            content: get!(x->5(String)),
+            edited: get!(x->6(i64)) as usize,
+        }
+    }
+
+    auto_method!(get_note_by_id(usize as i64)@get_note_from_row -> "SELECT * FROM notes WHERE id = $1" --name="note" --returns=Note --cache-key-tmpl="atto.note:{}");
+
+    /// Get all notes by journal.
+    ///
+    /// # Arguments
+    /// * `id` - the ID of the journal to fetch notes for
+    pub async fn get_notes_by_journal(&self, id: usize) -> Result<Vec<Note>> {
+        let conn = match self.0.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = query_rows!(
+            &conn,
+            "SELECT * FROM notes WHERE journal = $1 ORDER BY edited",
+            &[&(id as i64)],
+            |x| { Self::get_note_from_row(x) }
+        );
+
+        if res.is_err() {
+            return Err(Error::GeneralNotFound("note".to_string()));
+        }
+
+        Ok(res.unwrap())
+    }
+
+    /// Create a new note in the database.
+    ///
+    /// # Arguments
+    /// * `data` - a mock [`Note`] object to insert
+    pub async fn create_note(&self, data: Note) -> Result<Note> {
+        // check values
+        if data.title.len() < 2 {
+            return Err(Error::DataTooShort("title".to_string()));
+        } else if data.title.len() > 64 {
+            return Err(Error::DataTooLong("title".to_string()));
+        }
+
+        if data.content.len() < 2 {
+            return Err(Error::DataTooShort("content".to_string()));
+        } else if data.content.len() > 16384 {
+            return Err(Error::DataTooLong("content".to_string()));
+        }
+
+        // ...
+        let conn = match self.0.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = execute!(
+            &conn,
+            "INSERT INTO notes VALUES ($1, $2, $3, $4, $5, $6, $7)",
+            params![
+                &(data.id as i64),
+                &(data.created as i64),
+                &(data.owner as i64),
+                &data.title,
+                &(data.journal as i64),
+                &data.content,
+                &(data.edited as i64),
+            ]
+        );
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        Ok(data)
+    }
+
+    pub async fn delete_note(&self, id: usize, user: &User) -> Result<()> {
+        let note = self.get_note_by_id(id).await?;
+
+        // check user permission
+        if user.id != note.owner && !user.permissions.check(FinePermission::MANAGE_NOTES) {
+            return Err(Error::NotAllowed);
+        }
+
+        // ...
+        let conn = match self.0.connect().await {
+            Ok(c) => c,
+            Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+        };
+
+        let res = execute!(&conn, "DELETE FROM notes WHERE id = $1", &[&(id as i64)]);
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        // delete notes
+        let res = execute!(&conn, "DELETE FROM notes WHERE note = $1", &[&(id as i64)]);
+
+        if let Err(e) = res {
+            return Err(Error::DatabaseError(e.to_string()));
+        }
+
+        // ...
+        self.0.1.remove(format!("atto.note:{}", id)).await;
+        Ok(())
+    }
+
+    auto_method!(update_note_title(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}");
+}
diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs
index a02d2d4..29dd75a 100644
--- a/crates/core/src/database/posts.rs
+++ b/crates/core/src/database/posts.rs
@@ -1620,7 +1620,14 @@ impl DataManager {
 
             // create notification for question owner
             // (if the current user isn't the owner)
-            if (question.owner != data.owner) && (question.owner != 0) {
+            if (question.owner != data.owner)
+                && (question.owner != 0)
+                && (!owner.settings.private_profile
+                    | self
+                        .get_userfollow_by_initiator_receiver(data.owner, question.owner)
+                        .await
+                        .is_ok())
+            {
                 self.create_notification(Notification::new(
                     "Your question has received a new answer!".to_string(),
                     format!(
diff --git a/crates/core/src/model/journals.rs b/crates/core/src/model/journals.rs
new file mode 100644
index 0000000..9b33bcc
--- /dev/null
+++ b/crates/core/src/model/journals.rs
@@ -0,0 +1,69 @@
+use serde::{Serialize, Deserialize};
+use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp};
+
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
+pub enum JournalViewPermission {
+    /// Can be accessed by anyone via link.
+    Public,
+    /// Visible only to the journal owner.
+    Private,
+}
+
+impl Default for JournalViewPermission {
+    fn default() -> Self {
+        Self::Private
+    }
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct Journal {
+    pub id: usize,
+    pub created: usize,
+    pub owner: usize,
+    pub title: String,
+    pub view: JournalViewPermission,
+}
+
+impl Journal {
+    /// Create a new [`Journal`].
+    pub fn new(owner: usize, title: String) -> Self {
+        Self {
+            id: Snowflake::new().to_string().parse::<usize>().unwrap(),
+            created: unix_epoch_timestamp(),
+            owner,
+            title,
+            view: JournalViewPermission::default(),
+        }
+    }
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct Note {
+    pub id: usize,
+    pub created: usize,
+    pub owner: usize,
+    pub title: String,
+    /// The ID of the [`Journal`] this note belongs to.
+    ///
+    /// The note is subject to the settings set for the journal it's in.
+    pub journal: usize,
+    pub content: String,
+    pub edited: usize,
+}
+
+impl Note {
+    /// Create a new [`Note`].
+    pub fn new(owner: usize, title: String, journal: usize, content: String) -> Self {
+        let created = unix_epoch_timestamp();
+
+        Self {
+            id: Snowflake::new().to_string().parse::<usize>().unwrap(),
+            created,
+            owner,
+            title,
+            journal,
+            content,
+            edited: created,
+        }
+    }
+}
diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs
index 8beb286..c50ea7c 100644
--- a/crates/core/src/model/mod.rs
+++ b/crates/core/src/model/mod.rs
@@ -4,6 +4,7 @@ pub mod auth;
 pub mod channels;
 pub mod communities;
 pub mod communities_permissions;
+pub mod journals;
 pub mod moderation;
 pub mod oauth;
 pub mod permissions;
diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs
index c0c3542..9cd6dcb 100644
--- a/crates/core/src/model/permissions.rs
+++ b/crates/core/src/model/permissions.rs
@@ -37,6 +37,8 @@ bitflags! {
         const MANAGE_STACKS = 1 << 26;
         const STAFF_BADGE = 1 << 27;
         const MANAGE_APPS = 1 << 28;
+        const MANAGE_JOURNALS = 1 << 29;
+        const MANAGE_NOTES = 1 << 30;
 
         const _ = !0;
     }