//! Forum article event handlers on `MessageService`. //! //! Articles are long-form posts in forum channels. Creating an article //! creates both a `message` (with `message_type = "article"`) and a //! `message_article` row linked to it. use std::sync::Arc; use uuid::Uuid; use crate::ImksError; use crate::models::message::MessageType; use crate::models::message_article::ArticleSort; use crate::repo::CreateMessageInput; use crate::socket::socket::Socket; use super::message::MessageService; impl MessageService { /// Handle `article:create` — create a forum article (message + article metadata). pub async fn create_article( &self, socket: Arc, data: &serde_json::Value, ) -> crate::ImksResult<()> { let user_id = self.user_id(&socket)?; let arr = data .as_array() .and_then(|a| a.first()) .ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?; let channel_id: Uuid = Self::parse_field(arr, "channel_id")?; let title: String = Self::parse_field(arr, "title")?; let body: String = Self::parse_field(arr, "body")?; let summary: Option = Self::parse_optional(arr, "summary")?; let cover_url: Option = Self::parse_optional(arr, "cover_url")?; let tags: Option = Self::parse_optional(arr, "tags")?; self.validate_channel_write(&channel_id.to_string(), &user_id.to_string()) .await?; // Create the message first (with article type) let input = CreateMessageInput { channel_id, author_id: user_id, thread_id: None, reply_to_message_id: None, message_type: MessageType::Article.as_str().into(), body, metadata: None, system: false, }; let message = self.repo.create(&input).await?; // Create the article record let article = self .repo .create_article( message.id, &title, summary.as_deref(), cover_url.as_deref(), None, None, None, tags.as_ref(), ) .await?; if let Some(ns) = self.namespaces.get_namespace(&socket.namespace) { ns.emit_to_room( &channel_id.to_string(), "article:created", serde_json::json!({ "message": message, "article": article, }), ) .await; } tracing::info!(article_id = %article.id, %channel_id, %user_id, "Article created"); Ok(()) } /// Handle `article:update` — update article title, summary, cover, tags. pub async fn update_article( &self, socket: Arc, data: &serde_json::Value, ) -> crate::ImksResult<()> { let user_id = self.user_id(&socket)?; let arr = data .as_array() .and_then(|a| a.first()) .ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?; let message_id: Uuid = Self::parse_field(arr, "message_id")?; let title: Option = Self::parse_optional(arr, "title")?; let summary: Option = Self::parse_optional(arr, "summary")?; let cover_url: Option = Self::parse_optional(arr, "cover_url")?; let cover_color: Option = Self::parse_optional(arr, "cover_color")?; let tags: Option = Self::parse_optional(arr, "tags")?; let existing = self .repo .get(message_id) .await? .ok_or_else(|| ImksError::NotFound(format!("message {message_id}")))?; self.ensure_author_or_mod( existing.author_id, &existing.channel_id.to_string(), user_id, ) .await?; // Update article body if provided if let Ok(new_body) = Self::parse_field::(arr, "body") && !new_body.is_empty() { let old_body = existing.body.clone(); self.repo.update_body(message_id, &new_body).await?; self.repo .record_edit(message_id, user_id, &old_body, &new_body) .await?; } if let Some(updated) = self .repo .update_article( message_id, title.as_deref(), summary.as_deref(), cover_url.as_deref(), cover_color.as_deref(), tags.as_ref(), ) .await? && let Some(ns) = self.namespaces.get_namespace(&socket.namespace) { ns.emit_to_room( &existing.channel_id.to_string(), "article:updated", serde_json::to_value(&updated).unwrap_or_default(), ) .await; } Ok(()) } /// Handle `article:list` — list articles in a forum channel. pub async fn list_articles( &self, socket: Arc, data: &serde_json::Value, ) -> crate::ImksResult<()> { let user_id = self.user_id(&socket)?; let arr = data .as_array() .and_then(|a| a.first()) .ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?; let channel_id: Uuid = Self::parse_field(arr, "channel_id")?; self.ensure_readable(&channel_id.to_string(), &user_id.to_string()) .await?; self.ensure_member(&channel_id.to_string(), &user_id.to_string()) .await?; let before: Option<(i64, Uuid)> = None; let limit: Option = Self::parse_optional(arr, "limit")?; let page = self .repo .list_articles(channel_id, ArticleSort::LatestActivity, before, limit) .await?; let _ = socket.emit( "article:loaded", serde_json::to_value(&page).unwrap_or_default(), ); Ok(()) } /// Handle `article:delete` — soft-delete an article. pub async fn delete_article( &self, socket: Arc, data: &serde_json::Value, ) -> crate::ImksResult<()> { let user_id = self.user_id(&socket)?; let arr = data .as_array() .and_then(|a| a.first()) .ok_or_else(|| ImksError::InvalidInput("Expected [payload] array".into()))?; let message_id: Uuid = Self::parse_field(arr, "message_id")?; let _channel_id: Uuid = Self::parse_field(arr, "channel_id")?; let existing = self .repo .get(message_id) .await? .ok_or_else(|| ImksError::NotFound(format!("message {message_id}")))?; self.ensure_author_or_mod( existing.author_id, &existing.channel_id.to_string(), user_id, ) .await?; self.repo.soft_delete(message_id).await?; if let Some(ns) = self.namespaces.get_namespace(&socket.namespace) { ns.emit_to_room(&existing.channel_id.to_string(), "article:deleted", serde_json::json!({"id": message_id.to_string(), "channel_id": existing.channel_id.to_string()}), ).await; } Ok(()) } }