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
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
//! 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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user