//! Forum article / long-form post — maps to `message_article` table. //! //! Forum articles extend a regular [`Message`] with title, cover image, tags, //! and view/like stats. Rendered as waterfall cards in forum channel views. //! One article per message (1:1), linked via `message_id`. //! //! A message is an article when `message.message_type = "article"`. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; /// Extended metadata for a forum article / post. #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct MessageArticle { pub id: Uuid, /// FK to `message.id`. UNIQUE constraint ensures 1:1. pub message_id: Uuid, /// Article title (plain text, max 256 chars). pub title: String, /// Short excerpt for card preview (plain text, max 512 chars). pub summary: Option, /// Cover image URL (displayed at the top of the card). pub cover_url: Option, /// Cover image width in pixels (for waterfall layout height calculation). pub cover_width: Option, /// Cover image height in pixels. pub cover_height: Option, /// Cover image dominant color (hex, for placeholder while loading). pub cover_color: Option, /// Tag IDs referencing `forum_tag` table, stored as JSON array. pub tags: Option, /// View count (denormalized, incremented on read). pub view_count: i64, /// Reaction / like count (denormalized). pub like_count: i64, /// Bookmark count (denormalized). pub bookmark_count: i64, /// Reply count (denormalized, threads inside the article). pub reply_count: i64, /// Most recent reply message id. pub last_reply_message_id: Option, /// Most recent reply timestamp. pub last_reply_at: Option>, /// User id of the last replier. pub last_reply_user_id: Option, /// Whether the article is pinned to the top of the forum channel. pub is_pinned_to_top: bool, /// Whether the question has been answered / resolved. pub is_answered: bool, /// Who marked it as answered. pub answered_by: Option, /// When it was marked as answered. pub answered_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, } /// Waterfall card view — article enriched with author info + first attachment /// (cover fallback). Returned by forum list APIs. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ArticleCard { #[serde(flatten)] pub article: MessageArticle, /// Author display info. pub author: super::message::AuthorInfo, /// Resolved tag names (e.g. ["Bug Report", "High Priority"]). pub tag_names: Vec, /// First image attachment URL (fallback when cover_url is NULL). pub first_image_url: Option, /// Whether the current user has bookmarked this article. pub bookmarked: bool, /// Whether the current user has liked this article. pub liked: bool, } /// Input payload for creating a new forum article. /// /// Sent by the client when composing a forum post. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateArticleInput { pub channel_id: Uuid, pub title: String, pub body: String, pub summary: Option, pub cover_url: Option, pub tags: Option>, } /// Input payload for updating an existing forum article. /// /// All fields are optional — only provided fields are updated. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateArticleInput { pub title: Option, pub body: Option, pub summary: Option, pub cover_url: Option, pub cover_color: Option, pub tags: Option>, } /// Sort modes for forum article listing. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum ArticleSort { /// Most recent activity first. #[default] LatestActivity, /// Newest articles first. Newest, /// Most viewed first. MostViewed, /// Most liked first. MostLiked, /// Pinned articles first, then by latest activity. PinnedFirst, } #[cfg(test)] mod tests { use super::*; #[test] fn test_article_card_serialize() { let article = MessageArticle { id: Uuid::now_v7(), message_id: Uuid::now_v7(), title: "Bug Report: Login fails on Safari".into(), summary: Some("Users report that login fails on Safari 18...".into()), cover_url: Some("https://cdn.example.com/covers/bug.png".into()), cover_width: Some(1200), cover_height: Some(630), cover_color: Some("#FF6B6B".into()), tags: Some(serde_json::json!(["01909a", "01909b"])), view_count: 142, like_count: 7, bookmark_count: 3, reply_count: 5, last_reply_message_id: Some(Uuid::now_v7()), last_reply_at: Some(Utc::now()), last_reply_user_id: Some(Uuid::now_v7()), is_pinned_to_top: true, is_answered: false, answered_by: None, answered_at: None, created_at: Utc::now(), updated_at: Utc::now(), }; let card = ArticleCard { article, author: super::super::message::AuthorInfo { id: Uuid::now_v7(), username: "alice".into(), display_name: Some("Alice".into()), avatar_url: None, is_bot: false, }, tag_names: vec!["Bug Report".into(), "High Priority".into()], first_image_url: None, bookmarked: false, liked: true, }; let json = serde_json::to_value(&card).unwrap(); assert_eq!(json["title"], "Bug Report: Login fails on Safari"); assert_eq!(json["view_count"], 142); assert_eq!(json["author"]["username"], "alice"); assert_eq!(json["tag_names"][0], "Bug Report"); assert_eq!(json["is_pinned_to_top"], true); } #[test] fn test_article_sort_serialize() { let sort = ArticleSort::MostViewed; let json = serde_json::to_value(sort).unwrap(); assert_eq!(json, "most_viewed"); } #[test] fn test_create_article_input_serialize() { let input = CreateArticleInput { channel_id: Uuid::now_v7(), title: "New Feature Proposal".into(), body: "I'd like to propose...".into(), summary: Some("A proposal for...".into()), cover_url: None, tags: Some(vec![Uuid::now_v7()]), }; let json = serde_json::to_value(&input).unwrap(); assert_eq!(json["title"], "New Feature Proposal"); assert_eq!(json["tags"].as_array().unwrap().len(), 1); } }