- Add ArticleFeed component for article-based channels - Implement ArticleComposer with draft persistence - Add Newspaper icon for article room type - Update ChannelPage to conditionally render article feed vs message view - Add article-related API endpoints and models - Reset thread view when switching rooms - Add room type check in channel sidebar - Update CSS to hide scrollbars globally - Add gRPC message size limit configuration - Fix git diff tree handling
718 lines
25 KiB
Rust
718 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
|
|
}
|
|
}
|