821537186e
- 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
264 lines
9.0 KiB
Rust
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(())
|
|
}
|
|
}
|