feat(service): expand service layer with new domain operations
- Add IM service modules: audit, channel roles, custom emojis, forum tags, integrations, invitations, repo links, slash commands, stages, voice, webhooks - Add PR service modules: review requests, templates - Add repo service modules: contributors, release assets, git extras (archive, branch rename, commit extras, diff/merge, tag, tree) - Add user service: social (follow/block) - Add internal auth service - Update existing service modules with expanded functionality - Remove deleted IM modules: articles, delivery trace, drafts, follows, messages, polls, presence, reactions, threads
This commit is contained in:
@@ -1,715 +0,0 @@
|
||||
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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::channels::ChannelEvent;
|
||||
use crate::service::ImService;
|
||||
|
||||
use super::session::ImSession;
|
||||
|
||||
impl ImService {
|
||||
pub async fn audit_list(
|
||||
&self,
|
||||
_ctx: &ImSession,
|
||||
channel_id: Uuid,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<ChannelEvent>, AppError> {
|
||||
let limit = limit.clamp(1, 100);
|
||||
let offset = offset.max(0);
|
||||
sqlx::query_as::<_, ChannelEvent>(
|
||||
"SELECT id, channel_id, actor_id, event_type, target_type, target_id, \
|
||||
old_value, new_value, metadata, created_at \
|
||||
FROM channel_event WHERE channel_id = $1 \
|
||||
ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::immediate::{CategoryAction, CategoryEvent};
|
||||
use crate::service::im::events::{CategoryAction, CategoryEvent};
|
||||
use crate::models::channels::ChannelCategory;
|
||||
use crate::models::common::Role;
|
||||
use crate::service::ImService;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::channels::ChannelMemberRole;
|
||||
use crate::service::ImService;
|
||||
|
||||
use super::session::ImSession;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateChannelRoleParams {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub permissions: Vec<String>,
|
||||
pub assignable: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateChannelRoleParams {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub permissions: Option<Vec<String>>,
|
||||
pub assignable: Option<bool>,
|
||||
}
|
||||
|
||||
impl ImService {
|
||||
pub async fn channel_role_list(
|
||||
&self,
|
||||
_ctx: &ImSession,
|
||||
channel_id: Uuid,
|
||||
) -> Result<Vec<ChannelMemberRole>, AppError> {
|
||||
sqlx::query_as::<_, ChannelMemberRole>(
|
||||
"SELECT id, channel_id, name, description, permissions, assignable, \
|
||||
created_by, created_at, updated_at \
|
||||
FROM channel_member_role WHERE channel_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn channel_role_create(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
channel_id: Uuid,
|
||||
params: CreateChannelRoleParams,
|
||||
) -> Result<ChannelMemberRole, AppError> {
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, ChannelMemberRole>(
|
||||
"INSERT INTO channel_member_role \
|
||||
(id, channel_id, name, description, permissions, assignable, created_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) \
|
||||
RETURNING id, channel_id, name, description, permissions, assignable, \
|
||||
created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(channel_id)
|
||||
.bind(¶ms.name)
|
||||
.bind(params.description.as_deref())
|
||||
.bind(¶ms.permissions)
|
||||
.bind(params.assignable)
|
||||
.bind(ctx.user)
|
||||
.bind(now)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn channel_role_update(
|
||||
&self,
|
||||
_ctx: &ImSession,
|
||||
role_id: Uuid,
|
||||
params: UpdateChannelRoleParams,
|
||||
) -> Result<ChannelMemberRole, AppError> {
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, ChannelMemberRole>(
|
||||
"UPDATE channel_member_role SET \
|
||||
name = COALESCE($1, name), \
|
||||
description = COALESCE($2, description), \
|
||||
permissions = COALESCE($3, permissions), \
|
||||
assignable = COALESCE($4, assignable), \
|
||||
updated_at = $5 \
|
||||
WHERE id = $6 \
|
||||
RETURNING id, channel_id, name, description, permissions, assignable, \
|
||||
created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(params.name.as_deref())
|
||||
.bind(params.description.as_deref())
|
||||
.bind(params.permissions.as_ref())
|
||||
.bind(params.assignable)
|
||||
.bind(now)
|
||||
.bind(role_id)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn channel_role_delete(
|
||||
&self,
|
||||
_ctx: &ImSession,
|
||||
role_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query("DELETE FROM channel_member_role WHERE id = $1")
|
||||
.bind(role_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+20
-15
@@ -9,8 +9,7 @@ use crate::service::ImService;
|
||||
|
||||
use super::session::ImSession;
|
||||
use super::util::*;
|
||||
use crate::immediate::{ChannelAction, ChannelEvent};
|
||||
use crate::service::im::events::ImEvent;
|
||||
use crate::service::im::events::{ChannelAction, ChannelEvent, ImEvent};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateChannelParams {
|
||||
@@ -159,6 +158,14 @@ impl ImService {
|
||||
let now = chrono::Utc::now();
|
||||
let channel_id = Uuid::now_v7();
|
||||
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
let channel = sqlx::query_as::<_, Channel>(
|
||||
"INSERT INTO channel \
|
||||
(id, workspace_id, repo_id, category_id, created_by, name, topic, description, \
|
||||
@@ -189,7 +196,7 @@ impl ImService {
|
||||
.bind(params.rate_limit_per_user)
|
||||
.bind(params.parent_channel_id)
|
||||
.bind(now)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
@@ -203,10 +210,12 @@ impl ImService {
|
||||
.bind(channel_id)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(self.ctx.db.writer())
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
tracing::info!(channel_id = %channel_id, name = %name, "Channel created");
|
||||
|
||||
let event = ChannelEvent {
|
||||
@@ -429,6 +438,7 @@ impl ImService {
|
||||
Err(AppError::Unauthorized)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn ensure_channel_member(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
@@ -527,25 +537,20 @@ impl ImService {
|
||||
.unwrap_or(Role::Unknown))
|
||||
}
|
||||
|
||||
pub(crate) async fn update_channel_stats(
|
||||
pub(crate) async fn increment_channel_stat(
|
||||
&self,
|
||||
channel_id: Uuid,
|
||||
delta: i32,
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
txn: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query(
|
||||
"UPDATE channel_stats SET \
|
||||
members_count = (SELECT COUNT(*) FROM channel_member WHERE channel_id = $1 AND status = 'active'), \
|
||||
messages_count = (SELECT COUNT(*) FROM message WHERE channel_id = $1 AND deleted_at IS NULL), \
|
||||
threads_count = (SELECT COUNT(*) FROM message_thread WHERE channel_id = $1), \
|
||||
reactions_count = (SELECT COUNT(*) FROM message_reaction WHERE channel_id = $1), \
|
||||
mentions_count = (SELECT COUNT(*) FROM message_mention WHERE channel_id = $1), \
|
||||
files_count = (SELECT COUNT(*) FROM message_attachment WHERE channel_id = $1), \
|
||||
last_activity_at = $2, updated_at = $2 \
|
||||
WHERE channel_id = $1",
|
||||
"UPDATE channel_stats SET members_count = members_count + $1, \
|
||||
last_activity_at = $2, updated_at = $2 WHERE channel_id = $3",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.bind(delta)
|
||||
.bind(now)
|
||||
.bind(channel_id)
|
||||
.execute(&mut **txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::channels::CustomEmoji;
|
||||
use crate::service::ImService;
|
||||
|
||||
use super::session::ImSession;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateCustomEmojiParams {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub animated: Option<bool>,
|
||||
}
|
||||
|
||||
impl ImService {
|
||||
pub async fn custom_emoji_list(
|
||||
&self,
|
||||
_ctx: &ImSession,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<Vec<CustomEmoji>, AppError> {
|
||||
sqlx::query_as::<_, CustomEmoji>(
|
||||
"SELECT id, workspace_id, name, url, animated, managed, \
|
||||
created_by, created_at, updated_at \
|
||||
FROM custom_emoji WHERE workspace_id = $1 ORDER BY name",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn custom_emoji_create(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
workspace_id: Uuid,
|
||||
params: CreateCustomEmojiParams,
|
||||
) -> Result<CustomEmoji, AppError> {
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, CustomEmoji>(
|
||||
"INSERT INTO custom_emoji \
|
||||
(id, workspace_id, name, url, animated, managed, created_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, false, $6, $7, $7) \
|
||||
RETURNING id, workspace_id, name, url, animated, managed, \
|
||||
created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(workspace_id)
|
||||
.bind(¶ms.name)
|
||||
.bind(¶ms.url)
|
||||
.bind(params.animated.unwrap_or(false))
|
||||
.bind(ctx.user)
|
||||
.bind(now)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn custom_emoji_delete(
|
||||
&self,
|
||||
_ctx: &ImSession,
|
||||
emoji_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query("DELETE FROM custom_emoji WHERE id = $1")
|
||||
.bind(emoji_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn trace_request(stage: &'static str, request_id: Uuid, subject: &str) {
|
||||
tracing::info!(
|
||||
target: "im.delivery",
|
||||
stage,
|
||||
request_id = %request_id,
|
||||
subject,
|
||||
"im delivery trace"
|
||||
);
|
||||
}
|
||||
|
||||
pub fn trace_message(
|
||||
stage: &'static str,
|
||||
request_id: Uuid,
|
||||
channel_id: Uuid,
|
||||
message_id: Uuid,
|
||||
seq: Option<i64>,
|
||||
) {
|
||||
tracing::info!(
|
||||
target: "im.delivery",
|
||||
stage,
|
||||
request_id = %request_id,
|
||||
channel_id = %channel_id,
|
||||
message_id = %message_id,
|
||||
seq,
|
||||
"im message delivery trace"
|
||||
);
|
||||
}
|
||||
|
||||
pub fn trace_error(
|
||||
stage: &'static str,
|
||||
request_id: Uuid,
|
||||
subject: &str,
|
||||
error: &dyn std::fmt::Display,
|
||||
) {
|
||||
tracing::warn!(
|
||||
target: "im.delivery",
|
||||
stage,
|
||||
request_id = %request_id,
|
||||
subject,
|
||||
error = %error,
|
||||
"im delivery trace failed"
|
||||
);
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::immediate::{DraftAction, DraftEvent};
|
||||
use crate::models::channels::MessageDraft;
|
||||
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 SaveDraftParams {
|
||||
pub content: String,
|
||||
pub thread_id: Option<Uuid>,
|
||||
pub reply_to_message_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl ImService {
|
||||
async fn draft_realtime(
|
||||
&self,
|
||||
channel_id: Uuid,
|
||||
user_id: Uuid,
|
||||
thread_id: Option<Uuid>,
|
||||
action: DraftAction,
|
||||
) {
|
||||
let request_id = Uuid::nil();
|
||||
let event = DraftEvent {
|
||||
channel_id,
|
||||
user_id,
|
||||
thread_id,
|
||||
action,
|
||||
};
|
||||
self.publish(&format!("im.draft.{user_id}"), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Draft {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn draft_save(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
params: SaveDraftParams,
|
||||
) -> Result<MessageDraft, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
||||
|
||||
if params.content.len() > MAX_MESSAGE_BODY {
|
||||
return Err(AppError::BadRequest("draft content too long".into()));
|
||||
}
|
||||
|
||||
// NOTE: COALESCE(thread_id, nil_uuid) in ON CONFLICT requires a matching
|
||||
// UNIQUE index with the identical COALESCE expression.
|
||||
let now = chrono::Utc::now();
|
||||
let draft = sqlx::query_as::<_, MessageDraft>(
|
||||
"INSERT INTO message_draft \
|
||||
(id, user_id, channel_id, thread_id, reply_to_message_id, content, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $7) \
|
||||
ON CONFLICT (user_id, channel_id, COALESCE(thread_id, '00000000-0000-0000-0000-000000000000'::uuid)) \
|
||||
DO UPDATE SET content = $6, reply_to_message_id = $5, updated_at = $7 \
|
||||
RETURNING id, user_id, channel_id, thread_id, reply_to_message_id, content, \
|
||||
attachments, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(user_uid)
|
||||
.bind(channel_id)
|
||||
.bind(params.thread_id)
|
||||
.bind(params.reply_to_message_id)
|
||||
.bind(¶ms.content)
|
||||
.bind(now)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
self.draft_realtime(channel_id, user_uid, draft.thread_id, DraftAction::Saved)
|
||||
.await;
|
||||
Ok(draft)
|
||||
}
|
||||
|
||||
pub async fn draft_get(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
thread_id: Option<Uuid>,
|
||||
) -> Result<Option<MessageDraft>, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
||||
|
||||
sqlx::query_as::<_, MessageDraft>(
|
||||
"SELECT id, user_id, channel_id, thread_id, reply_to_message_id, content, \
|
||||
attachments, created_at, updated_at \
|
||||
FROM message_draft \
|
||||
WHERE user_id = $1 AND channel_id = $2 \
|
||||
AND (thread_id = $3 OR (thread_id IS NULL AND $3 IS NULL))",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.bind(channel_id)
|
||||
.bind(thread_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn draft_delete(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
thread_id: Option<Uuid>,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user;
|
||||
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM message_draft \
|
||||
WHERE user_id = $1 AND channel_id = $2 \
|
||||
AND (thread_id = $3 OR (thread_id IS NULL AND $3 IS NULL))",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.bind(channel_id)
|
||||
.bind(thread_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
ensure_affected(result.rows_affected(), "draft not found")?;
|
||||
self.draft_realtime(channel_id, user_uid, thread_id, DraftAction::Deleted)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn draft_list(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
wk_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<MessageDraft>, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let _ = self.resolve_workspace(wk_name).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
|
||||
sqlx::query_as::<_, MessageDraft>(
|
||||
"SELECT id, user_id, channel_id, thread_id, reply_to_message_id, content, \
|
||||
attachments, created_at, updated_at \
|
||||
FROM message_draft WHERE user_id = $1 \
|
||||
ORDER BY updated_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
+46
-40
@@ -1,64 +1,70 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::immediate::{
|
||||
ArticleEvent, CategoryEvent, ChannelEvent, DraftEvent, FollowEvent, MemberEvent, MessageEvent,
|
||||
PollEvent, PresenceEvent, ReactionEvent, ThreadEvent, TypingEvent,
|
||||
};
|
||||
use crate::models::base_info::UserBaseInfo;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ChannelAction {
|
||||
Created,
|
||||
Updated,
|
||||
Deleted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChannelEvent {
|
||||
pub channel_id: Uuid,
|
||||
pub action: ChannelAction,
|
||||
pub workspace_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum MemberAction {
|
||||
Joined,
|
||||
Updated,
|
||||
Kicked,
|
||||
Left,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemberEvent {
|
||||
pub channel_id: Uuid,
|
||||
pub user: UserBaseInfo,
|
||||
pub user_id: Uuid,
|
||||
pub action: MemberAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum CategoryAction {
|
||||
Created,
|
||||
Updated,
|
||||
Deleted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CategoryEvent {
|
||||
pub workspace_name: String,
|
||||
pub category_id: Uuid,
|
||||
pub action: CategoryAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ImEvent {
|
||||
Typing {
|
||||
request_id: Uuid,
|
||||
data: TypingEvent,
|
||||
},
|
||||
Presence {
|
||||
request_id: Uuid,
|
||||
data: PresenceEvent,
|
||||
},
|
||||
Message {
|
||||
request_id: Uuid,
|
||||
data: MessageEvent,
|
||||
},
|
||||
Channel {
|
||||
request_id: Uuid,
|
||||
data: ChannelEvent,
|
||||
},
|
||||
Thread {
|
||||
request_id: Uuid,
|
||||
data: ThreadEvent,
|
||||
},
|
||||
Member {
|
||||
request_id: Uuid,
|
||||
data: MemberEvent,
|
||||
},
|
||||
Reaction {
|
||||
request_id: Uuid,
|
||||
data: ReactionEvent,
|
||||
},
|
||||
Poll {
|
||||
request_id: Uuid,
|
||||
data: PollEvent,
|
||||
},
|
||||
Article {
|
||||
request_id: Uuid,
|
||||
data: ArticleEvent,
|
||||
},
|
||||
Category {
|
||||
request_id: Uuid,
|
||||
data: CategoryEvent,
|
||||
},
|
||||
Draft {
|
||||
request_id: Uuid,
|
||||
data: DraftEvent,
|
||||
},
|
||||
Follow {
|
||||
request_id: Uuid,
|
||||
data: FollowEvent,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::immediate::{FollowAction, FollowEvent};
|
||||
use crate::models::channels::{ArticleCrossPost, ChannelFollow};
|
||||
|
||||
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 FollowChannelParams {
|
||||
pub target_workspace_id: Uuid,
|
||||
pub target_channel_id: Option<Uuid>,
|
||||
pub webhook_url: Option<String>,
|
||||
}
|
||||
|
||||
impl ImService {
|
||||
async fn follow_realtime(&self, channel_id: Uuid, follow_id: Uuid, action: FollowAction) {
|
||||
let request_id = Uuid::nil();
|
||||
let event = FollowEvent {
|
||||
channel_id,
|
||||
follow_id,
|
||||
action,
|
||||
};
|
||||
self.publish(&format!("im.follow.{channel_id}"), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Follow {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn follow_list(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
) -> Result<Vec<ChannelFollow>, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_admin(user_uid, &channel).await?;
|
||||
|
||||
sqlx::query_as::<_, ChannelFollow>(
|
||||
"SELECT id, source_channel_id, target_workspace_id, target_channel_id, \
|
||||
webhook_url, webhook_secret_ciphertext, enabled, followed_by, \
|
||||
unfollowed_at, last_delivery_at, last_delivery_status, created_at, updated_at \
|
||||
FROM channel_follow WHERE source_channel_id = $1 AND unfollowed_at IS NULL \
|
||||
ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn follow_create(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
params: FollowChannelParams,
|
||||
) -> Result<ChannelFollow, 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 follow = sqlx::query_as::<_, ChannelFollow>(
|
||||
"INSERT INTO channel_follow \
|
||||
(id, source_channel_id, target_workspace_id, target_channel_id, \
|
||||
webhook_url, enabled, followed_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, true, $6, $7, $7) \
|
||||
ON CONFLICT (source_channel_id, target_workspace_id, target_channel_id) \
|
||||
DO UPDATE SET enabled = true, unfollowed_at = NULL, updated_at = $7 \
|
||||
RETURNING id, source_channel_id, target_workspace_id, target_channel_id, \
|
||||
webhook_url, webhook_secret_ciphertext, enabled, followed_by, \
|
||||
unfollowed_at, last_delivery_at, last_delivery_status, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(channel_id)
|
||||
.bind(params.target_workspace_id)
|
||||
.bind(params.target_channel_id)
|
||||
.bind(params.webhook_url.as_deref())
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
self.follow_realtime(channel_id, follow.id, FollowAction::Created)
|
||||
.await;
|
||||
Ok(follow)
|
||||
}
|
||||
|
||||
pub async fn follow_delete(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
follow_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_admin(user_uid, &channel).await?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let result = sqlx::query(
|
||||
"UPDATE channel_follow SET unfollowed_at = $1, enabled = false, updated_at = $1 \
|
||||
WHERE id = $2 AND source_channel_id = $3 AND unfollowed_at IS NULL",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(follow_id)
|
||||
.bind(channel_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
ensure_affected(result.rows_affected(), "follow not found")?;
|
||||
self.follow_realtime(channel_id, follow_id, FollowAction::Deleted)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn cross_post_article(
|
||||
&self,
|
||||
article_id: Uuid,
|
||||
channel_id: Uuid,
|
||||
_actor_id: Uuid,
|
||||
) -> Result<u64, AppError> {
|
||||
let followers = sqlx::query_as::<_, ChannelFollow>(
|
||||
"SELECT id, source_channel_id, target_workspace_id, target_channel_id, \
|
||||
webhook_url, webhook_secret_ciphertext, enabled, followed_by, \
|
||||
unfollowed_at, last_delivery_at, last_delivery_status, created_at, updated_at \
|
||||
FROM channel_follow WHERE source_channel_id = $1 AND enabled AND unfollowed_at IS NULL",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut count = 0u64;
|
||||
|
||||
for follow in &followers {
|
||||
sqlx::query(
|
||||
"INSERT INTO article_cross_post \
|
||||
(id, article_id, follow_id, target_workspace_id, target_channel_id, \
|
||||
status, attempts, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, 'pending', 0, $6) \
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(article_id)
|
||||
.bind(follow.id)
|
||||
.bind(follow.target_workspace_id)
|
||||
.bind(follow.target_channel_id)
|
||||
.bind(now)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
count += 1;
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
sqlx::query("UPDATE article SET cross_posted = true WHERE id = $1")
|
||||
.bind(article_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
article_id = %article_id,
|
||||
followers = count,
|
||||
"Cross-post jobs created"
|
||||
);
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub async fn cross_post_list(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
article_id: Uuid,
|
||||
) -> Result<Vec<ArticleCrossPost>, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_admin(user_uid, &channel).await?;
|
||||
|
||||
sqlx::query_as::<_, ArticleCrossPost>(
|
||||
"SELECT id, article_id, follow_id, target_workspace_id, target_channel_id, \
|
||||
status, attempts, last_error, sent_at, delivered_at, failed_at, created_at \
|
||||
FROM article_cross_post WHERE article_id = $1 ORDER BY created_at ASC",
|
||||
)
|
||||
.bind(article_id)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn cross_post_retry(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
cross_post_id: Uuid,
|
||||
) -> Result<ArticleCrossPost, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_admin(user_uid, &channel).await?;
|
||||
|
||||
let post = sqlx::query_as::<_, ArticleCrossPost>(
|
||||
"UPDATE article_cross_post SET status = 'pending', attempts = 0, \
|
||||
last_error = NULL, failed_at = NULL \
|
||||
WHERE id = $1 AND status = 'failed' \
|
||||
RETURNING id, article_id, follow_id, target_workspace_id, target_channel_id, \
|
||||
status, attempts, last_error, sent_at, delivered_at, failed_at, created_at",
|
||||
)
|
||||
.bind(cross_post_id)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
self.follow_realtime(channel_id, post.follow_id, FollowAction::Retried)
|
||||
.await;
|
||||
Ok(post)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::channels::ForumTag;
|
||||
use crate::service::ImService;
|
||||
|
||||
use super::session::ImSession;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateForumTagParams {
|
||||
pub name: String,
|
||||
pub emoji_id: Option<String>,
|
||||
pub emoji_name: Option<String>,
|
||||
pub moderated: Option<bool>,
|
||||
pub position: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateForumTagParams {
|
||||
pub name: Option<String>,
|
||||
pub emoji_id: Option<String>,
|
||||
pub emoji_name: Option<String>,
|
||||
pub moderated: Option<bool>,
|
||||
pub position: Option<i32>,
|
||||
}
|
||||
|
||||
impl ImService {
|
||||
pub async fn forum_tag_list(
|
||||
&self,
|
||||
_ctx: &ImSession,
|
||||
channel_id: Uuid,
|
||||
) -> Result<Vec<ForumTag>, AppError> {
|
||||
sqlx::query_as::<_, ForumTag>(
|
||||
"SELECT id, channel_id, name, emoji_id, emoji_name, moderated, position, \
|
||||
created_by, created_at, updated_at \
|
||||
FROM forum_tag WHERE channel_id = $1 ORDER BY position, name",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn forum_tag_create(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
channel_id: Uuid,
|
||||
params: CreateForumTagParams,
|
||||
) -> Result<ForumTag, AppError> {
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, ForumTag>(
|
||||
"INSERT INTO forum_tag \
|
||||
(id, channel_id, name, emoji_id, emoji_name, moderated, position, \
|
||||
created_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) \
|
||||
RETURNING id, channel_id, name, emoji_id, emoji_name, moderated, position, \
|
||||
created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(channel_id)
|
||||
.bind(¶ms.name)
|
||||
.bind(params.emoji_id.as_deref())
|
||||
.bind(params.emoji_name.as_deref())
|
||||
.bind(params.moderated.unwrap_or(false))
|
||||
.bind(params.position.unwrap_or(0))
|
||||
.bind(ctx.user)
|
||||
.bind(now)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn forum_tag_update(
|
||||
&self,
|
||||
_ctx: &ImSession,
|
||||
tag_id: Uuid,
|
||||
params: UpdateForumTagParams,
|
||||
) -> Result<ForumTag, AppError> {
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, ForumTag>(
|
||||
"UPDATE forum_tag SET \
|
||||
name = COALESCE($1, name), \
|
||||
emoji_id = COALESCE($2, emoji_id), \
|
||||
emoji_name = COALESCE($3, emoji_name), \
|
||||
moderated = COALESCE($4, moderated), \
|
||||
position = COALESCE($5, position), \
|
||||
updated_at = $6 \
|
||||
WHERE id = $7 \
|
||||
RETURNING id, channel_id, name, emoji_id, emoji_name, moderated, position, \
|
||||
created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(params.name.as_deref())
|
||||
.bind(params.emoji_id.as_deref())
|
||||
.bind(params.emoji_name.as_deref())
|
||||
.bind(params.moderated)
|
||||
.bind(params.position)
|
||||
.bind(now)
|
||||
.bind(tag_id)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn forum_tag_delete(
|
||||
&self,
|
||||
_ctx: &ImSession,
|
||||
tag_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query("DELETE FROM forum_tag WHERE id = $1")
|
||||
.bind(tag_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::channels::ImIntegration;
|
||||
use crate::service::ImService;
|
||||
|
||||
use super::session::ImSession;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateIntegrationParams {
|
||||
pub provider: String,
|
||||
pub name: String,
|
||||
pub external_workspace_id: Option<String>,
|
||||
pub internal_channel_id: Option<Uuid>,
|
||||
pub external_channel_id: Option<String>,
|
||||
pub bot_token: Option<String>,
|
||||
pub webhook_url: Option<String>,
|
||||
pub sync_direction: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateIntegrationParams {
|
||||
pub name: Option<String>,
|
||||
pub external_channel_id: Option<String>,
|
||||
pub webhook_url: Option<String>,
|
||||
pub sync_direction: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl ImService {
|
||||
pub async fn integration_list(
|
||||
&self,
|
||||
_ctx: &ImSession,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<Vec<ImIntegration>, AppError> {
|
||||
sqlx::query_as::<_, ImIntegration>(
|
||||
"SELECT id, workspace_id, provider, name, external_workspace_id, \
|
||||
internal_channel_id, external_channel_id, bot_token_ciphertext, webhook_url, \
|
||||
sync_direction, user_mapping, enabled, installed_by, last_sync_at, \
|
||||
created_at, updated_at \
|
||||
FROM im_integration WHERE workspace_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn integration_create(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
workspace_id: Uuid,
|
||||
params: CreateIntegrationParams,
|
||||
) -> Result<ImIntegration, AppError> {
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, ImIntegration>(
|
||||
"INSERT INTO im_integration \
|
||||
(id, workspace_id, provider, name, external_workspace_id, \
|
||||
internal_channel_id, external_channel_id, bot_token_ciphertext, webhook_url, \
|
||||
sync_direction, enabled, installed_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3::provider, $4, $5, $6, $7, $8, $9, $10::sync_direction, \
|
||||
true, $11, $12, $12) \
|
||||
RETURNING id, workspace_id, provider, name, external_workspace_id, \
|
||||
internal_channel_id, external_channel_id, bot_token_ciphertext, webhook_url, \
|
||||
sync_direction, user_mapping, enabled, installed_by, last_sync_at, \
|
||||
created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(workspace_id)
|
||||
.bind(¶ms.provider)
|
||||
.bind(¶ms.name)
|
||||
.bind(params.external_workspace_id.as_deref())
|
||||
.bind(params.internal_channel_id)
|
||||
.bind(params.external_channel_id.as_deref())
|
||||
.bind(params.bot_token.as_deref())
|
||||
.bind(params.webhook_url.as_deref())
|
||||
.bind(¶ms.sync_direction)
|
||||
.bind(ctx.user)
|
||||
.bind(now)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn integration_update(
|
||||
&self,
|
||||
_ctx: &ImSession,
|
||||
integration_id: Uuid,
|
||||
params: UpdateIntegrationParams,
|
||||
) -> Result<ImIntegration, AppError> {
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, ImIntegration>(
|
||||
"UPDATE im_integration SET \
|
||||
name = COALESCE($1, name), \
|
||||
external_channel_id = COALESCE($2, external_channel_id), \
|
||||
webhook_url = COALESCE($3, webhook_url), \
|
||||
sync_direction = COALESCE($4::sync_direction, sync_direction), \
|
||||
enabled = COALESCE($5, enabled), \
|
||||
updated_at = $6 \
|
||||
WHERE id = $7 \
|
||||
RETURNING id, workspace_id, provider, name, external_workspace_id, \
|
||||
internal_channel_id, external_channel_id, bot_token_ciphertext, webhook_url, \
|
||||
sync_direction, user_mapping, enabled, installed_by, last_sync_at, \
|
||||
created_at, updated_at",
|
||||
)
|
||||
.bind(params.name.as_deref())
|
||||
.bind(params.external_channel_id.as_deref())
|
||||
.bind(params.webhook_url.as_deref())
|
||||
.bind(params.sync_direction.as_deref())
|
||||
.bind(params.enabled)
|
||||
.bind(now)
|
||||
.bind(integration_id)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn integration_delete(
|
||||
&self,
|
||||
_ctx: &ImSession,
|
||||
integration_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query("DELETE FROM im_integration WHERE id = $1")
|
||||
.bind(integration_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+19
-39
@@ -2,7 +2,8 @@ use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::immediate::{MemberAction, MemberEvent};
|
||||
use crate::service::im::events::{MemberAction, MemberEvent};
|
||||
use crate::models::base_info::UserBaseInfo;
|
||||
use crate::models::channels::ChannelMember;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::workspaces::Workspace;
|
||||
@@ -93,8 +94,7 @@ impl ImService {
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
sqlx::query(set_local_user_id(user_uid))
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
@@ -115,13 +115,15 @@ impl ImService {
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
self.update_channel_stats(channel_id, now, &mut txn).await?;
|
||||
self.increment_channel_stat(channel_id, 1, now, &mut txn)
|
||||
.await?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
tracing::info!(channel_id = %channel_id, user_id = %params.user_id, "Member invited");
|
||||
let request_id = Uuid::nil();
|
||||
let event = MemberEvent {
|
||||
channel_id,
|
||||
user: UserBaseInfo::placeholder(member.user_id),
|
||||
user_id: member.user_id,
|
||||
action: MemberAction::Joined,
|
||||
};
|
||||
@@ -184,6 +186,7 @@ impl ImService {
|
||||
let request_id = Uuid::nil();
|
||||
let event = MemberEvent {
|
||||
channel_id,
|
||||
user: UserBaseInfo::placeholder(member.user_id),
|
||||
user_id: member.user_id,
|
||||
action: MemberAction::Updated,
|
||||
};
|
||||
@@ -227,8 +230,7 @@ impl ImService {
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
sqlx::query(set_local_user_id(user_uid))
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
@@ -245,13 +247,15 @@ impl ImService {
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "member not found")?;
|
||||
|
||||
self.update_channel_stats(channel_id, now, &mut txn).await?;
|
||||
self.increment_channel_stat(channel_id, -1, now, &mut txn)
|
||||
.await?;
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
tracing::info!(channel_id = %channel_id, user_id = %member_user_id, "Member kicked");
|
||||
let request_id = Uuid::nil();
|
||||
let event = MemberEvent {
|
||||
channel_id,
|
||||
user: UserBaseInfo::placeholder(member_user_id),
|
||||
user_id: member_user_id,
|
||||
action: MemberAction::Kicked,
|
||||
};
|
||||
@@ -285,8 +289,7 @@ impl ImService {
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
sqlx::query(set_local_user_id(user_uid))
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
@@ -303,11 +306,13 @@ impl ImService {
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "not a member")?;
|
||||
|
||||
self.update_channel_stats(channel_id, now, &mut txn).await?;
|
||||
self.increment_channel_stat(channel_id, -1, now, &mut txn)
|
||||
.await?;
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
let request_id = Uuid::nil();
|
||||
let event = MemberEvent {
|
||||
channel_id,
|
||||
user: UserBaseInfo::placeholder(user_uid),
|
||||
user_id: user_uid,
|
||||
action: MemberAction::Left,
|
||||
};
|
||||
@@ -343,8 +348,7 @@ impl ImService {
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
sqlx::query(set_local_user_id(user_uid))
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
@@ -364,11 +368,13 @@ impl ImService {
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
self.update_channel_stats(channel_id, now, &mut txn).await?;
|
||||
self.increment_channel_stat(channel_id, 1, now, &mut txn)
|
||||
.await?;
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
let request_id = Uuid::nil();
|
||||
let event = MemberEvent {
|
||||
channel_id,
|
||||
user: UserBaseInfo::placeholder(member.user_id),
|
||||
user_id: member.user_id,
|
||||
action: MemberAction::Joined,
|
||||
};
|
||||
@@ -381,30 +387,4 @@ impl ImService {
|
||||
Ok(member)
|
||||
}
|
||||
|
||||
pub async fn member_update_read(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
message_id: Uuid,
|
||||
) -> Result<ChannelMember, 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();
|
||||
sqlx::query_as::<_, ChannelMember>(
|
||||
"UPDATE channel_member SET last_read_message_id = $1, last_read_at = $2, updated_at = $2 \
|
||||
WHERE channel_id = $3 AND user_id = $4 AND status = 'active' \
|
||||
RETURNING id, channel_id, user_id, role, status, muted, pinned, \
|
||||
last_read_message_id, last_read_at, joined_at, left_at, created_at, updated_at",
|
||||
)
|
||||
.bind(message_id)
|
||||
.bind(now)
|
||||
.bind(channel_id)
|
||||
.bind(user_uid)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,889 +0,0 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::immediate::{MessageAction, MessageEvent};
|
||||
use crate::models::channels::{Message, MessageBookmark, MessageEditHistory, SavedMessage};
|
||||
use crate::models::common::{JsonValue, MessageType};
|
||||
use crate::service::ImService;
|
||||
use crate::service::im::delivery_trace::trace_message;
|
||||
use crate::service::im::events::ImEvent;
|
||||
use ::redis::Cmd;
|
||||
|
||||
use super::session::ImSession;
|
||||
use super::util::*;
|
||||
|
||||
const MESSAGE_SEQ_SCRIPT: &str = "local cur = redis.call('GET', KEYS[1]); if (not cur) or (tonumber(cur) < tonumber(ARGV[1])) then redis.call('SET', KEYS[1], ARGV[1]); end; return redis.call('INCR', KEYS[1]);";
|
||||
static MESSAGE_SEQ_SHA: OnceLock<String> = OnceLock::new();
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct SendMessageParams {
|
||||
pub body: String,
|
||||
pub message_type: Option<String>,
|
||||
pub thread_id: Option<Uuid>,
|
||||
pub reply_to_message_id: Option<Uuid>,
|
||||
pub pinned: Option<bool>,
|
||||
pub attachments: Option<Vec<CreateAttachmentParams>>,
|
||||
pub embeds: Option<Vec<CreateEmbedParams>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct EditMessageParams {
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateAttachmentParams {
|
||||
pub filename: String,
|
||||
pub url: String,
|
||||
pub proxy_url: Option<String>,
|
||||
pub size_bytes: i64,
|
||||
pub mime_type: String,
|
||||
pub width: Option<i32>,
|
||||
pub height: Option<i32>,
|
||||
pub duration_ms: Option<i64>,
|
||||
pub thumbnail_url: Option<String>,
|
||||
pub blurhash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateEmbedParams {
|
||||
pub embed_type: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub author_name: Option<String>,
|
||||
pub author_url: Option<String>,
|
||||
pub author_icon_url: Option<String>,
|
||||
pub thumbnail_url: Option<String>,
|
||||
pub image_url: Option<String>,
|
||||
pub color: Option<i32>,
|
||||
pub fields: Option<JsonValue>,
|
||||
pub footer_text: Option<String>,
|
||||
pub footer_icon_url: Option<String>,
|
||||
pub provider_name: Option<String>,
|
||||
pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct MessageListFilters {
|
||||
pub thread_id: Option<Uuid>,
|
||||
pub author_id: Option<Uuid>,
|
||||
pub pinned: Option<bool>,
|
||||
pub before: Option<Uuid>,
|
||||
pub after: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl ImService {
|
||||
pub async fn message_list(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
filters: MessageListFilters,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Message>, 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::<_, Message>(
|
||||
"SELECT id, channel_id, author_id, thread_id, reply_to_message_id, seq, \
|
||||
message_type, body, metadata, pinned, system, edited_at, deleted_at, \
|
||||
created_at, updated_at \
|
||||
FROM message \
|
||||
WHERE channel_id = $1 AND deleted_at IS NULL \
|
||||
AND ($2::uuid IS NULL OR thread_id = $2) \
|
||||
AND ($3::uuid IS NULL OR author_id = $3) \
|
||||
AND ($4::bool IS NULL OR pinned = $4) \
|
||||
AND ($5::uuid IS NULL OR seq < (SELECT seq FROM message WHERE id = $5)) \
|
||||
AND ($6::uuid IS NULL OR seq > (SELECT seq FROM message WHERE id = $6)) \
|
||||
ORDER BY seq DESC LIMIT $7 OFFSET $8",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.bind(filters.thread_id)
|
||||
.bind(filters.author_id)
|
||||
.bind(filters.pinned)
|
||||
.bind(filters.before)
|
||||
.bind(filters.after)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self, ctx, params))]
|
||||
pub async fn message_send(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
params: SendMessageParams,
|
||||
request_id: Uuid,
|
||||
) -> Result<Message, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_member(user_uid, &channel).await?;
|
||||
|
||||
if channel.read_only {
|
||||
self.ensure_channel_editable(user_uid, &channel).await?;
|
||||
}
|
||||
|
||||
let body = required_text(params.body, "body")?;
|
||||
if body.len() > MAX_MESSAGE_BODY {
|
||||
return Err(AppError::BadRequest("message body too long".into()));
|
||||
}
|
||||
|
||||
let msg_type = parse_enum(
|
||||
params.message_type,
|
||||
MessageType::Text,
|
||||
MessageType::Unknown,
|
||||
"message_type",
|
||||
)?;
|
||||
let thread_id = params.thread_id;
|
||||
if let Some(thread_id) = thread_id {
|
||||
self.resolve_thread(thread_id, channel_id).await?;
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let message_id = Uuid::now_v7();
|
||||
let seq = self.next_message_seq(channel_id).await?;
|
||||
|
||||
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 message = sqlx::query_as::<_, Message>(
|
||||
"INSERT INTO message \
|
||||
(id, channel_id, author_id, thread_id, reply_to_message_id, seq, \
|
||||
message_type, body, metadata, pinned, system, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL, $9, false, $10, $10) \
|
||||
RETURNING id, channel_id, author_id, thread_id, reply_to_message_id, seq, \
|
||||
message_type, body, metadata, pinned, system, edited_at, deleted_at, \
|
||||
created_at, updated_at",
|
||||
)
|
||||
.bind(message_id)
|
||||
.bind(channel_id)
|
||||
.bind(user_uid)
|
||||
.bind(thread_id)
|
||||
.bind(params.reply_to_message_id)
|
||||
.bind(seq)
|
||||
.bind(msg_type)
|
||||
.bind(&body)
|
||||
.bind(params.pinned.unwrap_or(false))
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
// Insert attachments
|
||||
if let Some(attachments) = params.attachments {
|
||||
for att in &attachments {
|
||||
let att_filename = required_text(att.filename.clone(), "filename")?;
|
||||
let att_url = required_text(att.url.clone(), "url")?;
|
||||
let att_mime = required_text(att.mime_type.clone(), "mime_type")?;
|
||||
sqlx::query(
|
||||
"INSERT INTO message_attachment \
|
||||
(id, message_id, channel_id, filename, url, proxy_url, \
|
||||
size_bytes, mime_type, width, height, duration_ms, \
|
||||
thumbnail_url, blurhash, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(message_id)
|
||||
.bind(channel_id)
|
||||
.bind(&att_filename)
|
||||
.bind(&att_url)
|
||||
.bind(att.proxy_url.as_deref())
|
||||
.bind(att.size_bytes)
|
||||
.bind(&att_mime)
|
||||
.bind(att.width)
|
||||
.bind(att.height)
|
||||
.bind(att.duration_ms)
|
||||
.bind(att.thumbnail_url.as_deref())
|
||||
.bind(att.blurhash.as_deref())
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert embeds
|
||||
if let Some(embeds) = params.embeds {
|
||||
for emb in &embeds {
|
||||
sqlx::query(
|
||||
"INSERT INTO message_embed \
|
||||
(id, message_id, embed_type, title, description, url, \
|
||||
author_name, author_url, author_icon_url, thumbnail_url, \
|
||||
image_url, color, fields, footer_text, footer_icon_url, \
|
||||
provider_name, \"timestamp\", created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, \
|
||||
$11, $12, $13, $14, $15, $16, $17, $18)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(message_id)
|
||||
.bind(emb.embed_type.as_deref().unwrap_or("rich"))
|
||||
.bind(emb.title.as_deref())
|
||||
.bind(emb.description.as_deref())
|
||||
.bind(emb.url.as_deref())
|
||||
.bind(emb.author_name.as_deref())
|
||||
.bind(emb.author_url.as_deref())
|
||||
.bind(emb.author_icon_url.as_deref())
|
||||
.bind(emb.thumbnail_url.as_deref())
|
||||
.bind(emb.image_url.as_deref())
|
||||
.bind(emb.color)
|
||||
.bind(emb.fields.clone())
|
||||
.bind(emb.footer_text.as_deref())
|
||||
.bind(emb.footer_icon_url.as_deref())
|
||||
.bind(emb.provider_name.as_deref())
|
||||
.bind(emb.timestamp)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(thread_id) = thread_id {
|
||||
sqlx::query(
|
||||
"UPDATE message_thread SET replies_count = replies_count + 1, \
|
||||
participants_count = (SELECT COUNT(DISTINCT author_id)::int FROM message WHERE thread_id = $3 AND deleted_at IS NULL), \
|
||||
last_reply_message_id = $1, last_reply_at = $2, updated_at = $2 \
|
||||
WHERE id = $3 AND channel_id = $4",
|
||||
)
|
||||
.bind(message_id)
|
||||
.bind(now)
|
||||
.bind(thread_id)
|
||||
.bind(channel_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
// Update channel last_message
|
||||
sqlx::query(
|
||||
"UPDATE channel SET last_message_id = $1, last_message_at = $2, updated_at = $2 \
|
||||
WHERE id = $3",
|
||||
)
|
||||
.bind(message_id)
|
||||
.bind(now)
|
||||
.bind(channel_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
self.update_channel_stats(channel_id, now, &mut txn).await?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
tracing::info!(message_id = %message_id, channel_id = %channel_id, "Message sent");
|
||||
trace_message(
|
||||
"committed",
|
||||
request_id,
|
||||
channel_id,
|
||||
message.id,
|
||||
Some(message.seq),
|
||||
);
|
||||
|
||||
let event = MessageEvent {
|
||||
channel_id,
|
||||
thread_id: message.thread_id,
|
||||
message_id: message.id,
|
||||
author_id: message.author_id,
|
||||
action: MessageAction::Created,
|
||||
body: Some(message.body.clone()),
|
||||
seq: Some(message.seq),
|
||||
};
|
||||
self.publish(&format!("im.message.{}", channel_id), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Message {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
pub async fn message_edit(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
message_id: Uuid,
|
||||
params: EditMessageParams,
|
||||
request_id: Uuid,
|
||||
) -> Result<Message, 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")?;
|
||||
if body.len() > MAX_MESSAGE_BODY {
|
||||
return Err(AppError::BadRequest("message body too long".into()));
|
||||
}
|
||||
|
||||
let existing = self.resolve_message(message_id, channel_id).await?;
|
||||
if existing.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)?;
|
||||
|
||||
// Save edit history
|
||||
sqlx::query(
|
||||
"INSERT INTO message_edit_history (id, message_id, channel_id, previous_body, edited_by, edited_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(message_id)
|
||||
.bind(channel_id)
|
||||
.bind(&existing.body)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let updated = sqlx::query_as::<_, Message>(
|
||||
"UPDATE message SET body = $1, edited_at = $2, updated_at = $2 \
|
||||
WHERE id = $3 AND channel_id = $4 AND deleted_at IS NULL \
|
||||
RETURNING id, channel_id, author_id, thread_id, reply_to_message_id, seq, \
|
||||
message_type, body, metadata, pinned, system, edited_at, deleted_at, \
|
||||
created_at, updated_at",
|
||||
)
|
||||
.bind(&body)
|
||||
.bind(now)
|
||||
.bind(message_id)
|
||||
.bind(channel_id)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
let event = MessageEvent {
|
||||
channel_id,
|
||||
thread_id: updated.thread_id,
|
||||
message_id: updated.id,
|
||||
author_id: updated.author_id,
|
||||
action: MessageAction::Edited,
|
||||
body: Some(updated.body.clone()),
|
||||
seq: Some(updated.seq),
|
||||
};
|
||||
self.publish(&format!("im.message.{}", channel_id), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Message {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn message_delete(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
message_id: Uuid,
|
||||
request_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
|
||||
let existing = self.resolve_message(message_id, channel_id).await?;
|
||||
if existing.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)?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE message SET deleted_at = $1, updated_at = $1 \
|
||||
WHERE id = $2 AND channel_id = $3 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(message_id)
|
||||
.bind(channel_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "message not found")?;
|
||||
|
||||
self.update_channel_stats(channel_id, now, &mut txn).await?;
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
let event = MessageEvent {
|
||||
channel_id,
|
||||
thread_id: None,
|
||||
message_id,
|
||||
author_id: existing.author_id,
|
||||
action: MessageAction::Deleted,
|
||||
body: None,
|
||||
seq: Some(existing.seq),
|
||||
};
|
||||
self.publish(&format!("im.message.{}", channel_id), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Message {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn message_pin(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
message_id: Uuid,
|
||||
request_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_editable(user_uid, &channel).await?;
|
||||
let message = self.resolve_message(message_id, channel_id).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 message SET pinned = true, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL")
|
||||
.bind(now)
|
||||
.bind(message_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO message_pin (id, message_id, channel_id, pinned_by, pinned_at) \
|
||||
VALUES ($1, $2, $3, $4, $5) \
|
||||
ON CONFLICT (message_id) DO NOTHING",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(message_id)
|
||||
.bind(channel_id)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
let event = MessageEvent {
|
||||
channel_id,
|
||||
thread_id: None,
|
||||
message_id,
|
||||
author_id: ctx.user,
|
||||
action: MessageAction::Pinned,
|
||||
body: None,
|
||||
seq: Some(message.seq),
|
||||
};
|
||||
self.publish(&format!("im.message.{}", channel_id), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Message {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn message_unpin(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
message_id: Uuid,
|
||||
request_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_editable(user_uid, &channel).await?;
|
||||
let message = self.resolve_message(message_id, channel_id).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 message SET pinned = false, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL")
|
||||
.bind(now)
|
||||
.bind(message_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query("DELETE FROM message_pin WHERE message_id = $1")
|
||||
.bind(message_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
let event = MessageEvent {
|
||||
channel_id,
|
||||
thread_id: None,
|
||||
message_id,
|
||||
author_id: ctx.user,
|
||||
action: MessageAction::Unpinned,
|
||||
body: None,
|
||||
seq: Some(message.seq),
|
||||
};
|
||||
self.publish(&format!("im.message.{}", channel_id), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Message {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn message_list_pinned(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
) -> Result<Vec<Message>, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
||||
|
||||
sqlx::query_as::<_, Message>(
|
||||
"SELECT m.id, m.channel_id, m.author_id, m.thread_id, m.reply_to_message_id, m.seq, \
|
||||
m.message_type, m.body, m.metadata, m.pinned, m.system, m.edited_at, m.deleted_at, \
|
||||
m.created_at, m.updated_at \
|
||||
FROM message m \
|
||||
JOIN message_pin mp ON mp.message_id = m.id \
|
||||
WHERE m.channel_id = $1 AND m.deleted_at IS NULL AND m.pinned \
|
||||
ORDER BY mp.pinned_at DESC",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn message_edit_history(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
message_id: Uuid,
|
||||
) -> Result<Vec<MessageEditHistory>, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
||||
|
||||
sqlx::query_as::<_, MessageEditHistory>(
|
||||
"SELECT id, message_id, channel_id, previous_body, edited_by, edited_at \
|
||||
FROM message_edit_history \
|
||||
WHERE message_id = $1 AND channel_id = $2 \
|
||||
ORDER BY edited_at ASC",
|
||||
)
|
||||
.bind(message_id)
|
||||
.bind(channel_id)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn message_bookmark(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
message_id: Uuid,
|
||||
note: Option<String>,
|
||||
) -> Result<MessageBookmark, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
||||
self.resolve_message(message_id, channel_id).await?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, MessageBookmark>(
|
||||
"INSERT INTO message_bookmark (id, message_id, channel_id, user_id, note, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6) \
|
||||
ON CONFLICT (message_id, user_id) DO UPDATE SET note = COALESCE($5, message_bookmark.note), updated_at = $6 \
|
||||
RETURNING id, message_id, channel_id, user_id, note, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(message_id)
|
||||
.bind(channel_id)
|
||||
.bind(user_uid)
|
||||
.bind(note.as_deref())
|
||||
.bind(now)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn message_unbookmark(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
message_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM message_bookmark WHERE message_id = $1 AND user_id = $2 AND channel_id = $3",
|
||||
)
|
||||
.bind(message_id)
|
||||
.bind(user_uid)
|
||||
.bind(channel_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
ensure_affected(result.rows_affected(), "bookmark not found")
|
||||
}
|
||||
|
||||
pub async fn message_list_bookmarks(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
wk_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<MessageBookmark>, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
self.ensure_workspace_readable(user_uid, &ws).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
|
||||
sqlx::query_as::<_, MessageBookmark>(
|
||||
"SELECT mb.id, mb.message_id, mb.channel_id, mb.user_id, mb.note, mb.created_at, mb.updated_at \
|
||||
FROM message_bookmark mb \
|
||||
JOIN channel c ON c.id = mb.channel_id \
|
||||
WHERE mb.user_id = $1 AND c.workspace_id = $2 \
|
||||
ORDER BY mb.created_at DESC LIMIT $3 OFFSET $4",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.bind(ws.id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn message_save(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
message_id: Uuid,
|
||||
note: Option<String>,
|
||||
) -> Result<SavedMessage, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
||||
self.resolve_message(message_id, channel_id).await?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, SavedMessage>(
|
||||
"INSERT INTO saved_message (id, user_id, message_id, channel_id, note, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6) \
|
||||
ON CONFLICT (user_id, message_id) DO NOTHING \
|
||||
RETURNING id, user_id, message_id, channel_id, note, created_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(user_uid)
|
||||
.bind(message_id)
|
||||
.bind(channel_id)
|
||||
.bind(note.as_deref())
|
||||
.bind(now)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn message_unsave(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
wk_name: &str,
|
||||
message_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
self.ensure_workspace_readable(user_uid, &ws).await?;
|
||||
|
||||
let result =
|
||||
sqlx::query("DELETE FROM saved_message WHERE user_id = $1 AND message_id = $2")
|
||||
.bind(user_uid)
|
||||
.bind(message_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
ensure_affected(result.rows_affected(), "saved message not found")
|
||||
}
|
||||
|
||||
pub async fn message_list_saved(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
wk_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<SavedMessage>, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
self.ensure_workspace_readable(user_uid, &ws).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
|
||||
sqlx::query_as::<_, SavedMessage>(
|
||||
"SELECT sm.id, sm.user_id, sm.message_id, sm.channel_id, sm.note, sm.created_at \
|
||||
FROM saved_message sm \
|
||||
JOIN channel c ON c.id = sm.channel_id \
|
||||
WHERE sm.user_id = $1 AND c.workspace_id = $2 \
|
||||
ORDER BY sm.created_at DESC LIMIT $3 OFFSET $4",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.bind(ws.id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
async fn next_message_seq(&self, channel_id: Uuid) -> Result<i64, AppError> {
|
||||
let key = format!("im:seq:{channel_id}");
|
||||
let mut conn = self.ctx.redis.get_connection()?;
|
||||
let exists: bool = Cmd::new()
|
||||
.arg("EXISTS")
|
||||
.arg(&key)
|
||||
.query(&mut *conn.inner_mut())
|
||||
.map_err(AppError::Redis)?;
|
||||
let db_max = if exists {
|
||||
0
|
||||
} else {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COALESCE(MAX(seq), 0)::bigint FROM message WHERE channel_id = $1",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
};
|
||||
let sha = self.message_seq_sha()?;
|
||||
let result: Result<i64, redis::RedisError> = Cmd::new()
|
||||
.arg("EVALSHA")
|
||||
.arg(&sha)
|
||||
.arg(1)
|
||||
.arg(&key)
|
||||
.arg(db_max)
|
||||
.query(&mut *conn.inner_mut());
|
||||
match result {
|
||||
Ok(seq) => Ok(seq),
|
||||
Err(e) if e.to_string().contains("NOSCRIPT") => Cmd::new()
|
||||
.arg("EVAL")
|
||||
.arg(MESSAGE_SEQ_SCRIPT)
|
||||
.arg(1)
|
||||
.arg(&key)
|
||||
.arg(db_max)
|
||||
.query(&mut *conn.inner_mut())
|
||||
.map_err(AppError::Redis),
|
||||
Err(e) => Err(AppError::Redis(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn message_seq_sha(&self) -> Result<String, AppError> {
|
||||
if let Some(sha) = MESSAGE_SEQ_SHA.get() {
|
||||
return Ok(sha.clone());
|
||||
}
|
||||
let mut conn = self.ctx.redis.get_connection()?;
|
||||
let sha: String = Cmd::new()
|
||||
.arg("SCRIPT")
|
||||
.arg("LOAD")
|
||||
.arg(MESSAGE_SEQ_SCRIPT)
|
||||
.query(&mut *conn.inner_mut())
|
||||
.map_err(AppError::Redis)?;
|
||||
let _ = MESSAGE_SEQ_SHA.set(sha.clone());
|
||||
Ok(sha)
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_message(
|
||||
&self,
|
||||
message_id: Uuid,
|
||||
channel_id: Uuid,
|
||||
) -> Result<Message, AppError> {
|
||||
sqlx::query_as::<_, Message>(
|
||||
"SELECT id, channel_id, author_id, thread_id, reply_to_message_id, seq, \
|
||||
message_type, body, metadata, pinned, system, edited_at, deleted_at, \
|
||||
created_at, updated_at \
|
||||
FROM message WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(message_id)
|
||||
.bind(channel_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("message not found".into()))
|
||||
}
|
||||
}
|
||||
+16
-17
@@ -4,27 +4,25 @@ use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::service::ServiceContext;
|
||||
use delivery_trace::{trace_error, trace_request};
|
||||
use events::ImEvent;
|
||||
|
||||
pub mod articles;
|
||||
pub mod audit;
|
||||
pub mod categories;
|
||||
pub mod channel_roles;
|
||||
pub mod channels;
|
||||
pub mod delivery_trace;
|
||||
pub mod drafts;
|
||||
pub mod custom_emojis;
|
||||
pub mod events;
|
||||
pub mod follows;
|
||||
pub mod forum_tags;
|
||||
pub mod integrations;
|
||||
pub mod invitations;
|
||||
pub mod members;
|
||||
pub mod messages;
|
||||
pub mod polls;
|
||||
pub mod presence;
|
||||
pub mod reactions;
|
||||
pub mod repo_links;
|
||||
pub mod session;
|
||||
pub mod threads;
|
||||
pub mod slash_commands;
|
||||
pub mod stages;
|
||||
pub mod util;
|
||||
pub mod voice;
|
||||
pub mod webhooks;
|
||||
|
||||
pub use messages::{EditMessageParams, SendMessageParams};
|
||||
pub use presence::UpdatePresenceParams;
|
||||
pub use session::ImSession;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -33,11 +31,11 @@ pub struct ImService {
|
||||
}
|
||||
|
||||
impl ImService {
|
||||
fn emit_event(&self, event: ImEvent) {
|
||||
pub(crate) fn emit_event(&self, event: events::ImEvent) {
|
||||
let _ = self.ctx.im_events.publish(event);
|
||||
}
|
||||
|
||||
async fn publish<T: Serialize>(&self, subject: &str, request_id: Uuid, event: &T) {
|
||||
pub(crate) async fn publish<T: Serialize>(&self, subject: &str, request_id: Uuid, event: &T) {
|
||||
match self
|
||||
.ctx
|
||||
.nats
|
||||
@@ -48,9 +46,10 @@ impl ImService {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => trace_request("nats_published", request_id, subject),
|
||||
Ok(_) => {
|
||||
tracing::debug!(subject, %request_id, "nats event published");
|
||||
}
|
||||
Err(e) => {
|
||||
trace_error("nats_failed", request_id, subject, &e);
|
||||
tracing::warn!(subject, error = %e, "nats publish failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,372 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::immediate::{PollAction, PollEvent};
|
||||
use crate::models::channels::{MessagePoll, MessagePollOption, MessagePollVote};
|
||||
use crate::models::common::PollLayout;
|
||||
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 CreatePollParams {
|
||||
pub question: String,
|
||||
pub description: Option<String>,
|
||||
pub options: Vec<CreatePollOptionParams>,
|
||||
pub layout: Option<String>,
|
||||
pub allow_multiselect: Option<bool>,
|
||||
pub duration_hours: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreatePollOptionParams {
|
||||
pub text: String,
|
||||
pub emoji_id: Option<String>,
|
||||
pub emoji_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct VoteParams {
|
||||
pub option_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
impl ImService {
|
||||
pub async fn poll_create(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
message_id: Uuid,
|
||||
params: CreatePollParams,
|
||||
) -> Result<MessagePoll, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
||||
self.resolve_message(message_id, channel_id).await?;
|
||||
|
||||
let question = required_text(params.question, "question")?;
|
||||
if params.options.is_empty() || params.options.len() > MAX_POLL_OPTIONS {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"poll must have between 1 and {MAX_POLL_OPTIONS} options"
|
||||
)));
|
||||
}
|
||||
|
||||
let layout = parse_enum(
|
||||
params.layout,
|
||||
PollLayout::Default,
|
||||
PollLayout::Unknown,
|
||||
"layout",
|
||||
)?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let poll_id = Uuid::now_v7();
|
||||
let ends_at = params
|
||||
.duration_hours
|
||||
.map(|h| now + chrono::Duration::hours(h as i64));
|
||||
|
||||
let validated_options: Vec<(String, Option<String>, Option<String>)> = params
|
||||
.options
|
||||
.iter()
|
||||
.map(|opt| {
|
||||
let text = required_text(opt.text.clone(), "option text")?;
|
||||
if text.len() > MAX_POLL_OPTION_TEXT {
|
||||
return Err(AppError::BadRequest("poll option text too long".into()));
|
||||
}
|
||||
Ok((text, opt.emoji_id.clone(), opt.emoji_name.clone()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, AppError>>()?;
|
||||
|
||||
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 poll = sqlx::query_as::<_, MessagePoll>(
|
||||
"INSERT INTO message_poll \
|
||||
(id, message_id, channel_id, question, description, layout, \
|
||||
allow_multiselect, duration_hours, ends_at, total_votes, metadata, \
|
||||
created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, NULL, $10, $10) \
|
||||
RETURNING id, message_id, channel_id, question, description, layout, \
|
||||
allow_multiselect, duration_hours, ends_at, total_votes, metadata, \
|
||||
created_at, updated_at",
|
||||
)
|
||||
.bind(poll_id)
|
||||
.bind(message_id)
|
||||
.bind(channel_id)
|
||||
.bind(&question)
|
||||
.bind(params.description.as_deref())
|
||||
.bind(layout)
|
||||
.bind(params.allow_multiselect.unwrap_or(false))
|
||||
.bind(params.duration_hours)
|
||||
.bind(ends_at)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
for (i, (text, emoji_id, emoji_name)) in validated_options.iter().enumerate() {
|
||||
sqlx::query(
|
||||
"INSERT INTO message_poll_option \
|
||||
(id, poll_id, position, text, emoji_id, emoji_name, vote_count, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 0, $7)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(poll_id)
|
||||
.bind(i as i32)
|
||||
.bind(text)
|
||||
.bind(emoji_id.as_deref())
|
||||
.bind(emoji_name.as_deref())
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
tracing::info!(poll_id = %poll_id, "Poll created");
|
||||
let request_id = Uuid::nil();
|
||||
let event = PollEvent {
|
||||
channel_id,
|
||||
poll_id,
|
||||
action: PollAction::Created,
|
||||
};
|
||||
self.publish(&format!("im.poll.{}", channel_id), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Poll {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
Ok(poll)
|
||||
}
|
||||
|
||||
pub async fn poll_get(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
poll_id: Uuid,
|
||||
) -> Result<(MessagePoll, Vec<MessagePollOption>), AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
||||
|
||||
let poll = sqlx::query_as::<_, MessagePoll>(
|
||||
"SELECT id, message_id, channel_id, question, description, layout, \
|
||||
allow_multiselect, duration_hours, ends_at, total_votes, metadata, \
|
||||
created_at, updated_at \
|
||||
FROM message_poll WHERE id = $1 AND channel_id = $2",
|
||||
)
|
||||
.bind(poll_id)
|
||||
.bind(channel_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("poll not found".into()))?;
|
||||
|
||||
let options = sqlx::query_as::<_, MessagePollOption>(
|
||||
"SELECT id, poll_id, position, text, emoji_id, emoji_name, vote_count, created_at \
|
||||
FROM message_poll_option WHERE poll_id = $1 ORDER BY position ASC",
|
||||
)
|
||||
.bind(poll_id)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
Ok((poll, options))
|
||||
}
|
||||
|
||||
pub async fn poll_vote(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
poll_id: Uuid,
|
||||
params: VoteParams,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
||||
|
||||
let poll = sqlx::query_as::<_, MessagePoll>(
|
||||
"SELECT id, message_id, channel_id, question, description, layout, \
|
||||
allow_multiselect, duration_hours, ends_at, total_votes, metadata, \
|
||||
created_at, updated_at \
|
||||
FROM message_poll WHERE id = $1 AND channel_id = $2",
|
||||
)
|
||||
.bind(poll_id)
|
||||
.bind(channel_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("poll not found".into()))?;
|
||||
|
||||
if let Some(ends) = poll.ends_at
|
||||
&& chrono::Utc::now() > ends
|
||||
{
|
||||
return Err(AppError::BadRequest("poll has ended".into()));
|
||||
}
|
||||
|
||||
if !poll.allow_multiselect && params.option_ids.len() > 1 {
|
||||
return Err(AppError::BadRequest("multiselect not allowed".into()));
|
||||
}
|
||||
|
||||
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)?;
|
||||
|
||||
// Collect old option_ids before deleting
|
||||
let old_option_ids: Vec<Uuid> = sqlx::query_scalar(
|
||||
"DELETE FROM message_poll_vote WHERE poll_id = $1 AND user_id = $2 RETURNING option_id",
|
||||
)
|
||||
.bind(poll_id)
|
||||
.bind(user_uid)
|
||||
.fetch_all(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let removed = old_option_ids.len() as i32;
|
||||
|
||||
// Decrement old vote counts
|
||||
for opt_id in &old_option_ids {
|
||||
sqlx::query(
|
||||
"UPDATE message_poll_option SET vote_count = GREATEST(vote_count - 1, 0) WHERE id = $1",
|
||||
)
|
||||
.bind(opt_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
// Insert new votes
|
||||
let mut new_count = 0i32;
|
||||
for option_id in ¶ms.option_ids {
|
||||
sqlx::query(
|
||||
"INSERT INTO message_poll_vote (id, poll_id, option_id, user_id, voted_at) \
|
||||
VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(poll_id)
|
||||
.bind(option_id)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE message_poll_option SET vote_count = vote_count + 1 \
|
||||
WHERE id = $1 AND poll_id = $2",
|
||||
)
|
||||
.bind(option_id)
|
||||
.bind(poll_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
new_count += 1;
|
||||
}
|
||||
|
||||
let delta = new_count - removed;
|
||||
sqlx::query(
|
||||
"UPDATE message_poll SET total_votes = total_votes + $1, updated_at = $2 WHERE id = $3",
|
||||
)
|
||||
.bind(delta)
|
||||
.bind(now)
|
||||
.bind(poll_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
let request_id = Uuid::nil();
|
||||
let event = PollEvent {
|
||||
channel_id,
|
||||
poll_id,
|
||||
action: PollAction::Voted,
|
||||
};
|
||||
self.publish(&format!("im.poll.{}", channel_id), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Poll {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn poll_results(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
poll_id: Uuid,
|
||||
) -> Result<Vec<MessagePollVote>, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
||||
|
||||
sqlx::query_as::<_, MessagePollVote>(
|
||||
"SELECT id, poll_id, option_id, user_id, voted_at \
|
||||
FROM message_poll_vote WHERE poll_id = $1 ORDER BY voted_at ASC",
|
||||
)
|
||||
.bind(poll_id)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn poll_delete(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
poll_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_editable(user_uid, &channel).await?;
|
||||
|
||||
let result = sqlx::query("DELETE FROM message_poll WHERE id = $1 AND channel_id = $2")
|
||||
.bind(poll_id)
|
||||
.bind(channel_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
ensure_affected(result.rows_affected(), "poll not found")?;
|
||||
let request_id = Uuid::nil();
|
||||
let event = PollEvent {
|
||||
channel_id,
|
||||
poll_id,
|
||||
action: PollAction::Deleted,
|
||||
};
|
||||
self.publish(&format!("im.poll.{}", channel_id), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Poll {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::immediate::{PresenceEvent, TypingEvent};
|
||||
use crate::models::common::PresenceStatus;
|
||||
use crate::models::users::UserPresence;
|
||||
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 UpdatePresenceParams {
|
||||
pub status: String,
|
||||
pub custom_status_text: Option<String>,
|
||||
pub custom_status_emoji: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct TypingParams {
|
||||
pub channel_id: Uuid,
|
||||
pub thread_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl ImService {
|
||||
pub async fn presence_update(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
wk_name: &str,
|
||||
params: UpdatePresenceParams,
|
||||
) -> Result<UserPresence, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let _ = self.resolve_workspace(wk_name).await?;
|
||||
|
||||
let status = parse_enum(
|
||||
Some(params.status),
|
||||
PresenceStatus::Online,
|
||||
PresenceStatus::Unknown,
|
||||
"status",
|
||||
)?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let presence = sqlx::query_as::<_, UserPresence>(
|
||||
"INSERT INTO user_presence (id, user_id, status, custom_status_text, custom_status_emoji, \
|
||||
last_active_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6, $6) \
|
||||
ON CONFLICT (user_id) DO UPDATE SET \
|
||||
status = $3, custom_status_text = $4, custom_status_emoji = $5, \
|
||||
last_active_at = $6, updated_at = $6 \
|
||||
RETURNING id, user_id, status, custom_status_text, custom_status_emoji, \
|
||||
device_type, ip_address, last_active_at, last_seen_at, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(user_uid)
|
||||
.bind(status)
|
||||
.bind(params.custom_status_text.as_deref())
|
||||
.bind(params.custom_status_emoji.as_deref())
|
||||
.bind(now)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
// Cache in Redis for fast lookup
|
||||
let key = format!("{PRESENCE_PREFIX}{user_uid}");
|
||||
if let Ok(mut conn) = self.ctx.redis.get_connection() {
|
||||
let _ = redis::cmd("SETEX")
|
||||
.arg(&key)
|
||||
.arg(PRESENCE_TTL_SECS as u64)
|
||||
.arg(status.to_string())
|
||||
.query::<()>(&mut *conn.inner_mut());
|
||||
}
|
||||
|
||||
let request_id = Uuid::nil();
|
||||
let event = PresenceEvent {
|
||||
user_id: user_uid,
|
||||
status: presence.status.to_string(),
|
||||
custom_status_text: presence.custom_status_text.clone(),
|
||||
custom_status_emoji: presence.custom_status_emoji.clone(),
|
||||
};
|
||||
self.publish(&format!("im.presence.{}", user_uid), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Presence {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
Ok(presence)
|
||||
}
|
||||
|
||||
pub async fn presence_get(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
wk_name: &str,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<UserPresence>, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
self.ensure_workspace_readable(user_uid, &ws).await?;
|
||||
|
||||
// Try DB first (has full record)
|
||||
if let Some(p) = sqlx::query_as::<_, UserPresence>(
|
||||
"SELECT id, user_id, status, custom_status_text, custom_status_emoji, \
|
||||
device_type, ip_address, last_active_at, last_seen_at, created_at, updated_at \
|
||||
FROM user_presence WHERE user_id = $1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
{
|
||||
return Ok(Some(p));
|
||||
}
|
||||
|
||||
// Fallback: check Redis for a cached status
|
||||
let key = format!("{PRESENCE_PREFIX}{user_id}");
|
||||
if let Ok(mut conn) = self.ctx.redis.get_connection() {
|
||||
let cached: Option<String> = redis::cmd("GET")
|
||||
.arg(&key)
|
||||
.query(&mut *conn.inner_mut())
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
if let Some(status_str) = cached
|
||||
&& let Ok(status) = status_str.parse::<PresenceStatus>()
|
||||
{
|
||||
let now = chrono::Utc::now();
|
||||
return Ok(Some(UserPresence {
|
||||
id: Uuid::nil(),
|
||||
user_id,
|
||||
status,
|
||||
custom_status_text: None,
|
||||
custom_status_emoji: None,
|
||||
device_type: None,
|
||||
ip_address: None,
|
||||
last_active_at: now,
|
||||
last_seen_at: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn presence_heartbeat(&self, ctx: &ImSession, wk_name: &str) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
self.ensure_workspace_readable(user_uid, &ws).await?;
|
||||
|
||||
let key = format!("{PRESENCE_PREFIX}{user_uid}");
|
||||
if let Ok(mut conn) = self.ctx.redis.get_connection()
|
||||
&& let Err(e) = redis::cmd("SETEX")
|
||||
.arg(&key)
|
||||
.arg(PRESENCE_TTL_SECS as u64)
|
||||
.arg("online")
|
||||
.query::<()>(&mut *conn.inner_mut())
|
||||
{
|
||||
tracing::warn!(error = %e, "redis presence heartbeat failed");
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
if let Err(e) = sqlx::query(
|
||||
"UPDATE user_presence SET last_active_at = $1, updated_at = $1 WHERE user_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "db presence heartbeat failed");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn typing_start(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
wk_name: &str,
|
||||
params: TypingParams,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
self.ensure_workspace_readable(user_uid, &ws).await?;
|
||||
|
||||
let channel = self.resolve_channel(params.channel_id).await?;
|
||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
||||
|
||||
let key = typing_key(params.channel_id, params.thread_id, user_uid);
|
||||
let mut conn = self.ctx.redis.get_connection()?;
|
||||
redis::cmd("SETEX")
|
||||
.arg(&key)
|
||||
.arg(TYPING_TTL_SECS as u64)
|
||||
.arg("1")
|
||||
.query::<()>(&mut *conn.inner_mut())?;
|
||||
|
||||
let request_id = Uuid::nil();
|
||||
let event = TypingEvent {
|
||||
channel_id: params.channel_id,
|
||||
thread_id: params.thread_id,
|
||||
user_id: user_uid,
|
||||
};
|
||||
self.publish(
|
||||
&format!("im.typing.{}", params.channel_id),
|
||||
request_id,
|
||||
&event,
|
||||
)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Typing {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn typing_stop(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
wk_name: &str,
|
||||
params: TypingParams,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
self.ensure_workspace_readable(user_uid, &ws).await?;
|
||||
|
||||
let key = typing_key(params.channel_id, params.thread_id, user_uid);
|
||||
let mut conn = self.ctx.redis.get_connection()?;
|
||||
redis::cmd("DEL")
|
||||
.arg(&key)
|
||||
.query::<()>(&mut *conn.inner_mut())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn typing_key(channel_id: Uuid, thread_id: Option<Uuid>, user_id: Uuid) -> String {
|
||||
match thread_id {
|
||||
Some(tid) => format!("{TYPING_PREFIX}{channel_id}:{tid}:{user_id}"),
|
||||
None => format!("{TYPING_PREFIX}{channel_id}:{user_id}"),
|
||||
}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::immediate::{ReactionAction, ReactionEvent};
|
||||
use crate::models::channels::{MessageMention, MessageReaction};
|
||||
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 AddReactionParams {
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct AddMentionParams {
|
||||
pub mentioned_user_id: Uuid,
|
||||
}
|
||||
|
||||
impl ImService {
|
||||
pub async fn reaction_list(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
message_id: Uuid,
|
||||
) -> Result<Vec<MessageReaction>, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
||||
|
||||
sqlx::query_as::<_, MessageReaction>(
|
||||
"SELECT id, message_id, channel_id, user_id, content, created_at \
|
||||
FROM message_reaction WHERE message_id = $1 AND channel_id = $2 \
|
||||
ORDER BY created_at ASC",
|
||||
)
|
||||
.bind(message_id)
|
||||
.bind(channel_id)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn reaction_add(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
message_id: Uuid,
|
||||
params: AddReactionParams,
|
||||
) -> Result<MessageReaction, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
||||
self.resolve_message(message_id, channel_id).await?;
|
||||
|
||||
let content = required_text(params.content, "content")?;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let reaction = sqlx::query_as::<_, MessageReaction>(
|
||||
"INSERT INTO message_reaction (id, message_id, channel_id, user_id, content, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6) \
|
||||
ON CONFLICT (message_id, user_id, content) DO NOTHING \
|
||||
RETURNING id, message_id, channel_id, user_id, content, created_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(message_id)
|
||||
.bind(channel_id)
|
||||
.bind(user_uid)
|
||||
.bind(&content)
|
||||
.bind(now)
|
||||
.fetch_optional(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if reaction.is_none() {
|
||||
return Err(AppError::Conflict("reaction already exists".into()));
|
||||
}
|
||||
|
||||
let reaction = reaction.unwrap();
|
||||
let request_id = Uuid::nil();
|
||||
let event = ReactionEvent {
|
||||
channel_id,
|
||||
message_id,
|
||||
user_id: reaction.user_id,
|
||||
action: ReactionAction::Added,
|
||||
content: Some(reaction.content.clone()),
|
||||
};
|
||||
self.publish(&format!("im.reaction.{}", channel_id), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Reaction {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
Ok(reaction)
|
||||
}
|
||||
|
||||
pub async fn reaction_remove(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
message_id: Uuid,
|
||||
content: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user;
|
||||
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM message_reaction \
|
||||
WHERE message_id = $1 AND channel_id = $2 AND user_id = $3 AND content = $4",
|
||||
)
|
||||
.bind(message_id)
|
||||
.bind(channel_id)
|
||||
.bind(user_uid)
|
||||
.bind(content)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
ensure_affected(result.rows_affected(), "reaction not found")?;
|
||||
let request_id = Uuid::nil();
|
||||
let event = ReactionEvent {
|
||||
channel_id,
|
||||
message_id,
|
||||
user_id: user_uid,
|
||||
action: ReactionAction::Removed,
|
||||
content: Some(content.to_string()),
|
||||
};
|
||||
self.publish(&format!("im.reaction.{}", channel_id), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Reaction {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reaction_remove_all(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
message_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_admin(user_uid, &channel).await?;
|
||||
|
||||
sqlx::query("DELETE FROM message_reaction WHERE message_id = $1 AND channel_id = $2")
|
||||
.bind(message_id)
|
||||
.bind(channel_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let request_id = Uuid::nil();
|
||||
let event = ReactionEvent {
|
||||
channel_id,
|
||||
message_id,
|
||||
user_id: user_uid,
|
||||
action: ReactionAction::Removed,
|
||||
content: None,
|
||||
};
|
||||
self.publish(&format!("im.reaction.{}", channel_id), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Reaction {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn mention_list_for_user(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
wk_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
unread_only: bool,
|
||||
) -> Result<Vec<MessageMention>, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let _ = self.resolve_workspace(wk_name).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
|
||||
if unread_only {
|
||||
sqlx::query_as::<_, MessageMention>(
|
||||
"SELECT id, message_id, channel_id, mentioned_user_id, mentioned_by, read_at, created_at \
|
||||
FROM message_mention \
|
||||
WHERE mentioned_user_id = $1 AND read_at IS NULL \
|
||||
ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
} else {
|
||||
sqlx::query_as::<_, MessageMention>(
|
||||
"SELECT id, message_id, channel_id, mentioned_user_id, mentioned_by, read_at, created_at \
|
||||
FROM message_mention \
|
||||
WHERE mentioned_user_id = $1 \
|
||||
ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn mention_mark_read(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
mention_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE message_mention SET read_at = $1 \
|
||||
WHERE id = $2 AND mentioned_user_id = $3 AND read_at IS NULL",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(mention_id)
|
||||
.bind(user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
ensure_affected(result.rows_affected(), "mention not found or already read")
|
||||
}
|
||||
|
||||
pub async fn mention_mark_all_read(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
wk_name: &str,
|
||||
) -> Result<u64, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let _ = self.resolve_workspace(wk_name).await?;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE message_mention SET read_at = $1 \
|
||||
WHERE mentioned_user_id = $2 AND read_at IS NULL",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::channels::ChannelRepoLink;
|
||||
use crate::service::ImService;
|
||||
|
||||
use super::session::ImSession;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateRepoLinkParams {
|
||||
pub repo_id: Uuid,
|
||||
pub link_type: String,
|
||||
pub notify_events: Vec<String>,
|
||||
}
|
||||
|
||||
impl ImService {
|
||||
pub async fn repo_link_list(
|
||||
&self,
|
||||
_ctx: &ImSession,
|
||||
channel_id: Uuid,
|
||||
) -> Result<Vec<ChannelRepoLink>, AppError> {
|
||||
sqlx::query_as::<_, ChannelRepoLink>(
|
||||
"SELECT id, channel_id, repo_id, link_type, notify_events, active, \
|
||||
created_by, created_at, updated_at \
|
||||
FROM channel_repo_link WHERE channel_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn repo_link_create(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
channel_id: Uuid,
|
||||
params: CreateRepoLinkParams,
|
||||
) -> Result<ChannelRepoLink, AppError> {
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, ChannelRepoLink>(
|
||||
"INSERT INTO channel_repo_link \
|
||||
(id, channel_id, repo_id, link_type, notify_events, active, \
|
||||
created_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4::link_type, $5, true, $6, $7, $7) \
|
||||
RETURNING id, channel_id, repo_id, link_type, notify_events, active, \
|
||||
created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(channel_id)
|
||||
.bind(params.repo_id)
|
||||
.bind(¶ms.link_type)
|
||||
.bind(¶ms.notify_events)
|
||||
.bind(ctx.user)
|
||||
.bind(now)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn repo_link_delete(
|
||||
&self,
|
||||
_ctx: &ImSession,
|
||||
link_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query("DELETE FROM channel_repo_link WHERE id = $1")
|
||||
.bind(link_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::channels::ChannelSlashCommand;
|
||||
use crate::service::ImService;
|
||||
|
||||
use super::session::ImSession;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateSlashCommandParams {
|
||||
pub command: String,
|
||||
pub description: Option<String>,
|
||||
pub request_url: String,
|
||||
pub secret: Option<String>,
|
||||
pub scopes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateSlashCommandParams {
|
||||
pub command: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub request_url: Option<String>,
|
||||
pub secret: Option<String>,
|
||||
pub scopes: Option<Vec<String>>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl ImService {
|
||||
pub async fn slash_command_list(
|
||||
&self,
|
||||
_ctx: &ImSession,
|
||||
channel_id: Uuid,
|
||||
) -> Result<Vec<ChannelSlashCommand>, AppError> {
|
||||
sqlx::query_as::<_, ChannelSlashCommand>(
|
||||
"SELECT id, channel_id, workspace_id, command, description, request_url, \
|
||||
secret_ciphertext, scopes, enabled, created_by, created_at, updated_at \
|
||||
FROM channel_slash_command WHERE channel_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn slash_command_create(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
channel_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
params: CreateSlashCommandParams,
|
||||
) -> Result<ChannelSlashCommand, AppError> {
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, ChannelSlashCommand>(
|
||||
"INSERT INTO channel_slash_command \
|
||||
(id, channel_id, workspace_id, command, description, request_url, \
|
||||
secret_ciphertext, scopes, enabled, created_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, $9, $10, $10) \
|
||||
RETURNING id, channel_id, workspace_id, command, description, request_url, \
|
||||
secret_ciphertext, scopes, enabled, created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(channel_id)
|
||||
.bind(workspace_id)
|
||||
.bind(¶ms.command)
|
||||
.bind(params.description.as_deref())
|
||||
.bind(¶ms.request_url)
|
||||
.bind(params.secret.as_deref())
|
||||
.bind(¶ms.scopes)
|
||||
.bind(ctx.user)
|
||||
.bind(now)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn slash_command_update(
|
||||
&self,
|
||||
_ctx: &ImSession,
|
||||
command_id: Uuid,
|
||||
params: UpdateSlashCommandParams,
|
||||
) -> Result<ChannelSlashCommand, AppError> {
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, ChannelSlashCommand>(
|
||||
"UPDATE channel_slash_command SET \
|
||||
command = COALESCE($1, command), \
|
||||
description = COALESCE($2, description), \
|
||||
request_url = COALESCE($3, request_url), \
|
||||
secret_ciphertext = COALESCE($4, secret_ciphertext), \
|
||||
scopes = COALESCE($5, scopes), \
|
||||
enabled = COALESCE($6, enabled), \
|
||||
updated_at = $7 \
|
||||
WHERE id = $8 \
|
||||
RETURNING id, channel_id, workspace_id, command, description, request_url, \
|
||||
secret_ciphertext, scopes, enabled, created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(params.command.as_deref())
|
||||
.bind(params.description.as_deref())
|
||||
.bind(params.request_url.as_deref())
|
||||
.bind(params.secret.as_deref())
|
||||
.bind(params.scopes.as_ref())
|
||||
.bind(params.enabled)
|
||||
.bind(now)
|
||||
.bind(command_id)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn slash_command_delete(
|
||||
&self,
|
||||
_ctx: &ImSession,
|
||||
command_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query("DELETE FROM channel_slash_command WHERE id = $1")
|
||||
.bind(command_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::immediate::{ThreadAction, ThreadEvent};
|
||||
use crate::models::channels::MessageThread;
|
||||
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 CreateThreadParams {
|
||||
pub title: Option<String>,
|
||||
pub root_message_id: Uuid,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub auto_archive_duration: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateThreadParams {
|
||||
pub title: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub pinned: Option<bool>,
|
||||
pub locked: Option<bool>,
|
||||
pub rate_limit_per_user: Option<i32>,
|
||||
pub resolved: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct ThreadListFilters {
|
||||
pub pinned: Option<bool>,
|
||||
pub locked: Option<bool>,
|
||||
pub resolved: Option<bool>,
|
||||
}
|
||||
|
||||
impl ImService {
|
||||
pub async fn thread_list(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
filters: ThreadListFilters,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<MessageThread>, 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::<_, MessageThread>(
|
||||
"SELECT id, channel_id, root_message_id, created_by, replies_count, \
|
||||
participants_count, last_reply_message_id, last_reply_at, resolved, \
|
||||
resolved_by, resolved_at, title, tags, pinned, locked, \
|
||||
rate_limit_per_user, auto_archive_at, created_at, updated_at \
|
||||
FROM message_thread WHERE channel_id = $1 \
|
||||
AND ($2::bool IS NULL OR pinned = $2) \
|
||||
AND ($3::bool IS NULL OR locked = $3) \
|
||||
AND ($4::bool IS NULL OR resolved = $4) \
|
||||
ORDER BY last_reply_at DESC NULLS LAST, created_at DESC \
|
||||
LIMIT $5 OFFSET $6",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.bind(filters.pinned)
|
||||
.bind(filters.locked)
|
||||
.bind(filters.resolved)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn thread_get(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
thread_id: Uuid,
|
||||
) -> Result<MessageThread, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
||||
self.resolve_thread(thread_id, channel_id).await
|
||||
}
|
||||
|
||||
pub async fn thread_create(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
params: CreateThreadParams,
|
||||
) -> Result<MessageThread, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
||||
|
||||
self.resolve_message(params.root_message_id, channel_id)
|
||||
.await?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let thread_id = Uuid::now_v7();
|
||||
let tags = params.tags.unwrap_or_default();
|
||||
let auto_archive_at = params
|
||||
.auto_archive_duration
|
||||
.map(|d| now + chrono::Duration::minutes(d as i64));
|
||||
|
||||
let thread = sqlx::query_as::<_, MessageThread>(
|
||||
"INSERT INTO message_thread \
|
||||
(id, channel_id, root_message_id, created_by, replies_count, \
|
||||
participants_count, last_reply_message_id, last_reply_at, resolved, \
|
||||
title, tags, pinned, locked, auto_archive_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, 0, 0, NULL, NULL, false, $5, $6, false, false, $7, $8, $8) \
|
||||
RETURNING id, channel_id, root_message_id, created_by, replies_count, \
|
||||
participants_count, last_reply_message_id, last_reply_at, resolved, \
|
||||
resolved_by, resolved_at, title, tags, pinned, locked, \
|
||||
rate_limit_per_user, auto_archive_at, created_at, updated_at",
|
||||
)
|
||||
.bind(thread_id)
|
||||
.bind(channel_id)
|
||||
.bind(params.root_message_id)
|
||||
.bind(user_uid)
|
||||
.bind(params.title.as_deref())
|
||||
.bind(&tags)
|
||||
.bind(auto_archive_at)
|
||||
.bind(now)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
tracing::info!(thread_id = %thread_id, channel_id = %channel_id, "Thread created");
|
||||
let request_id = Uuid::nil();
|
||||
let event = ThreadEvent {
|
||||
channel_id,
|
||||
thread_id,
|
||||
action: ThreadAction::Created,
|
||||
};
|
||||
self.publish(
|
||||
&format!("im.thread.{}.{}", channel_id, thread_id),
|
||||
request_id,
|
||||
&event,
|
||||
)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Thread {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
Ok(thread)
|
||||
}
|
||||
|
||||
pub async fn thread_update(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
thread_id: Uuid,
|
||||
params: UpdateThreadParams,
|
||||
) -> Result<MessageThread, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
let thread = self.resolve_thread(thread_id, channel_id).await?;
|
||||
|
||||
let is_owner = thread.created_by == user_uid;
|
||||
if !is_owner {
|
||||
self.ensure_channel_editable(user_uid, &channel).await?;
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let resolved_by = if params.resolved == Some(true) && !thread.resolved {
|
||||
Some(user_uid)
|
||||
} else {
|
||||
thread.resolved_by
|
||||
};
|
||||
let resolved_at = if params.resolved == Some(true) && !thread.resolved {
|
||||
Some(now)
|
||||
} else if params.resolved == Some(false) {
|
||||
None
|
||||
} else {
|
||||
thread.resolved_at
|
||||
};
|
||||
|
||||
let updated = sqlx::query_as::<_, MessageThread>(
|
||||
"UPDATE message_thread SET \
|
||||
title = COALESCE($1, title), \
|
||||
tags = COALESCE($2, tags), \
|
||||
pinned = COALESCE($3, pinned), \
|
||||
locked = COALESCE($4, locked), \
|
||||
rate_limit_per_user = COALESCE($5, rate_limit_per_user), \
|
||||
resolved = COALESCE($6, resolved), \
|
||||
resolved_by = $7, resolved_at = $8, \
|
||||
updated_at = $9 \
|
||||
WHERE id = $10 \
|
||||
RETURNING id, channel_id, root_message_id, created_by, replies_count, \
|
||||
participants_count, last_reply_message_id, last_reply_at, resolved, \
|
||||
resolved_by, resolved_at, title, tags, pinned, locked, \
|
||||
rate_limit_per_user, auto_archive_at, created_at, updated_at",
|
||||
)
|
||||
.bind(params.title.as_deref())
|
||||
.bind(params.tags.as_deref())
|
||||
.bind(params.pinned)
|
||||
.bind(params.locked)
|
||||
.bind(params.rate_limit_per_user)
|
||||
.bind(params.resolved)
|
||||
.bind(resolved_by)
|
||||
.bind(resolved_at)
|
||||
.bind(now)
|
||||
.bind(thread_id)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
let request_id = Uuid::nil();
|
||||
let event = ThreadEvent {
|
||||
channel_id,
|
||||
thread_id,
|
||||
action: ThreadAction::Updated,
|
||||
};
|
||||
self.publish(
|
||||
&format!("im.thread.{}.{}", channel_id, thread_id),
|
||||
request_id,
|
||||
&event,
|
||||
)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Thread {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn thread_delete(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
thread_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_admin(user_uid, &channel).await?;
|
||||
|
||||
let result = sqlx::query("DELETE FROM message_thread WHERE id = $1 AND channel_id = $2")
|
||||
.bind(thread_id)
|
||||
.bind(channel_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
ensure_affected(result.rows_affected(), "thread not found")?;
|
||||
let request_id = Uuid::nil();
|
||||
let event = ThreadEvent {
|
||||
channel_id,
|
||||
thread_id,
|
||||
action: ThreadAction::Deleted,
|
||||
};
|
||||
self.publish(
|
||||
&format!("im.thread.{}.{}", channel_id, thread_id),
|
||||
request_id,
|
||||
&event,
|
||||
)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Thread {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn thread_read_state_update(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
thread_id: Uuid,
|
||||
message_id: Uuid,
|
||||
) -> 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();
|
||||
sqlx::query(
|
||||
"INSERT INTO thread_read_state (id, user_id, thread_id, channel_id, last_read_message_id, last_read_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6) \
|
||||
ON CONFLICT (user_id, thread_id) DO UPDATE SET \
|
||||
last_read_message_id = $5, last_read_at = $6, updated_at = $6",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(user_uid)
|
||||
.bind(thread_id)
|
||||
.bind(channel_id)
|
||||
.bind(message_id)
|
||||
.bind(now)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_thread(
|
||||
&self,
|
||||
thread_id: Uuid,
|
||||
channel_id: Uuid,
|
||||
) -> Result<MessageThread, AppError> {
|
||||
sqlx::query_as::<_, MessageThread>(
|
||||
"SELECT id, channel_id, root_message_id, created_by, replies_count, \
|
||||
participants_count, last_reply_message_id, last_reply_at, resolved, \
|
||||
resolved_by, resolved_at, title, tags, pinned, locked, \
|
||||
rate_limit_per_user, auto_archive_at, created_at, updated_at \
|
||||
FROM message_thread WHERE id = $1 AND channel_id = $2",
|
||||
)
|
||||
.bind(thread_id)
|
||||
.bind(channel_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("thread not found".into()))
|
||||
}
|
||||
}
|
||||
+2
-56
@@ -1,5 +1,6 @@
|
||||
pub use crate::service::util::{
|
||||
clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text, role_level,
|
||||
clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text,
|
||||
role_level, set_local_user_id,
|
||||
};
|
||||
|
||||
/// Maximum length for a channel name.
|
||||
@@ -7,58 +8,3 @@ pub const MAX_CHANNEL_NAME: usize = 100;
|
||||
|
||||
/// Maximum length for a channel topic.
|
||||
pub const MAX_CHANNEL_TOPIC: usize = 1024;
|
||||
|
||||
/// Maximum length for a message body.
|
||||
pub const MAX_MESSAGE_BODY: usize = 4096;
|
||||
|
||||
/// Maximum length for an article title.
|
||||
pub const MAX_ARTICLE_TITLE: usize = 256;
|
||||
|
||||
/// Maximum number of poll options.
|
||||
pub const MAX_POLL_OPTIONS: usize = 10;
|
||||
|
||||
/// Maximum length for a poll option text.
|
||||
pub const MAX_POLL_OPTION_TEXT: usize = 100;
|
||||
|
||||
/// Redis key prefix for typing indicators.
|
||||
pub const TYPING_PREFIX: &str = "im:typing:";
|
||||
|
||||
/// Redis key prefix for user presence.
|
||||
pub const PRESENCE_PREFIX: &str = "im:presence:";
|
||||
|
||||
/// Redis TTL for typing indicators (seconds).
|
||||
pub const TYPING_TTL_SECS: usize = 8;
|
||||
|
||||
/// Redis TTL for presence heartbeats (seconds).
|
||||
pub const PRESENCE_TTL_SECS: usize = 120;
|
||||
|
||||
/// Maximum length for generated slugs.
|
||||
pub const MAX_SLUG_LEN: usize = 128;
|
||||
|
||||
/// Generate a slug from a title string.
|
||||
pub fn slugify(title: &str) -> String {
|
||||
let slug: String = title
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.filter_map(|c| {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
Some(c)
|
||||
} else if c.is_whitespace() || !c.is_ascii() {
|
||||
Some('-')
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
|
||||
let mut result = slug;
|
||||
result.truncate(MAX_SLUG_LEN);
|
||||
if result.ends_with('-') {
|
||||
result.pop();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::channels::VoiceParticipant;
|
||||
use crate::service::ImService;
|
||||
|
||||
use super::session::ImSession;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateVoiceStateParams {
|
||||
pub session_id: Option<String>,
|
||||
pub muted: Option<bool>,
|
||||
pub deafened: Option<bool>,
|
||||
pub self_muted: Option<bool>,
|
||||
pub self_deafened: Option<bool>,
|
||||
pub self_video: Option<bool>,
|
||||
pub streaming: Option<bool>,
|
||||
}
|
||||
|
||||
impl ImService {
|
||||
pub async fn voice_participant_list(
|
||||
&self,
|
||||
_ctx: &ImSession,
|
||||
channel_id: Uuid,
|
||||
) -> Result<Vec<VoiceParticipant>, AppError> {
|
||||
sqlx::query_as::<_, VoiceParticipant>(
|
||||
"SELECT id, channel_id, user_id, session_id, deafened, muted, \
|
||||
self_deafened, self_muted, self_video, streaming, speaking, \
|
||||
joined_at, left_at \
|
||||
FROM voice_participant WHERE channel_id = $1 AND left_at IS NULL \
|
||||
ORDER BY joined_at",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn voice_state_update(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
channel_id: Uuid,
|
||||
params: UpdateVoiceStateParams,
|
||||
) -> Result<VoiceParticipant, AppError> {
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, VoiceParticipant>(
|
||||
"INSERT INTO voice_participant \
|
||||
(id, channel_id, user_id, session_id, muted, deafened, \
|
||||
self_muted, self_deafened, self_video, streaming, speaking, joined_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, false, false, $10) \
|
||||
ON CONFLICT (channel_id, user_id) DO UPDATE SET \
|
||||
session_id = COALESCE($4, voice_participant.session_id), \
|
||||
muted = COALESCE($5, voice_participant.muted), \
|
||||
deafened = COALESCE($6, voice_participant.deafened), \
|
||||
self_muted = COALESCE($7, voice_participant.self_muted), \
|
||||
self_deafened = COALESCE($8, voice_participant.self_deafened), \
|
||||
self_video = COALESCE($9, voice_participant.self_video) \
|
||||
RETURNING id, channel_id, user_id, session_id, deafened, muted, \
|
||||
self_deafened, self_muted, self_video, streaming, speaking, \
|
||||
joined_at, left_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(channel_id)
|
||||
.bind(ctx.user)
|
||||
.bind(params.session_id.as_deref())
|
||||
.bind(params.muted.unwrap_or(false))
|
||||
.bind(params.deafened.unwrap_or(false))
|
||||
.bind(params.self_muted.unwrap_or(false))
|
||||
.bind(params.self_deafened.unwrap_or(false))
|
||||
.bind(params.self_video.unwrap_or(false))
|
||||
.bind(now)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::channels::ChannelWebhook;
|
||||
use crate::service::ImService;
|
||||
|
||||
use super::session::ImSession;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateWebhookParams {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub secret: Option<String>,
|
||||
pub events: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateWebhookParams {
|
||||
pub name: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub secret: Option<String>,
|
||||
pub events: Option<Vec<String>>,
|
||||
pub active: Option<bool>,
|
||||
}
|
||||
|
||||
impl ImService {
|
||||
pub async fn webhook_list(
|
||||
&self,
|
||||
_ctx: &ImSession,
|
||||
channel_id: Uuid,
|
||||
) -> Result<Vec<ChannelWebhook>, AppError> {
|
||||
sqlx::query_as::<_, ChannelWebhook>(
|
||||
"SELECT id, channel_id, name, url, secret_ciphertext, events, active, \
|
||||
last_delivery_status, last_delivery_at, created_by, created_at, updated_at \
|
||||
FROM channel_webhook WHERE channel_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn webhook_create(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
channel_id: Uuid,
|
||||
params: CreateWebhookParams,
|
||||
) -> Result<ChannelWebhook, AppError> {
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, ChannelWebhook>(
|
||||
"INSERT INTO channel_webhook \
|
||||
(id, channel_id, name, url, secret_ciphertext, events, active, \
|
||||
created_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, true, $7, $8, $8) \
|
||||
RETURNING id, channel_id, name, url, secret_ciphertext, events, active, \
|
||||
last_delivery_status, last_delivery_at, created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(channel_id)
|
||||
.bind(¶ms.name)
|
||||
.bind(¶ms.url)
|
||||
.bind(params.secret.as_deref())
|
||||
.bind(¶ms.events)
|
||||
.bind(ctx.user)
|
||||
.bind(now)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn webhook_update(
|
||||
&self,
|
||||
_ctx: &ImSession,
|
||||
webhook_id: Uuid,
|
||||
params: UpdateWebhookParams,
|
||||
) -> Result<ChannelWebhook, AppError> {
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, ChannelWebhook>(
|
||||
"UPDATE channel_webhook SET \
|
||||
name = COALESCE($1, name), \
|
||||
url = COALESCE($2, url), \
|
||||
secret_ciphertext = COALESCE($3, secret_ciphertext), \
|
||||
events = COALESCE($4, events), \
|
||||
active = COALESCE($5, active), \
|
||||
updated_at = $6 \
|
||||
WHERE id = $7 \
|
||||
RETURNING id, channel_id, name, url, secret_ciphertext, events, active, \
|
||||
last_delivery_status, last_delivery_at, created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(params.name.as_deref())
|
||||
.bind(params.url.as_deref())
|
||||
.bind(params.secret.as_deref())
|
||||
.bind(params.events.as_ref())
|
||||
.bind(params.active)
|
||||
.bind(now)
|
||||
.bind(webhook_id)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn webhook_delete(
|
||||
&self,
|
||||
_ctx: &ImSession,
|
||||
webhook_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query("DELETE FROM channel_webhook WHERE id = $1")
|
||||
.bind(webhook_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user