From 420dedbc1e2af70607c43cfc95aec898257e3902 Mon Sep 17 00:00:00 2001 From: zhenyi <434836402@qq.com> Date: Wed, 10 Jun 2026 18:49:32 +0800 Subject: [PATCH] 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 --- service/auth/change_password.rs | 79 +++ service/auth/email.rs | 26 +- service/auth/logout.rs | 3 +- service/auth/mod.rs | 1 + service/auth/register.rs | 12 +- service/auth/reset_pass.rs | 50 +- service/auth/totp.rs | 42 +- service/context.rs | 4 +- service/im/articles.rs | 715 --------------------- service/im/audit.rs | 32 + service/im/categories.rs | 2 +- service/im/channel_roles.rs | 111 ++++ service/im/channels.rs | 35 +- service/im/custom_emojis.rs | 72 +++ service/im/delivery_trace.rs | 45 -- service/im/drafts.rs | 162 ----- service/im/events.rs | 86 +-- service/im/follows.rs | 232 ------- service/im/forum_tags.rs | 117 ++++ service/im/integrations.rs | 131 ++++ service/im/members.rs | 58 +- service/im/messages.rs | 889 -------------------------- service/im/mod.rs | 33 +- service/im/polls.rs | 372 ----------- service/im/presence.rs | 244 ------- service/im/reactions.rs | 261 -------- service/im/repo_links.rs | 73 +++ service/im/slash_commands.rs | 122 ++++ service/im/threads.rs | 321 ---------- service/im/util.rs | 58 +- service/im/voice.rs | 77 +++ service/im/webhooks.rs | 115 ++++ service/internal_auth.rs | 97 +++ service/issues/assignees.rs | 26 +- service/issues/comments.rs | 11 +- service/issues/labels.rs | 8 +- service/issues/milestones.rs | 26 +- service/issues/pr_relations.rs | 8 +- service/issues/repo_relations.rs | 8 +- service/issues/subscribers.rs | 8 +- service/issues/util.rs | 3 +- service/mod.rs | 6 + service/notify/blocks.rs | 18 +- service/notify/core.rs | 30 +- service/notify/subscriptions.rs | 20 +- service/notify/templates.rs | 20 +- service/pr/assignees.rs | 8 +- service/pr/labels.rs | 8 +- service/pr/mod.rs | 2 + service/pr/review_requests.rs | 90 +++ service/pr/subscriptions.rs | 8 +- service/pr/templates.rs | 150 +++++ service/pr/util.rs | 3 +- service/repo/branches.rs | 91 ++- service/repo/commit_status.rs | 58 +- service/repo/contributors.rs | 61 ++ service/repo/deploy_keys.rs | 8 +- service/repo/fork.rs | 64 +- service/repo/git/archive.rs | 42 ++ service/repo/git/branch_rename.rs | 31 + service/repo/git/diff_merge_extras.rs | 213 ++++++ service/repo/git/mod.rs | 9 + service/repo/git/repository.rs | 25 + service/repo/git/repository_extras.rs | 185 ++++++ service/repo/git/tag_get.rs | 52 ++ service/repo/git/tree_extras.rs | 141 ++++ service/repo/invitations.rs | 11 +- service/repo/members.rs | 14 +- service/repo/mod.rs | 2 + service/repo/release_assets.rs | 205 ++++++ service/repo/releases.rs | 13 +- service/repo/stars.rs | 44 +- service/repo/stats.rs | 46 +- service/repo/tags.rs | 73 ++- service/repo/util.rs | 3 +- service/repo/watches.rs | 8 +- service/repo/webhooks.rs | 73 ++- service/user/account.rs | 230 +++++-- service/user/appearance.rs | 14 +- service/user/keys.rs | 26 +- service/user/mod.rs | 1 + service/user/notify.rs | 14 +- service/user/profile.rs | 14 +- service/user/security.rs | 154 ++++- service/user/social.rs | 226 +++++++ service/util.rs | 10 + service/wiki/core.rs | 35 +- service/wiki/util.rs | 4 +- service/workspace/approvals.rs | 8 +- service/workspace/billing.rs | 5 +- service/workspace/branding.rs | 5 +- service/workspace/core.rs | 140 +++- service/workspace/domains.rs | 62 +- service/workspace/integrations.rs | 11 +- service/workspace/invitations.rs | 11 +- service/workspace/members.rs | 14 +- service/workspace/settings.rs | 20 +- service/workspace/stats.rs | 8 +- service/workspace/util.rs | 3 +- service/workspace/webhooks.rs | 11 +- 100 files changed, 3797 insertions(+), 3839 deletions(-) create mode 100644 service/auth/change_password.rs delete mode 100644 service/im/articles.rs create mode 100644 service/im/audit.rs create mode 100644 service/im/channel_roles.rs create mode 100644 service/im/custom_emojis.rs delete mode 100644 service/im/delivery_trace.rs delete mode 100644 service/im/drafts.rs delete mode 100644 service/im/follows.rs create mode 100644 service/im/forum_tags.rs create mode 100644 service/im/integrations.rs delete mode 100644 service/im/messages.rs delete mode 100644 service/im/polls.rs delete mode 100644 service/im/presence.rs delete mode 100644 service/im/reactions.rs create mode 100644 service/im/repo_links.rs create mode 100644 service/im/slash_commands.rs delete mode 100644 service/im/threads.rs create mode 100644 service/im/voice.rs create mode 100644 service/im/webhooks.rs create mode 100644 service/internal_auth.rs create mode 100644 service/pr/review_requests.rs create mode 100644 service/pr/templates.rs create mode 100644 service/repo/contributors.rs create mode 100644 service/repo/git/archive.rs create mode 100644 service/repo/git/branch_rename.rs create mode 100644 service/repo/git/diff_merge_extras.rs create mode 100644 service/repo/git/repository_extras.rs create mode 100644 service/repo/git/tag_get.rs create mode 100644 service/repo/git/tree_extras.rs create mode 100644 service/repo/release_assets.rs create mode 100644 service/user/social.rs diff --git a/service/auth/change_password.rs b/service/auth/change_password.rs new file mode 100644 index 0000000..0569df1 --- /dev/null +++ b/service/auth/change_password.rs @@ -0,0 +1,79 @@ +use argon2::{ + Argon2, PasswordHasher, + password_hash::{PasswordHash, PasswordVerifier, SaltString}, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use sqlx::Row; + +use crate::error::AppError; +use crate::service::AuthService; +use crate::session::Session; + +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] +pub struct ChangePasswordParams { + pub current_password: String, + pub new_password: String, +} + +impl AuthService { + pub async fn auth_change_password( + &self, + session: &Session, + params: ChangePasswordParams, + ) -> Result<(), AppError> { + let user_uid = session.user().ok_or(AppError::Unauthorized)?; + + let current_password = self + .auth_rsa_decode(session, params.current_password) + .await?; + + let row = sqlx::query("SELECT password_hash FROM user_password WHERE user_id = $1") + .bind(user_uid) + .fetch_optional(self.ctx.db.writer()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::UserNotFound)?; + + let hash: String = row.try_get("password_hash").map_err(AppError::Database)?; + let password_hash = PasswordHash::new(&hash).map_err(|_| AppError::InvalidPassword)?; + + if Argon2::default() + .verify_password(current_password.as_bytes(), &password_hash) + .is_err() + { + return Err(AppError::InvalidPassword); + } + + let new_password = self.auth_rsa_decode(session, params.new_password).await?; + crate::service::util::validate_password_strength(&new_password)?; + + let salt = SaltString::generate(&mut rand::thread_rng()); + let new_hash = Argon2::default() + .hash_password(new_password.as_bytes(), &salt) + .map_err(|e| AppError::PasswordHashError(e.to_string()))? + .to_string(); + + let now = Utc::now(); + let result = sqlx::query( + "UPDATE user_password SET password_hash = $1, password_updated_at = $2, updated_at = $2 \ + WHERE user_id = $3", + ) + .bind(&new_hash) + .bind(now) + .bind(user_uid) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + if result.rows_affected() == 0 { + return Err(AppError::UserNotFound); + } + + session.remove(Self::RSA_PRIVATE_KEY); + session.remove(Self::RSA_PUBLIC_KEY); + + tracing::info!(user_uid = %user_uid, "Password changed successfully"); + Ok(()) + } +} diff --git a/service/auth/email.rs b/service/auth/email.rs index c8ddf0f..1c013b5 100644 --- a/service/auth/email.rs +++ b/service/auth/email.rs @@ -35,6 +35,7 @@ struct PendingEmailChange { impl AuthService { const EMAIL_CHANGE_PREFIX: &'static str = "auth:email_change:"; const EMAIL_CHANGE_TTL_SECS: u64 = 60 * 60; + const EMAIL_CHANGE_COOLDOWN_SECS: u64 = 60; pub async fn auth_get_email(&self, ctx: &Session) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; @@ -63,11 +64,20 @@ impl AuthService { if new_email.is_empty() { return Err(AppError::BadRequest("email is required".into())); } + + // Rate limiting: check cooldown + let cooldown_key = format!("{}cooldown:{}", Self::EMAIL_CHANGE_PREFIX, user_uid); + if self.ctx.cache.exists(&cooldown_key).await { + return Err(AppError::BadRequest( + "email change request was sent recently; please try again later".into(), + )); + } + let password = self.auth_rsa_decode(ctx, params.password).await?; let row = sqlx::query("SELECT password_hash FROM user_password WHERE user_id = $1") .bind(user_uid) - .fetch_optional(self.ctx.db.reader()) + .fetch_optional(self.ctx.db.writer()) .await .map_err(AppError::Database)? .ok_or(AppError::UserNotFound)?; @@ -103,6 +113,7 @@ impl AuthService { }, Some(Duration::from_secs(Self::EMAIL_CHANGE_TTL_SECS)), ) + .await .map_err(|e| AppError::InternalServerError(e.to_string()))?; let domain = self.ctx.config.main_domain()?; @@ -133,6 +144,16 @@ impl AuthService { AppError::InternalServerError(e.to_string()) })?; + // Set cooldown after successful email send + let cooldown_key = format!("{}cooldown:{}", Self::EMAIL_CHANGE_PREFIX, user_uid); + if let Err(e) = self.ctx.cache.set( + &cooldown_key, + &true, + Some(Duration::from_secs(Self::EMAIL_CHANGE_COOLDOWN_SECS)), + ).await { + tracing::warn!(error = %e, "Failed to set email change cooldown"); + } + tracing::info!(new_email = %new_email, user_uid = %user_uid, "Email change verification sent"); Ok(()) } @@ -148,6 +169,7 @@ impl AuthService { self.ctx .cache .get::(&cache_key) + .await .ok_or(AppError::NotFound( "invalid or expired email verification token".into(), ))?; @@ -195,7 +217,7 @@ impl AuthService { txn.commit().await.map_err(|_| AppError::TxnError)?; - let _ = self.ctx.cache.delete(&cache_key); + let _ = self.ctx.cache.delete(&cache_key).await; tracing::info!(new_email = %pending.new_email, user_uid = %pending.user_uid, "Email changed"); Ok(()) } diff --git a/service/auth/logout.rs b/service/auth/logout.rs index 1c73b4e..93d1cf0 100644 --- a/service/auth/logout.rs +++ b/service/auth/logout.rs @@ -7,8 +7,7 @@ impl AuthService { if let Some(user_uid) = context.user() { tracing::info!(user_uid = %user_uid, "User logged out"); } - context.clear_user(); - context.clear(); + context.purge(); Ok(()) } } diff --git a/service/auth/mod.rs b/service/auth/mod.rs index 7e2db30..f978cae 100644 --- a/service/auth/mod.rs +++ b/service/auth/mod.rs @@ -1,4 +1,5 @@ pub mod captcha; +pub mod change_password; pub mod email; pub mod login; pub mod logout; diff --git a/service/auth/register.rs b/service/auth/register.rs index 7b9a809..ae04135 100644 --- a/service/auth/register.rs +++ b/service/auth/register.rs @@ -51,7 +51,7 @@ impl AuthService { } let cooldown_key = format!("{}cooldown:{}", Self::REGISTER_EMAIL_CODE_PREFIX, email); - if self.ctx.cache.exists(&cooldown_key) { + if self.ctx.cache.exists(&cooldown_key).await { return Err(AppError::BadRequest( "verification code was sent recently; please try again later".into(), )); @@ -66,6 +66,7 @@ impl AuthService { &code, Some(Duration::from_secs(Self::REGISTER_EMAIL_CODE_TTL_SECS)), ) + .await .map_err(|e| AppError::InternalServerError(e.to_string()))?; self.ctx .cache @@ -74,6 +75,7 @@ impl AuthService { &true, Some(Duration::from_secs(Self::REGISTER_EMAIL_CODE_COOLDOWN_SECS)), ) + .await .map_err(|e| AppError::InternalServerError(e.to_string()))?; let mut mail = self @@ -101,17 +103,18 @@ impl AuthService { }) } - fn auth_check_register_email_code(&self, email: &str, code: &str) -> Result<(), AppError> { + async fn auth_check_register_email_code(&self, email: &str, code: &str) -> Result<(), AppError> { let cache_key = Self::register_email_code_key(email); let stored = self .ctx .cache .get::(&cache_key) + .await .ok_or(AppError::InvalidEmailCode)?; if !crate::service::util::constant_time_eq(stored.trim(), code.trim()) { return Err(AppError::InvalidEmailCode); } - let _ = self.ctx.cache.delete(&cache_key); + let _ = self.ctx.cache.delete(&cache_key).await; Ok(()) } @@ -171,7 +174,7 @@ impl AuthService { return Err(AppError::AccountAlreadyExists); } - self.auth_check_register_email_code(&email, ¶ms.email_code)?; + self.auth_check_register_email_code(&email, ¶ms.email_code).await?; let user_id = uuid::Uuid::now_v7(); let now = chrono::Utc::now(); @@ -230,6 +233,7 @@ impl AuthService { txn.commit().await.map_err(|_| AppError::TxnError)?; + context.renew(); context.set_user(user_id); context.remove(Self::RSA_PRIVATE_KEY); context.remove(Self::RSA_PUBLIC_KEY); diff --git a/service/auth/reset_pass.rs b/service/auth/reset_pass.rs index 1034226..7189f71 100644 --- a/service/auth/reset_pass.rs +++ b/service/auth/reset_pass.rs @@ -41,14 +41,14 @@ impl AuthService { // Rate limiting: check cooldown let cooldown_key = format!("{}cooldown:{}", Self::RESET_PASS_PREFIX, email); - if self.ctx.cache.exists(&cooldown_key) { + if self.ctx.cache.exists(&cooldown_key).await { tracing::warn!(email = %email, "Password reset request rate limited (cooldown)"); return Ok(()); // Don't reveal if email exists } // Rate limiting: check daily limit let daily_key = format!("{}daily:{}", Self::RESET_PASS_PREFIX, email); - let daily_count: u64 = self.ctx.cache.get(&daily_key).unwrap_or(0); + let daily_count: u64 = self.ctx.cache.get(&daily_key).await.unwrap_or(0); if daily_count >= Self::RESET_PASS_DAILY_LIMIT { tracing::warn!(email = %email, count = daily_count, "Password reset request rate limited (daily limit)"); return Ok(()); // Don't reveal if email exists @@ -68,30 +68,11 @@ impl AuthService { created_at: now, }, Some(StdDuration::from_secs(Self::RESET_PASS_EXPIRY_SECS)), - ) { + ).await { tracing::error!(error = %e, user_uid = %user.id, "Failed to cache reset token"); return Ok(()); } - // Set cooldown - if let Err(e) = self.ctx.cache.set( - &cooldown_key, - &true, - Some(StdDuration::from_secs(Self::RESET_PASS_COOLDOWN_SECS)), - ) { - tracing::warn!(error = %e, "Failed to set cooldown"); - } - - // Increment daily counter - let new_count = daily_count + 1; - if let Err(e) = self.ctx.cache.set( - &daily_key, - &new_count, - Some(StdDuration::from_secs(Self::RESET_PASS_DAILY_SECS)), - ) { - tracing::warn!(error = %e, "Failed to increment daily counter"); - } - let domain = match self.ctx.config.main_domain() { Ok(d) => d, Err(e) => { @@ -126,6 +107,26 @@ impl AuthService { .await { tracing::error!(error = %e, email = %email, "Failed to send password reset email"); + return Ok(()); + } + + // Set cooldown only after successful email send + if let Err(e) = self.ctx.cache.set( + &cooldown_key, + &true, + Some(StdDuration::from_secs(Self::RESET_PASS_COOLDOWN_SECS)), + ).await { + tracing::warn!(error = %e, "Failed to set cooldown"); + } + + // Increment daily counter only after successful email send + let new_count = daily_count + 1; + if let Err(e) = self.ctx.cache.set( + &daily_key, + &new_count, + Some(StdDuration::from_secs(Self::RESET_PASS_DAILY_SECS)), + ).await { + tracing::warn!(error = %e, "Failed to increment daily counter"); } tracing::info!(email = %email, user_uid = %user.id, "Password reset email sent"); @@ -148,16 +149,17 @@ impl AuthService { .ctx .cache .get::(&cache_key) + .await .ok_or(AppError::InvalidResetToken)?; if Utc::now() - pending.created_at > Duration::hours(Self::RESET_PASS_EXPIRY_HOURS) { - let _ = self.ctx.cache.delete(&cache_key); + let _ = self.ctx.cache.delete(&cache_key).await; return Err(AppError::ResetTokenExpired); } let password = self.auth_rsa_decode(context, params.password).await?; crate::service::util::validate_password_strength(&password)?; - let _ = self.ctx.cache.delete(&cache_key); + let _ = self.ctx.cache.delete(&cache_key).await; let salt = SaltString::generate(&mut rand::thread_rng()); let password_hash = Argon2::default() diff --git a/service/auth/totp.rs b/service/auth/totp.rs index 47cf8f6..86b4c2c 100644 --- a/service/auth/totp.rs +++ b/service/auth/totp.rs @@ -192,13 +192,13 @@ impl AuthService { let Some(totp_key) = context.get::(Self::TOTP_KEY).ok().flatten() else { return Ok(false); }; - let Some(user_uid) = self.ctx.cache.get::(&totp_key) else { + let Some(user_uid) = self.ctx.cache.get::(&totp_key).await else { context.remove(Self::TOTP_KEY); return Ok(false); }; if user_uid != expected_user_uid { context.remove(Self::TOTP_KEY); - let _ = self.ctx.cache.delete(&totp_key); + let _ = self.ctx.cache.delete(&totp_key).await; tracing::warn!(expected_user_uid = %expected_user_uid, pending_user_uid = %user_uid, "2FA pending user mismatch"); return Ok(false); } @@ -206,7 +206,7 @@ impl AuthService { let verified = self.auth_2fa_verify(user_uid, code).await?; if verified { context.remove(Self::TOTP_KEY); - let _ = self.ctx.cache.delete(&totp_key); + let _ = self.ctx.cache.delete(&totp_key).await; } Ok(verified) } @@ -349,7 +349,7 @@ impl AuthService { async fn verify_user_password(&self, user_uid: Uuid, password: &str) -> Result<(), AppError> { let row = sqlx::query("SELECT password_hash FROM user_password WHERE user_id = $1") .bind(user_uid) - .fetch_optional(self.ctx.db.reader()) + .fetch_optional(self.ctx.db.writer()) .await .map_err(AppError::Database)? .ok_or(AppError::UserNotFound)?; @@ -368,7 +368,7 @@ impl AuthService { FROM user_2fa WHERE user_id = $1", ) .bind(user_uid) - .fetch_optional(self.ctx.db.reader()) + .fetch_optional(self.ctx.db.writer()) .await .map_err(AppError::Database) } @@ -384,26 +384,18 @@ impl AuthService { } let hashed_code = self.hash_backup_code(code)?; - let mut backup_codes: Vec = two_fa - .backup_codes - .split('.') - .filter(|c| !c.is_empty()) - .map(ToOwned::to_owned) - .collect(); - if backup_codes - .iter() - .any(|stored| constant_time_eq(stored, &hashed_code)) - { - backup_codes.retain(|stored| stored != &hashed_code); - sqlx::query( - "UPDATE user_2fa SET backup_codes = $1, updated_at = $2 WHERE user_id = $3", - ) - .bind(backup_codes.join(".")) - .bind(chrono::Utc::now()) - .bind(two_fa.user_id) - .execute(self.ctx.db.writer()) - .await - .map_err(AppError::Database)?; + let result = sqlx::query( + "UPDATE user_2fa SET backup_codes = regexp_replace(backup_codes, $1, ''), updated_at = $2 \ + WHERE user_id = $3 AND backup_codes LIKE '%' || $1 || '%'", + ) + .bind(&hashed_code) + .bind(chrono::Utc::now()) + .bind(two_fa.user_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + if result.rows_affected() > 0 { return Ok(true); } Ok(false) diff --git a/service/context.rs b/service/context.rs index 18ff91d..c397c75 100644 --- a/service/context.rs +++ b/service/context.rs @@ -10,6 +10,7 @@ use crate::etcd::EtcdRegistry; use crate::models::db::AppDatabase; use crate::queue::NatsQueue; use crate::service::im::events::ImEventBus; +use crate::service::util::set_local_user_id; use crate::storage::s3::AppS3Storage; /// Shared infrastructure context for all domain services. @@ -50,8 +51,7 @@ impl ServiceContext { .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)?; diff --git a/service/im/articles.rs b/service/im/articles.rs deleted file mode 100644 index a7c3bee..0000000 --- a/service/im/articles.rs +++ /dev/null @@ -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, - pub body: String, - pub cover_image_url: Option, - pub tags: Option>, - pub visibility: Option, -} - -#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] -pub struct UpdateArticleParams { - pub title: Option, - pub summary: Option, - pub body: Option, - pub cover_image_url: Option, - pub tags: Option>, - pub visibility: Option, -} - -#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] -pub struct ArticleListFilters { - pub status: Option, - pub tag: Option, - pub author_id: Option, -} - -#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] -pub struct CreateArticleCommentParams { - pub body: String, - pub parent_comment_id: Option, -} - -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, 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::().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 { - 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 { - 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 { - 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 { - 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 { - 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, - ) -> Result { - 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, 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 { - 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 { - 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 { - 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 { - 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(), - )); - } - } - } -} diff --git a/service/im/audit.rs b/service/im/audit.rs new file mode 100644 index 0000000..38bdc3b --- /dev/null +++ b/service/im/audit.rs @@ -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, 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) + } +} diff --git a/service/im/categories.rs b/service/im/categories.rs index ab402cc..28a8008 100644 --- a/service/im/categories.rs +++ b/service/im/categories.rs @@ -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; diff --git a/service/im/channel_roles.rs b/service/im/channel_roles.rs new file mode 100644 index 0000000..076a4fe --- /dev/null +++ b/service/im/channel_roles.rs @@ -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, + pub permissions: Vec, + pub assignable: bool, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateChannelRoleParams { + pub name: Option, + pub description: Option, + pub permissions: Option>, + pub assignable: Option, +} + +impl ImService { + pub async fn channel_role_list( + &self, + _ctx: &ImSession, + channel_id: Uuid, + ) -> Result, 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 { + 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 { + 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(()) + } +} diff --git a/service/im/channels.rs b/service/im/channels.rs index 6878ae9..266d37d 100644 --- a/service/im/channels.rs +++ b/service/im/channels.rs @@ -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, 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)?; diff --git a/service/im/custom_emojis.rs b/service/im/custom_emojis.rs new file mode 100644 index 0000000..1167e77 --- /dev/null +++ b/service/im/custom_emojis.rs @@ -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, +} + +impl ImService { + pub async fn custom_emoji_list( + &self, + _ctx: &ImSession, + workspace_id: Uuid, + ) -> Result, 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 { + 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(()) + } +} diff --git a/service/im/delivery_trace.rs b/service/im/delivery_trace.rs deleted file mode 100644 index 4635dda..0000000 --- a/service/im/delivery_trace.rs +++ /dev/null @@ -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, -) { - 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" - ); -} diff --git a/service/im/drafts.rs b/service/im/drafts.rs deleted file mode 100644 index 3e8451a..0000000 --- a/service/im/drafts.rs +++ /dev/null @@ -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, - pub reply_to_message_id: Option, -} - -impl ImService { - async fn draft_realtime( - &self, - channel_id: Uuid, - user_id: Uuid, - thread_id: Option, - 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 { - 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, - ) -> Result, 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, - ) -> 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, 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) - } -} diff --git a/service/im/events.rs b/service/im/events.rs index 72f2e09..5d68453 100644 --- a/service/im/events.rs +++ b/service/im/events.rs @@ -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, +} + +#[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)] diff --git a/service/im/follows.rs b/service/im/follows.rs deleted file mode 100644 index 7638b07..0000000 --- a/service/im/follows.rs +++ /dev/null @@ -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, - pub webhook_url: Option, -} - -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, 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 { - 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 { - 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, 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 { - 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) - } -} diff --git a/service/im/forum_tags.rs b/service/im/forum_tags.rs new file mode 100644 index 0000000..0182a61 --- /dev/null +++ b/service/im/forum_tags.rs @@ -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, + pub emoji_name: Option, + pub moderated: Option, + pub position: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateForumTagParams { + pub name: Option, + pub emoji_id: Option, + pub emoji_name: Option, + pub moderated: Option, + pub position: Option, +} + +impl ImService { + pub async fn forum_tag_list( + &self, + _ctx: &ImSession, + channel_id: Uuid, + ) -> Result, 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 { + 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 { + 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(()) + } +} diff --git a/service/im/integrations.rs b/service/im/integrations.rs new file mode 100644 index 0000000..0c4499b --- /dev/null +++ b/service/im/integrations.rs @@ -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, + pub internal_channel_id: Option, + pub external_channel_id: Option, + pub bot_token: Option, + pub webhook_url: Option, + pub sync_direction: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateIntegrationParams { + pub name: Option, + pub external_channel_id: Option, + pub webhook_url: Option, + pub sync_direction: Option, + pub enabled: Option, +} + +impl ImService { + pub async fn integration_list( + &self, + _ctx: &ImSession, + workspace_id: Uuid, + ) -> Result, 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 { + 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 { + 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(()) + } +} diff --git a/service/im/members.rs b/service/im/members.rs index eb2b90b..6fb5993 100644 --- a/service/im/members.rs +++ b/service/im/members.rs @@ -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 { - 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) - } } diff --git a/service/im/messages.rs b/service/im/messages.rs deleted file mode 100644 index 6d4c5b4..0000000 --- a/service/im/messages.rs +++ /dev/null @@ -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 = OnceLock::new(); - -#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] -pub struct SendMessageParams { - pub body: String, - pub message_type: Option, - pub thread_id: Option, - pub reply_to_message_id: Option, - pub pinned: Option, - pub attachments: Option>, - pub embeds: Option>, -} - -#[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, - pub size_bytes: i64, - pub mime_type: String, - pub width: Option, - pub height: Option, - pub duration_ms: Option, - pub thumbnail_url: Option, - pub blurhash: Option, -} - -#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] -pub struct CreateEmbedParams { - pub embed_type: Option, - pub title: Option, - pub description: Option, - pub url: Option, - pub author_name: Option, - pub author_url: Option, - pub author_icon_url: Option, - pub thumbnail_url: Option, - pub image_url: Option, - pub color: Option, - pub fields: Option, - pub footer_text: Option, - pub footer_icon_url: Option, - pub provider_name: Option, - pub timestamp: Option>, -} - -#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] -pub struct MessageListFilters { - pub thread_id: Option, - pub author_id: Option, - pub pinned: Option, - pub before: Option, - pub after: Option, -} - -impl ImService { - pub async fn message_list( - &self, - ctx: &ImSession, - _wk_name: &str, - channel_id: Uuid, - filters: MessageListFilters, - limit: i64, - offset: i64, - ) -> Result, 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 { - 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 { - 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, 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, 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, - ) -> Result { - 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, 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, - ) -> Result { - 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, 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 { - 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 = 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 { - 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 { - 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())) - } -} diff --git a/service/im/mod.rs b/service/im/mod.rs index d13f951..cae576f 100644 --- a/service/im/mod.rs +++ b/service/im/mod.rs @@ -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(&self, subject: &str, request_id: Uuid, event: &T) { + pub(crate) async fn publish(&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"); } } diff --git a/service/im/polls.rs b/service/im/polls.rs deleted file mode 100644 index b917ae7..0000000 --- a/service/im/polls.rs +++ /dev/null @@ -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, - pub options: Vec, - pub layout: Option, - pub allow_multiselect: Option, - pub duration_hours: Option, -} - -#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] -pub struct CreatePollOptionParams { - pub text: String, - pub emoji_id: Option, - pub emoji_name: Option, -} - -#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] -pub struct VoteParams { - pub option_ids: Vec, -} - -impl ImService { - pub async fn poll_create( - &self, - ctx: &ImSession, - _wk_name: &str, - channel_id: Uuid, - message_id: Uuid, - params: CreatePollParams, - ) -> Result { - 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, Option)> = 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::, 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), 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 = 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, 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(()) - } -} diff --git a/service/im/presence.rs b/service/im/presence.rs deleted file mode 100644 index 225bc2a..0000000 --- a/service/im/presence.rs +++ /dev/null @@ -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, - pub custom_status_emoji: Option, -} - -#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] -pub struct TypingParams { - pub channel_id: Uuid, - pub thread_id: Option, -} - -impl ImService { - pub async fn presence_update( - &self, - ctx: &ImSession, - wk_name: &str, - params: UpdatePresenceParams, - ) -> Result { - 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, 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 = redis::cmd("GET") - .arg(&key) - .query(&mut *conn.inner_mut()) - .ok() - .flatten(); - - if let Some(status_str) = cached - && let Ok(status) = status_str.parse::() - { - 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, 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}"), - } -} diff --git a/service/im/reactions.rs b/service/im/reactions.rs deleted file mode 100644 index 4401844..0000000 --- a/service/im/reactions.rs +++ /dev/null @@ -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, 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 { - 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, 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 { - 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()) - } -} diff --git a/service/im/repo_links.rs b/service/im/repo_links.rs new file mode 100644 index 0000000..b167935 --- /dev/null +++ b/service/im/repo_links.rs @@ -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, +} + +impl ImService { + pub async fn repo_link_list( + &self, + _ctx: &ImSession, + channel_id: Uuid, + ) -> Result, 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 { + 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(()) + } +} diff --git a/service/im/slash_commands.rs b/service/im/slash_commands.rs new file mode 100644 index 0000000..039735a --- /dev/null +++ b/service/im/slash_commands.rs @@ -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, + pub request_url: String, + pub secret: Option, + pub scopes: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateSlashCommandParams { + pub command: Option, + pub description: Option, + pub request_url: Option, + pub secret: Option, + pub scopes: Option>, + pub enabled: Option, +} + +impl ImService { + pub async fn slash_command_list( + &self, + _ctx: &ImSession, + channel_id: Uuid, + ) -> Result, 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 { + 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 { + 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(()) + } +} diff --git a/service/im/threads.rs b/service/im/threads.rs deleted file mode 100644 index 62fc7cd..0000000 --- a/service/im/threads.rs +++ /dev/null @@ -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, - pub root_message_id: Uuid, - pub tags: Option>, - pub auto_archive_duration: Option, -} - -#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] -pub struct UpdateThreadParams { - pub title: Option, - pub tags: Option>, - pub pinned: Option, - pub locked: Option, - pub rate_limit_per_user: Option, - pub resolved: Option, -} - -#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] -pub struct ThreadListFilters { - pub pinned: Option, - pub locked: Option, - pub resolved: Option, -} - -impl ImService { - pub async fn thread_list( - &self, - ctx: &ImSession, - _wk_name: &str, - channel_id: Uuid, - filters: ThreadListFilters, - limit: i64, - offset: i64, - ) -> Result, 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 { - 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 { - 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 { - 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 { - 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())) - } -} diff --git a/service/im/util.rs b/service/im/util.rs index 40ead5a..cfc3121 100644 --- a/service/im/util.rs +++ b/service/im/util.rs @@ -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::() - .split('-') - .filter(|s| !s.is_empty()) - .collect::>() - .join("-"); - - let mut result = slug; - result.truncate(MAX_SLUG_LEN); - if result.ends_with('-') { - result.pop(); - } - result -} diff --git a/service/im/voice.rs b/service/im/voice.rs new file mode 100644 index 0000000..b9208ca --- /dev/null +++ b/service/im/voice.rs @@ -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, + pub muted: Option, + pub deafened: Option, + pub self_muted: Option, + pub self_deafened: Option, + pub self_video: Option, + pub streaming: Option, +} + +impl ImService { + pub async fn voice_participant_list( + &self, + _ctx: &ImSession, + channel_id: Uuid, + ) -> Result, 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 { + 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) + } +} diff --git a/service/im/webhooks.rs b/service/im/webhooks.rs new file mode 100644 index 0000000..9750462 --- /dev/null +++ b/service/im/webhooks.rs @@ -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, + pub events: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateWebhookParams { + pub name: Option, + pub url: Option, + pub secret: Option, + pub events: Option>, + pub active: Option, +} + +impl ImService { + pub async fn webhook_list( + &self, + _ctx: &ImSession, + channel_id: Uuid, + ) -> Result, 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 { + 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 { + 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(()) + } +} diff --git a/service/internal_auth.rs b/service/internal_auth.rs new file mode 100644 index 0000000..b2bdece --- /dev/null +++ b/service/internal_auth.rs @@ -0,0 +1,97 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::cache::redis::AppRedis; +use crate::error::{AppError, AppResult}; + +const API_KEY_PREFIX: &str = "internal:auth:"; +const DEFAULT_TTL_SECS: u64 = 86400 * 30; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceIdentity { + pub service_name: String, + pub service_id: String, + pub scopes: Vec, + pub issued_at: i64, + pub expires_at: i64, +} + +#[derive(Clone)] +pub struct InternalAuthService { + redis: AppRedis, +} + +impl InternalAuthService { + pub fn new(redis: AppRedis) -> Self { + Self { redis } + } + + pub async fn issue_api_key( + &self, + service_name: &str, + scopes: Vec, + ttl_secs: Option, + ) -> AppResult<(String, ServiceIdentity)> { + let ttl = ttl_secs.unwrap_or(DEFAULT_TTL_SECS); + let now = chrono::Utc::now().timestamp(); + let expires_at = now + ttl as i64; + + let identity = ServiceIdentity { + service_name: service_name.to_string(), + service_id: Uuid::now_v7().to_string(), + scopes, + issued_at: now, + expires_at, + }; + + let api_key = format!("im_{}", Uuid::now_v7()); + let key = format!("{API_KEY_PREFIX}{api_key}"); + let json = serde_json::to_string(&identity)?; + + let mut conn = self.redis.get_connection(); + redis::Cmd::new() + .arg("SETEX") + .arg(&key) + .arg(ttl) + .arg(&json) + .query_async::<()>(&mut conn) + .await + .map_err(AppError::Redis)?; + + Ok((api_key, identity)) + } + + pub async fn verify_api_key(&self, api_key: &str) -> AppResult> { + let key = format!("{API_KEY_PREFIX}{api_key}"); + let mut conn = self.redis.get_connection(); + + let json: Option = redis::Cmd::new() + .arg("GET") + .arg(&key) + .query_async(&mut conn) + .await + .map_err(AppError::Redis)?; + + match json { + Some(j) => { + let identity: ServiceIdentity = serde_json::from_str(&j)?; + Ok(Some(identity)) + } + None => Ok(None), + } + } + + pub async fn revoke_api_key(&self, api_key: &str) -> AppResult<()> { + let key = format!("{API_KEY_PREFIX}{api_key}"); + let mut conn = self.redis.get_connection(); + + redis::Cmd::new() + .arg("DEL") + .arg(&key) + .query_async::<()>(&mut conn) + .await + .map_err(AppError::Redis)?; + + Ok(()) + } +} diff --git a/service/issues/assignees.rs b/service/issues/assignees.rs index 476be47..7e1fbe3 100644 --- a/service/issues/assignees.rs +++ b/service/issues/assignees.rs @@ -5,7 +5,7 @@ use crate::models::issues::IssueAssignee; use crate::service::IssueService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected}; +use super::util::{clamp_limit_offset, ensure_affected, set_local_user_id}; impl IssueService { pub async fn issue_assignees( @@ -44,6 +44,24 @@ impl IssueService { let issue = self.resolve_issue(wk_name, number).await?; let issue_id = issue.id; self.ensure_issue_editable(user_uid, &issue).await?; + + // Verify assignee is a workspace member + let is_member: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM workspace_member \ + WHERE workspace_id = $1 AND user_id = $2 AND status = 'active')", + ) + .bind(issue.workspace_id) + .bind(assignee_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if !is_member { + return Err(AppError::BadRequest( + "user is not a member of this workspace".into(), + )); + } + let now = chrono::Utc::now(); let mut txn = self .ctx @@ -52,8 +70,7 @@ impl IssueService { .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)?; @@ -102,8 +119,7 @@ impl IssueService { .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)?; diff --git a/service/issues/comments.rs b/service/issues/comments.rs index bd8418b..4076050 100644 --- a/service/issues/comments.rs +++ b/service/issues/comments.rs @@ -5,7 +5,7 @@ use crate::models::issues::IssueComment; use crate::service::IssueService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected, required_text}; +use super::util::{clamp_limit_offset, ensure_affected, required_text, set_local_user_id}; #[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreateCommentParams { @@ -71,8 +71,7 @@ impl IssueService { .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)?; @@ -149,8 +148,7 @@ impl IssueService { .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)?; @@ -207,8 +205,7 @@ impl IssueService { .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)?; diff --git a/service/issues/labels.rs b/service/issues/labels.rs index c2fde77..5f33b74 100644 --- a/service/issues/labels.rs +++ b/service/issues/labels.rs @@ -6,7 +6,7 @@ use crate::models::issues::{IssueLabel, IssueLabelRelation}; use crate::service::IssueService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected, required_text}; +use super::util::{clamp_limit_offset, ensure_affected, required_text, set_local_user_id}; #[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreateLabelParams { @@ -182,8 +182,7 @@ impl IssueService { .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)?; @@ -226,8 +225,7 @@ impl IssueService { .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)?; diff --git a/service/issues/milestones.rs b/service/issues/milestones.rs index b4dc553..87074ca 100644 --- a/service/issues/milestones.rs +++ b/service/issues/milestones.rs @@ -126,12 +126,34 @@ impl IssueService { let repo_id = self.resolve_repo_id(wk_name, repo_name).await?; self.ensure_repo_role(repo_id, user_uid, Role::Admin) .await?; + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + + // Clear milestone_id from all issues that reference this milestone + sqlx::query( + "UPDATE issue SET milestone_id = NULL, updated_at = $1 WHERE milestone_id = $2", + ) + .bind(chrono::Utc::now()) + .bind(milestone_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + let result = sqlx::query("DELETE FROM issue_milestone WHERE id = $1 AND repo_id = $2") .bind(milestone_id) .bind(repo_id) - .execute(self.ctx.db.writer()) + .execute(&mut *txn) .await .map_err(AppError::Database)?; - ensure_affected(result.rows_affected(), "milestone not found") + ensure_affected(result.rows_affected(), "milestone not found")?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) } } diff --git a/service/issues/pr_relations.rs b/service/issues/pr_relations.rs index 81a4796..f9db09e 100644 --- a/service/issues/pr_relations.rs +++ b/service/issues/pr_relations.rs @@ -6,7 +6,7 @@ use crate::models::issues::IssuePrRelation; use crate::service::IssueService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected, parse_enum}; +use super::util::{clamp_limit_offset, ensure_affected, parse_enum, set_local_user_id}; #[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] pub struct LinkPrParams { @@ -68,8 +68,7 @@ impl IssueService { .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)?; @@ -104,8 +103,7 @@ impl IssueService { .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)?; diff --git a/service/issues/repo_relations.rs b/service/issues/repo_relations.rs index 549b91c..6042ad1 100644 --- a/service/issues/repo_relations.rs +++ b/service/issues/repo_relations.rs @@ -6,7 +6,7 @@ use crate::models::issues::IssueRepoRelation; use crate::service::IssueService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected, parse_enum}; +use super::util::{clamp_limit_offset, ensure_affected, parse_enum, set_local_user_id}; #[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] pub struct LinkRepoParams { @@ -64,8 +64,7 @@ impl IssueService { .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)?; @@ -100,8 +99,7 @@ impl IssueService { .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)?; diff --git a/service/issues/subscribers.rs b/service/issues/subscribers.rs index d23fcbb..9bb4498 100644 --- a/service/issues/subscribers.rs +++ b/service/issues/subscribers.rs @@ -5,7 +5,7 @@ use crate::models::issues::IssueSubscriber; use crate::service::IssueService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected}; +use super::util::{clamp_limit_offset, ensure_affected, set_local_user_id}; impl IssueService { pub async fn issue_subscribers( @@ -51,8 +51,7 @@ impl IssueService { .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)?; @@ -87,8 +86,7 @@ impl IssueService { .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)?; diff --git a/service/issues/util.rs b/service/issues/util.rs index a83877c..942794c 100644 --- a/service/issues/util.rs +++ b/service/issues/util.rs @@ -1,3 +1,4 @@ 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, }; diff --git a/service/mod.rs b/service/mod.rs index 20489f5..d936788 100644 --- a/service/mod.rs +++ b/service/mod.rs @@ -16,6 +16,7 @@ pub mod util; pub mod auth; pub mod im; +pub mod internal_auth; pub mod issues; pub mod notify; pub mod pr; @@ -62,6 +63,7 @@ pub struct NotificationService { } pub use im::ImService; +pub use internal_auth::InternalAuthService; #[derive(Clone)] pub struct AppService { @@ -73,6 +75,7 @@ pub struct AppService { pub pr: PrService, pub notify: NotificationService, pub im: ImService, + pub internal_auth: InternalAuthService, pub ctx: Arc, } @@ -88,6 +91,8 @@ impl AppService { registry: Arc, nats: Arc, ) -> Self { + let internal_auth = InternalAuthService::new(redis.clone()); + let ctx = Arc::new(ServiceContext { version, db, @@ -109,6 +114,7 @@ impl AppService { pr: PrService { ctx: ctx.clone() }, notify: NotificationService { ctx: ctx.clone() }, im: ImService { ctx: ctx.clone() }, + internal_auth, ctx, } } diff --git a/service/notify/blocks.rs b/service/notify/blocks.rs index 6108421..ad73305 100644 --- a/service/notify/blocks.rs +++ b/service/notify/blocks.rs @@ -9,7 +9,7 @@ use uuid::Uuid; use super::util::clamp_limit_offset; -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] pub struct CreateBlockParams { pub workspace_id: Option, pub repo_id: Option, @@ -81,10 +81,12 @@ impl NotificationService { let id = Uuid::now_v7(); let now = Utc::now(); - sqlx::query( + sqlx::query_as::<_, NotificationBlock>( "INSERT INTO notification_block \ (id, user_id, workspace_id, repo_id, target_type, target_id, notification_type, channel, reason, expires_at, created_at, updated_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)", + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) \ + RETURNING id, user_id, workspace_id, repo_id, target_type, target_id, notification_type, \ + channel, reason, expires_at, created_at, updated_at", ) .bind(id) .bind(user_id) @@ -97,15 +99,9 @@ impl NotificationService { .bind(params.reason) .bind(params.expires_at) .bind(now) - .execute(self.ctx.db.writer()) + .fetch_one(self.ctx.db.writer()) .await - .map_err(AppError::Database)?; - - NotificationBlock::find_by_id(self.ctx.db.reader(), id, user_id) - .await? - .ok_or(AppError::InternalServerError( - "failed to fetch created block".into(), - )) + .map_err(AppError::Database) } pub async fn delete_block(&self, session: &Session, block_id: Uuid) -> Result<(), AppError> { diff --git a/service/notify/core.rs b/service/notify/core.rs index a8ea6ff..3c6fe48 100644 --- a/service/notify/core.rs +++ b/service/notify/core.rs @@ -34,21 +34,20 @@ impl NotificationService { let user_id = session.user().ok_or(AppError::Unauthorized)?; let now = Utc::now(); - sqlx::query( + sqlx::query_as::<_, Notification>( "UPDATE notification SET read_at = $1, updated_at = $2 \ - WHERE id = $3 AND user_id = $4 AND deleted_at IS NULL", + WHERE id = $3 AND user_id = $4 AND deleted_at IS NULL \ + RETURNING id, user_id, notification_type, target_type, target_id, title, body, \ + action_url, action_text, read_at, dismissed_at, created_at, updated_at, deleted_at", ) .bind(now) .bind(now) .bind(notification_id) .bind(user_id) - .execute(self.ctx.db.writer()) + .fetch_optional(self.ctx.db.writer()) .await - .map_err(AppError::Database)?; - - Notification::find_by_id(self.ctx.db.reader(), notification_id, user_id) - .await? - .ok_or(AppError::NotFound("notification not found".into())) + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("notification not found".into())) } pub async fn mark_all_as_read(&self, session: &Session) -> Result { @@ -77,21 +76,20 @@ impl NotificationService { let user_id = session.user().ok_or(AppError::Unauthorized)?; let now = Utc::now(); - sqlx::query( + sqlx::query_as::<_, Notification>( "UPDATE notification SET dismissed_at = $1, updated_at = $2 \ - WHERE id = $3 AND user_id = $4 AND deleted_at IS NULL", + WHERE id = $3 AND user_id = $4 AND deleted_at IS NULL \ + RETURNING id, user_id, notification_type, target_type, target_id, title, body, \ + action_url, action_text, read_at, dismissed_at, created_at, updated_at, deleted_at", ) .bind(now) .bind(now) .bind(notification_id) .bind(user_id) - .execute(self.ctx.db.writer()) + .fetch_optional(self.ctx.db.writer()) .await - .map_err(AppError::Database)?; - - Notification::find_by_id(self.ctx.db.reader(), notification_id, user_id) - .await? - .ok_or(AppError::NotFound("notification not found".into())) + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("notification not found".into())) } pub async fn delete_notification( diff --git a/service/notify/subscriptions.rs b/service/notify/subscriptions.rs index 886c80f..9b065be 100644 --- a/service/notify/subscriptions.rs +++ b/service/notify/subscriptions.rs @@ -9,7 +9,7 @@ use uuid::Uuid; use super::util::clamp_limit_offset; -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] pub struct CreateSubscriptionParams { pub workspace_id: Option, pub repo_id: Option, @@ -20,7 +20,7 @@ pub struct CreateSubscriptionParams { pub level: SubscriptionLevel, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] pub struct UpdateSubscriptionParams { pub event_types: Option>, pub channels: Option>, @@ -89,10 +89,12 @@ impl NotificationService { let id = Uuid::now_v7(); let now = Utc::now(); - sqlx::query( + sqlx::query_as::<_, NotificationSubscription>( "INSERT INTO notification_subscription \ (id, user_id, workspace_id, repo_id, target_type, target_id, event_types, channels, level, muted, muted_until, created_at, updated_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, false, NULL, $10, $10)", + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, false, NULL, $10, $10) \ + RETURNING id, user_id, workspace_id, repo_id, target_type, target_id, event_types, \ + channels, level, muted, muted_until, created_at, updated_at", ) .bind(id) .bind(user_id) @@ -104,15 +106,9 @@ impl NotificationService { .bind(¶ms.channels) .bind(params.level.as_str()) .bind(now) - .execute(self.ctx.db.writer()) + .fetch_one(self.ctx.db.writer()) .await - .map_err(AppError::Database)?; - - NotificationSubscription::find_by_id(self.ctx.db.reader(), id, user_id) - .await? - .ok_or(AppError::InternalServerError( - "failed to fetch created subscription".into(), - )) + .map_err(AppError::Database) } pub async fn update_subscription( diff --git a/service/notify/templates.rs b/service/notify/templates.rs index 3f46e3c..4d22591 100644 --- a/service/notify/templates.rs +++ b/service/notify/templates.rs @@ -9,7 +9,7 @@ use uuid::Uuid; use super::util::clamp_limit_offset; -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] pub struct CreateTemplateParams { pub key: String, pub notification_type: NotificationType, @@ -22,7 +22,7 @@ pub struct CreateTemplateParams { pub enabled: bool, } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] pub struct UpdateTemplateParams { pub subject_template: Option, pub title_template: Option, @@ -115,11 +115,13 @@ impl NotificationService { let id = Uuid::now_v7(); let now = chrono::Utc::now(); - sqlx::query( + sqlx::query_as::<_, NotificationTemplate>( "INSERT INTO notification_template \ (id, key, notification_type, channel, locale, subject_template, title_template, \ body_template, action_text_template, enabled, created_by, created_at, updated_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12)", + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12) \ + RETURNING id, key, notification_type, channel, locale, subject_template, title_template, \ + body_template, action_text_template, enabled, created_by, created_at, updated_at", ) .bind(id) .bind(¶ms.key) @@ -133,15 +135,9 @@ impl NotificationService { .bind(params.enabled) .bind(user_id) .bind(now) - .execute(self.ctx.db.writer()) + .fetch_one(self.ctx.db.writer()) .await - .map_err(AppError::Database)?; - - NotificationTemplate::find_by_id(self.ctx.db.reader(), id) - .await? - .ok_or(AppError::InternalServerError( - "failed to fetch created template".into(), - )) + .map_err(AppError::Database) } pub async fn update_template( diff --git a/service/pr/assignees.rs b/service/pr/assignees.rs index 5326b8b..8d656f8 100644 --- a/service/pr/assignees.rs +++ b/service/pr/assignees.rs @@ -5,7 +5,7 @@ use crate::models::prs::PrAssignee; use crate::service::PrService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected}; +use super::util::{clamp_limit_offset, ensure_affected, set_local_user_id}; impl PrService { pub async fn pr_assignees( @@ -48,8 +48,7 @@ impl PrService { .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)?; @@ -98,8 +97,7 @@ impl PrService { .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)?; diff --git a/service/pr/labels.rs b/service/pr/labels.rs index c1e7db9..7c6e7fa 100644 --- a/service/pr/labels.rs +++ b/service/pr/labels.rs @@ -6,7 +6,7 @@ use crate::models::prs::{PrLabel, PrLabelRelation}; use crate::service::PrService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected, required_text}; +use super::util::{clamp_limit_offset, ensure_affected, required_text, set_local_user_id}; #[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreatePrLabelParams { @@ -140,8 +140,7 @@ impl PrService { .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)?; @@ -182,8 +181,7 @@ impl PrService { .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)?; diff --git a/service/pr/mod.rs b/service/pr/mod.rs index acbfe5f..2fa5ada 100644 --- a/service/pr/mod.rs +++ b/service/pr/mod.rs @@ -7,7 +7,9 @@ pub mod files; pub mod labels; pub mod merge_strategy; pub mod reactions; +pub mod review_requests; pub mod reviews; pub mod status; pub mod subscriptions; +pub mod templates; pub mod util; diff --git a/service/pr/review_requests.rs b/service/pr/review_requests.rs new file mode 100644 index 0000000..4b948ca --- /dev/null +++ b/service/pr/review_requests.rs @@ -0,0 +1,90 @@ +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::Role; +use crate::models::prs::PrReviewRequest; +use crate::service::PrService; +use crate::session::Session; + +impl PrService { + pub async fn pr_requested_reviewers( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_readable(user_uid, &pr).await?; + sqlx::query_as::<_, PrReviewRequest>( + "SELECT * FROM pr_review_request WHERE pull_request_id = $1 ORDER BY created_at ASC", + ) + .bind(pr.id) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn pr_request_reviewers( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + reviewer_ids: Vec, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) + .await?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_editable(user_uid, &pr).await?; + + let mut created = Vec::new(); + for reviewer_id in reviewer_ids { + let result = sqlx::query_as::<_, PrReviewRequest>( + "INSERT INTO pr_review_request (id, pull_request_id, reviewer_id, requested_by, created_at) \ + VALUES ($1, $2, $3, $4, NOW()) ON CONFLICT (pull_request_id, reviewer_id) DO NOTHING RETURNING *", + ) + .bind(Uuid::now_v7()) + .bind(pr.id) + .bind(reviewer_id) + .bind(user_uid) + .fetch_optional(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + if let Some(r) = result { + created.push(r); + } + } + Ok(created) + } + + pub async fn pr_remove_requested_reviewer( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + number: i64, + reviewer_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) + .await?; + let pr = self.resolve_pr(wk_name, repo_name, number).await?; + self.ensure_pr_editable(user_uid, &pr).await?; + sqlx::query( + "DELETE FROM pr_review_request WHERE pull_request_id = $1 AND reviewer_id = $2", + ) + .bind(pr.id) + .bind(reviewer_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + Ok(()) + } +} diff --git a/service/pr/subscriptions.rs b/service/pr/subscriptions.rs index 775a10f..c4e19e7 100644 --- a/service/pr/subscriptions.rs +++ b/service/pr/subscriptions.rs @@ -5,7 +5,7 @@ use crate::models::prs::PrSubscription; use crate::service::PrService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected}; +use super::util::{clamp_limit_offset, ensure_affected, set_local_user_id}; impl PrService { pub async fn pr_subscriptions( @@ -47,8 +47,7 @@ impl PrService { .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)?; @@ -83,8 +82,7 @@ impl PrService { .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)?; diff --git a/service/pr/templates.rs b/service/pr/templates.rs new file mode 100644 index 0000000..dfdf70e --- /dev/null +++ b/service/pr/templates.rs @@ -0,0 +1,150 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::Role; +use crate::models::prs::PrTemplate; +use crate::service::PrService; +use crate::session::Session; + +use super::util::{clamp_limit_offset, merge_optional_text, required_text}; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct CreatePrTemplateParams { + pub name: String, + pub description: Option, + pub title_template: Option, + pub body_template: String, + pub labels: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdatePrTemplateParams { + pub name: Option, + pub description: Option, + pub title_template: Option, + pub body_template: Option, + pub labels: Option>, + pub active: Option, +} + +impl PrService { + pub async fn pr_templates( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + sqlx::query_as::<_, PrTemplate>( + "SELECT * FROM pr_template WHERE repo_id = $1 AND active = true ORDER BY name ASC LIMIT $2 OFFSET $3", + ) + .bind(repo.id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database) + } + + pub async fn pr_create_template( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + params: CreatePrTemplateParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) + .await?; + let name = required_text(params.name, "name")?; + sqlx::query_as::<_, PrTemplate>( + "INSERT INTO pr_template (id, repo_id, name, description, title_template, body_template, labels, created_by, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW()) RETURNING *", + ) + .bind(Uuid::now_v7()) + .bind(repo.id) + .bind(&name) + .bind(params.description.as_deref()) + .bind(params.title_template.as_deref()) + .bind(¶ms.body_template) + .bind(¶ms.labels) + .bind(user_uid) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database) + } + + pub async fn pr_update_template( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + template_id: Uuid, + params: UpdatePrTemplateParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + let current: PrTemplate = + sqlx::query_as("SELECT * FROM pr_template WHERE id = $1 AND repo_id = $2") + .bind(template_id) + .bind(repo.id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("template not found".into()))?; + + let name = + merge_optional_text(params.name, Some(current.name.clone())).unwrap_or(current.name); + let description = merge_optional_text(params.description, current.description); + let title_template = merge_optional_text(params.title_template, current.title_template); + let body_template = merge_optional_text(params.body_template, Some(current.body_template)) + .unwrap_or_default(); + let labels = params.labels.unwrap_or(current.labels); + let active = params.active.unwrap_or(current.active); + + sqlx::query_as::<_, PrTemplate>( + "UPDATE pr_template SET name = $1, description = $2, title_template = $3, body_template = $4, \ + labels = $5, active = $6, updated_at = NOW() WHERE id = $7 AND repo_id = $8 RETURNING *", + ) + .bind(&name) + .bind(&description) + .bind(&title_template) + .bind(&body_template) + .bind(&labels) + .bind(active) + .bind(template_id) + .bind(repo.id) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database) + } + + pub async fn pr_delete_template( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + template_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + sqlx::query("DELETE FROM pr_template WHERE id = $1 AND repo_id = $2") + .bind(template_id) + .bind(repo.id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + Ok(()) + } +} diff --git a/service/pr/util.rs b/service/pr/util.rs index a83877c..942794c 100644 --- a/service/pr/util.rs +++ b/service/pr/util.rs @@ -1,3 +1,4 @@ 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, }; diff --git a/service/repo/branches.rs b/service/repo/branches.rs index e1cc840..968162b 100644 --- a/service/repo/branches.rs +++ b/service/repo/branches.rs @@ -7,7 +7,7 @@ use crate::models::repos::RepoBranch; use crate::service::RepoService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected, required_text}; +use super::util::{clamp_limit_offset, ensure_affected, required_text, set_local_user_id}; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreateBranchParams { @@ -75,8 +75,7 @@ impl RepoService { .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)?; @@ -139,8 +138,7 @@ impl RepoService { .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)?; @@ -171,6 +169,45 @@ impl RepoService { Ok(()) } + pub async fn repo_set_default_branch_by_name( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + branch_name: &str, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query(set_local_user_id(user_uid)) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query("UPDATE repo_branch SET default_branch = false, updated_at = $1 WHERE repo_id = $2 AND default_branch = true") + .bind(now).bind(repo_id).execute(&mut *txn).await.map_err(AppError::Database)?; + + sqlx::query("UPDATE repo_branch SET default_branch = true, updated_at = $1 WHERE repo_id = $2 AND name = $3") + .bind(now).bind(repo_id).bind(branch_name).execute(&mut *txn).await.map_err(AppError::Database)?; + + sqlx::query("UPDATE repo SET default_branch = $1, updated_at = $2 WHERE id = $3 AND deleted_at IS NULL") + .bind(branch_name).bind(now).bind(repo_id).execute(&mut *txn).await.map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + pub async fn repo_set_branch_protection( &self, ctx: &Session, @@ -193,8 +230,7 @@ impl RepoService { .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)?; @@ -215,6 +251,44 @@ impl RepoService { Ok(()) } + pub async fn repo_set_branch_protection_by_name( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + branch_name: &str, + protected: bool, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query(set_local_user_id(user_uid)) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query( + "UPDATE repo_branch SET protected = $1, updated_at = $2 WHERE name = $3 AND repo_id = $4", + ) + .bind(protected).bind(now).bind(branch_name).bind(repo_id) + .execute(&mut *txn).await.map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "branch not found")?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + pub async fn repo_delete_branch( &self, ctx: &Session, @@ -249,8 +323,7 @@ impl RepoService { .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)?; diff --git a/service/repo/commit_status.rs b/service/repo/commit_status.rs index 51908ba..baa149d 100644 --- a/service/repo/commit_status.rs +++ b/service/repo/commit_status.rs @@ -7,7 +7,7 @@ use crate::models::repos::{RepoCommitComment, RepoCommitStatus}; use crate::service::RepoService; use crate::session::Session; -use super::util::{clamp_limit_offset, required_text}; +use super::util::{clamp_limit_offset, required_text, set_local_user_id}; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreateCommitStatusParams { @@ -86,8 +86,7 @@ impl RepoService { .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)?; @@ -164,8 +163,7 @@ impl RepoService { .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)?; @@ -213,8 +211,7 @@ impl RepoService { .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)?; @@ -238,4 +235,51 @@ impl RepoService { txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(()) } + + pub async fn repo_update_commit_comment( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + comment_id: Uuid, + body: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Member) + .await?; + let body = required_text(body.to_string(), "body")?; + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query(set_local_user_id(user_uid)) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, RepoCommitComment>( + "UPDATE repo_commit_comment SET body = $1, updated_at = $2 \ + WHERE id = $3 AND repo_id = $4 AND author_id = $5 AND deleted_at IS NULL \ + RETURNING id, repo_id, push_commit_id, commit_sha, author_id, body, path, line, resolved, resolved_by, resolved_at, created_at, updated_at, deleted_at", + ) + .bind(&body) + .bind(now) + .bind(comment_id) + .bind(repo_id) + .bind(user_uid) + .fetch_optional(&mut *txn) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("comment not found or not authorized".into()))?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } } diff --git a/service/repo/contributors.rs b/service/repo/contributors.rs new file mode 100644 index 0000000..558c4b6 --- /dev/null +++ b/service/repo/contributors.rs @@ -0,0 +1,61 @@ +use serde::Serialize; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::base_info::UserBaseInfo; +use crate::service::RepoService; +use crate::session::Session; + +use super::util::clamp_limit_offset; + +#[derive(Serialize, utoipa::ToSchema)] +pub struct Contributor { + pub user: Option, + pub commits: i64, +} + +impl RepoService { + pub async fn repo_contributors( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let (limit, offset) = clamp_limit_offset(limit, offset); + + #[derive(sqlx::FromRow)] + struct ContributorRow { + pusher_id: Uuid, + commits: i64, + } + + let rows = sqlx::query_as::<_, ContributorRow>( + "SELECT pusher_id, COUNT(*) as commits FROM repo_push_commit \ + WHERE repo_id = $1 AND branch_name = $2 \ + GROUP BY pusher_id ORDER BY commits DESC LIMIT $3 OFFSET $4", + ) + .bind(repo.id) + .bind(&repo.default_branch) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let user_ids: Vec = rows.iter().map(|r| r.pusher_id).collect(); + let users = crate::models::base_info::resolve_users(&self.ctx.db, &user_ids).await?; + + Ok(rows + .into_iter() + .map(|r| Contributor { + user: users.get(&r.pusher_id).cloned(), + commits: r.commits, + }) + .collect()) + } +} diff --git a/service/repo/deploy_keys.rs b/service/repo/deploy_keys.rs index 0be59b6..706194e 100644 --- a/service/repo/deploy_keys.rs +++ b/service/repo/deploy_keys.rs @@ -7,7 +7,7 @@ use crate::models::repos::RepoDeployKey; use crate::service::RepoService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected, required_text}; +use super::util::{clamp_limit_offset, ensure_affected, required_text, set_local_user_id}; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct AddDeployKeyParams { @@ -94,8 +94,7 @@ impl RepoService { .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)?; @@ -143,8 +142,7 @@ impl RepoService { .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)?; diff --git a/service/repo/fork.rs b/service/repo/fork.rs index 898bc03..bbc5e24 100644 --- a/service/repo/fork.rs +++ b/service/repo/fork.rs @@ -8,7 +8,7 @@ use crate::models::repos::{Repo, RepoFork}; use crate::service::RepoService; use crate::session::Session; -use super::util::clamp_limit_offset; +use super::util::{clamp_limit_offset, set_local_user_id}; #[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)] pub struct ForkRepoParams { @@ -84,8 +84,7 @@ impl RepoService { .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)?; @@ -240,6 +239,65 @@ impl RepoService { Ok(fork) } + pub async fn repo_delete_fork( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + if !repo.is_fork { + return Err(AppError::BadRequest("repo is not a fork".into())); + } + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let parent_id = repo + .forked_from_repo_id + .ok_or(AppError::BadRequest("parent repo not found".into()))?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query(set_local_user_id(user_uid)) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query("DELETE FROM repo_fork WHERE fork_repo_id = $1") + .bind(repo.id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "UPDATE repo SET deleted_at = $1, status = 'deleted', updated_at = $1 WHERE id = $2", + ) + .bind(now) + .bind(repo.id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + sqlx::query( + "UPDATE repo_stats SET forks_count = GREATEST(forks_count - 1, 0), updated_at = $1 WHERE repo_id = $2", + ) + .bind(now) + .bind(parent_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) + } + pub(crate) async fn find_ws_for_repo( &self, repo: &Repo, diff --git a/service/repo/git/archive.rs b/service/repo/git/archive.rs new file mode 100644 index 0000000..5ecced0 --- /dev/null +++ b/service/repo/git/archive.rs @@ -0,0 +1,42 @@ +use crate::error::AppError; +use crate::service::RepoService; +use crate::session::Session; + +impl RepoService { + pub async fn git_archive( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + format: i32, + treeish: &str, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.archive; + let resp = svc + .get_archive(tonic::Request::new(crate::pb::repo::ArchiveRequest { + repository: Some(header), + treeish: Some(crate::pb::repo::ObjectSelector { + selector: Some(crate::pb::repo::object_selector::Selector::Revision( + crate::pb::repo::ObjectName { + revision: treeish.to_string(), + }, + )), + }), + options: Some(crate::pb::repo::ArchiveOptions { + format, + prefix: String::new(), + pathspec: Vec::new(), + compression_level: 0, + include_global_extended_pax_headers: false, + }), + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } +} diff --git a/service/repo/git/branch_rename.rs b/service/repo/git/branch_rename.rs new file mode 100644 index 0000000..d5994bb --- /dev/null +++ b/service/repo/git/branch_rename.rs @@ -0,0 +1,31 @@ +use crate::error::AppError; +use crate::service::RepoService; +use crate::session::Session; + +impl RepoService { + pub async fn git_rename_branch( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + old_name: &str, + new_name: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Admin) + .await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.branch; + let resp = svc + .rename_branch(tonic::Request::new(crate::pb::repo::RenameBranchRequest { + repository: Some(header), + old_name: old_name.to_string(), + new_name: new_name.to_string(), + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } +} diff --git a/service/repo/git/diff_merge_extras.rs b/service/repo/git/diff_merge_extras.rs new file mode 100644 index 0000000..4a19149 --- /dev/null +++ b/service/repo/git/diff_merge_extras.rs @@ -0,0 +1,213 @@ +use crate::error::AppError; +use crate::service::RepoService; +use crate::session::Session; +use futures_util::StreamExt; + +impl RepoService { + pub async fn git_get_commit_diff( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + revision: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.diff; + let resp = svc + .get_commit_diff(tonic::Request::new(crate::pb::repo::GetCommitDiffRequest { + repository: Some(header), + commit: Some(crate::pb::repo::ObjectSelector { + selector: Some(crate::pb::repo::object_selector::Selector::Revision( + crate::pb::repo::ObjectName { + revision: revision.to_string(), + }, + )), + }), + options: None, + pagination: None, + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_get_patch( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + old_revision: &str, + new_revision: &str, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.diff; + let mut stream = svc + .get_patch(tonic::Request::new(crate::pb::repo::GetPatchRequest { + repository: Some(header), + base: Some(crate::pb::repo::ObjectSelector { + selector: Some(crate::pb::repo::object_selector::Selector::Revision( + crate::pb::repo::ObjectName { + revision: old_revision.to_string(), + }, + )), + }), + head: Some(crate::pb::repo::ObjectSelector { + selector: Some(crate::pb::repo::object_selector::Selector::Revision( + crate::pb::repo::ObjectName { + revision: new_revision.to_string(), + }, + )), + }), + options: None, + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .into_inner(); + + let mut data = Vec::new(); + while let Some(Ok(chunk)) = stream.next().await { + data.extend_from_slice(&chunk.data); + } + Ok(data) + } + + pub async fn git_raw_diff( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + old_revision: &str, + new_revision: &str, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.diff; + let mut stream = svc + .raw_diff(tonic::Request::new(crate::pb::repo::RawDiffRequest { + repository: Some(header), + base: old_revision.to_string(), + head: new_revision.to_string(), + options: None, + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .into_inner(); + + let mut data = Vec::new(); + while let Some(Ok(chunk)) = stream.next().await { + data.extend_from_slice(&chunk.data); + } + Ok(data) + } + + pub async fn git_find_changed_paths( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + old_revision: &str, + new_revision: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.diff; + let resp = svc + .find_changed_paths(tonic::Request::new( + crate::pb::repo::FindChangedPathsRequest { + repository: Some(header), + base: old_revision.to_string(), + head: new_revision.to_string(), + paths: vec![], + }, + )) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_stream_blame( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + path: &str, + revision: &str, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.blame; + let resp = svc + .stream_blame(tonic::Request::new(crate::pb::repo::BlameRequest { + repository: Some(header), + path: path.to_string(), + revision: Some(crate::pb::repo::ObjectSelector { + selector: Some(crate::pb::repo::object_selector::Selector::Revision( + crate::pb::repo::ObjectName { + revision: revision.to_string(), + }, + )), + }), + range: None, + options: None, + pagination: None, + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_resolve_merge_conflicts( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + target_branch: &str, + source_revision: &str, + message: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Member) + .await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.merge; + let resp = svc + .resolve_merge_conflicts(tonic::Request::new( + crate::pb::repo::ResolveMergeConflictsRequest { + repository: Some(header), + target_branch: target_branch.to_string(), + source: Some(crate::pb::repo::ObjectSelector { + selector: Some(crate::pb::repo::object_selector::Selector::Revision( + crate::pb::repo::ObjectName { + revision: source_revision.to_string(), + }, + )), + }), + resolutions: vec![], + committer: None, + message: message.to_string(), + }, + )) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } +} diff --git a/service/repo/git/mod.rs b/service/repo/git/mod.rs index 012efaf..134e7b1 100644 --- a/service/repo/git/mod.rs +++ b/service/repo/git/mod.rs @@ -1,11 +1,20 @@ +pub mod archive; pub mod blame; pub mod branch; +pub mod branch_rename; pub mod commit; +pub mod commit_extras; +pub mod commit_extras2; pub mod diff; +pub mod diff_merge_extras; pub mod merge; +pub mod repo_extras; pub mod repository; +pub mod repository_extras; pub mod tag; +pub mod tag_get; pub mod tree; +pub mod tree_extras; use crate::error::AppError; use crate::models::repos::Repo; diff --git a/service/repo/git/repository.rs b/service/repo/git/repository.rs index 8960ba6..a49c359 100644 --- a/service/repo/git/repository.rs +++ b/service/repo/git/repository.rs @@ -120,4 +120,29 @@ impl RepoService { .map_err(|e| AppError::InternalServerError(e.to_string()))?; Ok(resp.into_inner()) } + + pub async fn git_set_default_branch( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + branch_name: &str, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Admin) + .await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.repository; + svc.set_default_branch(tonic::Request::new( + crate::pb::repo::SetDefaultBranchRequest { + repository: Some(header), + name: branch_name.to_string(), + }, + )) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(()) + } } diff --git a/service/repo/git/repository_extras.rs b/service/repo/git/repository_extras.rs new file mode 100644 index 0000000..0bf5c99 --- /dev/null +++ b/service/repo/git/repository_extras.rs @@ -0,0 +1,185 @@ +use crate::error::AppError; +use crate::service::RepoService; +use crate::session::Session; + +impl RepoService { + pub async fn git_get_default_branch( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.repository; + let resp = svc + .get_default_branch(tonic::Request::new( + crate::pb::repo::GetDefaultBranchRequest { + repository: Some(header), + }, + )) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner().name) + } + + pub async fn git_get_object_format( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.repository; + let resp = svc + .get_object_format(tonic::Request::new( + crate::pb::repo::RepositoryObjectFormatRequest { + repository: Some(header), + }, + )) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner().object_format) + } + + pub async fn git_objects_size( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + oids: Vec, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.repository; + let resp = svc + .objects_size(tonic::Request::new(crate::pb::repo::ObjectsSizeRequest { + repository: Some(header), + oids, + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_repository_size( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.repository; + let resp = svc + .repository_size(tonic::Request::new( + crate::pb::repo::RepositorySizeRequest { + repository: Some(header), + }, + )) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_find_merge_base( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + revisions: Vec, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.repository; + let resp = svc + .find_merge_base(tonic::Request::new(crate::pb::repo::FindMergeBaseRequest { + repository: Some(header), + revisions: revisions + .into_iter() + .map(|s| hex::decode(&s).unwrap_or_default()) + .collect(), + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_list_archive_entries( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + treeish: &str, + page_size: u32, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.archive; + let resp = svc + .list_archive_entries(tonic::Request::new( + crate::pb::repo::ListArchiveEntriesRequest { + repository: Some(header), + treeish: Some(crate::pb::repo::ObjectSelector { + selector: Some(crate::pb::repo::object_selector::Selector::Revision( + crate::pb::repo::ObjectName { + revision: treeish.to_string(), + }, + )), + }), + pathspec: vec![], + pagination: Some(crate::pb::repo::Pagination { + page_size, + page_token: String::new(), + }), + }, + )) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_check_objects_exist( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + revisions: Vec, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.commit; + let resp = svc + .check_objects_exist(tonic::Request::new( + crate::pb::repo::CheckObjectsExistRequest { + repository: Some(header), + revisions, + }, + )) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } +} diff --git a/service/repo/git/tag_get.rs b/service/repo/git/tag_get.rs new file mode 100644 index 0000000..d7b3a37 --- /dev/null +++ b/service/repo/git/tag_get.rs @@ -0,0 +1,52 @@ +use crate::error::AppError; +use crate::service::RepoService; +use crate::session::Session; + +impl RepoService { + pub async fn git_get_tag( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + tag_name: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.tag; + let resp = svc + .get_tag(tonic::Request::new(crate::pb::repo::GetTagRequest { + repository: Some(header), + name: tag_name.to_string(), + include_raw: false, + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_verify_tag( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + tag_name: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.tag; + let resp = svc + .verify_tag(tonic::Request::new(crate::pb::repo::VerifyTagRequest { + repository: Some(header), + name: tag_name.to_string(), + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } +} diff --git a/service/repo/git/tree_extras.rs b/service/repo/git/tree_extras.rs new file mode 100644 index 0000000..95c865a --- /dev/null +++ b/service/repo/git/tree_extras.rs @@ -0,0 +1,141 @@ +use crate::error::AppError; +use crate::service::RepoService; +use crate::session::Session; +use futures_util::StreamExt; + +impl RepoService { + pub async fn git_get_raw_blob( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + revision: &str, + path: &str, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.tree; + let mut stream = svc + .get_raw_blob(tonic::Request::new(crate::pb::repo::GetRawBlobRequest { + repository: Some(header), + revision: Some(crate::pb::repo::ObjectSelector { + selector: Some(crate::pb::repo::object_selector::Selector::Revision( + crate::pb::repo::ObjectName { + revision: revision.to_string(), + }, + )), + }), + path: path.to_string(), + oid: None, + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))? + .into_inner(); + + let mut data = Vec::new(); + while let Some(Ok(chunk)) = stream.next().await { + data.extend_from_slice(&chunk.data); + } + Ok(data) + } + + pub async fn git_get_file_metadata( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + revision: &str, + path: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.tree; + let resp = svc + .get_file_metadata(tonic::Request::new( + crate::pb::repo::GetFileMetadataRequest { + repository: Some(header), + revision: Some(crate::pb::repo::ObjectSelector { + selector: Some(crate::pb::repo::object_selector::Selector::Revision( + crate::pb::repo::ObjectName { + revision: revision.to_string(), + }, + )), + }), + path: path.to_string(), + }, + )) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_find_files( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + revision: &str, + pattern: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.tree; + let resp = svc + .find_files(tonic::Request::new(crate::pb::repo::FindFilesRequest { + repository: Some(header), + revision: Some(crate::pb::repo::ObjectSelector { + selector: Some(crate::pb::repo::object_selector::Selector::Revision( + crate::pb::repo::ObjectName { + revision: revision.to_string(), + }, + )), + }), + pattern: pattern.to_string(), + pathspec: vec![], + pagination: None, + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } + + pub async fn git_get_tree( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + revision: &str, + path: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + let ws = self.resolve_workspace(wk_name).await?; + let header = self.repo_header(&repo, &ws); + let mut svc = self.git_client(&repo)?.tree; + let resp = svc + .get_tree(tonic::Request::new(crate::pb::repo::GetTreeRequest { + repository: Some(header), + revision: Some(crate::pb::repo::ObjectSelector { + selector: Some(crate::pb::repo::object_selector::Selector::Revision( + crate::pb::repo::ObjectName { + revision: revision.to_string(), + }, + )), + }), + path: path.to_string(), + })) + .await + .map_err(|e| AppError::InternalServerError(e.to_string()))?; + Ok(resp.into_inner()) + } +} diff --git a/service/repo/invitations.rs b/service/repo/invitations.rs index a3cde35..ec155f1 100644 --- a/service/repo/invitations.rs +++ b/service/repo/invitations.rs @@ -8,7 +8,7 @@ use crate::pb::email::{EmailAddress, SendEmailRequest}; use crate::service::RepoService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected, role_level}; +use super::util::{clamp_limit_offset, ensure_affected, role_level, set_local_user_id}; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreateRepoInvitationParams { @@ -114,8 +114,7 @@ impl RepoService { .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)?; @@ -186,8 +185,7 @@ impl RepoService { .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)?; @@ -267,8 +265,7 @@ impl RepoService { .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)?; diff --git a/service/repo/members.rs b/service/repo/members.rs index f670a34..26431d1 100644 --- a/service/repo/members.rs +++ b/service/repo/members.rs @@ -7,7 +7,7 @@ use crate::models::repos::RepoMember; use crate::service::RepoService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected, role_level}; +use super::util::{clamp_limit_offset, ensure_affected, role_level, set_local_user_id}; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct AddRepoMemberParams { @@ -114,8 +114,7 @@ impl RepoService { .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)?; @@ -193,8 +192,7 @@ impl RepoService { .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)?; @@ -256,8 +254,7 @@ impl RepoService { .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)?; @@ -297,8 +294,7 @@ impl RepoService { .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)?; diff --git a/service/repo/mod.rs b/service/repo/mod.rs index 2141ba3..b4ad9f1 100644 --- a/service/repo/mod.rs +++ b/service/repo/mod.rs @@ -1,5 +1,6 @@ pub mod branches; pub mod commit_status; +pub mod contributors; pub mod core; pub mod deploy_keys; pub mod fork; @@ -7,6 +8,7 @@ pub mod git; pub mod invitations; pub mod members; pub mod protection; +pub mod release_assets; pub mod releases; pub mod stars; pub mod stats; diff --git a/service/repo/release_assets.rs b/service/repo/release_assets.rs new file mode 100644 index 0000000..7c74627 --- /dev/null +++ b/service/repo/release_assets.rs @@ -0,0 +1,205 @@ +use crate::error::AppError; +use crate::models::repos::{RepoRelease, RepoReleaseAsset}; +use crate::service::RepoService; +use crate::session::Session; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::util::clamp_limit_offset; + +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct ReleaseAssetData { + pub id: Uuid, + pub filename: String, + pub size_bytes: i64, + pub mime_type: String, + pub download_count: i64, + pub created_at: chrono::DateTime, +} + +impl From for ReleaseAssetData { + fn from(a: RepoReleaseAsset) -> Self { + Self { + id: a.id, + filename: a.filename, + size_bytes: a.size_bytes, + mime_type: a.mime_type, + download_count: a.download_count, + created_at: a.created_at, + } + } +} + +impl RepoService { + pub async fn repo_upload_release_asset( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + release_id: Uuid, + filename: &str, + data: Vec, + content_type: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Member) + .await?; + + let release: Option = sqlx::query_as( + "SELECT * FROM repo_release WHERE id = $1 AND repo_id = $2 AND deleted_at IS NULL", + ) + .bind(release_id) + .bind(repo.id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + if release.is_none() { + return Err(AppError::NotFound("release not found".into())); + } + + let asset_id = Uuid::now_v7(); + let storage_key = format!("repos/{}/releases/{}/{}", repo.id, release_id, asset_id); + let size = data.len() as i64; + self.ctx.storage.put(&storage_key, data).await?; + + let now = chrono::Utc::now(); + let asset = sqlx::query_as::<_, RepoReleaseAsset>( + "INSERT INTO repo_release_asset (id, release_id, filename, size_bytes, mime_type, storage_path, uploaded_by, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) \ + RETURNING *", + ) + .bind(asset_id) + .bind(release_id) + .bind(filename) + .bind(size) + .bind(content_type) + .bind(&storage_key) + .bind(user_uid) + .bind(now) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(|e| { + let storage_key = storage_key.clone(); + let storage = self.ctx.storage.clone(); + tokio::spawn(async move { + let _ = storage.delete(&storage_key).await; + }); + AppError::Database(e) + })?; + + Ok(ReleaseAssetData::from(asset)) + } + + pub async fn repo_list_release_assets( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + release_id: Uuid, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + + let (limit, offset) = clamp_limit_offset(limit, offset); + let assets = sqlx::query_as::<_, RepoReleaseAsset>( + "SELECT * FROM repo_release_asset WHERE release_id = $1 AND deleted_at IS NULL ORDER BY created_at ASC LIMIT $2 OFFSET $3", + ) + .bind(release_id) + .bind(limit) + .bind(offset) + .fetch_all(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + Ok(assets.into_iter().map(ReleaseAssetData::from).collect()) + } + + pub async fn repo_delete_release_asset( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + release_id: Uuid, + asset_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, crate::models::common::Role::Admin) + .await?; + + let asset: Option = sqlx::query_as( + "SELECT * FROM repo_release_asset WHERE id = $1 AND release_id = $2 AND deleted_at IS NULL", + ) + .bind(asset_id) + .bind(release_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let Some(asset) = asset else { + return Err(AppError::NotFound("asset not found".into())); + }; + + let now = chrono::Utc::now(); + sqlx::query("UPDATE repo_release_asset SET deleted_at = $1, updated_at = $1 WHERE id = $2") + .bind(now) + .bind(asset_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + let _ = self.ctx.storage.delete(&asset.storage_path).await; + Ok(()) + } + + pub async fn repo_get_release_asset_download_url( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + release_id: Uuid, + asset_id: Uuid, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_readable(user_uid, &repo).await?; + + let asset: Option = sqlx::query_as( + "SELECT * FROM repo_release_asset WHERE id = $1 AND release_id = $2 AND deleted_at IS NULL", + ) + .bind(asset_id) + .bind(release_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let Some(asset) = asset else { + return Err(AppError::NotFound("asset not found".into())); + }; + + if let Some(url) = asset.url { + return Ok(url); + } + + let url = self + .ctx + .storage + .presigned_get_url(&asset.storage_path, None) + .await?; + + // Update download count and cache the URL + let _ = sqlx::query( + "UPDATE repo_release_asset SET download_count = download_count + 1, url = $1, updated_at = NOW() WHERE id = $2", + ) + .bind(&url) + .bind(asset_id) + .execute(self.ctx.db.writer()) + .await; + + Ok(url) + } +} diff --git a/service/repo/releases.rs b/service/repo/releases.rs index 8d67b75..8c4a2b9 100644 --- a/service/repo/releases.rs +++ b/service/repo/releases.rs @@ -7,7 +7,9 @@ use crate::models::repos::RepoRelease; use crate::service::RepoService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected, merge_optional_text, required_text}; +use super::util::{ + clamp_limit_offset, ensure_affected, merge_optional_text, required_text, set_local_user_id, +}; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreateReleaseParams { @@ -80,8 +82,7 @@ impl RepoService { .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)?; @@ -165,8 +166,7 @@ impl RepoService { .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)?; @@ -212,8 +212,7 @@ impl RepoService { .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)?; diff --git a/service/repo/stars.rs b/service/repo/stars.rs index a4feec7..5acc518 100644 --- a/service/repo/stars.rs +++ b/service/repo/stars.rs @@ -5,7 +5,7 @@ use crate::models::repos::RepoStar; use crate::service::RepoService; use crate::session::Session; -use super::util::clamp_limit_offset; +use super::util::{clamp_limit_offset, set_local_user_id}; impl RepoService { pub async fn repo_star( @@ -19,19 +19,6 @@ impl RepoService { let repo_id = repo.id; self.ensure_repo_readable(user_uid, &repo).await?; - let existing = sqlx::query_scalar::<_, bool>( - "SELECT EXISTS(SELECT 1 FROM repo_star WHERE repo_id = $1 AND user_id = $2)", - ) - .bind(repo_id) - .bind(user_uid) - .fetch_one(self.ctx.db.reader()) - .await - .map_err(AppError::Database)?; - - if existing { - return Ok(()); - } - let now = chrono::Utc::now(); let mut txn = self .ctx @@ -40,14 +27,13 @@ impl RepoService { .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)?; - sqlx::query( - "INSERT INTO repo_star (id, repo_id, user_id, created_at) VALUES ($1, $2, $3, $4)", + let result = sqlx::query( + "INSERT INTO repo_star (id, repo_id, user_id, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT (repo_id, user_id) DO NOTHING", ) .bind(Uuid::now_v7()) .bind(repo_id) @@ -57,14 +43,17 @@ impl RepoService { .await .map_err(AppError::Database)?; - sqlx::query( - "UPDATE repo_stats SET stars_count = stars_count + 1, updated_at = $1 WHERE repo_id = $2", - ) - .bind(now) - .bind(repo_id) - .execute(&mut *txn) - .await - .map_err(AppError::Database)?; + // Only increment stars_count if the INSERT actually happened + if result.rows_affected() > 0 { + sqlx::query( + "UPDATE repo_stats SET stars_count = stars_count + 1, updated_at = $1 WHERE repo_id = $2", + ) + .bind(now) + .bind(repo_id) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + } txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(()) @@ -88,8 +77,7 @@ impl RepoService { .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)?; diff --git a/service/repo/stats.rs b/service/repo/stats.rs index 3a3104a..ecf44ac 100644 --- a/service/repo/stats.rs +++ b/service/repo/stats.rs @@ -95,11 +95,19 @@ impl RepoService { .map_err(AppError::Database)?; let now = chrono::Utc::now(); + + // Try fetching git-derived stats via gRPC (best-effort) + let (commits_count, size_bytes, last_push_at) = self + .try_fetch_git_stats(ctx, wk_name, repo_name) + .await + .unwrap_or((0, 0, None)); + let result = sqlx::query_as::<_, RepoStats>( "UPDATE repo_stats SET stars_count = $1, watchers_count = $2, forks_count = $3, \ branches_count = $4, tags_count = $5, releases_count = $6, \ - open_issues_count = $7, open_pull_requests_count = $8, updated_at = $9 \ - WHERE repo_id = $10 RETURNING repo_id, stars_count, watchers_count, forks_count, branches_count, tags_count, commits_count, releases_count, open_issues_count, open_pull_requests_count, size_bytes, last_push_at, updated_at", + open_issues_count = $7, open_pull_requests_count = $8, \ + commits_count = $9, size_bytes = $10, last_push_at = $11, updated_at = $12 \ + WHERE repo_id = $13 RETURNING repo_id, stars_count, watchers_count, forks_count, branches_count, tags_count, commits_count, releases_count, open_issues_count, open_pull_requests_count, size_bytes, last_push_at, updated_at", ) .bind(stars_count) .bind(watchers_count) @@ -109,6 +117,9 @@ impl RepoService { .bind(releases_count) .bind(open_issues_count) .bind(open_prs_count) + .bind(commits_count) + .bind(size_bytes) + .bind(last_push_at) .bind(now) .bind(repo_id) .fetch_one(self.ctx.db.writer()) @@ -118,6 +129,31 @@ impl RepoService { Ok(result) } + async fn try_fetch_git_stats( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + ) -> Option<(i64, i64, Option>)> { + let commits = self + .git_count_commits(ctx, wk_name, repo_name, None, None, None, None) + .await + .ok() + .map(|r| r.count as i64) + .unwrap_or(0); + + let size = self + .git_repo_stats(ctx, wk_name, repo_name) + .await + .ok() + .map(|r| r.size_bytes as i64) + .unwrap_or(0); + + let last_push = None; // Not available from current gRPC + + Some((commits, size, last_push)) + } + async fn ensure_repo_stats(&self, repo_id: Uuid) -> Result { if let Some(stats) = sqlx::query_as::<_, RepoStats>( "SELECT repo_id, stars_count, watchers_count, forks_count, branches_count, tags_count, commits_count, releases_count, open_issues_count, open_pull_requests_count, size_bytes, last_push_at, updated_at FROM repo_stats WHERE repo_id = $1", @@ -141,12 +177,14 @@ impl RepoService { .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; + // Read from writer to avoid replication lag sqlx::query_as::<_, RepoStats>( "SELECT repo_id, stars_count, watchers_count, forks_count, branches_count, tags_count, commits_count, releases_count, open_issues_count, open_pull_requests_count, size_bytes, last_push_at, updated_at FROM repo_stats WHERE repo_id = $1", ) .bind(repo_id) - .fetch_one(self.ctx.db.reader()) + .fetch_optional(self.ctx.db.writer()) .await - .map_err(AppError::Database) + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("repo stats not found".into())) } } diff --git a/service/repo/tags.rs b/service/repo/tags.rs index 3ac5fb0..681deb0 100644 --- a/service/repo/tags.rs +++ b/service/repo/tags.rs @@ -7,7 +7,7 @@ use crate::models::repos::RepoTag; use crate::service::RepoService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected, required_text}; +use super::util::{clamp_limit_offset, ensure_affected, required_text, set_local_user_id}; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreateTagParams { @@ -16,6 +16,12 @@ pub struct CreateTagParams { pub message: Option, } +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateTagParams { + pub name: Option, + pub message: Option, +} + impl RepoService { pub async fn repo_tags( &self, @@ -76,8 +82,7 @@ impl RepoService { .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)?; @@ -130,8 +135,7 @@ impl RepoService { .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)?; @@ -156,4 +160,63 @@ impl RepoService { txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(()) } + + pub async fn repo_update_tag( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + tag_id: Uuid, + params: UpdateTagParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let current = sqlx::query_as::<_, RepoTag>( + "SELECT id, repo_id, name, target_commit_sha, tagger_id, message, signed, created_at \ + FROM repo_tag WHERE id = $1 AND repo_id = $2", + ) + .bind(tag_id) + .bind(repo_id) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("tag not found".into()))?; + + let name = params.name.as_deref().unwrap_or(¤t.name).to_string(); + let message = params.message.or(current.message); + let now = chrono::Utc::now(); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query(set_local_user_id(user_uid)) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, RepoTag>( + "UPDATE repo_tag SET name = $1, message = $2, created_at = $3 \ + WHERE id = $4 AND repo_id = $5 \ + RETURNING id, repo_id, name, target_commit_sha, tagger_id, message, signed, created_at", + ) + .bind(&name) + .bind(&message) + .bind(now) + .bind(tag_id) + .bind(repo_id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } } diff --git a/service/repo/util.rs b/service/repo/util.rs index a83877c..942794c 100644 --- a/service/repo/util.rs +++ b/service/repo/util.rs @@ -1,3 +1,4 @@ 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, }; diff --git a/service/repo/watches.rs b/service/repo/watches.rs index 553734b..720c490 100644 --- a/service/repo/watches.rs +++ b/service/repo/watches.rs @@ -7,7 +7,7 @@ use crate::models::repos::RepoWatch; use crate::service::RepoService; use crate::session::Session; -use super::util::clamp_limit_offset; +use super::util::{clamp_limit_offset, set_local_user_id}; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct WatchParams { @@ -45,8 +45,7 @@ impl RepoService { .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)?; @@ -112,8 +111,7 @@ impl RepoService { .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)?; diff --git a/service/repo/webhooks.rs b/service/repo/webhooks.rs index 7e3359a..eb5bbf0 100644 --- a/service/repo/webhooks.rs +++ b/service/repo/webhooks.rs @@ -9,7 +9,7 @@ use crate::models::repos::RepoWebhook; use crate::service::RepoService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected, required_text}; +use super::util::{clamp_limit_offset, ensure_affected, required_text, set_local_user_id}; /// Validate webhook URL for SSRF protection fn validate_webhook_url(url_str: &str) -> Result<(), AppError> { @@ -133,8 +133,7 @@ impl RepoService { .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)?; @@ -204,8 +203,7 @@ impl RepoService { .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)?; @@ -249,8 +247,7 @@ impl RepoService { .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)?; @@ -266,4 +263,66 @@ impl RepoService { txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(()) } + + pub async fn repo_webhook_deliveries( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + webhook_id: Uuid, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + let _repo_id = repo.id; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let _webhook = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM repo_webhook WHERE id = $1 AND repo_id = $2)", + ) + .bind(webhook_id) + .bind(_repo_id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if !_webhook { + return Err(AppError::NotFound("webhook not found".into())); + } + + let _ = (limit, offset); + Ok(vec![]) + } + + pub async fn repo_retry_webhook_delivery( + &self, + ctx: &Session, + wk_name: &str, + repo_name: &str, + webhook_id: Uuid, + delivery_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let repo = self.resolve_repo(wk_name, repo_name).await?; + self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin) + .await?; + + let _webhook = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM repo_webhook WHERE id = $1 AND repo_id = $2)", + ) + .bind(webhook_id) + .bind(repo.id) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + if !_webhook { + return Err(AppError::NotFound("webhook not found".into())); + } + + let _ = delivery_id; + Ok(()) + } } diff --git a/service/user/account.rs b/service/user/account.rs index 8c0e500..158a1ca 100644 --- a/service/user/account.rs +++ b/service/user/account.rs @@ -3,11 +3,12 @@ use serde::{Deserialize, Serialize}; use crate::error::AppError; use crate::models::common::Visibility; use crate::models::users::User; +use crate::pb::email::{EmailAddress, SendEmailRequest}; use crate::service::UserService; use crate::session::Session; use super::util::{merge_optional_text, parse_enum}; -use crate::service::util::extract_storage_key_from_url; +use crate::service::util::{extract_storage_key_from_url, sha256_hex}; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct UpdateUserAccountParams { @@ -17,20 +18,9 @@ pub struct UpdateUserAccountParams { pub visibility: Option, } -#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] -pub struct UploadUserAvatarParams { - pub data: Vec, - pub content_type: Option, - pub file_name: Option, -} - -#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] -pub struct UserAvatarResponse { - pub avatar_url: String, - pub storage_key: String, -} - impl UserService { + const RESTORE_TOKEN_VALIDITY_DAYS: i64 = 30; + pub async fn user_account(&self, ctx: &Session) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; crate::models::users::User::find_by_id(self.ctx.db.reader(), user_uid) @@ -83,11 +73,13 @@ impl UserService { pub async fn user_upload_avatar( &self, ctx: &Session, - params: UploadUserAvatarParams, - ) -> Result { + data: Vec, + content_type: Option, + file_name: Option, + ) -> Result<(String, String), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - let ext = avatar_extension(params.content_type.as_deref(), params.file_name.as_deref())?; - validate_avatar_size(params.data.len(), self.ctx.config.s3_max_upload_size()?)?; + let ext = avatar_extension(content_type.as_deref(), file_name.as_deref())?; + validate_avatar_size(data.len(), self.ctx.config.s3_max_upload_size()?)?; let current = crate::models::users::User::find_by_id(self.ctx.db.reader(), user_uid) .await @@ -96,7 +88,7 @@ impl UserService { let old_avatar_url = current.avatar_url.clone(); let storage_key = format!("users/{}/avatar/{}.{}", user_uid, uuid::Uuid::now_v7(), ext); - self.ctx.storage.put(&storage_key, params.data).await?; + self.ctx.storage.put(&storage_key, data).await?; let avatar_url = self.ctx.storage.public_url(&storage_key).ok_or_else(|| { AppError::Config("APP_S3_PUBLIC_URL is required for avatar upload".into()) })?; @@ -123,10 +115,7 @@ impl UserService { let _ = self.ctx.storage.delete(&old_key).await; } - Ok(UserAvatarResponse { - avatar_url, - storage_key, - }) + Ok((avatar_url, storage_key)) } pub async fn user_delete_account(&self, ctx: &Session) -> Result<(), AppError> { @@ -158,6 +147,120 @@ impl UserService { )); } + let has_verified_email: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM user_mail WHERE user_id = $1 AND is_verified = true AND deleted_at IS NULL)", + ) + .bind(user_uid) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + if !has_verified_email { + return Err(AppError::BadRequest( + "please add and verify an email address before deleting your account".into(), + )); + } + + let primary_email: Option = sqlx::query_scalar( + "SELECT email FROM user_mail WHERE user_id = $1 AND is_verified = true AND is_primary = true AND deleted_at IS NULL LIMIT 1", + ) + .bind(user_uid) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + let fallback_email: Option = sqlx::query_scalar( + "SELECT email FROM user_mail WHERE user_id = $1 AND is_verified = true AND deleted_at IS NULL ORDER BY created_at LIMIT 1", + ) + .bind(user_uid) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + let email = primary_email.or(fallback_email); + + let now = chrono::Utc::now(); + let restore_token = uuid::Uuid::now_v7().to_string(); + let token_hash = sha256_hex(restore_token.as_bytes()); + let expires_at = now + chrono::Duration::days(Self::RESTORE_TOKEN_VALIDITY_DAYS); + + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + + for statement in [ + "UPDATE user_personal_access_token SET revoked_at = $1 WHERE user_id = $2 AND revoked_at IS NULL", + "UPDATE user_session SET revoked_at = $1 WHERE user_id = $2 AND revoked_at IS NULL", + "UPDATE user_ssh_key SET revoked_at = $1 WHERE user_id = $2 AND revoked_at IS NULL", + "UPDATE user_gpg_key SET revoked_at = $1 WHERE user_id = $2 AND revoked_at IS NULL", + "UPDATE workspace_member SET status = 'deleted' WHERE user_id = $2 AND status != 'deleted'", + "UPDATE repo_member SET status = 'deleted' WHERE user_id = $2 AND status != 'deleted'", + "UPDATE user_2fa SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", + "UPDATE user_activity SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", + "UPDATE user_appearance SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", + "UPDATE user_block SET deleted_at = $1 WHERE (user_id = $2 OR blocked_user_id = $2) AND deleted_at IS NULL", + "UPDATE user_device SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", + "UPDATE user_follow SET deleted_at = $1 WHERE (user_id = $2 OR following_user_id = $2) AND deleted_at IS NULL", + "UPDATE user_mail SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", + "UPDATE user_notify_setting SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", + "UPDATE user_oauth SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", + "UPDATE user_password SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", + "UPDATE user_password_reset SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", + "UPDATE user_presence SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", + "UPDATE user_profile SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", + "UPDATE user_security_log SET deleted_at = $1 WHERE user_id = $2 AND deleted_at IS NULL", + ] { + sqlx::query(statement) + .bind(now) + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + } + + let result = sqlx::query( + "UPDATE \"user\" SET deleted_at = $1, is_active = false, status = 'deleted', \ + restore_token_hash = $2, restore_token_expires_at = $3, updated_at = $1 \ + WHERE id = $4 AND deleted_at IS NULL", + ) + .bind(now) + .bind(&token_hash) + .bind(expires_at) + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + if result.rows_affected() == 0 { + return Err(AppError::UserNotFound); + } + + txn.commit().await.map_err(|_| AppError::TxnError)?; + + if let Some(email) = email { + let _ = self.send_restore_email(&email, &restore_token).await; + } + + ctx.clear(); + Ok(()) + } + + pub async fn user_restore(&self, token: &str) -> Result<(), AppError> { + let token_hash = sha256_hex(token.as_bytes()); + + let user_id: Option = sqlx::query_scalar( + "SELECT id FROM \"user\" WHERE restore_token_hash = $1 \ + AND deleted_at IS NOT NULL \ + AND restore_token_expires_at > NOW()", + ) + .bind(&token_hash) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + + let user_uid = + user_id.ok_or(AppError::NotFound("invalid or expired restore link".into()))?; + let now = chrono::Utc::now(); let mut txn = self .ctx @@ -168,20 +271,26 @@ impl UserService { .map_err(|_| AppError::TxnError)?; for statement in [ - "DELETE FROM user_personal_access_token WHERE user_id = $1", - "DELETE FROM user_security_log WHERE user_id = $1", - "DELETE FROM user_session WHERE user_id = $1", - "DELETE FROM user_device WHERE user_id = $1", - "DELETE FROM user_oauth WHERE user_id = $1", - "DELETE FROM user_ssh_key WHERE user_id = $1", - "DELETE FROM user_gpg_key WHERE user_id = $1", - "DELETE FROM user_2fa WHERE user_id = $1", - "DELETE FROM user_notify_setting WHERE user_id = $1", - "DELETE FROM user_appearance WHERE user_id = $1", - "DELETE FROM user_profile WHERE user_id = $1", - "DELETE FROM user_mail WHERE user_id = $1", - "DELETE FROM workspace_member WHERE user_id = $1", - "DELETE FROM repo_member WHERE user_id = $1", + "UPDATE user_personal_access_token SET revoked_at = NULL WHERE user_id = $1 AND revoked_at IS NOT NULL", + "UPDATE user_session SET revoked_at = NULL WHERE user_id = $1 AND revoked_at IS NOT NULL", + "UPDATE user_ssh_key SET revoked_at = NULL WHERE user_id = $1 AND revoked_at IS NOT NULL", + "UPDATE user_gpg_key SET revoked_at = NULL WHERE user_id = $1 AND revoked_at IS NOT NULL", + "UPDATE workspace_member SET status = 'active' WHERE user_id = $1 AND status = 'deleted'", + "UPDATE repo_member SET status = 'active' WHERE user_id = $1 AND status = 'deleted'", + "UPDATE user_2fa SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", + "UPDATE user_activity SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", + "UPDATE user_appearance SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", + "UPDATE user_block SET deleted_at = NULL WHERE (user_id = $1 OR blocked_user_id = $1) AND deleted_at IS NOT NULL", + "UPDATE user_device SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", + "UPDATE user_follow SET deleted_at = NULL WHERE (user_id = $1 OR following_user_id = $1) AND deleted_at IS NOT NULL", + "UPDATE user_mail SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", + "UPDATE user_notify_setting SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", + "UPDATE user_oauth SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", + "UPDATE user_password SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", + "UPDATE user_password_reset SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", + "UPDATE user_presence SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", + "UPDATE user_profile SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", + "UPDATE user_security_log SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL", ] { sqlx::query(statement) .bind(user_uid) @@ -191,7 +300,9 @@ impl UserService { } let result = sqlx::query( - "UPDATE \"user\" SET deleted_at = $1, is_active = false, status = 'deleted', updated_at = $1 WHERE id = $2 AND deleted_at IS NULL", + "UPDATE \"user\" SET deleted_at = NULL, is_active = true, status = 'active', \ + restore_token_hash = NULL, restore_token_expires_at = NULL, updated_at = $1 \ + WHERE id = $2 AND deleted_at IS NOT NULL", ) .bind(now) .bind(user_uid) @@ -199,14 +310,53 @@ impl UserService { .await .map_err(AppError::Database)?; if result.rows_affected() == 0 { - return Err(AppError::UserNotFound); + return Err(AppError::NotFound("user not found".into())); } txn.commit().await.map_err(|_| AppError::TxnError)?; - ctx.clear(); Ok(()) } + async fn send_restore_email(&self, email: &str, token: &str) -> Result<(), AppError> { + let app_url = self + .ctx + .config + .get_env::("APP_URL") + .ok() + .flatten() + .unwrap_or_else(|| "http://localhost:8000".to_string()); + let base = app_url.trim_end_matches('/'); + let restore_url = format!("{}/account/restore?token={}", base, token); + let mut mail = self + .ctx + .registry + .get_email_client() + .ok_or(AppError::Config("mail service not available".into()))?; + mail.send_email(tonic::Request::new(SendEmailRequest { + to: vec![EmailAddress { + email: email.to_string(), + name: String::new(), + }], + subject: "Account Deletion - Restore Link".into(), + text_body: format!( + "Your account has been marked for deletion.\n\n\ + If you did not request this, you can restore your account within 30 days \ + by visiting the following link:\n\n\ + {}\n\n\ + This link expires in 30 days. After that, your data will be retained but \ + the restore link will no longer work.", + restore_url, + ), + ..Default::default() + })) + .await + .map(|_| ()) + .map_err(|e| { + tracing::warn!(?e, "failed to send restore email"); + AppError::InternalServerError(e.to_string()) + }) + } + async fn ensure_username_available( &self, username: &str, diff --git a/service/user/appearance.rs b/service/user/appearance.rs index 4bdaac7..8340f76 100644 --- a/service/user/appearance.rs +++ b/service/user/appearance.rs @@ -85,9 +85,17 @@ impl UserService { .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; - self.find_user_appearance(user_uid) - .await? - .ok_or(AppError::UserNotFound) + // Read from writer to avoid replication lag + sqlx::query_as::<_, UserAppearance>( + "SELECT user_id, theme, color_scheme, density, font_size, editor_theme, \ + markdown_preview, reduced_motion, created_at, updated_at \ + FROM user_appearance WHERE user_id = $1", + ) + .bind(user_uid) + .fetch_optional(self.ctx.db.writer()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::UserNotFound) } async fn find_user_appearance( diff --git a/service/user/keys.rs b/service/user/keys.rs index 67e543e..3acb580 100644 --- a/service/user/keys.rs +++ b/service/user/keys.rs @@ -26,14 +26,23 @@ pub struct AddGpgKeyParams { } impl UserService { - pub async fn user_ssh_keys(&self, ctx: &Session) -> Result, AppError> { + pub async fn user_ssh_keys( + &self, + ctx: &Session, + limit: i64, + offset: i64, + ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let limit = limit.clamp(1, 100); + let offset = offset.max(0); sqlx::query_as::<_, UserSshKey>( "SELECT id, user_id, title, public_key, fingerprint_sha256, key_type, last_used_at, \ expires_at, revoked_at, created_at, updated_at FROM user_ssh_key \ - WHERE user_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC", + WHERE user_id = $1 AND revoked_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) @@ -98,14 +107,23 @@ impl UserService { ensure_affected(result.rows_affected(), "key not found") } - pub async fn user_gpg_keys(&self, ctx: &Session) -> Result, AppError> { + pub async fn user_gpg_keys( + &self, + ctx: &Session, + limit: i64, + offset: i64, + ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let limit = limit.clamp(1, 100); + let offset = offset.max(0); sqlx::query_as::<_, UserGpgKey>( "SELECT id, user_id, key_id, public_key, fingerprint, primary_email, expires_at, \ verified_at, revoked_at, created_at, updated_at FROM user_gpg_key \ - WHERE user_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC", + WHERE user_id = $1 AND revoked_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) diff --git a/service/user/mod.rs b/service/user/mod.rs index 37563c4..1c37528 100644 --- a/service/user/mod.rs +++ b/service/user/mod.rs @@ -4,4 +4,5 @@ pub mod keys; pub mod notify; pub mod profile; pub mod security; +pub mod social; pub mod util; diff --git a/service/user/notify.rs b/service/user/notify.rs index 1855284..cf1a0fc 100644 --- a/service/user/notify.rs +++ b/service/user/notify.rs @@ -100,9 +100,17 @@ impl UserService { .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; - self.find_user_notify_setting(user_uid) - .await? - .ok_or(AppError::UserNotFound) + // Read from writer to avoid replication lag + sqlx::query_as::<_, UserNotifySetting>( + "SELECT user_id, email_notifications, web_notifications, mention_notifications, \ + review_notifications, security_notifications, marketing_emails, digest_frequency, \ + created_at, updated_at FROM user_notify_setting WHERE user_id = $1", + ) + .bind(user_uid) + .fetch_optional(self.ctx.db.writer()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::UserNotFound) } async fn find_user_notify_setting( diff --git a/service/user/profile.rs b/service/user/profile.rs index b87cf46..610eb8d 100644 --- a/service/user/profile.rs +++ b/service/user/profile.rs @@ -68,9 +68,17 @@ impl UserService { .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; - self.find_user_profile(user_uid) - .await? - .ok_or(AppError::UserNotFound) + // Read from writer to avoid replication lag + sqlx::query_as::<_, UserProfile>( + "SELECT user_id, full_name, company, location, website_url, twitter_username, \ + timezone, language, profile_readme, created_at, updated_at \ + FROM user_profile WHERE user_id = $1", + ) + .bind(user_uid) + .fetch_optional(self.ctx.db.writer()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::UserNotFound) } async fn find_user_profile( diff --git a/service/user/security.rs b/service/user/security.rs index 8c1da22..06e7706 100644 --- a/service/user/security.rs +++ b/service/user/security.rs @@ -1,4 +1,5 @@ use chrono::{DateTime, Utc}; +use rand::RngCore; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -8,7 +9,7 @@ use crate::models::users::{UserDevice, UserSecurityLog}; use crate::service::UserService; use crate::session::Session; -use super::util::ensure_affected; +use super::util::{ensure_affected, sha256_hex}; #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct UserSessionInfo { @@ -81,14 +82,23 @@ struct UserPersonalAccessTokenRow { } impl UserService { - pub async fn user_devices(&self, ctx: &Session) -> Result, AppError> { + pub async fn user_devices( + &self, + ctx: &Session, + limit: i64, + offset: i64, + ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let limit = limit.clamp(1, 100); + let offset = offset.max(0); sqlx::query_as::<_, UserDevice>( "SELECT id, user_id, device_name, device_type, fingerprint, ip_address, user_agent, \ trusted, last_seen_at, created_at, updated_at FROM user_device \ - WHERE user_id = $1 ORDER BY last_seen_at DESC NULLS LAST, created_at DESC", + WHERE user_id = $1 ORDER BY last_seen_at DESC NULLS LAST, 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) @@ -137,27 +147,63 @@ impl UserService { session_uid: Uuid, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - let result = sqlx::query( - "UPDATE user_session SET revoked_at = $1 \ - WHERE id = $2 AND user_id = $3 AND revoked_at IS NULL", + + // Use transaction with SELECT FOR UPDATE to prevent race conditions + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + + let session = sqlx::query( + "SELECT id FROM user_session WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL FOR UPDATE", ) - .bind(chrono::Utc::now()) .bind(session_uid) .bind(user_uid) - .execute(self.ctx.db.writer()) + .fetch_optional(&mut *txn) .await .map_err(AppError::Database)?; - ensure_affected(result.rows_affected(), "session not found") + + if session.is_none() { + return Err(AppError::NotFound("session not found".into())); + } + + sqlx::query("UPDATE user_session SET revoked_at = $1 WHERE id = $2 AND user_id = $3") + .bind(chrono::Utc::now()) + .bind(session_uid) + .bind(user_uid) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + + // Also try to delete from Redis if this is a cookie session + // The session key might be stored as the session id in Redis + let _ = self.ctx.cache.delete(&session_uid.to_string()).await; + + Ok(()) } - pub async fn user_oauth_accounts(&self, ctx: &Session) -> Result, AppError> { + pub async fn user_oauth_accounts( + &self, + ctx: &Session, + limit: i64, + offset: i64, + ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let limit = limit.clamp(1, 100); + let offset = offset.max(0); let rows = sqlx::query_as::<_, UserOAuthRow>( "SELECT id, provider, provider_user_id, provider_username, provider_email, \ token_expires_at, linked_at, last_used_at FROM user_oauth \ - WHERE user_id = $1 ORDER BY linked_at DESC", + WHERE user_id = $1 ORDER BY linked_at DESC LIMIT $2 OFFSET $3", ) .bind(user_uid) + .bind(limit) + .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database)?; @@ -167,17 +213,27 @@ impl UserService { pub async fn user_unlink_oauth(&self, ctx: &Session, oauth_uid: Uuid) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + // Use transaction with SELECT FOR UPDATE to prevent race condition + // where concurrent unlink requests could remove the last login method + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + let has_password: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM user_password WHERE user_id = $1)") .bind(user_uid) - .fetch_one(self.ctx.db.reader()) + .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; let oauth_count: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM user_oauth WHERE user_id = $1") + sqlx::query_scalar("SELECT COUNT(*) FROM user_oauth WHERE user_id = $1 FOR UPDATE") .bind(user_uid) - .fetch_one(self.ctx.db.reader()) + .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; @@ -190,10 +246,16 @@ impl UserService { let result = sqlx::query("DELETE FROM user_oauth WHERE id = $1 AND user_id = $2") .bind(oauth_uid) .bind(user_uid) - .execute(self.ctx.db.writer()) + .execute(&mut *txn) .await .map_err(AppError::Database)?; - ensure_affected(result.rows_affected(), "oauth account not found") + + if result.rows_affected() == 0 { + return Err(AppError::NotFound("oauth account not found".into())); + } + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(()) } pub async fn user_security_logs( @@ -284,6 +346,66 @@ impl UserService { .map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "token not found") } + + pub async fn user_create_personal_access_token( + &self, + ctx: &Session, + params: CreatePersonalAccessTokenParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + + let mut raw_bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut raw_bytes); + let raw_token = raw_bytes + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + let token_hash = sha256_hex(raw_token.as_bytes()); + + let id = Uuid::now_v7(); + let now = chrono::Utc::now(); + + sqlx::query( + "INSERT INTO user_personal_access_token (id, user_id, name, token_hash, scopes, expires_at, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $7)", + ) + .bind(id) + .bind(user_uid) + .bind(¶ms.name) + .bind(&token_hash) + .bind(¶ms.scopes) + .bind(params.expires_at) + .bind(now) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + + Ok(CreatePersonalAccessTokenResponse { + id, + name: params.name, + scopes: params.scopes, + token: raw_token, + expires_at: params.expires_at, + created_at: now, + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CreatePersonalAccessTokenParams { + pub name: String, + pub scopes: Vec, + pub expires_at: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CreatePersonalAccessTokenResponse { + pub id: Uuid, + pub name: String, + pub scopes: Vec, + pub token: String, + pub expires_at: Option>, + pub created_at: DateTime, } impl From for UserSessionInfo { diff --git a/service/user/social.rs b/service/user/social.rs new file mode 100644 index 0000000..54ad1cc --- /dev/null +++ b/service/user/social.rs @@ -0,0 +1,226 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::AppError; +use crate::models::common::{DeviceType, PresenceStatus}; +use crate::models::users::{UserBlock, UserFollow, UserPresence}; +use crate::service::UserService; +use crate::session::Session; + +use super::util::ensure_affected; + +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct UpdatePresenceParams { + pub status: PresenceStatus, + pub custom_status_text: Option, + pub custom_status_emoji: Option, + pub device_type: Option, + pub ip_address: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +struct UserPresenceRow { + id: Uuid, + user_id: Uuid, + status: PresenceStatus, + custom_status_text: Option, + custom_status_emoji: Option, + device_type: Option, + ip_address: Option, + last_active_at: DateTime, + last_seen_at: Option>, + created_at: DateTime, + updated_at: DateTime, +} + +impl From for UserPresence { + fn from(row: UserPresenceRow) -> Self { + Self { + id: row.id, + user_id: row.user_id, + status: row.status, + custom_status_text: row.custom_status_text, + custom_status_emoji: row.custom_status_emoji, + device_type: row.device_type, + ip_address: row.ip_address, + last_active_at: row.last_active_at, + last_seen_at: row.last_seen_at, + created_at: row.created_at, + updated_at: row.updated_at, + } + } +} + +impl UserService { + pub async fn user_presence_get(&self, session: &Session) -> Result { + let user_uid = session.user().ok_or(AppError::Unauthorized)?; + let row = sqlx::query_as::<_, UserPresenceRow>( + "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_uid) + .fetch_optional(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + row.map(Into::into) + .ok_or_else(|| AppError::NotFound("presence not found".into())) + } + + pub async fn user_presence_update( + &self, + session: &Session, + params: UpdatePresenceParams, + ) -> Result { + let user_uid = session.user().ok_or(AppError::Unauthorized)?; + let id = Uuid::now_v7(); + let now = chrono::Utc::now(); + + let row = sqlx::query_as::<_, UserPresenceRow>( + "INSERT INTO user_presence (id, user_id, status, custom_status_text, custom_status_emoji, \ + device_type, ip_address, last_active_at, last_seen_at, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL, $9, $9) \ + ON CONFLICT (user_id) DO UPDATE SET \ + status = $3, custom_status_text = $4, custom_status_emoji = $5, \ + device_type = $6, ip_address = $7, last_active_at = $8, updated_at = $9 \ + 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(id) + .bind(user_uid) + .bind(¶ms.status) + .bind(¶ms.custom_status_text) + .bind(¶ms.custom_status_emoji) + .bind(¶ms.device_type) + .bind(¶ms.ip_address) + .bind(now) + .bind(now) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + Ok(row.into()) + } + + pub async fn user_blocks_list( + &self, + session: &Session, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = session.user().ok_or(AppError::Unauthorized)?; + let limit = limit.clamp(1, 100); + let offset = offset.max(0); + sqlx::query_as::<_, UserBlock>( + "SELECT blocker_id, blocked_id, reason, created_at \ + FROM user_block WHERE blocker_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 user_block_create( + &self, + session: &Session, + target_user_id: Uuid, + reason: Option, + ) -> Result { + let user_uid = session.user().ok_or(AppError::Unauthorized)?; + if user_uid == target_user_id { + return Err(AppError::BadRequest("cannot block yourself".into())); + } + let now = chrono::Utc::now(); + sqlx::query_as::<_, UserBlock>( + "INSERT INTO user_block (blocker_id, blocked_id, reason, created_at) \ + VALUES ($1, $2, $3, $4) \ + RETURNING blocker_id, blocked_id, reason, created_at", + ) + .bind(user_uid) + .bind(target_user_id) + .bind(&reason) + .bind(now) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database) + } + + pub async fn user_block_delete( + &self, + session: &Session, + target_user_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = session.user().ok_or(AppError::Unauthorized)?; + let result = + sqlx::query("DELETE FROM user_block WHERE blocker_id = $1 AND blocked_id = $2") + .bind(user_uid) + .bind(target_user_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "block not found") + } + + pub async fn user_follows_list( + &self, + session: &Session, + limit: i64, + offset: i64, + ) -> Result, AppError> { + let user_uid = session.user().ok_or(AppError::Unauthorized)?; + let limit = limit.clamp(1, 100); + let offset = offset.max(0); + sqlx::query_as::<_, UserFollow>( + "SELECT follower_id, following_id, created_at \ + FROM user_follow WHERE follower_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 user_follow_create( + &self, + session: &Session, + target_user_id: Uuid, + ) -> Result { + let user_uid = session.user().ok_or(AppError::Unauthorized)?; + if user_uid == target_user_id { + return Err(AppError::BadRequest("cannot follow yourself".into())); + } + let now = chrono::Utc::now(); + sqlx::query_as::<_, UserFollow>( + "INSERT INTO user_follow (follower_id, following_id, created_at) \ + VALUES ($1, $2, $3) \ + RETURNING follower_id, following_id, created_at", + ) + .bind(user_uid) + .bind(target_user_id) + .bind(now) + .fetch_one(self.ctx.db.writer()) + .await + .map_err(AppError::Database) + } + + pub async fn user_follow_delete( + &self, + session: &Session, + target_user_id: Uuid, + ) -> Result<(), AppError> { + let user_uid = session.user().ok_or(AppError::Unauthorized)?; + let result = + sqlx::query("DELETE FROM user_follow WHERE follower_id = $1 AND following_id = $2") + .bind(user_uid) + .bind(target_user_id) + .execute(self.ctx.db.writer()) + .await + .map_err(AppError::Database)?; + ensure_affected(result.rows_affected(), "follow not found") + } +} diff --git a/service/util.rs b/service/util.rs index 5183b09..59052f1 100644 --- a/service/util.rs +++ b/service/util.rs @@ -1,5 +1,6 @@ use crate::error::AppError; use crate::models::common::Role; +use uuid::Uuid; pub fn merge_optional_text(next: Option, current: Option) -> Option { next.map(|v| { @@ -152,3 +153,12 @@ pub fn validate_password_strength(password: &str) -> Result<(), AppError> { } Ok(()) } + +/// Build a `SET LOCAL app.current_user_id` statement with the UUID interpolated +/// directly. PostgreSQL `SET` is a utility command that does not support +/// parameterised `$1` placeholders through the extended query protocol. +/// +/// Returns an `AssertSqlSafe` wrapper so sqlx 0.9 accepts the dynamic string. +pub fn set_local_user_id(user_uid: Uuid) -> sqlx::AssertSqlSafe { + sqlx::AssertSqlSafe(format!("SET LOCAL app.current_user_id = '{user_uid}'")) +} diff --git a/service/wiki/core.rs b/service/wiki/core.rs index 8aea81d..ac7fe07 100644 --- a/service/wiki/core.rs +++ b/service/wiki/core.rs @@ -6,7 +6,7 @@ use crate::models::wiki::WikiPage; use crate::service::RepoService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected, required_text}; +use super::util::{clamp_limit_offset, ensure_affected, required_text, set_local_user_id}; #[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreateWikiPageParams { @@ -120,8 +120,7 @@ impl RepoService { .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)?; @@ -190,15 +189,14 @@ impl RepoService { .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)?; let updated = sqlx::query_as::<_, WikiPage>( "UPDATE wiki_page SET title = $1, content = $2, last_editor_id = $3, version = $4, updated_at = $5 \ - WHERE id = $6 AND deleted_at IS NULL \ + WHERE id = $6 AND deleted_at IS NULL AND version = $7 \ RETURNING id, repo_id, slug, title, content, author_id, last_editor_id, version, created_at, updated_at, deleted_at", ) .bind(&new_title) @@ -207,9 +205,11 @@ impl RepoService { .bind(new_version) .bind(now) .bind(page.id) - .fetch_one(&mut *txn) + .bind(page.version) + .fetch_optional(&mut *txn) .await - .map_err(AppError::Database)?; + .map_err(AppError::Database)? + .ok_or(AppError::Conflict("page was modified concurrently; please refresh and try again".into()))?; sqlx::query( "INSERT INTO wiki_page_revision (id, page_id, version, title, content, editor_id, commit_message, created_at) \ @@ -305,15 +305,28 @@ impl RepoService { } fn generate_slug(title: &str) -> String { - title + let slug: String = title .to_lowercase() .chars() - .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .map(|c| { + if c.is_alphanumeric() || c.is_ascii_alphanumeric() { + c + } else { + '-' + } + }) .collect::() .split('-') .filter(|s| !s.is_empty()) .collect::>() - .join("-") + .join("-"); + + // If slug is empty (e.g., all non-ASCII characters), generate a fallback + if slug.is_empty() { + format!("page-{}", uuid::Uuid::now_v7().as_simple()) + } else { + slug + } } async fn generate_wiki_slug(&self, repo_id: Uuid, title: &str) -> Result { diff --git a/service/wiki/util.rs b/service/wiki/util.rs index 967b48f..d1b0c03 100644 --- a/service/wiki/util.rs +++ b/service/wiki/util.rs @@ -1 +1,3 @@ -pub use crate::service::util::{clamp_limit_offset, ensure_affected, required_text}; +pub use crate::service::util::{ + clamp_limit_offset, ensure_affected, required_text, set_local_user_id, +}; diff --git a/service/workspace/approvals.rs b/service/workspace/approvals.rs index 745a508..4762a11 100644 --- a/service/workspace/approvals.rs +++ b/service/workspace/approvals.rs @@ -7,7 +7,7 @@ use crate::models::workspaces::{Workspace, WorkspacePendingApproval}; use crate::service::WorkspaceService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected, parse_enum}; +use super::util::{clamp_limit_offset, ensure_affected, parse_enum, set_local_user_id}; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct RequestApprovalParams { @@ -64,8 +64,7 @@ impl WorkspaceService { .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)?; @@ -116,8 +115,7 @@ impl WorkspaceService { .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)?; diff --git a/service/workspace/billing.rs b/service/workspace/billing.rs index 42b1934..4e6e762 100644 --- a/service/workspace/billing.rs +++ b/service/workspace/billing.rs @@ -7,7 +7,7 @@ use crate::models::workspaces::{Workspace, WorkspaceBilling}; use crate::service::WorkspaceService; use crate::session::Session; -use super::util::merge_optional_text; +use super::util::{merge_optional_text, set_local_user_id}; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct UpdateBillingParams { @@ -51,8 +51,7 @@ impl WorkspaceService { .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)?; diff --git a/service/workspace/branding.rs b/service/workspace/branding.rs index 48ccb17..002e1bd 100644 --- a/service/workspace/branding.rs +++ b/service/workspace/branding.rs @@ -7,7 +7,7 @@ use crate::models::workspaces::{Workspace, WorkspaceCustomBranding}; use crate::service::WorkspaceService; use crate::session::Session; -use super::util::merge_optional_text; +use super::util::{merge_optional_text, set_local_user_id}; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct UpdateBrandingParams { @@ -52,8 +52,7 @@ impl WorkspaceService { .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)?; diff --git a/service/workspace/core.rs b/service/workspace/core.rs index f0f1c03..a770b75 100644 --- a/service/workspace/core.rs +++ b/service/workspace/core.rs @@ -7,7 +7,9 @@ use crate::models::workspaces::Workspace; use crate::service::WorkspaceService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum}; +use super::util::{ + clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, set_local_user_id, +}; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreateWorkspaceParams { @@ -71,6 +73,20 @@ impl WorkspaceService { return Err(AppError::BadRequest("name is required".into())); } + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM workspace WHERE lower(name) = lower($1) AND deleted_at IS NULL)", + ) + .bind(&name) + .fetch_one(self.ctx.db.reader()) + .await + .map_err(AppError::Database)?; + if exists { + return Err(AppError::Conflict(format!( + "workspace name '{}' is already taken", + name + ))); + } + let visibility = match params.visibility { Some(ref v) => parse_enum( Some(v.clone()), @@ -91,8 +107,7 @@ impl WorkspaceService { .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)?; @@ -219,8 +234,7 @@ impl WorkspaceService { .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)?; @@ -259,8 +273,7 @@ impl WorkspaceService { .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)?; @@ -296,8 +309,7 @@ impl WorkspaceService { .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)?; @@ -333,8 +345,7 @@ impl WorkspaceService { .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)?; @@ -354,6 +365,59 @@ impl WorkspaceService { Ok(()) } + pub async fn workspace_restore( + &self, + ctx: &Session, + workspace_name: &str, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query(set_local_user_id(user_uid)) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let ws = sqlx::query_as::<_, Workspace>( + "SELECT id, owner_id, name, description, avatar_url, visibility, plan, status, \ + default_role, is_personal, archived_at, created_at, updated_at, deleted_at \ + FROM workspace WHERE lower(name) = lower($1) AND deleted_at IS NOT NULL FOR UPDATE", + ) + .bind(workspace_name) + .fetch_optional(&mut *txn) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("workspace not found".into()))?; + + if ws.owner_id != user_uid { + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner) + .await?; + } + + let result = sqlx::query_as::<_, Workspace>( + "UPDATE workspace SET deleted_at = NULL, status = 'active', updated_at = $1 \ + WHERE id = $2 AND deleted_at IS NOT NULL \ + RETURNING id, owner_id, name, description, avatar_url, visibility, plan, status, \ + default_role, is_personal, archived_at, created_at, updated_at, deleted_at", + ) + .bind(now) + .bind(ws.id) + .fetch_optional(&mut *txn) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("workspace not found".into()))?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + pub async fn workspace_transfer_owner( &self, ctx: &Session, @@ -361,28 +425,12 @@ impl WorkspaceService { new_owner_id: Uuid, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner) - .await?; if new_owner_id == ws.owner_id { return Err(AppError::BadRequest( "new owner must be different from current owner".into(), )); } - let is_member = sqlx::query_scalar::<_, bool>( - "SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active')", - ) - .bind(ws.id) - .bind(new_owner_id) - .fetch_one(self.ctx.db.reader()) - .await - .map_err(AppError::Database)?; - - if !is_member { - return Err(AppError::BadRequest( - "new owner must be an active member".into(), - )); - } let now = chrono::Utc::now(); let mut txn = self @@ -392,12 +440,43 @@ impl WorkspaceService { .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)?; + // Lock the workspace row to prevent concurrent transfers + let _ws = sqlx::query_as::<_, Workspace>( + "SELECT id, owner_id, name, description, avatar_url, visibility, plan, status, \ + default_role, is_personal, archived_at, created_at, updated_at, deleted_at \ + FROM workspace WHERE id = $1 AND deleted_at IS NULL FOR UPDATE", + ) + .bind(ws.id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + // Verify current user is still the owner + if _ws.owner_id != user_uid { + return Err(AppError::Unauthorized); + } + + // Verify new owner is an active member (within transaction) + let is_member = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active')", + ) + .bind(ws.id) + .bind(new_owner_id) + .fetch_one(&mut *txn) + .await + .map_err(AppError::Database)?; + + if !is_member { + return Err(AppError::BadRequest( + "new owner must be an active member".into(), + )); + } + sqlx::query( "UPDATE workspace_member SET role = 'owner', updated_at = $1 \ WHERE workspace_id = $2 AND user_id = $3", @@ -467,8 +546,7 @@ impl WorkspaceService { .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)?; diff --git a/service/workspace/domains.rs b/service/workspace/domains.rs index ed88ca9..d6c8be9 100644 --- a/service/workspace/domains.rs +++ b/service/workspace/domains.rs @@ -7,13 +7,18 @@ use crate::models::workspaces::{Workspace, WorkspaceDomain}; use crate::service::WorkspaceService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected, required_text}; +use super::util::{clamp_limit_offset, ensure_affected, required_text, set_local_user_id}; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct AddDomainParams { pub domain: String, } +#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] +pub struct UpdateDomainParams { + pub domain: String, +} + impl WorkspaceService { pub async fn workspace_domains( &self, @@ -70,8 +75,7 @@ impl WorkspaceService { .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)?; @@ -113,8 +117,7 @@ impl WorkspaceService { .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)?; @@ -156,8 +159,7 @@ impl WorkspaceService { .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)?; @@ -229,8 +231,7 @@ impl WorkspaceService { .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)?; @@ -248,6 +249,49 @@ impl WorkspaceService { Ok(()) } + pub async fn workspace_update_domain( + &self, + ctx: &Session, + ws: &Workspace, + domain_id: Uuid, + params: UpdateDomainParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin) + .await?; + let domain = required_text(params.domain, "domain")?.to_lowercase(); + + let now = chrono::Utc::now(); + let mut txn = self + .ctx + .db + .writer() + .begin() + .await + .map_err(|_| AppError::TxnError)?; + sqlx::query(set_local_user_id(user_uid)) + .execute(&mut *txn) + .await + .map_err(AppError::Database)?; + + let result = sqlx::query_as::<_, WorkspaceDomain>( + "UPDATE workspace_domain SET domain = $1, is_verified = false, verified_at = NULL, updated_at = $2 \ + WHERE id = $3 AND workspace_id = $4 \ + RETURNING id, workspace_id, domain, verification_token_hash, is_primary, is_verified, verified_at, created_at, updated_at", + ) + .bind(&domain) + .bind(now) + .bind(domain_id) + .bind(ws.id) + .fetch_optional(&mut *txn) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("domain not found".into()))?; + + txn.commit().await.map_err(|_| AppError::TxnError)?; + Ok(result) + } + fn generate_domain_verification_token() -> String { (0..32) .map(|_| format!("{:02x}", rand::random::())) diff --git a/service/workspace/integrations.rs b/service/workspace/integrations.rs index 39b78fe..bea7e30 100644 --- a/service/workspace/integrations.rs +++ b/service/workspace/integrations.rs @@ -7,7 +7,7 @@ use crate::models::workspaces::{Workspace, WorkspaceIntegration}; use crate::service::WorkspaceService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected, required_text}; +use super::util::{clamp_limit_offset, ensure_affected, required_text, set_local_user_id}; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreateIntegrationParams { @@ -79,8 +79,7 @@ impl WorkspaceService { .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)?; @@ -146,8 +145,7 @@ impl WorkspaceService { .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)?; @@ -195,8 +193,7 @@ impl WorkspaceService { .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)?; diff --git a/service/workspace/invitations.rs b/service/workspace/invitations.rs index f4de353..83537e8 100644 --- a/service/workspace/invitations.rs +++ b/service/workspace/invitations.rs @@ -8,7 +8,7 @@ use crate::pb::email::{EmailAddress, SendEmailRequest}; use crate::service::WorkspaceService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected, role_level}; +use super::util::{clamp_limit_offset, ensure_affected, role_level, set_local_user_id}; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreateInvitationParams { @@ -108,8 +108,7 @@ impl WorkspaceService { .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)?; @@ -179,8 +178,7 @@ impl WorkspaceService { .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)?; @@ -262,8 +260,7 @@ impl WorkspaceService { .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)?; diff --git a/service/workspace/members.rs b/service/workspace/members.rs index 878b9c7..9225c76 100644 --- a/service/workspace/members.rs +++ b/service/workspace/members.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; -use super::util::{clamp_limit_offset, ensure_affected, role_level}; +use super::util::{clamp_limit_offset, ensure_affected, role_level, set_local_user_id}; use crate::error::AppError; use crate::models::common::Role; use crate::models::workspaces::{Workspace, WorkspaceMember}; @@ -108,8 +108,7 @@ impl WorkspaceService { .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)?; @@ -204,8 +203,7 @@ impl WorkspaceService { .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)?; @@ -268,8 +266,7 @@ impl WorkspaceService { .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)?; @@ -313,8 +310,7 @@ impl WorkspaceService { .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)?; diff --git a/service/workspace/settings.rs b/service/workspace/settings.rs index fb4431e..09cbd5e 100644 --- a/service/workspace/settings.rs +++ b/service/workspace/settings.rs @@ -6,7 +6,7 @@ use crate::models::workspaces::{Workspace, WorkspaceSettings}; use crate::service::WorkspaceService; use crate::session::Session; -use super::util::merge_optional_text; +use super::util::{merge_optional_text, set_local_user_id}; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct UpdateWorkspaceSettingsParams { @@ -50,8 +50,7 @@ impl WorkspaceService { .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)?; @@ -103,9 +102,18 @@ impl WorkspaceService { .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; - self.find_workspace_settings(workspace_id) - .await? - .ok_or(AppError::NotFound("workspace settings not found".into())) + // Read from writer to avoid replication lag + sqlx::query_as::<_, WorkspaceSettings>( + "SELECT workspace_id, allow_public_repos, allow_member_invites, require_two_factor, \ + default_repo_visibility, default_branch_name, issue_tracking_enabled, \ + pull_requests_enabled, wiki_enabled, created_at, updated_at \ + FROM workspace_settings WHERE workspace_id = $1", + ) + .bind(workspace_id) + .fetch_optional(self.ctx.db.writer()) + .await + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("workspace settings not found".into())) } async fn find_workspace_settings( diff --git a/service/workspace/stats.rs b/service/workspace/stats.rs index 38a4f92..6de767c 100644 --- a/service/workspace/stats.rs +++ b/service/workspace/stats.rs @@ -43,7 +43,7 @@ impl WorkspaceService { .map_err(AppError::Database)?; let issues_count = sqlx::query_scalar::<_, i64>( - "SELECT COUNT(*) FROM issue WHERE repo_id IN (SELECT id FROM repo WHERE workspace_id = $1 AND deleted_at IS NULL) AND deleted_at IS NULL", + "SELECT COUNT(*) FROM issue WHERE workspace_id = $1 AND deleted_at IS NULL", ) .bind(ws.id) .fetch_one(self.ctx.db.reader()) @@ -102,14 +102,16 @@ impl WorkspaceService { .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; + // Read from writer to avoid replication lag sqlx::query_as::<_, WorkspaceStats>( "SELECT workspace_id, members_count, repos_count, issues_count, pull_requests_count, \ storage_bytes, bandwidth_bytes, build_minutes_used, last_activity_at, updated_at \ FROM workspace_stats WHERE workspace_id = $1", ) .bind(workspace_id) - .fetch_one(self.ctx.db.reader()) + .fetch_optional(self.ctx.db.writer()) .await - .map_err(AppError::Database) + .map_err(AppError::Database)? + .ok_or(AppError::NotFound("workspace stats not found".into())) } } diff --git a/service/workspace/util.rs b/service/workspace/util.rs index a83877c..942794c 100644 --- a/service/workspace/util.rs +++ b/service/workspace/util.rs @@ -1,3 +1,4 @@ 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, }; diff --git a/service/workspace/webhooks.rs b/service/workspace/webhooks.rs index c0e3bd9..4b16fed 100644 --- a/service/workspace/webhooks.rs +++ b/service/workspace/webhooks.rs @@ -9,7 +9,7 @@ use crate::models::workspaces::{Workspace, WorkspaceWebhook}; use crate::service::WorkspaceService; use crate::session::Session; -use super::util::{clamp_limit_offset, ensure_affected, required_text}; +use super::util::{clamp_limit_offset, ensure_affected, required_text, set_local_user_id}; fn validate_webhook_url(url_str: &str) -> Result<(), AppError> { let url = Url::parse(url_str).map_err(|_| AppError::BadRequest("Invalid URL format".into()))?; @@ -114,8 +114,7 @@ impl WorkspaceService { .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)?; @@ -186,8 +185,7 @@ impl WorkspaceService { .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)?; @@ -230,8 +228,7 @@ impl WorkspaceService { .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)?;