diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index b451601..6747948 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -22,7 +22,6 @@ use tetratto_core::{ use tetratto_l10n::LangFile; use tetratto_shared::hash::salt; use tokio::sync::RwLock; - use crate::{create_dir_if_not_exists, write_if_track, write_template}; // images diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index a5df713..4fa1c06 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -111,7 +111,7 @@ ("style" "display: contents") (text "{{ self::post(post=post, owner=owner, secondary=secondary, community=community, show_community=show_community, can_manage_post=can_manage_post, repost=repost, expect_repost=true) }}")) -(text "{%- endmacro %} {% macro post(post, owner, question=false, secondary=false, community=false, show_community=true, can_manage_post=false, repost=false, expect_repost=false) -%} {% if community and show_community and community.id != config.town_square or question %}") +(text "{%- endmacro %} {% macro post(post, owner, question=false, secondary=false, community=false, show_community=true, can_manage_post=false, repost=false, expect_repost=false, poll=false) -%} {% if community and show_community and community.id != config.town_square or question %}") (div ("class" "card-nest") (text "{% if question -%} {{ self::question(question=question[0], owner=question[1], profile=owner) }} {% else %}") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 359b1c5..70eda76 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -522,9 +522,9 @@ ("class" "card flex flex-col gap-2") (text "{% if is_supporter -%}") (p - (text "You") + (text "You ") (b - (text "are")) + (text "are ")) (text "a supporter! Thank you for all that you do. You can manage your billing information below.") @@ -539,9 +539,9 @@ (text "Manage billing")) (text "{% else %}") (p - (text "You're") + (text "You're ") (b - (text "not")) + (text "not ")) (text "currently a supporter! No pressure, but it helps us do some pretty cool things! As a supporter, you'll get:")) diff --git a/crates/app/src/public/html/timelines/all.lisp b/crates/app/src/public/html/timelines/all.lisp index 84c6589..c9878a8 100644 --- a/crates/app/src/public/html/timelines/all.lisp +++ b/crates/app/src/public/html/timelines/all.lisp @@ -30,6 +30,6 @@ (text "{%- endif %}") (div ("class" "card w-full flex flex-col gap-2") - (text "{% for post in list %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2]) }} {%- endif %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}"))) + (text "{% for post in list %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[3]) }} {%- endif %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}"))) (text "{% endblock %}") diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index 447a633..155bd7e 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -7,7 +7,7 @@ use axum::{ use axum_extra::extract::CookieJar; use tetratto_core::model::{ addr::RemoteAddr, - communities::Post, + communities::{Poll, PollVote, Post}, permissions::FinePermission, uploads::{MediaType, MediaUpload}, ApiReturn, Error, @@ -15,7 +15,7 @@ use tetratto_core::model::{ use crate::{ get_user_from_token, image::{save_webp_buffer, JsonMultipart}, - routes::api::v1::{CreatePost, CreateRepost, UpdatePostContent, UpdatePostContext}, + routes::api::v1::{CreatePost, CreateRepost, UpdatePostContent, UpdatePostContext, VoteInPoll}, State, }; @@ -66,6 +66,21 @@ pub async fn create_request( return Json(Error::NotAllowed.into()); } + // create poll + let poll_id = if let Some(p) = req.poll { + match data + .create_poll(Poll::new( + user.id, 86400000, p.option_a, p.option_b, p.option_c, p.option_d, + )) + .await + { + Ok(p) => p, + Err(e) => return Json(e.into()), + } + } else { + 0 + }; + // ... let mut props = Post::new( req.content, @@ -82,6 +97,7 @@ pub async fn create_request( None }, user.id, + poll_id, ); if !req.answering.is_empty() { @@ -308,3 +324,39 @@ pub async fn update_context_request( Err(e) => Json(e.into()), } } + +pub async fn vote_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + 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()), + }; + + let post = match data.get_post_by_id(id).await { + Ok(p) => p, + Err(e) => return Json(e.into()), + }; + + let poll = match data.get_poll_by_id(post.poll_id).await { + Ok(p) => p, + Err(e) => return Json(e.into()), + }; + + // ... + match data + .create_pollvote(PollVote::new(user.id, poll.id, req.option)) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Vote cast".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 02a7848..4395569 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -19,7 +19,7 @@ use serde::Deserialize; use tetratto_core::model::{ communities::{ CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess, - PostContext, + PollOption, PostContext, }, communities_permissions::CommunityPermission, permissions::FinePermission, @@ -113,6 +113,10 @@ pub fn routes() -> Router { "/posts/{id}/context", post(communities::posts::update_context_request), ) + .route( + "/posts/{id}/poll_vote", + post(communities::posts::vote_request), + ) // drafts .route("/drafts", post(communities::drafts::create_request)) .route("/drafts/{id}", delete(communities::drafts::delete_request)) @@ -419,6 +423,14 @@ pub struct UpdateCommunityJoinAccess { pub access: CommunityJoinAccess, } +#[derive(Deserialize)] +pub struct CreatePostPoll { + pub option_a: String, + pub option_b: String, + pub option_c: String, + pub option_d: String, +} + #[derive(Deserialize)] pub struct CreatePost { pub content: String, @@ -427,6 +439,8 @@ pub struct CreatePost { pub replying_to: Option, #[serde(default)] pub answering: String, + #[serde(default)] + pub poll: Option, } #[derive(Deserialize)] @@ -597,3 +611,8 @@ pub struct UpdateEmojiName { pub struct CreatePostDraft { pub content: String, } + +#[derive(Deserialize)] +pub struct VoteInPoll { + pub option: PollOption, +} diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index fe89a30..423eaec 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -358,6 +358,24 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } + // delete polls + let res = execute!(&conn, "DELETE FROM polls WHERE owner = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete poll votes + let res = execute!( + &conn, + "DELETE FROM pollvotes WHERE owner = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + // delete user follows... individually since it requires updating user counts for follow in self.get_userfollows_by_receiver_all(id).await? { self.delete_userfollow(follow.id, &user, true).await?; diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 1607ff7..8eed315 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -36,6 +36,8 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_EMOJIS).unwrap(); execute!(&conn, common::CREATE_TABLE_STACKS).unwrap(); execute!(&conn, common::CREATE_TABLE_DRAFTS).unwrap(); + execute!(&conn, common::CREATE_TABLE_POLLS).unwrap(); + execute!(&conn, common::CREATE_TABLE_POLLVOTES).unwrap(); self.2 .set("atto.active_connections:users".to_string(), "0".to_string()) diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index fda8e77..0647d43 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -19,3 +19,5 @@ pub const CREATE_TABLE_UPLOADS: &str = include_str!("./sql/create_uploads.sql"); pub const CREATE_TABLE_EMOJIS: &str = include_str!("./sql/create_emojis.sql"); pub const CREATE_TABLE_STACKS: &str = include_str!("./sql/create_stacks.sql"); pub const CREATE_TABLE_DRAFTS: &str = include_str!("./sql/create_drafts.sql"); +pub const CREATE_TABLE_POLLS: &str = include_str!("./sql/create_polls.sql"); +pub const CREATE_TABLE_POLLVOTES: &str = include_str!("./sql/create_pollvotes.sql"); diff --git a/crates/core/src/database/drivers/sql/create_polls.sql b/crates/core/src/database/drivers/sql/create_polls.sql new file mode 100644 index 0000000..1fabec0 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_polls.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS polls ( + id BIGINT NOT NULL PRIMARY KEY, + owner BIGINT NOT NULL, + created BIGINT NOT NULL, + expires INT NOT NULL DEFAULT 86400000, -- expires in a day by default + option_a TEXT NOT NULL, + option_b TEXT NOT NULL, + option_c TEXT NOT NULL, + option_d TEXT NOT NULL, + votes_a INT NOT NULL DEFAULT 0, + votes_b INT NOT NULL DEFAULT 0, + votes_c INT NOT NULL DEFAULT 0, + votes_d INT NOT NULL DEFAULT 0 +) diff --git a/crates/core/src/database/drivers/sql/create_pollvotes.sql b/crates/core/src/database/drivers/sql/create_pollvotes.sql new file mode 100644 index 0000000..1caf66d --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_pollvotes.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS pollvotes ( + id BIGINT NOT NULL, + owner BIGINT NOT NULL, + created BIGINT NOT NULL, + poll_id BIGINT NOT NULL, + vote INT NOT NULL, + PRIMARY KEY (owner, poll_id) +) diff --git a/crates/core/src/database/drivers/sql/create_posts.sql b/crates/core/src/database/drivers/sql/create_posts.sql index 640dbfa..665fa92 100644 --- a/crates/core/src/database/drivers/sql/create_posts.sql +++ b/crates/core/src/database/drivers/sql/create_posts.sql @@ -14,5 +14,6 @@ CREATE TABLE IF NOT EXISTS posts ( -- ... uploads TEXT NOT NULL, is_deleted INT NOT NULL, - tsvector_content tsvector GENERATED ALWAYS AS (to_tsvector ('english', coalesce(content, ''))) STORED + tsvector_content tsvector GENERATED ALWAYS AS (to_tsvector ('english', coalesce(content, ''))) STORED, + poll_id BIGINT NOT NULL ) diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index e02caff..d5decc5 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -10,6 +10,8 @@ mod ipbans; mod ipblocks; mod memberships; mod notifications; +mod polls; +mod pollvotes; mod posts; mod questions; mod reactions; diff --git a/crates/core/src/database/polls.rs b/crates/core/src/database/polls.rs new file mode 100644 index 0000000..231f9ac --- /dev/null +++ b/crates/core/src/database/polls.rs @@ -0,0 +1,141 @@ +use super::*; +use crate::cache::Cache; +use crate::model::communities::Poll; +use crate::model::moderation::AuditLogEntry; +use crate::model::{Error, Result, auth::User, permissions::FinePermission}; +use crate::{auto_method, execute, get, query_row, params}; + +#[cfg(feature = "sqlite")] +use rusqlite::Row; + +#[cfg(feature = "postgres")] +use tokio_postgres::Row; + +impl DataManager { + /// Get a [`Poll`] from an SQL row. + pub(crate) fn get_poll_from_row( + #[cfg(feature = "sqlite")] x: &Row<'_>, + #[cfg(feature = "postgres")] x: &Row, + ) -> Poll { + Poll { + id: get!(x->0(i64)) as usize, + owner: get!(x->1(i64)) as usize, + created: get!(x->2(i64)) as usize, + expires: get!(x->3(i64)) as usize, + option_a: get!(x->4(String)), + option_b: get!(x->5(String)), + option_c: get!(x->6(String)), + option_d: get!(x->7(String)), + votes_a: get!(x->8(i32)) as usize, + votes_b: get!(x->9(i32)) as usize, + votes_c: get!(x->10(i32)) as usize, + votes_d: get!(x->11(i32)) as usize, + } + } + + auto_method!(get_poll_by_id()@get_poll_from_row -> "SELECT * FROM polls WHERE id = $1" --name="poll" --returns=Poll --cache-key-tmpl="atto.poll:{}"); + + /// Create a new poll in the database. + /// + /// # Arguments + /// * `data` - a mock [`Poll`] object to insert + pub async fn create_poll(&self, data: Poll) -> Result { + // check values + if data.option_a.len() < 2 { + return Err(Error::DataTooShort("option A".to_string())); + } else if data.option_a.len() > 128 { + return Err(Error::DataTooLong("option A".to_string())); + } + + if data.option_b.len() < 2 { + return Err(Error::DataTooShort("option B".to_string())); + } else if data.option_b.len() > 128 { + return Err(Error::DataTooLong("option B".to_string())); + } + + if data.option_c.len() > 128 { + return Err(Error::DataTooLong("option C".to_string())); + } + + if data.option_d.len() > 128 { + return Err(Error::DataTooLong("option D".to_string())); + } + + // ... + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO polls VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", + params![ + &(data.id as i64), + &(data.owner as i64), + &(data.created as i64), + &(data.expires as i64), + &data.option_a, + &data.option_b, + &data.option_c, + &data.option_d, + &(data.votes_a as i64), + &(data.votes_b as i64), + &(data.votes_c as i64), + &(data.votes_d as i64), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data.id) + } + + pub async fn delete_poll(&self, id: usize, user: User) -> Result<()> { + let y = self.get_poll_by_id(id).await?; + + if user.id != y.owner { + if !user.permissions.check(FinePermission::MANAGE_POSTS) { + return Err(Error::NotAllowed); + } else { + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!("invoked `delete_poll` with x value `{id}`"), + )) + .await? + } + } + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, "DELETE FROM polls WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.2.remove(format!("atto.poll:{}", id)).await; + + Ok(()) + } + + pub async fn cache_clear_poll(&self, poll: &Poll) { + self.2.remove(format!("atto.poll:{}", poll.id)).await; + } + + auto_method!(incr_votes_a_count()@get_poll_by_id -> "UPDATE users SET votes_a = votes_a + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_poll --incr); + auto_method!(decr_votes_a_count()@get_poll_by_id -> "UPDATE users SET votes_a = votes_a - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_poll --decr=votes_a); + + auto_method!(incr_votes_b_count()@get_poll_by_id -> "UPDATE users SET votes_b = votes_b + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_poll --incr); + auto_method!(decr_votes_b_count()@get_poll_by_id -> "UPDATE users SET votes_b = votes_b - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_poll --decr=votes_b); + + auto_method!(incr_votes_c_count()@get_poll_by_id -> "UPDATE users SET votes_a = votes_d + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_poll --incr); + auto_method!(decr_votes_c_count()@get_poll_by_id -> "UPDATE users SET votes_a = votes_d - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_poll --decr=votes_c); + + auto_method!(incr_votes_d_count()@get_poll_by_id -> "UPDATE users SET votes_a = votes_d + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_poll --incr); + auto_method!(decr_votes_d_count()@get_poll_by_id -> "UPDATE users SET votes_a = votes_d - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_poll --decr=votes_d); +} diff --git a/crates/core/src/database/pollvotes.rs b/crates/core/src/database/pollvotes.rs new file mode 100644 index 0000000..73515c4 --- /dev/null +++ b/crates/core/src/database/pollvotes.rs @@ -0,0 +1,149 @@ +use super::*; +use crate::cache::Cache; +use crate::model::communities::PollVote; +use crate::model::moderation::AuditLogEntry; +use crate::model::{Error, Result, auth::User, permissions::FinePermission}; +use crate::{auto_method, execute, get, query_row, params}; + +#[cfg(feature = "sqlite")] +use rusqlite::Row; + +use tetratto_shared::unix_epoch_timestamp; +#[cfg(feature = "postgres")] +use tokio_postgres::Row; + +impl DataManager { + /// Get a [`PollVote`] from an SQL row. + pub(crate) fn get_pollvote_from_row( + #[cfg(feature = "sqlite")] x: &Row<'_>, + #[cfg(feature = "postgres")] x: &Row, + ) -> PollVote { + PollVote { + id: get!(x->0(i64)) as usize, + owner: get!(x->1(i64)) as usize, + created: get!(x->2(i64)) as usize, + poll_id: get!(x->3(i64)) as usize, + vote: (get!(x->4(i32)) as u8).into(), + } + } + + auto_method!(get_pollvote_by_id()@get_pollvote_from_row -> "SELECT * FROM pollvotes WHERE id = $1" --name="poll vote" --returns=PollVote --cache-key-tmpl="atto.pollvote:{}"); + + pub async fn get_pollvote_by_owner_poll(&self, id: usize, poll_id: usize) -> Result { + if let Some(cached) = self + .2 + .get(format!("atto.pollvote:{}:{}", id, poll_id)) + .await + { + return Ok(serde_json::from_str(&cached).unwrap()); + } + + 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 pollvotes WHERE id = $1 AND poll_id = $2", + &[&(id as i64), &(poll_id as i64)], + |x| { Ok(Self::get_pollvote_from_row(x)) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("poll vote".to_string())); + } + + let x = res.unwrap(); + self.2 + .set( + format!("atto.pollvote:{}:{}", id, poll_id), + serde_json::to_string(&x).unwrap(), + ) + .await; + + Ok(x) + } + + /// Create a new poll vote in the database. + /// + /// # Arguments + /// * `data` - a mock [`PollVote`] object to insert + pub async fn create_pollvote(&self, data: PollVote) -> Result { + // get poll and check permission + let poll = self.get_poll_by_id(data.poll_id).await?; + + let now = unix_epoch_timestamp() as usize; + let diff = now - poll.created; + + if diff > poll.expires { + return Err(Error::MiscError("Poll is closed".to_string())); + }; + + // ... + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let vote_u8: u8 = data.vote.into(); + let res = execute!( + &conn, + "INSERT INTO pollvotes VALUES ($1, $2, $3, $4, $5)", + params![ + &(data.id as i64), + &(data.owner as i64), + &(data.created as i64), + &(data.poll_id as i64), + &(vote_u8 as i64), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // update poll + self.incr_votes_a_count(poll.id).await?; + self.incr_votes_b_count(poll.id).await?; + self.incr_votes_c_count(poll.id).await?; + self.incr_votes_d_count(poll.id).await?; + + // ... + Ok(data.id) + } + + pub async fn delete_pollvote(&self, id: usize, user: User) -> Result<()> { + let y = self.get_pollvote_by_id(id).await?; + + if user.id != y.owner { + if !user.permissions.check(FinePermission::MANAGE_POSTS) { + return Err(Error::NotAllowed); + } else { + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!("invoked `delete_pollvote` with x value `{id}`"), + )) + .await? + } + } + let conn = match self.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "DELETE FROM pollvotes WHERE id = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.2.remove(format!("atto.pollvote:{}", id)).await; + + Ok(()) + } +} diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 2270711..5062cf8 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -3,7 +3,7 @@ use super::*; use crate::cache::Cache; use crate::config::StringBan; use crate::model::auth::Notification; -use crate::model::communities::Question; +use crate::model::communities::{Poll, Question}; use crate::model::communities_permissions::CommunityPermission; use crate::model::moderation::AuditLogEntry; use crate::model::stacks::StackSort; @@ -108,6 +108,8 @@ impl DataManager { // ... uploads: serde_json::from_str(&get!(x->10(String))).unwrap(), is_deleted: get!(x->11(i32)) as i8 == 1, + // SKIP tsvector (12) + poll_id: get!(x->13(i64)) as usize, } } @@ -234,14 +236,49 @@ impl DataManager { } } + /// Get the poll of the given post (if some). + pub async fn get_post_poll( + &self, + post: &Post, + user: &Option, + ) -> Result> { + let user = if let Some(ua) = user { + ua + } else { + return Err(Error::MiscError("Could not get user for pull".to_string())); + }; + + if post.poll_id != 0 { + Ok(Some(match self.get_poll_by_id(post.poll_id).await { + Ok(p) => ( + p, + self.get_pollvote_by_owner_poll(user.id, post.poll_id) + .await + .is_ok(), + ), + Err(_) => return Err(Error::MiscError("Invalid poll ID attached".to_string())), + })) + } else { + return Err(Error::MiscError("Invalid poll ID attached".to_string())); + } + } + /// Complete a vector of just posts with their owner as well. pub async fn fill_posts( &self, posts: Vec, ignore_users: &[usize], user: &Option, - ) -> Result, Option<(Question, User)>)>> { - let mut out: Vec<(Post, User, Option<(User, Post)>, Option<(Question, User)>)> = Vec::new(); + ) -> Result< + Vec<( + Post, + User, + Option<(User, Post)>, + Option<(Question, User)>, + Option<(Poll, bool)>, + )>, + > { + let mut out = Vec::new(); let mut users: HashMap = HashMap::new(); let mut seen_user_follow_statuses: HashMap<(usize, usize), bool> = HashMap::new(); @@ -260,6 +297,7 @@ impl DataManager { ua.clone(), self.get_post_reposting(&post, ignore_users, user).await, self.get_post_question(&post, ignore_users).await?, + self.get_post_poll(&post, user).await?, )); } else { let ua = self.get_user_by_id(owner).await?; @@ -314,6 +352,7 @@ impl DataManager { ua, self.get_post_reposting(&post, ignore_users, user).await, self.get_post_question(&post, ignore_users).await?, + self.get_post_poll(&post, user).await?, )); } } @@ -1384,7 +1423,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", + "INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, null, $13)", params![ &(data.id as i64), &(data.created as i64), @@ -1401,7 +1440,8 @@ impl DataManager { &0_i32, &0_i32, &serde_json::to_string(&data.uploads).unwrap(), - &{ if data.is_deleted { 1 } else { 0 } } + &{ if data.is_deleted { 1 } else { 0 } }, + &(data.poll_id as i64), ] ); diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index 5fa153b..9712e5a 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -240,6 +240,8 @@ pub struct Post { pub uploads: Vec, /// If the post was deleted. pub is_deleted: bool, + /// The ID of the poll associated with this post. 0 means no poll is connected. + pub poll_id: usize, } impl Post { @@ -249,6 +251,7 @@ impl Post { community: usize, replying_to: Option, owner: usize, + poll_id: usize, ) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), @@ -263,12 +266,13 @@ impl Post { comment_count: 0, uploads: Vec::new(), is_deleted: false, + poll_id, } } /// Create a new [`Post`] (as a repost of the given `post_id`). pub fn repost(content: String, community: usize, owner: usize, post_id: usize) -> Self { - let mut post = Self::new(content, community, None, owner); + let mut post = Self::new(content, community, None, owner, 0); post.context.repost = Some(RepostContext { is_repost: false, @@ -369,3 +373,107 @@ impl PostDraft { } } } + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Poll { + pub id: usize, + pub owner: usize, + pub created: usize, + /// The number of milliseconds until this poll can no longer receive votes. + pub expires: usize, + // options + pub option_a: String, + pub option_b: String, + pub option_c: String, + pub option_d: String, + // votes + pub votes_a: usize, + pub votes_b: usize, + pub votes_c: usize, + pub votes_d: usize, +} + +impl Poll { + /// Create a new [`Poll`]. + pub fn new( + owner: usize, + expires: usize, + option_a: String, + option_b: String, + option_c: String, + option_d: String, + ) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + owner, + created: unix_epoch_timestamp() as usize, + expires, + // options + option_a, + option_b, + option_c, + option_d, + // votes + votes_a: 0, + votes_b: 0, + votes_c: 0, + votes_d: 0, + } + } +} + +/// Poll option (selectors) are stored in the database as numbers 0 to 3. +/// +/// This enum allows us to convert from these numbers into letters. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum PollOption { + A, + B, + C, + D, +} + +impl From for PollOption { + fn from(value: u8) -> Self { + match value { + 0 => Self::A, + 1 => Self::B, + 2 => Self::C, + 3 => Self::D, + _ => Self::A, + } + } +} + +impl Into for PollOption { + fn into(self) -> u8 { + match self { + Self::A => 0, + Self::B => 1, + Self::C => 2, + Self::D => 3, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PollVote { + pub id: usize, + pub owner: usize, + pub created: usize, + pub poll_id: usize, + pub vote: PollOption, +} + +impl PollVote { + /// Create a new [`PollVote`]. + pub fn new(owner: usize, poll_id: usize, vote: PollOption) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + owner, + created: unix_epoch_timestamp() as usize, + poll_id, + vote, + } + } +} diff --git a/sql_changes/posts_poll_id.sql b/sql_changes/posts_poll_id.sql new file mode 100644 index 0000000..c4eea69 --- /dev/null +++ b/sql_changes/posts_poll_id.sql @@ -0,0 +1,2 @@ +ALTER TABLE posts +ADD COLUMN poll_id BIGINT NOT NULL DEFAULT 0;