gitdataai/lib/channel/http/handler/article.rs

721 lines
25 KiB
Rust

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<String>,
content: String,
content_type: Option<String>,
summary: Option<String>,
tags: Option<Vec<String>>,
status: Option<String>,
) -> ChannelResult<Option<WsOutEvent>> {
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<String>,
cover_url: Option<String>,
content: Option<String>,
content_type: Option<String>,
summary: Option<String>,
tags: Option<Vec<String>>,
is_pinned: Option<bool>,
status: Option<String>,
) -> ChannelResult<Option<WsOutEvent>> {
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<Option<WsOutEvent>> {
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<Uuid>,
limit: Option<i64>,
) -> ChannelResult<Option<WsOutEvent>> {
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<Uuid> = rows.iter().map(|r| r.author).collect();
let users = bus.lookup_users(&author_ids).await?;
let articles: Vec<article::ArticleItem> = 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<Option<WsOutEvent>> {
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<Option<WsOutEvent>> {
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<Uuid>,
) -> ChannelResult<Option<WsOutEvent>> {
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<Uuid>,
limit: Option<i64>,
) -> ChannelResult<Option<WsOutEvent>> {
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<model::channel::ArticleCommentItem> = 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<Option<WsOutEvent>> {
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<Uuid>,
limit: Option<i64>,
) -> ChannelResult<Option<WsOutEvent>> {
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<Uuid> = rows.iter().map(|r| r.0).collect();
let users_map = bus.lookup_users(&user_ids).await?;
let users: Vec<UserInfo> = 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
}
}