add: notifications table

add: query_rows macro
fix: postgres driver
This commit is contained in:
trisua 2025-03-25 18:18:33 -04:00
parent 0ea6b25138
commit 81005a6e1c
14 changed files with 258 additions and 33 deletions

View file

@ -21,8 +21,8 @@ impl DataManager {
#[cfg(feature = "postgres")] x: &Row, #[cfg(feature = "postgres")] x: &Row,
) -> User { ) -> User {
User { User {
id: get!(x->0(u64)) as usize, id: get!(x->0(i64)) as usize,
created: get!(x->1(u64)) as usize, created: get!(x->1(i64)) as usize,
username: get!(x->2(String)), username: get!(x->2(String)),
password: get!(x->3(String)), password: get!(x->3(String)),
salt: get!(x->4(String)), salt: get!(x->4(String)),
@ -87,7 +87,7 @@ impl DataManager {
let res = execute!( let res = execute!(
&conn, &conn,
"INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
&[ &[
&data.id.to_string().as_str(), &data.id.to_string().as_str(),
&data.created.to_string().as_str(), &data.created.to_string().as_str(),
@ -96,7 +96,8 @@ impl DataManager {
&data.salt.as_str(), &data.salt.as_str(),
&serde_json::to_string(&data.settings).unwrap().as_str(), &serde_json::to_string(&data.settings).unwrap().as_str(),
&serde_json::to_string(&data.tokens).unwrap().as_str(), &serde_json::to_string(&data.tokens).unwrap().as_str(),
&(FinePermission::DEFAULT.bits()).to_string().as_str() &(FinePermission::DEFAULT.bits()).to_string().as_str(),
&0.to_string().as_str()
] ]
); );
@ -138,4 +139,7 @@ impl DataManager {
} }
auto_method!(update_user_tokens(Vec<Token>) -> "UPDATE users SET tokens = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.user:{}"); auto_method!(update_user_tokens(Vec<Token>) -> "UPDATE users SET tokens = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.user:{}");
auto_method!(incr_user_notifications() -> "UPDATE users SET notification_count = notification_count + 1 WHERE id = $1" --cache-key-tmpl="atto.user:{}" --reactions-key-tmpl="atto.user.notification_count:{}" --incr);
auto_method!(decr_user_notifications() -> "UPDATE users SET notification_count = notification_count - 1 WHERE id = $1" --cache-key-tmpl="atto.user:{}" --reactions-key-tmpl="atto.user.notification_count:{}" --decr);
} }

View file

@ -13,11 +13,12 @@ impl DataManager {
Err(e) => return Err(Error::DatabaseConnection(e.to_string())), Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
}; };
execute!(&conn, common::CREATE_TABLE_USERS, []).unwrap(); execute!(&conn, common::CREATE_TABLE_USERS).unwrap();
execute!(&conn, common::CREATE_TABLE_PAGES, []).unwrap(); execute!(&conn, common::CREATE_TABLE_PAGES).unwrap();
execute!(&conn, common::CREATE_TABLE_ENTRIES, []).unwrap(); execute!(&conn, common::CREATE_TABLE_ENTRIES).unwrap();
execute!(&conn, common::CREATE_TABLE_MEMBERSHIPS, []).unwrap(); execute!(&conn, common::CREATE_TABLE_MEMBERSHIPS).unwrap();
execute!(&conn, common::CREATE_TABLE_REACTIONS, []).unwrap(); execute!(&conn, common::CREATE_TABLE_REACTIONS).unwrap();
execute!(&conn, common::CREATE_TABLE_NOTIFICATIONS).unwrap();
Ok(()) Ok(())
} }
@ -32,7 +33,9 @@ macro_rules! auto_method {
Err(e) => return Err(Error::DatabaseConnection(e.to_string())), Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
}; };
let res = query_row!(&conn, $query, &[&id], |x| { Ok(Self::$select_fn(x)) }); let res = query_row!(&conn, $query, &[&(id as i64)], |x| {
Ok(Self::$select_fn(x))
});
if res.is_err() { if res.is_err() {
return Err(Error::GeneralNotFound($name_.to_string())); return Err(Error::GeneralNotFound($name_.to_string()));
@ -49,7 +52,9 @@ macro_rules! auto_method {
Err(e) => return Err(Error::DatabaseConnection(e.to_string())), Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
}; };
let res = query_row!(&conn, $query, &[&id], |x| { Ok(Self::$select_fn(x)) }); let res = query_row!(&conn, $query, &[&(id as i64)], |x| {
Ok(Self::$select_fn(x))
});
if res.is_err() { if res.is_err() {
return Err(Error::GeneralNotFound($name_.to_string())); return Err(Error::GeneralNotFound($name_.to_string()));

View file

@ -3,3 +3,4 @@ pub const CREATE_TABLE_PAGES: &str = include_str!("./sql/create_pages.sql");
pub const CREATE_TABLE_ENTRIES: &str = include_str!("./sql/create_entries.sql"); pub const CREATE_TABLE_ENTRIES: &str = include_str!("./sql/create_entries.sql");
pub const CREATE_TABLE_MEMBERSHIPS: &str = include_str!("./sql/create_memberships.sql"); pub const CREATE_TABLE_MEMBERSHIPS: &str = include_str!("./sql/create_memberships.sql");
pub const CREATE_TABLE_REACTIONS: &str = include_str!("./sql/create_reactions.sql"); pub const CREATE_TABLE_REACTIONS: &str = include_str!("./sql/create_reactions.sql");
pub const CREATE_TABLE_NOTIFICATIONS: &str = include_str!("./sql/create_notifications.sql");

View file

@ -10,6 +10,7 @@ use bb8_postgres::{
PostgresConnectionManager, PostgresConnectionManager,
bb8::{Pool, PooledConnection}, bb8::{Pool, PooledConnection},
}; };
use std::collections::HashMap;
use tetratto_l10n::{LangFile, read_langs}; use tetratto_l10n::{LangFile, read_langs};
use tokio_postgres::{Config as PgConfig, NoTls, Row, types::ToSql}; use tokio_postgres::{Config as PgConfig, NoTls, Row, types::ToSql};
@ -91,6 +92,38 @@ macro_rules! query_row {
}; };
} }
pub async fn query_rows_helper<T, F>(
conn: &Connection<'_>,
sql: &str,
params: &[&(dyn ToSql + Sync)],
mut f: F,
) -> Result<Vec<T>>
where
F: FnMut(&Row) -> T,
{
let query = conn.prepare(sql).await.unwrap();
let res = conn.query(&query, params).await;
if let Ok(rows) = res {
let mut out = Vec::new();
for row in rows {
out.push(f(&row));
}
return Ok(out);
} else {
Err(res.unwrap_err())
}
}
#[macro_export]
macro_rules! query_rows {
($conn:expr, $sql:expr, $params:expr, $f:expr) => {
crate::database::query_rows_helper($conn, $sql, $params, $f).await
};
}
pub async fn execute_helper( pub async fn execute_helper(
conn: &Connection<'_>, conn: &Connection<'_>,
sql: &str, sql: &str,
@ -106,4 +139,8 @@ macro_rules! execute {
($conn:expr, $sql:expr, $params:expr) => { ($conn:expr, $sql:expr, $params:expr) => {
crate::database::execute_helper($conn, $sql, $params).await crate::database::execute_helper($conn, $sql, $params).await
}; };
($conn:expr, $sql:expr) => {
crate::database::execute_helper($conn, $sql, &[]).await
};
} }

View file

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER NOT NULL PRIMARY KEY,
created INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
owner INTEGER NOT NULL
)

