feat(service): expand service layer with new domain operations
- Add IM service modules: audit, channel roles, custom emojis, forum tags, integrations, invitations, repo links, slash commands, stages, voice, webhooks - Add PR service modules: review requests, templates - Add repo service modules: contributors, release assets, git extras (archive, branch rename, commit extras, diff/merge, tag, tree) - Add user service: social (follow/block) - Add internal auth service - Update existing service modules with expanded functionality - Remove deleted IM modules: articles, delivery trace, drafts, follows, messages, polls, presence, reactions, threads
This commit is contained in:
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-2
@@ -35,6 +35,7 @@ struct PendingEmailChange {
|
|||||||
impl AuthService {
|
impl AuthService {
|
||||||
const EMAIL_CHANGE_PREFIX: &'static str = "auth:email_change:";
|
const EMAIL_CHANGE_PREFIX: &'static str = "auth:email_change:";
|
||||||
const EMAIL_CHANGE_TTL_SECS: u64 = 60 * 60;
|
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<EmailResponse, AppError> {
|
pub async fn auth_get_email(&self, ctx: &Session) -> Result<EmailResponse, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||||
@@ -63,11 +64,20 @@ impl AuthService {
|
|||||||
if new_email.is_empty() {
|
if new_email.is_empty() {
|
||||||
return Err(AppError::BadRequest("email is required".into()));
|
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 password = self.auth_rsa_decode(ctx, params.password).await?;
|
||||||
|
|
||||||
let row = sqlx::query("SELECT password_hash FROM user_password WHERE user_id = $1")
|
let row = sqlx::query("SELECT password_hash FROM user_password WHERE user_id = $1")
|
||||||
.bind(user_uid)
|
.bind(user_uid)
|
||||||
.fetch_optional(self.ctx.db.reader())
|
.fetch_optional(self.ctx.db.writer())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?
|
.map_err(AppError::Database)?
|
||||||
.ok_or(AppError::UserNotFound)?;
|
.ok_or(AppError::UserNotFound)?;
|
||||||
@@ -103,6 +113,7 @@ impl AuthService {
|
|||||||
},
|
},
|
||||||
Some(Duration::from_secs(Self::EMAIL_CHANGE_TTL_SECS)),
|
Some(Duration::from_secs(Self::EMAIL_CHANGE_TTL_SECS)),
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||||
|
|
||||||
let domain = self.ctx.config.main_domain()?;
|
let domain = self.ctx.config.main_domain()?;
|
||||||
@@ -133,6 +144,16 @@ impl AuthService {
|
|||||||
AppError::InternalServerError(e.to_string())
|
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");
|
tracing::info!(new_email = %new_email, user_uid = %user_uid, "Email change verification sent");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -148,6 +169,7 @@ impl AuthService {
|
|||||||
self.ctx
|
self.ctx
|
||||||
.cache
|
.cache
|
||||||
.get::<PendingEmailChange>(&cache_key)
|
.get::<PendingEmailChange>(&cache_key)
|
||||||
|
.await
|
||||||
.ok_or(AppError::NotFound(
|
.ok_or(AppError::NotFound(
|
||||||
"invalid or expired email verification token".into(),
|
"invalid or expired email verification token".into(),
|
||||||
))?;
|
))?;
|
||||||
@@ -195,7 +217,7 @@ impl AuthService {
|
|||||||
|
|
||||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
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");
|
tracing::info!(new_email = %pending.new_email, user_uid = %pending.user_uid, "Email changed");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ impl AuthService {
|
|||||||
if let Some(user_uid) = context.user() {
|
if let Some(user_uid) = context.user() {
|
||||||
tracing::info!(user_uid = %user_uid, "User logged out");
|
tracing::info!(user_uid = %user_uid, "User logged out");
|
||||||
}
|
}
|
||||||
context.clear_user();
|
context.purge();
|
||||||
context.clear();
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod captcha;
|
pub mod captcha;
|
||||||
|
pub mod change_password;
|
||||||
pub mod email;
|
pub mod email;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod logout;
|
pub mod logout;
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ impl AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cooldown_key = format!("{}cooldown:{}", Self::REGISTER_EMAIL_CODE_PREFIX, email);
|
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(
|
return Err(AppError::BadRequest(
|
||||||
"verification code was sent recently; please try again later".into(),
|
"verification code was sent recently; please try again later".into(),
|
||||||
));
|
));
|
||||||
@@ -66,6 +66,7 @@ impl AuthService {
|
|||||||
&code,
|
&code,
|
||||||
Some(Duration::from_secs(Self::REGISTER_EMAIL_CODE_TTL_SECS)),
|
Some(Duration::from_secs(Self::REGISTER_EMAIL_CODE_TTL_SECS)),
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||||
self.ctx
|
self.ctx
|
||||||
.cache
|
.cache
|
||||||
@@ -74,6 +75,7 @@ impl AuthService {
|
|||||||
&true,
|
&true,
|
||||||
Some(Duration::from_secs(Self::REGISTER_EMAIL_CODE_COOLDOWN_SECS)),
|
Some(Duration::from_secs(Self::REGISTER_EMAIL_CODE_COOLDOWN_SECS)),
|
||||||
)
|
)
|
||||||
|
.await
|
||||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||||
|
|
||||||
let mut mail = self
|
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 cache_key = Self::register_email_code_key(email);
|
||||||
let stored = self
|
let stored = self
|
||||||
.ctx
|
.ctx
|
||||||
.cache
|
.cache
|
||||||
.get::<String>(&cache_key)
|
.get::<String>(&cache_key)
|
||||||
|
.await
|
||||||
.ok_or(AppError::InvalidEmailCode)?;
|
.ok_or(AppError::InvalidEmailCode)?;
|
||||||
if !crate::service::util::constant_time_eq(stored.trim(), code.trim()) {
|
if !crate::service::util::constant_time_eq(stored.trim(), code.trim()) {
|
||||||
return Err(AppError::InvalidEmailCode);
|
return Err(AppError::InvalidEmailCode);
|
||||||
}
|
}
|
||||||
let _ = self.ctx.cache.delete(&cache_key);
|
let _ = self.ctx.cache.delete(&cache_key).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +174,7 @@ impl AuthService {
|
|||||||
return Err(AppError::AccountAlreadyExists);
|
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 user_id = uuid::Uuid::now_v7();
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
@@ -230,6 +233,7 @@ impl AuthService {
|
|||||||
|
|
||||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||||
|
|
||||||
|
context.renew();
|
||||||
context.set_user(user_id);
|
context.set_user(user_id);
|
||||||
context.remove(Self::RSA_PRIVATE_KEY);
|
context.remove(Self::RSA_PRIVATE_KEY);
|
||||||
context.remove(Self::RSA_PUBLIC_KEY);
|
context.remove(Self::RSA_PUBLIC_KEY);
|
||||||
|
|||||||
+26
-24
@@ -41,14 +41,14 @@ impl AuthService {
|
|||||||
|
|
||||||
// Rate limiting: check cooldown
|
// Rate limiting: check cooldown
|
||||||
let cooldown_key = format!("{}cooldown:{}", Self::RESET_PASS_PREFIX, email);
|
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)");
|
tracing::warn!(email = %email, "Password reset request rate limited (cooldown)");
|
||||||
return Ok(()); // Don't reveal if email exists
|
return Ok(()); // Don't reveal if email exists
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limiting: check daily limit
|
// Rate limiting: check daily limit
|
||||||
let daily_key = format!("{}daily:{}", Self::RESET_PASS_PREFIX, email);
|
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 {
|
if daily_count >= Self::RESET_PASS_DAILY_LIMIT {
|
||||||
tracing::warn!(email = %email, count = daily_count, "Password reset request rate limited (daily limit)");
|
tracing::warn!(email = %email, count = daily_count, "Password reset request rate limited (daily limit)");
|
||||||
return Ok(()); // Don't reveal if email exists
|
return Ok(()); // Don't reveal if email exists
|
||||||
@@ -68,30 +68,11 @@ impl AuthService {
|
|||||||
created_at: now,
|
created_at: now,
|
||||||
},
|
},
|
||||||
Some(StdDuration::from_secs(Self::RESET_PASS_EXPIRY_SECS)),
|
Some(StdDuration::from_secs(Self::RESET_PASS_EXPIRY_SECS)),
|
||||||
) {
|
).await {
|
||||||
tracing::error!(error = %e, user_uid = %user.id, "Failed to cache reset token");
|
tracing::error!(error = %e, user_uid = %user.id, "Failed to cache reset token");
|
||||||
return Ok(());
|
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() {
|
let domain = match self.ctx.config.main_domain() {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -126,6 +107,26 @@ impl AuthService {
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
tracing::error!(error = %e, email = %email, "Failed to send password reset email");
|
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");
|
tracing::info!(email = %email, user_uid = %user.id, "Password reset email sent");
|
||||||
@@ -148,16 +149,17 @@ impl AuthService {
|
|||||||
.ctx
|
.ctx
|
||||||
.cache
|
.cache
|
||||||
.get::<PendingResetPassword>(&cache_key)
|
.get::<PendingResetPassword>(&cache_key)
|
||||||
|
.await
|
||||||
.ok_or(AppError::InvalidResetToken)?;
|
.ok_or(AppError::InvalidResetToken)?;
|
||||||
|
|
||||||
if Utc::now() - pending.created_at > Duration::hours(Self::RESET_PASS_EXPIRY_HOURS) {
|
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);
|
return Err(AppError::ResetTokenExpired);
|
||||||
}
|
}
|
||||||
|
|
||||||
let password = self.auth_rsa_decode(context, params.password).await?;
|
let password = self.auth_rsa_decode(context, params.password).await?;
|
||||||
crate::service::util::validate_password_strength(&password)?;
|
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 salt = SaltString::generate(&mut rand::thread_rng());
|
||||||
let password_hash = Argon2::default()
|
let password_hash = Argon2::default()
|
||||||
|
|||||||
+11
-19
@@ -192,13 +192,13 @@ impl AuthService {
|
|||||||
let Some(totp_key) = context.get::<String>(Self::TOTP_KEY).ok().flatten() else {
|
let Some(totp_key) = context.get::<String>(Self::TOTP_KEY).ok().flatten() else {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
let Some(user_uid) = self.ctx.cache.get::<Uuid>(&totp_key) else {
|
let Some(user_uid) = self.ctx.cache.get::<Uuid>(&totp_key).await else {
|
||||||
context.remove(Self::TOTP_KEY);
|
context.remove(Self::TOTP_KEY);
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
if user_uid != expected_user_uid {
|
if user_uid != expected_user_uid {
|
||||||
context.remove(Self::TOTP_KEY);
|
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");
|
tracing::warn!(expected_user_uid = %expected_user_uid, pending_user_uid = %user_uid, "2FA pending user mismatch");
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
@@ -206,7 +206,7 @@ impl AuthService {
|
|||||||
let verified = self.auth_2fa_verify(user_uid, code).await?;
|
let verified = self.auth_2fa_verify(user_uid, code).await?;
|
||||||
if verified {
|
if verified {
|
||||||
context.remove(Self::TOTP_KEY);
|
context.remove(Self::TOTP_KEY);
|
||||||
let _ = self.ctx.cache.delete(&totp_key);
|
let _ = self.ctx.cache.delete(&totp_key).await;
|
||||||
}
|
}
|
||||||
Ok(verified)
|
Ok(verified)
|
||||||
}
|
}
|
||||||
@@ -349,7 +349,7 @@ impl AuthService {
|
|||||||
async fn verify_user_password(&self, user_uid: Uuid, password: &str) -> Result<(), AppError> {
|
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")
|
let row = sqlx::query("SELECT password_hash FROM user_password WHERE user_id = $1")
|
||||||
.bind(user_uid)
|
.bind(user_uid)
|
||||||
.fetch_optional(self.ctx.db.reader())
|
.fetch_optional(self.ctx.db.writer())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?
|
.map_err(AppError::Database)?
|
||||||
.ok_or(AppError::UserNotFound)?;
|
.ok_or(AppError::UserNotFound)?;
|
||||||
@@ -368,7 +368,7 @@ impl AuthService {
|
|||||||
FROM user_2fa WHERE user_id = $1",
|
FROM user_2fa WHERE user_id = $1",
|
||||||
)
|
)
|
||||||
.bind(user_uid)
|
.bind(user_uid)
|
||||||
.fetch_optional(self.ctx.db.reader())
|
.fetch_optional(self.ctx.db.writer())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)
|
.map_err(AppError::Database)
|
||||||
}
|
}
|
||||||
@@ -384,26 +384,18 @@ impl AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let hashed_code = self.hash_backup_code(code)?;
|
let hashed_code = self.hash_backup_code(code)?;
|
||||||
let mut backup_codes: Vec<String> = two_fa
|
let result = sqlx::query(
|
||||||
.backup_codes
|
"UPDATE user_2fa SET backup_codes = regexp_replace(backup_codes, $1, ''), updated_at = $2 \
|
||||||
.split('.')
|
WHERE user_id = $3 AND backup_codes LIKE '%' || $1 || '%'",
|
||||||
.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(&hashed_code)
|
||||||
.bind(chrono::Utc::now())
|
.bind(chrono::Utc::now())
|
||||||
.bind(two_fa.user_id)
|
.bind(two_fa.user_id)
|
||||||
.execute(self.ctx.db.writer())
|
.execute(self.ctx.db.writer())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
if result.rows_affected() > 0 {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
Ok(false)
|
Ok(false)
|
||||||
|
|||||||
+2
-2
@@ -10,6 +10,7 @@ use crate::etcd::EtcdRegistry;
|
|||||||
use crate::models::db::AppDatabase;
|
use crate::models::db::AppDatabase;
|
||||||
use crate::queue::NatsQueue;
|
use crate::queue::NatsQueue;
|
||||||
use crate::service::im::events::ImEventBus;
|
use crate::service::im::events::ImEventBus;
|
||||||
|
use crate::service::util::set_local_user_id;
|
||||||
use crate::storage::s3::AppS3Storage;
|
use crate::storage::s3::AppS3Storage;
|
||||||
|
|
||||||
/// Shared infrastructure context for all domain services.
|
/// Shared infrastructure context for all domain services.
|
||||||
@@ -50,8 +51,7 @@ impl ServiceContext {
|
|||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
|
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -1,715 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::immediate::{ArticleAction, ArticleEvent};
|
|
||||||
use crate::models::channels::{Article, ArticleComment, ArticleReaction};
|
|
||||||
use crate::models::common::{ArticleStatus, Visibility};
|
|
||||||
use crate::service::ImService;
|
|
||||||
use crate::service::im::events::ImEvent;
|
|
||||||
|
|
||||||
use super::session::ImSession;
|
|
||||||
use super::util::*;
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct CreateArticleParams {
|
|
||||||
pub title: String,
|
|
||||||
pub summary: Option<String>,
|
|
||||||
pub body: String,
|
|
||||||
pub cover_image_url: Option<String>,
|
|
||||||
pub tags: Option<Vec<String>>,
|
|
||||||
pub visibility: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct UpdateArticleParams {
|
|
||||||
pub title: Option<String>,
|
|
||||||
pub summary: Option<String>,
|
|
||||||
pub body: Option<String>,
|
|
||||||
pub cover_image_url: Option<String>,
|
|
||||||
pub tags: Option<Vec<String>>,
|
|
||||||
pub visibility: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct ArticleListFilters {
|
|
||||||
pub status: Option<String>,
|
|
||||||
pub tag: Option<String>,
|
|
||||||
pub author_id: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct CreateArticleCommentParams {
|
|
||||||
pub body: String,
|
|
||||||
pub parent_comment_id: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImService {
|
|
||||||
async fn article_realtime(&self, channel_id: Uuid, article_id: Uuid, action: ArticleAction) {
|
|
||||||
let request_id = Uuid::nil();
|
|
||||||
let event = ArticleEvent {
|
|
||||||
channel_id,
|
|
||||||
article_id,
|
|
||||||
action,
|
|
||||||
};
|
|
||||||
self.publish(&format!("im.article.{}", channel_id), request_id, &event)
|
|
||||||
.await;
|
|
||||||
self.emit_event(ImEvent::Article {
|
|
||||||
request_id,
|
|
||||||
data: event,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn article_list(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
filters: ArticleListFilters,
|
|
||||||
limit: i64,
|
|
||||||
offset: i64,
|
|
||||||
) -> Result<Vec<Article>, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
|
||||||
|
|
||||||
let status = filters
|
|
||||||
.status
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|s| s.parse::<ArticleStatus>().ok())
|
|
||||||
.filter(|s| *s != ArticleStatus::Unknown);
|
|
||||||
|
|
||||||
sqlx::query_as::<_, Article>(
|
|
||||||
"SELECT id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
|
|
||||||
status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \
|
|
||||||
views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \
|
|
||||||
metadata, created_at, updated_at, deleted_at \
|
|
||||||
FROM article WHERE channel_id = $1 AND deleted_at IS NULL \
|
|
||||||
AND ($2::text IS NULL OR status::text = $2) \
|
|
||||||
AND ($3::uuid IS NULL OR author_id = $3) \
|
|
||||||
AND ($4::text IS NULL OR $4 = ANY(tags)) \
|
|
||||||
ORDER BY created_at DESC LIMIT $5 OFFSET $6",
|
|
||||||
)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(status.map(|s| s.to_string()))
|
|
||||||
.bind(filters.author_id)
|
|
||||||
.bind(filters.tag.as_deref())
|
|
||||||
.bind(limit)
|
|
||||||
.bind(offset)
|
|
||||||
.fetch_all(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn article_get(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
article_id: Uuid,
|
|
||||||
) -> Result<Article, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
let article = self.resolve_article(article_id, channel_id).await?;
|
|
||||||
|
|
||||||
// Increment view count (best-effort, not in a txn)
|
|
||||||
let _ = sqlx::query("UPDATE article SET views_count = views_count + 1 WHERE id = $1")
|
|
||||||
.bind(article_id)
|
|
||||||
.execute(self.ctx.db.writer())
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(article)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn article_create(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
params: CreateArticleParams,
|
|
||||||
) -> Result<Article, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_editable(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
let title = required_text(params.title, "title")?;
|
|
||||||
if title.len() > MAX_ARTICLE_TITLE {
|
|
||||||
return Err(AppError::BadRequest("article title too long".into()));
|
|
||||||
}
|
|
||||||
let body = required_text(params.body, "body")?;
|
|
||||||
|
|
||||||
let visibility = parse_enum(
|
|
||||||
params.visibility,
|
|
||||||
Visibility::Public,
|
|
||||||
Visibility::Unknown,
|
|
||||||
"visibility",
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let slug = self.generate_article_slug(channel_id, &title).await?;
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let tags = params.tags.unwrap_or_default();
|
|
||||||
|
|
||||||
let article = sqlx::query_as::<_, Article>(
|
|
||||||
"INSERT INTO article \
|
|
||||||
(id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
|
|
||||||
status, visibility, tags, cross_posted, created_at, updated_at) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'draft', $9, $10, false, $11, $11) \
|
|
||||||
RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
|
|
||||||
status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \
|
|
||||||
views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \
|
|
||||||
metadata, created_at, updated_at, deleted_at",
|
|
||||||
)
|
|
||||||
.bind(Uuid::now_v7())
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(&title)
|
|
||||||
.bind(&slug)
|
|
||||||
.bind(params.summary.as_deref())
|
|
||||||
.bind(&body)
|
|
||||||
.bind(params.cover_image_url.as_deref())
|
|
||||||
.bind(visibility)
|
|
||||||
.bind(&tags)
|
|
||||||
.bind(now)
|
|
||||||
.fetch_one(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
self.article_realtime(channel_id, article.id, ArticleAction::Created)
|
|
||||||
.await;
|
|
||||||
Ok(article)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn article_update(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
article_id: Uuid,
|
|
||||||
params: UpdateArticleParams,
|
|
||||||
) -> Result<Article, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
let article = self.resolve_article(article_id, channel_id).await?;
|
|
||||||
|
|
||||||
if article.author_id != user_uid {
|
|
||||||
self.ensure_channel_admin(user_uid, &channel).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let new_title = match params.title {
|
|
||||||
Some(t) => {
|
|
||||||
let t = required_text(t, "title")?;
|
|
||||||
if t.len() > MAX_ARTICLE_TITLE {
|
|
||||||
return Err(AppError::BadRequest("article title too long".into()));
|
|
||||||
}
|
|
||||||
t
|
|
||||||
}
|
|
||||||
None => article.title,
|
|
||||||
};
|
|
||||||
let new_body = params.body.unwrap_or(article.body);
|
|
||||||
let new_summary = params.summary.or(article.summary);
|
|
||||||
let new_cover = params.cover_image_url.or(article.cover_image_url);
|
|
||||||
let new_tags = params.tags.unwrap_or(article.tags);
|
|
||||||
let visibility = parse_enum(
|
|
||||||
params.visibility,
|
|
||||||
article.visibility,
|
|
||||||
Visibility::Unknown,
|
|
||||||
"visibility",
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let updated = sqlx::query_as::<_, Article>(
|
|
||||||
"UPDATE article SET title = $1, summary = $2, body = $3, cover_image_url = $4, \
|
|
||||||
tags = $5, visibility = $6, updated_at = $7 \
|
|
||||||
WHERE id = $8 AND deleted_at IS NULL \
|
|
||||||
RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
|
|
||||||
status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \
|
|
||||||
views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \
|
|
||||||
metadata, created_at, updated_at, deleted_at",
|
|
||||||
)
|
|
||||||
.bind(&new_title)
|
|
||||||
.bind(&new_summary)
|
|
||||||
.bind(&new_body)
|
|
||||||
.bind(&new_cover)
|
|
||||||
.bind(&new_tags)
|
|
||||||
.bind(visibility)
|
|
||||||
.bind(now)
|
|
||||||
.bind(article_id)
|
|
||||||
.fetch_one(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
self.article_realtime(channel_id, article_id, ArticleAction::Updated)
|
|
||||||
.await;
|
|
||||||
Ok(updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn article_publish(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
article_id: Uuid,
|
|
||||||
) -> Result<Article, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
let article = self.resolve_article(article_id, channel_id).await?;
|
|
||||||
|
|
||||||
if article.author_id != user_uid {
|
|
||||||
self.ensure_channel_editable(user_uid, &channel).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if article.status != ArticleStatus::Draft && article.status != ArticleStatus::Scheduled {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"only draft or scheduled articles can be published".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let published = sqlx::query_as::<_, Article>(
|
|
||||||
"UPDATE article SET status = 'published', published_at = $1, published_by = $2, \
|
|
||||||
updated_at = $1 \
|
|
||||||
WHERE id = $3 AND deleted_at IS NULL \
|
|
||||||
RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
|
|
||||||
status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \
|
|
||||||
views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \
|
|
||||||
metadata, created_at, updated_at, deleted_at",
|
|
||||||
)
|
|
||||||
.bind(now)
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(article_id)
|
|
||||||
.fetch_one(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
// Trigger cross-posts to followers
|
|
||||||
if let Err(e) = self
|
|
||||||
.cross_post_article(article_id, channel_id, user_uid)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!(article_id = %article_id, error = %e, "cross-post failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!(article_id = %article_id, "Article published");
|
|
||||||
self.article_realtime(channel_id, article_id, ArticleAction::Published)
|
|
||||||
.await;
|
|
||||||
Ok(published)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn article_unpublish(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
article_id: Uuid,
|
|
||||||
) -> Result<Article, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
let article = self.resolve_article(article_id, channel_id).await?;
|
|
||||||
|
|
||||||
if article.author_id != user_uid {
|
|
||||||
self.ensure_channel_editable(user_uid, &channel).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let unpublished = sqlx::query_as::<_, Article>(
|
|
||||||
"UPDATE article SET status = 'unpublished', unpublished_at = $1, updated_at = $1 \
|
|
||||||
WHERE id = $2 AND status = 'published' AND deleted_at IS NULL \
|
|
||||||
RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
|
|
||||||
status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \
|
|
||||||
views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \
|
|
||||||
metadata, created_at, updated_at, deleted_at",
|
|
||||||
)
|
|
||||||
.bind(now)
|
|
||||||
.bind(article_id)
|
|
||||||
.fetch_one(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
self.article_realtime(channel_id, article_id, ArticleAction::Unpublished)
|
|
||||||
.await;
|
|
||||||
Ok(unpublished)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn article_schedule(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
article_id: Uuid,
|
|
||||||
scheduled_at: chrono::DateTime<chrono::Utc>,
|
|
||||||
) -> Result<Article, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
let article = self.resolve_article(article_id, channel_id).await?;
|
|
||||||
|
|
||||||
if article.author_id != user_uid {
|
|
||||||
self.ensure_channel_editable(user_uid, &channel).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if article.status != ArticleStatus::Draft {
|
|
||||||
return Err(AppError::BadRequest(
|
|
||||||
"only draft articles can be scheduled".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let scheduled = sqlx::query_as::<_, Article>(
|
|
||||||
"UPDATE article SET status = 'scheduled', scheduled_at = $1, updated_at = $2 \
|
|
||||||
WHERE id = $3 AND deleted_at IS NULL \
|
|
||||||
RETURNING id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
|
|
||||||
status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \
|
|
||||||
views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \
|
|
||||||
metadata, created_at, updated_at, deleted_at",
|
|
||||||
)
|
|
||||||
.bind(scheduled_at)
|
|
||||||
.bind(now)
|
|
||||||
.bind(article_id)
|
|
||||||
.fetch_one(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
self.article_realtime(channel_id, article_id, ArticleAction::Updated)
|
|
||||||
.await;
|
|
||||||
Ok(scheduled)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn article_delete(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
article_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
let article = self.resolve_article(article_id, channel_id).await?;
|
|
||||||
|
|
||||||
if article.author_id != user_uid {
|
|
||||||
self.ensure_channel_admin(user_uid, &channel).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let result = sqlx::query(
|
|
||||||
"UPDATE article SET deleted_at = $1, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL",
|
|
||||||
)
|
|
||||||
.bind(now)
|
|
||||||
.bind(article_id)
|
|
||||||
.execute(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
ensure_affected(result.rows_affected(), "article not found")?;
|
|
||||||
self.article_realtime(channel_id, article_id, ArticleAction::Deleted)
|
|
||||||
.await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn article_comment_list(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
article_id: Uuid,
|
|
||||||
limit: i64,
|
|
||||||
offset: i64,
|
|
||||||
) -> Result<Vec<ArticleComment>, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
|
||||||
|
|
||||||
sqlx::query_as::<_, ArticleComment>(
|
|
||||||
"SELECT id, article_id, channel_id, author_id, parent_comment_id, body, \
|
|
||||||
edited_at, deleted_at, created_at, updated_at \
|
|
||||||
FROM article_comment WHERE article_id = $1 AND deleted_at IS NULL \
|
|
||||||
ORDER BY created_at ASC LIMIT $2 OFFSET $3",
|
|
||||||
)
|
|
||||||
.bind(article_id)
|
|
||||||
.bind(limit)
|
|
||||||
.bind(offset)
|
|
||||||
.fetch_all(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn article_comment_create(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
article_id: Uuid,
|
|
||||||
params: CreateArticleCommentParams,
|
|
||||||
) -> Result<ArticleComment, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
let body = required_text(params.body, "body")?;
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
|
|
||||||
let mut txn = self
|
|
||||||
.ctx
|
|
||||||
.db
|
|
||||||
.writer()
|
|
||||||
.begin()
|
|
||||||
.await
|
|
||||||
.map_err(|_| AppError::TxnError)?;
|
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
let comment = sqlx::query_as::<_, ArticleComment>(
|
|
||||||
"INSERT INTO article_comment \
|
|
||||||
(id, article_id, channel_id, author_id, parent_comment_id, body, created_at, updated_at) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $7) \
|
|
||||||
RETURNING id, article_id, channel_id, author_id, parent_comment_id, body, \
|
|
||||||
edited_at, deleted_at, created_at, updated_at",
|
|
||||||
)
|
|
||||||
.bind(Uuid::now_v7())
|
|
||||||
.bind(article_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(params.parent_comment_id)
|
|
||||||
.bind(&body)
|
|
||||||
.bind(now)
|
|
||||||
.fetch_one(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
sqlx::query("UPDATE article SET comments_count = comments_count + 1 WHERE id = $1")
|
|
||||||
.bind(article_id)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
|
||||||
self.article_realtime(channel_id, article_id, ArticleAction::Updated)
|
|
||||||
.await;
|
|
||||||
Ok(comment)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn article_comment_delete(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
article_id: Uuid,
|
|
||||||
comment_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
|
|
||||||
let comment = sqlx::query_as::<_, ArticleComment>(
|
|
||||||
"SELECT id, article_id, channel_id, author_id, parent_comment_id, body, \
|
|
||||||
edited_at, deleted_at, created_at, updated_at \
|
|
||||||
FROM article_comment WHERE id = $1 AND article_id = $2 AND deleted_at IS NULL",
|
|
||||||
)
|
|
||||||
.bind(comment_id)
|
|
||||||
.bind(article_id)
|
|
||||||
.fetch_optional(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?
|
|
||||||
.ok_or(AppError::NotFound("comment not found".into()))?;
|
|
||||||
|
|
||||||
if comment.author_id != user_uid {
|
|
||||||
self.ensure_channel_admin(user_uid, &channel).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let mut txn = self
|
|
||||||
.ctx
|
|
||||||
.db
|
|
||||||
.writer()
|
|
||||||
.begin()
|
|
||||||
.await
|
|
||||||
.map_err(|_| AppError::TxnError)?;
|
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
sqlx::query("UPDATE article_comment SET deleted_at = $1, updated_at = $1 WHERE id = $2")
|
|
||||||
.bind(now)
|
|
||||||
.bind(comment_id)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE article SET comments_count = GREATEST(comments_count - 1, 0) WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(article_id)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
|
||||||
self.article_realtime(channel_id, article_id, ArticleAction::Updated)
|
|
||||||
.await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn article_reaction_add(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
article_id: Uuid,
|
|
||||||
content: &str,
|
|
||||||
) -> Result<ArticleReaction, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
let content = required_text(content.to_string(), "content")?;
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
|
|
||||||
let mut txn = self
|
|
||||||
.ctx
|
|
||||||
.db
|
|
||||||
.writer()
|
|
||||||
.begin()
|
|
||||||
.await
|
|
||||||
.map_err(|_| AppError::TxnError)?;
|
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
let reaction = sqlx::query_as::<_, ArticleReaction>(
|
|
||||||
"INSERT INTO article_reaction (id, article_id, channel_id, user_id, content, created_at) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6) \
|
|
||||||
ON CONFLICT (article_id, user_id, content) DO NOTHING \
|
|
||||||
RETURNING id, article_id, channel_id, user_id, content, created_at",
|
|
||||||
)
|
|
||||||
.bind(Uuid::now_v7())
|
|
||||||
.bind(article_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(&content)
|
|
||||||
.bind(now)
|
|
||||||
.fetch_optional(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
if reaction.is_some() {
|
|
||||||
sqlx::query("UPDATE article SET reactions_count = reactions_count + 1 WHERE id = $1")
|
|
||||||
.bind(article_id)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
|
||||||
let reaction = reaction.ok_or(AppError::Conflict("reaction already exists".into()))?;
|
|
||||||
self.article_realtime(channel_id, article_id, ArticleAction::Updated)
|
|
||||||
.await;
|
|
||||||
Ok(reaction)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn article_reaction_remove(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
article_id: Uuid,
|
|
||||||
content: &str,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
let _now = chrono::Utc::now();
|
|
||||||
let mut txn = self
|
|
||||||
.ctx
|
|
||||||
.db
|
|
||||||
.writer()
|
|
||||||
.begin()
|
|
||||||
.await
|
|
||||||
.map_err(|_| AppError::TxnError)?;
|
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
let result = sqlx::query(
|
|
||||||
"DELETE FROM article_reaction WHERE article_id = $1 AND user_id = $2 AND content = $3",
|
|
||||||
)
|
|
||||||
.bind(article_id)
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(content)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
ensure_affected(result.rows_affected(), "reaction not found")?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE article SET reactions_count = GREATEST(reactions_count - 1, 0) WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(article_id)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
|
||||||
self.article_realtime(channel_id, article_id, ArticleAction::Updated)
|
|
||||||
.await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn resolve_article(
|
|
||||||
&self,
|
|
||||||
article_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
) -> Result<Article, AppError> {
|
|
||||||
sqlx::query_as::<_, Article>(
|
|
||||||
"SELECT id, channel_id, author_id, title, slug, summary, body, cover_image_url, \
|
|
||||||
status, visibility, tags, published_at, published_by, scheduled_at, unpublished_at, \
|
|
||||||
views_count, comments_count, reactions_count, cross_posted, cross_posted_from, \
|
|
||||||
metadata, created_at, updated_at, deleted_at \
|
|
||||||
FROM article WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL",
|
|
||||||
)
|
|
||||||
.bind(article_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.fetch_optional(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?
|
|
||||||
.ok_or(AppError::NotFound("article not found".into()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn generate_article_slug(
|
|
||||||
&self,
|
|
||||||
channel_id: Uuid,
|
|
||||||
title: &str,
|
|
||||||
) -> Result<String, AppError> {
|
|
||||||
let base = slugify(title);
|
|
||||||
let mut slug = base.clone();
|
|
||||||
let mut counter = 1u32;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
let exists: bool = sqlx::query_scalar(
|
|
||||||
"SELECT EXISTS(SELECT 1 FROM article WHERE channel_id = $1 AND slug = $2)",
|
|
||||||
)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(&slug)
|
|
||||||
.fetch_one(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
return Ok(slug);
|
|
||||||
}
|
|
||||||
slug = format!("{base}-{counter}");
|
|
||||||
counter += 1;
|
|
||||||
if counter > 100 {
|
|
||||||
return Err(AppError::InternalServerError(
|
|
||||||
"failed to generate unique slug".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::channels::ChannelEvent;
|
||||||
|
use crate::service::ImService;
|
||||||
|
|
||||||
|
use super::session::ImSession;
|
||||||
|
|
||||||
|
impl ImService {
|
||||||
|
pub async fn audit_list(
|
||||||
|
&self,
|
||||||
|
_ctx: &ImSession,
|
||||||
|
channel_id: Uuid,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<ChannelEvent>, AppError> {
|
||||||
|
let limit = limit.clamp(1, 100);
|
||||||
|
let offset = offset.max(0);
|
||||||
|
sqlx::query_as::<_, ChannelEvent>(
|
||||||
|
"SELECT id, channel_id, actor_id, event_type, target_type, target_id, \
|
||||||
|
old_value, new_value, metadata, created_at \
|
||||||
|
FROM channel_event WHERE channel_id = $1 \
|
||||||
|
ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(self.ctx.db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::immediate::{CategoryAction, CategoryEvent};
|
use crate::service::im::events::{CategoryAction, CategoryEvent};
|
||||||
use crate::models::channels::ChannelCategory;
|
use crate::models::channels::ChannelCategory;
|
||||||
use crate::models::common::Role;
|
use crate::models::common::Role;
|
||||||
use crate::service::ImService;
|
use crate::service::ImService;
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::channels::ChannelMemberRole;
|
||||||
|
use crate::service::ImService;
|
||||||
|
|
||||||
|
use super::session::ImSession;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
|
pub struct CreateChannelRoleParams {
|
||||||
|
pub name: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub permissions: Vec<String>,
|
||||||
|
pub assignable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
|
pub struct UpdateChannelRoleParams {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub permissions: Option<Vec<String>>,
|
||||||
|
pub assignable: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImService {
|
||||||
|
pub async fn channel_role_list(
|
||||||
|
&self,
|
||||||
|
_ctx: &ImSession,
|
||||||
|
channel_id: Uuid,
|
||||||
|
) -> Result<Vec<ChannelMemberRole>, AppError> {
|
||||||
|
sqlx::query_as::<_, ChannelMemberRole>(
|
||||||
|
"SELECT id, channel_id, name, description, permissions, assignable, \
|
||||||
|
created_by, created_at, updated_at \
|
||||||
|
FROM channel_member_role WHERE channel_id = $1 ORDER BY created_at",
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.fetch_all(self.ctx.db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn channel_role_create(
|
||||||
|
&self,
|
||||||
|
ctx: &ImSession,
|
||||||
|
channel_id: Uuid,
|
||||||
|
params: CreateChannelRoleParams,
|
||||||
|
) -> Result<ChannelMemberRole, AppError> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
sqlx::query_as::<_, ChannelMemberRole>(
|
||||||
|
"INSERT INTO channel_member_role \
|
||||||
|
(id, channel_id, name, description, permissions, assignable, created_by, created_at, updated_at) \
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) \
|
||||||
|
RETURNING id, channel_id, name, description, permissions, assignable, \
|
||||||
|
created_by, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(Uuid::now_v7())
|
||||||
|
.bind(channel_id)
|
||||||
|
.bind(¶ms.name)
|
||||||
|
.bind(params.description.as_deref())
|
||||||
|
.bind(¶ms.permissions)
|
||||||
|
.bind(params.assignable)
|
||||||
|
.bind(ctx.user)
|
||||||
|
.bind(now)
|
||||||
|
.fetch_one(self.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn channel_role_update(
|
||||||
|
&self,
|
||||||
|
_ctx: &ImSession,
|
||||||
|
role_id: Uuid,
|
||||||
|
params: UpdateChannelRoleParams,
|
||||||
|
) -> Result<ChannelMemberRole, AppError> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
sqlx::query_as::<_, ChannelMemberRole>(
|
||||||
|
"UPDATE channel_member_role SET \
|
||||||
|
name = COALESCE($1, name), \
|
||||||
|
description = COALESCE($2, description), \
|
||||||
|
permissions = COALESCE($3, permissions), \
|
||||||
|
assignable = COALESCE($4, assignable), \
|
||||||
|
updated_at = $5 \
|
||||||
|
WHERE id = $6 \
|
||||||
|
RETURNING id, channel_id, name, description, permissions, assignable, \
|
||||||
|
created_by, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(params.name.as_deref())
|
||||||
|
.bind(params.description.as_deref())
|
||||||
|
.bind(params.permissions.as_ref())
|
||||||
|
.bind(params.assignable)
|
||||||
|
.bind(now)
|
||||||
|
.bind(role_id)
|
||||||
|
.fetch_one(self.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn channel_role_delete(
|
||||||
|
&self,
|
||||||
|
_ctx: &ImSession,
|
||||||
|
role_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query("DELETE FROM channel_member_role WHERE id = $1")
|
||||||
|
.bind(role_id)
|
||||||
|
.execute(self.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-15
@@ -9,8 +9,7 @@ use crate::service::ImService;
|
|||||||
|
|
||||||
use super::session::ImSession;
|
use super::session::ImSession;
|
||||||
use super::util::*;
|
use super::util::*;
|
||||||
use crate::immediate::{ChannelAction, ChannelEvent};
|
use crate::service::im::events::{ChannelAction, ChannelEvent, ImEvent};
|
||||||
use crate::service::im::events::ImEvent;
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct CreateChannelParams {
|
pub struct CreateChannelParams {
|
||||||
@@ -159,6 +158,14 @@ impl ImService {
|
|||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
let channel_id = Uuid::now_v7();
|
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>(
|
let channel = sqlx::query_as::<_, Channel>(
|
||||||
"INSERT INTO channel \
|
"INSERT INTO channel \
|
||||||
(id, workspace_id, repo_id, category_id, created_by, name, topic, description, \
|
(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.rate_limit_per_user)
|
||||||
.bind(params.parent_channel_id)
|
.bind(params.parent_channel_id)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.fetch_one(self.ctx.db.writer())
|
.fetch_one(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
@@ -203,10 +210,12 @@ impl ImService {
|
|||||||
.bind(channel_id)
|
.bind(channel_id)
|
||||||
.bind(user_uid)
|
.bind(user_uid)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.execute(self.ctx.db.writer())
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||||
|
|
||||||
tracing::info!(channel_id = %channel_id, name = %name, "Channel created");
|
tracing::info!(channel_id = %channel_id, name = %name, "Channel created");
|
||||||
|
|
||||||
let event = ChannelEvent {
|
let event = ChannelEvent {
|
||||||
@@ -429,6 +438,7 @@ impl ImService {
|
|||||||
Err(AppError::Unauthorized)
|
Err(AppError::Unauthorized)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub(crate) async fn ensure_channel_member(
|
pub(crate) async fn ensure_channel_member(
|
||||||
&self,
|
&self,
|
||||||
user_uid: Uuid,
|
user_uid: Uuid,
|
||||||
@@ -527,25 +537,20 @@ impl ImService {
|
|||||||
.unwrap_or(Role::Unknown))
|
.unwrap_or(Role::Unknown))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn update_channel_stats(
|
pub(crate) async fn increment_channel_stat(
|
||||||
&self,
|
&self,
|
||||||
channel_id: Uuid,
|
channel_id: Uuid,
|
||||||
|
delta: i32,
|
||||||
now: chrono::DateTime<chrono::Utc>,
|
now: chrono::DateTime<chrono::Utc>,
|
||||||
txn: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
txn: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE channel_stats SET \
|
"UPDATE channel_stats SET members_count = members_count + $1, \
|
||||||
members_count = (SELECT COUNT(*) FROM channel_member WHERE channel_id = $1 AND status = 'active'), \
|
last_activity_at = $2, updated_at = $2 WHERE channel_id = $3",
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
.bind(channel_id)
|
.bind(delta)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
|
.bind(channel_id)
|
||||||
.execute(&mut **txn)
|
.execute(&mut **txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::channels::CustomEmoji;
|
||||||
|
use crate::service::ImService;
|
||||||
|
|
||||||
|
use super::session::ImSession;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
|
pub struct CreateCustomEmojiParams {
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
pub animated: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImService {
|
||||||
|
pub async fn custom_emoji_list(
|
||||||
|
&self,
|
||||||
|
_ctx: &ImSession,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
) -> Result<Vec<CustomEmoji>, AppError> {
|
||||||
|
sqlx::query_as::<_, CustomEmoji>(
|
||||||
|
"SELECT id, workspace_id, name, url, animated, managed, \
|
||||||
|
created_by, created_at, updated_at \
|
||||||
|
FROM custom_emoji WHERE workspace_id = $1 ORDER BY name",
|
||||||
|
)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.fetch_all(self.ctx.db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn custom_emoji_create(
|
||||||
|
&self,
|
||||||
|
ctx: &ImSession,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
params: CreateCustomEmojiParams,
|
||||||
|
) -> Result<CustomEmoji, AppError> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
sqlx::query_as::<_, CustomEmoji>(
|
||||||
|
"INSERT INTO custom_emoji \
|
||||||
|
(id, workspace_id, name, url, animated, managed, created_by, created_at, updated_at) \
|
||||||
|
VALUES ($1, $2, $3, $4, $5, false, $6, $7, $7) \
|
||||||
|
RETURNING id, workspace_id, name, url, animated, managed, \
|
||||||
|
created_by, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(Uuid::now_v7())
|
||||||
|
.bind(workspace_id)
|
||||||
|
.bind(¶ms.name)
|
||||||
|
.bind(¶ms.url)
|
||||||
|
.bind(params.animated.unwrap_or(false))
|
||||||
|
.bind(ctx.user)
|
||||||
|
.bind(now)
|
||||||
|
.fetch_one(self.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn custom_emoji_delete(
|
||||||
|
&self,
|
||||||
|
_ctx: &ImSession,
|
||||||
|
emoji_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query("DELETE FROM custom_emoji WHERE id = $1")
|
||||||
|
.bind(emoji_id)
|
||||||
|
.execute(self.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
pub fn trace_request(stage: &'static str, request_id: Uuid, subject: &str) {
|
|
||||||
tracing::info!(
|
|
||||||
target: "im.delivery",
|
|
||||||
stage,
|
|
||||||
request_id = %request_id,
|
|
||||||
subject,
|
|
||||||
"im delivery trace"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn trace_message(
|
|
||||||
stage: &'static str,
|
|
||||||
request_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
message_id: Uuid,
|
|
||||||
seq: Option<i64>,
|
|
||||||
) {
|
|
||||||
tracing::info!(
|
|
||||||
target: "im.delivery",
|
|
||||||
stage,
|
|
||||||
request_id = %request_id,
|
|
||||||
channel_id = %channel_id,
|
|
||||||
message_id = %message_id,
|
|
||||||
seq,
|
|
||||||
"im message delivery trace"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn trace_error(
|
|
||||||
stage: &'static str,
|
|
||||||
request_id: Uuid,
|
|
||||||
subject: &str,
|
|
||||||
error: &dyn std::fmt::Display,
|
|
||||||
) {
|
|
||||||
tracing::warn!(
|
|
||||||
target: "im.delivery",
|
|
||||||
stage,
|
|
||||||
request_id = %request_id,
|
|
||||||
subject,
|
|
||||||
error = %error,
|
|
||||||
"im delivery trace failed"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::immediate::{DraftAction, DraftEvent};
|
|
||||||
use crate::models::channels::MessageDraft;
|
|
||||||
use crate::service::ImService;
|
|
||||||
use crate::service::im::events::ImEvent;
|
|
||||||
|
|
||||||
use super::session::ImSession;
|
|
||||||
use super::util::*;
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct SaveDraftParams {
|
|
||||||
pub content: String,
|
|
||||||
pub thread_id: Option<Uuid>,
|
|
||||||
pub reply_to_message_id: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImService {
|
|
||||||
async fn draft_realtime(
|
|
||||||
&self,
|
|
||||||
channel_id: Uuid,
|
|
||||||
user_id: Uuid,
|
|
||||||
thread_id: Option<Uuid>,
|
|
||||||
action: DraftAction,
|
|
||||||
) {
|
|
||||||
let request_id = Uuid::nil();
|
|
||||||
let event = DraftEvent {
|
|
||||||
channel_id,
|
|
||||||
user_id,
|
|
||||||
thread_id,
|
|
||||||
action,
|
|
||||||
};
|
|
||||||
self.publish(&format!("im.draft.{user_id}"), request_id, &event)
|
|
||||||
.await;
|
|
||||||
self.emit_event(ImEvent::Draft {
|
|
||||||
request_id,
|
|
||||||
data: event,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn draft_save(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
params: SaveDraftParams,
|
|
||||||
) -> Result<MessageDraft, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
if params.content.len() > MAX_MESSAGE_BODY {
|
|
||||||
return Err(AppError::BadRequest("draft content too long".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: COALESCE(thread_id, nil_uuid) in ON CONFLICT requires a matching
|
|
||||||
// UNIQUE index with the identical COALESCE expression.
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let draft = sqlx::query_as::<_, MessageDraft>(
|
|
||||||
"INSERT INTO message_draft \
|
|
||||||
(id, user_id, channel_id, thread_id, reply_to_message_id, content, created_at, updated_at) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $7) \
|
|
||||||
ON CONFLICT (user_id, channel_id, COALESCE(thread_id, '00000000-0000-0000-0000-000000000000'::uuid)) \
|
|
||||||
DO UPDATE SET content = $6, reply_to_message_id = $5, updated_at = $7 \
|
|
||||||
RETURNING id, user_id, channel_id, thread_id, reply_to_message_id, content, \
|
|
||||||
attachments, created_at, updated_at",
|
|
||||||
)
|
|
||||||
.bind(Uuid::now_v7())
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(params.thread_id)
|
|
||||||
.bind(params.reply_to_message_id)
|
|
||||||
.bind(¶ms.content)
|
|
||||||
.bind(now)
|
|
||||||
.fetch_one(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
self.draft_realtime(channel_id, user_uid, draft.thread_id, DraftAction::Saved)
|
|
||||||
.await;
|
|
||||||
Ok(draft)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn draft_get(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Option<Uuid>,
|
|
||||||
) -> Result<Option<MessageDraft>, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
sqlx::query_as::<_, MessageDraft>(
|
|
||||||
"SELECT id, user_id, channel_id, thread_id, reply_to_message_id, content, \
|
|
||||||
attachments, created_at, updated_at \
|
|
||||||
FROM message_draft \
|
|
||||||
WHERE user_id = $1 AND channel_id = $2 \
|
|
||||||
AND (thread_id = $3 OR (thread_id IS NULL AND $3 IS NULL))",
|
|
||||||
)
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(thread_id)
|
|
||||||
.fetch_optional(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn draft_delete(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Option<Uuid>,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
|
|
||||||
let result = sqlx::query(
|
|
||||||
"DELETE FROM message_draft \
|
|
||||||
WHERE user_id = $1 AND channel_id = $2 \
|
|
||||||
AND (thread_id = $3 OR (thread_id IS NULL AND $3 IS NULL))",
|
|
||||||
)
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(thread_id)
|
|
||||||
.execute(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
ensure_affected(result.rows_affected(), "draft not found")?;
|
|
||||||
self.draft_realtime(channel_id, user_uid, thread_id, DraftAction::Deleted)
|
|
||||||
.await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn draft_list(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
wk_name: &str,
|
|
||||||
limit: i64,
|
|
||||||
offset: i64,
|
|
||||||
) -> Result<Vec<MessageDraft>, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let _ = self.resolve_workspace(wk_name).await?;
|
|
||||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
|
||||||
|
|
||||||
sqlx::query_as::<_, MessageDraft>(
|
|
||||||
"SELECT id, user_id, channel_id, thread_id, reply_to_message_id, content, \
|
|
||||||
attachments, created_at, updated_at \
|
|
||||||
FROM message_draft WHERE user_id = $1 \
|
|
||||||
ORDER BY updated_at DESC LIMIT $2 OFFSET $3",
|
|
||||||
)
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(limit)
|
|
||||||
.bind(offset)
|
|
||||||
.fetch_all(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+46
-40
@@ -1,64 +1,70 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::immediate::{
|
use crate::models::base_info::UserBaseInfo;
|
||||||
ArticleEvent, CategoryEvent, ChannelEvent, DraftEvent, FollowEvent, MemberEvent, MessageEvent,
|
|
||||||
PollEvent, PresenceEvent, ReactionEvent, ThreadEvent, TypingEvent,
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
};
|
pub enum ChannelAction {
|
||||||
|
Created,
|
||||||
|
Updated,
|
||||||
|
Deleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChannelEvent {
|
||||||
|
pub channel_id: Uuid,
|
||||||
|
pub action: ChannelAction,
|
||||||
|
pub workspace_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum MemberAction {
|
||||||
|
Joined,
|
||||||
|
Updated,
|
||||||
|
Kicked,
|
||||||
|
Left,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct MemberEvent {
|
||||||
|
pub channel_id: Uuid,
|
||||||
|
pub user: UserBaseInfo,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub action: MemberAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum CategoryAction {
|
||||||
|
Created,
|
||||||
|
Updated,
|
||||||
|
Deleted,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CategoryEvent {
|
||||||
|
pub workspace_name: String,
|
||||||
|
pub category_id: Uuid,
|
||||||
|
pub action: CategoryAction,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum ImEvent {
|
pub enum ImEvent {
|
||||||
Typing {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: TypingEvent,
|
|
||||||
},
|
|
||||||
Presence {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: PresenceEvent,
|
|
||||||
},
|
|
||||||
Message {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: MessageEvent,
|
|
||||||
},
|
|
||||||
Channel {
|
Channel {
|
||||||
request_id: Uuid,
|
request_id: Uuid,
|
||||||
data: ChannelEvent,
|
data: ChannelEvent,
|
||||||
},
|
},
|
||||||
Thread {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: ThreadEvent,
|
|
||||||
},
|
|
||||||
Member {
|
Member {
|
||||||
request_id: Uuid,
|
request_id: Uuid,
|
||||||
data: MemberEvent,
|
data: MemberEvent,
|
||||||
},
|
},
|
||||||
Reaction {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: ReactionEvent,
|
|
||||||
},
|
|
||||||
Poll {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: PollEvent,
|
|
||||||
},
|
|
||||||
Article {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: ArticleEvent,
|
|
||||||
},
|
|
||||||
Category {
|
Category {
|
||||||
request_id: Uuid,
|
request_id: Uuid,
|
||||||
data: CategoryEvent,
|
data: CategoryEvent,
|
||||||
},
|
},
|
||||||
Draft {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: DraftEvent,
|
|
||||||
},
|
|
||||||
Follow {
|
|
||||||
request_id: Uuid,
|
|
||||||
data: FollowEvent,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
|||||||
@@ -1,232 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::immediate::{FollowAction, FollowEvent};
|
|
||||||
use crate::models::channels::{ArticleCrossPost, ChannelFollow};
|
|
||||||
|
|
||||||
use crate::service::ImService;
|
|
||||||
use crate::service::im::events::ImEvent;
|
|
||||||
|
|
||||||
use super::session::ImSession;
|
|
||||||
use super::util::*;
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct FollowChannelParams {
|
|
||||||
pub target_workspace_id: Uuid,
|
|
||||||
pub target_channel_id: Option<Uuid>,
|
|
||||||
pub webhook_url: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImService {
|
|
||||||
async fn follow_realtime(&self, channel_id: Uuid, follow_id: Uuid, action: FollowAction) {
|
|
||||||
let request_id = Uuid::nil();
|
|
||||||
let event = FollowEvent {
|
|
||||||
channel_id,
|
|
||||||
follow_id,
|
|
||||||
action,
|
|
||||||
};
|
|
||||||
self.publish(&format!("im.follow.{channel_id}"), request_id, &event)
|
|
||||||
.await;
|
|
||||||
self.emit_event(ImEvent::Follow {
|
|
||||||
request_id,
|
|
||||||
data: event,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn follow_list(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
) -> Result<Vec<ChannelFollow>, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_admin(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
sqlx::query_as::<_, ChannelFollow>(
|
|
||||||
"SELECT id, source_channel_id, target_workspace_id, target_channel_id, \
|
|
||||||
webhook_url, webhook_secret_ciphertext, enabled, followed_by, \
|
|
||||||
unfollowed_at, last_delivery_at, last_delivery_status, created_at, updated_at \
|
|
||||||
FROM channel_follow WHERE source_channel_id = $1 AND unfollowed_at IS NULL \
|
|
||||||
ORDER BY created_at DESC",
|
|
||||||
)
|
|
||||||
.bind(channel_id)
|
|
||||||
.fetch_all(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn follow_create(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
params: FollowChannelParams,
|
|
||||||
) -> Result<ChannelFollow, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let follow = sqlx::query_as::<_, ChannelFollow>(
|
|
||||||
"INSERT INTO channel_follow \
|
|
||||||
(id, source_channel_id, target_workspace_id, target_channel_id, \
|
|
||||||
webhook_url, enabled, followed_by, created_at, updated_at) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, true, $6, $7, $7) \
|
|
||||||
ON CONFLICT (source_channel_id, target_workspace_id, target_channel_id) \
|
|
||||||
DO UPDATE SET enabled = true, unfollowed_at = NULL, updated_at = $7 \
|
|
||||||
RETURNING id, source_channel_id, target_workspace_id, target_channel_id, \
|
|
||||||
webhook_url, webhook_secret_ciphertext, enabled, followed_by, \
|
|
||||||
unfollowed_at, last_delivery_at, last_delivery_status, created_at, updated_at",
|
|
||||||
)
|
|
||||||
.bind(Uuid::now_v7())
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(params.target_workspace_id)
|
|
||||||
.bind(params.target_channel_id)
|
|
||||||
.bind(params.webhook_url.as_deref())
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(now)
|
|
||||||
.fetch_one(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
self.follow_realtime(channel_id, follow.id, FollowAction::Created)
|
|
||||||
.await;
|
|
||||||
Ok(follow)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn follow_delete(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
follow_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_admin(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let result = sqlx::query(
|
|
||||||
"UPDATE channel_follow SET unfollowed_at = $1, enabled = false, updated_at = $1 \
|
|
||||||
WHERE id = $2 AND source_channel_id = $3 AND unfollowed_at IS NULL",
|
|
||||||
)
|
|
||||||
.bind(now)
|
|
||||||
.bind(follow_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.execute(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
ensure_affected(result.rows_affected(), "follow not found")?;
|
|
||||||
self.follow_realtime(channel_id, follow_id, FollowAction::Deleted)
|
|
||||||
.await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn cross_post_article(
|
|
||||||
&self,
|
|
||||||
article_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
_actor_id: Uuid,
|
|
||||||
) -> Result<u64, AppError> {
|
|
||||||
let followers = sqlx::query_as::<_, ChannelFollow>(
|
|
||||||
"SELECT id, source_channel_id, target_workspace_id, target_channel_id, \
|
|
||||||
webhook_url, webhook_secret_ciphertext, enabled, followed_by, \
|
|
||||||
unfollowed_at, last_delivery_at, last_delivery_status, created_at, updated_at \
|
|
||||||
FROM channel_follow WHERE source_channel_id = $1 AND enabled AND unfollowed_at IS NULL",
|
|
||||||
)
|
|
||||||
.bind(channel_id)
|
|
||||||
.fetch_all(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let mut count = 0u64;
|
|
||||||
|
|
||||||
for follow in &followers {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO article_cross_post \
|
|
||||||
(id, article_id, follow_id, target_workspace_id, target_channel_id, \
|
|
||||||
status, attempts, created_at) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, 'pending', 0, $6) \
|
|
||||||
ON CONFLICT DO NOTHING",
|
|
||||||
)
|
|
||||||
.bind(Uuid::now_v7())
|
|
||||||
.bind(article_id)
|
|
||||||
.bind(follow.id)
|
|
||||||
.bind(follow.target_workspace_id)
|
|
||||||
.bind(follow.target_channel_id)
|
|
||||||
.bind(now)
|
|
||||||
.execute(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if count > 0 {
|
|
||||||
sqlx::query("UPDATE article SET cross_posted = true WHERE id = $1")
|
|
||||||
.bind(article_id)
|
|
||||||
.execute(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!(
|
|
||||||
article_id = %article_id,
|
|
||||||
followers = count,
|
|
||||||
"Cross-post jobs created"
|
|
||||||
);
|
|
||||||
Ok(count)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cross_post_list(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
article_id: Uuid,
|
|
||||||
) -> Result<Vec<ArticleCrossPost>, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_admin(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
sqlx::query_as::<_, ArticleCrossPost>(
|
|
||||||
"SELECT id, article_id, follow_id, target_workspace_id, target_channel_id, \
|
|
||||||
status, attempts, last_error, sent_at, delivered_at, failed_at, created_at \
|
|
||||||
FROM article_cross_post WHERE article_id = $1 ORDER BY created_at ASC",
|
|
||||||
)
|
|
||||||
.bind(article_id)
|
|
||||||
.fetch_all(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn cross_post_retry(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
cross_post_id: Uuid,
|
|
||||||
) -> Result<ArticleCrossPost, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_admin(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
let post = sqlx::query_as::<_, ArticleCrossPost>(
|
|
||||||
"UPDATE article_cross_post SET status = 'pending', attempts = 0, \
|
|
||||||
last_error = NULL, failed_at = NULL \
|
|
||||||
WHERE id = $1 AND status = 'failed' \
|
|
||||||
RETURNING id, article_id, follow_id, target_workspace_id, target_channel_id, \
|
|
||||||
status, attempts, last_error, sent_at, delivered_at, failed_at, created_at",
|
|
||||||
)
|
|
||||||
.bind(cross_post_id)
|
|
||||||
.fetch_one(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
self.follow_realtime(channel_id, post.follow_id, FollowAction::Retried)
|
|
||||||
.await;
|
|
||||||
Ok(post)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::channels::ForumTag;
|
||||||
|
use crate::service::ImService;
|
||||||
|
|
||||||
|
use super::session::ImSession;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
|
pub struct CreateForumTagParams {
|
||||||
|
pub name: String,
|
||||||
|
pub emoji_id: Option<String>,
|
||||||
|
pub emoji_name: Option<String>,
|
||||||
|
pub moderated: Option<bool>,
|
||||||
|
pub position: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
|
pub struct UpdateForumTagParams {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub emoji_id: Option<String>,
|
||||||
|
pub emoji_name: Option<String>,
|
||||||
|
pub moderated: Option<bool>,
|
||||||
|
pub position: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImService {
|
||||||
|
pub async fn forum_tag_list(
|
||||||
|
&self,
|
||||||
|
_ctx: &ImSession,
|
||||||
|
channel_id: Uuid,
|
||||||
|
) -> Result<Vec<ForumTag>, AppError> {
|
||||||
|
sqlx::query_as::<_, ForumTag>(
|
||||||
|
"SELECT id, channel_id, name, emoji_id, emoji_name, moderated, position, \
|
||||||
|
created_by, created_at, updated_at \
|
||||||
|
FROM forum_tag WHERE channel_id = $1 ORDER BY position, name",
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.fetch_all(self.ctx.db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn forum_tag_create(
|
||||||
|
&self,
|
||||||
|
ctx: &ImSession,
|
||||||
|
channel_id: Uuid,
|
||||||
|
params: CreateForumTagParams,
|
||||||
|
) -> Result<ForumTag, AppError> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
sqlx::query_as::<_, ForumTag>(
|
||||||
|
"INSERT INTO forum_tag \
|
||||||
|
(id, channel_id, name, emoji_id, emoji_name, moderated, position, \
|
||||||
|
created_by, created_at, updated_at) \
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) \
|
||||||
|
RETURNING id, channel_id, name, emoji_id, emoji_name, moderated, position, \
|
||||||
|
created_by, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(Uuid::now_v7())
|
||||||
|
.bind(channel_id)
|
||||||
|
.bind(¶ms.name)
|
||||||
|
.bind(params.emoji_id.as_deref())
|
||||||
|
.bind(params.emoji_name.as_deref())
|
||||||
|
.bind(params.moderated.unwrap_or(false))
|
||||||
|
.bind(params.position.unwrap_or(0))
|
||||||
|
.bind(ctx.user)
|
||||||
|
.bind(now)
|
||||||
|
.fetch_one(self.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn forum_tag_update(
|
||||||
|
&self,
|
||||||
|
_ctx: &ImSession,
|
||||||
|
tag_id: Uuid,
|
||||||
|
params: UpdateForumTagParams,
|
||||||
|
) -> Result<ForumTag, AppError> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
sqlx::query_as::<_, ForumTag>(
|
||||||
|
"UPDATE forum_tag SET \
|
||||||
|
name = COALESCE($1, name), \
|
||||||
|
emoji_id = COALESCE($2, emoji_id), \
|
||||||
|
emoji_name = COALESCE($3, emoji_name), \
|
||||||
|
moderated = COALESCE($4, moderated), \
|
||||||
|
position = COALESCE($5, position), \
|
||||||
|
updated_at = $6 \
|
||||||
|
WHERE id = $7 \
|
||||||
|
RETURNING id, channel_id, name, emoji_id, emoji_name, moderated, position, \
|
||||||
|
created_by, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(params.name.as_deref())
|
||||||
|
.bind(params.emoji_id.as_deref())
|
||||||
|
.bind(params.emoji_name.as_deref())
|
||||||
|
.bind(params.moderated)
|
||||||
|
.bind(params.position)
|
||||||
|
.bind(now)
|
||||||
|
.bind(tag_id)
|
||||||
|
.fetch_one(self.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn forum_tag_delete(
|
||||||
|
&self,
|
||||||
|
_ctx: &ImSession,
|
||||||
|
tag_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query("DELETE FROM forum_tag WHERE id = $1")
|
||||||
|
.bind(tag_id)
|
||||||
|
.execute(self.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::channels::ImIntegration;
|
||||||
|
use crate::service::ImService;
|
||||||
|
|
||||||
|
use super::session::ImSession;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
|
pub struct CreateIntegrationParams {
|
||||||
|
pub provider: String,
|
||||||
|
pub name: String,
|
||||||
|
pub external_workspace_id: Option<String>,
|
||||||
|
pub internal_channel_id: Option<Uuid>,
|
||||||
|
pub external_channel_id: Option<String>,
|
||||||
|
pub bot_token: Option<String>,
|
||||||
|
pub webhook_url: Option<String>,
|
||||||
|
pub sync_direction: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
|
pub struct UpdateIntegrationParams {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub external_channel_id: Option<String>,
|
||||||
|
pub webhook_url: Option<String>,
|
||||||
|
pub sync_direction: Option<String>,
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImService {
|
||||||
|
pub async fn integration_list(
|
||||||
|
&self,
|
||||||
|
_ctx: &ImSession,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
) -> Result<Vec<ImIntegration>, AppError> {
|
||||||
|
sqlx::query_as::<_, ImIntegration>(
|
||||||
|
"SELECT id, workspace_id, provider, name, external_workspace_id, \
|
||||||
|
internal_channel_id, external_channel_id, bot_token_ciphertext, webhook_url, \
|
||||||
|
sync_direction, user_mapping, enabled, installed_by, last_sync_at, \
|
||||||
|
created_at, updated_at \
|
||||||
|
FROM im_integration WHERE workspace_id = $1 ORDER BY created_at",
|
||||||
|
)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.fetch_all(self.ctx.db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn integration_create(
|
||||||
|
&self,
|
||||||
|
ctx: &ImSession,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
params: CreateIntegrationParams,
|
||||||
|
) -> Result<ImIntegration, AppError> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
sqlx::query_as::<_, ImIntegration>(
|
||||||
|
"INSERT INTO im_integration \
|
||||||
|
(id, workspace_id, provider, name, external_workspace_id, \
|
||||||
|
internal_channel_id, external_channel_id, bot_token_ciphertext, webhook_url, \
|
||||||
|
sync_direction, enabled, installed_by, created_at, updated_at) \
|
||||||
|
VALUES ($1, $2, $3::provider, $4, $5, $6, $7, $8, $9, $10::sync_direction, \
|
||||||
|
true, $11, $12, $12) \
|
||||||
|
RETURNING id, workspace_id, provider, name, external_workspace_id, \
|
||||||
|
internal_channel_id, external_channel_id, bot_token_ciphertext, webhook_url, \
|
||||||
|
sync_direction, user_mapping, enabled, installed_by, last_sync_at, \
|
||||||
|
created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(Uuid::now_v7())
|
||||||
|
.bind(workspace_id)
|
||||||
|
.bind(¶ms.provider)
|
||||||
|
.bind(¶ms.name)
|
||||||
|
.bind(params.external_workspace_id.as_deref())
|
||||||
|
.bind(params.internal_channel_id)
|
||||||
|
.bind(params.external_channel_id.as_deref())
|
||||||
|
.bind(params.bot_token.as_deref())
|
||||||
|
.bind(params.webhook_url.as_deref())
|
||||||
|
.bind(¶ms.sync_direction)
|
||||||
|
.bind(ctx.user)
|
||||||
|
.bind(now)
|
||||||
|
.fetch_one(self.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn integration_update(
|
||||||
|
&self,
|
||||||
|
_ctx: &ImSession,
|
||||||
|
integration_id: Uuid,
|
||||||
|
params: UpdateIntegrationParams,
|
||||||
|
) -> Result<ImIntegration, AppError> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
sqlx::query_as::<_, ImIntegration>(
|
||||||
|
"UPDATE im_integration SET \
|
||||||
|
name = COALESCE($1, name), \
|
||||||
|
external_channel_id = COALESCE($2, external_channel_id), \
|
||||||
|
webhook_url = COALESCE($3, webhook_url), \
|
||||||
|
sync_direction = COALESCE($4::sync_direction, sync_direction), \
|
||||||
|
enabled = COALESCE($5, enabled), \
|
||||||
|
updated_at = $6 \
|
||||||
|
WHERE id = $7 \
|
||||||
|
RETURNING id, workspace_id, provider, name, external_workspace_id, \
|
||||||
|
internal_channel_id, external_channel_id, bot_token_ciphertext, webhook_url, \
|
||||||
|
sync_direction, user_mapping, enabled, installed_by, last_sync_at, \
|
||||||
|
created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(params.name.as_deref())
|
||||||
|
.bind(params.external_channel_id.as_deref())
|
||||||
|
.bind(params.webhook_url.as_deref())
|
||||||
|
.bind(params.sync_direction.as_deref())
|
||||||
|
.bind(params.enabled)
|
||||||
|
.bind(now)
|
||||||
|
.bind(integration_id)
|
||||||
|
.fetch_one(self.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn integration_delete(
|
||||||
|
&self,
|
||||||
|
_ctx: &ImSession,
|
||||||
|
integration_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query("DELETE FROM im_integration WHERE id = $1")
|
||||||
|
.bind(integration_id)
|
||||||
|
.execute(self.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-39
@@ -2,7 +2,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::error::AppError;
|
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::channels::ChannelMember;
|
||||||
use crate::models::common::Role;
|
use crate::models::common::Role;
|
||||||
use crate::models::workspaces::Workspace;
|
use crate::models::workspaces::Workspace;
|
||||||
@@ -93,8 +94,7 @@ impl ImService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -115,13 +115,15 @@ impl ImService {
|
|||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.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)?;
|
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||||
tracing::info!(channel_id = %channel_id, user_id = %params.user_id, "Member invited");
|
tracing::info!(channel_id = %channel_id, user_id = %params.user_id, "Member invited");
|
||||||
let request_id = Uuid::nil();
|
let request_id = Uuid::nil();
|
||||||
let event = MemberEvent {
|
let event = MemberEvent {
|
||||||
channel_id,
|
channel_id,
|
||||||
|
user: UserBaseInfo::placeholder(member.user_id),
|
||||||
user_id: member.user_id,
|
user_id: member.user_id,
|
||||||
action: MemberAction::Joined,
|
action: MemberAction::Joined,
|
||||||
};
|
};
|
||||||
@@ -184,6 +186,7 @@ impl ImService {
|
|||||||
let request_id = Uuid::nil();
|
let request_id = Uuid::nil();
|
||||||
let event = MemberEvent {
|
let event = MemberEvent {
|
||||||
channel_id,
|
channel_id,
|
||||||
|
user: UserBaseInfo::placeholder(member.user_id),
|
||||||
user_id: member.user_id,
|
user_id: member.user_id,
|
||||||
action: MemberAction::Updated,
|
action: MemberAction::Updated,
|
||||||
};
|
};
|
||||||
@@ -227,8 +230,7 @@ impl ImService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -245,13 +247,15 @@ impl ImService {
|
|||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
ensure_affected(result.rows_affected(), "member not found")?;
|
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)?;
|
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||||
|
|
||||||
tracing::info!(channel_id = %channel_id, user_id = %member_user_id, "Member kicked");
|
tracing::info!(channel_id = %channel_id, user_id = %member_user_id, "Member kicked");
|
||||||
let request_id = Uuid::nil();
|
let request_id = Uuid::nil();
|
||||||
let event = MemberEvent {
|
let event = MemberEvent {
|
||||||
channel_id,
|
channel_id,
|
||||||
|
user: UserBaseInfo::placeholder(member_user_id),
|
||||||
user_id: member_user_id,
|
user_id: member_user_id,
|
||||||
action: MemberAction::Kicked,
|
action: MemberAction::Kicked,
|
||||||
};
|
};
|
||||||
@@ -285,8 +289,7 @@ impl ImService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -303,11 +306,13 @@ impl ImService {
|
|||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
ensure_affected(result.rows_affected(), "not a member")?;
|
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)?;
|
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||||
let request_id = Uuid::nil();
|
let request_id = Uuid::nil();
|
||||||
let event = MemberEvent {
|
let event = MemberEvent {
|
||||||
channel_id,
|
channel_id,
|
||||||
|
user: UserBaseInfo::placeholder(user_uid),
|
||||||
user_id: user_uid,
|
user_id: user_uid,
|
||||||
action: MemberAction::Left,
|
action: MemberAction::Left,
|
||||||
};
|
};
|
||||||
@@ -343,8 +348,7 @@ impl ImService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -364,11 +368,13 @@ impl ImService {
|
|||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.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)?;
|
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||||
let request_id = Uuid::nil();
|
let request_id = Uuid::nil();
|
||||||
let event = MemberEvent {
|
let event = MemberEvent {
|
||||||
channel_id,
|
channel_id,
|
||||||
|
user: UserBaseInfo::placeholder(member.user_id),
|
||||||
user_id: member.user_id,
|
user_id: member.user_id,
|
||||||
action: MemberAction::Joined,
|
action: MemberAction::Joined,
|
||||||
};
|
};
|
||||||
@@ -381,30 +387,4 @@ impl ImService {
|
|||||||
Ok(member)
|
Ok(member)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn member_update_read(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
message_id: Uuid,
|
|
||||||
) -> Result<ChannelMember, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
sqlx::query_as::<_, ChannelMember>(
|
|
||||||
"UPDATE channel_member SET last_read_message_id = $1, last_read_at = $2, updated_at = $2 \
|
|
||||||
WHERE channel_id = $3 AND user_id = $4 AND status = 'active' \
|
|
||||||
RETURNING id, channel_id, user_id, role, status, muted, pinned, \
|
|
||||||
last_read_message_id, last_read_at, joined_at, left_at, created_at, updated_at",
|
|
||||||
)
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(now)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(user_uid)
|
|
||||||
.fetch_one(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,889 +0,0 @@
|
|||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::immediate::{MessageAction, MessageEvent};
|
|
||||||
use crate::models::channels::{Message, MessageBookmark, MessageEditHistory, SavedMessage};
|
|
||||||
use crate::models::common::{JsonValue, MessageType};
|
|
||||||
use crate::service::ImService;
|
|
||||||
use crate::service::im::delivery_trace::trace_message;
|
|
||||||
use crate::service::im::events::ImEvent;
|
|
||||||
use ::redis::Cmd;
|
|
||||||
|
|
||||||
use super::session::ImSession;
|
|
||||||
use super::util::*;
|
|
||||||
|
|
||||||
const MESSAGE_SEQ_SCRIPT: &str = "local cur = redis.call('GET', KEYS[1]); if (not cur) or (tonumber(cur) < tonumber(ARGV[1])) then redis.call('SET', KEYS[1], ARGV[1]); end; return redis.call('INCR', KEYS[1]);";
|
|
||||||
static MESSAGE_SEQ_SHA: OnceLock<String> = OnceLock::new();
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct SendMessageParams {
|
|
||||||
pub body: String,
|
|
||||||
pub message_type: Option<String>,
|
|
||||||
pub thread_id: Option<Uuid>,
|
|
||||||
pub reply_to_message_id: Option<Uuid>,
|
|
||||||
pub pinned: Option<bool>,
|
|
||||||
pub attachments: Option<Vec<CreateAttachmentParams>>,
|
|
||||||
pub embeds: Option<Vec<CreateEmbedParams>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct EditMessageParams {
|
|
||||||
pub body: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct CreateAttachmentParams {
|
|
||||||
pub filename: String,
|
|
||||||
pub url: String,
|
|
||||||
pub proxy_url: Option<String>,
|
|
||||||
pub size_bytes: i64,
|
|
||||||
pub mime_type: String,
|
|
||||||
pub width: Option<i32>,
|
|
||||||
pub height: Option<i32>,
|
|
||||||
pub duration_ms: Option<i64>,
|
|
||||||
pub thumbnail_url: Option<String>,
|
|
||||||
pub blurhash: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct CreateEmbedParams {
|
|
||||||
pub embed_type: Option<String>,
|
|
||||||
pub title: Option<String>,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub url: Option<String>,
|
|
||||||
pub author_name: Option<String>,
|
|
||||||
pub author_url: Option<String>,
|
|
||||||
pub author_icon_url: Option<String>,
|
|
||||||
pub thumbnail_url: Option<String>,
|
|
||||||
pub image_url: Option<String>,
|
|
||||||
pub color: Option<i32>,
|
|
||||||
pub fields: Option<JsonValue>,
|
|
||||||
pub footer_text: Option<String>,
|
|
||||||
pub footer_icon_url: Option<String>,
|
|
||||||
pub provider_name: Option<String>,
|
|
||||||
pub timestamp: Option<chrono::DateTime<chrono::Utc>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct MessageListFilters {
|
|
||||||
pub thread_id: Option<Uuid>,
|
|
||||||
pub author_id: Option<Uuid>,
|
|
||||||
pub pinned: Option<bool>,
|
|
||||||
pub before: Option<Uuid>,
|
|
||||||
pub after: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImService {
|
|
||||||
pub async fn message_list(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
filters: MessageListFilters,
|
|
||||||
limit: i64,
|
|
||||||
offset: i64,
|
|
||||||
) -> Result<Vec<Message>, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
|
||||||
|
|
||||||
sqlx::query_as::<_, Message>(
|
|
||||||
"SELECT id, channel_id, author_id, thread_id, reply_to_message_id, seq, \
|
|
||||||
message_type, body, metadata, pinned, system, edited_at, deleted_at, \
|
|
||||||
created_at, updated_at \
|
|
||||||
FROM message \
|
|
||||||
WHERE channel_id = $1 AND deleted_at IS NULL \
|
|
||||||
AND ($2::uuid IS NULL OR thread_id = $2) \
|
|
||||||
AND ($3::uuid IS NULL OR author_id = $3) \
|
|
||||||
AND ($4::bool IS NULL OR pinned = $4) \
|
|
||||||
AND ($5::uuid IS NULL OR seq < (SELECT seq FROM message WHERE id = $5)) \
|
|
||||||
AND ($6::uuid IS NULL OR seq > (SELECT seq FROM message WHERE id = $6)) \
|
|
||||||
ORDER BY seq DESC LIMIT $7 OFFSET $8",
|
|
||||||
)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(filters.thread_id)
|
|
||||||
.bind(filters.author_id)
|
|
||||||
.bind(filters.pinned)
|
|
||||||
.bind(filters.before)
|
|
||||||
.bind(filters.after)
|
|
||||||
.bind(limit)
|
|
||||||
.bind(offset)
|
|
||||||
.fetch_all(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tracing::instrument(skip(self, ctx, params))]
|
|
||||||
pub async fn message_send(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
params: SendMessageParams,
|
|
||||||
request_id: Uuid,
|
|
||||||
) -> Result<Message, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_member(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
if channel.read_only {
|
|
||||||
self.ensure_channel_editable(user_uid, &channel).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let body = required_text(params.body, "body")?;
|
|
||||||
if body.len() > MAX_MESSAGE_BODY {
|
|
||||||
return Err(AppError::BadRequest("message body too long".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let msg_type = parse_enum(
|
|
||||||
params.message_type,
|
|
||||||
MessageType::Text,
|
|
||||||
MessageType::Unknown,
|
|
||||||
"message_type",
|
|
||||||
)?;
|
|
||||||
let thread_id = params.thread_id;
|
|
||||||
if let Some(thread_id) = thread_id {
|
|
||||||
self.resolve_thread(thread_id, channel_id).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let message_id = Uuid::now_v7();
|
|
||||||
let seq = self.next_message_seq(channel_id).await?;
|
|
||||||
|
|
||||||
let mut txn = self
|
|
||||||
.ctx
|
|
||||||
.db
|
|
||||||
.writer()
|
|
||||||
.begin()
|
|
||||||
.await
|
|
||||||
.map_err(|_| AppError::TxnError)?;
|
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
let message = sqlx::query_as::<_, Message>(
|
|
||||||
"INSERT INTO message \
|
|
||||||
(id, channel_id, author_id, thread_id, reply_to_message_id, seq, \
|
|
||||||
message_type, body, metadata, pinned, system, created_at, updated_at) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL, $9, false, $10, $10) \
|
|
||||||
RETURNING id, channel_id, author_id, thread_id, reply_to_message_id, seq, \
|
|
||||||
message_type, body, metadata, pinned, system, edited_at, deleted_at, \
|
|
||||||
created_at, updated_at",
|
|
||||||
)
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(thread_id)
|
|
||||||
.bind(params.reply_to_message_id)
|
|
||||||
.bind(seq)
|
|
||||||
.bind(msg_type)
|
|
||||||
.bind(&body)
|
|
||||||
.bind(params.pinned.unwrap_or(false))
|
|
||||||
.bind(now)
|
|
||||||
.fetch_one(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
// Insert attachments
|
|
||||||
if let Some(attachments) = params.attachments {
|
|
||||||
for att in &attachments {
|
|
||||||
let att_filename = required_text(att.filename.clone(), "filename")?;
|
|
||||||
let att_url = required_text(att.url.clone(), "url")?;
|
|
||||||
let att_mime = required_text(att.mime_type.clone(), "mime_type")?;
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO message_attachment \
|
|
||||||
(id, message_id, channel_id, filename, url, proxy_url, \
|
|
||||||
size_bytes, mime_type, width, height, duration_ms, \
|
|
||||||
thumbnail_url, blurhash, created_at) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)",
|
|
||||||
)
|
|
||||||
.bind(Uuid::now_v7())
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(&att_filename)
|
|
||||||
.bind(&att_url)
|
|
||||||
.bind(att.proxy_url.as_deref())
|
|
||||||
.bind(att.size_bytes)
|
|
||||||
.bind(&att_mime)
|
|
||||||
.bind(att.width)
|
|
||||||
.bind(att.height)
|
|
||||||
.bind(att.duration_ms)
|
|
||||||
.bind(att.thumbnail_url.as_deref())
|
|
||||||
.bind(att.blurhash.as_deref())
|
|
||||||
.bind(now)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert embeds
|
|
||||||
if let Some(embeds) = params.embeds {
|
|
||||||
for emb in &embeds {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO message_embed \
|
|
||||||
(id, message_id, embed_type, title, description, url, \
|
|
||||||
author_name, author_url, author_icon_url, thumbnail_url, \
|
|
||||||
image_url, color, fields, footer_text, footer_icon_url, \
|
|
||||||
provider_name, \"timestamp\", created_at) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, \
|
|
||||||
$11, $12, $13, $14, $15, $16, $17, $18)",
|
|
||||||
)
|
|
||||||
.bind(Uuid::now_v7())
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(emb.embed_type.as_deref().unwrap_or("rich"))
|
|
||||||
.bind(emb.title.as_deref())
|
|
||||||
.bind(emb.description.as_deref())
|
|
||||||
.bind(emb.url.as_deref())
|
|
||||||
.bind(emb.author_name.as_deref())
|
|
||||||
.bind(emb.author_url.as_deref())
|
|
||||||
.bind(emb.author_icon_url.as_deref())
|
|
||||||
.bind(emb.thumbnail_url.as_deref())
|
|
||||||
.bind(emb.image_url.as_deref())
|
|
||||||
.bind(emb.color)
|
|
||||||
.bind(emb.fields.clone())
|
|
||||||
.bind(emb.footer_text.as_deref())
|
|
||||||
.bind(emb.footer_icon_url.as_deref())
|
|
||||||
.bind(emb.provider_name.as_deref())
|
|
||||||
.bind(emb.timestamp)
|
|
||||||
.bind(now)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(thread_id) = thread_id {
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE message_thread SET replies_count = replies_count + 1, \
|
|
||||||
participants_count = (SELECT COUNT(DISTINCT author_id)::int FROM message WHERE thread_id = $3 AND deleted_at IS NULL), \
|
|
||||||
last_reply_message_id = $1, last_reply_at = $2, updated_at = $2 \
|
|
||||||
WHERE id = $3 AND channel_id = $4",
|
|
||||||
)
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(now)
|
|
||||||
.bind(thread_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update channel last_message
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE channel SET last_message_id = $1, last_message_at = $2, updated_at = $2 \
|
|
||||||
WHERE id = $3",
|
|
||||||
)
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(now)
|
|
||||||
.bind(channel_id)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
self.update_channel_stats(channel_id, now, &mut txn).await?;
|
|
||||||
|
|
||||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
|
||||||
tracing::info!(message_id = %message_id, channel_id = %channel_id, "Message sent");
|
|
||||||
trace_message(
|
|
||||||
"committed",
|
|
||||||
request_id,
|
|
||||||
channel_id,
|
|
||||||
message.id,
|
|
||||||
Some(message.seq),
|
|
||||||
);
|
|
||||||
|
|
||||||
let event = MessageEvent {
|
|
||||||
channel_id,
|
|
||||||
thread_id: message.thread_id,
|
|
||||||
message_id: message.id,
|
|
||||||
author_id: message.author_id,
|
|
||||||
action: MessageAction::Created,
|
|
||||||
body: Some(message.body.clone()),
|
|
||||||
seq: Some(message.seq),
|
|
||||||
};
|
|
||||||
self.publish(&format!("im.message.{}", channel_id), request_id, &event)
|
|
||||||
.await;
|
|
||||||
self.emit_event(ImEvent::Message {
|
|
||||||
request_id,
|
|
||||||
data: event,
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn message_edit(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
message_id: Uuid,
|
|
||||||
params: EditMessageParams,
|
|
||||||
request_id: Uuid,
|
|
||||||
) -> Result<Message, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
let body = required_text(params.body, "body")?;
|
|
||||||
if body.len() > MAX_MESSAGE_BODY {
|
|
||||||
return Err(AppError::BadRequest("message body too long".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let existing = self.resolve_message(message_id, channel_id).await?;
|
|
||||||
if existing.author_id != user_uid {
|
|
||||||
self.ensure_channel_admin(user_uid, &channel).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let mut txn = self
|
|
||||||
.ctx
|
|
||||||
.db
|
|
||||||
.writer()
|
|
||||||
.begin()
|
|
||||||
.await
|
|
||||||
.map_err(|_| AppError::TxnError)?;
|
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
// Save edit history
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO message_edit_history (id, message_id, channel_id, previous_body, edited_by, edited_at) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
|
||||||
)
|
|
||||||
.bind(Uuid::now_v7())
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(&existing.body)
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(now)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
let updated = sqlx::query_as::<_, Message>(
|
|
||||||
"UPDATE message SET body = $1, edited_at = $2, updated_at = $2 \
|
|
||||||
WHERE id = $3 AND channel_id = $4 AND deleted_at IS NULL \
|
|
||||||
RETURNING id, channel_id, author_id, thread_id, reply_to_message_id, seq, \
|
|
||||||
message_type, body, metadata, pinned, system, edited_at, deleted_at, \
|
|
||||||
created_at, updated_at",
|
|
||||||
)
|
|
||||||
.bind(&body)
|
|
||||||
.bind(now)
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.fetch_one(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
|
||||||
|
|
||||||
let event = MessageEvent {
|
|
||||||
channel_id,
|
|
||||||
thread_id: updated.thread_id,
|
|
||||||
message_id: updated.id,
|
|
||||||
author_id: updated.author_id,
|
|
||||||
action: MessageAction::Edited,
|
|
||||||
body: Some(updated.body.clone()),
|
|
||||||
seq: Some(updated.seq),
|
|
||||||
};
|
|
||||||
self.publish(&format!("im.message.{}", channel_id), request_id, &event)
|
|
||||||
.await;
|
|
||||||
self.emit_event(ImEvent::Message {
|
|
||||||
request_id,
|
|
||||||
data: event,
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn message_delete(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
message_id: Uuid,
|
|
||||||
request_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
|
|
||||||
let existing = self.resolve_message(message_id, channel_id).await?;
|
|
||||||
if existing.author_id != user_uid {
|
|
||||||
self.ensure_channel_admin(user_uid, &channel).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let mut txn = self
|
|
||||||
.ctx
|
|
||||||
.db
|
|
||||||
.writer()
|
|
||||||
.begin()
|
|
||||||
.await
|
|
||||||
.map_err(|_| AppError::TxnError)?;
|
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
let result = sqlx::query(
|
|
||||||
"UPDATE message SET deleted_at = $1, updated_at = $1 \
|
|
||||||
WHERE id = $2 AND channel_id = $3 AND deleted_at IS NULL",
|
|
||||||
)
|
|
||||||
.bind(now)
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
ensure_affected(result.rows_affected(), "message not found")?;
|
|
||||||
|
|
||||||
self.update_channel_stats(channel_id, now, &mut txn).await?;
|
|
||||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
|
||||||
|
|
||||||
let event = MessageEvent {
|
|
||||||
channel_id,
|
|
||||||
thread_id: None,
|
|
||||||
message_id,
|
|
||||||
author_id: existing.author_id,
|
|
||||||
action: MessageAction::Deleted,
|
|
||||||
body: None,
|
|
||||||
seq: Some(existing.seq),
|
|
||||||
};
|
|
||||||
self.publish(&format!("im.message.{}", channel_id), request_id, &event)
|
|
||||||
.await;
|
|
||||||
self.emit_event(ImEvent::Message {
|
|
||||||
request_id,
|
|
||||||
data: event,
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn message_pin(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
message_id: Uuid,
|
|
||||||
request_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_editable(user_uid, &channel).await?;
|
|
||||||
let message = self.resolve_message(message_id, channel_id).await?;
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let mut txn = self
|
|
||||||
.ctx
|
|
||||||
.db
|
|
||||||
.writer()
|
|
||||||
.begin()
|
|
||||||
.await
|
|
||||||
.map_err(|_| AppError::TxnError)?;
|
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
sqlx::query("UPDATE message SET pinned = true, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL")
|
|
||||||
.bind(now)
|
|
||||||
.bind(message_id)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO message_pin (id, message_id, channel_id, pinned_by, pinned_at) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5) \
|
|
||||||
ON CONFLICT (message_id) DO NOTHING",
|
|
||||||
)
|
|
||||||
.bind(Uuid::now_v7())
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(now)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
|
||||||
|
|
||||||
let event = MessageEvent {
|
|
||||||
channel_id,
|
|
||||||
thread_id: None,
|
|
||||||
message_id,
|
|
||||||
author_id: ctx.user,
|
|
||||||
action: MessageAction::Pinned,
|
|
||||||
body: None,
|
|
||||||
seq: Some(message.seq),
|
|
||||||
};
|
|
||||||
self.publish(&format!("im.message.{}", channel_id), request_id, &event)
|
|
||||||
.await;
|
|
||||||
self.emit_event(ImEvent::Message {
|
|
||||||
request_id,
|
|
||||||
data: event,
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn message_unpin(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
message_id: Uuid,
|
|
||||||
request_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_editable(user_uid, &channel).await?;
|
|
||||||
let message = self.resolve_message(message_id, channel_id).await?;
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let mut txn = self
|
|
||||||
.ctx
|
|
||||||
.db
|
|
||||||
.writer()
|
|
||||||
.begin()
|
|
||||||
.await
|
|
||||||
.map_err(|_| AppError::TxnError)?;
|
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
sqlx::query("UPDATE message SET pinned = false, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL")
|
|
||||||
.bind(now)
|
|
||||||
.bind(message_id)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
sqlx::query("DELETE FROM message_pin WHERE message_id = $1")
|
|
||||||
.bind(message_id)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
|
||||||
|
|
||||||
let event = MessageEvent {
|
|
||||||
channel_id,
|
|
||||||
thread_id: None,
|
|
||||||
message_id,
|
|
||||||
author_id: ctx.user,
|
|
||||||
action: MessageAction::Unpinned,
|
|
||||||
body: None,
|
|
||||||
seq: Some(message.seq),
|
|
||||||
};
|
|
||||||
self.publish(&format!("im.message.{}", channel_id), request_id, &event)
|
|
||||||
.await;
|
|
||||||
self.emit_event(ImEvent::Message {
|
|
||||||
request_id,
|
|
||||||
data: event,
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn message_list_pinned(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
) -> Result<Vec<Message>, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
sqlx::query_as::<_, Message>(
|
|
||||||
"SELECT m.id, m.channel_id, m.author_id, m.thread_id, m.reply_to_message_id, m.seq, \
|
|
||||||
m.message_type, m.body, m.metadata, m.pinned, m.system, m.edited_at, m.deleted_at, \
|
|
||||||
m.created_at, m.updated_at \
|
|
||||||
FROM message m \
|
|
||||||
JOIN message_pin mp ON mp.message_id = m.id \
|
|
||||||
WHERE m.channel_id = $1 AND m.deleted_at IS NULL AND m.pinned \
|
|
||||||
ORDER BY mp.pinned_at DESC",
|
|
||||||
)
|
|
||||||
.bind(channel_id)
|
|
||||||
.fetch_all(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn message_edit_history(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
message_id: Uuid,
|
|
||||||
) -> Result<Vec<MessageEditHistory>, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
sqlx::query_as::<_, MessageEditHistory>(
|
|
||||||
"SELECT id, message_id, channel_id, previous_body, edited_by, edited_at \
|
|
||||||
FROM message_edit_history \
|
|
||||||
WHERE message_id = $1 AND channel_id = $2 \
|
|
||||||
ORDER BY edited_at ASC",
|
|
||||||
)
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.fetch_all(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn message_bookmark(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
message_id: Uuid,
|
|
||||||
note: Option<String>,
|
|
||||||
) -> Result<MessageBookmark, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
self.resolve_message(message_id, channel_id).await?;
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
sqlx::query_as::<_, MessageBookmark>(
|
|
||||||
"INSERT INTO message_bookmark (id, message_id, channel_id, user_id, note, created_at, updated_at) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $6) \
|
|
||||||
ON CONFLICT (message_id, user_id) DO UPDATE SET note = COALESCE($5, message_bookmark.note), updated_at = $6 \
|
|
||||||
RETURNING id, message_id, channel_id, user_id, note, created_at, updated_at",
|
|
||||||
)
|
|
||||||
.bind(Uuid::now_v7())
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(note.as_deref())
|
|
||||||
.bind(now)
|
|
||||||
.fetch_one(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn message_unbookmark(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
message_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
let result = sqlx::query(
|
|
||||||
"DELETE FROM message_bookmark WHERE message_id = $1 AND user_id = $2 AND channel_id = $3",
|
|
||||||
)
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(channel_id)
|
|
||||||
.execute(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
ensure_affected(result.rows_affected(), "bookmark not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn message_list_bookmarks(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
wk_name: &str,
|
|
||||||
limit: i64,
|
|
||||||
offset: i64,
|
|
||||||
) -> Result<Vec<MessageBookmark>, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let ws = self.resolve_workspace(wk_name).await?;
|
|
||||||
self.ensure_workspace_readable(user_uid, &ws).await?;
|
|
||||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
|
||||||
|
|
||||||
sqlx::query_as::<_, MessageBookmark>(
|
|
||||||
"SELECT mb.id, mb.message_id, mb.channel_id, mb.user_id, mb.note, mb.created_at, mb.updated_at \
|
|
||||||
FROM message_bookmark mb \
|
|
||||||
JOIN channel c ON c.id = mb.channel_id \
|
|
||||||
WHERE mb.user_id = $1 AND c.workspace_id = $2 \
|
|
||||||
ORDER BY mb.created_at DESC LIMIT $3 OFFSET $4",
|
|
||||||
)
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(ws.id)
|
|
||||||
.bind(limit)
|
|
||||||
.bind(offset)
|
|
||||||
.fetch_all(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn message_save(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
message_id: Uuid,
|
|
||||||
note: Option<String>,
|
|
||||||
) -> Result<SavedMessage, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
self.resolve_message(message_id, channel_id).await?;
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
sqlx::query_as::<_, SavedMessage>(
|
|
||||||
"INSERT INTO saved_message (id, user_id, message_id, channel_id, note, created_at) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6) \
|
|
||||||
ON CONFLICT (user_id, message_id) DO NOTHING \
|
|
||||||
RETURNING id, user_id, message_id, channel_id, note, created_at",
|
|
||||||
)
|
|
||||||
.bind(Uuid::now_v7())
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(note.as_deref())
|
|
||||||
.bind(now)
|
|
||||||
.fetch_one(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn message_unsave(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
wk_name: &str,
|
|
||||||
message_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let ws = self.resolve_workspace(wk_name).await?;
|
|
||||||
self.ensure_workspace_readable(user_uid, &ws).await?;
|
|
||||||
|
|
||||||
let result =
|
|
||||||
sqlx::query("DELETE FROM saved_message WHERE user_id = $1 AND message_id = $2")
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(message_id)
|
|
||||||
.execute(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
ensure_affected(result.rows_affected(), "saved message not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn message_list_saved(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
wk_name: &str,
|
|
||||||
limit: i64,
|
|
||||||
offset: i64,
|
|
||||||
) -> Result<Vec<SavedMessage>, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let ws = self.resolve_workspace(wk_name).await?;
|
|
||||||
self.ensure_workspace_readable(user_uid, &ws).await?;
|
|
||||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
|
||||||
|
|
||||||
sqlx::query_as::<_, SavedMessage>(
|
|
||||||
"SELECT sm.id, sm.user_id, sm.message_id, sm.channel_id, sm.note, sm.created_at \
|
|
||||||
FROM saved_message sm \
|
|
||||||
JOIN channel c ON c.id = sm.channel_id \
|
|
||||||
WHERE sm.user_id = $1 AND c.workspace_id = $2 \
|
|
||||||
ORDER BY sm.created_at DESC LIMIT $3 OFFSET $4",
|
|
||||||
)
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(ws.id)
|
|
||||||
.bind(limit)
|
|
||||||
.bind(offset)
|
|
||||||
.fetch_all(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn next_message_seq(&self, channel_id: Uuid) -> Result<i64, AppError> {
|
|
||||||
let key = format!("im:seq:{channel_id}");
|
|
||||||
let mut conn = self.ctx.redis.get_connection()?;
|
|
||||||
let exists: bool = Cmd::new()
|
|
||||||
.arg("EXISTS")
|
|
||||||
.arg(&key)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?;
|
|
||||||
let db_max = if exists {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
sqlx::query_scalar(
|
|
||||||
"SELECT COALESCE(MAX(seq), 0)::bigint FROM message WHERE channel_id = $1",
|
|
||||||
)
|
|
||||||
.bind(channel_id)
|
|
||||||
.fetch_one(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?
|
|
||||||
};
|
|
||||||
let sha = self.message_seq_sha()?;
|
|
||||||
let result: Result<i64, redis::RedisError> = Cmd::new()
|
|
||||||
.arg("EVALSHA")
|
|
||||||
.arg(&sha)
|
|
||||||
.arg(1)
|
|
||||||
.arg(&key)
|
|
||||||
.arg(db_max)
|
|
||||||
.query(&mut *conn.inner_mut());
|
|
||||||
match result {
|
|
||||||
Ok(seq) => Ok(seq),
|
|
||||||
Err(e) if e.to_string().contains("NOSCRIPT") => Cmd::new()
|
|
||||||
.arg("EVAL")
|
|
||||||
.arg(MESSAGE_SEQ_SCRIPT)
|
|
||||||
.arg(1)
|
|
||||||
.arg(&key)
|
|
||||||
.arg(db_max)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis),
|
|
||||||
Err(e) => Err(AppError::Redis(e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn message_seq_sha(&self) -> Result<String, AppError> {
|
|
||||||
if let Some(sha) = MESSAGE_SEQ_SHA.get() {
|
|
||||||
return Ok(sha.clone());
|
|
||||||
}
|
|
||||||
let mut conn = self.ctx.redis.get_connection()?;
|
|
||||||
let sha: String = Cmd::new()
|
|
||||||
.arg("SCRIPT")
|
|
||||||
.arg("LOAD")
|
|
||||||
.arg(MESSAGE_SEQ_SCRIPT)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.map_err(AppError::Redis)?;
|
|
||||||
let _ = MESSAGE_SEQ_SHA.set(sha.clone());
|
|
||||||
Ok(sha)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn resolve_message(
|
|
||||||
&self,
|
|
||||||
message_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
) -> Result<Message, AppError> {
|
|
||||||
sqlx::query_as::<_, Message>(
|
|
||||||
"SELECT id, channel_id, author_id, thread_id, reply_to_message_id, seq, \
|
|
||||||
message_type, body, metadata, pinned, system, edited_at, deleted_at, \
|
|
||||||
created_at, updated_at \
|
|
||||||
FROM message WHERE id = $1 AND channel_id = $2 AND deleted_at IS NULL",
|
|
||||||
)
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.fetch_optional(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?
|
|
||||||
.ok_or(AppError::NotFound("message not found".into()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+16
-17
@@ -4,27 +4,25 @@ use serde::Serialize;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::service::ServiceContext;
|
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 categories;
|
||||||
|
pub mod channel_roles;
|
||||||
pub mod channels;
|
pub mod channels;
|
||||||
pub mod delivery_trace;
|
pub mod custom_emojis;
|
||||||
pub mod drafts;
|
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod follows;
|
pub mod forum_tags;
|
||||||
|
pub mod integrations;
|
||||||
|
pub mod invitations;
|
||||||
pub mod members;
|
pub mod members;
|
||||||
pub mod messages;
|
pub mod repo_links;
|
||||||
pub mod polls;
|
|
||||||
pub mod presence;
|
|
||||||
pub mod reactions;
|
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod threads;
|
pub mod slash_commands;
|
||||||
|
pub mod stages;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
pub mod voice;
|
||||||
|
pub mod webhooks;
|
||||||
|
|
||||||
pub use messages::{EditMessageParams, SendMessageParams};
|
|
||||||
pub use presence::UpdatePresenceParams;
|
|
||||||
pub use session::ImSession;
|
pub use session::ImSession;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -33,11 +31,11 @@ pub struct ImService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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);
|
let _ = self.ctx.im_events.publish(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn publish<T: Serialize>(&self, subject: &str, request_id: Uuid, event: &T) {
|
pub(crate) async fn publish<T: Serialize>(&self, subject: &str, request_id: Uuid, event: &T) {
|
||||||
match self
|
match self
|
||||||
.ctx
|
.ctx
|
||||||
.nats
|
.nats
|
||||||
@@ -48,9 +46,10 @@ impl ImService {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => trace_request("nats_published", request_id, subject),
|
Ok(_) => {
|
||||||
|
tracing::debug!(subject, %request_id, "nats event published");
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
trace_error("nats_failed", request_id, subject, &e);
|
|
||||||
tracing::warn!(subject, error = %e, "nats publish failed");
|
tracing::warn!(subject, error = %e, "nats publish failed");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,372 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::immediate::{PollAction, PollEvent};
|
|
||||||
use crate::models::channels::{MessagePoll, MessagePollOption, MessagePollVote};
|
|
||||||
use crate::models::common::PollLayout;
|
|
||||||
use crate::service::ImService;
|
|
||||||
use crate::service::im::events::ImEvent;
|
|
||||||
|
|
||||||
use super::session::ImSession;
|
|
||||||
use super::util::*;
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct CreatePollParams {
|
|
||||||
pub question: String,
|
|
||||||
pub description: Option<String>,
|
|
||||||
pub options: Vec<CreatePollOptionParams>,
|
|
||||||
pub layout: Option<String>,
|
|
||||||
pub allow_multiselect: Option<bool>,
|
|
||||||
pub duration_hours: Option<i32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct CreatePollOptionParams {
|
|
||||||
pub text: String,
|
|
||||||
pub emoji_id: Option<String>,
|
|
||||||
pub emoji_name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct VoteParams {
|
|
||||||
pub option_ids: Vec<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImService {
|
|
||||||
pub async fn poll_create(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
message_id: Uuid,
|
|
||||||
params: CreatePollParams,
|
|
||||||
) -> Result<MessagePoll, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
self.resolve_message(message_id, channel_id).await?;
|
|
||||||
|
|
||||||
let question = required_text(params.question, "question")?;
|
|
||||||
if params.options.is_empty() || params.options.len() > MAX_POLL_OPTIONS {
|
|
||||||
return Err(AppError::BadRequest(format!(
|
|
||||||
"poll must have between 1 and {MAX_POLL_OPTIONS} options"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let layout = parse_enum(
|
|
||||||
params.layout,
|
|
||||||
PollLayout::Default,
|
|
||||||
PollLayout::Unknown,
|
|
||||||
"layout",
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let poll_id = Uuid::now_v7();
|
|
||||||
let ends_at = params
|
|
||||||
.duration_hours
|
|
||||||
.map(|h| now + chrono::Duration::hours(h as i64));
|
|
||||||
|
|
||||||
let validated_options: Vec<(String, Option<String>, Option<String>)> = params
|
|
||||||
.options
|
|
||||||
.iter()
|
|
||||||
.map(|opt| {
|
|
||||||
let text = required_text(opt.text.clone(), "option text")?;
|
|
||||||
if text.len() > MAX_POLL_OPTION_TEXT {
|
|
||||||
return Err(AppError::BadRequest("poll option text too long".into()));
|
|
||||||
}
|
|
||||||
Ok((text, opt.emoji_id.clone(), opt.emoji_name.clone()))
|
|
||||||
})
|
|
||||||
.collect::<Result<Vec<_>, AppError>>()?;
|
|
||||||
|
|
||||||
let mut txn = self
|
|
||||||
.ctx
|
|
||||||
.db
|
|
||||||
.writer()
|
|
||||||
.begin()
|
|
||||||
.await
|
|
||||||
.map_err(|_| AppError::TxnError)?;
|
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
let poll = sqlx::query_as::<_, MessagePoll>(
|
|
||||||
"INSERT INTO message_poll \
|
|
||||||
(id, message_id, channel_id, question, description, layout, \
|
|
||||||
allow_multiselect, duration_hours, ends_at, total_votes, metadata, \
|
|
||||||
created_at, updated_at) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 0, NULL, $10, $10) \
|
|
||||||
RETURNING id, message_id, channel_id, question, description, layout, \
|
|
||||||
allow_multiselect, duration_hours, ends_at, total_votes, metadata, \
|
|
||||||
created_at, updated_at",
|
|
||||||
)
|
|
||||||
.bind(poll_id)
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(&question)
|
|
||||||
.bind(params.description.as_deref())
|
|
||||||
.bind(layout)
|
|
||||||
.bind(params.allow_multiselect.unwrap_or(false))
|
|
||||||
.bind(params.duration_hours)
|
|
||||||
.bind(ends_at)
|
|
||||||
.bind(now)
|
|
||||||
.fetch_one(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
for (i, (text, emoji_id, emoji_name)) in validated_options.iter().enumerate() {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO message_poll_option \
|
|
||||||
(id, poll_id, position, text, emoji_id, emoji_name, vote_count, created_at) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, 0, $7)",
|
|
||||||
)
|
|
||||||
.bind(Uuid::now_v7())
|
|
||||||
.bind(poll_id)
|
|
||||||
.bind(i as i32)
|
|
||||||
.bind(text)
|
|
||||||
.bind(emoji_id.as_deref())
|
|
||||||
.bind(emoji_name.as_deref())
|
|
||||||
.bind(now)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
|
||||||
tracing::info!(poll_id = %poll_id, "Poll created");
|
|
||||||
let request_id = Uuid::nil();
|
|
||||||
let event = PollEvent {
|
|
||||||
channel_id,
|
|
||||||
poll_id,
|
|
||||||
action: PollAction::Created,
|
|
||||||
};
|
|
||||||
self.publish(&format!("im.poll.{}", channel_id), request_id, &event)
|
|
||||||
.await;
|
|
||||||
self.emit_event(ImEvent::Poll {
|
|
||||||
request_id,
|
|
||||||
data: event,
|
|
||||||
});
|
|
||||||
Ok(poll)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn poll_get(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
poll_id: Uuid,
|
|
||||||
) -> Result<(MessagePoll, Vec<MessagePollOption>), AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
let poll = sqlx::query_as::<_, MessagePoll>(
|
|
||||||
"SELECT id, message_id, channel_id, question, description, layout, \
|
|
||||||
allow_multiselect, duration_hours, ends_at, total_votes, metadata, \
|
|
||||||
created_at, updated_at \
|
|
||||||
FROM message_poll WHERE id = $1 AND channel_id = $2",
|
|
||||||
)
|
|
||||||
.bind(poll_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.fetch_optional(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?
|
|
||||||
.ok_or(AppError::NotFound("poll not found".into()))?;
|
|
||||||
|
|
||||||
let options = sqlx::query_as::<_, MessagePollOption>(
|
|
||||||
"SELECT id, poll_id, position, text, emoji_id, emoji_name, vote_count, created_at \
|
|
||||||
FROM message_poll_option WHERE poll_id = $1 ORDER BY position ASC",
|
|
||||||
)
|
|
||||||
.bind(poll_id)
|
|
||||||
.fetch_all(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
Ok((poll, options))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn poll_vote(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
poll_id: Uuid,
|
|
||||||
params: VoteParams,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
let poll = sqlx::query_as::<_, MessagePoll>(
|
|
||||||
"SELECT id, message_id, channel_id, question, description, layout, \
|
|
||||||
allow_multiselect, duration_hours, ends_at, total_votes, metadata, \
|
|
||||||
created_at, updated_at \
|
|
||||||
FROM message_poll WHERE id = $1 AND channel_id = $2",
|
|
||||||
)
|
|
||||||
.bind(poll_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.fetch_optional(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?
|
|
||||||
.ok_or(AppError::NotFound("poll not found".into()))?;
|
|
||||||
|
|
||||||
if let Some(ends) = poll.ends_at
|
|
||||||
&& chrono::Utc::now() > ends
|
|
||||||
{
|
|
||||||
return Err(AppError::BadRequest("poll has ended".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if !poll.allow_multiselect && params.option_ids.len() > 1 {
|
|
||||||
return Err(AppError::BadRequest("multiselect not allowed".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let mut txn = self
|
|
||||||
.ctx
|
|
||||||
.db
|
|
||||||
.writer()
|
|
||||||
.begin()
|
|
||||||
.await
|
|
||||||
.map_err(|_| AppError::TxnError)?;
|
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
// Collect old option_ids before deleting
|
|
||||||
let old_option_ids: Vec<Uuid> = sqlx::query_scalar(
|
|
||||||
"DELETE FROM message_poll_vote WHERE poll_id = $1 AND user_id = $2 RETURNING option_id",
|
|
||||||
)
|
|
||||||
.bind(poll_id)
|
|
||||||
.bind(user_uid)
|
|
||||||
.fetch_all(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
let removed = old_option_ids.len() as i32;
|
|
||||||
|
|
||||||
// Decrement old vote counts
|
|
||||||
for opt_id in &old_option_ids {
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE message_poll_option SET vote_count = GREATEST(vote_count - 1, 0) WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(opt_id)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert new votes
|
|
||||||
let mut new_count = 0i32;
|
|
||||||
for option_id in ¶ms.option_ids {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO message_poll_vote (id, poll_id, option_id, user_id, voted_at) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING",
|
|
||||||
)
|
|
||||||
.bind(Uuid::now_v7())
|
|
||||||
.bind(poll_id)
|
|
||||||
.bind(option_id)
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(now)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE message_poll_option SET vote_count = vote_count + 1 \
|
|
||||||
WHERE id = $1 AND poll_id = $2",
|
|
||||||
)
|
|
||||||
.bind(option_id)
|
|
||||||
.bind(poll_id)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
new_count += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let delta = new_count - removed;
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE message_poll SET total_votes = total_votes + $1, updated_at = $2 WHERE id = $3",
|
|
||||||
)
|
|
||||||
.bind(delta)
|
|
||||||
.bind(now)
|
|
||||||
.bind(poll_id)
|
|
||||||
.execute(&mut *txn)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
|
||||||
let request_id = Uuid::nil();
|
|
||||||
let event = PollEvent {
|
|
||||||
channel_id,
|
|
||||||
poll_id,
|
|
||||||
action: PollAction::Voted,
|
|
||||||
};
|
|
||||||
self.publish(&format!("im.poll.{}", channel_id), request_id, &event)
|
|
||||||
.await;
|
|
||||||
self.emit_event(ImEvent::Poll {
|
|
||||||
request_id,
|
|
||||||
data: event,
|
|
||||||
});
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn poll_results(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
poll_id: Uuid,
|
|
||||||
) -> Result<Vec<MessagePollVote>, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
sqlx::query_as::<_, MessagePollVote>(
|
|
||||||
"SELECT id, poll_id, option_id, user_id, voted_at \
|
|
||||||
FROM message_poll_vote WHERE poll_id = $1 ORDER BY voted_at ASC",
|
|
||||||
)
|
|
||||||
.bind(poll_id)
|
|
||||||
.fetch_all(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn poll_delete(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
poll_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_editable(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
let result = sqlx::query("DELETE FROM message_poll WHERE id = $1 AND channel_id = $2")
|
|
||||||
.bind(poll_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.execute(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
ensure_affected(result.rows_affected(), "poll not found")?;
|
|
||||||
let request_id = Uuid::nil();
|
|
||||||
let event = PollEvent {
|
|
||||||
channel_id,
|
|
||||||
poll_id,
|
|
||||||
action: PollAction::Deleted,
|
|
||||||
};
|
|
||||||
self.publish(&format!("im.poll.{}", channel_id), request_id, &event)
|
|
||||||
.await;
|
|
||||||
self.emit_event(ImEvent::Poll {
|
|
||||||
request_id,
|
|
||||||
data: event,
|
|
||||||
});
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::immediate::{PresenceEvent, TypingEvent};
|
|
||||||
use crate::models::common::PresenceStatus;
|
|
||||||
use crate::models::users::UserPresence;
|
|
||||||
use crate::service::ImService;
|
|
||||||
use crate::service::im::events::ImEvent;
|
|
||||||
|
|
||||||
use super::session::ImSession;
|
|
||||||
use super::util::*;
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct UpdatePresenceParams {
|
|
||||||
pub status: String,
|
|
||||||
pub custom_status_text: Option<String>,
|
|
||||||
pub custom_status_emoji: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct TypingParams {
|
|
||||||
pub channel_id: Uuid,
|
|
||||||
pub thread_id: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImService {
|
|
||||||
pub async fn presence_update(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
wk_name: &str,
|
|
||||||
params: UpdatePresenceParams,
|
|
||||||
) -> Result<UserPresence, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let _ = self.resolve_workspace(wk_name).await?;
|
|
||||||
|
|
||||||
let status = parse_enum(
|
|
||||||
Some(params.status),
|
|
||||||
PresenceStatus::Online,
|
|
||||||
PresenceStatus::Unknown,
|
|
||||||
"status",
|
|
||||||
)?;
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
|
|
||||||
let presence = sqlx::query_as::<_, UserPresence>(
|
|
||||||
"INSERT INTO user_presence (id, user_id, status, custom_status_text, custom_status_emoji, \
|
|
||||||
last_active_at, created_at, updated_at) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $6, $6) \
|
|
||||||
ON CONFLICT (user_id) DO UPDATE SET \
|
|
||||||
status = $3, custom_status_text = $4, custom_status_emoji = $5, \
|
|
||||||
last_active_at = $6, updated_at = $6 \
|
|
||||||
RETURNING id, user_id, status, custom_status_text, custom_status_emoji, \
|
|
||||||
device_type, ip_address, last_active_at, last_seen_at, created_at, updated_at",
|
|
||||||
)
|
|
||||||
.bind(Uuid::now_v7())
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(status)
|
|
||||||
.bind(params.custom_status_text.as_deref())
|
|
||||||
.bind(params.custom_status_emoji.as_deref())
|
|
||||||
.bind(now)
|
|
||||||
.fetch_one(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
// Cache in Redis for fast lookup
|
|
||||||
let key = format!("{PRESENCE_PREFIX}{user_uid}");
|
|
||||||
if let Ok(mut conn) = self.ctx.redis.get_connection() {
|
|
||||||
let _ = redis::cmd("SETEX")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(PRESENCE_TTL_SECS as u64)
|
|
||||||
.arg(status.to_string())
|
|
||||||
.query::<()>(&mut *conn.inner_mut());
|
|
||||||
}
|
|
||||||
|
|
||||||
let request_id = Uuid::nil();
|
|
||||||
let event = PresenceEvent {
|
|
||||||
user_id: user_uid,
|
|
||||||
status: presence.status.to_string(),
|
|
||||||
custom_status_text: presence.custom_status_text.clone(),
|
|
||||||
custom_status_emoji: presence.custom_status_emoji.clone(),
|
|
||||||
};
|
|
||||||
self.publish(&format!("im.presence.{}", user_uid), request_id, &event)
|
|
||||||
.await;
|
|
||||||
self.emit_event(ImEvent::Presence {
|
|
||||||
request_id,
|
|
||||||
data: event,
|
|
||||||
});
|
|
||||||
Ok(presence)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn presence_get(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
wk_name: &str,
|
|
||||||
user_id: Uuid,
|
|
||||||
) -> Result<Option<UserPresence>, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let ws = self.resolve_workspace(wk_name).await?;
|
|
||||||
self.ensure_workspace_readable(user_uid, &ws).await?;
|
|
||||||
|
|
||||||
// Try DB first (has full record)
|
|
||||||
if let Some(p) = sqlx::query_as::<_, UserPresence>(
|
|
||||||
"SELECT id, user_id, status, custom_status_text, custom_status_emoji, \
|
|
||||||
device_type, ip_address, last_active_at, last_seen_at, created_at, updated_at \
|
|
||||||
FROM user_presence WHERE user_id = $1",
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.fetch_optional(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?
|
|
||||||
{
|
|
||||||
return Ok(Some(p));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: check Redis for a cached status
|
|
||||||
let key = format!("{PRESENCE_PREFIX}{user_id}");
|
|
||||||
if let Ok(mut conn) = self.ctx.redis.get_connection() {
|
|
||||||
let cached: Option<String> = redis::cmd("GET")
|
|
||||||
.arg(&key)
|
|
||||||
.query(&mut *conn.inner_mut())
|
|
||||||
.ok()
|
|
||||||
.flatten();
|
|
||||||
|
|
||||||
if let Some(status_str) = cached
|
|
||||||
&& let Ok(status) = status_str.parse::<PresenceStatus>()
|
|
||||||
{
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
return Ok(Some(UserPresence {
|
|
||||||
id: Uuid::nil(),
|
|
||||||
user_id,
|
|
||||||
status,
|
|
||||||
custom_status_text: None,
|
|
||||||
custom_status_emoji: None,
|
|
||||||
device_type: None,
|
|
||||||
ip_address: None,
|
|
||||||
last_active_at: now,
|
|
||||||
last_seen_at: None,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn presence_heartbeat(&self, ctx: &ImSession, wk_name: &str) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let ws = self.resolve_workspace(wk_name).await?;
|
|
||||||
self.ensure_workspace_readable(user_uid, &ws).await?;
|
|
||||||
|
|
||||||
let key = format!("{PRESENCE_PREFIX}{user_uid}");
|
|
||||||
if let Ok(mut conn) = self.ctx.redis.get_connection()
|
|
||||||
&& let Err(e) = redis::cmd("SETEX")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(PRESENCE_TTL_SECS as u64)
|
|
||||||
.arg("online")
|
|
||||||
.query::<()>(&mut *conn.inner_mut())
|
|
||||||
{
|
|
||||||
tracing::warn!(error = %e, "redis presence heartbeat failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
if let Err(e) = sqlx::query(
|
|
||||||
"UPDATE user_presence SET last_active_at = $1, updated_at = $1 WHERE user_id = $2",
|
|
||||||
)
|
|
||||||
.bind(now)
|
|
||||||
.bind(user_uid)
|
|
||||||
.execute(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!(error = %e, "db presence heartbeat failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn typing_start(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
wk_name: &str,
|
|
||||||
params: TypingParams,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let ws = self.resolve_workspace(wk_name).await?;
|
|
||||||
self.ensure_workspace_readable(user_uid, &ws).await?;
|
|
||||||
|
|
||||||
let channel = self.resolve_channel(params.channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
let key = typing_key(params.channel_id, params.thread_id, user_uid);
|
|
||||||
let mut conn = self.ctx.redis.get_connection()?;
|
|
||||||
redis::cmd("SETEX")
|
|
||||||
.arg(&key)
|
|
||||||
.arg(TYPING_TTL_SECS as u64)
|
|
||||||
.arg("1")
|
|
||||||
.query::<()>(&mut *conn.inner_mut())?;
|
|
||||||
|
|
||||||
let request_id = Uuid::nil();
|
|
||||||
let event = TypingEvent {
|
|
||||||
channel_id: params.channel_id,
|
|
||||||
thread_id: params.thread_id,
|
|
||||||
user_id: user_uid,
|
|
||||||
};
|
|
||||||
self.publish(
|
|
||||||
&format!("im.typing.{}", params.channel_id),
|
|
||||||
request_id,
|
|
||||||
&event,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
self.emit_event(ImEvent::Typing {
|
|
||||||
request_id,
|
|
||||||
data: event,
|
|
||||||
});
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn typing_stop(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
wk_name: &str,
|
|
||||||
params: TypingParams,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let ws = self.resolve_workspace(wk_name).await?;
|
|
||||||
self.ensure_workspace_readable(user_uid, &ws).await?;
|
|
||||||
|
|
||||||
let key = typing_key(params.channel_id, params.thread_id, user_uid);
|
|
||||||
let mut conn = self.ctx.redis.get_connection()?;
|
|
||||||
redis::cmd("DEL")
|
|
||||||
.arg(&key)
|
|
||||||
.query::<()>(&mut *conn.inner_mut())?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn typing_key(channel_id: Uuid, thread_id: Option<Uuid>, user_id: Uuid) -> String {
|
|
||||||
match thread_id {
|
|
||||||
Some(tid) => format!("{TYPING_PREFIX}{channel_id}:{tid}:{user_id}"),
|
|
||||||
None => format!("{TYPING_PREFIX}{channel_id}:{user_id}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::immediate::{ReactionAction, ReactionEvent};
|
|
||||||
use crate::models::channels::{MessageMention, MessageReaction};
|
|
||||||
use crate::service::ImService;
|
|
||||||
use crate::service::im::events::ImEvent;
|
|
||||||
|
|
||||||
use super::session::ImSession;
|
|
||||||
use super::util::*;
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct AddReactionParams {
|
|
||||||
pub content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct AddMentionParams {
|
|
||||||
pub mentioned_user_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImService {
|
|
||||||
pub async fn reaction_list(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
message_id: Uuid,
|
|
||||||
) -> Result<Vec<MessageReaction>, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
sqlx::query_as::<_, MessageReaction>(
|
|
||||||
"SELECT id, message_id, channel_id, user_id, content, created_at \
|
|
||||||
FROM message_reaction WHERE message_id = $1 AND channel_id = $2 \
|
|
||||||
ORDER BY created_at ASC",
|
|
||||||
)
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.fetch_all(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn reaction_add(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
message_id: Uuid,
|
|
||||||
params: AddReactionParams,
|
|
||||||
) -> Result<MessageReaction, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
self.resolve_message(message_id, channel_id).await?;
|
|
||||||
|
|
||||||
let content = required_text(params.content, "content")?;
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
|
|
||||||
let reaction = sqlx::query_as::<_, MessageReaction>(
|
|
||||||
"INSERT INTO message_reaction (id, message_id, channel_id, user_id, content, created_at) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6) \
|
|
||||||
ON CONFLICT (message_id, user_id, content) DO NOTHING \
|
|
||||||
RETURNING id, message_id, channel_id, user_id, content, created_at",
|
|
||||||
)
|
|
||||||
.bind(Uuid::now_v7())
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(&content)
|
|
||||||
.bind(now)
|
|
||||||
.fetch_optional(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
if reaction.is_none() {
|
|
||||||
return Err(AppError::Conflict("reaction already exists".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let reaction = reaction.unwrap();
|
|
||||||
let request_id = Uuid::nil();
|
|
||||||
let event = ReactionEvent {
|
|
||||||
channel_id,
|
|
||||||
message_id,
|
|
||||||
user_id: reaction.user_id,
|
|
||||||
action: ReactionAction::Added,
|
|
||||||
content: Some(reaction.content.clone()),
|
|
||||||
};
|
|
||||||
self.publish(&format!("im.reaction.{}", channel_id), request_id, &event)
|
|
||||||
.await;
|
|
||||||
self.emit_event(ImEvent::Reaction {
|
|
||||||
request_id,
|
|
||||||
data: event,
|
|
||||||
});
|
|
||||||
Ok(reaction)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn reaction_remove(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
message_id: Uuid,
|
|
||||||
content: &str,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
|
|
||||||
let result = sqlx::query(
|
|
||||||
"DELETE FROM message_reaction \
|
|
||||||
WHERE message_id = $1 AND channel_id = $2 AND user_id = $3 AND content = $4",
|
|
||||||
)
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(content)
|
|
||||||
.execute(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
ensure_affected(result.rows_affected(), "reaction not found")?;
|
|
||||||
let request_id = Uuid::nil();
|
|
||||||
let event = ReactionEvent {
|
|
||||||
channel_id,
|
|
||||||
message_id,
|
|
||||||
user_id: user_uid,
|
|
||||||
action: ReactionAction::Removed,
|
|
||||||
content: Some(content.to_string()),
|
|
||||||
};
|
|
||||||
self.publish(&format!("im.reaction.{}", channel_id), request_id, &event)
|
|
||||||
.await;
|
|
||||||
self.emit_event(ImEvent::Reaction {
|
|
||||||
request_id,
|
|
||||||
data: event,
|
|
||||||
});
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn reaction_remove_all(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
message_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_admin(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
sqlx::query("DELETE FROM message_reaction WHERE message_id = $1 AND channel_id = $2")
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.execute(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
let request_id = Uuid::nil();
|
|
||||||
let event = ReactionEvent {
|
|
||||||
channel_id,
|
|
||||||
message_id,
|
|
||||||
user_id: user_uid,
|
|
||||||
action: ReactionAction::Removed,
|
|
||||||
content: None,
|
|
||||||
};
|
|
||||||
self.publish(&format!("im.reaction.{}", channel_id), request_id, &event)
|
|
||||||
.await;
|
|
||||||
self.emit_event(ImEvent::Reaction {
|
|
||||||
request_id,
|
|
||||||
data: event,
|
|
||||||
});
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn mention_list_for_user(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
wk_name: &str,
|
|
||||||
limit: i64,
|
|
||||||
offset: i64,
|
|
||||||
unread_only: bool,
|
|
||||||
) -> Result<Vec<MessageMention>, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let _ = self.resolve_workspace(wk_name).await?;
|
|
||||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
|
||||||
|
|
||||||
if unread_only {
|
|
||||||
sqlx::query_as::<_, MessageMention>(
|
|
||||||
"SELECT id, message_id, channel_id, mentioned_user_id, mentioned_by, read_at, created_at \
|
|
||||||
FROM message_mention \
|
|
||||||
WHERE mentioned_user_id = $1 AND read_at IS NULL \
|
|
||||||
ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
|
||||||
)
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(limit)
|
|
||||||
.bind(offset)
|
|
||||||
.fetch_all(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)
|
|
||||||
} else {
|
|
||||||
sqlx::query_as::<_, MessageMention>(
|
|
||||||
"SELECT id, message_id, channel_id, mentioned_user_id, mentioned_by, read_at, created_at \
|
|
||||||
FROM message_mention \
|
|
||||||
WHERE mentioned_user_id = $1 \
|
|
||||||
ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
|
||||||
)
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(limit)
|
|
||||||
.bind(offset)
|
|
||||||
.fetch_all(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn mention_mark_read(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
mention_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
|
|
||||||
let result = sqlx::query(
|
|
||||||
"UPDATE message_mention SET read_at = $1 \
|
|
||||||
WHERE id = $2 AND mentioned_user_id = $3 AND read_at IS NULL",
|
|
||||||
)
|
|
||||||
.bind(now)
|
|
||||||
.bind(mention_id)
|
|
||||||
.bind(user_uid)
|
|
||||||
.execute(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
ensure_affected(result.rows_affected(), "mention not found or already read")
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn mention_mark_all_read(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
wk_name: &str,
|
|
||||||
) -> Result<u64, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let _ = self.resolve_workspace(wk_name).await?;
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
|
|
||||||
let result = sqlx::query(
|
|
||||||
"UPDATE message_mention SET read_at = $1 \
|
|
||||||
WHERE mentioned_user_id = $2 AND read_at IS NULL",
|
|
||||||
)
|
|
||||||
.bind(now)
|
|
||||||
.bind(user_uid)
|
|
||||||
.execute(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
Ok(result.rows_affected())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::channels::ChannelRepoLink;
|
||||||
|
use crate::service::ImService;
|
||||||
|
|
||||||
|
use super::session::ImSession;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
|
pub struct CreateRepoLinkParams {
|
||||||
|
pub repo_id: Uuid,
|
||||||
|
pub link_type: String,
|
||||||
|
pub notify_events: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImService {
|
||||||
|
pub async fn repo_link_list(
|
||||||
|
&self,
|
||||||
|
_ctx: &ImSession,
|
||||||
|
channel_id: Uuid,
|
||||||
|
) -> Result<Vec<ChannelRepoLink>, AppError> {
|
||||||
|
sqlx::query_as::<_, ChannelRepoLink>(
|
||||||
|
"SELECT id, channel_id, repo_id, link_type, notify_events, active, \
|
||||||
|
created_by, created_at, updated_at \
|
||||||
|
FROM channel_repo_link WHERE channel_id = $1 ORDER BY created_at",
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.fetch_all(self.ctx.db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn repo_link_create(
|
||||||
|
&self,
|
||||||
|
ctx: &ImSession,
|
||||||
|
channel_id: Uuid,
|
||||||
|
params: CreateRepoLinkParams,
|
||||||
|
) -> Result<ChannelRepoLink, AppError> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
sqlx::query_as::<_, ChannelRepoLink>(
|
||||||
|
"INSERT INTO channel_repo_link \
|
||||||
|
(id, channel_id, repo_id, link_type, notify_events, active, \
|
||||||
|
created_by, created_at, updated_at) \
|
||||||
|
VALUES ($1, $2, $3, $4::link_type, $5, true, $6, $7, $7) \
|
||||||
|
RETURNING id, channel_id, repo_id, link_type, notify_events, active, \
|
||||||
|
created_by, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(Uuid::now_v7())
|
||||||
|
.bind(channel_id)
|
||||||
|
.bind(params.repo_id)
|
||||||
|
.bind(¶ms.link_type)
|
||||||
|
.bind(¶ms.notify_events)
|
||||||
|
.bind(ctx.user)
|
||||||
|
.bind(now)
|
||||||
|
.fetch_one(self.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn repo_link_delete(
|
||||||
|
&self,
|
||||||
|
_ctx: &ImSession,
|
||||||
|
link_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query("DELETE FROM channel_repo_link WHERE id = $1")
|
||||||
|
.bind(link_id)
|
||||||
|
.execute(self.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::channels::ChannelSlashCommand;
|
||||||
|
use crate::service::ImService;
|
||||||
|
|
||||||
|
use super::session::ImSession;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
|
pub struct CreateSlashCommandParams {
|
||||||
|
pub command: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub request_url: String,
|
||||||
|
pub secret: Option<String>,
|
||||||
|
pub scopes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
|
pub struct UpdateSlashCommandParams {
|
||||||
|
pub command: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub request_url: Option<String>,
|
||||||
|
pub secret: Option<String>,
|
||||||
|
pub scopes: Option<Vec<String>>,
|
||||||
|
pub enabled: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImService {
|
||||||
|
pub async fn slash_command_list(
|
||||||
|
&self,
|
||||||
|
_ctx: &ImSession,
|
||||||
|
channel_id: Uuid,
|
||||||
|
) -> Result<Vec<ChannelSlashCommand>, AppError> {
|
||||||
|
sqlx::query_as::<_, ChannelSlashCommand>(
|
||||||
|
"SELECT id, channel_id, workspace_id, command, description, request_url, \
|
||||||
|
secret_ciphertext, scopes, enabled, created_by, created_at, updated_at \
|
||||||
|
FROM channel_slash_command WHERE channel_id = $1 ORDER BY created_at",
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.fetch_all(self.ctx.db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn slash_command_create(
|
||||||
|
&self,
|
||||||
|
ctx: &ImSession,
|
||||||
|
channel_id: Uuid,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
params: CreateSlashCommandParams,
|
||||||
|
) -> Result<ChannelSlashCommand, AppError> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
sqlx::query_as::<_, ChannelSlashCommand>(
|
||||||
|
"INSERT INTO channel_slash_command \
|
||||||
|
(id, channel_id, workspace_id, command, description, request_url, \
|
||||||
|
secret_ciphertext, scopes, enabled, created_by, created_at, updated_at) \
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, $9, $10, $10) \
|
||||||
|
RETURNING id, channel_id, workspace_id, command, description, request_url, \
|
||||||
|
secret_ciphertext, scopes, enabled, created_by, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(Uuid::now_v7())
|
||||||
|
.bind(channel_id)
|
||||||
|
.bind(workspace_id)
|
||||||
|
.bind(¶ms.command)
|
||||||
|
.bind(params.description.as_deref())
|
||||||
|
.bind(¶ms.request_url)
|
||||||
|
.bind(params.secret.as_deref())
|
||||||
|
.bind(¶ms.scopes)
|
||||||
|
.bind(ctx.user)
|
||||||
|
.bind(now)
|
||||||
|
.fetch_one(self.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn slash_command_update(
|
||||||
|
&self,
|
||||||
|
_ctx: &ImSession,
|
||||||
|
command_id: Uuid,
|
||||||
|
params: UpdateSlashCommandParams,
|
||||||
|
) -> Result<ChannelSlashCommand, AppError> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
sqlx::query_as::<_, ChannelSlashCommand>(
|
||||||
|
"UPDATE channel_slash_command SET \
|
||||||
|
command = COALESCE($1, command), \
|
||||||
|
description = COALESCE($2, description), \
|
||||||
|
request_url = COALESCE($3, request_url), \
|
||||||
|
secret_ciphertext = COALESCE($4, secret_ciphertext), \
|
||||||
|
scopes = COALESCE($5, scopes), \
|
||||||
|
enabled = COALESCE($6, enabled), \
|
||||||
|
updated_at = $7 \
|
||||||
|
WHERE id = $8 \
|
||||||
|
RETURNING id, channel_id, workspace_id, command, description, request_url, \
|
||||||
|
secret_ciphertext, scopes, enabled, created_by, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(params.command.as_deref())
|
||||||
|
.bind(params.description.as_deref())
|
||||||
|
.bind(params.request_url.as_deref())
|
||||||
|
.bind(params.secret.as_deref())
|
||||||
|
.bind(params.scopes.as_ref())
|
||||||
|
.bind(params.enabled)
|
||||||
|
.bind(now)
|
||||||
|
.bind(command_id)
|
||||||
|
.fetch_one(self.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn slash_command_delete(
|
||||||
|
&self,
|
||||||
|
_ctx: &ImSession,
|
||||||
|
command_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query("DELETE FROM channel_slash_command WHERE id = $1")
|
||||||
|
.bind(command_id)
|
||||||
|
.execute(self.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::error::AppError;
|
|
||||||
use crate::immediate::{ThreadAction, ThreadEvent};
|
|
||||||
use crate::models::channels::MessageThread;
|
|
||||||
use crate::service::ImService;
|
|
||||||
use crate::service::im::events::ImEvent;
|
|
||||||
|
|
||||||
use super::session::ImSession;
|
|
||||||
use super::util::*;
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct CreateThreadParams {
|
|
||||||
pub title: Option<String>,
|
|
||||||
pub root_message_id: Uuid,
|
|
||||||
pub tags: Option<Vec<String>>,
|
|
||||||
pub auto_archive_duration: Option<i32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct UpdateThreadParams {
|
|
||||||
pub title: Option<String>,
|
|
||||||
pub tags: Option<Vec<String>>,
|
|
||||||
pub pinned: Option<bool>,
|
|
||||||
pub locked: Option<bool>,
|
|
||||||
pub rate_limit_per_user: Option<i32>,
|
|
||||||
pub resolved: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct ThreadListFilters {
|
|
||||||
pub pinned: Option<bool>,
|
|
||||||
pub locked: Option<bool>,
|
|
||||||
pub resolved: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ImService {
|
|
||||||
pub async fn thread_list(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
filters: ThreadListFilters,
|
|
||||||
limit: i64,
|
|
||||||
offset: i64,
|
|
||||||
) -> Result<Vec<MessageThread>, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
|
||||||
|
|
||||||
sqlx::query_as::<_, MessageThread>(
|
|
||||||
"SELECT id, channel_id, root_message_id, created_by, replies_count, \
|
|
||||||
participants_count, last_reply_message_id, last_reply_at, resolved, \
|
|
||||||
resolved_by, resolved_at, title, tags, pinned, locked, \
|
|
||||||
rate_limit_per_user, auto_archive_at, created_at, updated_at \
|
|
||||||
FROM message_thread WHERE channel_id = $1 \
|
|
||||||
AND ($2::bool IS NULL OR pinned = $2) \
|
|
||||||
AND ($3::bool IS NULL OR locked = $3) \
|
|
||||||
AND ($4::bool IS NULL OR resolved = $4) \
|
|
||||||
ORDER BY last_reply_at DESC NULLS LAST, created_at DESC \
|
|
||||||
LIMIT $5 OFFSET $6",
|
|
||||||
)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(filters.pinned)
|
|
||||||
.bind(filters.locked)
|
|
||||||
.bind(filters.resolved)
|
|
||||||
.bind(limit)
|
|
||||||
.bind(offset)
|
|
||||||
.fetch_all(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn thread_get(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Uuid,
|
|
||||||
) -> Result<MessageThread, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
self.resolve_thread(thread_id, channel_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn thread_create(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
params: CreateThreadParams,
|
|
||||||
) -> Result<MessageThread, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
self.resolve_message(params.root_message_id, channel_id)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let thread_id = Uuid::now_v7();
|
|
||||||
let tags = params.tags.unwrap_or_default();
|
|
||||||
let auto_archive_at = params
|
|
||||||
.auto_archive_duration
|
|
||||||
.map(|d| now + chrono::Duration::minutes(d as i64));
|
|
||||||
|
|
||||||
let thread = sqlx::query_as::<_, MessageThread>(
|
|
||||||
"INSERT INTO message_thread \
|
|
||||||
(id, channel_id, root_message_id, created_by, replies_count, \
|
|
||||||
participants_count, last_reply_message_id, last_reply_at, resolved, \
|
|
||||||
title, tags, pinned, locked, auto_archive_at, created_at, updated_at) \
|
|
||||||
VALUES ($1, $2, $3, $4, 0, 0, NULL, NULL, false, $5, $6, false, false, $7, $8, $8) \
|
|
||||||
RETURNING id, channel_id, root_message_id, created_by, replies_count, \
|
|
||||||
participants_count, last_reply_message_id, last_reply_at, resolved, \
|
|
||||||
resolved_by, resolved_at, title, tags, pinned, locked, \
|
|
||||||
rate_limit_per_user, auto_archive_at, created_at, updated_at",
|
|
||||||
)
|
|
||||||
.bind(thread_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(params.root_message_id)
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(params.title.as_deref())
|
|
||||||
.bind(&tags)
|
|
||||||
.bind(auto_archive_at)
|
|
||||||
.bind(now)
|
|
||||||
.fetch_one(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
tracing::info!(thread_id = %thread_id, channel_id = %channel_id, "Thread created");
|
|
||||||
let request_id = Uuid::nil();
|
|
||||||
let event = ThreadEvent {
|
|
||||||
channel_id,
|
|
||||||
thread_id,
|
|
||||||
action: ThreadAction::Created,
|
|
||||||
};
|
|
||||||
self.publish(
|
|
||||||
&format!("im.thread.{}.{}", channel_id, thread_id),
|
|
||||||
request_id,
|
|
||||||
&event,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
self.emit_event(ImEvent::Thread {
|
|
||||||
request_id,
|
|
||||||
data: event,
|
|
||||||
});
|
|
||||||
Ok(thread)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn thread_update(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Uuid,
|
|
||||||
params: UpdateThreadParams,
|
|
||||||
) -> Result<MessageThread, AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
let thread = self.resolve_thread(thread_id, channel_id).await?;
|
|
||||||
|
|
||||||
let is_owner = thread.created_by == user_uid;
|
|
||||||
if !is_owner {
|
|
||||||
self.ensure_channel_editable(user_uid, &channel).await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let resolved_by = if params.resolved == Some(true) && !thread.resolved {
|
|
||||||
Some(user_uid)
|
|
||||||
} else {
|
|
||||||
thread.resolved_by
|
|
||||||
};
|
|
||||||
let resolved_at = if params.resolved == Some(true) && !thread.resolved {
|
|
||||||
Some(now)
|
|
||||||
} else if params.resolved == Some(false) {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
thread.resolved_at
|
|
||||||
};
|
|
||||||
|
|
||||||
let updated = sqlx::query_as::<_, MessageThread>(
|
|
||||||
"UPDATE message_thread SET \
|
|
||||||
title = COALESCE($1, title), \
|
|
||||||
tags = COALESCE($2, tags), \
|
|
||||||
pinned = COALESCE($3, pinned), \
|
|
||||||
locked = COALESCE($4, locked), \
|
|
||||||
rate_limit_per_user = COALESCE($5, rate_limit_per_user), \
|
|
||||||
resolved = COALESCE($6, resolved), \
|
|
||||||
resolved_by = $7, resolved_at = $8, \
|
|
||||||
updated_at = $9 \
|
|
||||||
WHERE id = $10 \
|
|
||||||
RETURNING id, channel_id, root_message_id, created_by, replies_count, \
|
|
||||||
participants_count, last_reply_message_id, last_reply_at, resolved, \
|
|
||||||
resolved_by, resolved_at, title, tags, pinned, locked, \
|
|
||||||
rate_limit_per_user, auto_archive_at, created_at, updated_at",
|
|
||||||
)
|
|
||||||
.bind(params.title.as_deref())
|
|
||||||
.bind(params.tags.as_deref())
|
|
||||||
.bind(params.pinned)
|
|
||||||
.bind(params.locked)
|
|
||||||
.bind(params.rate_limit_per_user)
|
|
||||||
.bind(params.resolved)
|
|
||||||
.bind(resolved_by)
|
|
||||||
.bind(resolved_at)
|
|
||||||
.bind(now)
|
|
||||||
.bind(thread_id)
|
|
||||||
.fetch_one(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
let request_id = Uuid::nil();
|
|
||||||
let event = ThreadEvent {
|
|
||||||
channel_id,
|
|
||||||
thread_id,
|
|
||||||
action: ThreadAction::Updated,
|
|
||||||
};
|
|
||||||
self.publish(
|
|
||||||
&format!("im.thread.{}.{}", channel_id, thread_id),
|
|
||||||
request_id,
|
|
||||||
&event,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
self.emit_event(ImEvent::Thread {
|
|
||||||
request_id,
|
|
||||||
data: event,
|
|
||||||
});
|
|
||||||
Ok(updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn thread_delete(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_admin(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
let result = sqlx::query("DELETE FROM message_thread WHERE id = $1 AND channel_id = $2")
|
|
||||||
.bind(thread_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.execute(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
ensure_affected(result.rows_affected(), "thread not found")?;
|
|
||||||
let request_id = Uuid::nil();
|
|
||||||
let event = ThreadEvent {
|
|
||||||
channel_id,
|
|
||||||
thread_id,
|
|
||||||
action: ThreadAction::Deleted,
|
|
||||||
};
|
|
||||||
self.publish(
|
|
||||||
&format!("im.thread.{}.{}", channel_id, thread_id),
|
|
||||||
request_id,
|
|
||||||
&event,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
self.emit_event(ImEvent::Thread {
|
|
||||||
request_id,
|
|
||||||
data: event,
|
|
||||||
});
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn thread_read_state_update(
|
|
||||||
&self,
|
|
||||||
ctx: &ImSession,
|
|
||||||
_wk_name: &str,
|
|
||||||
channel_id: Uuid,
|
|
||||||
thread_id: Uuid,
|
|
||||||
message_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user;
|
|
||||||
let channel = self.resolve_channel(channel_id).await?;
|
|
||||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO thread_read_state (id, user_id, thread_id, channel_id, last_read_message_id, last_read_at, updated_at) \
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $6) \
|
|
||||||
ON CONFLICT (user_id, thread_id) DO UPDATE SET \
|
|
||||||
last_read_message_id = $5, last_read_at = $6, updated_at = $6",
|
|
||||||
)
|
|
||||||
.bind(Uuid::now_v7())
|
|
||||||
.bind(user_uid)
|
|
||||||
.bind(thread_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.bind(message_id)
|
|
||||||
.bind(now)
|
|
||||||
.execute(self.ctx.db.writer())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn resolve_thread(
|
|
||||||
&self,
|
|
||||||
thread_id: Uuid,
|
|
||||||
channel_id: Uuid,
|
|
||||||
) -> Result<MessageThread, AppError> {
|
|
||||||
sqlx::query_as::<_, MessageThread>(
|
|
||||||
"SELECT id, channel_id, root_message_id, created_by, replies_count, \
|
|
||||||
participants_count, last_reply_message_id, last_reply_at, resolved, \
|
|
||||||
resolved_by, resolved_at, title, tags, pinned, locked, \
|
|
||||||
rate_limit_per_user, auto_archive_at, created_at, updated_at \
|
|
||||||
FROM message_thread WHERE id = $1 AND channel_id = $2",
|
|
||||||
)
|
|
||||||
.bind(thread_id)
|
|
||||||
.bind(channel_id)
|
|
||||||
.fetch_optional(self.ctx.db.reader())
|
|
||||||
.await
|
|
||||||
.map_err(AppError::Database)?
|
|
||||||
.ok_or(AppError::NotFound("thread not found".into()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-56
@@ -1,5 +1,6 @@
|
|||||||
pub use crate::service::util::{
|
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.
|
/// Maximum length for a channel name.
|
||||||
@@ -7,58 +8,3 @@ pub const MAX_CHANNEL_NAME: usize = 100;
|
|||||||
|
|
||||||
/// Maximum length for a channel topic.
|
/// Maximum length for a channel topic.
|
||||||
pub const MAX_CHANNEL_TOPIC: usize = 1024;
|
pub const MAX_CHANNEL_TOPIC: usize = 1024;
|
||||||
|
|
||||||
/// Maximum length for a message body.
|
|
||||||
pub const MAX_MESSAGE_BODY: usize = 4096;
|
|
||||||
|
|
||||||
/// Maximum length for an article title.
|
|
||||||
pub const MAX_ARTICLE_TITLE: usize = 256;
|
|
||||||
|
|
||||||
/// Maximum number of poll options.
|
|
||||||
pub const MAX_POLL_OPTIONS: usize = 10;
|
|
||||||
|
|
||||||
/// Maximum length for a poll option text.
|
|
||||||
pub const MAX_POLL_OPTION_TEXT: usize = 100;
|
|
||||||
|
|
||||||
/// Redis key prefix for typing indicators.
|
|
||||||
pub const TYPING_PREFIX: &str = "im:typing:";
|
|
||||||
|
|
||||||
/// Redis key prefix for user presence.
|
|
||||||
pub const PRESENCE_PREFIX: &str = "im:presence:";
|
|
||||||
|
|
||||||
/// Redis TTL for typing indicators (seconds).
|
|
||||||
pub const TYPING_TTL_SECS: usize = 8;
|
|
||||||
|
|
||||||
/// Redis TTL for presence heartbeats (seconds).
|
|
||||||
pub const PRESENCE_TTL_SECS: usize = 120;
|
|
||||||
|
|
||||||
/// Maximum length for generated slugs.
|
|
||||||
pub const MAX_SLUG_LEN: usize = 128;
|
|
||||||
|
|
||||||
/// Generate a slug from a title string.
|
|
||||||
pub fn slugify(title: &str) -> String {
|
|
||||||
let slug: String = title
|
|
||||||
.to_lowercase()
|
|
||||||
.chars()
|
|
||||||
.filter_map(|c| {
|
|
||||||
if c.is_ascii_alphanumeric() {
|
|
||||||
Some(c)
|
|
||||||
} else if c.is_whitespace() || !c.is_ascii() {
|
|
||||||
Some('-')
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<String>()
|
|
||||||
.split('-')
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("-");
|
|
||||||
|
|
||||||
let mut result = slug;
|
|
||||||
result.truncate(MAX_SLUG_LEN);
|
|
||||||
if result.ends_with('-') {
|
|
||||||
result.pop();
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::channels::VoiceParticipant;
|
||||||
|
use crate::service::ImService;
|
||||||
|
|
||||||
|
use super::session::ImSession;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
|
pub struct UpdateVoiceStateParams {
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
pub muted: Option<bool>,
|
||||||
|
pub deafened: Option<bool>,
|
||||||
|
pub self_muted: Option<bool>,
|
||||||
|
pub self_deafened: Option<bool>,
|
||||||
|
pub self_video: Option<bool>,
|
||||||
|
pub streaming: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImService {
|
||||||
|
pub async fn voice_participant_list(
|
||||||
|
&self,
|
||||||
|
_ctx: &ImSession,
|
||||||
|
channel_id: Uuid,
|
||||||
|
) -> Result<Vec<VoiceParticipant>, AppError> {
|
||||||
|
sqlx::query_as::<_, VoiceParticipant>(
|
||||||
|
"SELECT id, channel_id, user_id, session_id, deafened, muted, \
|
||||||
|
self_deafened, self_muted, self_video, streaming, speaking, \
|
||||||
|
joined_at, left_at \
|
||||||
|
FROM voice_participant WHERE channel_id = $1 AND left_at IS NULL \
|
||||||
|
ORDER BY joined_at",
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.fetch_all(self.ctx.db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn voice_state_update(
|
||||||
|
&self,
|
||||||
|
ctx: &ImSession,
|
||||||
|
channel_id: Uuid,
|
||||||
|
params: UpdateVoiceStateParams,
|
||||||
|
) -> Result<VoiceParticipant, AppError> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
sqlx::query_as::<_, VoiceParticipant>(
|
||||||
|
"INSERT INTO voice_participant \
|
||||||
|
(id, channel_id, user_id, session_id, muted, deafened, \
|
||||||
|
self_muted, self_deafened, self_video, streaming, speaking, joined_at) \
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, false, false, $10) \
|
||||||
|
ON CONFLICT (channel_id, user_id) DO UPDATE SET \
|
||||||
|
session_id = COALESCE($4, voice_participant.session_id), \
|
||||||
|
muted = COALESCE($5, voice_participant.muted), \
|
||||||
|
deafened = COALESCE($6, voice_participant.deafened), \
|
||||||
|
self_muted = COALESCE($7, voice_participant.self_muted), \
|
||||||
|
self_deafened = COALESCE($8, voice_participant.self_deafened), \
|
||||||
|
self_video = COALESCE($9, voice_participant.self_video) \
|
||||||
|
RETURNING id, channel_id, user_id, session_id, deafened, muted, \
|
||||||
|
self_deafened, self_muted, self_video, streaming, speaking, \
|
||||||
|
joined_at, left_at",
|
||||||
|
)
|
||||||
|
.bind(Uuid::now_v7())
|
||||||
|
.bind(channel_id)
|
||||||
|
.bind(ctx.user)
|
||||||
|
.bind(params.session_id.as_deref())
|
||||||
|
.bind(params.muted.unwrap_or(false))
|
||||||
|
.bind(params.deafened.unwrap_or(false))
|
||||||
|
.bind(params.self_muted.unwrap_or(false))
|
||||||
|
.bind(params.self_deafened.unwrap_or(false))
|
||||||
|
.bind(params.self_video.unwrap_or(false))
|
||||||
|
.bind(now)
|
||||||
|
.fetch_one(self.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::models::channels::ChannelWebhook;
|
||||||
|
use crate::service::ImService;
|
||||||
|
|
||||||
|
use super::session::ImSession;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
|
pub struct CreateWebhookParams {
|
||||||
|
pub name: String,
|
||||||
|
pub url: String,
|
||||||
|
pub secret: Option<String>,
|
||||||
|
pub events: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
|
pub struct UpdateWebhookParams {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub secret: Option<String>,
|
||||||
|
pub events: Option<Vec<String>>,
|
||||||
|
pub active: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImService {
|
||||||
|
pub async fn webhook_list(
|
||||||
|
&self,
|
||||||
|
_ctx: &ImSession,
|
||||||
|
channel_id: Uuid,
|
||||||
|
) -> Result<Vec<ChannelWebhook>, AppError> {
|
||||||
|
sqlx::query_as::<_, ChannelWebhook>(
|
||||||
|
"SELECT id, channel_id, name, url, secret_ciphertext, events, active, \
|
||||||
|
last_delivery_status, last_delivery_at, created_by, created_at, updated_at \
|
||||||
|
FROM channel_webhook WHERE channel_id = $1 ORDER BY created_at",
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.fetch_all(self.ctx.db.reader())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn webhook_create(
|
||||||
|
&self,
|
||||||
|
ctx: &ImSession,
|
||||||
|
channel_id: Uuid,
|
||||||
|
params: CreateWebhookParams,
|
||||||
|
) -> Result<ChannelWebhook, AppError> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
sqlx::query_as::<_, ChannelWebhook>(
|
||||||
|
"INSERT INTO channel_webhook \
|
||||||
|
(id, channel_id, name, url, secret_ciphertext, events, active, \
|
||||||
|
created_by, created_at, updated_at) \
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, true, $7, $8, $8) \
|
||||||
|
RETURNING id, channel_id, name, url, secret_ciphertext, events, active, \
|
||||||
|
last_delivery_status, last_delivery_at, created_by, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(Uuid::now_v7())
|
||||||
|
.bind(channel_id)
|
||||||
|
.bind(¶ms.name)
|
||||||
|
.bind(¶ms.url)
|
||||||
|
.bind(params.secret.as_deref())
|
||||||
|
.bind(¶ms.events)
|
||||||
|
.bind(ctx.user)
|
||||||
|
.bind(now)
|
||||||
|
.fetch_one(self.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn webhook_update(
|
||||||
|
&self,
|
||||||
|
_ctx: &ImSession,
|
||||||
|
webhook_id: Uuid,
|
||||||
|
params: UpdateWebhookParams,
|
||||||
|
) -> Result<ChannelWebhook, AppError> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
sqlx::query_as::<_, ChannelWebhook>(
|
||||||
|
"UPDATE channel_webhook SET \
|
||||||
|
name = COALESCE($1, name), \
|
||||||
|
url = COALESCE($2, url), \
|
||||||
|
secret_ciphertext = COALESCE($3, secret_ciphertext), \
|
||||||
|
events = COALESCE($4, events), \
|
||||||
|
active = COALESCE($5, active), \
|
||||||
|
updated_at = $6 \
|
||||||
|
WHERE id = $7 \
|
||||||
|
RETURNING id, channel_id, name, url, secret_ciphertext, events, active, \
|
||||||
|
last_delivery_status, last_delivery_at, created_by, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(params.name.as_deref())
|
||||||
|
.bind(params.url.as_deref())
|
||||||
|
.bind(params.secret.as_deref())
|
||||||
|
.bind(params.events.as_ref())
|
||||||
|
.bind(params.active)
|
||||||
|
.bind(now)
|
||||||
|
.bind(webhook_id)
|
||||||
|
.fetch_one(self.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn webhook_delete(
|
||||||
|
&self,
|
||||||
|
_ctx: &ImSession,
|
||||||
|
webhook_id: Uuid,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query("DELETE FROM channel_webhook WHERE id = $1")
|
||||||
|
.bind(webhook_id)
|
||||||
|
.execute(self.ctx.db.writer())
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
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<String>,
|
||||||
|
ttl_secs: Option<u64>,
|
||||||
|
) -> 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<Option<ServiceIdentity>> {
|
||||||
|
let key = format!("{API_KEY_PREFIX}{api_key}");
|
||||||
|
let mut conn = self.redis.get_connection();
|
||||||
|
|
||||||
|
let json: Option<String> = 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ use crate::models::issues::IssueAssignee;
|
|||||||
use crate::service::IssueService;
|
use crate::service::IssueService;
|
||||||
use crate::session::Session;
|
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 {
|
impl IssueService {
|
||||||
pub async fn issue_assignees(
|
pub async fn issue_assignees(
|
||||||
@@ -44,6 +44,24 @@ impl IssueService {
|
|||||||
let issue = self.resolve_issue(wk_name, number).await?;
|
let issue = self.resolve_issue(wk_name, number).await?;
|
||||||
let issue_id = issue.id;
|
let issue_id = issue.id;
|
||||||
self.ensure_issue_editable(user_uid, &issue).await?;
|
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 now = chrono::Utc::now();
|
||||||
let mut txn = self
|
let mut txn = self
|
||||||
.ctx
|
.ctx
|
||||||
@@ -52,8 +70,7 @@ impl IssueService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -102,8 +119,7 @@ impl IssueService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use crate::models::issues::IssueComment;
|
|||||||
use crate::service::IssueService;
|
use crate::service::IssueService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct CreateCommentParams {
|
pub struct CreateCommentParams {
|
||||||
@@ -71,8 +71,7 @@ impl IssueService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -149,8 +148,7 @@ impl IssueService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -207,8 +205,7 @@ impl IssueService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::models::issues::{IssueLabel, IssueLabelRelation};
|
|||||||
use crate::service::IssueService;
|
use crate::service::IssueService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct CreateLabelParams {
|
pub struct CreateLabelParams {
|
||||||
@@ -182,8 +182,7 @@ impl IssueService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -226,8 +225,7 @@ impl IssueService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -126,12 +126,34 @@ impl IssueService {
|
|||||||
let repo_id = self.resolve_repo_id(wk_name, repo_name).await?;
|
let repo_id = self.resolve_repo_id(wk_name, repo_name).await?;
|
||||||
self.ensure_repo_role(repo_id, user_uid, Role::Admin)
|
self.ensure_repo_role(repo_id, user_uid, Role::Admin)
|
||||||
.await?;
|
.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")
|
let result = sqlx::query("DELETE FROM issue_milestone WHERE id = $1 AND repo_id = $2")
|
||||||
.bind(milestone_id)
|
.bind(milestone_id)
|
||||||
.bind(repo_id)
|
.bind(repo_id)
|
||||||
.execute(self.ctx.db.writer())
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::models::issues::IssuePrRelation;
|
|||||||
use crate::service::IssueService;
|
use crate::service::IssueService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct LinkPrParams {
|
pub struct LinkPrParams {
|
||||||
@@ -68,8 +68,7 @@ impl IssueService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -104,8 +103,7 @@ impl IssueService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::models::issues::IssueRepoRelation;
|
|||||||
use crate::service::IssueService;
|
use crate::service::IssueService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct LinkRepoParams {
|
pub struct LinkRepoParams {
|
||||||
@@ -64,8 +64,7 @@ impl IssueService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -100,8 +99,7 @@ impl IssueService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use crate::models::issues::IssueSubscriber;
|
|||||||
use crate::service::IssueService;
|
use crate::service::IssueService;
|
||||||
use crate::session::Session;
|
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 {
|
impl IssueService {
|
||||||
pub async fn issue_subscribers(
|
pub async fn issue_subscribers(
|
||||||
@@ -51,8 +51,7 @@ impl IssueService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -87,8 +86,7 @@ impl IssueService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
pub use crate::service::util::{
|
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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub mod util;
|
|||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod im;
|
pub mod im;
|
||||||
|
pub mod internal_auth;
|
||||||
pub mod issues;
|
pub mod issues;
|
||||||
pub mod notify;
|
pub mod notify;
|
||||||
pub mod pr;
|
pub mod pr;
|
||||||
@@ -62,6 +63,7 @@ pub struct NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub use im::ImService;
|
pub use im::ImService;
|
||||||
|
pub use internal_auth::InternalAuthService;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppService {
|
pub struct AppService {
|
||||||
@@ -73,6 +75,7 @@ pub struct AppService {
|
|||||||
pub pr: PrService,
|
pub pr: PrService,
|
||||||
pub notify: NotificationService,
|
pub notify: NotificationService,
|
||||||
pub im: ImService,
|
pub im: ImService,
|
||||||
|
pub internal_auth: InternalAuthService,
|
||||||
pub ctx: Arc<ServiceContext>,
|
pub ctx: Arc<ServiceContext>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +91,8 @@ impl AppService {
|
|||||||
registry: Arc<EtcdRegistry>,
|
registry: Arc<EtcdRegistry>,
|
||||||
nats: Arc<NatsQueue>,
|
nats: Arc<NatsQueue>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let internal_auth = InternalAuthService::new(redis.clone());
|
||||||
|
|
||||||
let ctx = Arc::new(ServiceContext {
|
let ctx = Arc::new(ServiceContext {
|
||||||
version,
|
version,
|
||||||
db,
|
db,
|
||||||
@@ -109,6 +114,7 @@ impl AppService {
|
|||||||
pr: PrService { ctx: ctx.clone() },
|
pr: PrService { ctx: ctx.clone() },
|
||||||
notify: NotificationService { ctx: ctx.clone() },
|
notify: NotificationService { ctx: ctx.clone() },
|
||||||
im: ImService { ctx: ctx.clone() },
|
im: ImService { ctx: ctx.clone() },
|
||||||
|
internal_auth,
|
||||||
ctx,
|
ctx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use super::util::clamp_limit_offset;
|
use super::util::clamp_limit_offset;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||||
pub struct CreateBlockParams {
|
pub struct CreateBlockParams {
|
||||||
pub workspace_id: Option<Uuid>,
|
pub workspace_id: Option<Uuid>,
|
||||||
pub repo_id: Option<Uuid>,
|
pub repo_id: Option<Uuid>,
|
||||||
@@ -81,10 +81,12 @@ impl NotificationService {
|
|||||||
let id = Uuid::now_v7();
|
let id = Uuid::now_v7();
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query_as::<_, NotificationBlock>(
|
||||||
"INSERT INTO notification_block \
|
"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) \
|
(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(id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
@@ -97,15 +99,9 @@ impl NotificationService {
|
|||||||
.bind(params.reason)
|
.bind(params.reason)
|
||||||
.bind(params.expires_at)
|
.bind(params.expires_at)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.execute(self.ctx.db.writer())
|
.fetch_one(self.ctx.db.writer())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.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(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_block(&self, session: &Session, block_id: Uuid) -> Result<(), AppError> {
|
pub async fn delete_block(&self, session: &Session, block_id: Uuid) -> Result<(), AppError> {
|
||||||
|
|||||||
+12
-14
@@ -34,20 +34,19 @@ impl NotificationService {
|
|||||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query_as::<_, Notification>(
|
||||||
"UPDATE notification SET read_at = $1, updated_at = $2 \
|
"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(now)
|
.bind(now)
|
||||||
.bind(notification_id)
|
.bind(notification_id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.execute(self.ctx.db.writer())
|
.fetch_optional(self.ctx.db.writer())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.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()))
|
.ok_or(AppError::NotFound("notification not found".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,20 +76,19 @@ impl NotificationService {
|
|||||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query_as::<_, Notification>(
|
||||||
"UPDATE notification SET dismissed_at = $1, updated_at = $2 \
|
"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(now)
|
.bind(now)
|
||||||
.bind(notification_id)
|
.bind(notification_id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.execute(self.ctx.db.writer())
|
.fetch_optional(self.ctx.db.writer())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.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()))
|
.ok_or(AppError::NotFound("notification not found".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use super::util::clamp_limit_offset;
|
use super::util::clamp_limit_offset;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||||
pub struct CreateSubscriptionParams {
|
pub struct CreateSubscriptionParams {
|
||||||
pub workspace_id: Option<Uuid>,
|
pub workspace_id: Option<Uuid>,
|
||||||
pub repo_id: Option<Uuid>,
|
pub repo_id: Option<Uuid>,
|
||||||
@@ -20,7 +20,7 @@ pub struct CreateSubscriptionParams {
|
|||||||
pub level: SubscriptionLevel,
|
pub level: SubscriptionLevel,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||||
pub struct UpdateSubscriptionParams {
|
pub struct UpdateSubscriptionParams {
|
||||||
pub event_types: Option<Vec<EventType>>,
|
pub event_types: Option<Vec<EventType>>,
|
||||||
pub channels: Option<Vec<String>>,
|
pub channels: Option<Vec<String>>,
|
||||||
@@ -89,10 +89,12 @@ impl NotificationService {
|
|||||||
let id = Uuid::now_v7();
|
let id = Uuid::now_v7();
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query_as::<_, NotificationSubscription>(
|
||||||
"INSERT INTO notification_subscription \
|
"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) \
|
(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(id)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
@@ -104,15 +106,9 @@ impl NotificationService {
|
|||||||
.bind(¶ms.channels)
|
.bind(¶ms.channels)
|
||||||
.bind(params.level.as_str())
|
.bind(params.level.as_str())
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.execute(self.ctx.db.writer())
|
.fetch_one(self.ctx.db.writer())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.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(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_subscription(
|
pub async fn update_subscription(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use super::util::clamp_limit_offset;
|
use super::util::clamp_limit_offset;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||||
pub struct CreateTemplateParams {
|
pub struct CreateTemplateParams {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
pub notification_type: NotificationType,
|
pub notification_type: NotificationType,
|
||||||
@@ -22,7 +22,7 @@ pub struct CreateTemplateParams {
|
|||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||||
pub struct UpdateTemplateParams {
|
pub struct UpdateTemplateParams {
|
||||||
pub subject_template: Option<String>,
|
pub subject_template: Option<String>,
|
||||||
pub title_template: Option<String>,
|
pub title_template: Option<String>,
|
||||||
@@ -115,11 +115,13 @@ impl NotificationService {
|
|||||||
let id = Uuid::now_v7();
|
let id = Uuid::now_v7();
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query_as::<_, NotificationTemplate>(
|
||||||
"INSERT INTO notification_template \
|
"INSERT INTO notification_template \
|
||||||
(id, key, notification_type, channel, locale, subject_template, title_template, \
|
(id, key, notification_type, channel, locale, subject_template, title_template, \
|
||||||
body_template, action_text_template, enabled, created_by, created_at, updated_at) \
|
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(id)
|
||||||
.bind(¶ms.key)
|
.bind(¶ms.key)
|
||||||
@@ -133,15 +135,9 @@ impl NotificationService {
|
|||||||
.bind(params.enabled)
|
.bind(params.enabled)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.execute(self.ctx.db.writer())
|
.fetch_one(self.ctx.db.writer())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)
|
||||||
|
|
||||||
NotificationTemplate::find_by_id(self.ctx.db.reader(), id)
|
|
||||||
.await?
|
|
||||||
.ok_or(AppError::InternalServerError(
|
|
||||||
"failed to fetch created template".into(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_template(
|
pub async fn update_template(
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use crate::models::prs::PrAssignee;
|
|||||||
use crate::service::PrService;
|
use crate::service::PrService;
|
||||||
use crate::session::Session;
|
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 {
|
impl PrService {
|
||||||
pub async fn pr_assignees(
|
pub async fn pr_assignees(
|
||||||
@@ -48,8 +48,7 @@ impl PrService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -98,8 +97,7 @@ impl PrService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::models::prs::{PrLabel, PrLabelRelation};
|
|||||||
use crate::service::PrService;
|
use crate::service::PrService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct CreatePrLabelParams {
|
pub struct CreatePrLabelParams {
|
||||||
@@ -140,8 +140,7 @@ impl PrService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -182,8 +181,7 @@ impl PrService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ pub mod files;
|
|||||||
pub mod labels;
|
pub mod labels;
|
||||||
pub mod merge_strategy;
|
pub mod merge_strategy;
|
||||||
pub mod reactions;
|
pub mod reactions;
|
||||||
|
pub mod review_requests;
|
||||||
pub mod reviews;
|
pub mod reviews;
|
||||||
pub mod status;
|
pub mod status;
|
||||||
pub mod subscriptions;
|
pub mod subscriptions;
|
||||||
|
pub mod templates;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
|||||||
@@ -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<Vec<PrReviewRequest>, 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<Uuid>,
|
||||||
|
) -> Result<Vec<PrReviewRequest>, 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ use crate::models::prs::PrSubscription;
|
|||||||
use crate::service::PrService;
|
use crate::service::PrService;
|
||||||
use crate::session::Session;
|
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 {
|
impl PrService {
|
||||||
pub async fn pr_subscriptions(
|
pub async fn pr_subscriptions(
|
||||||
@@ -47,8 +47,7 @@ impl PrService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -83,8 +82,7 @@ impl PrService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
pub title_template: Option<String>,
|
||||||
|
pub body_template: String,
|
||||||
|
pub labels: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
|
pub struct UpdatePrTemplateParams {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub title_template: Option<String>,
|
||||||
|
pub body_template: Option<String>,
|
||||||
|
pub labels: Option<Vec<String>>,
|
||||||
|
pub active: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PrService {
|
||||||
|
pub async fn pr_templates(
|
||||||
|
&self,
|
||||||
|
ctx: &Session,
|
||||||
|
wk_name: &str,
|
||||||
|
repo_name: &str,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<PrTemplate>, 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<PrTemplate, 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 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<PrTemplate, 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 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
pub use crate::service::util::{
|
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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::models::repos::RepoBranch;
|
|||||||
use crate::service::RepoService;
|
use crate::service::RepoService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct CreateBranchParams {
|
pub struct CreateBranchParams {
|
||||||
@@ -75,8 +75,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -139,8 +138,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -171,6 +169,45 @@ impl RepoService {
|
|||||||
Ok(())
|
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(
|
pub async fn repo_set_branch_protection(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
@@ -193,8 +230,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -215,6 +251,44 @@ impl RepoService {
|
|||||||
Ok(())
|
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(
|
pub async fn repo_delete_branch(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
@@ -249,8 +323,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::models::repos::{RepoCommitComment, RepoCommitStatus};
|
|||||||
use crate::service::RepoService;
|
use crate::service::RepoService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct CreateCommitStatusParams {
|
pub struct CreateCommitStatusParams {
|
||||||
@@ -86,8 +86,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -164,8 +163,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -213,8 +211,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -238,4 +235,51 @@ impl RepoService {
|
|||||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn repo_update_commit_comment(
|
||||||
|
&self,
|
||||||
|
ctx: &Session,
|
||||||
|
wk_name: &str,
|
||||||
|
repo_name: &str,
|
||||||
|
comment_id: Uuid,
|
||||||
|
body: &str,
|
||||||
|
) -> Result<RepoCommitComment, 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::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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<UserBaseInfo>,
|
||||||
|
pub commits: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RepoService {
|
||||||
|
pub async fn repo_contributors(
|
||||||
|
&self,
|
||||||
|
ctx: &Session,
|
||||||
|
wk_name: &str,
|
||||||
|
repo_name: &str,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<Contributor>, 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<Uuid> = 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ use crate::models::repos::RepoDeployKey;
|
|||||||
use crate::service::RepoService;
|
use crate::service::RepoService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct AddDeployKeyParams {
|
pub struct AddDeployKeyParams {
|
||||||
@@ -94,8 +94,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -143,8 +142,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
+61
-3
@@ -8,7 +8,7 @@ use crate::models::repos::{Repo, RepoFork};
|
|||||||
use crate::service::RepoService;
|
use crate::service::RepoService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||||
pub struct ForkRepoParams {
|
pub struct ForkRepoParams {
|
||||||
@@ -84,8 +84,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -240,6 +239,65 @@ impl RepoService {
|
|||||||
Ok(fork)
|
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(
|
pub(crate) async fn find_ws_for_repo(
|
||||||
&self,
|
&self,
|
||||||
repo: &Repo,
|
repo: &Repo,
|
||||||
|
|||||||
@@ -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<tonic::Streaming<crate::pb::repo::ArchiveChunk>, 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<crate::pb::repo::Branch, 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)?.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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<crate::pb::repo::GetDiffResponse, 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 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<Vec<u8>, 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<Vec<u8>, 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<crate::pb::repo::FindChangedPathsResponse, 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 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<tonic::Streaming<crate::pb::repo::BlameHunk>, 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<crate::pb::repo::MergeResult, 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::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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
|
pub mod archive;
|
||||||
pub mod blame;
|
pub mod blame;
|
||||||
pub mod branch;
|
pub mod branch;
|
||||||
|
pub mod branch_rename;
|
||||||
pub mod commit;
|
pub mod commit;
|
||||||
|
pub mod commit_extras;
|
||||||
|
pub mod commit_extras2;
|
||||||
pub mod diff;
|
pub mod diff;
|
||||||
|
pub mod diff_merge_extras;
|
||||||
pub mod merge;
|
pub mod merge;
|
||||||
|
pub mod repo_extras;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
|
pub mod repository_extras;
|
||||||
pub mod tag;
|
pub mod tag;
|
||||||
|
pub mod tag_get;
|
||||||
pub mod tree;
|
pub mod tree;
|
||||||
|
pub mod tree_extras;
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::models::repos::Repo;
|
use crate::models::repos::Repo;
|
||||||
|
|||||||
@@ -120,4 +120,29 @@ impl RepoService {
|
|||||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||||
Ok(resp.into_inner())
|
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String, 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)?.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<i32, 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)?.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<String>,
|
||||||
|
) -> Result<crate::pb::repo::ObjectsSizeResponse, 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)?.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<crate::pb::repo::RepositorySizeResponse, 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)?.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<String>,
|
||||||
|
) -> Result<crate::pb::repo::FindMergeBaseResponse, 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)?.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<crate::pb::repo::ListArchiveEntriesResponse, 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
|
||||||
|
.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<String>,
|
||||||
|
) -> Result<crate::pb::repo::CheckObjectsExistResponse, 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)?.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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<crate::pb::repo::Tag, 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)?.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<crate::pb::repo::VerifiedSignature, 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)?.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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Vec<u8>, 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<crate::pb::repo::FileMetadata, 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 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<crate::pb::repo::FindFilesResponse, 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 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<crate::pb::repo::Tree, 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 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ use crate::pb::email::{EmailAddress, SendEmailRequest};
|
|||||||
use crate::service::RepoService;
|
use crate::service::RepoService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct CreateRepoInvitationParams {
|
pub struct CreateRepoInvitationParams {
|
||||||
@@ -114,8 +114,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -186,8 +185,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -267,8 +265,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::models::repos::RepoMember;
|
|||||||
use crate::service::RepoService;
|
use crate::service::RepoService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct AddRepoMemberParams {
|
pub struct AddRepoMemberParams {
|
||||||
@@ -114,8 +114,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -193,8 +192,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -256,8 +254,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -297,8 +294,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod branches;
|
pub mod branches;
|
||||||
pub mod commit_status;
|
pub mod commit_status;
|
||||||
|
pub mod contributors;
|
||||||
pub mod core;
|
pub mod core;
|
||||||
pub mod deploy_keys;
|
pub mod deploy_keys;
|
||||||
pub mod fork;
|
pub mod fork;
|
||||||
@@ -7,6 +8,7 @@ pub mod git;
|
|||||||
pub mod invitations;
|
pub mod invitations;
|
||||||
pub mod members;
|
pub mod members;
|
||||||
pub mod protection;
|
pub mod protection;
|
||||||
|
pub mod release_assets;
|
||||||
pub mod releases;
|
pub mod releases;
|
||||||
pub mod stars;
|
pub mod stars;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
|
|||||||
@@ -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<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RepoReleaseAsset> 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<u8>,
|
||||||
|
content_type: &str,
|
||||||
|
) -> Result<ReleaseAssetData, 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::Member)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let release: Option<RepoRelease> = 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<Vec<ReleaseAssetData>, 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<RepoReleaseAsset> = 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<String, 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 asset: Option<RepoReleaseAsset> = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ use crate::models::repos::RepoRelease;
|
|||||||
use crate::service::RepoService;
|
use crate::service::RepoService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct CreateReleaseParams {
|
pub struct CreateReleaseParams {
|
||||||
@@ -80,8 +82,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -165,8 +166,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -212,8 +212,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
+8
-20
@@ -5,7 +5,7 @@ use crate::models::repos::RepoStar;
|
|||||||
use crate::service::RepoService;
|
use crate::service::RepoService;
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
|
|
||||||
use super::util::clamp_limit_offset;
|
use super::util::{clamp_limit_offset, set_local_user_id};
|
||||||
|
|
||||||
impl RepoService {
|
impl RepoService {
|
||||||
pub async fn repo_star(
|
pub async fn repo_star(
|
||||||
@@ -19,19 +19,6 @@ impl RepoService {
|
|||||||
let repo_id = repo.id;
|
let repo_id = repo.id;
|
||||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
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 now = chrono::Utc::now();
|
||||||
let mut txn = self
|
let mut txn = self
|
||||||
.ctx
|
.ctx
|
||||||
@@ -40,14 +27,13 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
sqlx::query(
|
let result = sqlx::query(
|
||||||
"INSERT INTO repo_star (id, repo_id, user_id, created_at) VALUES ($1, $2, $3, $4)",
|
"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(Uuid::now_v7())
|
||||||
.bind(repo_id)
|
.bind(repo_id)
|
||||||
@@ -57,6 +43,8 @@ impl RepoService {
|
|||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
// Only increment stars_count if the INSERT actually happened
|
||||||
|
if result.rows_affected() > 0 {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE repo_stats SET stars_count = stars_count + 1, updated_at = $1 WHERE repo_id = $2",
|
"UPDATE repo_stats SET stars_count = stars_count + 1, updated_at = $1 WHERE repo_id = $2",
|
||||||
)
|
)
|
||||||
@@ -65,6 +53,7 @@ impl RepoService {
|
|||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
}
|
||||||
|
|
||||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -88,8 +77,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
+42
-4
@@ -95,11 +95,19 @@ impl RepoService {
|
|||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
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>(
|
let result = sqlx::query_as::<_, RepoStats>(
|
||||||
"UPDATE repo_stats SET stars_count = $1, watchers_count = $2, forks_count = $3, \
|
"UPDATE repo_stats SET stars_count = $1, watchers_count = $2, forks_count = $3, \
|
||||||
branches_count = $4, tags_count = $5, releases_count = $6, \
|
branches_count = $4, tags_count = $5, releases_count = $6, \
|
||||||
open_issues_count = $7, open_pull_requests_count = $8, updated_at = $9 \
|
open_issues_count = $7, open_pull_requests_count = $8, \
|
||||||
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",
|
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(stars_count)
|
||||||
.bind(watchers_count)
|
.bind(watchers_count)
|
||||||
@@ -109,6 +117,9 @@ impl RepoService {
|
|||||||
.bind(releases_count)
|
.bind(releases_count)
|
||||||
.bind(open_issues_count)
|
.bind(open_issues_count)
|
||||||
.bind(open_prs_count)
|
.bind(open_prs_count)
|
||||||
|
.bind(commits_count)
|
||||||
|
.bind(size_bytes)
|
||||||
|
.bind(last_push_at)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(repo_id)
|
.bind(repo_id)
|
||||||
.fetch_one(self.ctx.db.writer())
|
.fetch_one(self.ctx.db.writer())
|
||||||
@@ -118,6 +129,31 @@ impl RepoService {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn try_fetch_git_stats(
|
||||||
|
&self,
|
||||||
|
ctx: &Session,
|
||||||
|
wk_name: &str,
|
||||||
|
repo_name: &str,
|
||||||
|
) -> Option<(i64, i64, Option<chrono::DateTime<chrono::Utc>>)> {
|
||||||
|
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<RepoStats, AppError> {
|
async fn ensure_repo_stats(&self, repo_id: Uuid) -> Result<RepoStats, AppError> {
|
||||||
if let Some(stats) = sqlx::query_as::<_, RepoStats>(
|
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",
|
"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())
|
.execute(self.ctx.db.writer())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
// Read from writer to avoid replication lag
|
||||||
sqlx::query_as::<_, RepoStats>(
|
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",
|
"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)
|
.bind(repo_id)
|
||||||
.fetch_one(self.ctx.db.reader())
|
.fetch_optional(self.ctx.db.writer())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)
|
.map_err(AppError::Database)?
|
||||||
|
.ok_or(AppError::NotFound("repo stats not found".into()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-5
@@ -7,7 +7,7 @@ use crate::models::repos::RepoTag;
|
|||||||
use crate::service::RepoService;
|
use crate::service::RepoService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct CreateTagParams {
|
pub struct CreateTagParams {
|
||||||
@@ -16,6 +16,12 @@ pub struct CreateTagParams {
|
|||||||
pub message: Option<String>,
|
pub message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
|
pub struct UpdateTagParams {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
impl RepoService {
|
impl RepoService {
|
||||||
pub async fn repo_tags(
|
pub async fn repo_tags(
|
||||||
&self,
|
&self,
|
||||||
@@ -76,8 +82,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -130,8 +135,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -156,4 +160,63 @@ impl RepoService {
|
|||||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn repo_update_tag(
|
||||||
|
&self,
|
||||||
|
ctx: &Session,
|
||||||
|
wk_name: &str,
|
||||||
|
repo_name: &str,
|
||||||
|
tag_id: Uuid,
|
||||||
|
params: UpdateTagParams,
|
||||||
|
) -> Result<RepoTag, 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 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
pub use crate::service::util::{
|
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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::models::repos::RepoWatch;
|
|||||||
use crate::service::RepoService;
|
use crate::service::RepoService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct WatchParams {
|
pub struct WatchParams {
|
||||||
@@ -45,8 +45,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -112,8 +111,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use crate::models::repos::RepoWebhook;
|
|||||||
use crate::service::RepoService;
|
use crate::service::RepoService;
|
||||||
use crate::session::Session;
|
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
|
/// Validate webhook URL for SSRF protection
|
||||||
fn validate_webhook_url(url_str: &str) -> Result<(), AppError> {
|
fn validate_webhook_url(url_str: &str) -> Result<(), AppError> {
|
||||||
@@ -133,8 +133,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -204,8 +203,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -249,8 +247,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -266,4 +263,66 @@ impl RepoService {
|
|||||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn repo_webhook_deliveries(
|
||||||
|
&self,
|
||||||
|
ctx: &Session,
|
||||||
|
wk_name: &str,
|
||||||
|
repo_name: &str,
|
||||||
|
webhook_id: Uuid,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<serde_json::Value>, 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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+190
-40
@@ -3,11 +3,12 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::models::common::Visibility;
|
use crate::models::common::Visibility;
|
||||||
use crate::models::users::User;
|
use crate::models::users::User;
|
||||||
|
use crate::pb::email::{EmailAddress, SendEmailRequest};
|
||||||
use crate::service::UserService;
|
use crate::service::UserService;
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
|
|
||||||
use super::util::{merge_optional_text, parse_enum};
|
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)]
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct UpdateUserAccountParams {
|
pub struct UpdateUserAccountParams {
|
||||||
@@ -17,20 +18,9 @@ pub struct UpdateUserAccountParams {
|
|||||||
pub visibility: Option<String>,
|
pub visibility: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct UploadUserAvatarParams {
|
|
||||||
pub data: Vec<u8>,
|
|
||||||
pub content_type: Option<String>,
|
|
||||||
pub file_name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
|
||||||
pub struct UserAvatarResponse {
|
|
||||||
pub avatar_url: String,
|
|
||||||
pub storage_key: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UserService {
|
impl UserService {
|
||||||
|
const RESTORE_TOKEN_VALIDITY_DAYS: i64 = 30;
|
||||||
|
|
||||||
pub async fn user_account(&self, ctx: &Session) -> Result<User, AppError> {
|
pub async fn user_account(&self, ctx: &Session) -> Result<User, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||||
crate::models::users::User::find_by_id(self.ctx.db.reader(), user_uid)
|
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(
|
pub async fn user_upload_avatar(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
params: UploadUserAvatarParams,
|
data: Vec<u8>,
|
||||||
) -> Result<UserAvatarResponse, AppError> {
|
content_type: Option<String>,
|
||||||
|
file_name: Option<String>,
|
||||||
|
) -> Result<(String, String), AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||||
let ext = avatar_extension(params.content_type.as_deref(), params.file_name.as_deref())?;
|
let ext = avatar_extension(content_type.as_deref(), file_name.as_deref())?;
|
||||||
validate_avatar_size(params.data.len(), self.ctx.config.s3_max_upload_size()?)?;
|
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)
|
let current = crate::models::users::User::find_by_id(self.ctx.db.reader(), user_uid)
|
||||||
.await
|
.await
|
||||||
@@ -96,7 +88,7 @@ impl UserService {
|
|||||||
let old_avatar_url = current.avatar_url.clone();
|
let old_avatar_url = current.avatar_url.clone();
|
||||||
|
|
||||||
let storage_key = format!("users/{}/avatar/{}.{}", user_uid, uuid::Uuid::now_v7(), ext);
|
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(|| {
|
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())
|
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;
|
let _ = self.ctx.storage.delete(&old_key).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(UserAvatarResponse {
|
Ok((avatar_url, storage_key))
|
||||||
avatar_url,
|
|
||||||
storage_key,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_delete_account(&self, ctx: &Session) -> Result<(), AppError> {
|
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<String> = 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<String> = 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<uuid::Uuid> = 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 now = chrono::Utc::now();
|
||||||
let mut txn = self
|
let mut txn = self
|
||||||
.ctx
|
.ctx
|
||||||
@@ -168,20 +271,26 @@ impl UserService {
|
|||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
|
|
||||||
for statement in [
|
for statement in [
|
||||||
"DELETE FROM user_personal_access_token WHERE user_id = $1",
|
"UPDATE user_personal_access_token SET revoked_at = NULL WHERE user_id = $1 AND revoked_at IS NOT NULL",
|
||||||
"DELETE FROM user_security_log WHERE user_id = $1",
|
"UPDATE user_session SET revoked_at = NULL WHERE user_id = $1 AND revoked_at IS NOT NULL",
|
||||||
"DELETE FROM user_session WHERE user_id = $1",
|
"UPDATE user_ssh_key SET revoked_at = NULL WHERE user_id = $1 AND revoked_at IS NOT NULL",
|
||||||
"DELETE FROM user_device WHERE user_id = $1",
|
"UPDATE user_gpg_key SET revoked_at = NULL WHERE user_id = $1 AND revoked_at IS NOT NULL",
|
||||||
"DELETE FROM user_oauth WHERE user_id = $1",
|
"UPDATE workspace_member SET status = 'active' WHERE user_id = $1 AND status = 'deleted'",
|
||||||
"DELETE FROM user_ssh_key WHERE user_id = $1",
|
"UPDATE repo_member SET status = 'active' WHERE user_id = $1 AND status = 'deleted'",
|
||||||
"DELETE FROM user_gpg_key WHERE user_id = $1",
|
"UPDATE user_2fa SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL",
|
||||||
"DELETE FROM user_2fa WHERE user_id = $1",
|
"UPDATE user_activity SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL",
|
||||||
"DELETE FROM user_notify_setting WHERE user_id = $1",
|
"UPDATE user_appearance SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL",
|
||||||
"DELETE FROM user_appearance WHERE user_id = $1",
|
"UPDATE user_block SET deleted_at = NULL WHERE (user_id = $1 OR blocked_user_id = $1) AND deleted_at IS NOT NULL",
|
||||||
"DELETE FROM user_profile WHERE user_id = $1",
|
"UPDATE user_device SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL",
|
||||||
"DELETE FROM user_mail WHERE user_id = $1",
|
"UPDATE user_follow SET deleted_at = NULL WHERE (user_id = $1 OR following_user_id = $1) AND deleted_at IS NOT NULL",
|
||||||
"DELETE FROM workspace_member WHERE user_id = $1",
|
"UPDATE user_mail SET deleted_at = NULL WHERE user_id = $1 AND deleted_at IS NOT NULL",
|
||||||
"DELETE FROM repo_member WHERE user_id = $1",
|
"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)
|
sqlx::query(statement)
|
||||||
.bind(user_uid)
|
.bind(user_uid)
|
||||||
@@ -191,7 +300,9 @@ impl UserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let result = sqlx::query(
|
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(now)
|
||||||
.bind(user_uid)
|
.bind(user_uid)
|
||||||
@@ -199,14 +310,53 @@ impl UserService {
|
|||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
return Err(AppError::UserNotFound);
|
return Err(AppError::NotFound("user not found".into()));
|
||||||
}
|
}
|
||||||
|
|
||||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||||
ctx.clear();
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn send_restore_email(&self, email: &str, token: &str) -> Result<(), AppError> {
|
||||||
|
let app_url = self
|
||||||
|
.ctx
|
||||||
|
.config
|
||||||
|
.get_env::<String>("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(
|
async fn ensure_username_available(
|
||||||
&self,
|
&self,
|
||||||
username: &str,
|
username: &str,
|
||||||
|
|||||||
@@ -85,8 +85,16 @@ impl UserService {
|
|||||||
.execute(self.ctx.db.writer())
|
.execute(self.ctx.db.writer())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
self.find_user_appearance(user_uid)
|
// Read from writer to avoid replication lag
|
||||||
.await?
|
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)
|
.ok_or(AppError::UserNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-4
@@ -26,14 +26,23 @@ pub struct AddGpgKeyParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl UserService {
|
impl UserService {
|
||||||
pub async fn user_ssh_keys(&self, ctx: &Session) -> Result<Vec<UserSshKey>, AppError> {
|
pub async fn user_ssh_keys(
|
||||||
|
&self,
|
||||||
|
ctx: &Session,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<UserSshKey>, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||||
|
let limit = limit.clamp(1, 100);
|
||||||
|
let offset = offset.max(0);
|
||||||
sqlx::query_as::<_, UserSshKey>(
|
sqlx::query_as::<_, UserSshKey>(
|
||||||
"SELECT id, user_id, title, public_key, fingerprint_sha256, key_type, last_used_at, \
|
"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 \
|
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(user_uid)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
.fetch_all(self.ctx.db.reader())
|
.fetch_all(self.ctx.db.reader())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)
|
.map_err(AppError::Database)
|
||||||
@@ -98,14 +107,23 @@ impl UserService {
|
|||||||
ensure_affected(result.rows_affected(), "key not found")
|
ensure_affected(result.rows_affected(), "key not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_gpg_keys(&self, ctx: &Session) -> Result<Vec<UserGpgKey>, AppError> {
|
pub async fn user_gpg_keys(
|
||||||
|
&self,
|
||||||
|
ctx: &Session,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<UserGpgKey>, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||||
|
let limit = limit.clamp(1, 100);
|
||||||
|
let offset = offset.max(0);
|
||||||
sqlx::query_as::<_, UserGpgKey>(
|
sqlx::query_as::<_, UserGpgKey>(
|
||||||
"SELECT id, user_id, key_id, public_key, fingerprint, primary_email, expires_at, \
|
"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 \
|
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(user_uid)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
.fetch_all(self.ctx.db.reader())
|
.fetch_all(self.ctx.db.reader())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)
|
.map_err(AppError::Database)
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ pub mod keys;
|
|||||||
pub mod notify;
|
pub mod notify;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod security;
|
pub mod security;
|
||||||
|
pub mod social;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
|||||||
+10
-2
@@ -100,8 +100,16 @@ impl UserService {
|
|||||||
.execute(self.ctx.db.writer())
|
.execute(self.ctx.db.writer())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
self.find_user_notify_setting(user_uid)
|
// Read from writer to avoid replication lag
|
||||||
.await?
|
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)
|
.ok_or(AppError::UserNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -68,8 +68,16 @@ impl UserService {
|
|||||||
.execute(self.ctx.db.writer())
|
.execute(self.ctx.db.writer())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
self.find_user_profile(user_uid)
|
// Read from writer to avoid replication lag
|
||||||
.await?
|
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)
|
.ok_or(AppError::UserNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+137
-15
@@ -1,4 +1,5 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
use rand::RngCore;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -8,7 +9,7 @@ use crate::models::users::{UserDevice, UserSecurityLog};
|
|||||||
use crate::service::UserService;
|
use crate::service::UserService;
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
|
|
||||||
use super::util::ensure_affected;
|
use super::util::{ensure_affected, sha256_hex};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
pub struct UserSessionInfo {
|
pub struct UserSessionInfo {
|
||||||
@@ -81,14 +82,23 @@ struct UserPersonalAccessTokenRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl UserService {
|
impl UserService {
|
||||||
pub async fn user_devices(&self, ctx: &Session) -> Result<Vec<UserDevice>, AppError> {
|
pub async fn user_devices(
|
||||||
|
&self,
|
||||||
|
ctx: &Session,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<UserDevice>, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||||
|
let limit = limit.clamp(1, 100);
|
||||||
|
let offset = offset.max(0);
|
||||||
sqlx::query_as::<_, UserDevice>(
|
sqlx::query_as::<_, UserDevice>(
|
||||||
"SELECT id, user_id, device_name, device_type, fingerprint, ip_address, user_agent, \
|
"SELECT id, user_id, device_name, device_type, fingerprint, ip_address, user_agent, \
|
||||||
trusted, last_seen_at, created_at, updated_at FROM user_device \
|
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(user_uid)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
.fetch_all(self.ctx.db.reader())
|
.fetch_all(self.ctx.db.reader())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)
|
.map_err(AppError::Database)
|
||||||
@@ -137,27 +147,63 @@ impl UserService {
|
|||||||
session_uid: Uuid,
|
session_uid: Uuid,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||||
let result = sqlx::query(
|
|
||||||
"UPDATE user_session SET revoked_at = $1 \
|
// Use transaction with SELECT FOR UPDATE to prevent race conditions
|
||||||
WHERE id = $2 AND user_id = $3 AND revoked_at IS NULL",
|
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(session_uid)
|
||||||
|
.bind(user_uid)
|
||||||
|
.fetch_optional(&mut *txn)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
|
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(chrono::Utc::now())
|
||||||
.bind(session_uid)
|
.bind(session_uid)
|
||||||
.bind(user_uid)
|
.bind(user_uid)
|
||||||
.execute(self.ctx.db.writer())
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
ensure_affected(result.rows_affected(), "session not found")
|
|
||||||
|
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<Vec<UserOAuthInfo>, AppError> {
|
pub async fn user_oauth_accounts(
|
||||||
|
&self,
|
||||||
|
ctx: &Session,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
) -> Result<Vec<UserOAuthInfo>, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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>(
|
let rows = sqlx::query_as::<_, UserOAuthRow>(
|
||||||
"SELECT id, provider, provider_user_id, provider_username, provider_email, \
|
"SELECT id, provider, provider_user_id, provider_username, provider_email, \
|
||||||
token_expires_at, linked_at, last_used_at FROM user_oauth \
|
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(user_uid)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
.fetch_all(self.ctx.db.reader())
|
.fetch_all(self.ctx.db.reader())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -167,17 +213,27 @@ impl UserService {
|
|||||||
pub async fn user_unlink_oauth(&self, ctx: &Session, oauth_uid: Uuid) -> Result<(), AppError> {
|
pub async fn user_unlink_oauth(&self, ctx: &Session, oauth_uid: Uuid) -> Result<(), AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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 =
|
let has_password: bool =
|
||||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM user_password WHERE user_id = $1)")
|
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM user_password WHERE user_id = $1)")
|
||||||
.bind(user_uid)
|
.bind(user_uid)
|
||||||
.fetch_one(self.ctx.db.reader())
|
.fetch_one(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
let oauth_count: i64 =
|
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)
|
.bind(user_uid)
|
||||||
.fetch_one(self.ctx.db.reader())
|
.fetch_one(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.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")
|
let result = sqlx::query("DELETE FROM user_oauth WHERE id = $1 AND user_id = $2")
|
||||||
.bind(oauth_uid)
|
.bind(oauth_uid)
|
||||||
.bind(user_uid)
|
.bind(user_uid)
|
||||||
.execute(self.ctx.db.writer())
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.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(
|
pub async fn user_security_logs(
|
||||||
@@ -284,6 +346,66 @@ impl UserService {
|
|||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
ensure_affected(result.rows_affected(), "token not found")
|
ensure_affected(result.rows_affected(), "token not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn user_create_personal_access_token(
|
||||||
|
&self,
|
||||||
|
ctx: &Session,
|
||||||
|
params: CreatePersonalAccessTokenParams,
|
||||||
|
) -> Result<CreatePersonalAccessTokenResponse, AppError> {
|
||||||
|
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::<String>();
|
||||||
|
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<Scope>,
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct CreatePersonalAccessTokenResponse {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub scopes: Vec<Scope>,
|
||||||
|
pub token: String,
|
||||||
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<UserSessionRow> for UserSessionInfo {
|
impl From<UserSessionRow> for UserSessionInfo {
|
||||||
|
|||||||
@@ -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<String>,
|
||||||
|
pub custom_status_emoji: Option<String>,
|
||||||
|
pub device_type: Option<DeviceType>,
|
||||||
|
pub ip_address: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
struct UserPresenceRow {
|
||||||
|
id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
status: PresenceStatus,
|
||||||
|
custom_status_text: Option<String>,
|
||||||
|
custom_status_emoji: Option<String>,
|
||||||
|
device_type: Option<DeviceType>,
|
||||||
|
ip_address: Option<String>,
|
||||||
|
last_active_at: DateTime<Utc>,
|
||||||
|
last_seen_at: Option<DateTime<Utc>>,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<UserPresenceRow> 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<UserPresence, AppError> {
|
||||||
|
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<UserPresence, AppError> {
|
||||||
|
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<Vec<UserBlock>, 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<String>,
|
||||||
|
) -> Result<UserBlock, AppError> {
|
||||||
|
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<Vec<UserFollow>, 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<UserFollow, AppError> {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::models::common::Role;
|
use crate::models::common::Role;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub fn merge_optional_text(next: Option<String>, current: Option<String>) -> Option<String> {
|
pub fn merge_optional_text(next: Option<String>, current: Option<String>) -> Option<String> {
|
||||||
next.map(|v| {
|
next.map(|v| {
|
||||||
@@ -152,3 +153,12 @@ pub fn validate_password_strength(password: &str) -> Result<(), AppError> {
|
|||||||
}
|
}
|
||||||
Ok(())
|
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<String> {
|
||||||
|
sqlx::AssertSqlSafe(format!("SET LOCAL app.current_user_id = '{user_uid}'"))
|
||||||
|
}
|
||||||
|
|||||||
+24
-11
@@ -6,7 +6,7 @@ use crate::models::wiki::WikiPage;
|
|||||||
use crate::service::RepoService;
|
use crate::service::RepoService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct CreateWikiPageParams {
|
pub struct CreateWikiPageParams {
|
||||||
@@ -120,8 +120,7 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -190,15 +189,14 @@ impl RepoService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
let updated = sqlx::query_as::<_, WikiPage>(
|
let updated = sqlx::query_as::<_, WikiPage>(
|
||||||
"UPDATE wiki_page SET title = $1, content = $2, last_editor_id = $3, version = $4, updated_at = $5 \
|
"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",
|
RETURNING id, repo_id, slug, title, content, author_id, last_editor_id, version, created_at, updated_at, deleted_at",
|
||||||
)
|
)
|
||||||
.bind(&new_title)
|
.bind(&new_title)
|
||||||
@@ -207,9 +205,11 @@ impl RepoService {
|
|||||||
.bind(new_version)
|
.bind(new_version)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(page.id)
|
.bind(page.id)
|
||||||
.fetch_one(&mut *txn)
|
.bind(page.version)
|
||||||
|
.fetch_optional(&mut *txn)
|
||||||
.await
|
.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(
|
sqlx::query(
|
||||||
"INSERT INTO wiki_page_revision (id, page_id, version, title, content, editor_id, commit_message, created_at) \
|
"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 {
|
fn generate_slug(title: &str) -> String {
|
||||||
title
|
let slug: String = title
|
||||||
.to_lowercase()
|
.to_lowercase()
|
||||||
.chars()
|
.chars()
|
||||||
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
.map(|c| {
|
||||||
|
if c.is_alphanumeric() || c.is_ascii_alphanumeric() {
|
||||||
|
c
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect::<String>()
|
.collect::<String>()
|
||||||
.split('-')
|
.split('-')
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.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<String, AppError> {
|
async fn generate_wiki_slug(&self, repo_id: Uuid, title: &str) -> Result<String, AppError> {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::models::workspaces::{Workspace, WorkspacePendingApproval};
|
|||||||
use crate::service::WorkspaceService;
|
use crate::service::WorkspaceService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct RequestApprovalParams {
|
pub struct RequestApprovalParams {
|
||||||
@@ -64,8 +64,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -116,8 +115,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::models::workspaces::{Workspace, WorkspaceBilling};
|
|||||||
use crate::service::WorkspaceService;
|
use crate::service::WorkspaceService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct UpdateBillingParams {
|
pub struct UpdateBillingParams {
|
||||||
@@ -51,8 +51,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::models::workspaces::{Workspace, WorkspaceCustomBranding};
|
|||||||
use crate::service::WorkspaceService;
|
use crate::service::WorkspaceService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct UpdateBrandingParams {
|
pub struct UpdateBrandingParams {
|
||||||
@@ -52,8 +52,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
+109
-31
@@ -7,7 +7,9 @@ use crate::models::workspaces::Workspace;
|
|||||||
use crate::service::WorkspaceService;
|
use crate::service::WorkspaceService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct CreateWorkspaceParams {
|
pub struct CreateWorkspaceParams {
|
||||||
@@ -71,6 +73,20 @@ impl WorkspaceService {
|
|||||||
return Err(AppError::BadRequest("name is required".into()));
|
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 {
|
let visibility = match params.visibility {
|
||||||
Some(ref v) => parse_enum(
|
Some(ref v) => parse_enum(
|
||||||
Some(v.clone()),
|
Some(v.clone()),
|
||||||
@@ -91,8 +107,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -219,8 +234,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -259,8 +273,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -296,8 +309,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -333,8 +345,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -354,6 +365,59 @@ impl WorkspaceService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn workspace_restore(
|
||||||
|
&self,
|
||||||
|
ctx: &Session,
|
||||||
|
workspace_name: &str,
|
||||||
|
) -> Result<Workspace, AppError> {
|
||||||
|
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(
|
pub async fn workspace_transfer_owner(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
@@ -361,28 +425,12 @@ impl WorkspaceService {
|
|||||||
new_owner_id: Uuid,
|
new_owner_id: Uuid,
|
||||||
) -> Result<Workspace, AppError> {
|
) -> Result<Workspace, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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 {
|
if new_owner_id == ws.owner_id {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"new owner must be different from current owner".into(),
|
"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 now = chrono::Utc::now();
|
||||||
let mut txn = self
|
let mut txn = self
|
||||||
@@ -392,12 +440,43 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.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(
|
sqlx::query(
|
||||||
"UPDATE workspace_member SET role = 'owner', updated_at = $1 \
|
"UPDATE workspace_member SET role = 'owner', updated_at = $1 \
|
||||||
WHERE workspace_id = $2 AND user_id = $3",
|
WHERE workspace_id = $2 AND user_id = $3",
|
||||||
@@ -467,8 +546,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -7,13 +7,18 @@ use crate::models::workspaces::{Workspace, WorkspaceDomain};
|
|||||||
use crate::service::WorkspaceService;
|
use crate::service::WorkspaceService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct AddDomainParams {
|
pub struct AddDomainParams {
|
||||||
pub domain: String,
|
pub domain: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
|
pub struct UpdateDomainParams {
|
||||||
|
pub domain: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl WorkspaceService {
|
impl WorkspaceService {
|
||||||
pub async fn workspace_domains(
|
pub async fn workspace_domains(
|
||||||
&self,
|
&self,
|
||||||
@@ -70,8 +75,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -113,8 +117,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -156,8 +159,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -229,8 +231,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -248,6 +249,49 @@ impl WorkspaceService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn workspace_update_domain(
|
||||||
|
&self,
|
||||||
|
ctx: &Session,
|
||||||
|
ws: &Workspace,
|
||||||
|
domain_id: Uuid,
|
||||||
|
params: UpdateDomainParams,
|
||||||
|
) -> Result<WorkspaceDomain, AppError> {
|
||||||
|
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 {
|
fn generate_domain_verification_token() -> String {
|
||||||
(0..32)
|
(0..32)
|
||||||
.map(|_| format!("{:02x}", rand::random::<u8>()))
|
.map(|_| format!("{:02x}", rand::random::<u8>()))
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::models::workspaces::{Workspace, WorkspaceIntegration};
|
|||||||
use crate::service::WorkspaceService;
|
use crate::service::WorkspaceService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct CreateIntegrationParams {
|
pub struct CreateIntegrationParams {
|
||||||
@@ -79,8 +79,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -146,8 +145,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -195,8 +193,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::pb::email::{EmailAddress, SendEmailRequest};
|
|||||||
use crate::service::WorkspaceService;
|
use crate::service::WorkspaceService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct CreateInvitationParams {
|
pub struct CreateInvitationParams {
|
||||||
@@ -108,8 +108,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -179,8 +178,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -262,8 +260,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
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::error::AppError;
|
||||||
use crate::models::common::Role;
|
use crate::models::common::Role;
|
||||||
use crate::models::workspaces::{Workspace, WorkspaceMember};
|
use crate::models::workspaces::{Workspace, WorkspaceMember};
|
||||||
@@ -108,8 +108,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -204,8 +203,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -268,8 +266,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -313,8 +310,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::models::workspaces::{Workspace, WorkspaceSettings};
|
|||||||
use crate::service::WorkspaceService;
|
use crate::service::WorkspaceService;
|
||||||
use crate::session::Session;
|
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)]
|
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||||
pub struct UpdateWorkspaceSettingsParams {
|
pub struct UpdateWorkspaceSettingsParams {
|
||||||
@@ -50,8 +50,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -103,8 +102,17 @@ impl WorkspaceService {
|
|||||||
.execute(self.ctx.db.writer())
|
.execute(self.ctx.db.writer())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
self.find_workspace_settings(workspace_id)
|
// Read from writer to avoid replication lag
|
||||||
.await?
|
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()))
|
.ok_or(AppError::NotFound("workspace settings not found".into()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ impl WorkspaceService {
|
|||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|
||||||
let issues_count = sqlx::query_scalar::<_, i64>(
|
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)
|
.bind(ws.id)
|
||||||
.fetch_one(self.ctx.db.reader())
|
.fetch_one(self.ctx.db.reader())
|
||||||
@@ -102,14 +102,16 @@ impl WorkspaceService {
|
|||||||
.execute(self.ctx.db.writer())
|
.execute(self.ctx.db.writer())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
// Read from writer to avoid replication lag
|
||||||
sqlx::query_as::<_, WorkspaceStats>(
|
sqlx::query_as::<_, WorkspaceStats>(
|
||||||
"SELECT workspace_id, members_count, repos_count, issues_count, pull_requests_count, \
|
"SELECT workspace_id, members_count, repos_count, issues_count, pull_requests_count, \
|
||||||
storage_bytes, bandwidth_bytes, build_minutes_used, last_activity_at, updated_at \
|
storage_bytes, bandwidth_bytes, build_minutes_used, last_activity_at, updated_at \
|
||||||
FROM workspace_stats WHERE workspace_id = $1",
|
FROM workspace_stats WHERE workspace_id = $1",
|
||||||
)
|
)
|
||||||
.bind(workspace_id)
|
.bind(workspace_id)
|
||||||
.fetch_one(self.ctx.db.reader())
|
.fetch_optional(self.ctx.db.writer())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)
|
.map_err(AppError::Database)?
|
||||||
|
.ok_or(AppError::NotFound("workspace stats not found".into()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
pub use crate::service::util::{
|
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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use crate::models::workspaces::{Workspace, WorkspaceWebhook};
|
|||||||
use crate::service::WorkspaceService;
|
use crate::service::WorkspaceService;
|
||||||
use crate::session::Session;
|
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> {
|
fn validate_webhook_url(url_str: &str) -> Result<(), AppError> {
|
||||||
let url = Url::parse(url_str).map_err(|_| AppError::BadRequest("Invalid URL format".into()))?;
|
let url = Url::parse(url_str).map_err(|_| AppError::BadRequest("Invalid URL format".into()))?;
|
||||||
@@ -114,8 +114,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -186,8 +185,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -230,8 +228,7 @@ impl WorkspaceService {
|
|||||||
.begin()
|
.begin()
|
||||||
.await
|
.await
|
||||||
.map_err(|_| AppError::TxnError)?;
|
.map_err(|_| AppError::TxnError)?;
|
||||||
sqlx::query("SET LOCAL app.current_user_id = $1")
|
sqlx::query(set_local_user_id(user_uid))
|
||||||
.bind(user_uid)
|
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
Reference in New Issue
Block a user