716 lines
24 KiB
Rust
716 lines
24 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
use crate::error::AppError;
|
|
use crate::immediate::{ArticleAction, ArticleEvent};
|
|
use crate::models::channels::{Article, ArticleComment, ArticleReaction};
|
|
use crate::models::common::{ArticleStatus, Visibility};
|
|
use crate::service::ImService;
|
|
use crate::service::im::events::ImEvent;
|
|
|
|
use super::session::ImSession;
|
|
use super::util::*;
|
|
|
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
pub struct CreateArticleParams {
|
|
pub title: String,
|
|
pub summary: Option<String>,
|
|
pub body: String,
|
|
pub cover_image_url: Option<String>,
|
|
pub tags: Option<Vec<String>>,
|
|
pub visibility: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
pub struct UpdateArticleParams {
|
|
pub title: Option<String>,
|
|
pub summary: Option<String>,
|
|
pub body: Option<String>,
|
|
pub cover_image_url: Option<String>,
|
|
pub tags: Option<Vec<String>>,
|
|
pub visibility: Option<String>,
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
pub struct ArticleListFilters {
|
|
pub status: Option<String>,
|
|
pub tag: Option<String>,
|
|
pub author_id: Option<Uuid>,
|
|
}
|
|
|
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
pub struct CreateArticleCommentParams {
|
|
pub body: String,
|
|
pub parent_comment_id: Option<Uuid>,
|
|
}
|
|
|
|
impl ImService {
|
|
async fn article_realtime(&self, channel_id: Uuid, article_id: Uuid, action: ArticleAction) {
|
|
let request_id = Uuid::nil();
|
|
let event = ArticleEvent {
|
|
channel_id,
|
|
article_id,
|
|
action,
|
|
};
|
|
self.publish(&format!("im.article.{}", channel_id), request_id, &event)
|
|
.await;
|
|
self.emit_event(ImEvent::Article {
|
|
request_id,
|
|
data: event,
|
|
});
|
|
}
|
|
|
|
pub async fn article_list(
|
|
&self,
|
|
ctx: &ImSession,
|
|
_wk_name: &str,
|
|
channel_id: Uuid,
|
|
filters: ArticleListFilters,
|
|
limit: i64,
|
|
offset: i64,
|
|
) -> Result<Vec<Article>, AppError> {
|
|
let user_uid = ctx.user;
|
|
let channel = self.resolve_channel(channel_id).await?;
|
|
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
let (limit, offset) = clamp_limit_offset(limit, offset);
|
|
|
|
let status = filters
|
|
.status
|
|
.as_deref()
|
|
.and_then(|s| s.parse::<ArticleStatus>().ok())
|
|
.filter(|s| *s != ArticleStatus::Unknown);
|
|
|
|
sqlx::query_as::<_, Article>(
|
|
"SELECT id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
|
|
status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \
|
|
views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \
|
|
metadata, created_at, updated_at, deleted_at \
|
|
FROM article WHERE channel_id = $1 AND deleted_at IS NULL \
|
|
AND ($2::text IS NULL OR status::text = $2) \
|
|
AND ($3::uuid IS NULL OR author_id = $3) \
|
|
AND ($4::text IS NULL OR $4 = ANY(tags)) \
|
|
ORDER BY created_at DESC LIMIT $5 OFFSET $6",
|
|
)
|
|
.bind(channel_id)
|
|
.bind(status.map(|s| s.to_string()))
|
|
.bind(filters.author_id)
|
|
.bind(filters.tag.as_deref())
|
|
.bind(limit)
|
|
.bind(offset)
|
|
.fetch_all(self.ctx.db.reader())
|
|
.await
|
|
.map_err(AppError::Database)
|
|
}
|
|
|
|
pub async fn article_get(
|
|
&self,
|
|
ctx: &ImSession,
|
|
_wk_name: &str,
|
|
channel_id: Uuid,
|
|
article_id: Uuid,
|
|
) -> Result<Article, AppError> {
|
|
let user_uid = ctx.user;
|
|
let channel = self.resolve_channel(channel_id).await?;
|
|
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
|
|
let article = self.resolve_article(article_id, channel_id).await?;
|
|
|
|
// Increment view count (best-effort, not in a txn)
|
|
let _ = sqlx::query("UPDATE article SET views_count = views_count + 1 WHERE id = $1")
|
|
.bind(article_id)
|
|
.execute(self.ctx.db.writer())
|
|
.await;
|
|
|
|
Ok(article)
|
|
}
|
|
|
|
pub async fn article_create(
|
|
&self,
|
|
ctx: &ImSession,
|
|
_wk_name: &str,
|
|
channel_id: Uuid,
|
|
params: CreateArticleParams,
|
|
) -> Result<Article, AppError> {
|
|
let user_uid = ctx.user;
|
|
let channel = self.resolve_channel(channel_id).await?;
|
|
self.ensure_channel_editable(user_uid, &channel).await?;
|
|
|
|
let title = required_text(params.title, "title")?;
|
|
if title.len() > MAX_ARTICLE_TITLE {
|
|
return Err(AppError::BadRequest("article title too long".into()));
|
|
}
|
|
let body = required_text(params.body, "body")?;
|
|
|
|
let visibility = parse_enum(
|
|
params.visibility,
|
|
Visibility::Public,
|
|
Visibility::Unknown,
|
|
"visibility",
|
|
)?;
|
|
|
|
let slug = self.generate_article_slug(channel_id, &title).await?;
|
|
let now = chrono::Utc::now();
|
|
let tags = params.tags.unwrap_or_default();
|
|
|
|
let article = sqlx::query_as::<_, Article>(
|
|
"INSERT INTO article \
|
|
(id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
|
|
status, visibility, tags, cross_posted, created_at, updated_at) \
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'draft', $9, $10, false, $11, $11) \
|
|
RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
|
|
status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \
|
|
views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \
|
|
metadata, created_at, updated_at, deleted_at",
|
|
)
|
|
.bind(Uuid::now_v7())
|
|
.bind(channel_id)
|
|
.bind(user_uid)
|
|
.bind(&title)
|
|
.bind(&slug)
|
|
.bind(params.summary.as_deref())
|
|
.bind(&body)
|
|
.bind(params.cover_image_url.as_deref())
|
|
.bind(visibility)
|
|
.bind(&tags)
|
|
.bind(now)
|
|
.fetch_one(self.ctx.db.writer())
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
self.article_realtime(channel_id, article.id, ArticleAction::Created)
|
|
.await;
|
|
Ok(article)
|
|
}
|
|
|
|
pub async fn article_update(
|
|
&self,
|
|
ctx: &ImSession,
|
|
_wk_name: &str,
|
|
channel_id: Uuid,
|
|
article_id: Uuid,
|
|
params: UpdateArticleParams,
|
|
) -> Result<Article, AppError> {
|
|
let user_uid = ctx.user;
|
|
let channel = self.resolve_channel(channel_id).await?;
|
|
let article = self.resolve_article(article_id, channel_id).await?;
|
|
|
|
if article.author_id != user_uid {
|
|
self.ensure_channel_admin(user_uid, &channel).await?;
|
|
}
|
|
|
|
let new_title = match params.title {
|
|
Some(t) => {
|
|
let t = required_text(t, "title")?;
|
|
if t.len() > MAX_ARTICLE_TITLE {
|
|
return Err(AppError::BadRequest("article title too long".into()));
|
|
}
|
|
t
|
|
}
|
|
None => article.title,
|
|
};
|
|
let new_body = params.body.unwrap_or(article.body);
|
|
let new_summary = params.summary.or(article.summary);
|
|
let new_cover = params.cover_image_url.or(article.cover_image_url);
|
|
let new_tags = params.tags.unwrap_or(article.tags);
|
|
let visibility = parse_enum(
|
|
params.visibility,
|
|
article.visibility,
|
|
Visibility::Unknown,
|
|
"visibility",
|
|
)?;
|
|
|
|
let now = chrono::Utc::now();
|
|
let updated = sqlx::query_as::<_, Article>(
|
|
"UPDATE article SET title = $1, summary = $2, body = $3, cover_image_url = $4, \
|
|
tags = $5, visibility = $6, updated_at = $7 \
|
|
WHERE id = $8 AND deleted_at IS NULL \
|
|
RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
|
|
status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \
|
|
views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \
|
|
metadata, created_at, updated_at, deleted_at",
|
|
)
|
|
.bind(&new_title)
|
|
.bind(&new_summary)
|
|
.bind(&new_body)
|
|
.bind(&new_cover)
|
|
.bind(&new_tags)
|
|
.bind(visibility)
|
|
.bind(now)
|
|
.bind(article_id)
|
|
.fetch_one(self.ctx.db.writer())
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
self.article_realtime(channel_id, article_id, ArticleAction::Updated)
|
|
.await;
|
|
Ok(updated)
|
|
}
|
|
|
|
pub async fn article_publish(
|
|
&self,
|
|
ctx: &ImSession,
|
|
_wk_name: &str,
|
|
channel_id: Uuid,
|
|
article_id: Uuid,
|
|
) -> Result<Article, AppError> {
|
|
let user_uid = ctx.user;
|
|
let channel = self.resolve_channel(channel_id).await?;
|
|
let article = self.resolve_article(article_id, channel_id).await?;
|
|
|
|
if article.author_id != user_uid {
|
|
self.ensure_channel_editable(user_uid, &channel).await?;
|
|
}
|
|
|
|
if article.status != ArticleStatus::Draft && article.status != ArticleStatus::Scheduled {
|
|
return Err(AppError::BadRequest(
|
|
"only draft or scheduled articles can be published".into(),
|
|
));
|
|
}
|
|
|
|
let now = chrono::Utc::now();
|
|
let published = sqlx::query_as::<_, Article>(
|
|
"UPDATE article SET status = 'published', published_at = $1, published_by = $2, \
|
|
updated_at = $1 \
|
|
WHERE id = $3 AND deleted_at IS NULL \
|
|
RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
|
|
status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \
|
|
views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \
|
|
metadata, created_at, updated_at, deleted_at",
|
|
)
|
|
.bind(now)
|
|
.bind(user_uid)
|
|
.bind(article_id)
|
|
.fetch_one(self.ctx.db.writer())
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
|
|
// Trigger cross-posts to followers
|
|
if let Err(e) = self
|
|
.cross_post_article(article_id, channel_id, user_uid)
|
|
.await
|
|
{
|
|
tracing::warn!(article_id = %article_id, error = %e, "cross-post failed");
|
|
}
|
|
|
|
tracing::info!(article_id = %article_id, "Article published");
|
|
self.article_realtime(channel_id, article_id, ArticleAction::Published)
|
|
.await;
|
|
Ok(published)
|
|
}
|
|
|
|
pub async fn article_unpublish(
|
|
&self,
|
|
ctx: &ImSession,
|
|
_wk_name: &str,
|
|
channel_id: Uuid,
|
|
article_id: Uuid,
|
|
) -> Result<Article, AppError> {
|
|
let user_uid = ctx.user;
|
|
let channel = self.resolve_channel(channel_id).await?;
|
|
let article = self.resolve_article(article_id, channel_id).await?;
|
|
|
|
if article.author_id != user_uid {
|
|
self.ensure_channel_editable(user_uid, &channel).await?;
|
|
}
|
|
|
|
let now = chrono::Utc::now();
|
|
let unpublished = sqlx::query_as::<_, Article>(
|
|
"UPDATE article SET status = 'unpublished', unpublished_at = $1, updated_at = $1 \
|
|
WHERE id = $2 AND status = 'published' AND deleted_at IS NULL \
|
|
RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
|
|
status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \
|
|
views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \
|
|
metadata, created_at, updated_at, deleted_at",
|
|
)
|
|
.bind(now)
|
|
.bind(article_id)
|
|
.fetch_one(self.ctx.db.writer())
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
self.article_realtime(channel_id, article_id, ArticleAction::Unpublished)
|
|
.await;
|
|
Ok(unpublished)
|
|
}
|
|
|
|
pub async fn article_schedule(
|
|
&self,
|
|
ctx: &ImSession,
|
|
_wk_name: &str,
|
|
channel_id: Uuid,
|
|
article_id: Uuid,
|
|
scheduled_at: chrono::DateTime<chrono::Utc>,
|
|
) -> Result<Article, AppError> {
|
|
let user_uid = ctx.user;
|
|
let channel = self.resolve_channel(channel_id).await?;
|
|
let article = self.resolve_article(article_id, channel_id).await?;
|
|
|
|
if article.author_id != user_uid {
|
|
self.ensure_channel_editable(user_uid, &channel).await?;
|
|
}
|
|
|
|
if article.status != ArticleStatus::Draft {
|
|
return Err(AppError::BadRequest(
|
|
"only draft articles can be scheduled".into(),
|
|
));
|
|
}
|
|
|
|
let now = chrono::Utc::now();
|
|
let scheduled = sqlx::query_as::<_, Article>(
|
|
"UPDATE article SET status = 'scheduled', scheduled_at = $1, updated_at = $2 \
|
|
WHERE id = $3 AND deleted_at IS NULL \
|
|
RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
|
|
status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \
|
|
views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \
|
|
metadata, created_at, updated_at, deleted_at",
|
|
)
|
|
.bind(scheduled_at)
|
|
.bind(now)
|
|
.bind(article_id)
|
|
.fetch_one(self.ctx.db.writer())
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
self.article_realtime(channel_id, article_id, ArticleAction::Updated)
|
|
.await;
|
|
Ok(scheduled)
|
|
}
|
|
|
|
pub async fn article_delete(
|
|
&self,
|
|
ctx: &ImSession,
|
|
_wk_name: &str,
|
|
channel_id: Uuid,
|
|
article_id: Uuid,
|
|
) -> Result<(), AppError> {
|
|
let user_uid = ctx.user;
|
|
let channel = self.resolve_channel(channel_id).await?;
|
|
let article = self.resolve_article(article_id, channel_id).await?;
|
|
|
|
if article.author_id != user_uid {
|
|
self.ensure_channel_admin(user_uid, &channel).await?;
|
|
}
|
|
|
|
let now = chrono::Utc::now();
|
|
let result = sqlx::query(
|
|
"UPDATE article SET deleted_at = $1, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL",
|
|
)
|
|
.bind(now)
|
|
.bind(article_id)
|
|
.execute(self.ctx.db.writer())
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
|
|
ensure_affected(result.rows_affected(), "article not found")?;
|
|
self.article_realtime(channel_id, article_id, ArticleAction::Deleted)
|
|
.await;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn article_comment_list(
|
|
&self,
|
|
ctx: &ImSession,
|
|
_wk_name: &str,
|
|
channel_id: Uuid,
|
|
article_id: Uuid,
|
|
limit: i64,
|
|
offset: i64,
|
|
) -> Result<Vec<ArticleComment>, AppError> {
|
|
let user_uid = ctx.user;
|
|
let channel = self.resolve_channel(channel_id).await?;
|
|
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
let (limit, offset) = clamp_limit_offset(limit, offset);
|
|
|
|
sqlx::query_as::<_, ArticleComment>(
|
|
"SELECT id, article_id, channel_id, author_id, parent_comment_id, body, \
|
|
edited_at, deleted_at, created_at, updated_at \
|
|
FROM article_comment WHERE article_id = $1 AND deleted_at IS NULL \
|
|
ORDER BY created_at ASC LIMIT $2 OFFSET $3",
|
|
)
|
|
.bind(article_id)
|
|
.bind(limit)
|
|
.bind(offset)
|
|
.fetch_all(self.ctx.db.reader())
|
|
.await
|
|
.map_err(AppError::Database)
|
|
}
|
|
|
|
pub async fn article_comment_create(
|
|
&self,
|
|
ctx: &ImSession,
|
|
_wk_name: &str,
|
|
channel_id: Uuid,
|
|
article_id: Uuid,
|
|
params: CreateArticleCommentParams,
|
|
) -> Result<ArticleComment, AppError> {
|
|
let user_uid = ctx.user;
|
|
let channel = self.resolve_channel(channel_id).await?;
|
|
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
|
|
let body = required_text(params.body, "body")?;
|
|
let now = chrono::Utc::now();
|
|
|
|
let mut txn = self
|
|
.ctx
|
|
.db
|
|
.writer()
|
|
.begin()
|
|
.await
|
|
.map_err(|_| AppError::TxnError)?;
|
|
sqlx::query("SET LOCAL app.current_user_id = $1")
|
|
.bind(user_uid)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
|
|
let comment = sqlx::query_as::<_, ArticleComment>(
|
|
"INSERT INTO article_comment \
|
|
(id, article_id, channel_id, author_id, parent_comment_id, body, created_at, updated_at) \
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $7) \
|
|
RETURNING id, article_id, channel_id, author_id, parent_comment_id, body, \
|
|
edited_at, deleted_at, created_at, updated_at",
|
|
)
|
|
.bind(Uuid::now_v7())
|
|
.bind(article_id)
|
|
.bind(channel_id)
|
|
.bind(user_uid)
|
|
.bind(params.parent_comment_id)
|
|
.bind(&body)
|
|
.bind(now)
|
|
.fetch_one(&mut *txn)
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
|
|
sqlx::query("UPDATE article SET comments_count = comments_count + 1 WHERE id = $1")
|
|
.bind(article_id)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
|
|
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
|
self.article_realtime(channel_id, article_id, ArticleAction::Updated)
|
|
.await;
|
|
Ok(comment)
|
|
}
|
|
|
|
pub async fn article_comment_delete(
|
|
&self,
|
|
ctx: &ImSession,
|
|
_wk_name: &str,
|
|
channel_id: Uuid,
|
|
article_id: Uuid,
|
|
comment_id: Uuid,
|
|
) -> Result<(), AppError> {
|
|
let user_uid = ctx.user;
|
|
let channel = self.resolve_channel(channel_id).await?;
|
|
|
|
let comment = sqlx::query_as::<_, ArticleComment>(
|
|
"SELECT id, article_id, channel_id, author_id, parent_comment_id, body, \
|
|
edited_at, deleted_at, created_at, updated_at \
|
|
FROM article_comment WHERE id = $1 AND article_id = $2 AND deleted_at IS NULL",
|
|
)
|
|
.bind(comment_id)
|
|
.bind(article_id)
|
|
.fetch_optional(self.ctx.db.reader())
|
|
.await
|
|
.map_err(AppError::Database)?
|
|
.ok_or(AppError::NotFound("comment not found".into()))?;
|
|
|
|
if comment.author_id != user_uid {
|
|
self.ensure_channel_admin(user_uid, &channel).await?;
|
|
}
|
|
|
|
let now = chrono::Utc::now();
|
|
let mut txn = self
|
|
.ctx
|
|
.db
|
|
.writer()
|
|
.begin()
|
|
.await
|
|
.map_err(|_| AppError::TxnError)?;
|
|
sqlx::query("SET LOCAL app.current_user_id = $1")
|
|
.bind(user_uid)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
|
|
sqlx::query("UPDATE article_comment SET deleted_at = $1, updated_at = $1 WHERE id = $2")
|
|
.bind(now)
|
|
.bind(comment_id)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
|
|
sqlx::query(
|
|
"UPDATE article SET comments_count = GREATEST(comments_count - 1, 0) WHERE id = $1",
|
|
)
|
|
.bind(article_id)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
|
|
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
|
self.article_realtime(channel_id, article_id, ArticleAction::Updated)
|
|
.await;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn article_reaction_add(
|
|
&self,
|
|
ctx: &ImSession,
|
|
_wk_name: &str,
|
|
channel_id: Uuid,
|
|
article_id: Uuid,
|
|
content: &str,
|
|
) -> Result<ArticleReaction, AppError> {
|
|
let user_uid = ctx.user;
|
|
let channel = self.resolve_channel(channel_id).await?;
|
|
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
|
|
let content = required_text(content.to_string(), "content")?;
|
|
let now = chrono::Utc::now();
|
|
|
|
let mut txn = self
|
|
.ctx
|
|
.db
|
|
.writer()
|
|
.begin()
|
|
.await
|
|
.map_err(|_| AppError::TxnError)?;
|
|
sqlx::query("SET LOCAL app.current_user_id = $1")
|
|
.bind(user_uid)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
|
|
let reaction = sqlx::query_as::<_, ArticleReaction>(
|
|
"INSERT INTO article_reaction (id, article_id, channel_id, user_id, content, created_at) \
|
|
VALUES ($1, $2, $3, $4, $5, $6) \
|
|
ON CONFLICT (article_id, user_id, content) DO NOTHING \
|
|
RETURNING id, article_id, channel_id, user_id, content, created_at",
|
|
)
|
|
.bind(Uuid::now_v7())
|
|
.bind(article_id)
|
|
.bind(channel_id)
|
|
.bind(user_uid)
|
|
.bind(&content)
|
|
.bind(now)
|
|
.fetch_optional(&mut *txn)
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
|
|
if reaction.is_some() {
|
|
sqlx::query("UPDATE article SET reactions_count = reactions_count + 1 WHERE id = $1")
|
|
.bind(article_id)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
}
|
|
|
|
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
|
let reaction = reaction.ok_or(AppError::Conflict("reaction already exists".into()))?;
|
|
self.article_realtime(channel_id, article_id, ArticleAction::Updated)
|
|
.await;
|
|
Ok(reaction)
|
|
}
|
|
|
|
pub async fn article_reaction_remove(
|
|
&self,
|
|
ctx: &ImSession,
|
|
_wk_name: &str,
|
|
channel_id: Uuid,
|
|
article_id: Uuid,
|
|
content: &str,
|
|
) -> Result<(), AppError> {
|
|
let user_uid = ctx.user;
|
|
let channel = self.resolve_channel(channel_id).await?;
|
|
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
|
|
let _now = chrono::Utc::now();
|
|
let mut txn = self
|
|
.ctx
|
|
.db
|
|
.writer()
|
|
.begin()
|
|
.await
|
|
.map_err(|_| AppError::TxnError)?;
|
|
sqlx::query("SET LOCAL app.current_user_id = $1")
|
|
.bind(user_uid)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
|
|
let result = sqlx::query(
|
|
"DELETE FROM article_reaction WHERE article_id = $1 AND user_id = $2 AND content = $3",
|
|
)
|
|
.bind(article_id)
|
|
.bind(user_uid)
|
|
.bind(content)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
ensure_affected(result.rows_affected(), "reaction not found")?;
|
|
|
|
sqlx::query(
|
|
"UPDATE article SET reactions_count = GREATEST(reactions_count - 1, 0) WHERE id = $1",
|
|
)
|
|
.bind(article_id)
|
|
.execute(&mut *txn)
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
|
|
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
|
self.article_realtime(channel_id, article_id, ArticleAction::Updated)
|
|
.await;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn resolve_article(
|
|
&self,
|
|
article_id: Uuid,
|
|
channel_id: Uuid,
|
|
) -> Result<Article, AppError> {
|
|
sqlx::query_as::<_, Article>(
|
|
"SELECT id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
|
|
status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \
|
|
views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \
|
|
metadata, created_at, updated_at, deleted_at \
|
|
FROM article WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL",
|
|
)
|
|
.bind(article_id)
|
|
.bind(channel_id)
|
|
.fetch_optional(self.ctx.db.reader())
|
|
.await
|
|
.map_err(AppError::Database)?
|
|
.ok_or(AppError::NotFound("article not found".into()))
|
|
}
|
|
|
|
async fn generate_article_slug(
|
|
&self,
|
|
channel_id: Uuid,
|
|
title: &str,
|
|
) -> Result<String, AppError> {
|
|
let base = slugify(title);
|
|
let mut slug = base.clone();
|
|
let mut counter = 1u32;
|
|
|
|
loop {
|
|
let exists: bool = sqlx::query_scalar(
|
|
"SELECT EXISTS(SELECT 1 FROM article WHERE channel_id = $1 AND slug = $2)",
|
|
)
|
|
.bind(channel_id)
|
|
.bind(&slug)
|
|
.fetch_one(self.ctx.db.reader())
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
|
|
if !exists {
|
|
return Ok(slug);
|
|
}
|
|
slug = format!("{base}-{counter}");
|
|
counter += 1;
|
|
if counter > 100 {
|
|
return Err(AppError::InternalServerError(
|
|
"failed to generate unique slug".into(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|