add: reactions

This commit is contained in:
trisua 2025-03-24 22:42:33 -04:00
parent c46eb3b807
commit 382e3bc7a6
18 changed files with 357 additions and 11 deletions

View file

@ -43,7 +43,7 @@
.then((res) => res.json()) .then((res) => res.json())
.then((res) => { .then((res) => {
trigger("atto::toast", [ trigger("atto::toast", [
res.ok ? "sucesss" : "error", res.ok ? "success" : "error",
res.message, res.message,
]); ]);

View file

@ -43,7 +43,7 @@
.then((res) => res.json()) .then((res) => res.json())
.then((res) => { .then((res) => {
trigger("atto::toast", [ trigger("atto::toast", [
res.ok ? "sucesss" : "error", res.ok ? "success" : "error",
res.message, res.message,
]); ]);

View file

@ -16,7 +16,7 @@
.then((res) => res.json()) .then((res) => res.json())
.then((res) => { .then((res) => {
trigger("atto::toast", [ trigger("atto::toast", [
res.ok ? "sucesss" : "error", res.ok ? "success" : "error",
res.message, res.message,
]); ]);

View file

@ -1,17 +1,23 @@
pub mod auth; pub mod auth;
pub mod journal; pub mod journal;
pub mod reactions;
use axum::{ use axum::{
Router, Router,
routing::{delete, get, post}, routing::{delete, get, post},
}; };
use serde::Deserialize; use serde::Deserialize;
use tetratto_core::model::journal::{ use tetratto_core::model::{
JournalEntryContext, JournalPageReadAccess, JournalPageWriteAccess, journal::{JournalEntryContext, JournalPageReadAccess, JournalPageWriteAccess},
reactions::AssetType,
}; };
pub fn routes() -> Router { pub fn routes() -> Router {
Router::new() 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 // journal pages
.route("/pages", post(journal::pages::create_request)) .route("/pages", post(journal::pages::create_request))
.route("/pages/{id}", delete(journal::pages::delete_request)) .route("/pages/{id}", delete(journal::pages::delete_request))
@ -113,3 +119,9 @@ pub struct UpdateJournalEntryContent {
pub struct UpdateJournalEntryContext { pub struct UpdateJournalEntryContext {
pub context: JournalEntryContext, pub context: JournalEntryContext,
} }
#[derive(Deserialize)]
pub struct CreateReaction {
pub asset: usize,
pub asset_type: AssetType,
}

View file

@ -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<State>,
Path(id): Path<usize>,
) -> 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<State>,
Json(req): Json<CreateReaction>,
) -> 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<State>,
Path(id): Path<usize>,
) -> 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()),
}
}

View file

@ -17,6 +17,7 @@ impl DataManager {
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();
Ok(()) Ok(())
} }
@ -110,9 +111,9 @@ macro_rules! auto_method {
($name:ident()@$select_fn:ident:$permission:ident -> $query:literal) => { ($name:ident()@$select_fn:ident:$permission:ident -> $query:literal) => {
pub async fn $name(&self, id: usize, user: User) -> Result<()> { 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) { if !user.permissions.check(FinePermission::$permission) {
return Err(Error::NotAllowed); 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) => { ($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<()> { 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) { if !user.permissions.check(FinePermission::$permission) {
return Err(Error::NotAllowed); return Err(Error::NotAllowed);
} }
@ -351,4 +352,44 @@ macro_rules! auto_method {
Ok(()) 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(())
}
};
} }

View file

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

View file

@ -4,5 +4,8 @@ CREATE TABLE IF NOT EXISTS entries (
content TEXT NOT NULL, content TEXT NOT NULL,
owner INTEGER NOT NULL, owner INTEGER NOT NULL,
journal INTEGER NOT NULL, journal INTEGER NOT NULL,
context TEXT NOT NULL context TEXT NOT NULL,
-- likes
likes INTEGER NOT NULL,
dislikes INTEGER NOT NULL
) )

View file

@ -5,5 +5,8 @@ CREATE TABLE IF NOT EXISTS pages (
prompt TEXT NOT NULL, prompt TEXT NOT NULL,
owner INTEGER NOT NULL, owner INTEGER NOT NULL,
read_access TEXT 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
) )

View file

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

View file

@ -26,6 +26,9 @@ impl DataManager {
owner: get!(x->3(u64)) as usize, owner: get!(x->3(u64)) as usize,
journal: get!(x->4(u64)) as usize, journal: get!(x->4(u64)) as usize,
context: serde_json::from_str(&get!(x->5(String))).unwrap(), 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!(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_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!(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);
} }

View file

@ -4,6 +4,7 @@ mod drivers;
mod entries; mod entries;
mod memberships; mod memberships;
mod pages; mod pages;
mod reactions;
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
pub use drivers::sqlite::*; pub use drivers::sqlite::*;

View file

@ -31,6 +31,9 @@ impl DataManager {
owner: get!(x->4(u64)) as usize, owner: get!(x->4(u64)) 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: 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_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_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!(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);
} }

View file

@ -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<Reaction> {
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(())
}
}

View file

@ -18,6 +18,8 @@ pub struct JournalPage {
/// The owner of the journal page (and moderators) are the ***only*** people /// The owner of the journal page (and moderators) are the ***only*** people
/// capable of removing entries. /// capable of removing entries.
pub write_access: JournalPageWriteAccess, pub write_access: JournalPageWriteAccess,
pub likes: isize,
pub dislikes: isize,
} }
impl JournalPage { impl JournalPage {
@ -34,6 +36,8 @@ impl JournalPage {
owner, owner,
read_access: JournalPageReadAccess::default(), read_access: JournalPageReadAccess::default(),
write_access: JournalPageWriteAccess::default(), write_access: JournalPageWriteAccess::default(),
likes: 0,
dislikes: 0,
} }
} }
} }
@ -124,6 +128,8 @@ pub struct JournalEntry {
pub journal: usize, pub journal: usize,
/// Extra information about the journal entry. /// Extra information about the journal entry.
pub context: JournalEntryContext, pub context: JournalEntryContext,
pub likes: isize,
pub dislikes: isize,
} }
impl JournalEntry { impl JournalEntry {
@ -139,6 +145,8 @@ impl JournalEntry {
owner, owner,
journal, journal,
context: JournalEntryContext::default(), context: JournalEntryContext::default(),
likes: 0,
dislikes: 0,
} }
} }
} }

View file

@ -2,6 +2,7 @@ pub mod auth;
pub mod journal; pub mod journal;
pub mod journal_permissions; pub mod journal_permissions;
pub mod permissions; pub mod permissions;
pub mod reactions;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};

View file

@ -20,6 +20,7 @@ bitflags! {
const VIEW_REPORTS = 1 << 9; const VIEW_REPORTS = 1 << 9;
const VIEW_AUDIT_LOG = 1 << 10; const VIEW_AUDIT_LOG = 1 << 10;
const MANAGE_MEMBERSHIPS = 1 << 11; const MANAGE_MEMBERSHIPS = 1 << 11;
const MANAGE_REACTIONS = 1 << 12;
const _ = !0; const _ = !0;
} }

View file

@ -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::<usize>()
.unwrap(),
created: unix_epoch_timestamp() as usize,
owner,
asset,
asset_type,
}
}
}