821537186e
- Reorganized import statements in adapter tests for better readability - Replaced or_insert_with(Vec::new) with or_default() in test closures - Updated Cargo.lock with new dependency versions and checksums - Added TLS features to tonic dependency configuration - Included sqlx, chrono, and uuid dependencies with specific features - Added jsonwebtoken and arc-swap as project dependencies - Reformatted assertion statements to comply with line length limits - Adjusted base64 import order in engine codec module - Updated protobuf include statement formatting
171 lines
4.9 KiB
Rust
171 lines
4.9 KiB
Rust
//! Poll on a message — new tables `message_poll`, `message_poll_option`,
|
||
//! `message_poll_vote`.
|
||
//!
|
||
//! Discord-style polls: attached to a message, with multiple options,
|
||
//! optional multi-vote, and an expiry time.
|
||
|
||
use chrono::{DateTime, Utc};
|
||
use serde::{Deserialize, Serialize};
|
||
use uuid::Uuid;
|
||
|
||
/// The poll itself (one per message, optional).
|
||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||
pub struct MessagePoll {
|
||
pub id: Uuid,
|
||
pub message_id: Uuid,
|
||
/// The question displayed to voters.
|
||
pub question: String,
|
||
/// Whether users can select multiple options.
|
||
pub allow_multiselect: bool,
|
||
/// Maximum number of options a user can select (NULL = unlimited when multiselect).
|
||
pub max_selections: Option<i32>,
|
||
/// When voting closes (NULL = no expiry).
|
||
pub expires_at: Option<DateTime<Utc>>,
|
||
/// Total number of votes cast (denormalized for fast reads).
|
||
pub total_votes: i64,
|
||
pub created_at: DateTime<Utc>,
|
||
pub updated_at: DateTime<Utc>,
|
||
}
|
||
|
||
/// A single selectable option within a poll.
|
||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||
pub struct MessagePollOption {
|
||
pub id: Uuid,
|
||
pub poll_id: Uuid,
|
||
/// Display text for this option.
|
||
pub text: String,
|
||
/// Optional emoji prefix (Discord-style).
|
||
pub emoji: Option<String>,
|
||
/// Number of votes this option received (denormalized).
|
||
pub vote_count: i64,
|
||
/// Display order.
|
||
pub position: i32,
|
||
}
|
||
|
||
/// A single vote cast by a user.
|
||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||
pub struct MessagePollVote {
|
||
pub id: Uuid,
|
||
pub poll_id: Uuid,
|
||
pub option_id: Uuid,
|
||
pub user_id: Uuid,
|
||
pub created_at: DateTime<Utc>,
|
||
}
|
||
|
||
/// Aggregated poll results for API responses.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct PollResult {
|
||
pub poll: MessagePoll,
|
||
pub options: Vec<PollOptionResult>,
|
||
/// Which options the current user voted for (empty if not voted).
|
||
pub my_votes: Vec<Uuid>,
|
||
/// Whether the poll has expired.
|
||
pub is_expired: bool,
|
||
}
|
||
|
||
/// Option with its vote count and percentage.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct PollOptionResult {
|
||
#[serde(flatten)]
|
||
pub option: MessagePollOption,
|
||
/// Percentage of total votes (0.0–100.0), rounded to 1 decimal.
|
||
pub percentage: f64,
|
||
}
|
||
|
||
impl PollResult {
|
||
/// Compute percentages from total_votes.
|
||
pub fn from_poll(
|
||
poll: MessagePoll,
|
||
options: Vec<MessagePollOption>,
|
||
my_votes: Vec<Uuid>,
|
||
) -> Self {
|
||
let total = poll.total_votes.max(1) as f64;
|
||
let now = Utc::now();
|
||
let is_expired = poll.expires_at.is_some_and(|exp| now >= exp);
|
||
|
||
let options = options
|
||
.into_iter()
|
||
.map(|opt| {
|
||
let pct = if poll.total_votes > 0 {
|
||
(opt.vote_count as f64 / total * 100.0 * 10.0).round() / 10.0
|
||
} else {
|
||
0.0
|
||
};
|
||
PollOptionResult {
|
||
option: opt,
|
||
percentage: pct,
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
Self {
|
||
poll,
|
||
options,
|
||
my_votes,
|
||
is_expired,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_poll_result_percentages() {
|
||
let poll = MessagePoll {
|
||
id: Uuid::now_v7(),
|
||
message_id: Uuid::now_v7(),
|
||
question: "Best language?".to_string(),
|
||
allow_multiselect: false,
|
||
max_selections: None,
|
||
expires_at: None,
|
||
total_votes: 10,
|
||
created_at: Utc::now(),
|
||
updated_at: Utc::now(),
|
||
};
|
||
|
||
let options = vec![
|
||
MessagePollOption {
|
||
id: Uuid::now_v7(),
|
||
poll_id: poll.id,
|
||
text: "Rust".to_string(),
|
||
emoji: None,
|
||
vote_count: 7,
|
||
position: 0,
|
||
},
|
||
MessagePollOption {
|
||
id: Uuid::now_v7(),
|
||
poll_id: poll.id,
|
||
text: "Go".to_string(),
|
||
emoji: None,
|
||
vote_count: 3,
|
||
position: 1,
|
||
},
|
||
];
|
||
|
||
let result = PollResult::from_poll(poll, options, vec![]);
|
||
assert!(!result.is_expired);
|
||
assert_eq!(result.options[0].percentage, 70.0);
|
||
assert_eq!(result.options[1].percentage, 30.0);
|
||
}
|
||
|
||
#[test]
|
||
fn test_poll_result_zero_votes() {
|
||
let poll = MessagePoll {
|
||
id: Uuid::now_v7(),
|
||
message_id: Uuid::now_v7(),
|
||
question: "Empty poll".to_string(),
|
||
allow_multiselect: false,
|
||
max_selections: None,
|
||
expires_at: None,
|
||
total_votes: 0,
|
||
created_at: Utc::now(),
|
||
updated_at: Utc::now(),
|
||
};
|
||
|
||
let result = PollResult::from_poll(poll, vec![], vec![]);
|
||
assert!(result.options.is_empty());
|
||
}
|
||
}
|