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_shared::hash::salt;
use tokio::sync::RwLock;
use crate::{create_dir_if_not_exists, write_if_track, write_template};
// images

View file

@ -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 %}")

View file

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

View file

@ -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 %}")

View file

@ -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<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::{
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<String>,
#[serde(default)]
pub answering: String,
#[serde(default)]
pub poll: Option<CreatePostPoll>,
}
#[derive(Deserialize)]
@ -597,3 +611,8 @@ pub struct UpdateEmojiName {
pub struct CreatePostDraft {
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()));
}
// 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?;

View file

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

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

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,
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 memberships;
mod notifications;
mod polls;
mod pollvotes;
mod posts;
mod questions;
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::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<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.
pub async fn fill_posts(
&self,
posts: Vec<Post>,
ignore_users: &[usize],
user: &Option<User>,
) -> Result<Vec<(Post, User, Option<(User, Post)>, 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<usize, User> = 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),
]
);

View file

@ -240,6 +240,8 @@ pub struct Post {
pub uploads: Vec<usize>,
/// 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<usize>,
owner: usize,
poll_id: usize,
) -> Self {
Self {
id: Snowflake::new().to_string().parse::<usize>().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::<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,
}
}
}