use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::error::AppError; use crate::immediate::{ArticleAction, ArticleEvent}; use crate::models::channels::{Article, ArticleComment, ArticleReaction}; use crate::models::common::{ArticleStatus, Visibility}; use crate::service::ImService; use crate::service::im::events::ImEvent; use super::session::ImSession; use super::util::*; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreateArticleParams { pub title: String, pub summary: Option, pub body: String, pub cover_image_url: Option, pub tags: Option>, pub visibility: Option, } #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct UpdateArticleParams { pub title: Option, pub summary: Option, pub body: Option, pub cover_image_url: Option, pub tags: Option>, pub visibility: Option, } #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct ArticleListFilters { pub status: Option, pub tag: Option, pub author_id: Option, } #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreateArticleCommentParams { pub body: String, pub parent_comment_id: Option, } impl ImService { async fn article_realtime(&self, channel_id: Uuid, article_id: Uuid, action: ArticleAction) { let request_id = Uuid::nil(); let event = ArticleEvent { channel_id, article_id, action, }; self.publish(&format!("im.article.{}", channel_id), request_id, &event) .await; self.emit_event(ImEvent::Article { request_id, data: event, }); } pub async fn article_list( &self, ctx: &ImSession, _wk_name: &str, channel_id: Uuid, filters: ArticleListFilters, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = ctx.user; let channel = self.resolve_channel(channel_id).await?; self.ensure_channel_readable(user_uid, &channel).await?; let (limit, offset) = clamp_limit_offset(limit, offset); let status = filters .status .as_deref() .and_then(|s| s.parse::().ok()) .filter(|s| *s != ArticleStatus::Unknown); sqlx::query_as::<_, Article>( "SELECT id, channel_id, author_id, title, slug, summary, body, cover_image_url, \ status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \ views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \ metadata, created_at, updated_at, deleted_at \ FROM article WHERE channel_id = $1 AND deleted_at IS NULL \ AND ($2::text IS NULL OR status::text = $2) \ AND ($3::uuid IS NULL OR author_id = $3) \ AND ($4::text IS NULL OR $4 = ANY(tags)) \ ORDER BY created_at DESC LIMIT $5 OFFSET $6", ) .bind(channel_id) .bind(status.map(|s| s.to_string())) .bind(filters.author_id) .bind(filters.tag.as_deref()) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database) } pub async fn article_get( &self, ctx: &ImSession, _wk_name: &str, channel_id: Uuid, article_id: Uuid, ) -> Result { let user_uid = ctx.user; let channel = self.resolve_channel(channel_id).await?; self.ensure_channel_readable(user_uid, &channel).await?; let article = self.resolve_article(article_id, channel_id).await?; // Increment view count (best-effort, not in a txn) let _ = sqlx::query("UPDATE article SET views_count = views_count + 1 WHERE id = $1") .bind(article_id) .execute(self.ctx.db.writer()) .await; Ok(article) } pub async fn article_create( &self, ctx: &ImSession, _wk_name: &str, channel_id: Uuid, params: CreateArticleParams, ) -> Result { let user_uid = ctx.user; let channel = self.resolve_channel(channel_id).await?; self.ensure_channel_editable(user_uid, &channel).await?; let title = required_text(params.title, "title")?; if title.len() > MAX_ARTICLE_TITLE { return Err(AppError::BadRequest("article title too long".into())); } let body = required_text(params.body, "body")?; let visibility = parse_enum( params.visibility, Visibility::Public, Visibility::Unknown, "visibility", )?; let slug = self.generate_article_slug(channel_id, &title).await?; let now = chrono::Utc::now(); let tags = params.tags.unwrap_or_default(); let article = sqlx::query_as::<_, Article>( "INSERT INTO article \ (id, channel_id, author_id, title, slug, summary, body, cover_image_url, \ status, visibility, tags, cross_posted, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'draft', $9, $10, false, $11, $11) \ RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \ status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \ views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \ metadata, created_at, updated_at, deleted_at", ) .bind(Uuid::now_v7()) .bind(channel_id) .bind(user_uid) .bind(&title) .bind(&slug) .bind(params.summary.as_deref()) .bind(&body) .bind(params.cover_image_url.as_deref()) .bind(visibility) .bind(&tags) .bind(now) .fetch_one(self.ctx.db.writer()) .await .map_err(AppError::Database)?; self.article_realtime(channel_id, article.id, ArticleAction::Created) .await; Ok(article) } pub async fn article_update( &self, ctx: &ImSession, _wk_name: &str, channel_id: Uuid, article_id: Uuid, params: UpdateArticleParams, ) -> Result { let user_uid = ctx.user; let channel = self.resolve_channel(channel_id).await?; let article = self.resolve_article(article_id, channel_id).await?; if article.author_id != user_uid { self.ensure_channel_admin(user_uid, &channel).await?; } let new_title = match params.title { Some(t) => { let t = required_text(t, "title")?; if t.len() > MAX_ARTICLE_TITLE { return Err(AppError::BadRequest("article title too long".into())); } t } None => article.title, }; let new_body = params.body.unwrap_or(article.body); let new_summary = params.summary.or(article.summary); let new_cover = params.cover_image_url.or(article.cover_image_url); let new_tags = params.tags.unwrap_or(article.tags); let visibility = parse_enum( params.visibility, article.visibility, Visibility::Unknown, "visibility", )?; let now = chrono::Utc::now(); let updated = sqlx::query_as::<_, Article>( "UPDATE article SET title = $1, summary = $2, body = $3, cover_image_url = $4, \ tags = $5, visibility = $6, updated_at = $7 \ WHERE id = $8 AND deleted_at IS NULL \ RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \ status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \ views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \ metadata, created_at, updated_at, deleted_at", ) .bind(&new_title) .bind(&new_summary) .bind(&new_body) .bind(&new_cover) .bind(&new_tags) .bind(visibility) .bind(now) .bind(article_id) .fetch_one(self.ctx.db.writer()) .await .map_err(AppError::Database)?; self.article_realtime(channel_id, article_id, ArticleAction::Updated) .await; Ok(updated) } pub async fn article_publish( &self, ctx: &ImSession, _wk_name: &str, channel_id: Uuid, article_id: Uuid, ) -> Result { let user_uid = ctx.user; let channel = self.resolve_channel(channel_id).await?; let article = self.resolve_article(article_id, channel_id).await?; if article.author_id != user_uid { self.ensure_channel_editable(user_uid, &channel).await?; } if article.status != ArticleStatus::Draft && article.status != ArticleStatus::Scheduled { return Err(AppError::BadRequest( "only draft or scheduled articles can be published".into(), )); } let now = chrono::Utc::now(); let published = sqlx::query_as::<_, Article>( "UPDATE article SET status = 'published', published_at = $1, published_by = $2, \ updated_at = $1 \ WHERE id = $3 AND deleted_at IS NULL \ RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \ status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \ views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \ metadata, created_at, updated_at, deleted_at", ) .bind(now) .bind(user_uid) .bind(article_id) .fetch_one(self.ctx.db.writer()) .await .map_err(AppError::Database)?; // Trigger cross-posts to followers if let Err(e) = self .cross_post_article(article_id, channel_id, user_uid) .await { tracing::warn!(article_id = %article_id, error = %e, "cross-post failed"); } tracing::info!(article_id = %article_id, "Article published"); self.article_realtime(channel_id, article_id, ArticleAction::Published) .await; Ok(published) } pub async fn article_unpublish( &self, ctx: &ImSession, _wk_name: &str, channel_id: Uuid, article_id: Uuid, ) -> Result { let user_uid = ctx.user; let channel = self.resolve_channel(channel_id).await?; let article = self.resolve_article(article_id, channel_id).await?; if article.author_id != user_uid { self.ensure_channel_editable(user_uid, &channel).await?; } let now = chrono::Utc::now(); let unpublished = sqlx::query_as::<_, Article>( "UPDATE article SET status = 'unpublished', unpublished_at = $1, updated_at = $1 \ WHERE id = $2 AND status = 'published' AND deleted_at IS NULL \ RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \ status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \ views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \ metadata, created_at, updated_at, deleted_at", ) .bind(now) .bind(article_id) .fetch_one(self.ctx.db.writer()) .await .map_err(AppError::Database)?; self.article_realtime(channel_id, article_id, ArticleAction::Unpublished) .await; Ok(unpublished) } pub async fn article_schedule( &self, ctx: &ImSession, _wk_name: &str, channel_id: Uuid, article_id: Uuid, scheduled_at: chrono::DateTime, ) -> Result { let user_uid = ctx.user; let channel = self.resolve_channel(channel_id).await?; let article = self.resolve_article(article_id, channel_id).await?; if article.author_id != user_uid { self.ensure_channel_editable(user_uid, &channel).await?; } if article.status != ArticleStatus::Draft { return Err(AppError::BadRequest( "only draft articles can be scheduled".into(), )); } let now = chrono::Utc::now(); let scheduled = sqlx::query_as::<_, Article>( "UPDATE article SET status = 'scheduled', scheduled_at = $1, updated_at = $2 \ WHERE id = $3 AND deleted_at IS NULL \ RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \ status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \ views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \ metadata, created_at, updated_at, deleted_at", ) .bind(scheduled_at) .bind(now) .bind(article_id) .fetch_one(self.ctx.db.writer()) .await .map_err(AppError::Database)?; self.article_realtime(channel_id, article_id, ArticleAction::Updated) .await; Ok(scheduled) } pub async fn article_delete( &self, ctx: &ImSession, _wk_name: &str, channel_id: Uuid, article_id: Uuid, ) -> Result<(), AppError> { let user_uid = ctx.user; let channel = self.resolve_channel(channel_id).await?; let article = self.resolve_article(article_id, channel_id).await?; if article.author_id != user_uid { self.ensure_channel_admin(user_uid, &channel).await?; } let now = chrono::Utc::now(); let result = sqlx::query( "UPDATE article SET deleted_at = $1, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL", ) .bind(now) .bind(article_id) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "article not found")?; self.article_realtime(channel_id, article_id, ArticleAction::Deleted) .await; Ok(()) } pub async fn article_comment_list( &self, ctx: &ImSession, _wk_name: &str, channel_id: Uuid, article_id: Uuid, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = ctx.user; let channel = self.resolve_channel(channel_id).await?; self.ensure_channel_readable(user_uid, &channel).await?; let (limit, offset) = clamp_limit_offset(limit, offset); sqlx::query_as::<_, ArticleComment>( "SELECT id, article_id, channel_id, author_id, parent_comment_id, body, \ edited_at, deleted_at, created_at, updated_at \ FROM article_comment WHERE article_id = $1 AND deleted_at IS NULL \ ORDER BY created_at ASC LIMIT $2 OFFSET $3", ) .bind(article_id) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database) } pub async fn article_comment_create( &self, ctx: &ImSession, _wk_name: &str, channel_id: Uuid, article_id: Uuid, params: CreateArticleCommentParams, ) -> Result { let user_uid = ctx.user; let channel = self.resolve_channel(channel_id).await?; self.ensure_channel_readable(user_uid, &channel).await?; let body = required_text(params.body, "body")?; let now = chrono::Utc::now(); let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; sqlx::query("SET LOCAL app.current_user_id = $1") .bind(user_uid) .execute(&mut *txn) .await .map_err(AppError::Database)?; let comment = sqlx::query_as::<_, ArticleComment>( "INSERT INTO article_comment \ (id, article_id, channel_id, author_id, parent_comment_id, body, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $7) \ RETURNING id, article_id, channel_id, author_id, parent_comment_id, body, \ edited_at, deleted_at, created_at, updated_at", ) .bind(Uuid::now_v7()) .bind(article_id) .bind(channel_id) .bind(user_uid) .bind(params.parent_comment_id) .bind(&body) .bind(now) .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; sqlx::query("UPDATE article SET comments_count = comments_count + 1 WHERE id = $1") .bind(article_id) .execute(&mut *txn) .await .map_err(AppError::Database)?; txn.commit().await.map_err(|_| AppError::TxnError)?; self.article_realtime(channel_id, article_id, ArticleAction::Updated) .await; Ok(comment) } pub async fn article_comment_delete( &self, ctx: &ImSession, _wk_name: &str, channel_id: Uuid, article_id: Uuid, comment_id: Uuid, ) -> Result<(), AppError> { let user_uid = ctx.user; let channel = self.resolve_channel(channel_id).await?; let comment = sqlx::query_as::<_, ArticleComment>( "SELECT id, article_id, channel_id, author_id, parent_comment_id, body, \ edited_at, deleted_at, created_at, updated_at \ FROM article_comment WHERE id = $1 AND article_id = $2 AND deleted_at IS NULL", ) .bind(comment_id) .bind(article_id) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)? .ok_or(AppError::NotFound("comment not found".into()))?; if comment.author_id != user_uid { self.ensure_channel_admin(user_uid, &channel).await?; } let now = chrono::Utc::now(); let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; sqlx::query("SET LOCAL app.current_user_id = $1") .bind(user_uid) .execute(&mut *txn) .await .map_err(AppError::Database)?; sqlx::query("UPDATE article_comment SET deleted_at = $1, updated_at = $1 WHERE id = $2") .bind(now) .bind(comment_id) .execute(&mut *txn) .await .map_err(AppError::Database)?; sqlx::query( "UPDATE article SET comments_count = GREATEST(comments_count - 1, 0) WHERE id = $1", ) .bind(article_id) .execute(&mut *txn) .await .map_err(AppError::Database)?; txn.commit().await.map_err(|_| AppError::TxnError)?; self.article_realtime(channel_id, article_id, ArticleAction::Updated) .await; Ok(()) } pub async fn article_reaction_add( &self, ctx: &ImSession, _wk_name: &str, channel_id: Uuid, article_id: Uuid, content: &str, ) -> Result { let user_uid = ctx.user; let channel = self.resolve_channel(channel_id).await?; self.ensure_channel_readable(user_uid, &channel).await?; let content = required_text(content.to_string(), "content")?; let now = chrono::Utc::now(); let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; sqlx::query("SET LOCAL app.current_user_id = $1") .bind(user_uid) .execute(&mut *txn) .await .map_err(AppError::Database)?; let reaction = sqlx::query_as::<_, ArticleReaction>( "INSERT INTO article_reaction (id, article_id, channel_id, user_id, content, created_at) \ VALUES ($1, $2, $3, $4, $5, $6) \ ON CONFLICT (article_id, user_id, content) DO NOTHING \ RETURNING id, article_id, channel_id, user_id, content, created_at", ) .bind(Uuid::now_v7()) .bind(article_id) .bind(channel_id) .bind(user_uid) .bind(&content) .bind(now) .fetch_optional(&mut *txn) .await .map_err(AppError::Database)?; if reaction.is_some() { sqlx::query("UPDATE article SET reactions_count = reactions_count + 1 WHERE id = $1") .bind(article_id) .execute(&mut *txn) .await .map_err(AppError::Database)?; } txn.commit().await.map_err(|_| AppError::TxnError)?; let reaction = reaction.ok_or(AppError::Conflict("reaction already exists".into()))?; self.article_realtime(channel_id, article_id, ArticleAction::Updated) .await; Ok(reaction) } pub async fn article_reaction_remove( &self, ctx: &ImSession, _wk_name: &str, channel_id: Uuid, article_id: Uuid, content: &str, ) -> Result<(), AppError> { let user_uid = ctx.user; let channel = self.resolve_channel(channel_id).await?; self.ensure_channel_readable(user_uid, &channel).await?; let _now = chrono::Utc::now(); let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; sqlx::query("SET LOCAL app.current_user_id = $1") .bind(user_uid) .execute(&mut *txn) .await .map_err(AppError::Database)?; let result = sqlx::query( "DELETE FROM article_reaction WHERE article_id = $1 AND user_id = $2 AND content = $3", ) .bind(article_id) .bind(user_uid) .bind(content) .execute(&mut *txn) .await .map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "reaction not found")?; sqlx::query( "UPDATE article SET reactions_count = GREATEST(reactions_count - 1, 0) WHERE id = $1", ) .bind(article_id) .execute(&mut *txn) .await .map_err(AppError::Database)?; txn.commit().await.map_err(|_| AppError::TxnError)?; self.article_realtime(channel_id, article_id, ArticleAction::Updated) .await; Ok(()) } pub(crate) async fn resolve_article( &self, article_id: Uuid, channel_id: Uuid, ) -> Result { sqlx::query_as::<_, Article>( "SELECT id, channel_id, author_id, title, slug, summary, body, cover_image_url, \ status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \ views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \ metadata, created_at, updated_at, deleted_at \ FROM article WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL", ) .bind(article_id) .bind(channel_id) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)? .ok_or(AppError::NotFound("article not found".into())) } async fn generate_article_slug( &self, channel_id: Uuid, title: &str, ) -> Result { let base = slugify(title); let mut slug = base.clone(); let mut counter = 1u32; loop { let exists: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM article WHERE channel_id = $1 AND slug = $2)", ) .bind(channel_id) .bind(&slug) .fetch_one(self.ctx.db.reader()) .await .map_err(AppError::Database)?; if !exists { return Ok(slug); } slug = format!("{base}-{counter}"); counter += 1; if counter > 100 { return Err(AppError::InternalServerError( "failed to generate unique slug".into(), )); } } } }