add: journals/notes database interfaces

This commit is contained in:
trisua 2025-06-18 19:21:01 -04:00
parent 0f48a46c40
commit 102ea0ee35
14 changed files with 386 additions and 6 deletions

View file

@ -112,7 +112,19 @@
("class" "button") ("class" "button")
("data-turbo" "false") ("data-turbo" "false")
(icon (text "rabbit")) (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 "{%- endif %}")))
(text "{%- endmacro %}") (text "{%- endmacro %}")

View file

@ -585,7 +585,9 @@
(li (li
(text "Add unlimited users to stacks")) (text "Add unlimited users to stacks"))
(li (li
(text "Increased proxied image size"))) (text "Increased proxied image size"))
(li
(text "Create infinite journals")))
(a (a
("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
("class" "button") ("class" "button")

View file

@ -25,7 +25,7 @@
globalThis.ns_config = { globalThis.ns_config = {
root: \"/js/\", root: \"/js/\",
verbose: globalThis.ns_verbose, verbose: globalThis.ns_verbose,
version: \"cache-breaker-{{ random_cache_breaker }}\", version: \"tetratto-{{ random_cache_breaker }}\",
}; };
globalThis._app_base = { globalThis._app_base = {
@ -38,8 +38,8 @@
globalThis.BUILD_CODE = \"{{ random_cache_breaker }}\"; globalThis.BUILD_CODE = \"{{ random_cache_breaker }}\";
</script>") </script>")
(script ("src" "/js/loader.js" )) (script ("src" "/js/loader.js?v=tetratto-{{ random_cache_breaker }}" ))
(script ("src" "/js/atto.js" )) (script ("src" "/js/atto.js?v=tetratto-{{ random_cache_breaker }}" ))
(meta ("name" "theme-color") ("content" "{{ config.color }}")) (meta ("name" "theme-color") ("content" "{{ config.color }}"))
(meta ("name" "description") ("content" "{{ config.description }}")) (meta ("name" "description") ("content" "{{ config.description }}"))

View file

@ -36,6 +36,8 @@ impl DataManager {
execute!(&conn, common::CREATE_TABLE_POLLVOTES).unwrap(); execute!(&conn, common::CREATE_TABLE_POLLVOTES).unwrap();
execute!(&conn, common::CREATE_TABLE_APPS).unwrap(); execute!(&conn, common::CREATE_TABLE_APPS).unwrap();
execute!(&conn, common::CREATE_TABLE_STACKBLOCKS).unwrap(); execute!(&conn, common::CREATE_TABLE_STACKBLOCKS).unwrap();
execute!(&conn, common::CREATE_TABLE_JOURNALS).unwrap();
execute!(&conn, common::CREATE_TABLE_NOTES).unwrap();
self.0 self.0
.1 .1

View file

@ -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_POLLVOTES: &str = include_str!("./sql/create_pollvotes.sql");
pub const CREATE_TABLE_APPS: &str = include_str!("./sql/create_apps.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_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");

View file

@ -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
)

View file

@ -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
)

View file

@ -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:{}");
}

View file

@ -10,8 +10,10 @@ mod drivers;
mod emojis; mod emojis;
mod ipbans; mod ipbans;
mod ipblocks; mod ipblocks;
mod journals;
mod memberships; mod memberships;
mod messages; mod messages;
mod notes;
mod notifications; mod notifications;
mod polls; mod polls;
mod pollvotes; mod pollvotes;

View file

@ -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:{}");
}

View file

@ -1620,7 +1620,14 @@ impl DataManager {
// create notification for question owner // create notification for question owner
// (if the current user isn't the 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( self.create_notification(Notification::new(
"Your question has received a new answer!".to_string(), "Your question has received a new answer!".to_string(),
format!( format!(

View file

@ -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,
}
}
}

View file

@ -4,6 +4,7 @@ pub mod auth;
pub mod channels; pub mod channels;
pub mod communities; pub mod communities;
pub mod communities_permissions; pub mod communities_permissions;
pub mod journals;
pub mod moderation; pub mod moderation;
pub mod oauth; pub mod oauth;
pub mod permissions; pub mod permissions;

View file

@ -37,6 +37,8 @@ bitflags! {
const MANAGE_STACKS = 1 << 26; const MANAGE_STACKS = 1 << 26;
const STAFF_BADGE = 1 << 27; const STAFF_BADGE = 1 << 27;
const MANAGE_APPS = 1 << 28; const MANAGE_APPS = 1 << 28;
const MANAGE_JOURNALS = 1 << 29;
const MANAGE_NOTES = 1 << 30;
const _ = !0; const _ = !0;
} }