View file

@ -6,5 +6,7 @@ CREATE TABLE IF NOT EXISTS users (
salt TEXT NOT NULL, salt TEXT NOT NULL,
settings TEXT NOT NULL, settings TEXT NOT NULL,
tokens TEXT NOT NULL, tokens TEXT NOT NULL,
permissions INTEGER NOT NULL permissions INTEGER NOT NULL,
-- counts
notification_count INTEGER NOT NULL
) )

View file

@ -42,7 +42,6 @@ impl DataManager {
} }
} }
#[cfg(feature = "sqlite")]
#[macro_export] #[macro_export]
macro_rules! get { macro_rules! get {
($row:ident->$idx:literal($t:tt)) => { ($row:ident->$idx:literal($t:tt)) => {
@ -58,9 +57,32 @@ macro_rules! query_row {
}}; }};
} }
#[macro_export]
macro_rules! query_rows {
($conn:expr, $sql:expr, $params:expr, $f:expr) => {{
let mut query = $conn.prepare($sql).unwrap();
if let Ok(mut rows) = query.query($params) {
let mut out = Vec::new();
while let Some(row) = rows.next().unwrap() {
out.push($f(&row));
}
Ok(out)
} else {
Err(Error::Unknown)
}
}};
}
#[macro_export] #[macro_export]
macro_rules! execute { macro_rules! execute {
($conn:expr, $sql:expr, $params:expr) => { ($conn:expr, $sql:expr, $params:expr) => {
$conn.prepare($sql).unwrap().execute($params) $conn.prepare($sql).unwrap().execute($params)
}; };
($conn:expr, $sql:expr) => {
$conn.prepare($sql).unwrap().execute(())
};
} }

