diff --git a/crates/app/src/public/html/auth/login.html b/crates/app/src/public/html/auth/login.html index ed178a9..0aea70a 100644 --- a/crates/app/src/public/html/auth/login.html +++ b/crates/app/src/public/html/auth/login.html @@ -43,7 +43,7 @@ .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ - res.ok ? "sucesss" : "error", + res.ok ? "success" : "error", res.message, ]); diff --git a/crates/app/src/public/html/auth/register.html b/crates/app/src/public/html/auth/register.html index 8349d18..cf9ac54 100644 --- a/crates/app/src/public/html/auth/register.html +++ b/crates/app/src/public/html/auth/register.html @@ -43,7 +43,7 @@ .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ - res.ok ? "sucesss" : "error", + res.ok ? "success" : "error", res.message, ]); diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index b617364..1e644ef 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -16,7 +16,7 @@ .then((res) => res.json()) .then((res) => { trigger("atto::toast", [ - res.ok ? "sucesss" : "error", + res.ok ? "success" : "error", res.message, ]); diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index cc64af5..a980caa 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -1,17 +1,23 @@ pub mod auth; pub mod journal; +pub mod reactions; use axum::{ Router, routing::{delete, get, post}, }; use serde::Deserialize; -use tetratto_core::model::journal::{ - JournalEntryContext, JournalPageReadAccess, JournalPageWriteAccess, +use tetratto_core::model::{ + journal::{JournalEntryContext, JournalPageReadAccess, JournalPageWriteAccess}, + reactions::AssetType, }; pub fn routes() -> Router { Router::new() + // reactions + .route("/reactions", post(reactions::create_request)) + .route("/reactions/{id}", get(reactions::get_request)) + .route("/reactions/{id}", delete(reactions::delete_request)) // journal pages .route("/pages", post(journal::pages::create_request)) .route("/pages/{id}", delete(journal::pages::delete_request)) @@ -113,3 +119,9 @@ pub struct UpdateJournalEntryContent { pub struct UpdateJournalEntryContext { pub context: JournalEntryContext, } + +#[derive(Deserialize)] +pub struct CreateReaction { + pub asset: usize, + pub asset_type: AssetType, +} diff --git a/crates/app/src/routes/api/v1/reactions.rs b/crates/app/src/routes/api/v1/reactions.rs new file mode 100644 index 0000000..9599ef8 --- /dev/null +++ b/crates/app/src/routes/api/v1/reactions.rs @@ -0,0 +1,76 @@ +use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{ApiReturn, Error, reactions::Reaction}; + +use crate::{State, get_user_from_token, routes::api::v1::CreateReaction}; + +pub async fn get_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_reaction_by_owner_asset(user.id, id).await { + Ok(r) => Json(ApiReturn { + ok: true, + message: "Reaction exists".to_string(), + payload: Some(r), + }), + Err(e) => return Json(e.into()), + } +} + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_reaction(Reaction::new(user.id, req.asset, req.asset_type)) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Reaction created".to_string(), + payload: (), + }), + Err(e) => return Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let reaction = match data.get_reaction_by_owner_asset(user.id, id).await { + Ok(r) => r, + Err(e) => return Json(e.into()), + }; + + match data.delete_reaction(reaction.id, user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Reaction deleted".to_string(), + payload: (), + }), + Err(e) => return Json(e.into()), + } +} diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index ed4ae44..100bf22 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -17,6 +17,7 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_PAGES, []).unwrap(); execute!(&conn, common::CREATE_TABLE_ENTRIES, []).unwrap(); execute!(&conn, common::CREATE_TABLE_MEMBERSHIPS, []).unwrap(); + execute!(&conn, common::CREATE_TABLE_REACTIONS, []).unwrap(); Ok(()) } @@ -110,9 +111,9 @@ macro_rules! auto_method { ($name:ident()@$select_fn:ident:$permission:ident -> $query:literal) => { pub async fn $name(&self, id: usize, user: User) -> Result<()> { - let page = self.$select_fn(id).await?; + let y = self.$select_fn(id).await?; - if user.id != page.owner { + if user.id != y.owner { if !user.permissions.check(FinePermission::$permission) { return Err(Error::NotAllowed); } @@ -135,9 +136,9 @@ macro_rules! auto_method { ($name:ident()@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => { pub async fn $name(&self, id: usize, user: User) -> Result<()> { - let page = self.$select_fn(id).await?; + let y = self.$select_fn(id).await?; - if user.id != page.owner { + if user.id != y.owner { if !user.permissions.check(FinePermission::$permission) { return Err(Error::NotAllowed); } @@ -351,4 +352,44 @@ macro_rules! auto_method { Ok(()) } }; + + ($name:ident() -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal --reactions-key-tmpl=$reactions_key_tmpl:literal --incr) => { + pub async fn $name(&self, id: usize) -> Result<()> { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, $query, &[&id.to_string()]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.2.remove(format!($cache_key_tmpl, id)).await; + self.2.remove(format!($reactions_key_tmpl, id)).await; + + Ok(()) + } + }; + + ($name:ident() -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal --reactions-key-tmpl=$reactions_key_tmpl:literal --decr) => { + pub async fn $name(&self, id: usize) -> Result<()> { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, $query, &[&id.to_string()]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.2.remove(format!($cache_key_tmpl, id)).await; + self.2.remove(format!($reactions_key_tmpl, id)).await; + + Ok(()) + } + }; } diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index e67039f..d638b5d 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -2,3 +2,4 @@ pub const CREATE_TABLE_USERS: &str = include_str!("./sql/create_users.sql"); 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_MEMBERSHIPS: &str = include_str!("./sql/create_memberships.sql"); +pub const CREATE_TABLE_REACTIONS: &str = include_str!("./sql/create_reactions.sql"); diff --git a/crates/core/src/database/drivers/sql/create_entries.sql b/crates/core/src/database/drivers/sql/create_entries.sql index 2d2968a..1ea21d2 100644 --- a/crates/core/src/database/drivers/sql/create_entries.sql +++ b/crates/core/src/database/drivers/sql/create_entries.sql @@ -4,5 +4,8 @@ CREATE TABLE IF NOT EXISTS entries ( content TEXT NOT NULL, owner INTEGER NOT NULL, journal INTEGER NOT NULL, - context TEXT NOT NULL + context TEXT NOT NULL, + -- likes + likes INTEGER NOT NULL, + dislikes INTEGER NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/create_pages.sql b/crates/core/src/database/drivers/sql/create_pages.sql index b025223..2d4375e 100644 --- a/crates/core/src/database/drivers/sql/create_pages.sql +++ b/crates/core/src/database/drivers/sql/create_pages.sql @@ -5,5 +5,8 @@ CREATE TABLE IF NOT EXISTS pages ( prompt TEXT NOT NULL, owner INTEGER NOT NULL, read_access TEXT NOT NULL, - write_access TEXT NOT NULL + write_access TEXT NOT NULL, + -- likes + likes INTEGER NOT NULL, + dislikes INTEGER NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/create_reactions.sql b/crates/core/src/database/drivers/sql/create_reactions.sql new file mode 100644 index 0000000..4b4b004 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_reactions.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS reactions ( + id INTEGER NOT NULL PRIMARY KEY, + created INTEGER NOT NULL, + owner INTEGER NOT NULL, + asset INTEGER NOT NULL, + asset_type TEXT NOT NULL +) diff --git a/crates/core/src/database/entries.rs b/crates/core/src/database/entries.rs index 527ad3d..f08bd33 100644 --- a/crates/core/src/database/entries.rs +++ b/crates/core/src/database/entries.rs @@ -26,6 +26,9 @@ impl DataManager { owner: get!(x->3(u64)) as usize, journal: get!(x->4(u64)) as usize, context: serde_json::from_str(&get!(x->5(String))).unwrap(), + // likes + likes: get!(x->6(i64)) as isize, + dislikes: get!(x->7(i64)) as isize, } } @@ -95,4 +98,9 @@ impl DataManager { auto_method!(delete_entry()@get_entry_by_id:MANAGE_JOURNAL_ENTRIES -> "DELETE FROM entries WHERE id = $1" --cache-key-tmpl="atto.entry:{}"); auto_method!(update_entry_content(String)@get_entry_by_id:MANAGE_JOURNAL_ENTRIES -> "UPDATE entries SET content = $1 WHERE id = $2" --cache-key-tmpl="atto.entry:{}"); auto_method!(update_entry_context(JournalEntryContext)@get_entry_by_id:MANAGE_JOURNAL_ENTRIES -> "UPDATE entries SET context = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.entry:{}"); + + auto_method!(incr_entry_likes() -> "UPDATE entries SET likes = likes + 1 WHERE id = $1" --cache-key-tmpl="atto.entry:{}" --reactions-key-tmpl="atto.entry.likes:{}" --incr); + auto_method!(incr_entry_dislikes() -> "UPDATE entries SET likes = dislikes + 1 WHERE id = $1" --cache-key-tmpl="atto.entry:{}" --reactions-key-tmpl="atto.entry.dislikes:{}" --incr); + auto_method!(decr_entry_likes() -> "UPDATE entries SET likes = likes - 1 WHERE id = $1" --cache-key-tmpl="atto.entry:{}" --reactions-key-tmpl="atto.entry.likes:{}" --decr); + auto_method!(decr_entry_dislikes() -> "UPDATE entries SET likes = dislikes - 1 WHERE id = $1" --cache-key-tmpl="atto.entry:{}" --reactions-key-tmpl="atto.entry.dislikes:{}" --decr); } diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 4028982..dc02fcd 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -4,6 +4,7 @@ mod drivers; mod entries; mod memberships; mod pages; +mod reactions; #[cfg(feature = "sqlite")] pub use drivers::sqlite::*; diff --git a/crates/core/src/database/pages.rs b/crates/core/src/database/pages.rs index d5d3752..8748444 100644 --- a/crates/core/src/database/pages.rs +++ b/crates/core/src/database/pages.rs @@ -31,6 +31,9 @@ impl DataManager { owner: get!(x->4(u64)) as usize, 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(), + // likes + likes: get!(x->6(i64)) as isize, + dislikes: get!(x->7(i64)) as isize, } } @@ -96,4 +99,9 @@ impl DataManager { auto_method!(update_page_prompt(String)@get_page_by_id:MANAGE_JOURNAL_PAGES -> "UPDATE pages SET prompt = $1 WHERE id = $2" --cache-key-tmpl="atto.page:{}"); auto_method!(update_page_read_access(JournalPageReadAccess)@get_page_by_id:MANAGE_JOURNAL_PAGES -> "UPDATE pages SET read_access = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.page:{}"); auto_method!(update_page_write_access(JournalPageWriteAccess)@get_page_by_id:MANAGE_JOURNAL_PAGES -> "UPDATE pages SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.page:{}"); + + auto_method!(incr_page_likes() -> "UPDATE pages SET likes = likes + 1 WHERE id = $1" --cache-key-tmpl="atto.pages:{}" --reactions-key-tmpl="atto.entry.likes:{}" --incr); + auto_method!(incr_page_dislikes() -> "UPDATE pages SET likes = dislikes + 1 WHERE id = $1" --cache-key-tmpl="atto.pages:{}" --reactions-key-tmpl="atto.entry.dislikes:{}" --incr); + auto_method!(decr_page_likes() -> "UPDATE pages SET likes = likes - 1 WHERE id = $1" --cache-key-tmpl="atto.pages:{}" --reactions-key-tmpl="atto.entry.likes:{}" --decr); + auto_method!(decr_page_dislikes() -> "UPDATE pages SET likes = dislikes - 1 WHERE id = $1" --cache-key-tmpl="atto.pages:{}" --reactions-key-tmpl="atto.entry.dislikes:{}" --decr); } diff --git a/crates/core/src/database/reactions.rs b/crates/core/src/database/reactions.rs new file mode 100644 index 0000000..0345ce9 --- /dev/null +++ b/crates/core/src/database/reactions.rs @@ -0,0 +1,142 @@ +use super::*; +use crate::cache::Cache; +use crate::model::reactions::AssetType; +use crate::model::{Error, Result, auth::User, permissions::FinePermission, reactions::Reaction}; +use crate::{auto_method, execute, get, query_row}; + +#[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_reaction_from_row( + #[cfg(feature = "sqlite")] x: &Row<'_>, + #[cfg(feature = "postgres")] x: &Row, + ) -> Reaction { + Reaction { + id: get!(x->0(u64)) as usize, + created: get!(x->1(u64)) as usize, + owner: get!(x->2(u64)) as usize, + asset: get!(x->3(u64)) as usize, + asset_type: serde_json::from_str(&get!(x->4(String))).unwrap(), + } + } + + auto_method!(get_reaction_by_id()@get_reaction_from_row -> "SELECT * FROM reactions WHERE id = $1" --name="reaction" --returns=Reaction --cache-key-tmpl="atto.reaction:{}"); + + /// Get a reaction by `owner` and `asset`. + pub async fn get_reaction_by_owner_asset( + &self, + owner: usize, + asset: usize, + ) -> Result { + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_row!( + &conn, + "SELECT * FROM reactions WHERE owner = $1 AND asset = $2", + &[&owner, &asset], + |x| { Ok(Self::get_reaction_from_row(x)) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("reactions".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new journal page membership in the database. + /// + /// # Arguments + /// * `data` - a mock [`Reaction`] object to insert + pub async fn create_reaction(&self, data: Reaction) -> 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.owner.to_string().as_str(), + &data.asset.to_string().as_str(), + &serde_json::to_string(&data.asset_type).unwrap().as_str(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // incr corresponding + match data.asset_type { + AssetType::JournalPage => { + if let Err(e) = self.incr_page_likes(data.id).await { + return Err(e); + } + } + AssetType::JournalEntry => { + if let Err(e) = self.incr_entry_likes(data.id).await { + return Err(e); + } + } + }; + + // return + Ok(()) + } + + pub async fn delete_reaction(&self, id: usize, user: User) -> Result<()> { + let reaction = self.get_reaction_by_id(id).await?; + + if user.id != reaction.owner { + if !user.permissions.check(FinePermission::MANAGE_REACTIONS) { + 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 reactions WHERE id = $1", + &[&id.to_string()] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.2.remove(format!("atto.reaction:{}", id)).await; + + // decr corresponding + match reaction.asset_type { + AssetType::JournalPage => { + if let Err(e) = self.decr_page_likes(reaction.asset).await { + return Err(e); + } + } + AssetType::JournalEntry => { + if let Err(e) = self.decr_entry_likes(reaction.asset).await { + return Err(e); + } + } + }; + + // return + Ok(()) + } +} diff --git a/crates/core/src/model/journal.rs b/crates/core/src/model/journal.rs index 1d96dd3..2ba5c36 100644 --- a/crates/core/src/model/journal.rs +++ b/crates/core/src/model/journal.rs @@ -18,6 +18,8 @@ pub struct JournalPage { /// The owner of the journal page (and moderators) are the ***only*** people /// capable of removing entries. pub write_access: JournalPageWriteAccess, + pub likes: isize, + pub dislikes: isize, } impl JournalPage { @@ -34,6 +36,8 @@ impl JournalPage { owner, read_access: JournalPageReadAccess::default(), write_access: JournalPageWriteAccess::default(), + likes: 0, + dislikes: 0, } } } @@ -124,6 +128,8 @@ pub struct JournalEntry { pub journal: usize, /// Extra information about the journal entry. pub context: JournalEntryContext, + pub likes: isize, + pub dislikes: isize, } impl JournalEntry { @@ -139,6 +145,8 @@ impl JournalEntry { owner, journal, context: JournalEntryContext::default(), + likes: 0, + dislikes: 0, } } } diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index e80eb71..e7b1fce 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -2,6 +2,7 @@ pub mod auth; pub mod journal; pub mod journal_permissions; pub mod permissions; +pub mod reactions; use serde::{Deserialize, Serialize}; diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index 629289d..527be07 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -20,6 +20,7 @@ bitflags! { const VIEW_REPORTS = 1 << 9; const VIEW_AUDIT_LOG = 1 << 10; const MANAGE_MEMBERSHIPS = 1 << 11; + const MANAGE_REACTIONS = 1 << 12; const _ = !0; } diff --git a/crates/core/src/model/reactions.rs b/crates/core/src/model/reactions.rs new file mode 100644 index 0000000..88b8b4a --- /dev/null +++ b/crates/core/src/model/reactions.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; +use tetratto_shared::{snow::AlmostSnowflake, unix_epoch_timestamp}; + +/// All of the items which support reactions. +#[derive(Serialize, Deserialize)] +pub enum AssetType { + JournalPage, + JournalEntry, +} + +#[derive(Serialize, Deserialize)] +pub struct Reaction { + pub id: usize, + pub created: usize, + pub owner: usize, + pub asset: usize, + pub asset_type: AssetType, +} + +impl Reaction { + /// Create a new [`Reaction`]. + pub fn new(owner: usize, asset: usize, asset_type: AssetType) -> Self { + Self { + id: AlmostSnowflake::new(1234567890) + .to_string() + .parse::() + .unwrap(), + created: unix_epoch_timestamp() as usize, + owner, + asset, + asset_type, + } + } +}