use uuid::Uuid; use crate::event::{RoomInfo, UserInfo, article}; use crate::http::handler::WsHandler; use crate::http::out_event::WsOutEvent; use crate::{ChannelBus, ChannelError, ChannelResult}; impl WsHandler { pub(super) async fn article_create( bus: &ChannelBus, user_id: Uuid, channel: Uuid, title: String, cover_url: Option, content: String, content_type: Option, summary: Option, tags: Option>, status: Option, ) -> ChannelResult> { Self::ensure_room_access(bus, user_id, channel).await?; let ctype = content_type.unwrap_or_else(|| "markdown".to_string()); let st = status.unwrap_or_else(|| "published".to_string()); let tag_list = tags.unwrap_or_default(); let row = db::sqlx::query_as::<_, model::channel::ChannelArticleModel>( "INSERT INTO channel_article \ (channel, author, title, cover_url, content, content_type, summary, tags, status, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now(), now()) \ RETURNING id, channel, author, title, cover_url, content, content_type, summary, \ tags, is_pinned, view_count, like_count, comment_count, status, \ created_at, updated_at, deleted_at", ) .bind(channel) .bind(user_id) .bind(&title) .bind(&cover_url) .bind(&content) .bind(&ctype) .bind(&summary) .bind(&tag_list) .bind(&st) .fetch_one(bus.inner.db.writer()) .await?; let author = bus .lookup_user(user_id) .await .unwrap_or_else(|_| UserInfo::unknown(user_id)); let room = bus .lookup_room(channel) .await .unwrap_or_else(|_| RoomInfo::unknown(channel)); let item = article_item_from_model(&row, &author); let data = article::ArticleCreatedService { article: item, channel: room.clone(), author: author.clone(), }; bus.publish_room_event(channel, "article.created", &data).await?; Ok(Some(WsOutEvent::ArticleCreated { room, data, })) } pub(super) async fn article_update( bus: &ChannelBus, user_id: Uuid, article_id: Uuid, title: Option, cover_url: Option, content: Option, content_type: Option, summary: Option, tags: Option>, is_pinned: Option, status: Option, ) -> ChannelResult> { let old = db::sqlx::query_as::<_, model::channel::ChannelArticleModel>( "SELECT id, channel, author, title, cover_url, content, content_type, summary, \ tags, is_pinned, view_count, like_count, comment_count, status, \ created_at, updated_at, deleted_at \ FROM channel_article WHERE id = $1 AND deleted_at IS NULL", ) .bind(article_id) .fetch_optional(bus.inner.db.reader()) .await? .ok_or(ChannelError::RoomNotFound)?; if old.author != user_id { return Err(ChannelError::AccessDenied); } Self::ensure_room_access(bus, user_id, old.channel).await?; let new_title = title.unwrap_or(old.title.clone()); let new_cover = cover_url.or(old.cover_url.clone()); let new_content = content.unwrap_or(old.content.clone()); let new_ctype = content_type.unwrap_or(old.content_type.clone()); let new_summary = summary.or(old.summary.clone()); let new_tags = tags.unwrap_or(old.tags.clone()); let new_pinned = is_pinned.unwrap_or(old.is_pinned); let new_status = status.unwrap_or(old.status.clone()); let row = db::sqlx::query_as::<_, model::channel::ChannelArticleModel>( "UPDATE channel_article \ SET title = $2, cover_url = $3, content = $4, content_type = $5, \ summary = $6, tags = $7, is_pinned = $8, status = $9, updated_at = now() \ WHERE id = $1 AND deleted_at IS NULL \ RETURNING id, channel, author, title, cover_url, content, content_type, summary, \ tags, is_pinned, view_count, like_count, comment_count, status, \ created_at, updated_at, deleted_at", ) .bind(article_id) .bind(&new_title) .bind(&new_cover) .bind(&new_content) .bind(&new_ctype) .bind(&new_summary) .bind(&new_tags) .bind(new_pinned) .bind(&new_status) .fetch_one(bus.inner.db.writer()) .await?; let author = bus .lookup_user(row.author) .await .unwrap_or_else(|_| UserInfo::unknown(row.author)); let room = bus .lookup_room(row.channel) .await .unwrap_or_else(|_| RoomInfo::unknown(row.channel)); let item = article_item_from_model(&row, &author); let data = article::ArticleUpdatedService { article: item, channel: room.clone(), }; bus.publish_room_event(row.channel, "article.updated", &data).await?; Ok(Some(WsOutEvent::ArticleUpdated { room, data, })) } pub(super) async fn article_delete( bus: &ChannelBus, user_id: Uuid, article_id: Uuid, ) -> ChannelResult> { let old = db::sqlx::query_as::<_, model::channel::ChannelArticleModel>( "SELECT id, channel, author, title, cover_url, content, content_type, summary, \ tags, is_pinned, view_count, like_count, comment_count, status, \ created_at, updated_at, deleted_at \ FROM channel_article WHERE id = $1 AND deleted_at IS NULL", ) .bind(article_id) .fetch_optional(bus.inner.db.reader()) .await? .ok_or(ChannelError::RoomNotFound)?; if old.author != user_id { return Err(ChannelError::AccessDenied); } db::sqlx::query( "UPDATE channel_article SET deleted_at = now(), updated_at = now() \ WHERE id = $1 AND deleted_at IS NULL", ) .bind(article_id) .execute(bus.inner.db.writer()) .await?; let deleted_by = bus .lookup_user(user_id) .await .unwrap_or_else(|_| UserInfo::unknown(user_id)); let room = bus .lookup_room(old.channel) .await .unwrap_or_else(|_| RoomInfo::unknown(old.channel)); let data = article::ArticleDeletedService { article_id, channel: room.clone(), deleted_by: deleted_by.clone(), }; bus.publish_room_event(old.channel, "article.deleted", &data).await?; Ok(Some(WsOutEvent::ArticleDeleted { room, data, })) } pub(super) async fn article_list( bus: &ChannelBus, user_id: Uuid, channel: Uuid, before: Option, limit: Option, ) -> ChannelResult> { Self::ensure_room_access(bus, user_id, channel).await?; let lim = limit.unwrap_or(20).min(50); let rows = if let Some(cursor) = before { // Cursor-based pagination: get articles older than the cursor db::sqlx::query_as::<_, model::channel::ChannelArticleCard>( "SELECT id, channel, author, title, cover_url, summary, tags, \ like_count, comment_count, view_count, created_at \ FROM channel_article \ WHERE channel = $1 AND deleted_at IS NULL \ AND created_at < (SELECT created_at FROM channel_article WHERE id = $2) \ ORDER BY is_pinned DESC, created_at DESC \ LIMIT $3", ) .bind(channel) .bind(cursor) .bind(lim) .fetch_all(bus.inner.db.reader()) .await? } else { db::sqlx::query_as::<_, model::channel::ChannelArticleCard>( "SELECT id, channel, author, title, cover_url, summary, tags, \ like_count, comment_count, view_count, created_at \ FROM channel_article \ WHERE channel = $1 AND deleted_at IS NULL \ ORDER BY is_pinned DESC, created_at DESC \ LIMIT $2", ) .bind(channel) .bind(lim) .fetch_all(bus.inner.db.reader()) .await? }; let total: (i64,) = db::sqlx::query_as("SELECT COUNT(*) FROM channel_article WHERE channel = $1 AND deleted_at IS NULL") .bind(channel) .fetch_one(bus.inner.db.reader()) .await?; let author_ids: Vec = rows.iter().map(|r| r.author).collect(); let users = bus.lookup_users(&author_ids).await?; let articles: Vec = rows .iter() .map(|r| { let author = users .get(&r.author) .cloned() .unwrap_or_else(|| UserInfo::unknown(r.author)); article_item_from_card(r, &author) }) .collect(); let data = article::ArticleListService { has_more: articles.len() as i64 >= lim, articles, total: total.0, }; Ok(Some(WsOutEvent::ArticleList { data })) } pub(super) async fn article_get( bus: &ChannelBus, user_id: Uuid, article_id: Uuid, ) -> ChannelResult> { let row = db::sqlx::query_as::<_, model::channel::ChannelArticleModel>( "SELECT id, channel, author, title, cover_url, content, content_type, summary, \ tags, is_pinned, view_count, like_count, comment_count, status, \ created_at, updated_at, deleted_at \ FROM channel_article WHERE id = $1 AND deleted_at IS NULL", ) .bind(article_id) .fetch_optional(bus.inner.db.reader()) .await? .ok_or(ChannelError::RoomNotFound)?; Self::ensure_room_access(bus, user_id, row.channel).await?; // Increment view count (fire-and-forget) let _ = db::sqlx::query( "UPDATE channel_article SET view_count = view_count + 1 WHERE id = $1", ) .bind(article_id) .execute(bus.inner.db.writer()) .await; let author = bus .lookup_user(row.author) .await .unwrap_or_else(|_| UserInfo::unknown(row.author)); let card = article_item_from_model(&row, &author); let detail = article::ArticleDetail { content: row.content, card, }; Ok(Some(WsOutEvent::ArticleDetail { data: detail })) } pub(super) async fn article_like( bus: &ChannelBus, user_id: Uuid, article_id: Uuid, like: bool, ) -> ChannelResult> { let art = db::sqlx::query_as::<_, model::channel::ChannelArticleModel>( "SELECT id, channel, author, title, cover_url, content, content_type, summary, \ tags, is_pinned, view_count, like_count, comment_count, status, \ created_at, updated_at, deleted_at \ FROM channel_article WHERE id = $1 AND deleted_at IS NULL", ) .bind(article_id) .fetch_optional(bus.inner.db.reader()) .await? .ok_or(ChannelError::RoomNotFound)?; Self::ensure_room_access(bus, user_id, art.channel).await?; let new_count = if like { let affected = db::sqlx::query( "INSERT INTO channel_article_like (article, \"user\") VALUES ($1, $2) ON CONFLICT DO NOTHING", ) .bind(article_id) .bind(user_id) .execute(bus.inner.db.writer()) .await?; if affected.rows_affected() == 0 { return Ok(None); } let row: (i64,) = db::sqlx::query_as( "UPDATE channel_article SET like_count = like_count + 1 WHERE id = $1 RETURNING like_count", ) .bind(article_id) .fetch_one(bus.inner.db.writer()) .await?; row.0 } else { let affected = db::sqlx::query( "DELETE FROM channel_article_like WHERE article = $1 AND \"user\" = $2", ) .bind(article_id) .bind(user_id) .execute(bus.inner.db.writer()) .await?; if affected.rows_affected() == 0 { return Ok(None); } let row: (i64,) = db::sqlx::query_as( "UPDATE channel_article SET like_count = GREATEST(like_count - 1, 0) WHERE id = $1 RETURNING like_count", ) .bind(article_id) .fetch_one(bus.inner.db.writer()) .await?; row.0 }; let user = bus .lookup_user(user_id) .await .unwrap_or_else(|_| UserInfo::unknown(user_id)); let room = bus .lookup_room(art.channel) .await .unwrap_or_else(|_| RoomInfo::unknown(art.channel)); if like { let data = article::ArticleLikedService { article_id, channel: room.clone(), user, like_count: new_count, }; bus.publish_room_event(art.channel, "article.liked", &data).await?; Ok(Some(WsOutEvent::ArticleLiked { room, data })) } else { let data = article::ArticleUnlikedService { article_id, channel: room.clone(), user, like_count: new_count, }; bus.publish_room_event(art.channel, "article.unliked", &data).await?; Ok(Some(WsOutEvent::ArticleUnliked { room, data })) } } pub(super) async fn article_comment_create( bus: &ChannelBus, user_id: Uuid, article_id: Uuid, content: String, parent: Option, ) -> ChannelResult> { let art = db::sqlx::query_as::<_, model::channel::ChannelArticleModel>( "SELECT id, channel, author, title, cover_url, content, content_type, summary, \ tags, is_pinned, view_count, like_count, comment_count, status, \ created_at, updated_at, deleted_at \ FROM channel_article WHERE id = $1 AND deleted_at IS NULL", ) .bind(article_id) .fetch_optional(bus.inner.db.reader()) .await? .ok_or(ChannelError::RoomNotFound)?; Self::ensure_room_access(bus, user_id, art.channel).await?; let row = db::sqlx::query_as::<_, model::channel::ArticleCommentModel>( "INSERT INTO channel_article_comment (article, author, parent, content, created_at, updated_at) \ VALUES ($1, $2, $3, $4, now(), now()) \ RETURNING id, article, author, parent, content, created_at, updated_at, deleted_at", ) .bind(article_id) .bind(user_id) .bind(parent) .bind(&content) .fetch_one(bus.inner.db.writer()) .await?; let count_row: (i64,) = db::sqlx::query_as( "UPDATE channel_article SET comment_count = comment_count + 1 WHERE id = $1 RETURNING comment_count", ) .bind(article_id) .fetch_one(bus.inner.db.writer()) .await?; let author = bus .lookup_user(user_id) .await .unwrap_or_else(|_| UserInfo::unknown(user_id)); let room = bus .lookup_room(art.channel) .await .unwrap_or_else(|_| RoomInfo::unknown(art.channel)); let item = model::channel::ArticleCommentItem { id: row.id, article: row.article, parent: row.parent, content: row.content, author: row.author, created_at: row.created_at, updated_at: row.updated_at, }; let data = article::ArticleCommentCreatedService { comment: item, channel: room.clone(), author, comment_count: count_row.0, }; bus.publish_room_event(art.channel, "article.comment.created", &data).await?; Ok(Some(WsOutEvent::ArticleCommentCreated { room, data })) } pub(super) async fn article_comment_list( bus: &ChannelBus, user_id: Uuid, article_id: Uuid, before: Option, limit: Option, ) -> ChannelResult> { let art = db::sqlx::query_as::<_, (Uuid,)>( "SELECT channel FROM channel_article WHERE id = $1 AND deleted_at IS NULL", ) .bind(article_id) .fetch_optional(bus.inner.db.reader()) .await? .ok_or(ChannelError::RoomNotFound)?; Self::ensure_room_access(bus, user_id, art.0).await?; let lim = limit.unwrap_or(30).min(50); let rows = if let Some(cursor) = before { db::sqlx::query_as::<_, model::channel::ArticleCommentModel>( "SELECT id, article, author, parent, content, created_at, updated_at, deleted_at \ FROM channel_article_comment \ WHERE article = $1 AND deleted_at IS NULL \ AND created_at > (SELECT created_at FROM channel_article_comment WHERE id = $2) \ ORDER BY created_at ASC \ LIMIT $3", ) .bind(article_id) .bind(cursor) .bind(lim) .fetch_all(bus.inner.db.reader()) .await? } else { db::sqlx::query_as::<_, model::channel::ArticleCommentModel>( "SELECT id, article, author, parent, content, created_at, updated_at, deleted_at \ FROM channel_article_comment \ WHERE article = $1 AND deleted_at IS NULL \ ORDER BY created_at ASC \ LIMIT $2", ) .bind(article_id) .bind(lim) .fetch_all(bus.inner.db.reader()) .await? }; let total: (i64,) = db::sqlx::query_as( "SELECT COUNT(*) FROM channel_article_comment WHERE article = $1 AND deleted_at IS NULL", ) .bind(article_id) .fetch_one(bus.inner.db.reader()) .await?; let comments: Vec = rows .into_iter() .map(|r| model::channel::ArticleCommentItem { id: r.id, article: r.article, parent: r.parent, content: r.content, author: r.author, created_at: r.created_at, updated_at: r.updated_at, }) .collect(); let data = article::ArticleCommentListService { comments, total: total.0, }; Ok(Some(WsOutEvent::ArticleCommentList { data })) } pub(super) async fn article_comment_delete( bus: &ChannelBus, user_id: Uuid, comment_id: Uuid, ) -> ChannelResult> { let cmt = db::sqlx::query_as::<_, model::channel::ArticleCommentModel>( "SELECT id, article, author, parent, content, created_at, updated_at, deleted_at \ FROM channel_article_comment WHERE id = $1 AND deleted_at IS NULL", ) .bind(comment_id) .fetch_optional(bus.inner.db.reader()) .await? .ok_or(ChannelError::RoomNotFound)?; let art = db::sqlx::query_as::<_, model::channel::ChannelArticleModel>( "SELECT id, channel, author FROM channel_article WHERE id = $1 AND deleted_at IS NULL", ) .bind(cmt.article) .fetch_optional(bus.inner.db.reader()) .await? .ok_or(ChannelError::RoomNotFound)?; // Auth: article author can delete any comment; comment author can delete own if art.author != user_id && cmt.author != user_id { return Err(ChannelError::AccessDenied); } db::sqlx::query( "UPDATE channel_article_comment SET deleted_at = now(), updated_at = now() \ WHERE id = $1 AND deleted_at IS NULL", ) .bind(comment_id) .execute(bus.inner.db.writer()) .await?; let count_row: (i64,) = db::sqlx::query_as( "UPDATE channel_article SET comment_count = GREATEST(comment_count - 1, 0) \ WHERE id = $1 RETURNING comment_count", ) .bind(cmt.article) .fetch_one(bus.inner.db.writer()) .await?; let deleted_by = bus .lookup_user(user_id) .await .unwrap_or_else(|_| UserInfo::unknown(user_id)); let room = bus .lookup_room(art.channel) .await .unwrap_or_else(|_| RoomInfo::unknown(art.channel)); let data = article::ArticleCommentDeletedService { comment_id, article_id: cmt.article, channel: room.clone(), deleted_by, comment_count: count_row.0, }; bus.publish_room_event(art.channel, "article.comment.deleted", &data).await?; Ok(Some(WsOutEvent::ArticleCommentDeleted { room, data })) } pub(super) async fn article_liked_users( bus: &ChannelBus, user_id: Uuid, article_id: Uuid, before: Option, limit: Option, ) -> ChannelResult> { let art = db::sqlx::query_as::<_, (Uuid,)>( "SELECT channel FROM channel_article WHERE id = $1 AND deleted_at IS NULL", ) .bind(article_id) .fetch_optional(bus.inner.db.reader()) .await? .ok_or(ChannelError::RoomNotFound)?; Self::ensure_room_access(bus, user_id, art.0).await?; let lim = limit.unwrap_or(30).min(50); let rows: Vec<(Uuid,)> = if let Some(cursor) = before { db::sqlx::query_as( "SELECT l.\"user\" FROM channel_article_like l \ WHERE l.article = $1 \ AND l.created_at < (SELECT created_at FROM channel_article_like WHERE article = $1 AND \"user\" = $2) \ ORDER BY l.created_at DESC LIMIT $3", ) .bind(article_id) .bind(cursor) .bind(lim) .fetch_all(bus.inner.db.reader()) .await? } else { db::sqlx::query_as( "SELECT \"user\" FROM channel_article_like \ WHERE article = $1 ORDER BY created_at DESC LIMIT $2", ) .bind(article_id) .bind(lim) .fetch_all(bus.inner.db.reader()) .await? }; let total: (i64,) = db::sqlx::query_as( "SELECT COUNT(*) FROM channel_article_like WHERE article = $1", ) .bind(article_id) .fetch_one(bus.inner.db.reader()) .await?; let user_ids: Vec = rows.iter().map(|r| r.0).collect(); let users_map = bus.lookup_users(&user_ids).await?; let users: Vec = user_ids .iter() .map(|id| users_map.get(id).cloned().unwrap_or_else(|| UserInfo::unknown(*id))) .collect(); Ok(Some(WsOutEvent::ArticleLikedUsers { data: article::ArticleLikedUsersService { article_id, users, total: total.0, }, })) } } fn article_item_from_model( m: &model::channel::ChannelArticleModel, author: &UserInfo, ) -> article::ArticleItem { article::ArticleItem { id: m.id, channel: m.channel, author: author.clone(), title: m.title.clone(), cover_url: m.cover_url.clone(), summary: m.summary.clone(), tags: m.tags.clone(), like_count: m.like_count, comment_count: m.comment_count, view_count: m.view_count, is_pinned: m.is_pinned, content_type: m.content_type.clone(), status: m.status.clone(), created_at: m.created_at, updated_at: m.updated_at, } } fn article_item_from_card( m: &model::channel::ChannelArticleCard, author: &UserInfo, ) -> article::ArticleItem { article::ArticleItem { id: m.id, channel: m.channel, author: author.clone(), title: m.title.clone(), cover_url: m.cover_url.clone(), summary: m.summary.clone(), tags: m.tags.clone(), like_count: m.like_count, comment_count: m.comment_count, view_count: m.view_count, is_pinned: false, // card doesn't carry is_pinned content_type: String::new(), status: String::new(), created_at: m.created_at, updated_at: m.created_at, // card only has created_at } }