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