add: polls backend

TODO: polls frontend
This commit is contained in:
trisua 2025-06-03 23:35:34 -04:00
parent b5e060e8ae
commit 4dfa09207e
18 changed files with 574 additions and 17 deletions

View file

@ -22,7 +22,6 @@ use tetratto_core::{
use tetratto_l10n::LangFile; use tetratto_l10n::LangFile;
use tetratto_shared::hash::salt; use tetratto_shared::hash::salt;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use crate::{create_dir_if_not_exists, write_if_track, write_template}; use crate::{create_dir_if_not_exists, write_if_track, write_template};
// images // images

View file

@ -111,7 +111,7 @@
("style" "display: contents") ("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 "{{ 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 (div
("class" "card-nest") ("class" "card-nest")
(text "{% if question -%} {{ self::question(question=question[0], owner=question[1], profile=owner) }} {% else %}") (text "{% if question -%} {{ self::question(question=question[0], owner=question[1], profile=owner) }} {% else %}")

View file

@ -522,9 +522,9 @@
("class" "card flex flex-col gap-2") ("class" "card flex flex-col gap-2")
(text "{% if is_supporter -%}") (text "{% if is_supporter -%}")
(p (p
(text "You") (text "You ")
(b (b
(text "are")) (text "are "))
(text "a supporter! Thank you for all (text "a supporter! Thank you for all
that you do. You can manage your billing that you do. You can manage your billing
information below.") information below.")
@ -539,9 +539,9 @@
(text "Manage billing")) (text "Manage billing"))
(text "{% else %}") (text "{% else %}")
(p (p
(text "You're") (text "You're ")
(b (b
(text "not")) (text "not "))
(text "currently a supporter! No (text "currently a supporter! No
pressure, but it helps us do some pretty cool pressure, but it helps us do some pretty cool
things! As a supporter, you'll get:")) things! As a supporter, you'll get:"))

View file

@ -30,6 +30,6 @@
(text "{%- endif %}") (text "{%- endif %}")
(div (div
("class" "card w-full flex flex-col gap-2") ("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 %}") (text "{% endblock %}")

View file

@ -7,7 +7,7 @@ use axum::{
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use tetratto_core::model::{ use tetratto_core::model::{
addr::RemoteAddr, addr::RemoteAddr,
communities::Post, communities::{Poll, PollVote, Post},
permissions::FinePermission, permissions::FinePermission,
uploads::{MediaType, MediaUpload}, uploads::{MediaType, MediaUpload},
ApiReturn, Error, ApiReturn, Error,
@ -15,7 +15,7 @@ use tetratto_core::model::{
use crate::{ use crate::{
get_user_from_token, get_user_from_token,
image::{save_webp_buffer, JsonMultipart}, image::{save_webp_buffer, JsonMultipart},
routes::api::v1::{CreatePost, CreateRepost, UpdatePostContent, UpdatePostContext}, routes::api::v1::{CreatePost, CreateRepost, UpdatePostContent, UpdatePostContext, VoteInPoll},
State, State,
}; };
@ -66,6 +66,21 @@ pub async fn create_request(
return Json(Error::NotAllowed.into()); 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( let mut props = Post::new(
req.content, req.content,
@ -82,6 +97,7 @@ pub async fn create_request(
None None
}, },
user.id, user.id,
poll_id,
); );
if !req.answering.is_empty() { if !req.answering.is_empty() {
@ -308,3 +324,39 @@ pub async fn update_context_request(
Err(e) => Json(e.into()), Err(e) => Json(e.into()),
} }
} }
pub async fn vote_request(
jar: CookieJar,
Extension(data): Extension<State>,
Path(id): Path<usize>,
Json(req): Json<VoteInPoll>,
) -> 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()),
}
}

View file

@ -19,7 +19,7 @@ use serde::Deserialize;
use tetratto_core::model::{ use tetratto_core::model::{
communities::{ communities::{
CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess, CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess,
PostContext, PollOption, PostContext,
}, },
communities_permissions::CommunityPermission, communities_permissions::CommunityPermission,
permissions::FinePermission, permissions::FinePermission,
@ -113,6 +113,10 @@ pub fn routes() -> Router {
"/posts/{id}/context", "/posts/{id}/context",
post(communities::posts::update_context_request), post(communities::posts::update_context_request),
) )
.route(
"/posts/{id}/poll_vote",
post(communities::posts::vote_request),
)
// drafts // drafts
.route("/drafts", post(communities::drafts::create_request)) .route("/drafts", post(communities::drafts::create_request))
.route("/drafts/{id}", delete(communities::drafts::delete_request)) .route("/drafts/{id}", delete(communities::drafts::delete_request))
@ -419,6 +423,14 @@ pub struct UpdateCommunityJoinAccess {
pub access: CommunityJoinAccess, 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)] #[derive(Deserialize)]
pub struct CreatePost { pub struct CreatePost {
pub content: String, pub content: String,
@ -427,6 +439,8 @@ pub struct CreatePost {
pub replying_to: Option<String>, pub replying_to: Option<String>,
#[serde(default)] #[serde(default)]
pub answering: String, pub answering: String,
#[serde(default)]
pub poll: Option<CreatePostPoll>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -597,3 +611,8 @@ pub struct UpdateEmojiName {
pub struct CreatePostDraft { pub struct CreatePostDraft {
pub content: String, pub content: String,
} }
#[derive(Deserialize)]
pub struct VoteInPoll {
pub option: PollOption,
}

View file

@ -358,6 +358,24 @@ impl DataManager {
return Err(Error::DatabaseError(e.to_string())); 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 // delete user follows... individually since it requires updating user counts
for follow in self.get_userfollows_by_receiver_all(id).await? { for follow in self.get_userfollows_by_receiver_all(id).await? {
self.delete_userfollow(follow.id, &user, true).await?; self.delete_userfollow(follow.id, &user, true).await?;

View file

@ -36,6 +36,8 @@ impl DataManager {
execute!(&conn, common::CREATE_TABLE_EMOJIS).unwrap(); execute!(&conn, common::CREATE_TABLE_EMOJIS).unwrap();
execute!(&conn, common::CREATE_TABLE_STACKS).unwrap(); execute!(&conn, common::CREATE_TABLE_STACKS).unwrap();
execute!(&conn, common::CREATE_TABLE_DRAFTS).unwrap(); execute!(&conn, common::CREATE_TABLE_DRAFTS).unwrap();
execute!(&conn, common::CREATE_TABLE_POLLS).unwrap();
execute!(&conn, common::CREATE_TABLE_POLLVOTES).unwrap();
self.2 self.2
.set("atto.active_connections:users".to_string(), "0".to_string()) .set("atto.active_connections:users".to_string(), "0".to_string())

View file

@ -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_EMOJIS: &str = include_str!("./sql/create_emojis.sql");
pub const CREATE_TABLE_STACKS: &str = include_str!("./sql/create_stacks.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_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");

View file

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

View file

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

View file

@ -14,5 +14,6 @@ CREATE TABLE IF NOT EXISTS posts (
-- ... -- ...
uploads TEXT NOT NULL, uploads TEXT NOT NULL,
is_deleted INT 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
) )

View file

@ -10,6 +10,8 @@ mod ipbans;
mod ipblocks; mod ipblocks;
mod memberships; mod memberships;
mod notifications; mod notifications;
mod polls;
mod pollvotes;
mod posts; mod posts;
mod questions; mod questions;
mod reactions; mod reactions;

View file

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

View file

@ -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<PollVote> {
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<usize> {
// 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(())
}
}

View file

@ -3,7 +3,7 @@ use super::*;
use crate::cache::Cache; use crate::cache::Cache;
use crate::config::StringBan; use crate::config::StringBan;
use crate::model::auth::Notification; 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::communities_permissions::CommunityPermission;
use crate::model::moderation::AuditLogEntry; use crate::model::moderation::AuditLogEntry;
use crate::model::stacks::StackSort; use crate::model::stacks::StackSort;
@ -108,6 +108,8 @@ impl DataManager {
// ... // ...
uploads: serde_json::from_str(&get!(x->10(String))).unwrap(), uploads: serde_json::from_str(&get!(x->10(String))).unwrap(),
is_deleted: get!(x->11(i32)) as i8 == 1, 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<User>,
) -> Result<Option<(Poll, bool)>> {
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. /// Complete a vector of just posts with their owner as well.
pub async fn fill_posts( pub async fn fill_posts(
&self, &self,
posts: Vec<Post>, posts: Vec<Post>,
ignore_users: &[usize], ignore_users: &[usize],
user: &Option<User>, user: &Option<User>,
) -> Result<Vec<(Post, User, Option<(User, Post)>, Option<(Question, User)>)>> { ) -> Result<
let mut out: Vec<(Post, User, Option<(User, Post)>, Option<(Question, User)>)> = Vec::new(); Vec<(
Post,
User,
Option<(User, Post)>,
Option<(Question, User)>,
Option<(Poll, bool)>,
)>,
> {
let mut out = Vec::new();
let mut users: HashMap<usize, User> = HashMap::new(); let mut users: HashMap<usize, User> = HashMap::new();
let mut seen_user_follow_statuses: HashMap<(usize, usize), bool> = HashMap::new(); let mut seen_user_follow_statuses: HashMap<(usize, usize), bool> = HashMap::new();
@ -260,6 +297,7 @@ impl DataManager {
ua.clone(), ua.clone(),
self.get_post_reposting(&post, ignore_users, user).await, self.get_post_reposting(&post, ignore_users, user).await,
self.get_post_question(&post, ignore_users).await?, self.get_post_question(&post, ignore_users).await?,
self.get_post_poll(&post, user).await?,
)); ));
} else { } else {
let ua = self.get_user_by_id(owner).await?; let ua = self.get_user_by_id(owner).await?;
@ -314,6 +352,7 @@ impl DataManager {
ua, ua,
self.get_post_reposting(&post, ignore_users, user).await, self.get_post_reposting(&post, ignore_users, user).await,
self.get_post_question(&post, ignore_users).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!( let res = execute!(
&conn, &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![ params![
&(data.id as i64), &(data.id as i64),
&(data.created as i64), &(data.created as i64),
@ -1401,7 +1440,8 @@ impl DataManager {
&0_i32, &0_i32,
&0_i32, &0_i32,
&serde_json::to_string(&data.uploads).unwrap(), &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),
] ]
); );

View file

@ -240,6 +240,8 @@ pub struct Post {
pub uploads: Vec<usize>, pub uploads: Vec<usize>,
/// If the post was deleted. /// If the post was deleted.
pub is_deleted: bool, 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 { impl Post {
@ -249,6 +251,7 @@ impl Post {
community: usize, community: usize,
replying_to: Option<usize>, replying_to: Option<usize>,
owner: usize, owner: usize,
poll_id: usize,
) -> Self { ) -> Self {
Self { Self {
id: Snowflake::new().to_string().parse::<usize>().unwrap(), id: Snowflake::new().to_string().parse::<usize>().unwrap(),
@ -263,12 +266,13 @@ impl Post {
comment_count: 0, comment_count: 0,
uploads: Vec::new(), uploads: Vec::new(),
is_deleted: false, is_deleted: false,
poll_id,
} }
} }
/// Create a new [`Post`] (as a repost of the given `post_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 { 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 { post.context.repost = Some(RepostContext {
is_repost: false, 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::<usize>().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<u8> 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<u8> 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::<usize>().unwrap(),
owner,
created: unix_epoch_timestamp() as usize,
poll_id,
vote,
}
}
}

View file

@ -0,0 +1,2 @@
ALTER TABLE posts
ADD COLUMN poll_id BIGINT NOT NULL DEFAULT 0;