Files
imks/repo/message_article.rs
zhenyi 821537186e refactor(tests): reformat code and update dependency management
- Reorganized import statements in adapter tests for better readability
- Replaced or_insert_with(Vec::new) with or_default() in test closures
- Updated Cargo.lock with new dependency versions and checksums
- Added TLS features to tonic dependency configuration
- Included sqlx, chrono, and uuid dependencies with specific features
- Added jsonwebtoken and arc-swap as project dependencies
- Reformatted assertion statements to comply with line length limits
- Adjusted base64 import order in engine codec module
- Updated protobuf include statement formatting
2026-06-11 12:11:05 +08:00

264 lines
9.0 KiB
Rust

//! Forum article CRUD operations on `MessageRepo`.
//!
//! Articles extend regular messages with forum-specific metadata (title, cover,
//! view/like stats, tags). Rendered as waterfall cards in forum channels.
use chrono::Utc;
use sqlx::Row;
use uuid::Uuid;
use crate::ImksResult;
use crate::models::message::AuthorInfo;
use crate::models::message_article::{ArticleCard, ArticleSort, MessageArticle};
use super::message_repo::MessageRepo;
use super::pagination::{CursorPage, clamp_limit};
impl MessageRepo {
/// Create an article record linked to an existing message.
#[allow(clippy::too_many_arguments)]
pub async fn create_article(
&self,
message_id: Uuid,
title: &str,
summary: Option<&str>,
cover_url: Option<&str>,
cover_width: Option<i32>,
cover_height: Option<i32>,
cover_color: Option<&str>,
tags: Option<&serde_json::Value>,
) -> ImksResult<MessageArticle> {
let id = Uuid::now_v7();
let now = Utc::now();
sqlx::query_as::<_, MessageArticle>(
r#"
INSERT INTO message_article (
id, message_id, title, summary, cover_url, cover_width, cover_height,
cover_color, tags, view_count, like_count, bookmark_count, reply_count,
is_pinned_to_top, is_answered, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, 0, 0, 0, FALSE, FALSE, $10, $10)
RETURNING *
"#,
)
.bind(id)
.bind(message_id)
.bind(title)
.bind(summary)
.bind(cover_url)
.bind(cover_width)
.bind(cover_height)
.bind(cover_color)
.bind(tags)
.bind(now)
.fetch_one(self.pool())
.await
.map_err(Into::into)
}
/// Update an existing article's metadata. Does NOT update the message body.
pub async fn update_article(
&self,
message_id: Uuid,
title: Option<&str>,
summary: Option<&str>,
cover_url: Option<&str>,
cover_color: Option<&str>,
tags: Option<&serde_json::Value>,
) -> ImksResult<Option<MessageArticle>> {
let now = Utc::now();
sqlx::query_as::<_, MessageArticle>(
r#"
UPDATE message_article
SET title = COALESCE($1, title),
summary = COALESCE($2, summary),
cover_url = COALESCE($3, cover_url),
cover_color = COALESCE($4, cover_color),
tags = COALESCE($5, tags),
updated_at = $6
WHERE message_id = $7
RETURNING *
"#,
)
.bind(title)
.bind(summary)
.bind(cover_url)
.bind(cover_color)
.bind(tags)
.bind(now)
.bind(message_id)
.fetch_optional(self.pool())
.await
.map_err(Into::into)
}
/// Get an article by its message_id.
pub async fn get_article(&self, message_id: Uuid) -> ImksResult<Option<MessageArticle>> {
sqlx::query_as::<_, MessageArticle>("SELECT * FROM message_article WHERE message_id = $1")
.bind(message_id)
.fetch_optional(self.pool())
.await
.map_err(Into::into)
}
/// List articles in a forum channel with id-based cursor pagination.
pub async fn list_articles(
&self,
channel_id: Uuid,
sort: ArticleSort,
before: Option<(i64, Uuid)>,
limit: Option<i64>,
) -> ImksResult<CursorPage<ArticleCard>> {
let effective_limit = clamp_limit(limit);
let fetch_limit = effective_limit + 1;
let cursor_id = before.map(|(_, id)| id);
let order_by = match sort {
ArticleSort::LatestActivity => "a.last_reply_at DESC NULLS LAST, m.id DESC",
ArticleSort::Newest => "m.id DESC",
ArticleSort::MostViewed => "a.view_count DESC, m.id DESC",
ArticleSort::MostLiked => "a.like_count DESC, m.id DESC",
ArticleSort::PinnedFirst => {
"a.is_pinned_to_top DESC, a.last_reply_at DESC NULLS LAST, m.id DESC"
}
};
let query = if cursor_id.is_some() {
format!(
r#"
SELECT a.*, m.author_id,
(
SELECT att.url
FROM message_attachment att
WHERE att.message_id = a.message_id
AND att.content_type LIKE 'image/%'
ORDER BY att.created_at
LIMIT 1
) AS first_image_url
FROM message_article a
JOIN message m ON m.id = a.message_id
WHERE m.channel_id = $1
AND m.deleted_at IS NULL
AND m.id < $2
ORDER BY {order_by}
LIMIT $3
"#,
)
} else {
format!(
r#"
SELECT a.*, m.author_id,
(
SELECT att.url
FROM message_attachment att
WHERE att.message_id = a.message_id
AND att.content_type LIKE 'image/%'
ORDER BY att.created_at
LIMIT 1
) AS first_image_url
FROM message_article a
JOIN message m ON m.id = a.message_id
WHERE m.channel_id = $1
AND m.deleted_at IS NULL
ORDER BY {order_by}
LIMIT $2
"#,
)
};
let rows = if let Some(cursor) = cursor_id {
sqlx::query(sqlx::AssertSqlSafe(query.as_str()))
.bind(channel_id)
.bind(cursor)
.bind(fetch_limit)
.fetch_all(self.pool())
.await?
} else {
sqlx::query(sqlx::AssertSqlSafe(query.as_str()))
.bind(channel_id)
.bind(fetch_limit)
.fetch_all(self.pool())
.await?
};
// Convert raw rows to MessageArticle
let articles: Vec<MessageArticle> = rows
.iter()
.map(|r| MessageArticle {
id: r.get("id"),
message_id: r.get("message_id"),
title: r.get("title"),
summary: r.get("summary"),
cover_url: r.get("cover_url"),
cover_width: r.get("cover_width"),
cover_height: r.get("cover_height"),
cover_color: r.get("cover_color"),
tags: r.get("tags"),
view_count: r.get("view_count"),
like_count: r.get("like_count"),
bookmark_count: r.get("bookmark_count"),
reply_count: r.get("reply_count"),
last_reply_message_id: r.get("last_reply_message_id"),
last_reply_at: r.get("last_reply_at"),
last_reply_user_id: r.get("last_reply_user_id"),
is_pinned_to_top: r.get("is_pinned_to_top"),
is_answered: r.get("is_answered"),
answered_by: r.get("answered_by"),
answered_at: r.get("answered_at"),
created_at: r.get("created_at"),
updated_at: r.get("updated_at"),
})
.collect();
let has_more = articles.len() > effective_limit as usize;
let items: Vec<MessageArticle> = articles
.into_iter()
.take(effective_limit as usize)
.collect();
let next_cursor = if has_more {
items.last().map(|a| a.message_id)
} else {
None
};
let cards: Vec<ArticleCard> = items
.into_iter()
.zip(rows.iter())
.map(|(article, row)| {
let author_id: Uuid = row.get("author_id");
ArticleCard {
article,
author: AuthorInfo {
id: author_id,
username: author_id.to_string(),
display_name: None,
avatar_url: None,
is_bot: false,
},
tag_names: Vec::new(),
first_image_url: row.get("first_image_url"),
bookmarked: false,
liked: false,
}
})
.collect();
Ok(CursorPage {
items: cards,
next_cursor,
has_more,
})
}
/// Increment the view count for an article.
pub async fn increment_article_view(&self, message_id: Uuid) -> ImksResult<()> {
sqlx::query("UPDATE message_article SET view_count = view_count + 1 WHERE message_id = $1")
.bind(message_id)
.execute(self.pool())
.await?;
Ok(())
}
}