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")
("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 %}")

View file

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

View file

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

View file

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

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_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");

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 ipbans;
mod ipblocks;
mod journals;
mod memberships;
mod messages;
mod notes;
mod notifications;
mod polls;
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
// (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!(

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 communities;
pub mod communities_permissions;
pub mod journals;
pub mod moderation;
pub mod oauth;
pub mod permissions;

View file

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