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) => {
trigger("atto::toast", [
res.ok ? "sucesss" : "error",
res.ok ? "success" : "error",
res.message,
]);

View file

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

View file

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

View file

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

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_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(())
}
};
}

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

View file

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

View file

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

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,
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);
}

View file

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

View file

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

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

View file

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

View file

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

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