View file

@ -20,11 +20,11 @@ impl DataManager {
#[cfg(feature = "postgres")] x: &Row, #[cfg(feature = "postgres")] x: &Row,
) -> JournalEntry { ) -> JournalEntry {
JournalEntry { JournalEntry {
id: get!(x->0(u64)) as usize, id: get!(x->0(i64)) as usize,
created: get!(x->1(u64)) as usize, created: get!(x->1(i64)) as usize,
content: get!(x->2(String)), content: get!(x->2(String)),
owner: get!(x->3(u64)) as usize, owner: get!(x->3(i64)) as usize,
journal: get!(x->4(u64)) as usize, journal: get!(x->4(i64)) as usize,
context: serde_json::from_str(&get!(x->5(String))).unwrap(), context: serde_json::from_str(&get!(x->5(String))).unwrap(),
// likes // likes
likes: get!(x->6(i64)) as isize, likes: get!(x->6(i64)) as isize,

View file

@ -19,10 +19,10 @@ impl DataManager {
#[cfg(feature = "postgres")] x: &Row, #[cfg(feature = "postgres")] x: &Row,
) -> JournalPageMembership { ) -> JournalPageMembership {
JournalPageMembership { JournalPageMembership {
id: get!(x->0(u64)) as usize, id: get!(x->0(i64)) as usize,
created: get!(x->1(u64)) as usize, created: get!(x->1(i64)) as usize,
owner: get!(x->2(u64)) as usize, owner: get!(x->2(i64)) as usize,
journal: get!(x->3(u64)) as usize, journal: get!(x->3(i64)) as usize,
role: JournalPermission::from_bits(get!(x->4(u32))).unwrap(), role: JournalPermission::from_bits(get!(x->4(u32))).unwrap(),
} }
} }
@ -43,7 +43,7 @@ impl DataManager {
let res = query_row!( let res = query_row!(
&conn, &conn,
"SELECT * FROM memberships WHERE owner = $1 AND journal = $2", "SELECT * FROM memberships WHERE owner = $1 AND journal = $2",
&[&owner, &journal], &[&(owner as i64), &(journal as i64)],
|x| { Ok(Self::get_membership_from_row(x)) } |x| { Ok(Self::get_membership_from_row(x)) }
); );

View file

@ -3,6 +3,7 @@ mod common;
mod drivers; mod drivers;
mod entries; mod entries;
mod memberships; mod memberships;
mod notifications;
mod pages; mod pages;
mod reactions; mod reactions;

View file

@ -0,0 +1,117 @@
use super::*;
use crate::cache::Cache;
use crate::model::{Error, Result, auth::Notification, auth::User, permissions::FinePermission};
use crate::{auto_method, execute, get, query_row, query_rows};
#[cfg(feature = "sqlite")]
use rusqlite::Row;
#[cfg(feature = "postgres")]
use tokio_postgres::Row;
impl DataManager {
/// Get a [`Reaction`] from an SQL row.
pub(crate) fn get_notification_from_row(
#[cfg(feature = "sqlite")] x: &Row<'_>,
#[cfg(feature = "postgres")] x: &Row,
) -> Notification {
Notification {
id: get!(x->0(i64)) as usize,
created: get!(x->1(i64)) as usize,
title: get!(x->2(String)),
content: get!(x->3(String)),
owner: get!(x->4(i64)) as usize,
}
}
auto_method!(get_notification_by_id()@get_notification_from_row -> "SELECT * FROM notifications WHERE id = $1" --name="notification" --returns=Notification --cache-key-tmpl="atto.notification:{}");
/// Get a reaction by `owner` and `asset`.
pub async fn get_notifications_by_owner(&self, owner: usize) -> Result<Vec<Notification>> {
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 notifications WHERE owner = $1",
&[&(owner as i64)],
|x| { Self::get_notification_from_row(x) }
);
if res.is_err() {
return Err(Error::GeneralNotFound("reactions".to_string()));
}
Ok(res.unwrap())
}
/// Create a new notification in the database.
///
/// # Arguments
/// * `data` - a mock [`Reaction`] object to insert
pub async fn create_notification(&self, data: Notification) -> Result<()> {
let conn = match self.connect().await {
Ok(c) => c,
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
};
let res = execute!(
&conn,
"INSERT INTO reactions VALUES ($1, $2, $3, $4, $5)",
&[
&data.id.to_string().as_str(),
&data.created.to_string().as_str(),
&data.title.to_string().as_str(),
&data.content.to_string().as_str(),
&data.owner.to_string().as_str()
]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
// incr notification count
self.incr_user_notifications(data.owner).await.unwrap();
// return
Ok(())
}
pub async fn delete_notification(&self, id: usize, user: User) -> Result<()> {
let notification = self.get_notification_by_id(id).await?;
if user.id != notification.owner {
if !user.permissions.check(FinePermission::MANAGE_NOTIFICATIONS) {
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 notification WHERE id = $1",
&[&id.to_string()]
);
if let Err(e) = res {
return Err(Error::DatabaseError(e.to_string()));
}
self.2.remove(format!("atto.notification:{}", id)).await;
// decr notification count
// self.decr_user_notifications(notification.owner)
// .await
// .unwrap();
// return
Ok(())
}
}

View file

@ -24,11 +24,11 @@ impl DataManager {
#[cfg(feature = "postgres")] x: &Row, #[cfg(feature = "postgres")] x: &Row,
) -> JournalPage { ) -> JournalPage {
JournalPage { JournalPage {
id: get!(x->0(u64)) as usize, id: get!(x->0(i64)) as usize,
created: get!(x->1(u64)) as usize, created: get!(x->1(i64)) as usize,
title: get!(x->2(String)), title: get!(x->2(String)),
prompt: get!(x->3(String)), prompt: get!(x->3(String)),
owner: get!(x->4(u64)) as usize, owner: get!(x->4(i64)) as usize,
read_access: serde_json::from_str(&get!(x->5(String)).to_string()).unwrap(), read_access: serde_json::from_str(&get!(x->5(String)).to_string()).unwrap(),
write_access: serde_json::from_str(&get!(x->6(String)).to_string()).unwrap(), write_access: serde_json::from_str(&get!(x->6(String)).to_string()).unwrap(),
// likes // likes

View file

@ -1,7 +1,11 @@
use super::*; use super::*;
use crate::cache::Cache; use crate::cache::Cache;
use crate::model::reactions::AssetType; use crate::model::{
use crate::model::{Error, Result, auth::User, permissions::FinePermission, reactions::Reaction}; Error, Result,
auth::User,
permissions::FinePermission,
reactions::{AssetType, Reaction},
};
use crate::{auto_method, execute, get, query_row}; use crate::{auto_method, execute, get, query_row};
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
@ -17,12 +21,12 @@ impl DataManager {
#[cfg(feature = "postgres")] x: &Row, #[cfg(feature = "postgres")] x: &Row,
) -> Reaction { ) -> Reaction {
Reaction { Reaction {
id: get!(x->0(u64)) as usize, id: get!(x->0(i64)) as usize,
created: get!(x->1(u64)) as usize, created: get!(x->1(i64)) as usize,
owner: get!(x->2(u64)) as usize, owner: get!(x->2(i64)) as usize,
asset: get!(x->3(u64)) as usize, asset: get!(x->3(i64)) as usize,
asset_type: serde_json::from_str(&get!(x->4(String))).unwrap(), asset_type: serde_json::from_str(&get!(x->4(String))).unwrap(),
is_like: if get!(x->5(u8)) == 1 { true } else { false }, is_like: if get!(x->5(i8)) == 1 { true } else { false },
} }
} }
@ -42,7 +46,7 @@ impl DataManager {
let res = query_row!( let res = query_row!(
&conn, &conn,
"SELECT * FROM reactions WHERE owner = $1 AND asset = $2", "SELECT * FROM reactions WHERE owner = $1 AND asset = $2",
&[&owner, &asset], &[&(owner as i64), &(asset as i64)],
|x| { Ok(Self::get_reaction_from_row(x)) } |x| { Ok(Self::get_reaction_from_row(x)) }
); );

View file

@ -72,3 +72,28 @@ impl User {
self.password == hash_salted(against, self.salt.clone()) self.password == hash_salted(against, self.salt.clone())
} }
} }
#[derive(Debug, Serialize)]
pub struct Notification {
pub id: usize,
pub created: usize,
pub title: String,
pub content: String,
pub owner: usize,
}
impl Notification {
/// Returns a new [`Notification`].
pub fn new(title: String, content: String, owner: usize) -> Self {
Self {
id: AlmostSnowflake::new(1234567890)
.to_string()
.parse::<usize>()
.unwrap(),
created: unix_epoch_timestamp() as usize,
title,
content,
owner,
}
}
}