//! Forum article CRUD operations on `MessageRepo`. //! //! Articles extend regular messages with forum-specific metadata (title, cover, //! view/like stats, tags). Rendered as waterfall cards in forum channels. use chrono::Utc; use sqlx::Row; use uuid::Uuid; use crate::ImksResult; use crate::models::message::AuthorInfo; use crate::models::message_article::{ArticleCard, ArticleSort, MessageArticle}; use super::message_repo::MessageRepo; use super::pagination::{CursorPage, clamp_limit}; impl MessageRepo { /// Create an article record linked to an existing message. #[allow(clippy::too_many_arguments)] pub async fn create_article( &self, message_id: Uuid, title: &str, summary: Option<&str>, cover_url: Option<&str>, cover_width: Option, cover_height: Option, cover_color: Option<&str>, tags: Option<&serde_json::Value>, ) -> ImksResult { let id = Uuid::now_v7(); let now = Utc::now(); sqlx::query_as::<_, MessageArticle>( r#" INSERT INTO message_article ( id, message_id, title, summary, cover_url, cover_width, cover_height, cover_color, tags, view_count, like_count, bookmark_count, reply_count, is_pinned_to_top, is_answered, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, 0, 0, 0, FALSE, FALSE, $10, $10) RETURNING * "#, ) .bind(id) .bind(message_id) .bind(title) .bind(summary) .bind(cover_url) .bind(cover_width) .bind(cover_height) .bind(cover_color) .bind(tags) .bind(now) .fetch_one(self.pool()) .await .map_err(Into::into) } /// Update an existing article's metadata. Does NOT update the message body. pub async fn update_article( &self, message_id: Uuid, title: Option<&str>, summary: Option<&str>, cover_url: Option<&str>, cover_color: Option<&str>, tags: Option<&serde_json::Value>, ) -> ImksResult> { let now = Utc::now(); sqlx::query_as::<_, MessageArticle>( r#" UPDATE message_article SET title = COALESCE($1, title), summary = COALESCE($2, summary), cover_url = COALESCE($3, cover_url), cover_color = COALESCE($4, cover_color), tags = COALESCE($5, tags), updated_at = $6 WHERE message_id = $7 RETURNING * "#, ) .bind(title) .bind(summary) .bind(cover_url) .bind(cover_color) .bind(tags) .bind(now) .bind(message_id) .fetch_optional(self.pool()) .await .map_err(Into::into) } /// Get an article by its message_id. pub async fn get_article(&self, message_id: Uuid) -> ImksResult> { sqlx::query_as::<_, MessageArticle>("SELECT * FROM message_article WHERE message_id = $1") .bind(message_id) .fetch_optional(self.pool()) .await .map_err(Into::into) } /// List articles in a forum channel with id-based cursor pagination. pub async fn list_articles( &self, channel_id: Uuid, sort: ArticleSort, before: Option<(i64, Uuid)>, limit: Option, ) -> ImksResult> { let effective_limit = clamp_limit(limit); let fetch_limit = effective_limit + 1; let cursor_id = before.map(|(_, id)| id); let order_by = match sort { ArticleSort::LatestActivity => "a.last_reply_at DESC NULLS LAST, m.id DESC", ArticleSort::Newest => "m.id DESC", ArticleSort::MostViewed => "a.view_count DESC, m.id DESC", ArticleSort::MostLiked => "a.like_count DESC, m.id DESC", ArticleSort::PinnedFirst => { "a.is_pinned_to_top DESC, a.last_reply_at DESC NULLS LAST, m.id DESC" } }; let query = if cursor_id.is_some() { format!( r#" SELECT a.*, m.author_id, ( SELECT att.url FROM message_attachment att WHERE att.message_id = a.message_id AND att.content_type LIKE 'image/%' ORDER BY att.created_at LIMIT 1 ) AS first_image_url FROM message_article a JOIN message m ON m.id = a.message_id WHERE m.channel_id = $1 AND m.deleted_at IS NULL AND m.id < $2 ORDER BY {order_by} LIMIT $3 "#, ) } else { format!( r#" SELECT a.*, m.author_id, ( SELECT att.url FROM message_attachment att WHERE att.message_id = a.message_id AND att.content_type LIKE 'image/%' ORDER BY att.created_at LIMIT 1 ) AS first_image_url FROM message_article a JOIN message m ON m.id = a.message_id WHERE m.channel_id = $1 AND m.deleted_at IS NULL ORDER BY {order_by} LIMIT $2 "#, ) }; let rows = if let Some(cursor) = cursor_id { sqlx::query(sqlx::AssertSqlSafe(query.as_str())) .bind(channel_id) .bind(cursor) .bind(fetch_limit) .fetch_all(self.pool()) .await? } else { sqlx::query(sqlx::AssertSqlSafe(query.as_str())) .bind(channel_id) .bind(fetch_limit) .fetch_all(self.pool()) .await? }; // Convert raw rows to MessageArticle let articles: Vec = rows .iter() .map(|r| MessageArticle { id: r.get("id"), message_id: r.get("message_id"), title: r.get("title"), summary: r.get("summary"), cover_url: r.get("cover_url"), cover_width: r.get("cover_width"), cover_height: r.get("cover_height"), cover_color: r.get("cover_color"), tags: r.get("tags"), view_count: r.get("view_count"), like_count: r.get("like_count"), bookmark_count: r.get("bookmark_count"), reply_count: r.get("reply_count"), last_reply_message_id: r.get("last_reply_message_id"), last_reply_at: r.get("last_reply_at"), last_reply_user_id: r.get("last_reply_user_id"), is_pinned_to_top: r.get("is_pinned_to_top"), is_answered: r.get("is_answered"), answered_by: r.get("answered_by"), answered_at: r.get("answered_at"), created_at: r.get("created_at"), updated_at: r.get("updated_at"), }) .collect(); let has_more = articles.len() > effective_limit as usize; let items: Vec = articles .into_iter() .take(effective_limit as usize) .collect(); let next_cursor = if has_more { items.last().map(|a| a.message_id) } else { None }; let cards: Vec = items .into_iter() .zip(rows.iter()) .map(|(article, row)| { let author_id: Uuid = row.get("author_id"); ArticleCard { article, author: AuthorInfo { id: author_id, username: author_id.to_string(), display_name: None, avatar_url: None, is_bot: false, }, tag_names: Vec::new(), first_image_url: row.get("first_image_url"), bookmarked: false, liked: false, } }) .collect(); Ok(CursorPage { items: cards, next_cursor, has_more, }) } /// Increment the view count for an article. pub async fn increment_article_view(&self, message_id: Uuid) -> ImksResult<()> { sqlx::query("UPDATE message_article SET view_count = view_count + 1 WHERE message_id = $1") .bind(message_id) .execute(self.pool()) .await?; Ok(()) } }