add: polls backend
TODO: polls frontend
This commit is contained in:
parent
b5e060e8ae
commit
4dfa09207e
18 changed files with 574 additions and 17 deletions
|
@ -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
|
||||||
|
|
|
@ -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 %}")
|
||||||
|
|
|
@ -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 %}")
|
||||||
|
|
|
@ -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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
|
@ -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?;
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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");
|
||||||
|
|
14
crates/core/src/database/drivers/sql/create_polls.sql
Normal file
14
crates/core/src/database/drivers/sql/create_polls.sql
Normal 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
|
||||||
|
)
|
|
@ -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)
|
||||||
|
)
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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;
|
||||||
|
|
141
crates/core/src/database/polls.rs
Normal file
141
crates/core/src/database/polls.rs
Normal 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);
|
||||||
|
}
|
149
crates/core/src/database/pollvotes.rs
Normal file
149
crates/core/src/database/pollvotes.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -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),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
2
sql_changes/posts_poll_id.sql
Normal file
2
sql_changes/posts_poll_id.sql
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE posts
|
||||||
|
ADD COLUMN poll_id BIGINT NOT NULL DEFAULT 0;
|
Loading…
Add table
Add a link
Reference in a new issue