//! 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, /// When voting closes (NULL = no expiry). pub expires_at: Option>, /// Total number of votes cast (denormalized for fast reads). pub total_votes: i64, pub created_at: DateTime, pub updated_at: DateTime, } /// 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, /// 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, } /// Aggregated poll results for API responses. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PollResult { pub poll: MessagePoll, pub options: Vec, /// Which options the current user voted for (empty if not voted). pub my_votes: Vec, /// 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, my_votes: Vec, ) -> 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()); } }