feat: init
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
use crate::session::Session;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::service::AuthService;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CaptchaQuery {
|
||||
pub w: u32,
|
||||
pub h: u32,
|
||||
pub dark: bool,
|
||||
pub rsa: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, utoipa::ToSchema)]
|
||||
pub struct CaptchaResponse {
|
||||
pub base64: String,
|
||||
pub rsa: Option<super::rsa::RsaResponse>,
|
||||
pub req: CaptchaQuery,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
const CAPTCHA_KEY: &'static str = "captcha";
|
||||
const CAPTCHA_LENGTH: usize = 4;
|
||||
const CAPTCHA_MIN_WIDTH: u32 = 80;
|
||||
const CAPTCHA_MAX_WIDTH: u32 = 400;
|
||||
const CAPTCHA_MIN_HEIGHT: u32 = 30;
|
||||
const CAPTCHA_MAX_HEIGHT: u32 = 200;
|
||||
|
||||
pub async fn auth_captcha(
|
||||
&self,
|
||||
context: &Session,
|
||||
query: CaptchaQuery,
|
||||
) -> Result<CaptchaResponse, AppError> {
|
||||
let CaptchaQuery { w, h, dark, rsa } = query;
|
||||
if !(Self::CAPTCHA_MIN_WIDTH..=Self::CAPTCHA_MAX_WIDTH).contains(&w)
|
||||
|| !(Self::CAPTCHA_MIN_HEIGHT..=Self::CAPTCHA_MAX_HEIGHT).contains(&h)
|
||||
{
|
||||
return Err(AppError::BadRequest("invalid captcha size".into()));
|
||||
}
|
||||
|
||||
let captcha = captcha_rs::CaptchaBuilder::new()
|
||||
.width(w)
|
||||
.height(h)
|
||||
.dark_mode(dark)
|
||||
.length(Self::CAPTCHA_LENGTH)
|
||||
.build();
|
||||
|
||||
let base64 = captcha.to_base64();
|
||||
let text = captcha.text;
|
||||
context
|
||||
.insert(Self::CAPTCHA_KEY, text)
|
||||
.map_err(|_| AppError::InternalServerError("session insert failed".into()))?;
|
||||
|
||||
Ok(CaptchaResponse {
|
||||
base64,
|
||||
rsa: if rsa {
|
||||
Some(self.auth_rsa(context).await?)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
req: CaptchaQuery { w, h, dark, rsa },
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn auth_check_captcha(
|
||||
&self,
|
||||
context: &Session,
|
||||
captcha: String,
|
||||
) -> Result<(), AppError> {
|
||||
let text = context
|
||||
.get::<String>(Self::CAPTCHA_KEY)
|
||||
.map_err(|_| AppError::CaptchaError)?
|
||||
.ok_or(AppError::CaptchaError)?;
|
||||
if !constant_time_eq(&text.to_lowercase(), &captcha.to_lowercase()) {
|
||||
context.remove(Self::CAPTCHA_KEY);
|
||||
tracing::warn!("Captcha verification failed");
|
||||
return Err(AppError::CaptchaError);
|
||||
}
|
||||
context.remove(Self::CAPTCHA_KEY);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
use crate::service::util::constant_time_eq;
|
||||
@@ -0,0 +1,202 @@
|
||||
use argon2::{Argon2, PasswordHash, password_hash::PasswordVerifier};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use sqlx::Row;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::users::UserMail;
|
||||
use crate::pb::email::{EmailAddress, SendEmailRequest};
|
||||
use crate::service::AuthService;
|
||||
use crate::session::Session;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct EmailChangeRequest {
|
||||
pub new_email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct EmailVerifyRequest {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
|
||||
pub struct EmailResponse {
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
struct PendingEmailChange {
|
||||
user_uid: uuid::Uuid,
|
||||
new_email: String,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
const EMAIL_CHANGE_PREFIX: &'static str = "auth:email_change:";
|
||||
const EMAIL_CHANGE_TTL_SECS: u64 = 60 * 60;
|
||||
|
||||
pub async fn auth_get_email(&self, ctx: &Session) -> Result<EmailResponse, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let email = sqlx::query_as::<_, UserMail>(
|
||||
"SELECT id, user_id, email, is_primary, is_verified, \
|
||||
verification_token_hash, verified_at, created_at, updated_at \
|
||||
FROM user_mail WHERE user_id = $1 AND is_verified = true",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
Ok(EmailResponse {
|
||||
email: email.map(|e| e.email),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn auth_email_change_request(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
params: EmailChangeRequest,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let new_email = params.new_email.trim().to_lowercase();
|
||||
if new_email.is_empty() {
|
||||
return Err(AppError::BadRequest("email is required".into()));
|
||||
}
|
||||
let password = self.auth_rsa_decode(ctx, params.password).await?;
|
||||
|
||||
let row = sqlx::query("SELECT password_hash FROM user_password WHERE user_id = $1")
|
||||
.bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.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::UserNotFound)?;
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &password_hash)
|
||||
.map_err(|_| AppError::InvalidPassword)?;
|
||||
|
||||
let existing = sqlx::query_as::<_, UserMail>(
|
||||
"SELECT id, user_id, email, is_primary, is_verified, \
|
||||
verification_token_hash, verified_at, created_at, updated_at \
|
||||
FROM user_mail WHERE lower(email) = lower($1) AND is_verified = true",
|
||||
)
|
||||
.bind(&new_email)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if existing.is_some() {
|
||||
return Err(AppError::EmailExists);
|
||||
}
|
||||
|
||||
let token = super::generate_token("emc");
|
||||
let cache_key = format!("{}{}", Self::EMAIL_CHANGE_PREFIX, token);
|
||||
self.ctx
|
||||
.cache
|
||||
.set(
|
||||
&cache_key,
|
||||
&PendingEmailChange {
|
||||
user_uid,
|
||||
new_email: new_email.clone(),
|
||||
},
|
||||
Some(Duration::from_secs(Self::EMAIL_CHANGE_TTL_SECS)),
|
||||
)
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
let domain = self.ctx.config.main_domain()?;
|
||||
let verify_link = format!("{}/auth/verify-email?token={}", domain, 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: new_email.clone(),
|
||||
name: String::new(),
|
||||
}],
|
||||
subject: "Confirm Email Change".into(),
|
||||
text_body: format!(
|
||||
"You requested to change your email address.\n\n\
|
||||
Confirm the change here:\n\n{}\n\n\
|
||||
If you did not request this change, ignore this email.",
|
||||
verify_link
|
||||
),
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(error = %e, new_email = %new_email, "Failed to send email change verification");
|
||||
AppError::InternalServerError(e.to_string())
|
||||
})?;
|
||||
|
||||
tracing::info!(new_email = %new_email, user_uid = %user_uid, "Email change verification sent");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn auth_email_verify(&self, params: EmailVerifyRequest) -> Result<(), AppError> {
|
||||
if params.token.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"missing email verification token".into(),
|
||||
));
|
||||
}
|
||||
let cache_key = format!("{}{}", Self::EMAIL_CHANGE_PREFIX, params.token);
|
||||
let pending =
|
||||
self.ctx
|
||||
.cache
|
||||
.get::<PendingEmailChange>(&cache_key)
|
||||
.ok_or(AppError::NotFound(
|
||||
"invalid or expired email verification token".into(),
|
||||
))?;
|
||||
|
||||
let existing = sqlx::query_as::<_, UserMail>(
|
||||
"SELECT id, user_id, email, is_primary, is_verified, \
|
||||
verification_token_hash, verified_at, created_at, updated_at \
|
||||
FROM user_mail WHERE lower(email) = lower($1) AND is_verified = true",
|
||||
)
|
||||
.bind(&pending.new_email)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if existing.is_some() {
|
||||
return Err(AppError::EmailExists);
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
sqlx::query("UPDATE user_mail SET is_verified = false, updated_at = $1 WHERE user_id = $2")
|
||||
.bind(now)
|
||||
.bind(pending.user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO user_mail (id, user_id, email, is_primary, is_verified, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, true, true, $4, $4)",
|
||||
)
|
||||
.bind(uuid::Uuid::now_v7())
|
||||
.bind(pending.user_uid)
|
||||
.bind(&pending.new_email)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
let _ = self.ctx.cache.delete(&cache_key);
|
||||
tracing::info!(new_email = %pending.new_email, user_uid = %pending.user_uid, "Email changed");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
use argon2::{
|
||||
Argon2, PasswordHash,
|
||||
password_hash::{PasswordHasher, PasswordVerifier},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::users::User;
|
||||
use crate::service::AuthService;
|
||||
use crate::session::Session;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct LoginParams {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub captcha: String,
|
||||
pub totp_code: Option<String>,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
pub const TOTP_KEY: &'static str = "totp_key";
|
||||
const TOTP_ATTEMPTS_PREFIX: &'static str = "auth:totp_attempts:";
|
||||
const TOTP_MAX_ATTEMPTS: u64 = 5;
|
||||
const TOTP_PENDING_TTL_SECS: u64 = 600;
|
||||
|
||||
#[tracing::instrument(skip(self, params, context), fields(username = %params.username))]
|
||||
pub async fn auth_login(&self, params: LoginParams, context: Session) -> Result<(), AppError> {
|
||||
let login = params.username.trim().to_string();
|
||||
let totp_pending = context
|
||||
.get::<String>(Self::TOTP_KEY)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
if !totp_pending {
|
||||
self.auth_check_captcha(&context, params.captcha).await?;
|
||||
}
|
||||
let password = self.auth_rsa_decode(&context, params.password).await?;
|
||||
|
||||
let user = match self.auth_find_user_by_username(&login).await {
|
||||
Ok(user) => user,
|
||||
Err(_) => match self.auth_find_user_by_email(&login).await {
|
||||
Ok(user) => user,
|
||||
Err(_) => {
|
||||
let _ = Argon2::default().hash_password(
|
||||
password.as_bytes(),
|
||||
&argon2::password_hash::SaltString::generate(&mut rand::thread_rng()),
|
||||
);
|
||||
tracing::warn!(username = %login, "Login: user not found");
|
||||
return Err(AppError::UserNotFound);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT user_id, password_hash, password_algo, password_salt, \
|
||||
must_change_password, password_updated_at, created_at, updated_at \
|
||||
FROM user_password WHERE user_id = $1",
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let row = row.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::UserNotFound)?;
|
||||
if Argon2::default()
|
||||
.verify_password(password.as_bytes(), &password_hash)
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(username = %login, "Login: invalid password");
|
||||
return Err(AppError::UserNotFound);
|
||||
}
|
||||
|
||||
let two_factor_enabled = self.auth_2fa_status_by_uid(user.id).await?.is_enabled;
|
||||
if two_factor_enabled {
|
||||
if let Some(totp_session_key) = context.get::<String>(Self::TOTP_KEY).ok().flatten() {
|
||||
let Some(ref totp_code) = params.totp_code else {
|
||||
return Err(AppError::InvalidTwoFactorCode);
|
||||
};
|
||||
let attempts_key = format!("{}{}", Self::TOTP_ATTEMPTS_PREFIX, totp_session_key);
|
||||
let attempts = self.ctx.cache.get::<u64>(&attempts_key).unwrap_or(0);
|
||||
if attempts >= Self::TOTP_MAX_ATTEMPTS {
|
||||
context.remove(Self::TOTP_KEY);
|
||||
let _ = self.ctx.cache.delete(&totp_session_key);
|
||||
let _ = self.ctx.cache.delete(&attempts_key);
|
||||
return Err(AppError::InvalidTwoFactorCode);
|
||||
}
|
||||
|
||||
if !self
|
||||
.auth_2fa_verify_login(&context, user.id, totp_code)
|
||||
.await?
|
||||
{
|
||||
let next_attempts = attempts + 1;
|
||||
if next_attempts >= Self::TOTP_MAX_ATTEMPTS {
|
||||
context.remove(Self::TOTP_KEY);
|
||||
let _ = self.ctx.cache.delete(&totp_session_key);
|
||||
let _ = self.ctx.cache.delete(&attempts_key);
|
||||
} else {
|
||||
self.ctx
|
||||
.cache
|
||||
.set(
|
||||
&attempts_key,
|
||||
&next_attempts,
|
||||
Some(std::time::Duration::from_secs(Self::TOTP_PENDING_TTL_SECS)),
|
||||
)
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
}
|
||||
return Err(AppError::InvalidTwoFactorCode);
|
||||
}
|
||||
let _ = self.ctx.cache.delete(&attempts_key);
|
||||
} else {
|
||||
let totp_session_key = uuid::Uuid::new_v4().to_string();
|
||||
context
|
||||
.insert(Self::TOTP_KEY, totp_session_key.clone())
|
||||
.map_err(|_| AppError::InternalServerError("session insert failed".into()))?;
|
||||
self.ctx
|
||||
.cache
|
||||
.set(
|
||||
&totp_session_key,
|
||||
&user.id,
|
||||
Some(std::time::Duration::from_secs(Self::TOTP_PENDING_TTL_SECS)),
|
||||
)
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
tracing::info!(username = %login, "Login 2FA triggered");
|
||||
return Err(AppError::TwoFactorRequired);
|
||||
}
|
||||
} else if let Some(totp_session_key) = context.get::<String>(Self::TOTP_KEY).ok().flatten()
|
||||
{
|
||||
context.remove(Self::TOTP_KEY);
|
||||
let attempts_key = format!("{}{}", Self::TOTP_ATTEMPTS_PREFIX, totp_session_key);
|
||||
let _ = self.ctx.cache.delete(&totp_session_key);
|
||||
let _ = self.ctx.cache.delete(&attempts_key);
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE \"user\" SET last_login_at = $1, updated_at = $1 WHERE id = $2")
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(user.id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
context.renew();
|
||||
context.set_user(user.id);
|
||||
context.remove(Self::RSA_PRIVATE_KEY);
|
||||
context.remove(Self::RSA_PUBLIC_KEY);
|
||||
tracing::info!(user_uid = %user.id, username = %user.username, "User logged in");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn auth_find_user_by_username(
|
||||
&self,
|
||||
username: &str,
|
||||
) -> Result<User, AppError> {
|
||||
sqlx::query_as::<_, User>(
|
||||
"SELECT id, username, display_name, avatar_url, bio, status, role, visibility, \
|
||||
is_active, is_bot, last_login_at, created_at, updated_at, deleted_at \
|
||||
FROM \"user\" WHERE lower(username) = lower($1) AND is_active = true AND status = 'active' AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::UserNotFound)
|
||||
}
|
||||
|
||||
pub(crate) async fn auth_find_user_by_email(&self, email: &str) -> Result<User, AppError> {
|
||||
sqlx::query_as::<_, User>(
|
||||
"SELECT u.id, u.username, u.display_name, u.avatar_url, u.bio, u.status, u.role, \
|
||||
u.visibility, u.is_active, u.is_bot, u.last_login_at, u.created_at, u.updated_at, u.deleted_at \
|
||||
FROM \"user\" u \
|
||||
INNER JOIN user_mail e ON e.user_id = u.id \
|
||||
WHERE lower(e.email) = lower($1) AND e.is_verified = true AND u.is_active = true AND u.status = 'active' AND u.deleted_at IS NULL",
|
||||
)
|
||||
.bind(email)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::UserNotFound)
|
||||
}
|
||||
|
||||
pub(crate) async fn auth_find_user_by_uid(&self, uid: uuid::Uuid) -> Result<User, AppError> {
|
||||
sqlx::query_as::<_, User>(
|
||||
"SELECT id, username, display_name, avatar_url, bio, status, role, visibility, \
|
||||
is_active, is_bot, last_login_at, created_at, updated_at, deleted_at \
|
||||
FROM \"user\" WHERE id = $1 AND is_active = true AND status = 'active' AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(uid)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::UserNotFound)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use crate::error::AppError;
|
||||
use crate::service::AuthService;
|
||||
use crate::session::Session;
|
||||
|
||||
impl AuthService {
|
||||
pub async fn auth_logout(&self, context: &Session) -> Result<(), AppError> {
|
||||
if let Some(user_uid) = context.user() {
|
||||
tracing::info!(user_uid = %user_uid, "User logged out");
|
||||
}
|
||||
context.clear_user();
|
||||
context.clear();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::service::AuthService;
|
||||
use crate::session::Session;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct ContextMe {
|
||||
pub id: uuid::Uuid,
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub has_unread_notifications: u64,
|
||||
pub language: String,
|
||||
pub timezone: String,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
pub async fn auth_me(&self, ctx: Session) -> Result<ContextMe, AppError> {
|
||||
let user_id = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let user = self.auth_find_user_by_uid(user_id).await?;
|
||||
let profile =
|
||||
crate::models::users::UserProfile::find_by_user_id(self.ctx.db.reader(), user_id)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
Ok(ContextMe {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
display_name: user.display_name.filter(|n| !n.is_empty()),
|
||||
avatar_url: user.avatar_url.filter(|u| !u.is_empty()),
|
||||
has_unread_notifications: 0,
|
||||
language: profile
|
||||
.as_ref()
|
||||
.and_then(|p| p.language.clone())
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| "en".to_string()),
|
||||
timezone: profile
|
||||
.as_ref()
|
||||
.and_then(|p| p.timezone.clone())
|
||||
.filter(|v| !v.is_empty())
|
||||
.unwrap_or_else(|| "UTC".to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
pub mod captcha;
|
||||
pub mod email;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod me;
|
||||
pub mod register;
|
||||
pub mod reset_pass;
|
||||
pub mod rsa;
|
||||
pub mod totp;
|
||||
|
||||
pub(crate) fn generate_token(prefix: &str) -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
use rand::Rng;
|
||||
let chars: String = (0..64)
|
||||
.map(|_| {
|
||||
let idx = rng.gen_range(0..62);
|
||||
const CHARSET: &[u8] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
CHARSET[idx] as char
|
||||
})
|
||||
.collect();
|
||||
format!("{}_{}", prefix, chars)
|
||||
}
|
||||
|
||||
// constant_time_eq is provided by crate::service::util
|
||||
@@ -0,0 +1,239 @@
|
||||
use argon2::password_hash::SaltString;
|
||||
use argon2::{Argon2, password_hash::PasswordHasher};
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::users::User;
|
||||
use crate::pb::email::{EmailAddress, SendEmailRequest};
|
||||
use crate::service::AuthService;
|
||||
use crate::session::Session;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct RegisterParams {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub captcha: String,
|
||||
pub email_code: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct RegisterEmailCodeParams {
|
||||
pub email: String,
|
||||
pub captcha: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct RegisterEmailCodeResponse {
|
||||
pub expires_in_secs: u64,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
const REGISTER_EMAIL_CODE_PREFIX: &'static str = "auth:register_email:";
|
||||
const REGISTER_EMAIL_CODE_TTL_SECS: u64 = 600;
|
||||
const REGISTER_EMAIL_CODE_COOLDOWN_SECS: u64 = 60;
|
||||
|
||||
pub async fn auth_register_email_code(
|
||||
&self,
|
||||
params: RegisterEmailCodeParams,
|
||||
context: &Session,
|
||||
) -> Result<RegisterEmailCodeResponse, AppError> {
|
||||
self.auth_check_captcha(context, params.captcha).await?;
|
||||
let email = params.email.trim().to_lowercase();
|
||||
if email.is_empty() {
|
||||
return Err(AppError::BadRequest("email is required".into()));
|
||||
}
|
||||
|
||||
if self.auth_verified_email_exists(&email).await? {
|
||||
return Err(AppError::EmailExists);
|
||||
}
|
||||
|
||||
let cooldown_key = format!("{}cooldown:{}", Self::REGISTER_EMAIL_CODE_PREFIX, email);
|
||||
if self.ctx.cache.exists(&cooldown_key) {
|
||||
return Err(AppError::BadRequest(
|
||||
"verification code was sent recently; please try again later".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let code = Self::generate_register_email_code();
|
||||
let cache_key = Self::register_email_code_key(&email);
|
||||
self.ctx
|
||||
.cache
|
||||
.set(
|
||||
&cache_key,
|
||||
&code,
|
||||
Some(Duration::from_secs(Self::REGISTER_EMAIL_CODE_TTL_SECS)),
|
||||
)
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
self.ctx
|
||||
.cache
|
||||
.set(
|
||||
&cooldown_key,
|
||||
&true,
|
||||
Some(Duration::from_secs(Self::REGISTER_EMAIL_CODE_COOLDOWN_SECS)),
|
||||
)
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
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.clone(),
|
||||
name: String::new(),
|
||||
}],
|
||||
subject: "Register Email Verification".into(),
|
||||
text_body: format!(
|
||||
"Your registration verification code is: {}\n\nThis code expires in 10 minutes.",
|
||||
code
|
||||
),
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
Ok(RegisterEmailCodeResponse {
|
||||
expires_in_secs: Self::REGISTER_EMAIL_CODE_TTL_SECS,
|
||||
})
|
||||
}
|
||||
|
||||
fn auth_check_register_email_code(&self, email: &str, code: &str) -> Result<(), AppError> {
|
||||
let cache_key = Self::register_email_code_key(email);
|
||||
let stored = self
|
||||
.ctx
|
||||
.cache
|
||||
.get::<String>(&cache_key)
|
||||
.ok_or(AppError::InvalidEmailCode)?;
|
||||
if !crate::service::util::constant_time_eq(stored.trim(), code.trim()) {
|
||||
return Err(AppError::InvalidEmailCode);
|
||||
}
|
||||
let _ = self.ctx.cache.delete(&cache_key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn register_email_code_key(email: &str) -> String {
|
||||
format!(
|
||||
"{}{}",
|
||||
Self::REGISTER_EMAIL_CODE_PREFIX,
|
||||
email.trim().to_lowercase()
|
||||
)
|
||||
}
|
||||
|
||||
fn generate_register_email_code() -> String {
|
||||
let mut rng = rand::thread_rng();
|
||||
format!("{:06}", rng.gen_range(0..1_000_000))
|
||||
}
|
||||
|
||||
async fn auth_username_exists(&self, username: &str) -> Result<bool, AppError> {
|
||||
sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM \"user\" WHERE lower(username) = lower($1) AND deleted_at IS NULL)",
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
async fn auth_verified_email_exists(&self, email: &str) -> Result<bool, AppError> {
|
||||
sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM user_mail WHERE lower(email) = lower($1) AND is_verified = true)",
|
||||
)
|
||||
.bind(email)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self, params, context), fields(username = %params.username))]
|
||||
pub async fn auth_register(
|
||||
&self,
|
||||
params: RegisterParams,
|
||||
context: &Session,
|
||||
) -> Result<User, AppError> {
|
||||
self.auth_check_captcha(context, params.captcha).await?;
|
||||
let username = params.username.trim().to_string();
|
||||
let email = params.email.trim().to_lowercase();
|
||||
if username.is_empty() || email.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"username and email are required".into(),
|
||||
));
|
||||
}
|
||||
let password = self.auth_rsa_decode(context, params.password).await?;
|
||||
crate::service::util::validate_password_strength(&password)?;
|
||||
|
||||
let username_exists = self.auth_username_exists(&username).await?;
|
||||
let email_exists = self.auth_verified_email_exists(&email).await?;
|
||||
if username_exists || email_exists {
|
||||
return Err(AppError::AccountAlreadyExists);
|
||||
}
|
||||
|
||||
self.auth_check_register_email_code(&email, ¶ms.email_code)?;
|
||||
|
||||
let user_id = uuid::Uuid::now_v7();
|
||||
let now = chrono::Utc::now();
|
||||
let salt = SaltString::generate(&mut rand::thread_rng());
|
||||
let password_hash = Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| AppError::PasswordHashError(e.to_string()))?
|
||||
.to_string();
|
||||
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
let user = sqlx::query_as::<_, User>(
|
||||
"INSERT INTO \"user\" \
|
||||
(id, username, display_name, status, role, visibility, is_active, is_bot, \
|
||||
last_login_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $2, 'active', 'user', 'public', true, false, NULL, $3, $3) \
|
||||
RETURNING id, username, display_name, avatar_url, bio, status, role, visibility, \
|
||||
is_active, is_bot, last_login_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&username)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO user_mail (id, user_id, email, is_primary, is_verified, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, true, true, $4, $4)",
|
||||
)
|
||||
.bind(uuid::Uuid::now_v7())
|
||||
.bind(user_id)
|
||||
.bind(&email)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO user_password (user_id, password_hash, password_algo, password_salt, \
|
||||
must_change_password, password_updated_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, 'argon2id', '', false, $3, $3, $3)",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&password_hash)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
context.set_user(user_id);
|
||||
context.remove(Self::RSA_PRIVATE_KEY);
|
||||
context.remove(Self::RSA_PUBLIC_KEY);
|
||||
tracing::info!(user_uid = %user_id, username = %user.username, "User registered");
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
use argon2::{Argon2, PasswordHasher, password_hash::SaltString};
|
||||
use chrono::{Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration as StdDuration;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::pb::email::{EmailAddress, SendEmailRequest};
|
||||
use crate::service::AuthService;
|
||||
use crate::session::Session;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct ResetPasswordRequest {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct ResetPasswordVerifyParams {
|
||||
pub token: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
struct PendingResetPassword {
|
||||
user_uid: uuid::Uuid,
|
||||
created_at: chrono::DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
const RESET_PASS_PREFIX: &'static str = "auth:reset_pass:";
|
||||
const RESET_PASS_EXPIRY_HOURS: i64 = 1;
|
||||
const RESET_PASS_EXPIRY_SECS: u64 = 60 * 60;
|
||||
const RESET_PASS_COOLDOWN_SECS: u64 = 60; // 60 seconds between requests
|
||||
const RESET_PASS_DAILY_LIMIT: u64 = 5; // Max 5 requests per day
|
||||
const RESET_PASS_DAILY_SECS: u64 = 24 * 60 * 60; // 24 hours
|
||||
|
||||
pub async fn auth_reset_password_request(
|
||||
&self,
|
||||
params: ResetPasswordRequest,
|
||||
) -> Result<(), AppError> {
|
||||
let email = params.email.trim().to_lowercase();
|
||||
|
||||
// Rate limiting: check cooldown
|
||||
let cooldown_key = format!("{}cooldown:{}", Self::RESET_PASS_PREFIX, email);
|
||||
if self.ctx.cache.exists(&cooldown_key) {
|
||||
tracing::warn!(email = %email, "Password reset request rate limited (cooldown)");
|
||||
return Ok(()); // Don't reveal if email exists
|
||||
}
|
||||
|
||||
// Rate limiting: check daily limit
|
||||
let daily_key = format!("{}daily:{}", Self::RESET_PASS_PREFIX, email);
|
||||
let daily_count: u64 = self.ctx.cache.get(&daily_key).unwrap_or(0);
|
||||
if daily_count >= Self::RESET_PASS_DAILY_LIMIT {
|
||||
tracing::warn!(email = %email, count = daily_count, "Password reset request rate limited (daily limit)");
|
||||
return Ok(()); // Don't reveal if email exists
|
||||
}
|
||||
|
||||
let user = self.auth_find_user_by_email(&email).await.ok();
|
||||
|
||||
if let Some(user) = user {
|
||||
let token = super::generate_token("rst");
|
||||
let cache_key = format!("{}{}", Self::RESET_PASS_PREFIX, token);
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
if let Err(e) = self.ctx.cache.set(
|
||||
&cache_key,
|
||||
&PendingResetPassword {
|
||||
user_uid: user.id,
|
||||
created_at: now,
|
||||
},
|
||||
Some(StdDuration::from_secs(Self::RESET_PASS_EXPIRY_SECS)),
|
||||
) {
|
||||
tracing::error!(error = %e, user_uid = %user.id, "Failed to cache reset token");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Set cooldown
|
||||
if let Err(e) = self.ctx.cache.set(
|
||||
&cooldown_key,
|
||||
&true,
|
||||
Some(StdDuration::from_secs(Self::RESET_PASS_COOLDOWN_SECS)),
|
||||
) {
|
||||
tracing::warn!(error = %e, "Failed to set cooldown");
|
||||
}
|
||||
|
||||
// Increment daily counter
|
||||
let new_count = daily_count + 1;
|
||||
if let Err(e) = self.ctx.cache.set(
|
||||
&daily_key,
|
||||
&new_count,
|
||||
Some(StdDuration::from_secs(Self::RESET_PASS_DAILY_SECS)),
|
||||
) {
|
||||
tracing::warn!(error = %e, "Failed to increment daily counter");
|
||||
}
|
||||
|
||||
let domain = match self.ctx.config.main_domain() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Domain not configured for password reset");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let reset_link = format!("{}/auth/reset-password?token={}", domain, token);
|
||||
|
||||
let mut mail = match self.ctx.registry.get_email_client() {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
tracing::error!("mail service not available");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if let Err(e) = mail
|
||||
.send_email(tonic::Request::new(SendEmailRequest {
|
||||
to: vec![EmailAddress {
|
||||
email: email.clone(),
|
||||
name: String::new(),
|
||||
}],
|
||||
subject: "Reset Your Password".into(),
|
||||
text_body: format!(
|
||||
"You requested to reset your password.\n\n\
|
||||
Reset your password here:\n\n{}\n\n\
|
||||
If you did not request this, ignore this email.",
|
||||
reset_link
|
||||
),
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
{
|
||||
tracing::error!(error = %e, email = %email, "Failed to send password reset email");
|
||||
}
|
||||
|
||||
tracing::info!(email = %email, user_uid = %user.id, "Password reset email sent");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn auth_reset_password_verify(
|
||||
&self,
|
||||
context: &Session,
|
||||
params: ResetPasswordVerifyParams,
|
||||
) -> Result<(), AppError> {
|
||||
if params.token.is_empty() {
|
||||
return Err(AppError::InvalidResetToken);
|
||||
}
|
||||
|
||||
let cache_key = format!("{}{}", Self::RESET_PASS_PREFIX, params.token);
|
||||
let pending = self
|
||||
.ctx
|
||||
.cache
|
||||
.get::<PendingResetPassword>(&cache_key)
|
||||
.ok_or(AppError::InvalidResetToken)?;
|
||||
|
||||
if Utc::now() - pending.created_at > Duration::hours(Self::RESET_PASS_EXPIRY_HOURS) {
|
||||
let _ = self.ctx.cache.delete(&cache_key);
|
||||
return Err(AppError::ResetTokenExpired);
|
||||
}
|
||||
|
||||
let password = self.auth_rsa_decode(context, params.password).await?;
|
||||
crate::service::util::validate_password_strength(&password)?;
|
||||
let _ = self.ctx.cache.delete(&cache_key);
|
||||
|
||||
let salt = SaltString::generate(&mut rand::thread_rng());
|
||||
let password_hash = Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| AppError::PasswordHashError(e.to_string()))?
|
||||
.to_string();
|
||||
|
||||
let now = chrono::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(&password_hash)
|
||||
.bind(now)
|
||||
.bind(pending.user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::InvalidResetToken);
|
||||
}
|
||||
|
||||
tracing::info!(user_uid = %pending.user_uid, "Password reset successfully");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
use base64::Engine;
|
||||
use chacha20poly1305::{ChaCha20Poly1305, KeyInit, Nonce, aead::Aead};
|
||||
use hkdf::Hkdf;
|
||||
use rsa::{
|
||||
Oaep, RsaPrivateKey, RsaPublicKey,
|
||||
pkcs1::{DecodeRsaPrivateKey, EncodeRsaPrivateKey, EncodeRsaPublicKey},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Sha256;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::service::AuthService;
|
||||
use crate::session::Session;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct RsaResponse {
|
||||
pub public_key: String,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
pub const RSA_PRIVATE_KEY: &'static str = "rsa:private";
|
||||
pub const RSA_PUBLIC_KEY: &'static str = "rsa:public";
|
||||
const RSA_BIT_SIZE: usize = 2048;
|
||||
|
||||
fn derive_rsa_encryption_key(&self) -> Result<[u8; 32], AppError> {
|
||||
let secret = self
|
||||
.ctx
|
||||
.config
|
||||
.env
|
||||
.get("APP_SESSION_SECRET")
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| AppError::Config("APP_SESSION_SECRET is required".into()))?;
|
||||
let hk = Hkdf::<Sha256>::new(Some(b"rsa-session-encryption"), secret.as_bytes());
|
||||
let mut okm = [0u8; 32];
|
||||
hk.expand(b"rsa-private-key-aead", &mut okm)
|
||||
.map_err(|_| AppError::RsaGenerationError)?;
|
||||
Ok(okm)
|
||||
}
|
||||
|
||||
fn encrypt_rsa_key(&self, plaintext: &str) -> Result<String, AppError> {
|
||||
let key = self.derive_rsa_encryption_key()?;
|
||||
let cipher = ChaCha20Poly1305::new_from_slice(&key)
|
||||
.expect("32-byte key is valid for ChaCha20Poly1305");
|
||||
let nonce_bytes: [u8; 12] = rand::random();
|
||||
let nonce = Nonce::from(nonce_bytes);
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, plaintext.as_bytes())
|
||||
.map_err(|_| AppError::RsaGenerationError)?;
|
||||
let mut combined = nonce_bytes.to_vec();
|
||||
combined.extend_from_slice(&ciphertext);
|
||||
Ok(base64::engine::general_purpose::STANDARD.encode(&combined))
|
||||
}
|
||||
|
||||
fn decrypt_rsa_key(&self, encrypted: &str) -> Result<String, AppError> {
|
||||
let key = self.derive_rsa_encryption_key()?;
|
||||
let cipher = ChaCha20Poly1305::new_from_slice(&key)
|
||||
.expect("32-byte key is valid for ChaCha20Poly1305");
|
||||
let combined = base64::engine::general_purpose::STANDARD
|
||||
.decode(encrypted)
|
||||
.map_err(|_| AppError::RsaDecodeError)?;
|
||||
if combined.len() < 12 {
|
||||
return Err(AppError::RsaDecodeError);
|
||||
}
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
nonce_bytes.copy_from_slice(&combined[..12]);
|
||||
let nonce = Nonce::from(nonce_bytes);
|
||||
let plaintext = cipher
|
||||
.decrypt(&nonce, &combined[12..])
|
||||
.map_err(|_| AppError::RsaDecodeError)?;
|
||||
String::from_utf8(plaintext).map_err(|_| AppError::RsaDecodeError)
|
||||
}
|
||||
|
||||
pub async fn auth_rsa(&self, context: &Session) -> Result<RsaResponse, AppError> {
|
||||
if context
|
||||
.get::<String>(Self::RSA_PRIVATE_KEY)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
&& context
|
||||
.get::<String>(Self::RSA_PUBLIC_KEY)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
{
|
||||
let public_key = context
|
||||
.get::<String>(Self::RSA_PUBLIC_KEY)
|
||||
.ok()
|
||||
.flatten()
|
||||
.expect("checked above");
|
||||
return Ok(RsaResponse { public_key });
|
||||
}
|
||||
|
||||
let mut rng = rand::thread_rng();
|
||||
let priv_key = RsaPrivateKey::new(&mut rng, Self::RSA_BIT_SIZE).map_err(|_| {
|
||||
tracing::error!("RSA key generation failed");
|
||||
AppError::RsaGenerationError
|
||||
})?;
|
||||
let pub_key = RsaPublicKey::from(&priv_key);
|
||||
let priv_pem = priv_key
|
||||
.to_pkcs1_pem(Default::default())
|
||||
.map_err(|_| AppError::RsaGenerationError)?
|
||||
.to_string();
|
||||
let public_key = pub_key
|
||||
.to_pkcs1_pem(Default::default())
|
||||
.map_err(|_| AppError::RsaGenerationError)?
|
||||
.to_string();
|
||||
|
||||
context
|
||||
.insert(Self::RSA_PRIVATE_KEY, self.encrypt_rsa_key(&priv_pem)?)
|
||||
.map_err(|_| AppError::RsaGenerationError)?;
|
||||
context
|
||||
.insert(Self::RSA_PUBLIC_KEY, public_key.clone())
|
||||
.map_err(|_| AppError::RsaGenerationError)?;
|
||||
|
||||
Ok(RsaResponse { public_key })
|
||||
}
|
||||
|
||||
pub async fn auth_rsa_decode(
|
||||
&self,
|
||||
context: &Session,
|
||||
data: String,
|
||||
) -> Result<String, AppError> {
|
||||
let encrypted_priv = context
|
||||
.get::<String>(Self::RSA_PRIVATE_KEY)
|
||||
.map_err(|_| AppError::RsaDecodeError)?
|
||||
.ok_or(AppError::RsaDecodeError)?;
|
||||
let priv_pem = self.decrypt_rsa_key(&encrypted_priv)?;
|
||||
|
||||
let priv_key = RsaPrivateKey::from_pkcs1_pem(&priv_pem).map_err(|_| {
|
||||
tracing::warn!("RSA decode failed: invalid private key");
|
||||
AppError::RsaDecodeError
|
||||
})?;
|
||||
let cipher = base64::engine::general_purpose::STANDARD
|
||||
.decode(&data)
|
||||
.map_err(|_| AppError::RsaDecodeError)?;
|
||||
let decrypted = priv_key
|
||||
.decrypt(Oaep::new::<Sha256>(), &cipher)
|
||||
.map_err(|_| {
|
||||
tracing::warn!("RSA decrypt failed");
|
||||
AppError::RsaDecodeError
|
||||
})?;
|
||||
Ok(String::from_utf8_lossy(&decrypted).to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
use argon2::{Argon2, PasswordHash, password_hash::PasswordVerifier};
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha1::Sha1;
|
||||
use sha2::Sha256;
|
||||
use sqlx::Row;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::users::User2Fa;
|
||||
use crate::service::AuthService;
|
||||
use crate::session::Session;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct Enable2FAResponse {
|
||||
pub secret: String,
|
||||
pub qr_code: String,
|
||||
pub backup_codes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct Verify2FAParams {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct Disable2FAParams {
|
||||
pub code: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct Get2FAStatusResponse {
|
||||
pub is_enabled: bool,
|
||||
pub method: Option<String>,
|
||||
pub has_backup_codes: bool,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
pub async fn auth_2fa_enable(&self, context: &Session) -> Result<Enable2FAResponse, AppError> {
|
||||
let user_uid = context.user().ok_or(AppError::Unauthorized)?;
|
||||
let user = self.auth_find_user_by_uid(user_uid).await?;
|
||||
|
||||
let existing = self.find_2fa(user_uid).await?;
|
||||
if existing.as_ref().is_some_and(|f| f.enabled) {
|
||||
return Err(AppError::TwoFactorAlreadyEnabled);
|
||||
}
|
||||
|
||||
let secret = Self::generate_totp_secret();
|
||||
let backup_codes = Self::generate_backup_codes(10);
|
||||
let qr_code = format!(
|
||||
"otpauth://totp/AppKS:{}?secret={}&issuer=AppKS",
|
||||
user.username, secret
|
||||
);
|
||||
let now = chrono::Utc::now();
|
||||
let hashed_backup_codes = self.hash_backup_codes(&backup_codes)?.join(".");
|
||||
|
||||
if existing.is_some() {
|
||||
sqlx::query(
|
||||
"UPDATE user_2fa SET secret = $1, backup_codes = $2, enabled = false, updated_at = $3 \
|
||||
WHERE user_id = $4",
|
||||
)
|
||||
.bind(&secret)
|
||||
.bind(&hashed_backup_codes)
|
||||
.bind(now)
|
||||
.bind(user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
} else {
|
||||
sqlx::query(
|
||||
"INSERT INTO user_2fa (user_id, secret, backup_codes, enabled, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, false, $4, $4)",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.bind(&secret)
|
||||
.bind(&hashed_backup_codes)
|
||||
.bind(now)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
Ok(Enable2FAResponse {
|
||||
secret,
|
||||
qr_code,
|
||||
backup_codes,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn auth_2fa_verify_and_enable(
|
||||
&self,
|
||||
context: &Session,
|
||||
params: Verify2FAParams,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = context.user().ok_or(AppError::Unauthorized)?;
|
||||
let two_fa = self
|
||||
.find_2fa(user_uid)
|
||||
.await?
|
||||
.ok_or(AppError::TwoFactorNotSetup)?;
|
||||
if two_fa.enabled {
|
||||
return Err(AppError::TwoFactorAlreadyEnabled);
|
||||
}
|
||||
let secret = two_fa.secret.as_ref().ok_or(AppError::TwoFactorNotSetup)?;
|
||||
if !self.verify_totp_code(secret, ¶ms.code)? {
|
||||
return Err(AppError::InvalidTwoFactorCode);
|
||||
}
|
||||
|
||||
sqlx::query("UPDATE user_2fa SET enabled = true, updated_at = $1 WHERE user_id = $2")
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn auth_2fa_disable(
|
||||
&self,
|
||||
context: &Session,
|
||||
params: Disable2FAParams,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = context.user().ok_or(AppError::Unauthorized)?;
|
||||
let password = self.auth_rsa_decode(context, params.password).await?;
|
||||
self.verify_user_password(user_uid, &password).await?;
|
||||
|
||||
let two_fa = self
|
||||
.find_2fa(user_uid)
|
||||
.await?
|
||||
.ok_or(AppError::TwoFactorNotSetup)?;
|
||||
if !two_fa.enabled {
|
||||
return Err(AppError::TwoFactorNotEnabled);
|
||||
}
|
||||
if !self
|
||||
.verify_2fa_or_backup_code(&two_fa, ¶ms.code)
|
||||
.await?
|
||||
{
|
||||
return Err(AppError::InvalidTwoFactorCode);
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM user_2fa WHERE user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn auth_2fa_verify(&self, user_uid: Uuid, code: &str) -> Result<bool, AppError> {
|
||||
let Some(two_fa) = self.find_2fa(user_uid).await? else {
|
||||
return Ok(true);
|
||||
};
|
||||
if !two_fa.enabled {
|
||||
return Ok(true);
|
||||
}
|
||||
self.verify_2fa_or_backup_code(&two_fa, code).await
|
||||
}
|
||||
|
||||
pub async fn auth_2fa_status_by_uid(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
) -> Result<Get2FAStatusResponse, AppError> {
|
||||
let Some(two_fa) = self.find_2fa(user_uid).await? else {
|
||||
return Ok(Get2FAStatusResponse {
|
||||
is_enabled: false,
|
||||
method: None,
|
||||
has_backup_codes: false,
|
||||
});
|
||||
};
|
||||
Ok(Get2FAStatusResponse {
|
||||
is_enabled: two_fa.enabled,
|
||||
method: Some("totp".into()),
|
||||
has_backup_codes: !two_fa.backup_codes.is_empty(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn auth_2fa_status(
|
||||
&self,
|
||||
context: &Session,
|
||||
) -> Result<Get2FAStatusResponse, AppError> {
|
||||
let user_uid = context.user().ok_or(AppError::Unauthorized)?;
|
||||
self.auth_2fa_status_by_uid(user_uid).await
|
||||
}
|
||||
|
||||
pub async fn auth_2fa_verify_login(
|
||||
&self,
|
||||
context: &Session,
|
||||
expected_user_uid: Uuid,
|
||||
code: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
let Some(totp_key) = context.get::<String>(Self::TOTP_KEY).ok().flatten() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(user_uid) = self.ctx.cache.get::<Uuid>(&totp_key) else {
|
||||
context.remove(Self::TOTP_KEY);
|
||||
return Ok(false);
|
||||
};
|
||||
if user_uid != expected_user_uid {
|
||||
context.remove(Self::TOTP_KEY);
|
||||
let _ = self.ctx.cache.delete(&totp_key);
|
||||
tracing::warn!(expected_user_uid = %expected_user_uid, pending_user_uid = %user_uid, "2FA pending user mismatch");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let verified = self.auth_2fa_verify(user_uid, code).await?;
|
||||
if verified {
|
||||
context.remove(Self::TOTP_KEY);
|
||||
let _ = self.ctx.cache.delete(&totp_key);
|
||||
}
|
||||
Ok(verified)
|
||||
}
|
||||
|
||||
pub async fn auth_2fa_regenerate_backup_codes(
|
||||
&self,
|
||||
context: &Session,
|
||||
password: String,
|
||||
) -> Result<Vec<String>, AppError> {
|
||||
let user_uid = context.user().ok_or(AppError::Unauthorized)?;
|
||||
let password = self.auth_rsa_decode(context, password).await?;
|
||||
self.verify_user_password(user_uid, &password).await?;
|
||||
let two_fa = self
|
||||
.find_2fa(user_uid)
|
||||
.await?
|
||||
.ok_or(AppError::TwoFactorNotSetup)?;
|
||||
if !two_fa.enabled {
|
||||
return Err(AppError::TwoFactorNotEnabled);
|
||||
}
|
||||
|
||||
let backup_codes = Self::generate_backup_codes(10);
|
||||
sqlx::query("UPDATE user_2fa SET backup_codes = $1, updated_at = $2 WHERE user_id = $3")
|
||||
.bind(self.hash_backup_codes(&backup_codes)?.join("."))
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(backup_codes)
|
||||
}
|
||||
|
||||
fn generate_totp_secret() -> String {
|
||||
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..32)
|
||||
.map(|_| {
|
||||
let idx = rng.gen_range(0..CHARSET.len());
|
||||
CHARSET[idx] as char
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn generate_backup_codes(count: usize) -> Vec<String> {
|
||||
let mut rng = rand::thread_rng();
|
||||
(0..count)
|
||||
.map(|_| {
|
||||
format!(
|
||||
"{:04}-{:04}-{:04}",
|
||||
rng.gen_range(0..10000),
|
||||
rng.gen_range(0..10000),
|
||||
rng.gen_range(0..10000)
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn backup_code_pepper(&self) -> Result<String, AppError> {
|
||||
self.ctx
|
||||
.config
|
||||
.env
|
||||
.get("APP_SESSION_SECRET")
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| AppError::Config("APP_SESSION_SECRET is required".into()))
|
||||
}
|
||||
|
||||
fn hash_backup_code(&self, code: &str) -> Result<String, AppError> {
|
||||
let pepper = self.backup_code_pepper()?;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(pepper.as_bytes())
|
||||
.map_err(|_| AppError::InternalServerError("invalid backup code pepper".into()))?;
|
||||
mac.update(code.trim().as_bytes());
|
||||
Ok(mac
|
||||
.finalize()
|
||||
.into_bytes()
|
||||
.iter()
|
||||
.map(|b| format!("{:02x}", b))
|
||||
.collect::<String>())
|
||||
}
|
||||
|
||||
fn hash_backup_codes(&self, codes: &[String]) -> Result<Vec<String>, AppError> {
|
||||
codes.iter().map(|c| self.hash_backup_code(c)).collect()
|
||||
}
|
||||
|
||||
fn verify_totp_code(&self, secret: &str, code: &str) -> Result<bool, AppError> {
|
||||
let now = chrono::Utc::now().timestamp() as u64;
|
||||
let time_step = 30;
|
||||
let counter = now / time_step;
|
||||
|
||||
for offset in [-1i64, 0, 1] {
|
||||
let test_counter = (counter as i64 + offset) as u64;
|
||||
let expected_code = self.generate_totp_code(secret, test_counter)?;
|
||||
if constant_time_eq(&expected_code, code) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn generate_totp_code(&self, secret: &str, counter: u64) -> Result<String, AppError> {
|
||||
let secret_bytes = Self::decode_base32(secret)?;
|
||||
let counter_bytes = counter.to_be_bytes();
|
||||
|
||||
let mut mac = Hmac::<Sha1>::new_from_slice(&secret_bytes)
|
||||
.map_err(|_| AppError::InvalidTwoFactorCode)?;
|
||||
mac.update(&counter_bytes);
|
||||
let result = mac.finalize().into_bytes();
|
||||
|
||||
let offset = (result[19] & 0x0f) as usize;
|
||||
let code = u32::from_be_bytes([
|
||||
result[offset] & 0x7f,
|
||||
result[offset + 1],
|
||||
result[offset + 2],
|
||||
result[offset + 3],
|
||||
]);
|
||||
|
||||
Ok(format!("{:06}", code % 1_000_000))
|
||||
}
|
||||
|
||||
fn decode_base32(input: &str) -> Result<Vec<u8>, AppError> {
|
||||
const CHARSET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
let input = input.to_uppercase().replace("=", "");
|
||||
let mut bits = 0u64;
|
||||
let mut bit_count = 0;
|
||||
let mut output = Vec::new();
|
||||
|
||||
for c in input.chars() {
|
||||
let val = CHARSET.find(c).ok_or(AppError::InvalidTwoFactorCode)? as u64;
|
||||
bits = (bits << 5) | val;
|
||||
bit_count += 5;
|
||||
|
||||
if bit_count >= 8 {
|
||||
bit_count -= 8;
|
||||
output.push((bits >> bit_count) as u8);
|
||||
bits &= (1 << bit_count) - 1;
|
||||
}
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
async fn verify_user_password(&self, user_uid: Uuid, password: &str) -> Result<(), AppError> {
|
||||
let row = sqlx::query("SELECT password_hash FROM user_password WHERE user_id = $1")
|
||||
.bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.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)?;
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &password_hash)
|
||||
.map_err(|_| AppError::InvalidPassword)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_2fa(&self, user_uid: Uuid) -> Result<Option<User2Fa>, AppError> {
|
||||
sqlx::query_as::<_, User2Fa>(
|
||||
"SELECT user_id, secret, backup_codes, enabled, created_at, updated_at \
|
||||
FROM user_2fa WHERE user_id = $1",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
async fn verify_2fa_or_backup_code(
|
||||
&self,
|
||||
two_fa: &User2Fa,
|
||||
code: &str,
|
||||
) -> Result<bool, AppError> {
|
||||
let secret = two_fa.secret.as_ref().ok_or(AppError::TwoFactorNotSetup)?;
|
||||
if self.verify_totp_code(secret, code)? {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let hashed_code = self.hash_backup_code(code)?;
|
||||
let mut backup_codes: Vec<String> = two_fa
|
||||
.backup_codes
|
||||
.split('.')
|
||||
.filter(|c| !c.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect();
|
||||
if backup_codes
|
||||
.iter()
|
||||
.any(|stored| constant_time_eq(stored, &hashed_code))
|
||||
{
|
||||
backup_codes.retain(|stored| stored != &hashed_code);
|
||||
sqlx::query(
|
||||
"UPDATE user_2fa SET backup_codes = $1, updated_at = $2 WHERE user_id = $3",
|
||||
)
|
||||
.bind(backup_codes.join("."))
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(two_fa.user_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
use crate::service::util::constant_time_eq;
|
||||
@@ -0,0 +1,65 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::cache::AppCache;
|
||||
use crate::cache::redis::AppRedis;
|
||||
use crate::config::AppConfig;
|
||||
use crate::error::AppError;
|
||||
use crate::etcd::EtcdRegistry;
|
||||
use crate::models::db::AppDatabase;
|
||||
use crate::queue::NatsQueue;
|
||||
use crate::service::im::events::ImEventBus;
|
||||
use crate::storage::s3::AppS3Storage;
|
||||
|
||||
/// Shared infrastructure context for all domain services.
|
||||
///
|
||||
/// Each sub-service (Auth, User, Workspace, Repo) holds an `Arc<ServiceContext>`
|
||||
/// so they share the same database, cache, and other infrastructure without
|
||||
/// duplicating ownership.
|
||||
#[derive(Clone)]
|
||||
pub struct ServiceContext {
|
||||
pub version: String,
|
||||
pub db: AppDatabase,
|
||||
pub redis: AppRedis,
|
||||
pub cache: Arc<AppCache>,
|
||||
pub config: AppConfig,
|
||||
pub storage: AppS3Storage,
|
||||
/// etcd-based service registry for discovering git and mail RPC services.
|
||||
pub registry: Arc<EtcdRegistry>,
|
||||
/// NATS JetStream queue for real-time event broadcasting.
|
||||
pub nats: Arc<NatsQueue>,
|
||||
pub im_events: Arc<ImEventBus>,
|
||||
}
|
||||
|
||||
impl ServiceContext {
|
||||
/// Run a block of work inside a database transaction.
|
||||
///
|
||||
/// - Begins a transaction on the writer pool
|
||||
/// - Sets `app.current_user_id` for RLS / audit triggers
|
||||
/// - Commits on success, rolls back on error
|
||||
pub async fn run_in_transaction<F, Fut, T>(&self, user_uid: Uuid, f: F) -> Result<T, AppError>
|
||||
where
|
||||
F: FnOnce(&mut sqlx::Transaction<'_, sqlx::Postgres>) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<T, AppError>>,
|
||||
{
|
||||
let mut txn = self
|
||||
.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 = f(&mut txn).await?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,715 @@
|
||||
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,176 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::immediate::{CategoryAction, CategoryEvent};
|
||||
use crate::models::channels::ChannelCategory;
|
||||
use crate::models::common::Role;
|
||||
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 CreateCategoryParams {
|
||||
pub name: String,
|
||||
pub position: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateCategoryParams {
|
||||
pub name: Option<String>,
|
||||
pub position: Option<i32>,
|
||||
pub collapsed: Option<bool>,
|
||||
}
|
||||
|
||||
impl ImService {
|
||||
async fn category_realtime(
|
||||
&self,
|
||||
workspace_name: &str,
|
||||
category_id: Uuid,
|
||||
action: CategoryAction,
|
||||
) {
|
||||
let request_id = Uuid::nil();
|
||||
let event = CategoryEvent {
|
||||
workspace_name: workspace_name.to_string(),
|
||||
category_id,
|
||||
action,
|
||||
};
|
||||
self.publish(&format!("im.category.{workspace_name}"), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Category {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn category_list(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
wk_name: &str,
|
||||
) -> Result<Vec<ChannelCategory>, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
self.ensure_workspace_readable(user_uid, &ws).await?;
|
||||
|
||||
sqlx::query_as::<_, ChannelCategory>(
|
||||
"SELECT id, workspace_id, name, position, collapsed, created_by, created_at, updated_at \
|
||||
FROM channel_category WHERE workspace_id = $1 ORDER BY position ASC, name ASC",
|
||||
)
|
||||
.bind(ws.id)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn category_create(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
wk_name: &str,
|
||||
params: CreateCategoryParams,
|
||||
) -> Result<ChannelCategory, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Member)
|
||||
.await?;
|
||||
|
||||
let name = required_text(params.name, "name")?;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let category = sqlx::query_as::<_, ChannelCategory>(
|
||||
"INSERT INTO channel_category (id, workspace_id, name, position, collapsed, created_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, false, $5, $6, $6) \
|
||||
RETURNING id, workspace_id, name, position, collapsed, created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(ws.id)
|
||||
.bind(&name)
|
||||
.bind(params.position.unwrap_or(0))
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
self.category_realtime(wk_name, category.id, CategoryAction::Created)
|
||||
.await;
|
||||
Ok(category)
|
||||
}
|
||||
|
||||
pub async fn category_update(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
wk_name: &str,
|
||||
category_id: Uuid,
|
||||
params: UpdateCategoryParams,
|
||||
) -> Result<ChannelCategory, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Member)
|
||||
.await?;
|
||||
|
||||
let cat = self.resolve_category(category_id, ws.id).await?;
|
||||
let new_name = params.name.unwrap_or(cat.name);
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let category = sqlx::query_as::<_, ChannelCategory>(
|
||||
"UPDATE channel_category SET name = $1, position = COALESCE($2, position), \
|
||||
collapsed = COALESCE($3, collapsed), updated_at = $4 \
|
||||
WHERE id = $5 \
|
||||
RETURNING id, workspace_id, name, position, collapsed, created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(&new_name)
|
||||
.bind(params.position)
|
||||
.bind(params.collapsed)
|
||||
.bind(now)
|
||||
.bind(category_id)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
self.category_realtime(wk_name, category_id, CategoryAction::Updated)
|
||||
.await;
|
||||
Ok(category)
|
||||
}
|
||||
|
||||
pub async fn category_delete(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
wk_name: &str,
|
||||
category_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let result =
|
||||
sqlx::query("DELETE FROM channel_category WHERE id = $1 AND workspace_id = $2")
|
||||
.bind(category_id)
|
||||
.bind(ws.id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
ensure_affected(result.rows_affected(), "category not found")?;
|
||||
self.category_realtime(wk_name, category_id, CategoryAction::Deleted)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_category(
|
||||
&self,
|
||||
category_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<ChannelCategory, AppError> {
|
||||
sqlx::query_as::<_, ChannelCategory>(
|
||||
"SELECT id, workspace_id, name, position, collapsed, created_by, created_at, updated_at \
|
||||
FROM channel_category WHERE id = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(category_id)
|
||||
.bind(workspace_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("category not found".into()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::channels::Channel;
|
||||
use crate::models::common::{ChannelKind, ChannelType, Role, Visibility};
|
||||
use crate::models::workspaces::Workspace;
|
||||
use crate::service::ImService;
|
||||
|
||||
use super::session::ImSession;
|
||||
use super::util::*;
|
||||
use crate::immediate::{ChannelAction, ChannelEvent};
|
||||
use crate::service::im::events::ImEvent;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateChannelParams {
|
||||
pub name: String,
|
||||
pub topic: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub channel_type: Option<String>,
|
||||
pub channel_kind: Option<String>,
|
||||
pub visibility: Option<String>,
|
||||
pub category_id: Option<Uuid>,
|
||||
pub parent_channel_id: Option<Uuid>,
|
||||
pub nsfw: Option<bool>,
|
||||
pub rate_limit_per_user: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateChannelParams {
|
||||
pub name: Option<String>,
|
||||
pub topic: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub visibility: Option<String>,
|
||||
pub category_id: Option<Uuid>,
|
||||
pub position: Option<i32>,
|
||||
pub nsfw: Option<bool>,
|
||||
pub rate_limit_per_user: Option<i32>,
|
||||
pub archived: Option<bool>,
|
||||
pub read_only: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct ChannelListFilters {
|
||||
pub channel_type: Option<String>,
|
||||
pub channel_kind: Option<String>,
|
||||
pub category_id: Option<Uuid>,
|
||||
pub archived: Option<bool>,
|
||||
}
|
||||
|
||||
impl ImService {
|
||||
pub async fn channel_list(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
wk_name: &str,
|
||||
filters: ChannelListFilters,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Channel>, 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);
|
||||
|
||||
let kind = filters
|
||||
.channel_kind
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse::<ChannelKind>().ok())
|
||||
.filter(|k| *k != ChannelKind::Unknown);
|
||||
let ch_type = filters
|
||||
.channel_type
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse::<ChannelType>().ok())
|
||||
.filter(|t| *t != ChannelType::Unknown);
|
||||
|
||||
sqlx::query_as::<_, Channel>(
|
||||
"SELECT id, workspace_id, repo_id, category_id, created_by, name, topic, description, \
|
||||
channel_type, channel_kind, visibility, position, nsfw, archived, read_only, \
|
||||
bitrate, user_limit, rtc_region, \
|
||||
default_auto_archive_duration, default_reaction_emoji, default_sort_order, \
|
||||
default_forum_layout, require_tag, available_tags, default_thread_rate_limit, \
|
||||
rate_limit_per_user, parent_channel_id, \
|
||||
last_message_id, last_message_at, archived_at, created_at, updated_at, deleted_at \
|
||||
FROM channel \
|
||||
WHERE workspace_id = $1 AND deleted_at IS NULL \
|
||||
AND ($2::text IS NULL OR channel_kind::text = $2) \
|
||||
AND ($3::text IS NULL OR channel_type::text = $3) \
|
||||
AND ($4::uuid IS NULL OR category_id = $4) \
|
||||
AND ($5::bool IS NULL OR archived = $5) \
|
||||
ORDER BY position ASC NULLS LAST, name ASC \
|
||||
LIMIT $6 OFFSET $7",
|
||||
)
|
||||
.bind(ws.id)
|
||||
.bind(kind.map(|k| k.to_string()))
|
||||
.bind(ch_type.map(|t| t.to_string()))
|
||||
.bind(filters.category_id)
|
||||
.bind(filters.archived)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn channel_get(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
) -> Result<Channel, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_readable(user_uid, &channel).await?;
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self, ctx, params), fields(name = %params.name))]
|
||||
pub async fn channel_create(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
wk_name: &str,
|
||||
params: CreateChannelParams,
|
||||
request_id: Uuid,
|
||||
) -> Result<Channel, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let name = required_text(params.name, "name")?;
|
||||
if name.len() > MAX_CHANNEL_NAME {
|
||||
return Err(AppError::BadRequest("channel name too long".into()));
|
||||
}
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Member)
|
||||
.await?;
|
||||
|
||||
if let Some(topic) = ¶ms.topic
|
||||
&& topic.len() > MAX_CHANNEL_TOPIC
|
||||
{
|
||||
return Err(AppError::BadRequest("channel topic too long".into()));
|
||||
}
|
||||
|
||||
let ch_kind = parse_enum(
|
||||
params.channel_kind,
|
||||
ChannelKind::Text,
|
||||
ChannelKind::Unknown,
|
||||
"channel_kind",
|
||||
)?;
|
||||
let ch_type = parse_enum(
|
||||
params.channel_type,
|
||||
ChannelType::Public,
|
||||
ChannelType::Unknown,
|
||||
"channel_type",
|
||||
)?;
|
||||
let visibility = parse_enum(
|
||||
params.visibility,
|
||||
Visibility::Public,
|
||||
Visibility::Unknown,
|
||||
"visibility",
|
||||
)?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let channel_id = Uuid::now_v7();
|
||||
|
||||
let channel = sqlx::query_as::<_, Channel>(
|
||||
"INSERT INTO channel \
|
||||
(id, workspace_id, repo_id, category_id, created_by, name, topic, description, \
|
||||
channel_type, channel_kind, visibility, position, nsfw, archived, read_only, \
|
||||
rate_limit_per_user, parent_channel_id, created_at, updated_at) \
|
||||
VALUES ($1, $2, NULL, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, false, false, \
|
||||
$13, $14, $15, $15) \
|
||||
RETURNING id, workspace_id, repo_id, category_id, created_by, name, topic, description, \
|
||||
channel_type, channel_kind, visibility, position, nsfw, archived, read_only, \
|
||||
bitrate, user_limit, rtc_region, \
|
||||
default_auto_archive_duration, default_reaction_emoji, default_sort_order, \
|
||||
default_forum_layout, require_tag, available_tags, default_thread_rate_limit, \
|
||||
rate_limit_per_user, parent_channel_id, \
|
||||
last_message_id, last_message_at, archived_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.bind(ws.id)
|
||||
.bind(params.category_id)
|
||||
.bind(user_uid)
|
||||
.bind(&name)
|
||||
.bind(params.topic.as_deref())
|
||||
.bind(params.description.as_deref())
|
||||
.bind(ch_type)
|
||||
.bind(ch_kind)
|
||||
.bind(visibility)
|
||||
.bind(0_i32) // position
|
||||
.bind(params.nsfw.unwrap_or(false))
|
||||
.bind(params.rate_limit_per_user)
|
||||
.bind(params.parent_channel_id)
|
||||
.bind(now)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
// Auto-add creator as channel member with owner role
|
||||
sqlx::query(
|
||||
"INSERT INTO channel_member \
|
||||
(id, channel_id, user_id, role, status, muted, pinned, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, 'owner', 'active', false, false, $4, $4)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(channel_id)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
tracing::info!(channel_id = %channel_id, name = %name, "Channel created");
|
||||
|
||||
let event = ChannelEvent {
|
||||
channel_id: channel.id,
|
||||
action: ChannelAction::Created,
|
||||
workspace_name: Some(ws.name.clone()),
|
||||
};
|
||||
self.publish(&format!("im.channel.{}", ws.name), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Channel {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
pub async fn channel_update(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
params: UpdateChannelParams,
|
||||
request_id: Uuid,
|
||||
) -> Result<Channel, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_editable(user_uid, &channel).await?;
|
||||
|
||||
if let Some(name) = ¶ms.name
|
||||
&& name.len() > MAX_CHANNEL_NAME
|
||||
{
|
||||
return Err(AppError::BadRequest("channel name too long".into()));
|
||||
}
|
||||
|
||||
let visibility = match params.visibility {
|
||||
Some(ref v) => parse_enum(
|
||||
Some(v.clone()),
|
||||
channel.visibility,
|
||||
Visibility::Unknown,
|
||||
"visibility",
|
||||
)?,
|
||||
None => channel.visibility,
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let new_name = merge_optional_text(params.name, Some(channel.name.clone()))
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or(channel.name);
|
||||
let new_topic = merge_optional_text(params.topic, channel.topic.clone());
|
||||
let new_desc = merge_optional_text(params.description, channel.description.clone());
|
||||
|
||||
let updated = sqlx::query_as::<_, Channel>(
|
||||
"UPDATE channel SET \
|
||||
name = $1, topic = $2, description = $3, visibility = $4, \
|
||||
category_id = COALESCE($5, category_id), position = COALESCE($6, position), \
|
||||
nsfw = COALESCE($7, nsfw), archived = COALESCE($8, archived), \
|
||||
read_only = COALESCE($9, read_only), \
|
||||
rate_limit_per_user = COALESCE($10, rate_limit_per_user), \
|
||||
updated_at = $11 \
|
||||
WHERE id = $12 AND deleted_at IS NULL \
|
||||
RETURNING id, workspace_id, repo_id, category_id, created_by, name, topic, description, \
|
||||
channel_type, channel_kind, visibility, position, nsfw, archived, read_only, \
|
||||
bitrate, user_limit, rtc_region, \
|
||||
default_auto_archive_duration, default_reaction_emoji, default_sort_order, \
|
||||
default_forum_layout, require_tag, available_tags, default_thread_rate_limit, \
|
||||
rate_limit_per_user, parent_channel_id, \
|
||||
last_message_id, last_message_at, archived_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(&new_name)
|
||||
.bind(&new_topic)
|
||||
.bind(&new_desc)
|
||||
.bind(visibility)
|
||||
.bind(params.category_id)
|
||||
.bind(params.position)
|
||||
.bind(params.nsfw)
|
||||
.bind(params.archived)
|
||||
.bind(params.read_only)
|
||||
.bind(params.rate_limit_per_user)
|
||||
.bind(now)
|
||||
.bind(channel_id)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let event = ChannelEvent {
|
||||
channel_id,
|
||||
action: ChannelAction::Updated,
|
||||
workspace_name: None,
|
||||
};
|
||||
self.publish(&format!("im.channel.{}", channel_id), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Channel {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn channel_delete(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
request_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 SET deleted_at = $1, updated_at = $1 \
|
||||
WHERE id = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(channel_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
ensure_affected(result.rows_affected(), "channel not found")?;
|
||||
|
||||
let event = ChannelEvent {
|
||||
channel_id,
|
||||
action: ChannelAction::Deleted,
|
||||
workspace_name: None,
|
||||
};
|
||||
self.publish(&format!("im.channel.{}", channel_id), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Channel {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_workspace(&self, wk_name: &str) -> Result<Workspace, AppError> {
|
||||
Workspace::find_by_name(self.ctx.db.reader(), wk_name)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("workspace not found".into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_channel(&self, channel_id: Uuid) -> Result<Channel, AppError> {
|
||||
sqlx::query_as::<_, Channel>(
|
||||
"SELECT id, workspace_id, repo_id, category_id, created_by, name, topic, description, \
|
||||
channel_type, channel_kind, visibility, position, nsfw, archived, read_only, \
|
||||
bitrate, user_limit, rtc_region, \
|
||||
default_auto_archive_duration, default_reaction_emoji, default_sort_order, \
|
||||
default_forum_layout, require_tag, available_tags, default_thread_rate_limit, \
|
||||
rate_limit_per_user, parent_channel_id, \
|
||||
last_message_id, last_message_at, archived_at, created_at, updated_at, deleted_at \
|
||||
FROM channel WHERE id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("channel not found".into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_workspace_readable(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
ws: &Workspace,
|
||||
) -> Result<(), AppError> {
|
||||
if Workspace::is_readable(self.ctx.db.reader(), ws, user_uid)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Unauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_workspace_role_at_least(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
ws: &Workspace,
|
||||
min_role: Role,
|
||||
) -> Result<Role, AppError> {
|
||||
let role = Workspace::user_role(self.ctx.db.reader(), ws.id, user_uid, ws.owner_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.unwrap_or(Role::Unknown);
|
||||
if role_level(role) < role_level(min_role) {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
Ok(role)
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_channel_readable(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
channel: &Channel,
|
||||
) -> Result<(), AppError> {
|
||||
if channel.created_by == user_uid {
|
||||
return Ok(());
|
||||
}
|
||||
let is_member = self.is_channel_member(channel.id, user_uid).await?;
|
||||
if is_member {
|
||||
return Ok(());
|
||||
}
|
||||
if channel.visibility == Visibility::Public {
|
||||
let ws = Workspace::find_by_id(self.ctx.db.reader(), channel.workspace_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("workspace not found".into()))?;
|
||||
if Workspace::is_readable(self.ctx.db.reader(), &ws, user_uid)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(AppError::Unauthorized)
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_channel_member(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
channel: &Channel,
|
||||
) -> Result<(), AppError> {
|
||||
if channel.created_by == user_uid {
|
||||
return Ok(());
|
||||
}
|
||||
let is_member = self.is_channel_member(channel.id, user_uid).await?;
|
||||
if is_member {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Forbidden("not a channel member".into()))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_channel_editable(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
channel: &Channel,
|
||||
) -> Result<(), AppError> {
|
||||
if channel.created_by == user_uid {
|
||||
return Ok(());
|
||||
}
|
||||
let role = self.channel_member_role(channel.id, user_uid).await?;
|
||||
if role_level(role) >= role_level(Role::Member) {
|
||||
return Ok(());
|
||||
}
|
||||
self.ensure_workspace_role_at_least(
|
||||
user_uid,
|
||||
&Workspace::find_by_id(self.ctx.db.reader(), channel.workspace_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("workspace not found".into()))?,
|
||||
Role::Admin,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_channel_admin(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
channel: &Channel,
|
||||
) -> Result<(), AppError> {
|
||||
let role = self.channel_member_role(channel.id, user_uid).await?;
|
||||
if role_level(role) >= role_level(Role::Admin) {
|
||||
return Ok(());
|
||||
}
|
||||
self.ensure_workspace_role_at_least(
|
||||
user_uid,
|
||||
&Workspace::find_by_id(self.ctx.db.reader(), channel.workspace_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("workspace not found".into()))?,
|
||||
Role::Admin,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn is_channel_member(
|
||||
&self,
|
||||
channel_id: Uuid,
|
||||
user_uid: Uuid,
|
||||
) -> Result<bool, AppError> {
|
||||
sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM channel_member \
|
||||
WHERE channel_id = $1 AND user_id = $2 AND status = 'active')",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.bind(user_uid)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub(crate) async fn channel_member_role(
|
||||
&self,
|
||||
channel_id: Uuid,
|
||||
user_uid: Uuid,
|
||||
) -> Result<Role, AppError> {
|
||||
let role: Option<String> = sqlx::query_scalar(
|
||||
"SELECT role::text FROM channel_member \
|
||||
WHERE channel_id = $1 AND user_id = $2 AND status = 'active'",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
Ok(role
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse::<Role>().ok())
|
||||
.unwrap_or(Role::Unknown))
|
||||
}
|
||||
|
||||
pub(crate) async fn update_channel_stats(
|
||||
&self,
|
||||
channel_id: Uuid,
|
||||
now: chrono::DateTime<chrono::Utc>,
|
||||
txn: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query(
|
||||
"UPDATE channel_stats SET \
|
||||
members_count = (SELECT COUNT(*) FROM channel_member WHERE channel_id = $1 AND status = 'active'), \
|
||||
messages_count = (SELECT COUNT(*) FROM message WHERE channel_id = $1 AND deleted_at IS NULL), \
|
||||
threads_count = (SELECT COUNT(*) FROM message_thread WHERE channel_id = $1), \
|
||||
reactions_count = (SELECT COUNT(*) FROM message_reaction WHERE channel_id = $1), \
|
||||
mentions_count = (SELECT COUNT(*) FROM message_mention WHERE channel_id = $1), \
|
||||
files_count = (SELECT COUNT(*) FROM message_attachment WHERE channel_id = $1), \
|
||||
last_activity_at = $2, updated_at = $2 \
|
||||
WHERE channel_id = $1",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.bind(now)
|
||||
.execute(&mut **txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use tokio::sync::broadcast;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::immediate::{
|
||||
ArticleEvent, CategoryEvent, ChannelEvent, DraftEvent, FollowEvent, MemberEvent, MessageEvent,
|
||||
PollEvent, PresenceEvent, ReactionEvent, ThreadEvent, TypingEvent,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ImEvent {
|
||||
Typing {
|
||||
request_id: Uuid,
|
||||
data: TypingEvent,
|
||||
},
|
||||
Presence {
|
||||
request_id: Uuid,
|
||||
data: PresenceEvent,
|
||||
},
|
||||
Message {
|
||||
request_id: Uuid,
|
||||
data: MessageEvent,
|
||||
},
|
||||
Channel {
|
||||
request_id: Uuid,
|
||||
data: ChannelEvent,
|
||||
},
|
||||
Thread {
|
||||
request_id: Uuid,
|
||||
data: ThreadEvent,
|
||||
},
|
||||
Member {
|
||||
request_id: Uuid,
|
||||
data: MemberEvent,
|
||||
},
|
||||
Reaction {
|
||||
request_id: Uuid,
|
||||
data: ReactionEvent,
|
||||
},
|
||||
Poll {
|
||||
request_id: Uuid,
|
||||
data: PollEvent,
|
||||
},
|
||||
Article {
|
||||
request_id: Uuid,
|
||||
data: ArticleEvent,
|
||||
},
|
||||
Category {
|
||||
request_id: Uuid,
|
||||
data: CategoryEvent,
|
||||
},
|
||||
Draft {
|
||||
request_id: Uuid,
|
||||
data: DraftEvent,
|
||||
},
|
||||
Follow {
|
||||
request_id: Uuid,
|
||||
data: FollowEvent,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ImEventBus {
|
||||
tx: broadcast::Sender<ImEvent>,
|
||||
lagged: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl ImEventBus {
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
let (tx, _) = broadcast::channel(capacity);
|
||||
Self {
|
||||
tx,
|
||||
lagged: Arc::new(AtomicU64::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn publish(&self, event: ImEvent) -> bool {
|
||||
self.tx.send(event).is_ok()
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<ImEvent> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
pub fn record_lagged(&self, count: u64) {
|
||||
self.lagged.fetch_add(count, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn lagged_total(&self) -> u64 {
|
||||
self.lagged.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ImEventBus {
|
||||
fn default() -> Self {
|
||||
Self::new(1024)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
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,410 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::immediate::{MemberAction, MemberEvent};
|
||||
use crate::models::channels::ChannelMember;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::workspaces::Workspace;
|
||||
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 InviteMemberParams {
|
||||
pub user_id: Uuid,
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateMemberParams {
|
||||
pub role: Option<String>,
|
||||
pub muted: Option<bool>,
|
||||
pub pinned: Option<bool>,
|
||||
}
|
||||
|
||||
impl ImService {
|
||||
pub async fn member_list(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<ChannelMember>, 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::<_, ChannelMember>(
|
||||
"SELECT id, channel_id, user_id, role, status, muted, pinned, \
|
||||
last_read_message_id, last_read_at, joined_at, left_at, created_at, updated_at \
|
||||
FROM channel_member WHERE channel_id = $1 AND status = 'active' \
|
||||
ORDER BY joined_at ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn member_invite(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
params: InviteMemberParams,
|
||||
) -> Result<ChannelMember, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_editable(user_uid, &channel).await?;
|
||||
|
||||
let ws = Workspace::find_by_id(self.ctx.db.reader(), channel.workspace_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("workspace not found".into()))?;
|
||||
let ws_role =
|
||||
Workspace::user_role(self.ctx.db.reader(), ws.id, params.user_id, ws.owner_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if ws_role == Some(Role::Unknown) || ws_role.is_none() {
|
||||
return Err(AppError::BadRequest(
|
||||
"invited user is not a workspace member".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let is_already = self.is_channel_member(channel_id, params.user_id).await?;
|
||||
if is_already {
|
||||
return Err(AppError::Conflict("user is already a member".into()));
|
||||
}
|
||||
|
||||
let role = parse_enum(params.role, Role::Member, Role::Unknown, "role")?;
|
||||
|
||||
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 member = sqlx::query_as::<_, ChannelMember>(
|
||||
"INSERT INTO channel_member \
|
||||
(id, channel_id, user_id, role, status, muted, pinned, joined_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, 'active', false, false, $5, $5, $5) \
|
||||
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(Uuid::now_v7())
|
||||
.bind(channel_id)
|
||||
.bind(params.user_id)
|
||||
.bind(role)
|
||||
.bind(now)
|
||||
.fetch_one(&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!(channel_id = %channel_id, user_id = %params.user_id, "Member invited");
|
||||
let request_id = Uuid::nil();
|
||||
let event = MemberEvent {
|
||||
channel_id,
|
||||
user_id: member.user_id,
|
||||
action: MemberAction::Joined,
|
||||
};
|
||||
self.publish(&format!("im.member.{}", channel_id), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Member {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
Ok(member)
|
||||
}
|
||||
|
||||
pub async fn member_update(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
member_user_id: Uuid,
|
||||
params: UpdateMemberParams,
|
||||
) -> Result<ChannelMember, AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
self.ensure_channel_admin(user_uid, &channel).await?;
|
||||
|
||||
let role = match params.role {
|
||||
Some(ref v) => parse_enum(Some(v.clone()), Role::Member, Role::Unknown, "role")?,
|
||||
None => {
|
||||
// Fetch current role
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT role::text FROM channel_member \
|
||||
WHERE channel_id = $1 AND user_id = $2 AND status = 'active'",
|
||||
)
|
||||
.bind(channel_id)
|
||||
.bind(member_user_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.map(|s| s.parse::<Role>().unwrap_or(Role::Member))
|
||||
.unwrap_or(Role::Member)
|
||||
}
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let member = sqlx::query_as::<_, ChannelMember>(
|
||||
"UPDATE channel_member SET role = $1, muted = COALESCE($2, muted), \
|
||||
pinned = COALESCE($3, pinned), updated_at = $4 \
|
||||
WHERE channel_id = $5 AND user_id = $6 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(role)
|
||||
.bind(params.muted)
|
||||
.bind(params.pinned)
|
||||
.bind(now)
|
||||
.bind(channel_id)
|
||||
.bind(member_user_id)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
let request_id = Uuid::nil();
|
||||
let event = MemberEvent {
|
||||
channel_id,
|
||||
user_id: member.user_id,
|
||||
action: MemberAction::Updated,
|
||||
};
|
||||
self.publish(&format!("im.member.{}", channel_id), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Member {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
Ok(member)
|
||||
}
|
||||
|
||||
pub async fn member_kick(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
member_user_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?;
|
||||
|
||||
if member_user_id == channel.created_by {
|
||||
return Err(AppError::Forbidden("cannot kick channel owner".into()));
|
||||
}
|
||||
|
||||
let ws = Workspace::find_by_id(self.ctx.db.reader(), channel.workspace_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("workspace not found".into()))?;
|
||||
if member_user_id == ws.owner_id {
|
||||
return Err(AppError::Forbidden("cannot kick workspace owner".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)?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE channel_member SET status = 'inactive', left_at = $1, updated_at = $1 \
|
||||
WHERE channel_id = $2 AND user_id = $3 AND status = 'active'",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(channel_id)
|
||||
.bind(member_user_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "member not found")?;
|
||||
|
||||
self.update_channel_stats(channel_id, now, &mut txn).await?;
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
tracing::info!(channel_id = %channel_id, user_id = %member_user_id, "Member kicked");
|
||||
let request_id = Uuid::nil();
|
||||
let event = MemberEvent {
|
||||
channel_id,
|
||||
user_id: member_user_id,
|
||||
action: MemberAction::Kicked,
|
||||
};
|
||||
self.publish(&format!("im.member.{}", channel_id), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Member {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn member_leave(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user;
|
||||
let channel = self.resolve_channel(channel_id).await?;
|
||||
|
||||
if channel.created_by == user_uid {
|
||||
return Err(AppError::Forbidden("channel owner cannot leave".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)?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE channel_member SET status = 'inactive', left_at = $1, updated_at = $1 \
|
||||
WHERE channel_id = $2 AND user_id = $3 AND status = 'active'",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(channel_id)
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "not a member")?;
|
||||
|
||||
self.update_channel_stats(channel_id, now, &mut txn).await?;
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
let request_id = Uuid::nil();
|
||||
let event = MemberEvent {
|
||||
channel_id,
|
||||
user_id: user_uid,
|
||||
action: MemberAction::Left,
|
||||
};
|
||||
self.publish(&format!("im.member.{}", channel_id), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Member {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn member_join(
|
||||
&self,
|
||||
ctx: &ImSession,
|
||||
_wk_name: &str,
|
||||
channel_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 is_already = self.is_channel_member(channel_id, user_uid).await?;
|
||||
if is_already {
|
||||
return Err(AppError::Conflict("already a member".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)?;
|
||||
|
||||
let member = sqlx::query_as::<_, ChannelMember>(
|
||||
"INSERT INTO channel_member \
|
||||
(id, channel_id, user_id, role, status, muted, pinned, joined_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, 'member', 'active', false, false, $4, $4, $4) \
|
||||
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(Uuid::now_v7())
|
||||
.bind(channel_id)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
self.update_channel_stats(channel_id, now, &mut txn).await?;
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
let request_id = Uuid::nil();
|
||||
let event = MemberEvent {
|
||||
channel_id,
|
||||
user_id: member.user_id,
|
||||
action: MemberAction::Joined,
|
||||
};
|
||||
self.publish(&format!("im.member.{}", channel_id), request_id, &event)
|
||||
.await;
|
||||
self.emit_event(ImEvent::Member {
|
||||
request_id,
|
||||
data: event,
|
||||
});
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,889 @@
|
||||
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()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::service::ServiceContext;
|
||||
use delivery_trace::{trace_error, trace_request};
|
||||
use events::ImEvent;
|
||||
|
||||
pub mod articles;
|
||||
pub mod categories;
|
||||
pub mod channels;
|
||||
pub mod delivery_trace;
|
||||
pub mod drafts;
|
||||
pub mod events;
|
||||
pub mod follows;
|
||||
pub mod members;
|
||||
pub mod messages;
|
||||
pub mod polls;
|
||||
pub mod presence;
|
||||
pub mod reactions;
|
||||
pub mod session;
|
||||
pub mod threads;
|
||||
pub mod util;
|
||||
|
||||
pub use messages::{EditMessageParams, SendMessageParams};
|
||||
pub use presence::UpdatePresenceParams;
|
||||
pub use session::ImSession;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ImService {
|
||||
pub ctx: Arc<ServiceContext>,
|
||||
}
|
||||
|
||||
impl ImService {
|
||||
fn emit_event(&self, event: ImEvent) {
|
||||
let _ = self.ctx.im_events.publish(event);
|
||||
}
|
||||
|
||||
async fn publish<T: Serialize>(&self, subject: &str, request_id: Uuid, event: &T) {
|
||||
match self
|
||||
.ctx
|
||||
.nats
|
||||
.publish_with_headers(
|
||||
subject,
|
||||
&serde_json::to_vec(event).unwrap_or_default(),
|
||||
vec![("X-Request-Id".into(), request_id.to_string())],
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => trace_request("nats_published", request_id, subject),
|
||||
Err(e) => {
|
||||
trace_error("nats_failed", request_id, subject, &e);
|
||||
tracing::warn!(subject, error = %e, "nats publish failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
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,12 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImSession {
|
||||
pub user: Uuid,
|
||||
}
|
||||
|
||||
impl ImSession {
|
||||
pub fn new(user: Uuid) -> Self {
|
||||
Self { user }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
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()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
pub use crate::service::util::{
|
||||
clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text, role_level,
|
||||
};
|
||||
|
||||
/// Maximum length for a channel name.
|
||||
pub const MAX_CHANNEL_NAME: usize = 100;
|
||||
|
||||
/// Maximum length for a channel topic.
|
||||
pub const MAX_CHANNEL_TOPIC: usize = 1024;
|
||||
|
||||
/// Maximum length for a message body.
|
||||
pub const MAX_MESSAGE_BODY: usize = 4096;
|
||||
|
||||
/// Maximum length for an article title.
|
||||
pub const MAX_ARTICLE_TITLE: usize = 256;
|
||||
|
||||
/// Maximum number of poll options.
|
||||
pub const MAX_POLL_OPTIONS: usize = 10;
|
||||
|
||||
/// Maximum length for a poll option text.
|
||||
pub const MAX_POLL_OPTION_TEXT: usize = 100;
|
||||
|
||||
/// Redis key prefix for typing indicators.
|
||||
pub const TYPING_PREFIX: &str = "im:typing:";
|
||||
|
||||
/// Redis key prefix for user presence.
|
||||
pub const PRESENCE_PREFIX: &str = "im:presence:";
|
||||
|
||||
/// Redis TTL for typing indicators (seconds).
|
||||
pub const TYPING_TTL_SECS: usize = 8;
|
||||
|
||||
/// Redis TTL for presence heartbeats (seconds).
|
||||
pub const PRESENCE_TTL_SECS: usize = 120;
|
||||
|
||||
/// Maximum length for generated slugs.
|
||||
pub const MAX_SLUG_LEN: usize = 128;
|
||||
|
||||
/// Generate a slug from a title string.
|
||||
pub fn slugify(title: &str) -> String {
|
||||
let slug: String = title
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.filter_map(|c| {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
Some(c)
|
||||
} else if c.is_whitespace() || !c.is_ascii() {
|
||||
Some('-')
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
|
||||
let mut result = slug;
|
||||
result.truncate(MAX_SLUG_LEN);
|
||||
if result.ends_with('-') {
|
||||
result.pop();
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::issues::IssueAssignee;
|
||||
use crate::service::IssueService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected};
|
||||
|
||||
impl IssueService {
|
||||
pub async fn issue_assignees(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<IssueAssignee>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_readable(user_uid, &issue).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, IssueAssignee>(
|
||||
"SELECT id, issue_id, assignee_id, assigned_by, created_at \
|
||||
FROM issue_assignee WHERE issue_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(issue_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn issue_assign(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
assignee_id: Uuid,
|
||||
) -> Result<IssueAssignee, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_editable(user_uid, &issue).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 assignee = sqlx::query_as::<_, IssueAssignee>(
|
||||
"INSERT INTO issue_assignee (id, issue_id, assignee_id, assigned_by, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (issue_id, assignee_id) DO NOTHING \
|
||||
RETURNING id, issue_id, assignee_id, assigned_by, created_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(issue_id)
|
||||
.bind(assignee_id)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.fetch_optional(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::Conflict("user already assigned".into()))?;
|
||||
sqlx::query("UPDATE issue_stats SET assignees_count = assignees_count + 1, updated_at = $1 WHERE issue_id = $2")
|
||||
.bind(now).bind(issue_id).execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
sqlx::query(
|
||||
"INSERT INTO issue_subscriber (id, issue_id, user_id, reason, muted, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, 'assignee', false, $4, $4) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(Uuid::now_v7()).bind(issue_id).bind(assignee_id).bind(now)
|
||||
.execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(assignee)
|
||||
}
|
||||
|
||||
pub async fn issue_unassign(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
assignee_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_editable(user_uid, &issue).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 issue_assignee WHERE issue_id = $1 AND assignee_id = $2")
|
||||
.bind(issue_id)
|
||||
.bind(assignee_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "assignee not found")?;
|
||||
sqlx::query("UPDATE issue_stats SET assignees_count = GREATEST(assignees_count - 1, 0), updated_at = $1 WHERE issue_id = $2")
|
||||
.bind(now).bind(issue_id).execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::issues::IssueComment;
|
||||
use crate::service::IssueService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, required_text};
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateCommentParams {
|
||||
pub body: String,
|
||||
pub reply_to_comment_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateCommentParams {
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
impl IssueService {
|
||||
pub async fn issue_comments(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<IssueComment>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_readable(user_uid, &issue).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, IssueComment>(
|
||||
"SELECT id, issue_id, author_id, body, reply_to_comment_id, edited_at, deleted_at, \
|
||||
created_at, updated_at FROM issue_comment \
|
||||
WHERE issue_id = $1 AND deleted_at IS NULL ORDER BY created_at ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(issue_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn issue_create_comment(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
params: CreateCommentParams,
|
||||
) -> Result<IssueComment, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_readable(user_uid, &issue).await?;
|
||||
|
||||
if issue.locked {
|
||||
self.ensure_issue_editable(user_uid, &issue).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::<_, IssueComment>(
|
||||
"INSERT INTO issue_comment (id, issue_id, author_id, body, reply_to_comment_id, \
|
||||
edited_at, deleted_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, NULL, NULL, $6, $6) \
|
||||
RETURNING id, issue_id, author_id, body, reply_to_comment_id, edited_at, deleted_at, \
|
||||
created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(issue_id)
|
||||
.bind(user_uid)
|
||||
.bind(&body)
|
||||
.bind(params.reply_to_comment_id)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE issue_stats SET comments_count = comments_count + 1, \
|
||||
last_commented_at = $1, updated_at = $1 WHERE issue_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(issue_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let is_subscribed: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM issue_subscriber WHERE issue_id = $1 AND user_id = $2)",
|
||||
)
|
||||
.bind(issue_id)
|
||||
.bind(user_uid)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if !is_subscribed {
|
||||
sqlx::query(
|
||||
"INSERT INTO issue_subscriber (id, issue_id, user_id, reason, muted, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, 'participant', false, $4, $4) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(Uuid::now_v7()).bind(issue_id).bind(user_uid).bind(now)
|
||||
.execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(comment)
|
||||
}
|
||||
|
||||
pub async fn issue_update_comment(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
comment_id: Uuid,
|
||||
params: UpdateCommentParams,
|
||||
) -> Result<IssueComment, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_readable(user_uid, &issue).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 result = sqlx::query_as::<_, IssueComment>(
|
||||
"UPDATE issue_comment SET body = $1, edited_at = $2, updated_at = $2 \
|
||||
WHERE id = $3 AND issue_id = $4 AND author_id = $5 AND deleted_at IS NULL \
|
||||
RETURNING id, issue_id, author_id, body, reply_to_comment_id, edited_at, deleted_at, \
|
||||
created_at, updated_at",
|
||||
)
|
||||
.bind(&body)
|
||||
.bind(now)
|
||||
.bind(comment_id)
|
||||
.bind(issue_id)
|
||||
.bind(user_uid)
|
||||
.fetch_optional(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound(
|
||||
"comment not found or not authored by you".into(),
|
||||
))?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn issue_delete_comment(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
comment_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
|
||||
let comment = sqlx::query_as::<_, IssueComment>(
|
||||
"SELECT id, issue_id, author_id, body, reply_to_comment_id, edited_at, deleted_at, \
|
||||
created_at, updated_at FROM issue_comment WHERE id = $1 AND issue_id = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(comment_id).bind(issue_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_issue_admin(user_uid, &issue).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 issue_comment SET deleted_at = $1, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(now).bind(comment_id).execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "comment not found")?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE issue_stats SET comments_count = GREATEST(comments_count - 1, 0), updated_at = $1 WHERE issue_id = $2",
|
||||
)
|
||||
.bind(now).bind(issue_id).execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,867 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::{EventType, Priority, Role, State, Visibility};
|
||||
use crate::models::issues::Issue;
|
||||
use crate::models::workspaces::Workspace;
|
||||
use crate::service::IssueService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateIssueParams {
|
||||
pub title: String,
|
||||
pub body: Option<String>,
|
||||
pub priority: Option<String>,
|
||||
pub visibility: Option<String>,
|
||||
pub due_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub repo_ids: Vec<Uuid>,
|
||||
pub label_ids: Vec<Uuid>,
|
||||
pub assignee_ids: Vec<Uuid>,
|
||||
pub milestone_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateIssueParams {
|
||||
pub title: Option<String>,
|
||||
pub body: Option<String>,
|
||||
pub priority: Option<String>,
|
||||
pub visibility: Option<String>,
|
||||
pub due_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub milestone_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct IssueListFilters {
|
||||
pub state: Option<String>,
|
||||
pub priority: Option<String>,
|
||||
pub author_id: Option<Uuid>,
|
||||
pub assignee_id: Option<Uuid>,
|
||||
pub milestone_id: Option<Uuid>,
|
||||
pub label_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl IssueService {
|
||||
pub async fn issue_list(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
filters: IssueListFilters,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Issue>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
self.ensure_workspace_readable(wk_name, user_uid).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
|
||||
let state = filters
|
||||
.state
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse::<State>().ok())
|
||||
.filter(|s| *s != State::Unknown);
|
||||
let priority = filters
|
||||
.priority
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse::<Priority>().ok())
|
||||
.filter(|p| *p != Priority::Unknown);
|
||||
|
||||
sqlx::query_as::<_, Issue>(
|
||||
"SELECT DISTINCT i.id, i.workspace_id, i.author_id, i.number, i.title, i.body, \
|
||||
i.state, i.priority, i.visibility, i.locked, i.milestone_id, i.closed_by, i.closed_at, i.due_at, \
|
||||
i.created_at, i.updated_at, i.deleted_at \
|
||||
FROM issue i \
|
||||
LEFT JOIN issue_assignee ia ON ia.issue_id = i.id \
|
||||
LEFT JOIN issue_label_relation ilr ON ilr.issue_id = i.id \
|
||||
WHERE i.workspace_id = $1 AND i.deleted_at IS NULL \
|
||||
AND ($2::text IS NULL OR i.state::text = $2) \
|
||||
AND ($3::text IS NULL OR i.priority::text = $3) \
|
||||
AND ($4::uuid IS NULL OR i.author_id = $4) \
|
||||
AND ($5::uuid IS NULL OR ia.assignee_id = $5) \
|
||||
AND ($6::uuid IS NULL OR i.milestone_id = $6) \
|
||||
AND ($7::uuid IS NULL OR ilr.label_id = $7) \
|
||||
AND (i.visibility = 'public' OR i.workspace_id IN \
|
||||
(SELECT workspace_id FROM workspace_member WHERE user_id = $8 AND status = 'active') \
|
||||
OR i.author_id = $8) \
|
||||
ORDER BY i.number DESC LIMIT $9 OFFSET $10",
|
||||
)
|
||||
.bind(ws.id)
|
||||
.bind(state.map(|s| s.to_string()))
|
||||
.bind(priority.map(|p| p.to_string()))
|
||||
.bind(filters.author_id)
|
||||
.bind(filters.assignee_id)
|
||||
.bind(filters.milestone_id)
|
||||
.bind(filters.label_id)
|
||||
.bind(user_uid)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn issue_get(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
) -> Result<Issue, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
self.ensure_issue_readable(user_uid, &issue).await?;
|
||||
Ok(issue)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self, ctx, params), fields(title = %params.title))]
|
||||
pub async fn issue_create(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
params: CreateIssueParams,
|
||||
) -> Result<Issue, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let title = crate::service::util::required_text(params.title, "title")?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
self.ensure_workspace_role_at_least(wk_name, user_uid, Role::Member)
|
||||
.await?;
|
||||
|
||||
// Validate repo_ids belong to this workspace
|
||||
for repo_id in ¶ms.repo_ids {
|
||||
let repo = crate::models::repos::Repo::find_by_id(self.ctx.db.reader(), *repo_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("Repository {} not found", repo_id)))?;
|
||||
|
||||
if repo.workspace_id != ws.id {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Repository {} does not belong to this workspace",
|
||||
repo_id
|
||||
)));
|
||||
}
|
||||
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
}
|
||||
|
||||
// Validate label_ids belong to repos in this workspace
|
||||
for label_id in ¶ms.label_ids {
|
||||
let label: Option<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT r.workspace_id FROM issue_label il \
|
||||
JOIN repo r ON r.id = il.repo_id \
|
||||
WHERE il.id = $1",
|
||||
)
|
||||
.bind(label_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
match label {
|
||||
Some((workspace_id,)) if workspace_id == ws.id => {}
|
||||
Some(_) => {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Label {} does not belong to this workspace",
|
||||
label_id
|
||||
)));
|
||||
}
|
||||
None => return Err(AppError::NotFound(format!("Label {} not found", label_id))),
|
||||
}
|
||||
}
|
||||
|
||||
// Validate assignee_ids are workspace members
|
||||
for assignee_id in ¶ms.assignee_ids {
|
||||
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(ws.id)
|
||||
.bind(assignee_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if !is_member {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"User {} is not a member of this workspace",
|
||||
assignee_id
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate milestone_id belongs to a repo in this workspace
|
||||
if let Some(milestone_id) = params.milestone_id {
|
||||
let milestone: Option<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT r.workspace_id FROM issue_milestone im \
|
||||
JOIN repo r ON r.id = im.repo_id \
|
||||
WHERE im.id = $1",
|
||||
)
|
||||
.bind(milestone_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
match milestone {
|
||||
Some((workspace_id,)) if workspace_id == ws.id => {}
|
||||
Some(_) => {
|
||||
return Err(AppError::BadRequest(
|
||||
"Milestone does not belong to this workspace".into(),
|
||||
));
|
||||
}
|
||||
None => return Err(AppError::NotFound("Milestone not found".into())),
|
||||
}
|
||||
}
|
||||
|
||||
let priority = match params.priority {
|
||||
Some(ref v) => parse_enum(
|
||||
Some(v.clone()),
|
||||
Priority::None,
|
||||
Priority::Unknown,
|
||||
"priority",
|
||||
)?,
|
||||
None => Priority::None,
|
||||
};
|
||||
let visibility = match params.visibility {
|
||||
Some(ref v) => parse_enum(
|
||||
Some(v.clone()),
|
||||
Visibility::Public,
|
||||
Visibility::Unknown,
|
||||
"visibility",
|
||||
)?,
|
||||
None => Visibility::Public,
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let issue_id = Uuid::now_v7();
|
||||
|
||||
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 number = Issue::next_number(&mut *txn, ws.id)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let issue = sqlx::query_as::<_, Issue>(
|
||||
"INSERT INTO issue (id, workspace_id, author_id, number, title, body, state, priority, \
|
||||
visibility, locked, milestone_id, closed_by, closed_at, due_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'open', $7, $8, false, $9, NULL, NULL, $10, $11, $11) \
|
||||
RETURNING id, workspace_id, author_id, number, title, body, state, priority, \
|
||||
visibility, locked, milestone_id, closed_by, closed_at, due_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(issue_id)
|
||||
.bind(ws.id)
|
||||
.bind(user_uid)
|
||||
.bind(number)
|
||||
.bind(&title)
|
||||
.bind(params.body.as_deref())
|
||||
.bind(priority)
|
||||
.bind(visibility)
|
||||
.bind(params.milestone_id)
|
||||
.bind(params.due_at)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO issue_stats (issue_id, comments_count, reactions_count, assignees_count, \
|
||||
labels_count, subscribers_count, last_commented_at, updated_at) \
|
||||
VALUES ($1, 0, 0, $2, $3, 1, NULL, $4)",
|
||||
)
|
||||
.bind(issue_id)
|
||||
.bind(params.assignee_ids.len() as i32)
|
||||
.bind(params.label_ids.len() as i32)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO issue_subscriber (id, issue_id, user_id, reason, muted, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, 'author', false, $4, $4)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(issue_id)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
for repo_id in ¶ms.repo_ids {
|
||||
sqlx::query(
|
||||
"INSERT INTO issue_repo_relation (id, issue_id, repo_id, relation_type, created_by, created_at) \
|
||||
VALUES ($1, $2, $3, 'references', $4, $5)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(issue_id)
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
for label_id in ¶ms.label_ids {
|
||||
sqlx::query(
|
||||
"INSERT INTO issue_label_relation (id, issue_id, label_id, created_by, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(issue_id)
|
||||
.bind(label_id)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
for assignee_id in ¶ms.assignee_ids {
|
||||
sqlx::query(
|
||||
"INSERT INTO issue_assignee (id, issue_id, assignee_id, assigned_by, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(issue_id)
|
||||
.bind(assignee_id)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE workspace_stats SET issues_count = issues_count + 1, updated_at = $1 \
|
||||
WHERE workspace_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(ws.id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
self.record_issue_event(issue_id, Some(user_uid), EventType::Created)
|
||||
.await;
|
||||
tracing::info!(issue_id = %issue_id, number = number, "Issue created");
|
||||
Ok(issue)
|
||||
}
|
||||
|
||||
pub async fn issue_update(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
params: UpdateIssueParams,
|
||||
) -> Result<Issue, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_editable(user_uid, &issue).await?;
|
||||
|
||||
let title = merge_optional_text(params.title, Some(issue.title.clone()))
|
||||
.unwrap_or(issue.title.clone());
|
||||
let body = merge_optional_text(params.body, issue.body.clone());
|
||||
let priority = match params.priority {
|
||||
Some(ref v) => parse_enum(
|
||||
Some(v.clone()),
|
||||
issue.priority,
|
||||
Priority::Unknown,
|
||||
"priority",
|
||||
)?,
|
||||
None => issue.priority,
|
||||
};
|
||||
let visibility = match params.visibility {
|
||||
Some(ref v) => parse_enum(
|
||||
Some(v.clone()),
|
||||
issue.visibility,
|
||||
Visibility::Unknown,
|
||||
"visibility",
|
||||
)?,
|
||||
None => issue.visibility,
|
||||
};
|
||||
|
||||
if let Some(milestone_id) = params.milestone_id {
|
||||
self.ensure_milestone_in_workspace(milestone_id, issue.workspace_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)?;
|
||||
|
||||
let result = sqlx::query_as::<_, Issue>(
|
||||
"UPDATE issue SET title = $1, body = $2, priority = $3, visibility = $4, \
|
||||
due_at = $5, milestone_id = $6, updated_at = $7 WHERE id = $8 AND deleted_at IS NULL \
|
||||
RETURNING id, workspace_id, author_id, number, title, body, state, priority, \
|
||||
visibility, locked, milestone_id, closed_by, closed_at, due_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(&title)
|
||||
.bind(&body)
|
||||
.bind(priority)
|
||||
.bind(visibility)
|
||||
.bind(params.due_at.or(issue.due_at))
|
||||
.bind(params.milestone_id.or(issue.milestone_id))
|
||||
.bind(now)
|
||||
.bind(issue_id)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
self.record_issue_event(issue_id, Some(user_uid), EventType::Updated)
|
||||
.await;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn issue_close(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
) -> Result<Issue, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_editable(user_uid, &issue).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_as::<_, Issue>(
|
||||
"UPDATE issue SET state = 'closed', closed_by = $1, closed_at = $2, updated_at = $2 \
|
||||
WHERE id = $3 AND deleted_at IS NULL AND state = 'open' \
|
||||
RETURNING id, workspace_id, author_id, number, title, body, state, priority, \
|
||||
visibility, locked, milestone_id, closed_by, closed_at, due_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.bind(issue_id)
|
||||
.fetch_optional(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("issue not found or already closed".into()))?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
self.record_issue_event(issue_id, Some(user_uid), EventType::Closed)
|
||||
.await;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn issue_reopen(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
) -> Result<Issue, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_editable(user_uid, &issue).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_as::<_, Issue>(
|
||||
"UPDATE issue SET state = 'open', closed_by = NULL, closed_at = NULL, updated_at = $1 \
|
||||
WHERE id = $2 AND deleted_at IS NULL AND state = 'closed' \
|
||||
RETURNING id, workspace_id, author_id, number, title, body, state, priority, \
|
||||
visibility, locked, milestone_id, closed_by, closed_at, due_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(issue_id)
|
||||
.fetch_optional(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("issue not found or not closed".into()))?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
self.record_issue_event(issue_id, Some(user_uid), EventType::Reopened)
|
||||
.await;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn issue_delete(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_admin(user_uid, &issue).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 issue SET deleted_at = $1, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(issue_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "issue not found")?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE workspace_stats SET issues_count = GREATEST(issues_count - 1, 0), updated_at = $1 \
|
||||
WHERE workspace_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(issue.workspace_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
self.record_issue_event(issue_id, Some(user_uid), EventType::Deleted)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn issue_lock(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
locked: bool,
|
||||
) -> Result<Issue, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_editable(user_uid, &issue).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_as::<_, Issue>(
|
||||
"UPDATE issue SET locked = $1, updated_at = $2 WHERE id = $3 AND deleted_at IS NULL \
|
||||
RETURNING id, workspace_id, author_id, number, title, body, state, priority, \
|
||||
visibility, locked, milestone_id, closed_by, closed_at, due_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(locked)
|
||||
.bind(now)
|
||||
.bind(issue_id)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
let event_type = if locked {
|
||||
EventType::Archived
|
||||
} else {
|
||||
EventType::Restored
|
||||
};
|
||||
self.record_issue_event(issue_id, Some(user_uid), event_type)
|
||||
.await;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn issue_transfer(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
new_wk_name: &str,
|
||||
) -> Result<Issue, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_admin(user_uid, &issue).await?;
|
||||
let new_ws = self.resolve_workspace(new_wk_name).await?;
|
||||
self.ensure_workspace_role_at_least(new_wk_name, user_uid, 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 app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let new_number = Issue::next_number(&mut *txn, new_ws.id)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query_as::<_, Issue>(
|
||||
"UPDATE issue SET workspace_id = $1, number = $2, updated_at = $3 WHERE id = $4 AND deleted_at IS NULL \
|
||||
RETURNING id, workspace_id, author_id, number, title, body, state, priority, \
|
||||
visibility, locked, milestone_id, closed_by, closed_at, due_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(new_ws.id)
|
||||
.bind(new_number)
|
||||
.bind(now)
|
||||
.bind(issue_id)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE workspace_stats SET issues_count = GREATEST(issues_count - 1, 0), updated_at = $1 WHERE workspace_id = $2",
|
||||
).bind(now).bind(issue.workspace_id).execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE workspace_stats SET issues_count = issues_count + 1, updated_at = $1 WHERE workspace_id = $2",
|
||||
).bind(now).bind(new_ws.id).execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
self.record_issue_event(issue_id, Some(user_uid), EventType::Updated)
|
||||
.await;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn record_issue_event(
|
||||
&self,
|
||||
issue_id: Uuid,
|
||||
actor_id: Option<Uuid>,
|
||||
event_type: EventType,
|
||||
) {
|
||||
if let Err(err) = self
|
||||
.create_issue_event(issue_id, actor_id, event_type, None, None, None)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(issue_id = %issue_id, error = %err, "Failed to create issue event");
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_milestone_in_workspace(
|
||||
&self,
|
||||
milestone_id: Uuid,
|
||||
workspace_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let milestone_workspace_id: Option<Uuid> = sqlx::query_scalar(
|
||||
"SELECT r.workspace_id FROM issue_milestone im \
|
||||
JOIN repo r ON r.id = im.repo_id \
|
||||
WHERE im.id = $1",
|
||||
)
|
||||
.bind(milestone_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
match milestone_workspace_id {
|
||||
Some(id) if id == workspace_id => Ok(()),
|
||||
Some(_) => Err(AppError::BadRequest(
|
||||
"Milestone does not belong to this workspace".into(),
|
||||
)),
|
||||
None => Err(AppError::NotFound("Milestone not found".into())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_workspace(&self, wk_name: &str) -> Result<Workspace, AppError> {
|
||||
Workspace::find_by_name(self.ctx.db.reader(), wk_name)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("workspace not found".into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_issue(
|
||||
&self,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
) -> Result<Issue, AppError> {
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
Issue::find_by_number(self.ctx.db.reader(), ws.id, number)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("issue not found".into()))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn find_issue_by_id(&self, issue_id: Uuid) -> Result<Issue, AppError> {
|
||||
Issue::find_by_id(self.ctx.db.reader(), issue_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("issue not found".into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_issue_readable(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
issue: &Issue,
|
||||
) -> Result<(), AppError> {
|
||||
if issue.author_id == user_uid {
|
||||
return Ok(());
|
||||
}
|
||||
let is_member = Workspace::is_member(self.ctx.db.reader(), issue.workspace_id, user_uid)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if is_member {
|
||||
return Ok(());
|
||||
}
|
||||
if issue.visibility == Visibility::Public {
|
||||
return Ok(());
|
||||
}
|
||||
Err(AppError::Unauthorized)
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_issue_editable(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
issue: &Issue,
|
||||
) -> Result<(), AppError> {
|
||||
if issue.author_id == user_uid {
|
||||
return Ok(());
|
||||
}
|
||||
let role = Workspace::user_role(
|
||||
self.ctx.db.reader(),
|
||||
issue.workspace_id,
|
||||
user_uid,
|
||||
Uuid::nil(),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.unwrap_or(Role::Unknown);
|
||||
if crate::service::util::role_level(role) >= crate::service::util::role_level(Role::Member)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(AppError::Unauthorized)
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_issue_admin(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
issue: &Issue,
|
||||
) -> Result<(), AppError> {
|
||||
let role = Workspace::user_role(
|
||||
self.ctx.db.reader(),
|
||||
issue.workspace_id,
|
||||
user_uid,
|
||||
Uuid::nil(),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.unwrap_or(Role::Unknown);
|
||||
if crate::service::util::role_level(role) >= crate::service::util::role_level(Role::Admin) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(AppError::Unauthorized)
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_workspace_readable(
|
||||
&self,
|
||||
wk_name: &str,
|
||||
user_uid: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
if Workspace::is_readable(self.ctx.db.reader(), &ws, user_uid)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Unauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_workspace_role_at_least(
|
||||
&self,
|
||||
wk_name: &str,
|
||||
user_uid: Uuid,
|
||||
min_role: Role,
|
||||
) -> Result<Role, AppError> {
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let role = Workspace::user_role(self.ctx.db.reader(), ws.id, user_uid, ws.owner_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.unwrap_or(Role::Unknown);
|
||||
if crate::service::util::role_level(role) < crate::service::util::role_level(min_role) {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
Ok(role)
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_repo_readable(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
repo: &crate::models::repos::Repo,
|
||||
) -> Result<(), AppError> {
|
||||
use crate::models::repos::Repo;
|
||||
|
||||
if Repo::is_readable(self.ctx.db.reader(), repo, user_uid)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Unauthorized)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::{EventType, JsonValue};
|
||||
use crate::models::issues::IssueEvent;
|
||||
use crate::service::IssueService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::clamp_limit_offset;
|
||||
|
||||
impl IssueService {
|
||||
pub async fn issue_list_events(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<IssueEvent>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
self.ensure_issue_readable(user_uid, &issue).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
|
||||
sqlx::query_as::<_, IssueEvent>(
|
||||
"SELECT id, issue_id, actor_id, event_type, old_value, new_value, metadata, created_at \
|
||||
FROM issue_event WHERE issue_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(issue.id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub(crate) async fn create_issue_event(
|
||||
&self,
|
||||
issue_id: Uuid,
|
||||
actor_id: Option<Uuid>,
|
||||
event_type: EventType,
|
||||
old_value: Option<JsonValue>,
|
||||
new_value: Option<JsonValue>,
|
||||
metadata: Option<JsonValue>,
|
||||
) -> Result<IssueEvent, AppError> {
|
||||
sqlx::query_as::<_, IssueEvent>(
|
||||
"INSERT INTO issue_event (id, issue_id, actor_id, event_type, old_value, new_value, metadata, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
|
||||
RETURNING id, issue_id, actor_id, event_type, old_value, new_value, metadata, created_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(issue_id)
|
||||
.bind(actor_id)
|
||||
.bind(event_type)
|
||||
.bind(old_value)
|
||||
.bind(new_value)
|
||||
.bind(metadata)
|
||||
.bind(chrono::Utc::now())
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::issues::{IssueLabel, IssueLabelRelation};
|
||||
use crate::service::IssueService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, required_text};
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateLabelParams {
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateLabelParams {
|
||||
pub name: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl IssueService {
|
||||
pub(crate) async fn resolve_repo_id(
|
||||
&self,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<Uuid, AppError> {
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
crate::models::repos::Repo::find_by_name(self.ctx.db.reader(), ws.id, repo_name)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.map(|r| r.id)
|
||||
.ok_or(AppError::NotFound("repo not found".into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_repo_role(
|
||||
&self,
|
||||
repo_id: Uuid,
|
||||
user_uid: Uuid,
|
||||
min: Role,
|
||||
) -> Result<(), AppError> {
|
||||
let repo = crate::models::repos::Repo::find_by_id(self.ctx.db.reader(), repo_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("repo not found".into()))?;
|
||||
let role = crate::models::repos::Repo::user_role(
|
||||
self.ctx.db.reader(),
|
||||
repo.id,
|
||||
user_uid,
|
||||
repo.owner_id,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.unwrap_or(Role::Unknown);
|
||||
if crate::service::util::role_level(role) < crate::service::util::role_level(min) {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn issue_labels(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<Vec<IssueLabel>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo_id = self.resolve_repo_id(wk_name, repo_name).await?;
|
||||
let repo = crate::models::repos::Repo::find_by_id(self.ctx.db.reader(), repo_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("repo not found".into()))?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
sqlx::query_as::<_, IssueLabel>(
|
||||
"SELECT id, repo_id, name, color, description, created_by, created_at, updated_at \
|
||||
FROM issue_label WHERE repo_id = $1 ORDER BY name ASC",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn issue_create_label(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateLabelParams,
|
||||
) -> Result<IssueLabel, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo_id = self.resolve_repo_id(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role(repo_id, user_uid, Role::Member)
|
||||
.await?;
|
||||
let name = required_text(params.name, "name")?;
|
||||
let color = required_text(params.color, "color")?;
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, IssueLabel>(
|
||||
"INSERT INTO issue_label (id, repo_id, name, color, description, created_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $7) \
|
||||
RETURNING id, repo_id, name, color, description, created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7()).bind(repo_id).bind(&name).bind(&color)
|
||||
.bind(params.description.as_deref()).bind(user_uid).bind(now)
|
||||
.fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn issue_update_label(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
label_id: Uuid,
|
||||
params: UpdateLabelParams,
|
||||
) -> Result<IssueLabel, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo_id = self.resolve_repo_id(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role(repo_id, user_uid, Role::Admin)
|
||||
.await?;
|
||||
let current = sqlx::query_as::<_, IssueLabel>(
|
||||
"SELECT id, repo_id, name, color, description, created_by, created_at, updated_at \
|
||||
FROM issue_label WHERE id = $1 AND repo_id = $2",
|
||||
)
|
||||
.bind(label_id)
|
||||
.bind(repo_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("label not found".into()))?;
|
||||
|
||||
let name = params.name.unwrap_or(current.name);
|
||||
let color = params.color.unwrap_or(current.color);
|
||||
let description = super::util::merge_optional_text(params.description, current.description);
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, IssueLabel>(
|
||||
"UPDATE issue_label SET name = $1, color = $2, description = $3, updated_at = $4 \
|
||||
WHERE id = $5 RETURNING id, repo_id, name, color, description, created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(&name).bind(&color).bind(&description).bind(now).bind(label_id)
|
||||
.fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn issue_delete_label(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
label_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo_id = self.resolve_repo_id(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role(repo_id, user_uid, Role::Admin)
|
||||
.await?;
|
||||
let result = sqlx::query("DELETE FROM issue_label WHERE id = $1 AND repo_id = $2")
|
||||
.bind(label_id)
|
||||
.bind(repo_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "label not found")
|
||||
}
|
||||
|
||||
pub async fn issue_assign_label(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
label_id: Uuid,
|
||||
) -> Result<IssueLabelRelation, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_editable(user_uid, &issue).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 rel = sqlx::query_as::<_, IssueLabelRelation>(
|
||||
"INSERT INTO issue_label_relation (id, issue_id, label_id, created_by, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (issue_id, label_id) DO NOTHING \
|
||||
RETURNING id, issue_id, label_id, created_by, created_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(issue_id)
|
||||
.bind(label_id)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.fetch_optional(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::Conflict("label already assigned".into()))?;
|
||||
sqlx::query("UPDATE issue_stats SET labels_count = labels_count + 1, updated_at = $1 WHERE issue_id = $2")
|
||||
.bind(now).bind(issue_id).execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(rel)
|
||||
}
|
||||
|
||||
pub async fn issue_unassign_label(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
label_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_editable(user_uid, &issue).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 issue_label_relation WHERE issue_id = $1 AND label_id = $2")
|
||||
.bind(issue_id)
|
||||
.bind(label_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "label not assigned")?;
|
||||
sqlx::query("UPDATE issue_stats SET labels_count = GREATEST(labels_count - 1, 0), updated_at = $1 WHERE issue_id = $2")
|
||||
.bind(now).bind(issue_id).execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn issue_label_relations(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<IssueLabelRelation>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_readable(user_uid, &issue).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, IssueLabelRelation>(
|
||||
"SELECT id, issue_id, label_id, created_by, created_at \
|
||||
FROM issue_label_relation WHERE issue_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(issue_id).bind(limit).bind(offset)
|
||||
.fetch_all(self.ctx.db.reader()).await.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::{Role, State};
|
||||
use crate::models::issues::IssueMilestone;
|
||||
use crate::service::IssueService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, merge_optional_text, required_text};
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateMilestoneParams {
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub due_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateMilestoneParams {
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub due_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub state: Option<String>,
|
||||
}
|
||||
|
||||
impl IssueService {
|
||||
pub async fn issue_milestones(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<IssueMilestone>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo_id = self.resolve_repo_id(wk_name, repo_name).await?;
|
||||
let repo = crate::models::repos::Repo::find_by_id(self.ctx.db.reader(), repo_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("repo not found".into()))?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, IssueMilestone>(
|
||||
"SELECT id, repo_id, title, description, state, due_at, closed_at, created_by, \
|
||||
created_at, updated_at FROM issue_milestone WHERE repo_id = $1 \
|
||||
ORDER BY state ASC, due_at ASC NULLS LAST 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 issue_create_milestone(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateMilestoneParams,
|
||||
) -> Result<IssueMilestone, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo_id = self.resolve_repo_id(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role(repo_id, user_uid, Role::Member)
|
||||
.await?;
|
||||
let title = required_text(params.title, "title")?;
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, IssueMilestone>(
|
||||
"INSERT INTO issue_milestone (id, repo_id, title, description, state, due_at, closed_at, \
|
||||
created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, 'open', $5, NULL, $6, $7, $7) \
|
||||
RETURNING id, repo_id, title, description, state, due_at, closed_at, created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7()).bind(repo_id).bind(&title).bind(params.description.as_deref())
|
||||
.bind(params.due_at).bind(user_uid).bind(now)
|
||||
.fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn issue_update_milestone(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
milestone_id: Uuid,
|
||||
params: UpdateMilestoneParams,
|
||||
) -> Result<IssueMilestone, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo_id = self.resolve_repo_id(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role(repo_id, user_uid, Role::Member)
|
||||
.await?;
|
||||
let current = sqlx::query_as::<_, IssueMilestone>(
|
||||
"SELECT id, repo_id, title, description, state, due_at, closed_at, created_by, created_at, updated_at \
|
||||
FROM issue_milestone WHERE id = $1 AND repo_id = $2",
|
||||
)
|
||||
.bind(milestone_id).bind(repo_id).fetch_optional(self.ctx.db.reader()).await
|
||||
.map_err(AppError::Database)?.ok_or(AppError::NotFound("milestone not found".into()))?;
|
||||
|
||||
let title = params.title.unwrap_or(current.title);
|
||||
let description = merge_optional_text(params.description, current.description);
|
||||
let due_at = params.due_at.or(current.due_at);
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let (state, closed_at) = match params.state.as_deref() {
|
||||
Some("closed") if current.state != State::Closed => (State::Closed, Some(now)),
|
||||
Some("open") if current.state != State::Open => (State::Open, None),
|
||||
_ => (current.state, current.closed_at),
|
||||
};
|
||||
|
||||
sqlx::query_as::<_, IssueMilestone>(
|
||||
"UPDATE issue_milestone SET title = $1, description = $2, state = $3, due_at = $4, \
|
||||
closed_at = $5, updated_at = $6 WHERE id = $7 \
|
||||
RETURNING id, repo_id, title, description, state, due_at, closed_at, created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(&title).bind(&description).bind(state).bind(due_at).bind(closed_at).bind(now).bind(milestone_id)
|
||||
.fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn issue_delete_milestone(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
milestone_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo_id = self.resolve_repo_id(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role(repo_id, user_uid, Role::Admin)
|
||||
.await?;
|
||||
let result = sqlx::query("DELETE FROM issue_milestone WHERE id = $1 AND repo_id = $2")
|
||||
.bind(milestone_id)
|
||||
.bind(repo_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "milestone not found")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
pub mod assignees;
|
||||
pub mod comments;
|
||||
pub mod core;
|
||||
pub mod events;
|
||||
pub mod labels;
|
||||
pub mod milestones;
|
||||
pub mod pr_relations;
|
||||
pub mod reactions;
|
||||
pub mod repo_relations;
|
||||
pub mod subscribers;
|
||||
pub mod templates;
|
||||
pub mod util;
|
||||
@@ -0,0 +1,122 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::RelationType;
|
||||
use crate::models::issues::IssuePrRelation;
|
||||
use crate::service::IssueService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, parse_enum};
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct LinkPrParams {
|
||||
pub pull_request_id: Uuid,
|
||||
pub relation_type: Option<String>,
|
||||
}
|
||||
|
||||
impl IssueService {
|
||||
pub async fn issue_pr_relations(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<IssuePrRelation>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_readable(user_uid, &issue).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, IssuePrRelation>(
|
||||
"SELECT id, issue_id, pull_request_id, relation_type, created_by, created_at \
|
||||
FROM issue_pr_relation WHERE issue_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(issue_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn issue_link_pr(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
params: LinkPrParams,
|
||||
) -> Result<IssuePrRelation, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_editable(user_uid, &issue).await?;
|
||||
let relation_type = match params.relation_type {
|
||||
Some(ref v) => parse_enum(
|
||||
Some(v.clone()),
|
||||
RelationType::References,
|
||||
RelationType::Unknown,
|
||||
"relation_type",
|
||||
)?,
|
||||
None => RelationType::References,
|
||||
};
|
||||
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 rel = sqlx::query_as::<_, IssuePrRelation>(
|
||||
"INSERT INTO issue_pr_relation (id, issue_id, pull_request_id, relation_type, created_by, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (issue_id, pull_request_id) DO NOTHING \
|
||||
RETURNING id, issue_id, pull_request_id, relation_type, created_by, created_at",
|
||||
)
|
||||
.bind(Uuid::now_v7()).bind(issue_id).bind(params.pull_request_id)
|
||||
.bind(relation_type).bind(user_uid).bind(now)
|
||||
.fetch_optional(&mut *txn).await.map_err(AppError::Database)?
|
||||
.ok_or(AppError::Conflict("PR already linked".into()))?;
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(rel)
|
||||
}
|
||||
|
||||
pub async fn issue_unlink_pr(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
relation_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_editable(user_uid, &issue).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 result = sqlx::query("DELETE FROM issue_pr_relation WHERE id = $1 AND issue_id = $2")
|
||||
.bind(relation_id)
|
||||
.bind(issue_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "PR relation not found")?;
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::TargetType;
|
||||
use crate::models::issues::IssueReaction;
|
||||
use crate::service::IssueService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, required_text};
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateIssueReactionParams {
|
||||
pub content: String,
|
||||
pub target_type: Option<String>,
|
||||
pub target_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl IssueService {
|
||||
pub async fn issue_reactions(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<IssueReaction>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
self.ensure_issue_readable(user_uid, &issue).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
|
||||
sqlx::query_as::<_, IssueReaction>(
|
||||
"SELECT id, issue_id, user_id, content, target_type, target_id, created_at \
|
||||
FROM issue_reaction WHERE issue_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(issue.id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn issue_add_reaction(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
params: CreateIssueReactionParams,
|
||||
) -> Result<IssueReaction, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
self.ensure_issue_readable(user_uid, &issue).await?;
|
||||
|
||||
let content = required_text(params.content, "content")?;
|
||||
let target_type = params
|
||||
.target_type
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse::<TargetType>().ok())
|
||||
.unwrap_or(TargetType::Issue);
|
||||
if target_type == TargetType::Unknown {
|
||||
return Err(AppError::BadRequest("invalid target_type".into()));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, IssueReaction>(
|
||||
"INSERT INTO issue_reaction (id, issue_id, user_id, content, target_type, target_id, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, issue_id, user_id, content, target_type, target_id, created_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(issue.id)
|
||||
.bind(user_uid)
|
||||
.bind(&content)
|
||||
.bind(target_type)
|
||||
.bind(params.target_id)
|
||||
.bind(now)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn issue_remove_reaction(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
reaction_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
self.ensure_issue_readable(user_uid, &issue).await?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM issue_reaction WHERE id = $1 AND issue_id = $2 AND user_id = $3",
|
||||
)
|
||||
.bind(reaction_id)
|
||||
.bind(issue.id)
|
||||
.bind(user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(
|
||||
result.rows_affected(),
|
||||
"reaction not found or not authored by you",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::RelationType;
|
||||
use crate::models::issues::IssueRepoRelation;
|
||||
use crate::service::IssueService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, parse_enum};
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct LinkRepoParams {
|
||||
pub repo_id: Uuid,
|
||||
pub relation_type: Option<String>,
|
||||
}
|
||||
|
||||
impl IssueService {
|
||||
pub async fn issue_repo_relations(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<IssueRepoRelation>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_readable(user_uid, &issue).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, IssueRepoRelation>(
|
||||
"SELECT id, issue_id, repo_id, relation_type, created_by, created_at \
|
||||
FROM issue_repo_relation WHERE issue_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(issue_id).bind(limit).bind(offset)
|
||||
.fetch_all(self.ctx.db.reader()).await.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn issue_link_repo(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
params: LinkRepoParams,
|
||||
) -> Result<IssueRepoRelation, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_editable(user_uid, &issue).await?;
|
||||
let relation_type = match params.relation_type {
|
||||
Some(ref v) => parse_enum(
|
||||
Some(v.clone()),
|
||||
RelationType::References,
|
||||
RelationType::Unknown,
|
||||
"relation_type",
|
||||
)?,
|
||||
None => RelationType::References,
|
||||
};
|
||||
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 rel = sqlx::query_as::<_, IssueRepoRelation>(
|
||||
"INSERT INTO issue_repo_relation (id, issue_id, repo_id, relation_type, created_by, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (issue_id, repo_id) DO NOTHING \
|
||||
RETURNING id, issue_id, repo_id, relation_type, created_by, created_at",
|
||||
)
|
||||
.bind(Uuid::now_v7()).bind(issue_id).bind(params.repo_id)
|
||||
.bind(relation_type).bind(user_uid).bind(now)
|
||||
.fetch_optional(&mut *txn).await.map_err(AppError::Database)?
|
||||
.ok_or(AppError::Conflict("repo already linked".into()))?;
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(rel)
|
||||
}
|
||||
|
||||
pub async fn issue_unlink_repo(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
relation_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_editable(user_uid, &issue).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 result = sqlx::query("DELETE FROM issue_repo_relation WHERE id = $1 AND issue_id = $2")
|
||||
.bind(relation_id)
|
||||
.bind(issue_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "repo relation not found")?;
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::issues::IssueSubscriber;
|
||||
use crate::service::IssueService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected};
|
||||
|
||||
impl IssueService {
|
||||
pub async fn issue_subscribers(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<IssueSubscriber>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_readable(user_uid, &issue).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, IssueSubscriber>(
|
||||
"SELECT id, issue_id, user_id, reason, muted, created_at, updated_at \
|
||||
FROM issue_subscriber WHERE issue_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(issue_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn issue_subscribe(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
) -> Result<IssueSubscriber, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
self.ensure_issue_readable(user_uid, &issue).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 sub = sqlx::query_as::<_, IssueSubscriber>(
|
||||
"INSERT INTO issue_subscriber (id, issue_id, user_id, reason, muted, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, 'manual', false, $4, $4) ON CONFLICT (issue_id, user_id) DO NOTHING \
|
||||
RETURNING id, issue_id, user_id, reason, muted, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7()).bind(issue_id).bind(user_uid).bind(now)
|
||||
.fetch_optional(&mut *txn).await.map_err(AppError::Database)?
|
||||
.ok_or(AppError::Conflict("already subscribed".into()))?;
|
||||
sqlx::query("UPDATE issue_stats SET subscribers_count = subscribers_count + 1, updated_at = $1 WHERE issue_id = $2")
|
||||
.bind(now).bind(issue_id).execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(sub)
|
||||
}
|
||||
|
||||
pub async fn issue_unsubscribe(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
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 issue_subscriber WHERE issue_id = $1 AND user_id = $2")
|
||||
.bind(issue_id)
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "not subscribed")?;
|
||||
sqlx::query("UPDATE issue_stats SET subscribers_count = GREATEST(subscribers_count - 1, 0), updated_at = $1 WHERE issue_id = $2")
|
||||
.bind(now).bind(issue_id).execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn issue_mute(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
number: i64,
|
||||
muted: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let issue = self.resolve_issue(wk_name, number).await?;
|
||||
let issue_id = issue.id;
|
||||
let result = sqlx::query(
|
||||
"UPDATE issue_subscriber SET muted = $1, updated_at = $2 WHERE issue_id = $3 AND user_id = $4",
|
||||
)
|
||||
.bind(muted).bind(chrono::Utc::now()).bind(issue_id).bind(user_uid)
|
||||
.execute(self.ctx.db.writer()).await.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "not subscribed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::issues::IssueTemplate;
|
||||
use crate::service::IssueService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, required_text};
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateTemplateParams {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub title_template: Option<String>,
|
||||
pub body_template: String,
|
||||
pub labels: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateTemplateParams {
|
||||
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 IssueService {
|
||||
pub async fn issue_templates(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<IssueTemplate>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo_id = self.resolve_repo_id(wk_name, repo_name).await?;
|
||||
let repo = crate::models::repos::Repo::find_by_id(self.ctx.db.reader(), repo_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("repo not found".into()))?;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, IssueTemplate>(
|
||||
"SELECT id, repo_id, name, description, title_template, body_template, labels, \
|
||||
active, created_by, created_at, updated_at \
|
||||
FROM issue_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 issue_create_template(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateTemplateParams,
|
||||
) -> Result<IssueTemplate, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo_id = self.resolve_repo_id(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role(repo_id, user_uid, Role::Member)
|
||||
.await?;
|
||||
let name = required_text(params.name, "name")?;
|
||||
let body_template = required_text(params.body_template, "body_template")?;
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, IssueTemplate>(
|
||||
"INSERT INTO issue_template (id, repo_id, name, description, title_template, body_template, \
|
||||
labels, active, created_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, true, $8, $9, $9) \
|
||||
RETURNING id, repo_id, name, description, title_template, body_template, labels, \
|
||||
active, created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7()).bind(repo_id).bind(&name).bind(params.description.as_deref())
|
||||
.bind(params.title_template.as_deref()).bind(&body_template)
|
||||
.bind(sqlx::types::Json(¶ms.labels)).bind(user_uid).bind(now)
|
||||
.fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn issue_update_template(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
template_id: Uuid,
|
||||
params: UpdateTemplateParams,
|
||||
) -> Result<IssueTemplate, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let repo_id = self.resolve_repo_id(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role(repo_id, user_uid, Role::Admin)
|
||||
.await?;
|
||||
let current = sqlx::query_as::<_, IssueTemplate>(
|
||||
"SELECT id, repo_id, name, description, title_template, body_template, labels, \
|
||||
active, created_by, created_at, updated_at \
|
||||
FROM issue_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 = params.name.unwrap_or(current.name);
|
||||
let description = params.description.or(current.description);
|
||||
let title_template = params.title_template.or(current.title_template);
|
||||
let body_template = params.body_template.unwrap_or(current.body_template);
|
||||
let labels = params.labels.unwrap_or(current.labels);
|
||||
let active = params.active.unwrap_or(current.active);
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
sqlx::query_as::<_, IssueTemplate>(
|
||||
"UPDATE issue_template SET name = $1, description = $2, title_template = $3, \
|
||||
body_template = $4, labels = $5, active = $6, updated_at = $7 WHERE id = $8 \
|
||||
RETURNING id, repo_id, name, description, title_template, body_template, labels, \
|
||||
active, created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(&name)
|
||||
.bind(&description)
|
||||
.bind(&title_template)
|
||||
.bind(&body_template)
|
||||
.bind(sqlx::types::Json(&labels))
|
||||
.bind(active)
|
||||
.bind(now)
|
||||
.bind(template_id)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn issue_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_id = self.resolve_repo_id(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role(repo_id, user_uid, Role::Admin)
|
||||
.await?;
|
||||
let result = sqlx::query("DELETE FROM issue_template WHERE id = $1 AND repo_id = $2")
|
||||
.bind(template_id)
|
||||
.bind(repo_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "template not found")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub use crate::service::util::{
|
||||
clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text, role_level,
|
||||
};
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cache::AppCache;
|
||||
use crate::cache::redis::AppRedis;
|
||||
use crate::config::AppConfig;
|
||||
use crate::etcd::EtcdRegistry;
|
||||
use crate::models::db::AppDatabase;
|
||||
use crate::queue::NatsQueue;
|
||||
use crate::service::im::events::ImEventBus;
|
||||
use crate::storage::s3::AppS3Storage;
|
||||
|
||||
pub mod context;
|
||||
pub mod util;
|
||||
|
||||
pub mod auth;
|
||||
pub mod im;
|
||||
pub mod issues;
|
||||
pub mod notify;
|
||||
pub mod pr;
|
||||
pub mod repo;
|
||||
pub mod user;
|
||||
pub mod wiki;
|
||||
pub mod workspace;
|
||||
|
||||
pub use context::ServiceContext;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthService {
|
||||
pub ctx: Arc<ServiceContext>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UserService {
|
||||
pub ctx: Arc<ServiceContext>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct WorkspaceService {
|
||||
pub ctx: Arc<ServiceContext>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RepoService {
|
||||
pub ctx: Arc<ServiceContext>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct IssueService {
|
||||
pub ctx: Arc<ServiceContext>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PrService {
|
||||
pub ctx: Arc<ServiceContext>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct NotificationService {
|
||||
pub ctx: Arc<ServiceContext>,
|
||||
}
|
||||
|
||||
pub use im::ImService;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppService {
|
||||
pub auth: AuthService,
|
||||
pub user: UserService,
|
||||
pub workspace: WorkspaceService,
|
||||
pub repo: RepoService,
|
||||
pub issue: IssueService,
|
||||
pub pr: PrService,
|
||||
pub notify: NotificationService,
|
||||
pub im: ImService,
|
||||
pub ctx: Arc<ServiceContext>,
|
||||
}
|
||||
|
||||
impl AppService {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
version: String,
|
||||
db: AppDatabase,
|
||||
redis: AppRedis,
|
||||
cache: Arc<AppCache>,
|
||||
config: AppConfig,
|
||||
storage: AppS3Storage,
|
||||
registry: Arc<EtcdRegistry>,
|
||||
nats: Arc<NatsQueue>,
|
||||
) -> Self {
|
||||
let ctx = Arc::new(ServiceContext {
|
||||
version,
|
||||
db,
|
||||
redis,
|
||||
cache,
|
||||
config,
|
||||
storage,
|
||||
registry,
|
||||
nats,
|
||||
im_events: Arc::new(ImEventBus::default()),
|
||||
});
|
||||
|
||||
Self {
|
||||
auth: AuthService { ctx: ctx.clone() },
|
||||
user: UserService { ctx: ctx.clone() },
|
||||
workspace: WorkspaceService { ctx: ctx.clone() },
|
||||
repo: RepoService { ctx: ctx.clone() },
|
||||
issue: IssueService { ctx: ctx.clone() },
|
||||
pr: PrService { ctx: ctx.clone() },
|
||||
notify: NotificationService { ctx: ctx.clone() },
|
||||
im: ImService { ctx: ctx.clone() },
|
||||
ctx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct Pager {
|
||||
pub page: i64,
|
||||
pub per_page: i64,
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::{DeliveryChannel, NotificationType, TargetType};
|
||||
use crate::models::notifications::NotificationBlock;
|
||||
use crate::service::NotificationService;
|
||||
use crate::session::Session;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::util::clamp_limit_offset;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct CreateBlockParams {
|
||||
pub workspace_id: Option<Uuid>,
|
||||
pub repo_id: Option<Uuid>,
|
||||
pub target_type: TargetType,
|
||||
pub target_id: Option<Uuid>,
|
||||
pub notification_type: Option<NotificationType>,
|
||||
pub channel: Option<DeliveryChannel>,
|
||||
pub reason: Option<String>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl NotificationBlock {
|
||||
pub async fn find_by_id(
|
||||
pool: &sqlx::PgPool,
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<Self>, AppError> {
|
||||
sqlx::query_as::<_, NotificationBlock>(
|
||||
"SELECT id, user_id, workspace_id, repo_id, target_type, target_id, notification_type, \
|
||||
channel, reason, expires_at, created_at, updated_at \
|
||||
FROM notification_block WHERE id = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn list_for_user(
|
||||
pool: &sqlx::PgPool,
|
||||
user_id: Uuid,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Self>, AppError> {
|
||||
sqlx::query_as::<_, NotificationBlock>(
|
||||
"SELECT id, user_id, workspace_id, repo_id, target_type, target_id, notification_type, \
|
||||
channel, reason, expires_at, created_at, updated_at \
|
||||
FROM notification_block WHERE user_id = $1 \
|
||||
ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
|
||||
impl NotificationService {
|
||||
pub async fn list_blocks(
|
||||
&self,
|
||||
session: &Session,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<NotificationBlock>, AppError> {
|
||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
NotificationBlock::list_for_user(self.ctx.db.reader(), user_id, limit, offset).await
|
||||
}
|
||||
|
||||
pub async fn create_block(
|
||||
&self,
|
||||
session: &Session,
|
||||
params: CreateBlockParams,
|
||||
) -> Result<NotificationBlock, AppError> {
|
||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
||||
let id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO notification_block \
|
||||
(id, user_id, workspace_id, repo_id, target_type, target_id, notification_type, channel, reason, expires_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(user_id)
|
||||
.bind(params.workspace_id)
|
||||
.bind(params.repo_id)
|
||||
.bind(params.target_type.as_str())
|
||||
.bind(params.target_id)
|
||||
.bind(params.notification_type.map(|t| t.as_str()))
|
||||
.bind(params.channel.map(|c| c.as_str()))
|
||||
.bind(params.reason)
|
||||
.bind(params.expires_at)
|
||||
.bind(now)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
NotificationBlock::find_by_id(self.ctx.db.reader(), id, user_id)
|
||||
.await?
|
||||
.ok_or(AppError::InternalServerError(
|
||||
"failed to fetch created block".into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn delete_block(&self, session: &Session, block_id: Uuid) -> Result<(), AppError> {
|
||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let result = sqlx::query("DELETE FROM notification_block WHERE id = $1 AND user_id = $2")
|
||||
.bind(block_id)
|
||||
.bind(user_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("block not found".into()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
use crate::error::AppError;
|
||||
use crate::models::notifications::Notification;
|
||||
use crate::service::NotificationService;
|
||||
use crate::session::Session;
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::util::clamp_limit_offset;
|
||||
|
||||
impl NotificationService {
|
||||
pub async fn list_notifications(
|
||||
&self,
|
||||
session: &Session,
|
||||
unread_only: bool,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Notification>, AppError> {
|
||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
|
||||
Notification::list_for_user(self.ctx.db.reader(), user_id, unread_only, limit, offset).await
|
||||
}
|
||||
|
||||
pub async fn count_unread(&self, session: &Session) -> Result<i64, AppError> {
|
||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
||||
Notification::count_unread(self.ctx.db.reader(), user_id).await
|
||||
}
|
||||
|
||||
pub async fn mark_as_read(
|
||||
&self,
|
||||
session: &Session,
|
||||
notification_id: Uuid,
|
||||
) -> Result<Notification, AppError> {
|
||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
||||
let now = Utc::now();
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE notification SET read_at = $1, updated_at = $2 \
|
||||
WHERE id = $3 AND user_id = $4 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.bind(notification_id)
|
||||
.bind(user_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
Notification::find_by_id(self.ctx.db.reader(), notification_id, user_id)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound("notification not found".into()))
|
||||
}
|
||||
|
||||
pub async fn mark_all_as_read(&self, session: &Session) -> Result<i64, AppError> {
|
||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
||||
let now = Utc::now();
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE notification SET read_at = $1, updated_at = $2 \
|
||||
WHERE user_id = $3 AND deleted_at IS NULL AND read_at IS NULL",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.bind(user_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
Ok(result.rows_affected() as i64)
|
||||
}
|
||||
|
||||
pub async fn dismiss_notification(
|
||||
&self,
|
||||
session: &Session,
|
||||
notification_id: Uuid,
|
||||
) -> Result<Notification, AppError> {
|
||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
||||
let now = Utc::now();
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE notification SET dismissed_at = $1, updated_at = $2 \
|
||||
WHERE id = $3 AND user_id = $4 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.bind(notification_id)
|
||||
.bind(user_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
Notification::find_by_id(self.ctx.db.reader(), notification_id, user_id)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound("notification not found".into()))
|
||||
}
|
||||
|
||||
pub async fn delete_notification(
|
||||
&self,
|
||||
session: &Session,
|
||||
notification_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
||||
let now = Utc::now();
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE notification SET deleted_at = $1, updated_at = $2 \
|
||||
WHERE id = $3 AND user_id = $4 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.bind(notification_id)
|
||||
.bind(user_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("notification not found".into()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_all_notifications(&self, session: &Session) -> Result<i64, AppError> {
|
||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
||||
let now = Utc::now();
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE notification SET dismissed_at = $1, updated_at = $2 \
|
||||
WHERE user_id = $3 AND deleted_at IS NULL AND dismissed_at IS NULL",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(now)
|
||||
.bind(user_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
Ok(result.rows_affected() as i64)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use crate::error::AppError;
|
||||
use crate::models::notifications::NotificationDelivery;
|
||||
use crate::service::NotificationService;
|
||||
use crate::session::Session;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::util::clamp_limit_offset;
|
||||
|
||||
impl NotificationDelivery {
|
||||
pub async fn list_for_user(
|
||||
pool: &sqlx::PgPool,
|
||||
user_id: Uuid,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Self>, AppError> {
|
||||
sqlx::query_as::<_, NotificationDelivery>(
|
||||
"SELECT id, notification_id, user_id, channel, destination, status, provider, \
|
||||
provider_message_id, attempts, last_error, scheduled_at, sent_at, delivered_at, failed_at, created_at, updated_at \
|
||||
FROM notification_delivery WHERE user_id = $1 \
|
||||
ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn list_for_notification(
|
||||
pool: &sqlx::PgPool,
|
||||
notification_id: Uuid,
|
||||
user_id: Uuid,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Self>, AppError> {
|
||||
sqlx::query_as::<_, NotificationDelivery>(
|
||||
"SELECT d.id, d.notification_id, d.user_id, d.channel, d.destination, d.status, d.provider, \
|
||||
d.provider_message_id, d.attempts, d.last_error, d.scheduled_at, d.sent_at, d.delivered_at, d.failed_at, d.created_at, d.updated_at \
|
||||
FROM notification_delivery d \
|
||||
JOIN notification n ON d.notification_id = n.id \
|
||||
WHERE d.notification_id = $1 AND n.user_id = $2 \
|
||||
ORDER BY d.created_at DESC LIMIT $3 OFFSET $4",
|
||||
)
|
||||
.bind(notification_id)
|
||||
.bind(user_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
|
||||
impl NotificationService {
|
||||
pub async fn list_deliveries(
|
||||
&self,
|
||||
session: &Session,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<NotificationDelivery>, AppError> {
|
||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
NotificationDelivery::list_for_user(self.ctx.db.reader(), user_id, limit, offset).await
|
||||
}
|
||||
|
||||
pub async fn list_deliveries_for_notification(
|
||||
&self,
|
||||
session: &Session,
|
||||
notification_id: Uuid,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<NotificationDelivery>, AppError> {
|
||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
NotificationDelivery::list_for_notification(
|
||||
self.ctx.db.reader(),
|
||||
notification_id,
|
||||
user_id,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod blocks;
|
||||
pub mod core;
|
||||
pub mod deliveries;
|
||||
pub mod subscriptions;
|
||||
pub mod templates;
|
||||
pub mod util;
|
||||
@@ -0,0 +1,183 @@
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::{EventType, SubscriptionLevel, TargetType};
|
||||
use crate::models::notifications::NotificationSubscription;
|
||||
use crate::service::NotificationService;
|
||||
use crate::session::Session;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::util::clamp_limit_offset;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct CreateSubscriptionParams {
|
||||
pub workspace_id: Option<Uuid>,
|
||||
pub repo_id: Option<Uuid>,
|
||||
pub target_type: TargetType,
|
||||
pub target_id: Option<Uuid>,
|
||||
pub event_types: Vec<EventType>,
|
||||
pub channels: Vec<String>,
|
||||
pub level: SubscriptionLevel,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct UpdateSubscriptionParams {
|
||||
pub event_types: Option<Vec<EventType>>,
|
||||
pub channels: Option<Vec<String>>,
|
||||
pub level: Option<SubscriptionLevel>,
|
||||
pub muted: Option<bool>,
|
||||
pub muted_until: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl NotificationSubscription {
|
||||
pub async fn find_by_id(
|
||||
pool: &sqlx::PgPool,
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<Self>, AppError> {
|
||||
sqlx::query_as::<_, NotificationSubscription>(
|
||||
"SELECT id, user_id, workspace_id, repo_id, target_type, target_id, event_types, \
|
||||
channels, level, muted, muted_until, created_at, updated_at \
|
||||
FROM notification_subscription WHERE id = $1 AND user_id = $2",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn list_for_user(
|
||||
pool: &sqlx::PgPool,
|
||||
user_id: Uuid,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Self>, AppError> {
|
||||
sqlx::query_as::<_, NotificationSubscription>(
|
||||
"SELECT id, user_id, workspace_id, repo_id, target_type, target_id, event_types, \
|
||||
channels, level, muted, muted_until, created_at, updated_at \
|
||||
FROM notification_subscription WHERE user_id = $1 \
|
||||
ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
|
||||
impl NotificationService {
|
||||
pub async fn list_subscriptions(
|
||||
&self,
|
||||
session: &Session,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<NotificationSubscription>, AppError> {
|
||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
NotificationSubscription::list_for_user(self.ctx.db.reader(), user_id, limit, offset).await
|
||||
}
|
||||
|
||||
pub async fn create_subscription(
|
||||
&self,
|
||||
session: &Session,
|
||||
params: CreateSubscriptionParams,
|
||||
) -> Result<NotificationSubscription, AppError> {
|
||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
||||
let id = Uuid::now_v7();
|
||||
let now = Utc::now();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO notification_subscription \
|
||||
(id, user_id, workspace_id, repo_id, target_type, target_id, event_types, channels, level, muted, muted_until, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, false, NULL, $10, $10)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(user_id)
|
||||
.bind(params.workspace_id)
|
||||
.bind(params.repo_id)
|
||||
.bind(params.target_type.as_str())
|
||||
.bind(params.target_id)
|
||||
.bind(¶ms.event_types)
|
||||
.bind(¶ms.channels)
|
||||
.bind(params.level.as_str())
|
||||
.bind(now)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
NotificationSubscription::find_by_id(self.ctx.db.reader(), id, user_id)
|
||||
.await?
|
||||
.ok_or(AppError::InternalServerError(
|
||||
"failed to fetch created subscription".into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn update_subscription(
|
||||
&self,
|
||||
session: &Session,
|
||||
subscription_id: Uuid,
|
||||
params: UpdateSubscriptionParams,
|
||||
) -> Result<NotificationSubscription, AppError> {
|
||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
||||
let now = Utc::now();
|
||||
|
||||
let existing =
|
||||
NotificationSubscription::find_by_id(self.ctx.db.reader(), subscription_id, user_id)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound("subscription not found".into()))?;
|
||||
|
||||
let event_types = params.event_types.unwrap_or(existing.event_types);
|
||||
let channels = params.channels.unwrap_or(existing.channels);
|
||||
let level = params.level.unwrap_or(existing.level);
|
||||
let muted = params.muted.unwrap_or(existing.muted);
|
||||
let muted_until = params.muted_until.or(existing.muted_until);
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE notification_subscription \
|
||||
SET event_types = $1, channels = $2, level = $3, muted = $4, muted_until = $5, updated_at = $6 \
|
||||
WHERE id = $7 AND user_id = $8",
|
||||
)
|
||||
.bind(&event_types)
|
||||
.bind(&channels)
|
||||
.bind(level.as_str())
|
||||
.bind(muted)
|
||||
.bind(muted_until)
|
||||
.bind(now)
|
||||
.bind(subscription_id)
|
||||
.bind(user_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
NotificationSubscription::find_by_id(self.ctx.db.reader(), subscription_id, user_id)
|
||||
.await?
|
||||
.ok_or(AppError::InternalServerError(
|
||||
"failed to fetch updated subscription".into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn delete_subscription(
|
||||
&self,
|
||||
session: &Session,
|
||||
subscription_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let result =
|
||||
sqlx::query("DELETE FROM notification_subscription WHERE id = $1 AND user_id = $2")
|
||||
.bind(subscription_id)
|
||||
.bind(user_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("subscription not found".into()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::{DeliveryChannel, NotificationType, Role};
|
||||
use crate::models::notifications::NotificationTemplate;
|
||||
use crate::models::users::User;
|
||||
use crate::service::NotificationService;
|
||||
use crate::session::Session;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::util::clamp_limit_offset;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct CreateTemplateParams {
|
||||
pub key: String,
|
||||
pub notification_type: NotificationType,
|
||||
pub channel: DeliveryChannel,
|
||||
pub locale: String,
|
||||
pub subject_template: Option<String>,
|
||||
pub title_template: String,
|
||||
pub body_template: String,
|
||||
pub action_text_template: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct UpdateTemplateParams {
|
||||
pub subject_template: Option<String>,
|
||||
pub title_template: Option<String>,
|
||||
pub body_template: Option<String>,
|
||||
pub action_text_template: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
impl NotificationTemplate {
|
||||
pub async fn find_by_id(pool: &sqlx::PgPool, id: Uuid) -> Result<Option<Self>, AppError> {
|
||||
sqlx::query_as::<_, NotificationTemplate>(
|
||||
"SELECT id, key, notification_type, channel, locale, subject_template, title_template, \
|
||||
body_template, action_text_template, enabled, created_by, created_at, updated_at \
|
||||
FROM notification_template WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn list_all(
|
||||
pool: &sqlx::PgPool,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Self>, AppError> {
|
||||
sqlx::query_as::<_, NotificationTemplate>(
|
||||
"SELECT id, key, notification_type, channel, locale, subject_template, title_template, \
|
||||
body_template, action_text_template, enabled, created_by, created_at, updated_at \
|
||||
FROM notification_template ORDER BY key, channel, locale LIMIT $1 OFFSET $2",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
|
||||
impl NotificationService {
|
||||
/// Check if user is system admin
|
||||
async fn ensure_system_admin(&self, session: &Session) -> Result<Uuid, AppError> {
|
||||
let user_id = session.user().ok_or(AppError::Unauthorized)?;
|
||||
let user: User = sqlx::query_as(
|
||||
"SELECT id, username, display_name, avatar_url, bio, status, role, visibility, \
|
||||
is_active, is_bot, last_login_at, created_at, updated_at, deleted_at \
|
||||
FROM \"user\" WHERE id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("User not found".into()))?;
|
||||
|
||||
if user.role != Role::System && user.role != Role::Admin {
|
||||
return Err(AppError::Forbidden("System admin access required".into()));
|
||||
}
|
||||
|
||||
Ok(user_id)
|
||||
}
|
||||
|
||||
pub async fn list_templates(
|
||||
&self,
|
||||
session: &Session,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<NotificationTemplate>, AppError> {
|
||||
self.ensure_system_admin(session).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
NotificationTemplate::list_all(self.ctx.db.reader(), limit, offset).await
|
||||
}
|
||||
|
||||
pub async fn get_template(
|
||||
&self,
|
||||
session: &Session,
|
||||
template_id: Uuid,
|
||||
) -> Result<NotificationTemplate, AppError> {
|
||||
self.ensure_system_admin(session).await?;
|
||||
NotificationTemplate::find_by_id(self.ctx.db.reader(), template_id)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound("template not found".into()))
|
||||
}
|
||||
|
||||
pub async fn create_template(
|
||||
&self,
|
||||
session: &Session,
|
||||
params: CreateTemplateParams,
|
||||
) -> Result<NotificationTemplate, AppError> {
|
||||
let user_id = self.ensure_system_admin(session).await?;
|
||||
let id = Uuid::now_v7();
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO notification_template \
|
||||
(id, key, notification_type, channel, locale, subject_template, title_template, \
|
||||
body_template, action_text_template, enabled, created_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(¶ms.key)
|
||||
.bind(params.notification_type.as_str())
|
||||
.bind(params.channel.as_str())
|
||||
.bind(¶ms.locale)
|
||||
.bind(params.subject_template)
|
||||
.bind(¶ms.title_template)
|
||||
.bind(¶ms.body_template)
|
||||
.bind(params.action_text_template)
|
||||
.bind(params.enabled)
|
||||
.bind(user_id)
|
||||
.bind(now)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
NotificationTemplate::find_by_id(self.ctx.db.reader(), id)
|
||||
.await?
|
||||
.ok_or(AppError::InternalServerError(
|
||||
"failed to fetch created template".into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn update_template(
|
||||
&self,
|
||||
session: &Session,
|
||||
template_id: Uuid,
|
||||
params: UpdateTemplateParams,
|
||||
) -> Result<NotificationTemplate, AppError> {
|
||||
self.ensure_system_admin(session).await?;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let existing = NotificationTemplate::find_by_id(self.ctx.db.reader(), template_id)
|
||||
.await?
|
||||
.ok_or(AppError::NotFound("template not found".into()))?;
|
||||
|
||||
let subject_template = params.subject_template.or(existing.subject_template);
|
||||
let title_template = params.title_template.unwrap_or(existing.title_template);
|
||||
let body_template = params.body_template.unwrap_or(existing.body_template);
|
||||
let action_text_template = params
|
||||
.action_text_template
|
||||
.or(existing.action_text_template);
|
||||
let enabled = params.enabled.unwrap_or(existing.enabled);
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE notification_template \
|
||||
SET subject_template = $1, title_template = $2, body_template = $3, \
|
||||
action_text_template = $4, enabled = $5, updated_at = $6 \
|
||||
WHERE id = $7",
|
||||
)
|
||||
.bind(subject_template)
|
||||
.bind(&title_template)
|
||||
.bind(&body_template)
|
||||
.bind(action_text_template)
|
||||
.bind(enabled)
|
||||
.bind(now)
|
||||
.bind(template_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
NotificationTemplate::find_by_id(self.ctx.db.reader(), template_id)
|
||||
.await?
|
||||
.ok_or(AppError::InternalServerError(
|
||||
"failed to fetch updated template".into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn delete_template(
|
||||
&self,
|
||||
session: &Session,
|
||||
template_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
self.ensure_system_admin(session).await?;
|
||||
let result = sqlx::query("DELETE FROM notification_template WHERE id = $1")
|
||||
.bind(template_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(AppError::NotFound("template not found".into()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
pub use crate::service::util::clamp_limit_offset;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::notifications::Notification;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
impl Notification {
|
||||
pub async fn find_by_id(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<Self>, AppError> {
|
||||
sqlx::query_as::<_, Notification>(
|
||||
"SELECT id, user_id, actor_id, workspace_id, repo_id, issue_id, pull_request_id, \
|
||||
channel_id, message_id, notification_type, title, body, target_type, target_id, \
|
||||
action_url, priority, read_at, dismissed_at, metadata, created_at, updated_at, deleted_at \
|
||||
FROM notification WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(user_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn list_for_user(
|
||||
pool: &PgPool,
|
||||
user_id: Uuid,
|
||||
unread_only: bool,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Self>, AppError> {
|
||||
let sql = if unread_only {
|
||||
"SELECT id, user_id, actor_id, workspace_id, repo_id, issue_id, pull_request_id, \
|
||||
channel_id, message_id, notification_type, title, body, target_type, target_id, \
|
||||
action_url, priority, read_at, dismissed_at, metadata, created_at, updated_at, deleted_at \
|
||||
FROM notification \
|
||||
WHERE user_id = $1 AND deleted_at IS NULL AND read_at IS NULL AND dismissed_at IS NULL \
|
||||
ORDER BY created_at DESC LIMIT $2 OFFSET $3"
|
||||
} else {
|
||||
"SELECT id, user_id, actor_id, workspace_id, repo_id, issue_id, pull_request_id, \
|
||||
channel_id, message_id, notification_type, title, body, target_type, target_id, \
|
||||
action_url, priority, read_at, dismissed_at, metadata, created_at, updated_at, deleted_at \
|
||||
FROM notification \
|
||||
WHERE user_id = $1 AND deleted_at IS NULL AND dismissed_at IS NULL \
|
||||
ORDER BY created_at DESC LIMIT $2 OFFSET $3"
|
||||
};
|
||||
|
||||
sqlx::query_as::<_, Notification>(sql)
|
||||
.bind(user_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn count_unread(pool: &PgPool, user_id: Uuid) -> Result<i64, AppError> {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM notification \
|
||||
WHERE user_id = $1 AND deleted_at IS NULL AND read_at IS NULL AND dismissed_at IS NULL",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::prs::PrAssignee;
|
||||
use crate::service::PrService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected};
|
||||
|
||||
impl PrService {
|
||||
pub async fn pr_assignees(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<PrAssignee>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_readable(user_uid, &pr).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, PrAssignee>(
|
||||
"SELECT id, pull_request_id, assignee_id, assigned_by, created_at \
|
||||
FROM pr_assignee WHERE pull_request_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(pr.id).bind(limit).bind(offset)
|
||||
.fetch_all(self.ctx.db.reader()).await.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn pr_assign(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
assignee_id: Uuid,
|
||||
) -> Result<PrAssignee, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_editable(user_uid, &pr).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 assignee = sqlx::query_as::<_, PrAssignee>(
|
||||
"INSERT INTO pr_assignee (id, pull_request_id, assignee_id, assigned_by, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (pull_request_id, assignee_id) DO NOTHING \
|
||||
RETURNING id, pull_request_id, assignee_id, assigned_by, created_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(pr.id)
|
||||
.bind(assignee_id)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.fetch_optional(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::Conflict("user already assigned".into()))?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO pr_subscription (id, pull_request_id, user_id, reason, muted, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, 'assignee', false, $4, $4) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(Uuid::now_v7()).bind(pr.id).bind(assignee_id).bind(now)
|
||||
.execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(assignee)
|
||||
}
|
||||
|
||||
pub async fn pr_unassign(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
assignee_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_editable(user_uid, &pr).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 result =
|
||||
sqlx::query("DELETE FROM pr_assignee WHERE pull_request_id = $1 AND assignee_id = $2")
|
||||
.bind(pr.id)
|
||||
.bind(assignee_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "assignee not found")?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::{Role, Status};
|
||||
use crate::models::prs::PrCheckRun;
|
||||
use crate::service::PrService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, required_text};
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateCheckRunParams {
|
||||
pub commit_sha: String,
|
||||
pub name: String,
|
||||
pub status: String,
|
||||
pub conclusion: Option<String>,
|
||||
pub details_url: Option<String>,
|
||||
pub external_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateCheckRunParams {
|
||||
pub status: Option<String>,
|
||||
pub conclusion: Option<String>,
|
||||
pub details_url: Option<String>,
|
||||
}
|
||||
|
||||
impl PrService {
|
||||
pub async fn pr_check_runs(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<PrCheckRun>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_readable(user_uid, &pr).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, PrCheckRun>(
|
||||
"SELECT id, pull_request_id, commit_sha, name, status, conclusion, details_url, \
|
||||
external_id, started_at, completed_at, created_at, updated_at \
|
||||
FROM pr_check_run WHERE pull_request_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(pr.id).bind(limit).bind(offset)
|
||||
.fetch_all(self.ctx.db.reader()).await.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn pr_create_check_run(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
params: CreateCheckRunParams,
|
||||
) -> Result<PrCheckRun, 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?;
|
||||
|
||||
let name = required_text(params.name, "name")?;
|
||||
let status = params
|
||||
.status
|
||||
.trim()
|
||||
.parse::<Status>()
|
||||
.map_err(|_| AppError::BadRequest("invalid status".into()))?;
|
||||
if status == Status::Unknown {
|
||||
return Err(AppError::BadRequest("invalid status".into()));
|
||||
}
|
||||
let conclusion = params
|
||||
.conclusion
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse::<Status>().ok())
|
||||
.filter(|s| *s != Status::Unknown);
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, PrCheckRun>(
|
||||
"INSERT INTO pr_check_run (id, pull_request_id, commit_sha, name, status, conclusion, \
|
||||
details_url, external_id, started_at, completed_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) \
|
||||
RETURNING id, pull_request_id, commit_sha, name, status, conclusion, details_url, \
|
||||
external_id, started_at, completed_at, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(pr.id)
|
||||
.bind(¶ms.commit_sha)
|
||||
.bind(&name)
|
||||
.bind(status)
|
||||
.bind(conclusion)
|
||||
.bind(params.details_url.as_deref())
|
||||
.bind(params.external_id.as_deref())
|
||||
.bind(if status == Status::Running {
|
||||
Some(now)
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.bind(
|
||||
if matches!(status, Status::Completed | Status::Success | Status::Failed) {
|
||||
Some(now)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
.bind(now)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn pr_update_check_run(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
check_run_id: Uuid,
|
||||
params: UpdateCheckRunParams,
|
||||
) -> Result<PrCheckRun, 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?;
|
||||
|
||||
let current = sqlx::query_as::<_, PrCheckRun>(
|
||||
"SELECT id, pull_request_id, commit_sha, name, status, conclusion, details_url, \
|
||||
external_id, started_at, completed_at, created_at, updated_at \
|
||||
FROM pr_check_run WHERE id = $1 AND pull_request_id = $2",
|
||||
)
|
||||
.bind(check_run_id)
|
||||
.bind(pr.id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("check run not found".into()))?;
|
||||
|
||||
let status = match params.status {
|
||||
Some(ref v) => v
|
||||
.trim()
|
||||
.parse::<Status>()
|
||||
.map_err(|_| AppError::BadRequest("invalid status".into()))?,
|
||||
None => current.status,
|
||||
};
|
||||
let conclusion = params
|
||||
.conclusion
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse::<Status>().ok())
|
||||
.filter(|s| *s != Status::Unknown)
|
||||
.or(current.conclusion);
|
||||
let details_url = params.details_url.or(current.details_url);
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
sqlx::query_as::<_, PrCheckRun>(
|
||||
"UPDATE pr_check_run SET status = $1, conclusion = $2, details_url = $3, updated_at = $4 \
|
||||
WHERE id = $5 \
|
||||
RETURNING id, pull_request_id, commit_sha, name, status, conclusion, details_url, \
|
||||
external_id, started_at, completed_at, created_at, updated_at",
|
||||
)
|
||||
.bind(status).bind(conclusion).bind(details_url.as_deref()).bind(now).bind(check_run_id)
|
||||
.fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn pr_delete_check_run(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
check_run_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 pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
|
||||
let result = sqlx::query("DELETE FROM pr_check_run WHERE id = $1 AND pull_request_id = $2")
|
||||
.bind(check_run_id)
|
||||
.bind(pr.id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "check run not found")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use crate::error::AppError;
|
||||
use crate::models::prs::PrCommit;
|
||||
use crate::service::PrService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::clamp_limit_offset;
|
||||
|
||||
impl PrService {
|
||||
pub async fn pr_commits(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<PrCommit>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_readable(user_uid, &pr).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, PrCommit>(
|
||||
"SELECT id, pull_request_id, repo_id, commit_sha, position, authored_at, committed_at, created_at \
|
||||
FROM pr_commit WHERE pull_request_id = $1 ORDER BY position ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(pr.id).bind(limit).bind(offset)
|
||||
.fetch_all(self.ctx.db.reader()).await.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
+1084
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::{EventType, JsonValue};
|
||||
use crate::models::prs::PrEvent;
|
||||
use crate::service::PrService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::clamp_limit_offset;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreatePrEventParams {
|
||||
pub event_type: String,
|
||||
pub old_value: Option<JsonValue>,
|
||||
pub new_value: Option<JsonValue>,
|
||||
pub metadata: Option<JsonValue>,
|
||||
}
|
||||
|
||||
impl PrService {
|
||||
pub async fn pr_list_events(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<PrEvent>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_readable(user_uid, &pr).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
|
||||
sqlx::query_as::<_, PrEvent>(
|
||||
"SELECT id, pull_request_id, actor_id, event_type, old_value, new_value, metadata, created_at \
|
||||
FROM pr_event WHERE pull_request_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(pr.id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub(crate) async fn create_pr_event(
|
||||
&self,
|
||||
pull_request_id: Uuid,
|
||||
actor_id: Option<Uuid>,
|
||||
event_type: EventType,
|
||||
old_value: Option<JsonValue>,
|
||||
new_value: Option<JsonValue>,
|
||||
metadata: Option<JsonValue>,
|
||||
) -> Result<PrEvent, AppError> {
|
||||
sqlx::query_as::<_, PrEvent>(
|
||||
"INSERT INTO pr_event (id, pull_request_id, actor_id, event_type, old_value, new_value, metadata, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
|
||||
RETURNING id, pull_request_id, actor_id, event_type, old_value, new_value, metadata, created_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(pull_request_id)
|
||||
.bind(actor_id)
|
||||
.bind(event_type)
|
||||
.bind(old_value)
|
||||
.bind(new_value)
|
||||
.bind(metadata)
|
||||
.bind(chrono::Utc::now())
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use crate::error::AppError;
|
||||
use crate::models::prs::PrFile;
|
||||
use crate::service::PrService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::clamp_limit_offset;
|
||||
|
||||
impl PrService {
|
||||
pub async fn pr_files(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<PrFile>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_readable(user_uid, &pr).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, PrFile>(
|
||||
"SELECT id, pull_request_id, path, old_path, status, additions, deletions, changes, \
|
||||
patch, created_at, updated_at \
|
||||
FROM pr_file WHERE pull_request_id = $1 ORDER BY path ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(pr.id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::prs::{PrLabel, PrLabelRelation};
|
||||
use crate::service::PrService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, required_text};
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreatePrLabelParams {
|
||||
pub name: String,
|
||||
pub color: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdatePrLabelParams {
|
||||
pub name: Option<String>,
|
||||
pub color: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl PrService {
|
||||
pub async fn pr_labels(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<Vec<PrLabel>, 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?;
|
||||
sqlx::query_as::<_, PrLabel>(
|
||||
"SELECT id, repo_id, name, color, description, created_by, created_at, updated_at \
|
||||
FROM pr_label WHERE repo_id = $1 ORDER BY name ASC",
|
||||
)
|
||||
.bind(repo.id)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn pr_create_label(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreatePrLabelParams,
|
||||
) -> Result<PrLabel, 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")?;
|
||||
let color = required_text(params.color, "color")?;
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, PrLabel>(
|
||||
"INSERT INTO pr_label (id, repo_id, name, color, description, created_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $7) \
|
||||
RETURNING id, repo_id, name, color, description, created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7()).bind(repo.id).bind(&name).bind(&color)
|
||||
.bind(params.description.as_deref()).bind(user_uid).bind(now)
|
||||
.fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn pr_update_label(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
label_id: Uuid,
|
||||
params: UpdatePrLabelParams,
|
||||
) -> Result<PrLabel, 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 = sqlx::query_as::<_, PrLabel>(
|
||||
"SELECT id, repo_id, name, color, description, created_by, created_at, updated_at \
|
||||
FROM pr_label WHERE id = $1 AND repo_id = $2",
|
||||
)
|
||||
.bind(label_id)
|
||||
.bind(repo.id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("label not found".into()))?;
|
||||
|
||||
let name = params.name.unwrap_or(current.name);
|
||||
let color = params.color.unwrap_or(current.color);
|
||||
let description = super::util::merge_optional_text(params.description, current.description);
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, PrLabel>(
|
||||
"UPDATE pr_label SET name = $1, color = $2, description = $3, updated_at = $4 \
|
||||
WHERE id = $5 RETURNING id, repo_id, name, color, description, created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(&name).bind(&color).bind(&description).bind(now).bind(label_id)
|
||||
.fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn pr_delete_label(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
label_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 result = sqlx::query("DELETE FROM pr_label WHERE id = $1 AND repo_id = $2")
|
||||
.bind(label_id)
|
||||
.bind(repo.id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "label not found")
|
||||
}
|
||||
|
||||
pub async fn pr_assign_label(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
label_id: Uuid,
|
||||
) -> Result<PrLabelRelation, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_editable(user_uid, &pr).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 rel = sqlx::query_as::<_, PrLabelRelation>(
|
||||
"INSERT INTO pr_label_relation (id, pull_request_id, label_id, created_by, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5) \
|
||||
RETURNING id, pull_request_id, label_id, created_by, created_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(pr.id)
|
||||
.bind(label_id)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(rel)
|
||||
}
|
||||
|
||||
pub async fn pr_unassign_label(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
label_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_editable(user_uid, &pr).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 result = sqlx::query(
|
||||
"DELETE FROM pr_label_relation WHERE pull_request_id = $1 AND label_id = $2",
|
||||
)
|
||||
.bind(pr.id)
|
||||
.bind(label_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "label not assigned")?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn pr_label_relations(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<PrLabelRelation>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_readable(user_uid, &pr).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, PrLabelRelation>(
|
||||
"SELECT id, pull_request_id, label_id, created_by, created_at \
|
||||
FROM pr_label_relation WHERE pull_request_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(pr.id).bind(limit).bind(offset)
|
||||
.fetch_all(self.ctx.db.reader()).await.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::MergeStrategyKind;
|
||||
use crate::models::prs::PrMergeStrategy;
|
||||
use crate::service::PrService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::parse_enum;
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateMergeStrategyParams {
|
||||
pub strategy: Option<String>,
|
||||
pub auto_merge: Option<bool>,
|
||||
pub squash_title: Option<String>,
|
||||
pub squash_message: Option<String>,
|
||||
pub delete_source_branch: Option<bool>,
|
||||
pub merge_when_checks_pass: Option<bool>,
|
||||
}
|
||||
|
||||
impl PrService {
|
||||
pub async fn pr_merge_strategy(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
) -> Result<PrMergeStrategy, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_readable(user_uid, &pr).await?;
|
||||
sqlx::query_as::<_, PrMergeStrategy>(
|
||||
"SELECT pull_request_id, strategy, auto_merge, squash_title, squash_message, \
|
||||
delete_source_branch, merge_when_checks_pass, selected_by, created_at, updated_at \
|
||||
FROM pr_merge_strategy WHERE pull_request_id = $1",
|
||||
)
|
||||
.bind(pr.id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("PR merge strategy not found".into()))
|
||||
}
|
||||
|
||||
pub async fn pr_update_merge_strategy(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
params: UpdateMergeStrategyParams,
|
||||
) -> Result<PrMergeStrategy, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_editable(user_uid, &pr).await?;
|
||||
|
||||
let current = sqlx::query_as::<_, PrMergeStrategy>(
|
||||
"SELECT pull_request_id, strategy, auto_merge, squash_title, squash_message, \
|
||||
delete_source_branch, merge_when_checks_pass, selected_by, created_at, updated_at \
|
||||
FROM pr_merge_strategy WHERE pull_request_id = $1",
|
||||
)
|
||||
.bind(pr.id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let strategy = match params.strategy {
|
||||
Some(ref v) => parse_enum(
|
||||
Some(v.clone()),
|
||||
current
|
||||
.as_ref()
|
||||
.map(|c| c.strategy)
|
||||
.unwrap_or(MergeStrategyKind::Merge),
|
||||
MergeStrategyKind::Unknown,
|
||||
"strategy",
|
||||
)?,
|
||||
None => current
|
||||
.as_ref()
|
||||
.map(|c| c.strategy)
|
||||
.unwrap_or(MergeStrategyKind::Merge),
|
||||
};
|
||||
let auto_merge = params
|
||||
.auto_merge
|
||||
.or(current.as_ref().map(|c| c.auto_merge))
|
||||
.unwrap_or(false);
|
||||
let squash_title = params
|
||||
.squash_title
|
||||
.or_else(|| current.as_ref().and_then(|c| c.squash_title.clone()));
|
||||
let squash_message = params
|
||||
.squash_message
|
||||
.or_else(|| current.as_ref().and_then(|c| c.squash_message.clone()));
|
||||
let delete_source_branch = params
|
||||
.delete_source_branch
|
||||
.or(current.as_ref().map(|c| c.delete_source_branch))
|
||||
.unwrap_or(false);
|
||||
let merge_when_checks_pass = params
|
||||
.merge_when_checks_pass
|
||||
.or(current.as_ref().map(|c| c.merge_when_checks_pass))
|
||||
.unwrap_or(false);
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
if current.is_some() {
|
||||
sqlx::query_as::<_, PrMergeStrategy>(
|
||||
"UPDATE pr_merge_strategy SET strategy = $1, auto_merge = $2, squash_title = $3, \
|
||||
squash_message = $4, delete_source_branch = $5, merge_when_checks_pass = $6, \
|
||||
selected_by = $7, updated_at = $8 WHERE pull_request_id = $9 \
|
||||
RETURNING pull_request_id, strategy, auto_merge, squash_title, squash_message, \
|
||||
delete_source_branch, merge_when_checks_pass, selected_by, created_at, updated_at",
|
||||
)
|
||||
.bind(strategy)
|
||||
.bind(auto_merge)
|
||||
.bind(squash_title.as_deref())
|
||||
.bind(squash_message.as_deref())
|
||||
.bind(delete_source_branch)
|
||||
.bind(merge_when_checks_pass)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.bind(pr.id)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
} else {
|
||||
sqlx::query_as::<_, PrMergeStrategy>(
|
||||
"INSERT INTO pr_merge_strategy (pull_request_id, strategy, auto_merge, squash_title, \
|
||||
squash_message, delete_source_branch, merge_when_checks_pass, selected_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) \
|
||||
RETURNING pull_request_id, strategy, auto_merge, squash_title, squash_message, \
|
||||
delete_source_branch, merge_when_checks_pass, selected_by, created_at, updated_at",
|
||||
)
|
||||
.bind(pr.id).bind(strategy).bind(auto_merge).bind(squash_title.as_deref()).bind(squash_message.as_deref())
|
||||
.bind(delete_source_branch).bind(merge_when_checks_pass).bind(user_uid).bind(now)
|
||||
.fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
pub mod assignees;
|
||||
pub mod check_runs;
|
||||
pub mod commits;
|
||||
pub mod core;
|
||||
pub mod events;
|
||||
pub mod files;
|
||||
pub mod labels;
|
||||
pub mod merge_strategy;
|
||||
pub mod reactions;
|
||||
pub mod reviews;
|
||||
pub mod status;
|
||||
pub mod subscriptions;
|
||||
pub mod util;
|
||||
@@ -0,0 +1,96 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::TargetType;
|
||||
use crate::models::prs::PrReaction;
|
||||
use crate::service::PrService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, required_text};
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateReactionParams {
|
||||
pub content: String,
|
||||
pub target_type: Option<String>,
|
||||
pub target_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
impl PrService {
|
||||
pub async fn pr_reactions(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<PrReaction>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_readable(user_uid, &pr).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, PrReaction>(
|
||||
"SELECT id, pull_request_id, user_id, content, target_type, target_id, created_at \
|
||||
FROM pr_reaction WHERE pull_request_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(pr.id).bind(limit).bind(offset)
|
||||
.fetch_all(self.ctx.db.reader()).await.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn pr_add_reaction(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
params: CreateReactionParams,
|
||||
) -> Result<PrReaction, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_readable(user_uid, &pr).await?;
|
||||
let content = required_text(params.content, "content")?;
|
||||
let target_type = params
|
||||
.target_type
|
||||
.as_deref()
|
||||
.and_then(|s| s.parse::<TargetType>().ok())
|
||||
.unwrap_or(TargetType::PullRequest);
|
||||
if target_type == TargetType::Unknown {
|
||||
return Err(AppError::BadRequest("invalid target_type".into()));
|
||||
}
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query_as::<_, PrReaction>(
|
||||
"INSERT INTO pr_reaction (id, pull_request_id, user_id, content, target_type, target_id, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, pull_request_id, user_id, content, target_type, target_id, created_at",
|
||||
)
|
||||
.bind(Uuid::now_v7()).bind(pr.id).bind(user_uid).bind(&content)
|
||||
.bind(target_type).bind(params.target_id).bind(now)
|
||||
.fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn pr_remove_reaction(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
reaction_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_readable(user_uid, &pr).await?;
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM pr_reaction WHERE id = $1 AND pull_request_id = $2 AND user_id = $3",
|
||||
)
|
||||
.bind(reaction_id)
|
||||
.bind(pr.id)
|
||||
.bind(user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(
|
||||
result.rows_affected(),
|
||||
"reaction not found or not authored by you",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::prs::{PrReview, PrReviewComment};
|
||||
use crate::service::PrService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, required_text};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct CreateReviewParams {
|
||||
pub body: Option<String>,
|
||||
pub state: Option<String>,
|
||||
pub commit_sha: Option<String>,
|
||||
pub comments: Option<Vec<ReviewCommentParams>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct ReviewCommentParams {
|
||||
pub path: String,
|
||||
pub body: String,
|
||||
pub line: Option<i32>,
|
||||
pub start_line: Option<i32>,
|
||||
pub diff_hunk: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct SubmitReviewParams {
|
||||
pub body: Option<String>,
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct DismissReviewParams {
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct AddReplyParams {
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
impl PrService {
|
||||
pub async fn pr_list_reviews(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<PrReview>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_readable(user_uid, &pr).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, PrReview>(
|
||||
"SELECT id, pull_request_id, author_id, state, body, commit_sha, \
|
||||
submitted_at, dismissed_at, dismissed_by, dismiss_reason, created_at, updated_at \
|
||||
FROM pr_review WHERE pull_request_id = $1 \
|
||||
ORDER BY created_at ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(pr.id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn pr_create_review(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
params: CreateReviewParams,
|
||||
) -> Result<PrReview, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_readable(user_uid, &pr).await?;
|
||||
|
||||
let state = params.state.as_deref().unwrap_or("pending");
|
||||
if !["pending", "approved", "changes_requested", "commented"].contains(&state) {
|
||||
return Err(AppError::BadRequest("invalid review state".into()));
|
||||
}
|
||||
if matches!(state, "approved" | "changes_requested") {
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Member)
|
||||
.await?;
|
||||
if state == "approved" && pr.author_id == user_uid {
|
||||
return Err(AppError::BadRequest(
|
||||
"PR authors cannot approve their own pull requests".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let review_id = Uuid::now_v7();
|
||||
|
||||
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 review = sqlx::query_as::<_, PrReview>(
|
||||
"INSERT INTO pr_review (id, pull_request_id, author_id, state, body, commit_sha, \
|
||||
submitted_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) \
|
||||
RETURNING id, pull_request_id, author_id, state, body, commit_sha, \
|
||||
submitted_at, dismissed_at, dismissed_by, dismiss_reason, created_at, updated_at",
|
||||
)
|
||||
.bind(review_id)
|
||||
.bind(pr.id)
|
||||
.bind(user_uid)
|
||||
.bind(state)
|
||||
.bind(params.body.as_deref())
|
||||
.bind(
|
||||
params
|
||||
.commit_sha
|
||||
.as_deref()
|
||||
.or(Some(pr.head_commit_sha.as_str())),
|
||||
)
|
||||
.bind(if state != "pending" { Some(now) } else { None })
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if let Some(comments) = ¶ms.comments {
|
||||
for c in comments {
|
||||
let path = required_text(c.path.clone(), "path")?;
|
||||
let body = required_text(c.body.clone(), "body")?;
|
||||
sqlx::query(
|
||||
"INSERT INTO pr_review_comment (id, review_id, pull_request_id, author_id, body, path, \
|
||||
line, original_line, start_line, original_start_line, diff_hunk, in_reply_to_id, \
|
||||
edited_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NULL, NULL, $12, $12)",
|
||||
)
|
||||
.bind(Uuid::now_v7()).bind(review_id).bind(pr.id).bind(user_uid)
|
||||
.bind(&body).bind(&path)
|
||||
.bind(c.line).bind(c.line)
|
||||
.bind(c.start_line).bind(c.start_line)
|
||||
.bind(c.diff_hunk.as_deref())
|
||||
.bind(now)
|
||||
.execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(state, "approved" | "changes_requested") {
|
||||
sqlx::query(
|
||||
"UPDATE pr_status SET approvals_count = (SELECT COUNT(*) FROM pr_review r \
|
||||
JOIN pull_request pr ON pr.id = r.pull_request_id \
|
||||
WHERE r.pull_request_id = $1 AND r.state = 'approved' AND r.dismissed_at IS NULL \
|
||||
AND r.submitted_at IS NOT NULL AND r.author_id <> pr.author_id), \
|
||||
updated_at = $2 WHERE pull_request_id = $1",
|
||||
)
|
||||
.bind(pr.id)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(review)
|
||||
}
|
||||
|
||||
pub async fn pr_submit_review(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
review_id: Uuid,
|
||||
params: SubmitReviewParams,
|
||||
) -> Result<PrReview, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_readable(user_uid, &pr).await?;
|
||||
|
||||
let state = params.state.as_str();
|
||||
if !["approved", "changes_requested", "commented"].contains(&state) {
|
||||
return Err(AppError::BadRequest("invalid review state".into()));
|
||||
}
|
||||
|
||||
if matches!(state, "approved" | "changes_requested") {
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Member)
|
||||
.await?;
|
||||
if state == "approved" && pr.author_id == user_uid {
|
||||
return Err(AppError::BadRequest(
|
||||
"PR authors cannot approve their own pull requests".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let now = 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 review = sqlx::query_as::<_, PrReview>(
|
||||
"UPDATE pr_review SET state = $1, body = COALESCE($2, body), submitted_at = $3, updated_at = $3 \
|
||||
WHERE id = $4 AND pull_request_id = $5 AND author_id = $6 AND submitted_at IS NULL AND dismissed_at IS NULL \
|
||||
RETURNING id, pull_request_id, author_id, state, body, commit_sha, \
|
||||
submitted_at, dismissed_at, dismissed_by, dismiss_reason, created_at, updated_at",
|
||||
)
|
||||
.bind(state).bind(params.body.as_deref()).bind(now)
|
||||
.bind(review_id).bind(pr.id).bind(user_uid)
|
||||
.fetch_optional(&mut *txn).await.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("review not found or already submitted".into()))?;
|
||||
|
||||
if state == "approved" || state == "changes_requested" {
|
||||
sqlx::query(
|
||||
"UPDATE pr_status SET approvals_count = (SELECT COUNT(*) FROM pr_review r \
|
||||
JOIN pull_request pr ON pr.id = r.pull_request_id \
|
||||
WHERE r.pull_request_id = $1 AND r.state = 'approved' AND r.dismissed_at IS NULL \
|
||||
AND r.submitted_at IS NOT NULL AND r.author_id <> pr.author_id), \
|
||||
updated_at = $2 WHERE pull_request_id = $1",
|
||||
)
|
||||
.bind(pr.id)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(review)
|
||||
}
|
||||
|
||||
pub async fn pr_dismiss_review(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
review_id: Uuid,
|
||||
params: DismissReviewParams,
|
||||
) -> Result<PrReview, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let reason = required_text(params.reason, "reason")?;
|
||||
let now = 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 review = sqlx::query_as::<_, PrReview>(
|
||||
"UPDATE pr_review SET dismissed_at = $1, dismissed_by = $2, dismiss_reason = $3, updated_at = $1 \
|
||||
WHERE id = $4 AND pull_request_id = $5 AND submitted_at IS NOT NULL AND dismissed_at IS NULL \
|
||||
RETURNING id, pull_request_id, author_id, state, body, commit_sha, \
|
||||
submitted_at, dismissed_at, dismissed_by, dismiss_reason, created_at, updated_at",
|
||||
)
|
||||
.bind(now).bind(user_uid).bind(&reason)
|
||||
.bind(review_id).bind(pr.id)
|
||||
.fetch_optional(&mut *txn).await.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("review not found or not submitted".into()))?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE pr_status SET approvals_count = (SELECT COUNT(*) FROM pr_review \
|
||||
WHERE pull_request_id = $1 AND state = 'approved' AND dismissed_at IS NULL), \
|
||||
updated_at = $2 WHERE pull_request_id = $1",
|
||||
)
|
||||
.bind(pr.id)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(review)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn pr_review_comments(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
review_id: Uuid,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<PrReviewComment>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_readable(user_uid, &pr).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, PrReviewComment>(
|
||||
"SELECT id, review_id, pull_request_id, author_id, body, path, line, original_line, \
|
||||
start_line, original_start_line, diff_hunk, in_reply_to_id, edited_at, created_at, updated_at \
|
||||
FROM pr_review_comment WHERE review_id = $1 AND pull_request_id = $2 ORDER BY created_at ASC LIMIT $3 OFFSET $4",
|
||||
)
|
||||
.bind(review_id).bind(pr.id).bind(limit).bind(offset)
|
||||
.fetch_all(self.ctx.db.reader()).await.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn pr_add_review_reply(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
comment_id: Uuid,
|
||||
params: AddReplyParams,
|
||||
) -> Result<PrReviewComment, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_readable(user_uid, &pr).await?;
|
||||
|
||||
let body = required_text(params.body, "body")?;
|
||||
let parent = sqlx::query_as::<_, PrReviewComment>(
|
||||
"SELECT id, review_id, pull_request_id, author_id, body, path, line, original_line, \
|
||||
start_line, original_start_line, diff_hunk, in_reply_to_id, edited_at, created_at, updated_at \
|
||||
FROM pr_review_comment WHERE id = $1 AND pull_request_id = $2",
|
||||
)
|
||||
.bind(comment_id).bind(pr.id)
|
||||
.fetch_optional(self.ctx.db.reader()).await.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("comment not found".into()))?;
|
||||
|
||||
let now = Utc::now();
|
||||
sqlx::query_as::<_, PrReviewComment>(
|
||||
"INSERT INTO pr_review_comment (id, review_id, pull_request_id, author_id, body, path, \
|
||||
line, original_line, start_line, original_start_line, diff_hunk, in_reply_to_id, \
|
||||
edited_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULL, $13, $13) \
|
||||
RETURNING id, review_id, pull_request_id, author_id, body, path, line, original_line, \
|
||||
start_line, original_start_line, diff_hunk, in_reply_to_id, edited_at, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7()).bind(parent.review_id).bind(pr.id).bind(user_uid)
|
||||
.bind(&body).bind(&parent.path)
|
||||
.bind(parent.line).bind(parent.original_line)
|
||||
.bind(parent.start_line).bind(parent.original_start_line)
|
||||
.bind(parent.diff_hunk.as_deref())
|
||||
.bind(comment_id).bind(now)
|
||||
.fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn pr_update_review_comment(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
comment_id: Uuid,
|
||||
params: AddReplyParams,
|
||||
) -> Result<PrReviewComment, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
let body = required_text(params.body, "body")?;
|
||||
let now = Utc::now();
|
||||
sqlx::query_as::<_, PrReviewComment>(
|
||||
"UPDATE pr_review_comment SET body = $1, edited_at = $2, updated_at = $2 \
|
||||
WHERE id = $3 AND pull_request_id = $4 AND author_id = $5 \
|
||||
RETURNING id, review_id, pull_request_id, author_id, body, path, line, original_line, \
|
||||
start_line, original_start_line, diff_hunk, in_reply_to_id, edited_at, created_at, updated_at",
|
||||
)
|
||||
.bind(&body).bind(now).bind(comment_id).bind(pr.id).bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.writer()).await.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("comment not found or not authored by you".into()))
|
||||
}
|
||||
|
||||
pub async fn pr_delete_review_comment(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
comment_id: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
let repo = self.resolve_repo(wk_name, repo_name).await?;
|
||||
|
||||
let comment = sqlx::query_as::<_, PrReviewComment>(
|
||||
"SELECT id, review_id, pull_request_id, author_id, body, path, line, original_line, \
|
||||
start_line, original_start_line, diff_hunk, in_reply_to_id, edited_at, created_at, updated_at \
|
||||
FROM pr_review_comment WHERE id = $1 AND pull_request_id = $2",
|
||||
)
|
||||
.bind(comment_id).bind(pr.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_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let result = sqlx::query("DELETE FROM pr_review_comment WHERE id = $1")
|
||||
.bind(comment_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "comment not found")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use crate::error::AppError;
|
||||
use crate::models::prs::PrStatus;
|
||||
use crate::service::PrService;
|
||||
use crate::session::Session;
|
||||
|
||||
impl PrService {
|
||||
pub async fn pr_status(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
) -> Result<PrStatus, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_readable(user_uid, &pr).await?;
|
||||
sqlx::query_as::<_, PrStatus>(
|
||||
"SELECT pull_request_id, head_commit_sha, checks_state, mergeable_state, conflicts, \
|
||||
approvals_count, requested_reviews_count, changed_files_count, additions_count, \
|
||||
deletions_count, updated_at \
|
||||
FROM pr_status WHERE pull_request_id = $1",
|
||||
)
|
||||
.bind(pr.id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("PR status not found".into()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::prs::PrSubscription;
|
||||
use crate::service::PrService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected};
|
||||
|
||||
impl PrService {
|
||||
pub async fn pr_subscriptions(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<PrSubscription>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_readable(user_uid, &pr).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, PrSubscription>(
|
||||
"SELECT id, pull_request_id, user_id, reason, muted, created_at, updated_at \
|
||||
FROM pr_subscription WHERE pull_request_id = $1 ORDER BY created_at ASC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(pr.id).bind(limit).bind(offset)
|
||||
.fetch_all(self.ctx.db.reader()).await.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn pr_subscribe(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
) -> Result<PrSubscription, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_readable(user_uid, &pr).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 sub = sqlx::query_as::<_, PrSubscription>(
|
||||
"INSERT INTO pr_subscription (id, pull_request_id, user_id, reason, muted, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, 'manual', false, $4, $4) ON CONFLICT (pull_request_id, user_id) DO NOTHING \
|
||||
RETURNING id, pull_request_id, user_id, reason, muted, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7()).bind(pr.id).bind(user_uid).bind(now)
|
||||
.fetch_optional(&mut *txn).await.map_err(AppError::Database)?
|
||||
.ok_or(AppError::Conflict("already subscribed".into()))?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(sub)
|
||||
}
|
||||
|
||||
pub async fn pr_unsubscribe(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_readable(user_uid, &pr).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 result =
|
||||
sqlx::query("DELETE FROM pr_subscription WHERE pull_request_id = $1 AND user_id = $2")
|
||||
.bind(pr.id)
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "not subscribed")?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn pr_mute(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
number: i64,
|
||||
muted: bool,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let pr = self.resolve_pr(wk_name, repo_name, number).await?;
|
||||
self.ensure_pr_readable(user_uid, &pr).await?;
|
||||
let result = sqlx::query(
|
||||
"UPDATE pr_subscription SET muted = $1, updated_at = $2 WHERE pull_request_id = $3 AND user_id = $4",
|
||||
)
|
||||
.bind(muted).bind(chrono::Utc::now()).bind(pr.id).bind(user_uid)
|
||||
.execute(self.ctx.db.writer()).await.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "not subscribed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub use crate::service::util::{
|
||||
clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text, role_level,
|
||||
};
|
||||
@@ -0,0 +1,278 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::repos::RepoBranch;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, required_text};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateBranchParams {
|
||||
pub name: String,
|
||||
pub commit_sha: String,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_branches(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoBranch>, 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_readable(user_uid, &repo).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoBranch>(
|
||||
"SELECT id, repo_id, name, commit_sha, protected, default_branch, created_by, last_push_id, last_push_at, created_at, updated_at FROM repo_branch WHERE repo_id = $1 ORDER BY default_branch DESC, 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 repo_create_branch(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateBranchParams,
|
||||
) -> Result<RepoBranch, 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 name = required_text(params.name, "name")?;
|
||||
|
||||
let existing = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_branch WHERE repo_id = $1 AND name = $2)",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(&name)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if existing {
|
||||
return Err(AppError::Conflict("branch already exists".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)?;
|
||||
|
||||
let branch = sqlx::query_as::<_, RepoBranch>(
|
||||
"INSERT INTO repo_branch (id, repo_id, name, commit_sha, protected, default_branch, created_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, false, false, $5, $6, $6) RETURNING id, repo_id, name, commit_sha, protected, default_branch, created_by, last_push_id, last_push_at, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(&name)
|
||||
.bind(¶ms.commit_sha)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET branches_count = branches_count + 1, updated_at = $1 WHERE repo_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(branch)
|
||||
}
|
||||
|
||||
pub async fn repo_set_default_branch(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
branch_id: Uuid,
|
||||
) -> 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 branch = sqlx::query_as::<_, RepoBranch>(
|
||||
"SELECT id, repo_id, name, commit_sha, protected, default_branch, created_by, last_push_id, last_push_at, created_at, updated_at FROM repo_branch WHERE id = $1 AND repo_id = $2",
|
||||
)
|
||||
.bind(branch_id)
|
||||
.bind(repo_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("branch 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 app.current_user_id = $1")
|
||||
.bind(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 id = $2")
|
||||
.bind(now)
|
||||
.bind(branch_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query("UPDATE repo SET default_branch = $1, updated_at = $2 WHERE id = $3 AND deleted_at IS NULL")
|
||||
.bind(&branch.name)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_set_branch_protection(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
branch_id: Uuid,
|
||||
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 app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE repo_branch SET protected = $1, updated_at = $2 WHERE id = $3 AND repo_id = $4",
|
||||
)
|
||||
.bind(protected)
|
||||
.bind(now)
|
||||
.bind(branch_id)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "branch not found")?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_delete_branch(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
branch_id: Uuid,
|
||||
) -> 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 is_default = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT default_branch FROM repo_branch WHERE id = $1 AND repo_id = $2",
|
||||
)
|
||||
.bind(branch_id)
|
||||
.bind(repo_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("branch not found".into()))?;
|
||||
|
||||
if is_default {
|
||||
return Err(AppError::BadRequest("cannot delete default branch".into()));
|
||||
}
|
||||
|
||||
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 repo_branch WHERE id = $1 AND repo_id = $2")
|
||||
.bind(branch_id)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "branch not found")?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET branches_count = GREATEST(branches_count - 1, 0), updated_at = $1 WHERE repo_id = $2",
|
||||
)
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::{Role, State};
|
||||
use crate::models::repos::{RepoCommitComment, RepoCommitStatus};
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, required_text};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateCommitStatusParams {
|
||||
pub push_commit_id: Uuid,
|
||||
pub latest_commit_sha: String,
|
||||
pub context: String,
|
||||
pub state: String,
|
||||
pub target_url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateCommitCommentParams {
|
||||
pub push_commit_id: Uuid,
|
||||
pub commit_sha: String,
|
||||
pub body: String,
|
||||
pub path: Option<String>,
|
||||
pub line: Option<i32>,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_commit_statuses(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
push_commit_id: Uuid,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoCommitStatus>, 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_readable(user_uid, &repo).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoCommitStatus>(
|
||||
"SELECT id, repo_id, push_commit_id, latest_commit_sha, context, state, target_url, description, reported_by, reported_at, created_at, updated_at FROM repo_commit_status WHERE repo_id = $1 AND push_commit_id = $2 ORDER BY created_at DESC LIMIT $3 OFFSET $4",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(push_commit_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn repo_create_commit_status(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateCommitStatusParams,
|
||||
) -> Result<RepoCommitStatus, 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 context = required_text(params.context, "context")?;
|
||||
let state = params
|
||||
.state
|
||||
.trim()
|
||||
.parse::<State>()
|
||||
.map_err(|_| AppError::BadRequest("invalid state".into()))?;
|
||||
if state == State::Unknown {
|
||||
return Err(AppError::BadRequest("invalid state".into()));
|
||||
}
|
||||
let latest_commit_sha = required_text(params.latest_commit_sha, "latest_commit_sha")?;
|
||||
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_as::<_, RepoCommitStatus>(
|
||||
"INSERT INTO repo_commit_status (id, repo_id, push_commit_id, latest_commit_sha, context, \
|
||||
state, target_url, description, reported_by, reported_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10, $10) RETURNING id, repo_id, push_commit_id, latest_commit_sha, context, state, target_url, description, reported_by, reported_at, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(params.push_commit_id)
|
||||
.bind(&latest_commit_sha)
|
||||
.bind(&context)
|
||||
.bind(state)
|
||||
.bind(¶ms.target_url)
|
||||
.bind(¶ms.description)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn repo_commit_comments(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
push_commit_id: Uuid,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<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_readable(user_uid, &repo).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoCommitComment>(
|
||||
"SELECT id, repo_id, push_commit_id, commit_sha, author_id, body, path, line, resolved, resolved_by, resolved_at, created_at, updated_at, deleted_at FROM repo_commit_comment WHERE repo_id = $1 AND push_commit_id = $2 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT $3 OFFSET $4",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(push_commit_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn repo_create_commit_comment(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateCommitCommentParams,
|
||||
) -> 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(params.body, "body")?;
|
||||
let commit_sha = required_text(params.commit_sha, "commit_sha")?;
|
||||
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_as::<_, RepoCommitComment>(
|
||||
"INSERT INTO repo_commit_comment (id, repo_id, push_commit_id, commit_sha, author_id, body, \
|
||||
path, line, resolved, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, false, $9, $9) 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(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(params.push_commit_id)
|
||||
.bind(&commit_sha)
|
||||
.bind(user_uid)
|
||||
.bind(&body)
|
||||
.bind(¶ms.path)
|
||||
.bind(params.line)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn repo_resolve_commit_comment(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
comment_id: Uuid,
|
||||
) -> 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::Member)
|
||||
.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 repo_commit_comment SET resolved = true, resolved_by = $1, resolved_at = $2, updated_at = $2 \
|
||||
WHERE id = $3 AND repo_id = $4 AND deleted_at IS NULL AND resolved = false",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.bind(comment_id)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
super::util::ensure_affected(
|
||||
result.rows_affected(),
|
||||
"comment not found or already resolved",
|
||||
)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,789 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::{GitService, Role, Visibility};
|
||||
use crate::models::repos::Repo;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{
|
||||
clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text,
|
||||
};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateRepoParams {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub visibility: Option<String>,
|
||||
pub default_branch: Option<String>,
|
||||
pub git_service: Option<String>,
|
||||
pub storage_node_ids: Option<Vec<Uuid>>,
|
||||
pub storage_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateRepoParams {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub visibility: Option<String>,
|
||||
pub default_branch: Option<String>,
|
||||
}
|
||||
|
||||
fn validate_storage_path(path: &str) -> Result<String, AppError> {
|
||||
let path = path.trim().trim_matches('/');
|
||||
if path.is_empty()
|
||||
|| path.contains("..")
|
||||
|| path.split('/').any(|part| part.is_empty() || part == ".")
|
||||
{
|
||||
return Err(AppError::BadRequest("storage_path is invalid".into()));
|
||||
}
|
||||
Ok(path.to_string())
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_list(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<Repo>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
self.ensure_workspace_readable(user_uid, &ws).await?;
|
||||
let workspace_id = ws.id;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, Repo>(
|
||||
"SELECT id, workspace_id, owner_id, name, description, default_branch, visibility, status, is_fork, forked_from_repo_id, storage_node_ids, primary_storage_node_id, storage_path, git_service, archived_at, created_at, updated_at, deleted_at \
|
||||
FROM repo WHERE workspace_id = $1 AND deleted_at IS NULL AND (owner_id = $2 OR visibility = 'public' OR id IN (SELECT repo_id FROM repo_member WHERE user_id = $2 AND status = 'active') OR (visibility = 'internal' AND EXISTS (SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active'))) \
|
||||
ORDER BY created_at DESC LIMIT $3 OFFSET $4",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.bind(user_uid)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn repo_get(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<Repo, 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?;
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
pub async fn repo_create(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
params: CreateRepoParams,
|
||||
) -> Result<Repo, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
let workspace_id = ws.id;
|
||||
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Member)
|
||||
.await?;
|
||||
|
||||
let name = required_text(params.name, "name")?;
|
||||
let visibility = match params.visibility {
|
||||
Some(ref v) => parse_enum(
|
||||
Some(v.clone()),
|
||||
Visibility::Private,
|
||||
Visibility::Unknown,
|
||||
"visibility",
|
||||
)?,
|
||||
None => {
|
||||
let settings_visibility: String = sqlx::query_scalar(
|
||||
"SELECT default_repo_visibility FROM workspace_settings WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
settings_visibility.parse().unwrap_or(Visibility::Private)
|
||||
}
|
||||
};
|
||||
if visibility == Visibility::Public {
|
||||
let allow_public_repos: bool = sqlx::query_scalar(
|
||||
"SELECT allow_public_repos FROM workspace_settings WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(workspace_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if !allow_public_repos {
|
||||
return Err(AppError::BadRequest(
|
||||
"public repositories are disabled for this workspace".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let default_branch = required_text(
|
||||
params.default_branch.unwrap_or_else(|| "main".to_string()),
|
||||
"default_branch",
|
||||
)?;
|
||||
let git_service = match params.git_service {
|
||||
Some(ref v) => parse_enum(
|
||||
Some(v.clone()),
|
||||
GitService::Local,
|
||||
GitService::Unknown,
|
||||
"git_service",
|
||||
)?,
|
||||
None => GitService::Local,
|
||||
};
|
||||
|
||||
let available_storage_nodes: std::collections::HashSet<Uuid> =
|
||||
self.ctx.registry.git_node_ids().into_iter().collect();
|
||||
if available_storage_nodes.is_empty() {
|
||||
return Err(AppError::Config("no git storage nodes configured".into()));
|
||||
}
|
||||
let storage_node_ids = params.storage_node_ids.unwrap_or_else(|| {
|
||||
available_storage_nodes
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<Vec<Uuid>>()
|
||||
});
|
||||
if storage_node_ids.is_empty()
|
||||
|| storage_node_ids
|
||||
.iter()
|
||||
.any(|node_id| !available_storage_nodes.contains(node_id))
|
||||
{
|
||||
return Err(AppError::BadRequest("invalid storage_node_ids".into()));
|
||||
}
|
||||
let primary_storage_node_id = storage_node_ids[0];
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let repo_id = Uuid::now_v7();
|
||||
let storage_path = match params.storage_path {
|
||||
Some(path) if !path.trim().is_empty() => validate_storage_path(&path)?,
|
||||
Some(_) => return Err(AppError::BadRequest("storage_path is invalid".into())),
|
||||
None => format!("repos/{}/{}", workspace_id, repo_id),
|
||||
};
|
||||
|
||||
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 repo = sqlx::query_as::<_, Repo>(
|
||||
"INSERT INTO repo (id, workspace_id, owner_id, name, description, default_branch, \
|
||||
visibility, status, is_fork, forked_from_repo_id, storage_node_ids, \
|
||||
primary_storage_node_id, storage_path, git_service, archived_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', false, NULL, $8, $9, $10, $11, NULL, $12, $12) \
|
||||
RETURNING id, workspace_id, owner_id, name, description, default_branch, visibility, status, is_fork, forked_from_repo_id, storage_node_ids, primary_storage_node_id, storage_path, git_service, archived_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(workspace_id)
|
||||
.bind(user_uid)
|
||||
.bind(&name)
|
||||
.bind(params.description.as_deref())
|
||||
.bind(&default_branch)
|
||||
.bind(visibility)
|
||||
.bind(&storage_node_ids)
|
||||
.bind(primary_storage_node_id)
|
||||
.bind(&storage_path)
|
||||
.bind(git_service)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO repo_member (id, repo_id, user_id, role, status, joined_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, 'owner', 'active', $4, $4, $4)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO repo_stats (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, updated_at) \
|
||||
VALUES ($1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, $2)",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO repo_branch (id, repo_id, name, commit_sha, protected, default_branch, created_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, '', false, true, $4, $5, $5)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(&default_branch)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE workspace_stats SET repos_count = repos_count + 1, updated_at = $1 WHERE workspace_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(workspace_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
// Init git repo on primary node (nodes auto-sync).
|
||||
if let Some(mut client) = self.ctx.registry.get_git_client(&primary_storage_node_id) {
|
||||
let req = tonic::Request::new(crate::pb::repo::InitRepositoryRequest {
|
||||
repository: Some(crate::pb::repo::RepositoryHeader {
|
||||
storage_name: ws.name.clone(),
|
||||
relative_path: format!("{}.git", name),
|
||||
storage_path: storage_path.clone(),
|
||||
}),
|
||||
bare: true,
|
||||
object_format: crate::pb::repo::ObjectFormat::Sha1 as i32,
|
||||
initial_branch: default_branch.clone(),
|
||||
});
|
||||
if let Err(err) = client.repository.init_repository(req).await {
|
||||
tracing::error!(repo_id = %repo_id, error = %err, "Failed to init git repo");
|
||||
let _ = sqlx::query(
|
||||
"UPDATE repo SET status = 'deleted', deleted_at = $1 WHERE id = $2",
|
||||
)
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(repo_id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await;
|
||||
let _ = sqlx::query("UPDATE workspace_stats SET repos_count = GREATEST(repos_count - 1, 0), updated_at = $1 WHERE workspace_id = $2")
|
||||
.bind(chrono::Utc::now()).bind(workspace_id).execute(self.ctx.db.writer()).await;
|
||||
return Err(AppError::InternalServerError(err.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(repo)
|
||||
}
|
||||
|
||||
pub async fn repo_update(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: UpdateRepoParams,
|
||||
) -> Result<Repo, 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 name =
|
||||
merge_optional_text(params.name, Some(repo.name.clone())).unwrap_or(repo.name.clone());
|
||||
let description = merge_optional_text(params.description, repo.description);
|
||||
let visibility = parse_enum(
|
||||
params.visibility,
|
||||
repo.visibility,
|
||||
Visibility::Unknown,
|
||||
"visibility",
|
||||
)?;
|
||||
if visibility == Visibility::Public {
|
||||
let allow_public_repos: bool = sqlx::query_scalar(
|
||||
"SELECT allow_public_repos FROM workspace_settings WHERE workspace_id = $1",
|
||||
)
|
||||
.bind(repo.workspace_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if !allow_public_repos {
|
||||
return Err(AppError::BadRequest(
|
||||
"public repositories are disabled for this workspace".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)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo SET name = $1, description = $2, visibility = $3, updated_at = $4 \
|
||||
WHERE id = $5 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(&name)
|
||||
.bind(&description)
|
||||
.bind(visibility)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if let Some(ref new_default) = params.default_branch
|
||||
&& new_default != &repo.default_branch
|
||||
{
|
||||
// Check if the branch exists
|
||||
let branch_exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_branch WHERE repo_id = $1 AND name = $2)",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(new_default)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if !branch_exists {
|
||||
return Err(AppError::BadRequest(format!(
|
||||
"Branch '{}' does not exist",
|
||||
new_default
|
||||
)));
|
||||
}
|
||||
|
||||
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(new_default)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query("UPDATE repo SET default_branch = $1, updated_at = $2 WHERE id = $3")
|
||||
.bind(new_default)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
let result = sqlx::query_as::<_, Repo>(
|
||||
"SELECT id, workspace_id, owner_id, name, description, default_branch, visibility, status, is_fork, forked_from_repo_id, storage_node_ids, primary_storage_node_id, storage_path, git_service, archived_at, created_at, updated_at, deleted_at \
|
||||
FROM repo WHERE id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn repo_archive(
|
||||
&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?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Owner)
|
||||
.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 repo SET status = 'archived', archived_at = $1, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL AND status <> 'archived'",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "repo not found or already archived")?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_unarchive(
|
||||
&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?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Owner)
|
||||
.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 repo SET status = 'active', archived_at = NULL, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL AND status = 'archived'",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "repo not found or not archived")?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_delete(
|
||||
&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?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_role_at_least(user_uid, &repo, Role::Owner)
|
||||
.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 repo SET deleted_at = $1, status = 'deleted', updated_at = $1 WHERE id = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "repo not found")?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE workspace_stats SET repos_count = GREATEST(repos_count - 1, 0), updated_at = $1 WHERE workspace_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo.workspace_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_transfer_owner(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
new_owner_id: Uuid,
|
||||
) -> Result<Repo, 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::Owner)
|
||||
.await?;
|
||||
|
||||
if new_owner_id == repo.owner_id {
|
||||
return Err(AppError::BadRequest(
|
||||
"new owner must be different from current owner".into(),
|
||||
));
|
||||
}
|
||||
let is_workspace_member = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active')",
|
||||
)
|
||||
.bind(repo.workspace_id)
|
||||
.bind(new_owner_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
let is_member = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_member WHERE repo_id = $1 AND user_id = $2 AND status = 'active')",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(new_owner_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if !is_workspace_member || !is_member {
|
||||
return Err(AppError::BadRequest(
|
||||
"new owner must be an active workspace and repo member".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)?;
|
||||
|
||||
sqlx::query("UPDATE repo_member SET role = 'owner', updated_at = $1 WHERE repo_id = $2 AND user_id = $3")
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.bind(new_owner_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query("UPDATE repo_member SET role = 'admin', updated_at = $1 WHERE repo_id = $2 AND user_id = $3")
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query_as::<_, Repo>(
|
||||
"UPDATE repo SET owner_id = $1, updated_at = $2 WHERE id = $3 AND deleted_at IS NULL \
|
||||
RETURNING id, workspace_id, owner_id, name, description, default_branch, visibility, status, is_fork, forked_from_repo_id, storage_node_ids, primary_storage_node_id, storage_path, git_service, archived_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(new_owner_id)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_workspace(
|
||||
&self,
|
||||
wk_name: &str,
|
||||
) -> Result<crate::models::workspaces::Workspace, AppError> {
|
||||
crate::models::workspaces::Workspace::find_by_name(self.ctx.db.reader(), wk_name)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("workspace not found".into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_repo(
|
||||
&self,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<Repo, AppError> {
|
||||
let ws = self.resolve_workspace(wk_name).await?;
|
||||
Repo::find_by_name(self.ctx.db.reader(), ws.id, repo_name)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("repo not found".into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn find_repo_by_id(&self, repo_id: Uuid) -> Result<Repo, AppError> {
|
||||
sqlx::query_as::<_, Repo>(
|
||||
"SELECT id, workspace_id, owner_id, name, description, default_branch, visibility, status, is_fork, forked_from_repo_id, storage_node_ids, primary_storage_node_id, storage_path, git_service, archived_at, created_at, updated_at, deleted_at FROM repo WHERE id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("repo not found".into()))
|
||||
}
|
||||
|
||||
pub async fn repo_user_role(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
repo_id: Uuid,
|
||||
) -> Result<Option<Role>, AppError> {
|
||||
let role_str: Option<String> = sqlx::query_scalar(
|
||||
"SELECT role FROM repo_member WHERE repo_id = $1 AND user_id = $2 AND status = 'active'",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
match role_str {
|
||||
Some(r) => Ok(Some(r.parse().unwrap_or(Role::Unknown))),
|
||||
None => {
|
||||
let repo = self.find_repo_by_id(repo_id).await?;
|
||||
if repo.owner_id == user_uid {
|
||||
return Ok(Some(Role::Owner));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ensure_repo_readable(&self, user_uid: Uuid, repo: &Repo) -> Result<(), AppError> {
|
||||
if repo.owner_id == user_uid {
|
||||
return Ok(());
|
||||
}
|
||||
let is_workspace_member = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active')",
|
||||
)
|
||||
.bind(repo.workspace_id)
|
||||
.bind(user_uid)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
let is_member = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_member WHERE repo_id = $1 AND user_id = $2 AND status = 'active')",
|
||||
)
|
||||
.bind(repo.id)
|
||||
.bind(user_uid)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
match repo.visibility {
|
||||
Visibility::Public => Ok(()),
|
||||
Visibility::Internal if is_workspace_member => Ok(()),
|
||||
Visibility::Private if is_workspace_member && is_member => Ok(()),
|
||||
_ => Err(AppError::Unauthorized),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ensure_repo_role_at_least(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
repo: &Repo,
|
||||
min_role: Role,
|
||||
) -> Result<Role, AppError> {
|
||||
if repo.owner_id == user_uid {
|
||||
return Ok(Role::Owner);
|
||||
}
|
||||
let is_workspace_member = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active')",
|
||||
)
|
||||
.bind(repo.workspace_id)
|
||||
.bind(user_uid)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if !is_workspace_member {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
let role_str: Option<String> = sqlx::query_scalar(
|
||||
"SELECT role FROM repo_member WHERE repo_id = $1 AND user_id = $2 AND status = 'active'",
|
||||
)
|
||||
.bind(repo.id)
|
||||
.bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let role = role_str
|
||||
.and_then(|r| r.parse::<Role>().ok())
|
||||
.unwrap_or(Role::Unknown);
|
||||
|
||||
if super::util::role_level(role) < super::util::role_level(min_role) {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
Ok(role)
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_workspace_readable(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
ws: &crate::models::workspaces::Workspace,
|
||||
) -> Result<(), AppError> {
|
||||
let readable =
|
||||
crate::models::workspaces::Workspace::is_readable(self.ctx.db.reader(), ws, user_uid)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if readable {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Unauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_workspace_role_at_least(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
ws: &crate::models::workspaces::Workspace,
|
||||
min_role: Role,
|
||||
) -> Result<Role, AppError> {
|
||||
let role = crate::models::workspaces::Workspace::user_role(
|
||||
self.ctx.db.reader(),
|
||||
ws.id,
|
||||
user_uid,
|
||||
ws.owner_id,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.unwrap_or(Role::Unknown);
|
||||
if crate::service::util::role_level(role) < crate::service::util::role_level(min_role) {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
Ok(role)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::{KeyType, Role};
|
||||
use crate::models::repos::RepoDeployKey;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, required_text};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct AddDeployKeyParams {
|
||||
pub title: String,
|
||||
pub public_key: String,
|
||||
pub key_type: String,
|
||||
pub read_only: Option<bool>,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_deploy_keys(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoDeployKey>, 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 (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoDeployKey>(
|
||||
"SELECT id, repo_id, title, public_key, fingerprint_sha256, key_type, read_only, last_used_at, expires_at, revoked_at, created_by, created_at, updated_at FROM repo_deploy_key WHERE repo_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC 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 repo_add_deploy_key(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: AddDeployKeyParams,
|
||||
) -> Result<RepoDeployKey, 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 title = required_text(params.title, "title")?;
|
||||
let public_key = required_text(params.public_key, "public_key")?;
|
||||
let key_type = params
|
||||
.key_type
|
||||
.trim()
|
||||
.parse::<KeyType>()
|
||||
.map_err(|_| AppError::BadRequest("invalid key_type".into()))?;
|
||||
if key_type == KeyType::Unknown {
|
||||
return Err(AppError::BadRequest("invalid key_type".into()));
|
||||
}
|
||||
|
||||
use base64::Engine;
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(public_key.trim())
|
||||
.unwrap_or_else(|_| public_key.as_bytes().to_vec());
|
||||
let fingerprint = super::deploy_keys::sha256_hex(&decoded);
|
||||
|
||||
let existing = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_deploy_key WHERE fingerprint_sha256 = $1 AND repo_id = $2 AND revoked_at IS NULL LIMIT 1)",
|
||||
)
|
||||
.bind(&fingerprint)
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if existing {
|
||||
return Err(AppError::Conflict("deploy key already exists".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)?;
|
||||
|
||||
let key = sqlx::query_as::<_, RepoDeployKey>(
|
||||
"INSERT INTO repo_deploy_key (id, repo_id, title, public_key, fingerprint_sha256, key_type, \
|
||||
read_only, created_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) RETURNING id, repo_id, title, public_key, fingerprint_sha256, key_type, read_only, last_used_at, expires_at, revoked_at, created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(title)
|
||||
.bind(&public_key)
|
||||
.bind(&fingerprint)
|
||||
.bind(key_type)
|
||||
.bind(params.read_only.unwrap_or(true))
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
pub async fn repo_delete_deploy_key(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
key_id: Uuid,
|
||||
) -> 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 app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE repo_deploy_key SET revoked_at = $1, updated_at = $1 WHERE id = $2 AND repo_id = $3 AND revoked_at IS NULL",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(key_id)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "key not found")?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sha256_hex(data: &[u8]) -> String {
|
||||
use sha2::Digest;
|
||||
sha2::Sha256::digest(data)
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::repos::{Repo, RepoFork};
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::clamp_limit_offset;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct ForkRepoParams {
|
||||
pub target_workspace_name: Option<String>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_forks(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoFork>, 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::<_, RepoFork>(
|
||||
"SELECT id, parent_repo_id, fork_repo_id, forked_by, created_at \
|
||||
FROM repo_fork WHERE parent_repo_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(repo.id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(self, ctx, params))]
|
||||
pub async fn repo_fork(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: ForkRepoParams,
|
||||
) -> Result<Repo, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let parent = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_readable(user_uid, &parent).await?;
|
||||
|
||||
let ws_name = params.target_workspace_name.as_deref().unwrap_or(wk_name);
|
||||
let ws = self.resolve_workspace(ws_name).await?;
|
||||
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Member)
|
||||
.await?;
|
||||
|
||||
let fork_name = params.name.as_deref().unwrap_or(repo_name);
|
||||
|
||||
let existing = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo WHERE workspace_id = $1 AND name = $2 AND deleted_at IS NULL)",
|
||||
)
|
||||
.bind(ws.id).bind(fork_name)
|
||||
.fetch_one(self.ctx.db.reader()).await.map_err(AppError::Database)?;
|
||||
if existing {
|
||||
return Err(AppError::Conflict(
|
||||
"repo name already taken in target workspace".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let fork_id = Uuid::now_v7();
|
||||
let storage_path = format!("repos/{}/{}", ws.id, fork_id);
|
||||
let storage_node_ids = parent.storage_node_ids.clone();
|
||||
let primary_node_id = parent.primary_storage_node_id;
|
||||
|
||||
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 fork = sqlx::query_as::<_, Repo>(
|
||||
"INSERT INTO repo (id, workspace_id, owner_id, name, description, default_branch, \
|
||||
visibility, status, is_fork, forked_from_repo_id, storage_node_ids, \
|
||||
primary_storage_node_id, storage_path, git_service, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'private', 'active', true, $7, $8, $9, $10, $11, $12, $12) \
|
||||
RETURNING id, workspace_id, owner_id, name, description, default_branch, visibility, status, \
|
||||
is_fork, forked_from_repo_id, storage_node_ids, primary_storage_node_id, storage_path, \
|
||||
git_service, archived_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(fork_id).bind(ws.id).bind(user_uid)
|
||||
.bind(fork_name).bind(parent.description.as_deref())
|
||||
.bind(&parent.default_branch).bind(parent.id)
|
||||
.bind(&storage_node_ids).bind(primary_node_id)
|
||||
.bind(&storage_path).bind(parent.git_service)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn).await.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO repo_member (id, repo_id, user_id, role, status, joined_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, 'owner', 'active', $4, $4, $4)",
|
||||
)
|
||||
.bind(Uuid::now_v7()).bind(fork_id).bind(user_uid).bind(now)
|
||||
.execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO repo_stats (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, updated_at) \
|
||||
VALUES ($1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, $2)",
|
||||
)
|
||||
.bind(fork_id).bind(now)
|
||||
.execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO repo_branch (id, repo_id, name, commit_sha, protected, default_branch, created_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, '', false, true, $4, $5, $5)",
|
||||
)
|
||||
.bind(Uuid::now_v7()).bind(fork_id).bind(&parent.default_branch).bind(user_uid).bind(now)
|
||||
.execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO repo_fork (id, parent_repo_id, fork_repo_id, forked_by, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(parent.id)
|
||||
.bind(fork_id)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET forks_count = forks_count + 1, updated_at = $1 WHERE repo_id = $2",
|
||||
).bind(now).bind(parent.id).execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE workspace_stats SET repos_count = repos_count + 1, updated_at = $1 WHERE workspace_id = $2",
|
||||
).bind(now).bind(ws.id).execute(&mut *txn).await.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
if let Some(mut client) = self.ctx.registry.get_git_client(&primary_node_id) {
|
||||
let parent_ws = self.resolve_workspace(wk_name).await?;
|
||||
let _header = crate::pb::repo::RepositoryHeader {
|
||||
storage_name: parent_ws.name.clone(),
|
||||
relative_path: format!("{}.git", parent.name),
|
||||
storage_path: parent.storage_path.clone(),
|
||||
};
|
||||
let fork_header = crate::pb::repo::RepositoryHeader {
|
||||
storage_name: ws.name.clone(),
|
||||
relative_path: format!("{}.git", fork_name),
|
||||
storage_path: storage_path.clone(),
|
||||
};
|
||||
let _ = client
|
||||
.repository
|
||||
.init_repository(tonic::Request::new(
|
||||
crate::pb::repo::InitRepositoryRequest {
|
||||
repository: Some(fork_header),
|
||||
bare: true,
|
||||
object_format: crate::pb::repo::ObjectFormat::Sha1 as i32,
|
||||
initial_branch: parent.default_branch.clone(),
|
||||
},
|
||||
))
|
||||
.await;
|
||||
}
|
||||
|
||||
tracing::info!(fork_id = %fork_id, parent_id = %parent.id, "Repo forked");
|
||||
Ok(fork)
|
||||
}
|
||||
|
||||
pub async fn repo_sync_fork(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<Repo, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let fork = self.resolve_repo(wk_name, repo_name).await?;
|
||||
self.ensure_repo_role_at_least(user_uid, &fork, Role::Member)
|
||||
.await?;
|
||||
|
||||
if !fork.is_fork {
|
||||
return Err(AppError::BadRequest("repo is not a fork".into()));
|
||||
}
|
||||
let parent_id = fork
|
||||
.forked_from_repo_id
|
||||
.ok_or(AppError::BadRequest("parent repo not found".into()))?;
|
||||
let parent = Repo::find_by_id(self.ctx.db.reader(), parent_id)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("parent repo not found".into()))?;
|
||||
|
||||
let header = self.repo_header(&fork, &self.resolve_workspace(wk_name).await?);
|
||||
let parent_ws = self.find_ws_for_repo(&parent).await?;
|
||||
let _parent_header = self.repo_header(&parent, &parent_ws);
|
||||
|
||||
let mut client = self.git_client(&fork)?;
|
||||
let result = client
|
||||
.merge
|
||||
.merge(tonic::Request::new(crate::pb::repo::MergeRequest {
|
||||
repository: Some(header),
|
||||
target_branch: fork.default_branch.clone(),
|
||||
source: Some(crate::pb::repo::ObjectSelector {
|
||||
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
|
||||
crate::pb::repo::ObjectName {
|
||||
revision: parent.default_branch.clone(),
|
||||
},
|
||||
)),
|
||||
}),
|
||||
committer: None,
|
||||
message: format!("Sync from upstream {}/{}", parent_ws.name, parent.name),
|
||||
options: None,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(format!("sync failed: {e}")))?;
|
||||
|
||||
let merge_result = result.into_inner();
|
||||
if merge_result.status
|
||||
== crate::pb::repo::merge_result::Status::MergeResultStatusConflicts as i32
|
||||
{
|
||||
return Err(AppError::Conflict(
|
||||
"sync failed: merge conflicts with upstream".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(fork)
|
||||
}
|
||||
|
||||
pub(crate) async fn find_ws_for_repo(
|
||||
&self,
|
||||
repo: &Repo,
|
||||
) -> Result<crate::models::workspaces::Workspace, AppError> {
|
||||
sqlx::query_as::<_, crate::models::workspaces::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",
|
||||
)
|
||||
.bind(repo.workspace_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("workspace not found".into()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use crate::error::AppError;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
impl RepoService {
|
||||
pub async fn git_blame(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
revision: &str,
|
||||
path: &str,
|
||||
page_size: u32,
|
||||
) -> Result<crate::pb::repo::BlameResponse, 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
|
||||
.blame(tonic::Request::new(crate::pb::repo::BlameRequest {
|
||||
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(),
|
||||
range: None,
|
||||
options: None,
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
use crate::error::AppError;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
impl RepoService {
|
||||
pub async fn git_list_branches(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
pattern: Option<String>,
|
||||
page_size: u32,
|
||||
page_token: Option<String>,
|
||||
) -> Result<crate::pb::repo::ListBranchesResponse, 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)?.branch;
|
||||
let resp = svc
|
||||
.list_branches(tonic::Request::new(crate::pb::repo::ListBranchesRequest {
|
||||
repository: Some(header),
|
||||
pattern: pattern.unwrap_or_default(),
|
||||
merged_into_head: false,
|
||||
not_merged_into_head: false,
|
||||
pagination: Some(crate::pb::repo::Pagination {
|
||||
page_size,
|
||||
page_token: page_token.unwrap_or_default(),
|
||||
}),
|
||||
sort_direction: crate::pb::repo::SortDirection::Desc as i32,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_get_branch(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
branch_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_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)?.branch;
|
||||
let resp = svc
|
||||
.get_branch(tonic::Request::new(crate::pb::repo::GetBranchRequest {
|
||||
repository: Some(header),
|
||||
name: branch_name.to_string(),
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_create_branch(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
branch_name: &str,
|
||||
start_point: &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::Member)
|
||||
.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
|
||||
.create_branch(tonic::Request::new(crate::pb::repo::CreateBranchRequest {
|
||||
repository: Some(header),
|
||||
name: branch_name.to_string(),
|
||||
start_point: Some(crate::pb::repo::ObjectSelector {
|
||||
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
|
||||
crate::pb::repo::ObjectName {
|
||||
revision: start_point.to_string(),
|
||||
},
|
||||
)),
|
||||
}),
|
||||
force: false,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_delete_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)?.branch;
|
||||
svc.delete_branch(tonic::Request::new(crate::pb::repo::DeleteBranchRequest {
|
||||
repository: Some(header),
|
||||
name: branch_name.to_string(),
|
||||
force: false,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn git_compare_branches(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
source_branch: &str,
|
||||
target_branch: &str,
|
||||
) -> Result<crate::pb::repo::CompareBranchResponse, 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)?.branch;
|
||||
let resp = svc
|
||||
.compare_branch(tonic::Request::new(crate::pb::repo::CompareBranchRequest {
|
||||
repository: Some(header),
|
||||
source_branch: source_branch.to_string(),
|
||||
target_branch: target_branch.to_string(),
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use crate::error::AppError;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
impl RepoService {
|
||||
pub async fn git_list_commits(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
revision: &str,
|
||||
path: Option<String>,
|
||||
page_size: u32,
|
||||
) -> Result<crate::pb::repo::ListCommitsResponse, 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
|
||||
.list_commits(tonic::Request::new(crate::pb::repo::ListCommitsRequest {
|
||||
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.unwrap_or_default(),
|
||||
since: None,
|
||||
until: None,
|
||||
first_parent: false,
|
||||
all: false,
|
||||
reverse: false,
|
||||
max_parents: 0,
|
||||
min_parents: 0,
|
||||
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_get_commit(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
revision: &str,
|
||||
) -> Result<crate::pb::repo::Commit, 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
|
||||
.get_commit(tonic::Request::new(crate::pb::repo::GetCommitRequest {
|
||||
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(),
|
||||
},
|
||||
)),
|
||||
}),
|
||||
include_stats: true,
|
||||
include_raw: false,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use crate::error::AppError;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
fn rev(revision: &str) -> crate::pb::repo::ObjectSelector {
|
||||
crate::pb::repo::ObjectSelector {
|
||||
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
|
||||
crate::pb::repo::ObjectName {
|
||||
revision: revision.to_string(),
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn git_diff(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
base: &str,
|
||||
head: &str,
|
||||
page_size: u32,
|
||||
) -> 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_diff(tonic::Request::new(crate::pb::repo::GetDiffRequest {
|
||||
repository: Some(header),
|
||||
base: Some(rev(base)),
|
||||
head: Some(rev(head)),
|
||||
options: None,
|
||||
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_diff_stats(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
base: &str,
|
||||
head: &str,
|
||||
) -> Result<crate::pb::repo::DiffStats, 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_diff_stats(tonic::Request::new(crate::pb::repo::GetDiffStatsRequest {
|
||||
repository: Some(header),
|
||||
base: Some(rev(base)),
|
||||
head: Some(rev(head)),
|
||||
options: None,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
fn rev(r: &str) -> crate::pb::repo::ObjectSelector {
|
||||
crate::pb::repo::ObjectSelector {
|
||||
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
|
||||
crate::pb::repo::ObjectName {
|
||||
revision: r.to_string(),
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct MergeParams {
|
||||
pub target_branch: String,
|
||||
pub source: String,
|
||||
pub message: Option<String>,
|
||||
pub squash: Option<bool>,
|
||||
pub no_commit: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct RebaseParams {
|
||||
pub branch: String,
|
||||
pub upstream: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct CherryPickParams {
|
||||
pub commit: String,
|
||||
pub branch: String,
|
||||
pub message: Option<String>,
|
||||
pub mainline: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct RevertParams {
|
||||
pub commit: String,
|
||||
pub branch: String,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct CreateCommitParams {
|
||||
pub branch: String,
|
||||
pub message: String,
|
||||
pub start_revision: Option<String>,
|
||||
pub actions: Vec<CommitAction>,
|
||||
pub force: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct CommitAction {
|
||||
pub action: String,
|
||||
pub file_path: String,
|
||||
pub previous_path: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub executable: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct CompareParams {
|
||||
pub base: String,
|
||||
pub head: String,
|
||||
pub page_size: Option<u32>,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn git_check_merge(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
target: &str,
|
||||
source: &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_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)?.merge;
|
||||
let resp = svc
|
||||
.check_merge(tonic::Request::new(crate::pb::repo::CheckMergeRequest {
|
||||
repository: Some(header),
|
||||
target: Some(rev(target)),
|
||||
source: Some(rev(source)),
|
||||
options: None,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_merge(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: MergeParams,
|
||||
) -> 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 message = params
|
||||
.message
|
||||
.unwrap_or_else(|| format!("Merge {} into {}", params.source, params.target_branch));
|
||||
|
||||
let options = crate::pb::repo::MergeOptions {
|
||||
strategy: crate::pb::repo::merge_options::Strategy::MergeStrategyOrt as i32,
|
||||
fast_forward:
|
||||
crate::pb::repo::merge_options::FastForwardMode::MergeFastForwardModeAllowed as i32,
|
||||
squash: params.squash.unwrap_or(false),
|
||||
no_commit: params.no_commit.unwrap_or(false),
|
||||
allow_unrelated_histories: false,
|
||||
strategy_options: vec![],
|
||||
};
|
||||
|
||||
let mut svc = self.git_client(&repo)?.merge;
|
||||
let resp = svc
|
||||
.merge(tonic::Request::new(crate::pb::repo::MergeRequest {
|
||||
repository: Some(header),
|
||||
target_branch: params.target_branch,
|
||||
source: Some(rev(¶ms.source)),
|
||||
committer: None,
|
||||
message,
|
||||
options: Some(options),
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_rebase(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: RebaseParams,
|
||||
) -> Result<crate::pb::repo::RebaseResult, 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
|
||||
.rebase(tonic::Request::new(crate::pb::repo::RebaseRequest {
|
||||
repository: Some(header),
|
||||
branch: params.branch,
|
||||
upstream: Some(rev(¶ms.upstream)),
|
||||
committer: None,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_cherry_pick(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CherryPickParams,
|
||||
) -> Result<crate::pb::repo::CreateCommitResponse, 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 message = params
|
||||
.message
|
||||
.unwrap_or_else(|| format!("Cherry-pick {}", params.commit));
|
||||
let mut svc = self.git_client(&repo)?.commit;
|
||||
let resp = svc
|
||||
.cherry_pick_commit(tonic::Request::new(
|
||||
crate::pb::repo::CherryPickCommitRequest {
|
||||
repository: Some(header),
|
||||
commit: Some(rev(¶ms.commit)),
|
||||
branch: params.branch,
|
||||
committer: None,
|
||||
message,
|
||||
mainline: params.mainline.unwrap_or(1),
|
||||
},
|
||||
))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_revert(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: RevertParams,
|
||||
) -> Result<crate::pb::repo::CreateCommitResponse, 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 message = params
|
||||
.message
|
||||
.unwrap_or_else(|| format!("Revert {}", params.commit));
|
||||
let mut svc = self.git_client(&repo)?.commit;
|
||||
let resp = svc
|
||||
.revert_commit(tonic::Request::new(crate::pb::repo::RevertCommitRequest {
|
||||
repository: Some(header),
|
||||
commit: Some(rev(¶ms.commit)),
|
||||
branch: params.branch,
|
||||
committer: None,
|
||||
message,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_create_commit(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateCommitParams,
|
||||
) -> Result<crate::pb::repo::CreateCommitResponse, 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 actions: Vec<crate::pb::repo::CreateCommitAction> = params
|
||||
.actions
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let action = match a.action.as_str() {
|
||||
"create" => {
|
||||
crate::pb::repo::create_commit_action::Action::CreateCommitActionCreate
|
||||
as i32
|
||||
}
|
||||
"update" => {
|
||||
crate::pb::repo::create_commit_action::Action::CreateCommitActionUpdate
|
||||
as i32
|
||||
}
|
||||
"delete" => {
|
||||
crate::pb::repo::create_commit_action::Action::CreateCommitActionDelete
|
||||
as i32
|
||||
}
|
||||
"move" => {
|
||||
crate::pb::repo::create_commit_action::Action::CreateCommitActionMove as i32
|
||||
}
|
||||
"chmod" => {
|
||||
crate::pb::repo::create_commit_action::Action::CreateCommitActionChmod
|
||||
as i32
|
||||
}
|
||||
_ => {
|
||||
crate::pb::repo::create_commit_action::Action::CreateCommitActionUnspecified
|
||||
as i32
|
||||
}
|
||||
};
|
||||
crate::pb::repo::CreateCommitAction {
|
||||
action,
|
||||
file_path: a.file_path.clone(),
|
||||
previous_path: a.previous_path.clone().unwrap_or_default(),
|
||||
content: a
|
||||
.content
|
||||
.as_ref()
|
||||
.map(|c| c.as_bytes().to_vec())
|
||||
.unwrap_or_default(),
|
||||
encoding: String::new(),
|
||||
executable: a.executable.unwrap_or(false),
|
||||
last_commit_oid: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let start_revision = params.start_revision.as_ref().map(|r| rev(r));
|
||||
let mut svc = self.git_client(&repo)?.commit;
|
||||
let resp = svc
|
||||
.create_commit(tonic::Request::new(crate::pb::repo::CreateCommitRequest {
|
||||
repository: Some(header),
|
||||
branch: params.branch,
|
||||
message: params.message,
|
||||
author: None,
|
||||
committer: None,
|
||||
actions,
|
||||
start_revision,
|
||||
force: params.force.unwrap_or(false),
|
||||
trailers: vec![],
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_compare_commits(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CompareParams,
|
||||
) -> Result<crate::pb::repo::CompareCommitsResponse, 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 page_size = params.page_size.unwrap_or(30);
|
||||
let mut svc = self.git_client(&repo)?.commit;
|
||||
let resp = svc
|
||||
.compare_commits(tonic::Request::new(
|
||||
crate::pb::repo::CompareCommitsRequest {
|
||||
repository: Some(header),
|
||||
base: Some(rev(¶ms.base)),
|
||||
head: Some(rev(¶ms.head)),
|
||||
straight: false,
|
||||
first_parent: false,
|
||||
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_list_conflicts(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
target: &str,
|
||||
source: &str,
|
||||
page_size: u32,
|
||||
) -> Result<crate::pb::repo::ListMergeConflictsResponse, 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)?.merge;
|
||||
let resp = svc
|
||||
.list_merge_conflicts(tonic::Request::new(
|
||||
crate::pb::repo::ListMergeConflictsRequest {
|
||||
repository: Some(header),
|
||||
target: Some(rev(target)),
|
||||
source: Some(rev(source)),
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
pub mod blame;
|
||||
pub mod branch;
|
||||
pub mod commit;
|
||||
pub mod diff;
|
||||
pub mod merge;
|
||||
pub mod repository;
|
||||
pub mod tag;
|
||||
pub mod tree;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::repos::Repo;
|
||||
use crate::models::workspaces::Workspace;
|
||||
use crate::pb::RepoClient;
|
||||
use crate::pb::repo::RepositoryHeader;
|
||||
use crate::service::RepoService;
|
||||
|
||||
impl RepoService {
|
||||
pub(crate) fn repo_header(&self, repo: &Repo, ws: &Workspace) -> RepositoryHeader {
|
||||
RepositoryHeader {
|
||||
storage_name: ws.name.clone(),
|
||||
relative_path: format!("{}.git", repo.name),
|
||||
storage_path: repo.storage_path.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn git_client(&self, repo: &Repo) -> Result<RepoClient, AppError> {
|
||||
self.ctx
|
||||
.registry
|
||||
.get_git_client(&repo.primary_storage_node_id)
|
||||
.ok_or_else(|| AppError::Config("primary git node not available".into()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use crate::error::AppError;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
impl RepoService {
|
||||
pub async fn git_repo_info(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<crate::pb::repo::Repository, 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_repository(tonic::Request::new(crate::pb::repo::GetRepositoryRequest {
|
||||
repository: Some(header),
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_repo_exists(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<bool, 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_exists(tonic::Request::new(
|
||||
crate::pb::repo::RepositoryExistsRequest {
|
||||
repository: Some(header),
|
||||
},
|
||||
))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner().exists)
|
||||
}
|
||||
|
||||
pub async fn git_repo_stats(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<crate::pb::repo::RepositoryStatistics, 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_repository_statistics(tonic::Request::new(
|
||||
crate::pb::repo::RepositoryStatisticsRequest {
|
||||
repository: Some(header),
|
||||
},
|
||||
))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_repo_health(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<crate::pb::repo::RepositoryHealthResponse, 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;
|
||||
let resp = svc
|
||||
.check_repository_health(tonic::Request::new(
|
||||
crate::pb::repo::RepositoryHealthRequest {
|
||||
repository: Some(header),
|
||||
connectivity_only: false,
|
||||
},
|
||||
))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_garbage_collect(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<crate::pb::repo::RepositoryMaintenanceResponse, 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;
|
||||
let resp = svc
|
||||
.garbage_collect(tonic::Request::new(
|
||||
crate::pb::repo::GarbageCollectRequest {
|
||||
repository: Some(header),
|
||||
prune: true,
|
||||
aggressive: false,
|
||||
},
|
||||
))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
use crate::error::AppError;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
impl RepoService {
|
||||
pub async fn git_list_tags(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
pattern: Option<String>,
|
||||
page_size: u32,
|
||||
) -> Result<crate::pb::repo::ListTagsResponse, 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
|
||||
.list_tags(tonic::Request::new(crate::pb::repo::ListTagsRequest {
|
||||
repository: Some(header),
|
||||
pattern: pattern.unwrap_or_default(),
|
||||
pagination: Some(crate::pb::repo::Pagination {
|
||||
page_size,
|
||||
page_token: String::new(),
|
||||
}),
|
||||
sort_direction: crate::pb::repo::SortDirection::Desc as i32,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn git_create_tag(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
tag_name: &str,
|
||||
target: &str,
|
||||
message: Option<String>,
|
||||
annotated: bool,
|
||||
) -> 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_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)?.tag;
|
||||
let resp = svc
|
||||
.create_tag(tonic::Request::new(crate::pb::repo::CreateTagRequest {
|
||||
repository: Some(header),
|
||||
name: tag_name.to_string(),
|
||||
target: Some(crate::pb::repo::ObjectSelector {
|
||||
selector: Some(crate::pb::repo::object_selector::Selector::Revision(
|
||||
crate::pb::repo::ObjectName {
|
||||
revision: target.to_string(),
|
||||
},
|
||||
)),
|
||||
}),
|
||||
message: message.unwrap_or_default(),
|
||||
tagger: None,
|
||||
force: false,
|
||||
annotated,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
|
||||
pub async fn git_delete_tag(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
tag_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)?.tag;
|
||||
svc.delete_tag(tonic::Request::new(crate::pb::repo::DeleteTagRequest {
|
||||
repository: Some(header),
|
||||
name: tag_name.to_string(),
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use crate::error::AppError;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
impl RepoService {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn git_list_tree(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
revision: &str,
|
||||
path: Option<String>,
|
||||
recursive: bool,
|
||||
page_size: u32,
|
||||
) -> Result<crate::pb::repo::ListTreeResponse, 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
|
||||
.list_tree(tonic::Request::new(crate::pb::repo::ListTreeRequest {
|
||||
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.unwrap_or_default(),
|
||||
recursive,
|
||||
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_get_blob(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
revision: &str,
|
||||
path: &str,
|
||||
) -> Result<crate::pb::repo::Blob, 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_blob(tonic::Request::new(crate::pb::repo::GetBlobRequest {
|
||||
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,
|
||||
max_bytes: 0,
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
Ok(resp.into_inner())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::repos::RepoInvitation;
|
||||
use crate::pb::email::{EmailAddress, SendEmailRequest};
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, role_level};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateRepoInvitationParams {
|
||||
pub email: String,
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
pub struct CreateRepoInvitationResponse {
|
||||
pub invitation: RepoInvitation,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_invitations(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoInvitation>, 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 (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoInvitation>(
|
||||
"SELECT id, repo_id, email, role, token_hash, invited_by, accepted_by, accepted_at, revoked_at, expires_at, created_at FROM repo_invitation \
|
||||
WHERE repo_id = $1 AND revoked_at IS NULL AND accepted_at IS NULL \
|
||||
AND expires_at > NOW() ORDER BY created_at DESC 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 repo_create_invitation(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateRepoInvitationParams,
|
||||
) -> Result<CreateRepoInvitationResponse, 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;
|
||||
let actor_role = self
|
||||
.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let email = params.email.trim().to_lowercase();
|
||||
if email.is_empty() {
|
||||
return Err(AppError::BadRequest("email is required".into()));
|
||||
}
|
||||
|
||||
let existing = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_invitation \
|
||||
WHERE repo_id = $1 AND lower(email) = lower($2) \
|
||||
AND revoked_at IS NULL AND accepted_at IS NULL AND expires_at > NOW())",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(&email)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if existing {
|
||||
return Err(AppError::BadRequest(
|
||||
"invitation already exists for this email".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let role = params
|
||||
.role
|
||||
.as_deref()
|
||||
.and_then(|r| r.parse::<Role>().ok())
|
||||
.unwrap_or(Role::Member);
|
||||
|
||||
if role == Role::Owner || role == Role::Unknown {
|
||||
return Err(AppError::BadRequest("invalid role for invitation".into()));
|
||||
}
|
||||
|
||||
// Non-owner admins cannot invite with roles equal to or higher than their own
|
||||
if actor_role != Role::Owner && role_level(role) >= role_level(actor_role) {
|
||||
return Err(AppError::BadRequest(
|
||||
"cannot invite with role equal to or higher than your own".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let token = generate_repo_invitation_token();
|
||||
let token_hash = sha256_hex(token.as_bytes());
|
||||
let now = chrono::Utc::now();
|
||||
let expires_at = now + chrono::Duration::days(7);
|
||||
|
||||
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 invitation = sqlx::query_as::<_, RepoInvitation>(
|
||||
"INSERT INTO repo_invitation (id, repo_id, email, role, token_hash, invited_by, expires_at, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
|
||||
RETURNING id, repo_id, email, role, token_hash, invited_by, accepted_by, accepted_at, revoked_at, expires_at, created_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(&email)
|
||||
.bind(role.to_string())
|
||||
.bind(&token_hash)
|
||||
.bind(user_uid)
|
||||
.bind(expires_at)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
let domain = self.ctx.config.main_domain()?;
|
||||
let invite_link = format!("{}/repo/invitations/accept?token={}", domain, 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.clone(),
|
||||
name: String::new(),
|
||||
}],
|
||||
subject: format!("You're invited to join repo {}", repo.name),
|
||||
text_body: format!(
|
||||
"You've been invited to join repository '{}'.\n\nAccept the invitation here:\n\n{}\n\nThis invitation expires in 7 days.",
|
||||
repo.name, invite_link
|
||||
),
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
||||
|
||||
tracing::info!(email = %email, invitation_id = %invitation.id, repo_id = %repo_id, "Repo invitation created");
|
||||
Ok(CreateRepoInvitationResponse { invitation })
|
||||
}
|
||||
|
||||
pub async fn repo_revoke_invitation(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
invitation_id: Uuid,
|
||||
) -> 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 app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE repo_invitation SET revoked_at = $1 WHERE id = $2 AND repo_id = $3 \
|
||||
AND revoked_at IS NULL AND accepted_at IS NULL",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(invitation_id)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(
|
||||
result.rows_affected(),
|
||||
"invitation not found or already used",
|
||||
)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_accept_invitation(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
token: &str,
|
||||
) -> Result<RepoInvitation, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let token_hash = sha256_hex(token.as_bytes());
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let invitation = sqlx::query_as::<_, RepoInvitation>(
|
||||
"SELECT id, repo_id, email, role, token_hash, invited_by, accepted_by, accepted_at, revoked_at, expires_at, created_at FROM repo_invitation \
|
||||
WHERE token_hash = $1 AND revoked_at IS NULL AND accepted_at IS NULL AND expires_at > NOW()",
|
||||
)
|
||||
.bind(&token_hash)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::BadRequest("invalid or expired invitation".into()))?;
|
||||
|
||||
let already_member = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_member WHERE repo_id = $1 AND user_id = $2)",
|
||||
)
|
||||
.bind(invitation.repo_id)
|
||||
.bind(user_uid)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if already_member {
|
||||
return Err(AppError::BadRequest("already a member of this repo".into()));
|
||||
}
|
||||
|
||||
let user_email: Option<String> = sqlx::query_scalar(
|
||||
"SELECT email FROM user_mail WHERE user_id = $1 AND is_verified = true LIMIT 1",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if user_email
|
||||
.as_deref()
|
||||
.map(|e| e.trim().eq_ignore_ascii_case(&invitation.email))
|
||||
!= Some(true)
|
||||
{
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
let repo = self.find_repo_by_id(invitation.repo_id).await?;
|
||||
let role_str = invitation.role.to_string();
|
||||
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 workspace_member_result = sqlx::query(
|
||||
"INSERT INTO workspace_member (id, workspace_id, user_id, role, status, joined_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, 'member', 'active', $4, $4, $4) ON CONFLICT (workspace_id, user_id) DO NOTHING",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo.workspace_id)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if workspace_member_result.rows_affected() > 0 {
|
||||
sqlx::query(
|
||||
"UPDATE workspace_stats SET members_count = members_count + 1, updated_at = $1 WHERE workspace_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo.workspace_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
let result = sqlx::query_as::<_, RepoInvitation>(
|
||||
"UPDATE repo_invitation SET accepted_by = $1, accepted_at = $2 \
|
||||
WHERE id = $3 AND revoked_at IS NULL AND accepted_at IS NULL \
|
||||
RETURNING id, repo_id, email, role, token_hash, invited_by, accepted_by, accepted_at, revoked_at, expires_at, created_at",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.bind(invitation.id)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO repo_member (id, repo_id, user_id, role, status, invited_by, joined_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, 'active', $5, $6, $6, $6) \
|
||||
ON CONFLICT (repo_id, user_id) DO NOTHING",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(invitation.repo_id)
|
||||
.bind(user_uid)
|
||||
.bind(&role_str)
|
||||
.bind(invitation.invited_by)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn sha256_hex(data: &[u8]) -> String {
|
||||
use sha2::Digest;
|
||||
sha2::Sha256::digest(data)
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn generate_repo_invitation_token() -> String {
|
||||
(0..64)
|
||||
.map(|_| format!("{:02x}", rand::random::<u8>()))
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::repos::RepoMember;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, role_level};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct AddRepoMemberParams {
|
||||
pub user_id: Uuid,
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateRepoMemberRoleParams {
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_members(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoMember>, 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_readable(user_uid, &repo).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoMember>(
|
||||
"SELECT id, repo_id, user_id, role, status, invited_by, joined_at, last_active_at, created_at, updated_at FROM repo_member WHERE repo_id = $1 AND status = 'active' ORDER BY created_at 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 repo_add_member(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: AddRepoMemberParams,
|
||||
) -> Result<RepoMember, 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;
|
||||
let actor_role = self
|
||||
.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let target_workspace_member = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active')",
|
||||
)
|
||||
.bind(repo.workspace_id)
|
||||
.bind(params.user_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if !target_workspace_member {
|
||||
return Err(AppError::BadRequest(
|
||||
"user must be a workspace member".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let existing = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_member WHERE repo_id = $1 AND user_id = $2)",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(params.user_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if existing {
|
||||
return Err(AppError::Conflict("user is already a member".into()));
|
||||
}
|
||||
|
||||
let role = params
|
||||
.role
|
||||
.as_deref()
|
||||
.and_then(|r| r.parse::<Role>().ok())
|
||||
.unwrap_or_else(|| "member".to_string().parse().unwrap_or(Role::Member));
|
||||
|
||||
if role == Role::Owner {
|
||||
return Err(AppError::BadRequest("cannot add member as owner".into()));
|
||||
}
|
||||
if role == Role::Unknown {
|
||||
return Err(AppError::BadRequest("invalid role".into()));
|
||||
}
|
||||
if role_level(actor_role) < role_level(Role::Owner)
|
||||
&& role_level(role) >= role_level(actor_role)
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"cannot assign role equal or higher than your own".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)?;
|
||||
|
||||
let member = sqlx::query_as::<_, RepoMember>(
|
||||
"INSERT INTO repo_member (id, repo_id, user_id, role, status, invited_by, joined_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, 'active', $5, $6, $6, $6) RETURNING id, repo_id, user_id, role, status, invited_by, joined_at, last_active_at, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(params.user_id)
|
||||
.bind(role.to_string())
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(member)
|
||||
}
|
||||
|
||||
pub async fn repo_update_member_role(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
member_id: Uuid,
|
||||
params: UpdateRepoMemberRoleParams,
|
||||
) -> Result<RepoMember, 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;
|
||||
let actor_role = self
|
||||
.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let new_role = params
|
||||
.role
|
||||
.parse::<Role>()
|
||||
.map_err(|_| AppError::BadRequest("invalid role".into()))?;
|
||||
if new_role == Role::Owner {
|
||||
return Err(AppError::BadRequest(
|
||||
"use repo_transfer_owner to change owner".into(),
|
||||
));
|
||||
}
|
||||
if new_role == Role::Unknown {
|
||||
return Err(AppError::BadRequest("invalid role".into()));
|
||||
}
|
||||
|
||||
let target = sqlx::query_as::<_, RepoMember>(
|
||||
"SELECT id, repo_id, user_id, role, status, invited_by, joined_at, last_active_at, created_at, updated_at FROM repo_member WHERE id = $1 AND repo_id = $2",
|
||||
)
|
||||
.bind(member_id)
|
||||
.bind(repo_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("member not found".into()))?;
|
||||
|
||||
if target.role == Role::Owner {
|
||||
return Err(AppError::BadRequest("cannot change owner role".into()));
|
||||
}
|
||||
if role_level(actor_role) <= role_level(target.role) && actor_role != Role::Owner {
|
||||
return Err(AppError::BadRequest(
|
||||
"cannot change role of member with equal or higher role".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)?;
|
||||
|
||||
let result = sqlx::query_as::<_, RepoMember>(
|
||||
"UPDATE repo_member SET role = $1, updated_at = $2 WHERE id = $3 AND repo_id = $4 RETURNING id, repo_id, user_id, role, status, invited_by, joined_at, last_active_at, created_at, updated_at",
|
||||
)
|
||||
.bind(new_role.to_string())
|
||||
.bind(now)
|
||||
.bind(member_id)
|
||||
.bind(repo_id)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn repo_remove_member(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
member_id: Uuid,
|
||||
) -> 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;
|
||||
let actor_role = self
|
||||
.ensure_repo_role_at_least(user_uid, &repo, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let target = sqlx::query_as::<_, RepoMember>(
|
||||
"SELECT id, repo_id, user_id, role, status, invited_by, joined_at, last_active_at, created_at, updated_at FROM repo_member WHERE id = $1 AND repo_id = $2",
|
||||
)
|
||||
.bind(member_id)
|
||||
.bind(repo_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("member not found".into()))?;
|
||||
|
||||
if target.role == Role::Owner {
|
||||
return Err(AppError::BadRequest(
|
||||
"cannot remove owner; transfer ownership first".into(),
|
||||
));
|
||||
}
|
||||
if role_level(actor_role) <= role_level(target.role) && actor_role != Role::Owner {
|
||||
return Err(AppError::BadRequest(
|
||||
"cannot remove a member with equal or higher role".into(),
|
||||
));
|
||||
}
|
||||
|
||||
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 repo_member WHERE id = $1 AND repo_id = $2")
|
||||
.bind(member_id)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "member not found")?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_leave(
|
||||
&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?;
|
||||
let repo_id = repo.id;
|
||||
|
||||
if repo.owner_id == user_uid {
|
||||
return Err(AppError::BadRequest(
|
||||
"owner cannot leave; transfer ownership first".into(),
|
||||
));
|
||||
}
|
||||
|
||||
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 repo_member WHERE repo_id = $1 AND user_id = $2")
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "not a member")?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
pub mod branches;
|
||||
pub mod commit_status;
|
||||
pub mod core;
|
||||
pub mod deploy_keys;
|
||||
pub mod fork;
|
||||
pub mod git;
|
||||
pub mod invitations;
|
||||
pub mod members;
|
||||
pub mod protection;
|
||||
pub mod releases;
|
||||
pub mod stars;
|
||||
pub mod stats;
|
||||
pub mod tags;
|
||||
pub mod util;
|
||||
pub mod watches;
|
||||
pub mod webhooks;
|
||||
@@ -0,0 +1,389 @@
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::repos::BranchProtectionRule;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, required_text};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct CreateProtectionRuleParams {
|
||||
pub pattern: String,
|
||||
pub require_approvals: Option<i32>,
|
||||
pub require_status_checks: Option<bool>,
|
||||
pub required_status_checks: Option<Vec<String>>,
|
||||
pub require_linear_history: Option<bool>,
|
||||
pub allow_force_pushes: Option<bool>,
|
||||
pub allow_deletions: Option<bool>,
|
||||
pub require_signed_commits: Option<bool>,
|
||||
pub require_code_owner_review: Option<bool>,
|
||||
pub dismiss_stale_reviews: Option<bool>,
|
||||
pub restrict_pushes: Option<bool>,
|
||||
pub push_allowances: Option<Vec<Uuid>>,
|
||||
pub restrict_review_dismissal: Option<bool>,
|
||||
pub dismissal_allowances: Option<Vec<Uuid>>,
|
||||
pub require_conversation_resolution: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, utoipa::ToSchema)]
|
||||
pub struct UpdateProtectionRuleParams {
|
||||
pub require_approvals: Option<i32>,
|
||||
pub require_status_checks: Option<bool>,
|
||||
pub required_status_checks: Option<Vec<String>>,
|
||||
pub require_linear_history: Option<bool>,
|
||||
pub allow_force_pushes: Option<bool>,
|
||||
pub allow_deletions: Option<bool>,
|
||||
pub require_signed_commits: Option<bool>,
|
||||
pub require_code_owner_review: Option<bool>,
|
||||
pub dismiss_stale_reviews: Option<bool>,
|
||||
pub restrict_pushes: Option<bool>,
|
||||
pub push_allowances: Option<Vec<Uuid>>,
|
||||
pub restrict_review_dismissal: Option<bool>,
|
||||
pub dismissal_allowances: Option<Vec<Uuid>>,
|
||||
pub require_conversation_resolution: Option<bool>,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_protection_rules(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<BranchProtectionRule>, 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::<_, BranchProtectionRule>(
|
||||
"SELECT id, repo_id, pattern, require_approvals, require_status_checks, \
|
||||
required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \
|
||||
require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \
|
||||
restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \
|
||||
require_conversation_resolution, created_by, created_at, updated_at \
|
||||
FROM branch_protection_rule WHERE repo_id = $1 ORDER BY pattern 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 repo_get_protection_rule(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
rule_id: Uuid,
|
||||
) -> Result<BranchProtectionRule, 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?;
|
||||
sqlx::query_as::<_, BranchProtectionRule>(
|
||||
"SELECT id, repo_id, pattern, require_approvals, require_status_checks, \
|
||||
required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \
|
||||
require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \
|
||||
restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \
|
||||
require_conversation_resolution, created_by, created_at, updated_at \
|
||||
FROM branch_protection_rule WHERE id = $1 AND repo_id = $2",
|
||||
)
|
||||
.bind(rule_id)
|
||||
.bind(repo.id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("protection rule not found".into()))
|
||||
}
|
||||
|
||||
pub async fn repo_match_protection(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
branch_name: &str,
|
||||
) -> Result<Option<BranchProtectionRule>, 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 rules = sqlx::query_as::<_, BranchProtectionRule>(
|
||||
"SELECT id, repo_id, pattern, require_approvals, require_status_checks, \
|
||||
required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \
|
||||
require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \
|
||||
restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \
|
||||
require_conversation_resolution, created_by, created_at, updated_at \
|
||||
FROM branch_protection_rule WHERE repo_id = $1 ORDER BY pattern ASC",
|
||||
)
|
||||
.bind(repo.id)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
Ok(rules
|
||||
.into_iter()
|
||||
.find(|r| glob_match(&r.pattern, branch_name)))
|
||||
}
|
||||
|
||||
pub async fn repo_create_protection_rule(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateProtectionRuleParams,
|
||||
) -> Result<BranchProtectionRule, 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 pattern = required_text(params.pattern, "pattern")?;
|
||||
let now = Utc::now();
|
||||
let rule_id = Uuid::now_v7();
|
||||
let required_checks = params.required_status_checks.unwrap_or_default();
|
||||
let push_allow = params.push_allowances.unwrap_or_default();
|
||||
let dismiss_allow = params.dismissal_allowances.unwrap_or_default();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO branch_protection_rule (id, repo_id, pattern, require_approvals, \
|
||||
require_status_checks, required_status_checks, require_linear_history, allow_force_pushes, \
|
||||
allow_deletions, require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \
|
||||
restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \
|
||||
require_conversation_resolution, created_by, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $19)",
|
||||
)
|
||||
.bind(rule_id).bind(repo.id).bind(&pattern)
|
||||
.bind(params.require_approvals.unwrap_or(0))
|
||||
.bind(params.require_status_checks.unwrap_or(false))
|
||||
.bind(&required_checks)
|
||||
.bind(params.require_linear_history.unwrap_or(false))
|
||||
.bind(params.allow_force_pushes.unwrap_or(false))
|
||||
.bind(params.allow_deletions.unwrap_or(false))
|
||||
.bind(params.require_signed_commits.unwrap_or(false))
|
||||
.bind(params.require_code_owner_review.unwrap_or(false))
|
||||
.bind(params.dismiss_stale_reviews.unwrap_or(false))
|
||||
.bind(params.restrict_pushes.unwrap_or(false))
|
||||
.bind(&push_allow)
|
||||
.bind(params.restrict_review_dismissal.unwrap_or(false))
|
||||
.bind(&dismiss_allow)
|
||||
.bind(params.require_conversation_resolution.unwrap_or(false))
|
||||
.bind(user_uid).bind(now)
|
||||
.execute(self.ctx.db.writer()).await.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query_as::<_, BranchProtectionRule>(
|
||||
"SELECT id, repo_id, pattern, require_approvals, require_status_checks, \
|
||||
required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \
|
||||
require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \
|
||||
restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \
|
||||
require_conversation_resolution, created_by, created_at, updated_at \
|
||||
FROM branch_protection_rule WHERE id = $1",
|
||||
)
|
||||
.bind(rule_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn repo_update_protection_rule(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
rule_id: Uuid,
|
||||
params: UpdateProtectionRuleParams,
|
||||
) -> Result<BranchProtectionRule, 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 existing = sqlx::query_as::<_, BranchProtectionRule>(
|
||||
"SELECT id, repo_id, pattern, require_approvals, require_status_checks, \
|
||||
required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \
|
||||
require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \
|
||||
restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \
|
||||
require_conversation_resolution, created_by, created_at, updated_at \
|
||||
FROM branch_protection_rule WHERE id = $1 AND repo_id = $2",
|
||||
)
|
||||
.bind(rule_id)
|
||||
.bind(repo.id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("protection rule not found".into()))?;
|
||||
|
||||
let now = Utc::now();
|
||||
sqlx::query(
|
||||
"UPDATE branch_protection_rule SET \
|
||||
require_approvals = $1, require_status_checks = $2, required_status_checks = $3, \
|
||||
require_linear_history = $4, allow_force_pushes = $5, allow_deletions = $6, \
|
||||
require_signed_commits = $7, require_code_owner_review = $8, dismiss_stale_reviews = $9, \
|
||||
restrict_pushes = $10, push_allowances = $11, restrict_review_dismissal = $12, \
|
||||
dismissal_allowances = $13, require_conversation_resolution = $14, updated_at = $15 \
|
||||
WHERE id = $16",
|
||||
)
|
||||
.bind(params.require_approvals.unwrap_or(existing.require_approvals))
|
||||
.bind(params.require_status_checks.unwrap_or(existing.require_status_checks))
|
||||
.bind(params.required_status_checks.as_ref().unwrap_or(&existing.required_status_checks))
|
||||
.bind(params.require_linear_history.unwrap_or(existing.require_linear_history))
|
||||
.bind(params.allow_force_pushes.unwrap_or(existing.allow_force_pushes))
|
||||
.bind(params.allow_deletions.unwrap_or(existing.allow_deletions))
|
||||
.bind(params.require_signed_commits.unwrap_or(existing.require_signed_commits))
|
||||
.bind(params.require_code_owner_review.unwrap_or(existing.require_code_owner_review))
|
||||
.bind(params.dismiss_stale_reviews.unwrap_or(existing.dismiss_stale_reviews))
|
||||
.bind(params.restrict_pushes.unwrap_or(existing.restrict_pushes))
|
||||
.bind(params.push_allowances.as_ref().unwrap_or(&existing.push_allowances))
|
||||
.bind(params.restrict_review_dismissal.unwrap_or(existing.restrict_review_dismissal))
|
||||
.bind(params.dismissal_allowances.as_ref().unwrap_or(&existing.dismissal_allowances))
|
||||
.bind(params.require_conversation_resolution.unwrap_or(existing.require_conversation_resolution))
|
||||
.bind(now).bind(rule_id)
|
||||
.execute(self.ctx.db.writer()).await.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query_as::<_, BranchProtectionRule>(
|
||||
"SELECT id, repo_id, pattern, require_approvals, require_status_checks, \
|
||||
required_status_checks, require_linear_history, allow_force_pushes, allow_deletions, \
|
||||
require_signed_commits, require_code_owner_review, dismiss_stale_reviews, \
|
||||
restrict_pushes, push_allowances, restrict_review_dismissal, dismissal_allowances, \
|
||||
require_conversation_resolution, created_by, created_at, updated_at \
|
||||
FROM branch_protection_rule WHERE id = $1",
|
||||
)
|
||||
.bind(rule_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn repo_delete_protection_rule(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
rule_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 result =
|
||||
sqlx::query("DELETE FROM branch_protection_rule WHERE id = $1 AND repo_id = $2")
|
||||
.bind(rule_id)
|
||||
.bind(repo.id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "protection rule not found")
|
||||
}
|
||||
|
||||
pub async fn repo_check_branch_merge_allowed(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
target_branch: &str,
|
||||
pr_number: i64,
|
||||
) -> Result<BranchMergeCheck, 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 rule = self
|
||||
.repo_match_protection(ctx, wk_name, repo_name, target_branch)
|
||||
.await?;
|
||||
let Some(rule) = rule else {
|
||||
return Ok(BranchMergeCheck {
|
||||
allowed: true,
|
||||
reasons: vec![],
|
||||
});
|
||||
};
|
||||
|
||||
let mut reasons = Vec::new();
|
||||
|
||||
if rule.require_approvals > 0 {
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM pr_review r \
|
||||
JOIN pull_request pr ON pr.id = r.pull_request_id \
|
||||
WHERE pr.repo_id = $1 AND pr.number = $2 AND pr.deleted_at IS NULL \
|
||||
AND r.state = 'approved' AND r.dismissed_at IS NULL AND r.submitted_at IS NOT NULL",
|
||||
)
|
||||
.bind(repo.id).bind(pr_number)
|
||||
.fetch_one(self.ctx.db.reader()).await.map_err(AppError::Database)?;
|
||||
if count < rule.require_approvals as i64 {
|
||||
reasons.push(format!(
|
||||
"requires {} approvals, has {}",
|
||||
rule.require_approvals, count
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if rule.require_status_checks && !rule.required_status_checks.is_empty() {
|
||||
let passed: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT DISTINCT cr.context FROM repo_commit_status cr \
|
||||
JOIN pull_request pr ON pr.head_commit_sha = cr.latest_commit_sha \
|
||||
WHERE pr.repo_id = $1 AND pr.number = $2 AND pr.deleted_at IS NULL \
|
||||
AND cr.state = 'success'",
|
||||
)
|
||||
.bind(repo.id)
|
||||
.bind(pr_number)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
for required in &rule.required_status_checks {
|
||||
if !passed.contains(required) {
|
||||
reasons.push(format!("required check '{}' has not passed", required));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(BranchMergeCheck {
|
||||
allowed: reasons.is_empty(),
|
||||
reasons,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct BranchMergeCheck {
|
||||
pub allowed: bool,
|
||||
pub reasons: Vec<String>,
|
||||
}
|
||||
|
||||
fn glob_match(pattern: &str, text: &str) -> bool {
|
||||
if pattern == text {
|
||||
return true;
|
||||
}
|
||||
if pattern == "*" {
|
||||
return true;
|
||||
}
|
||||
|
||||
let p: Vec<char> = pattern.chars().collect();
|
||||
let t: Vec<char> = text.chars().collect();
|
||||
let (mut pi, mut ti) = (0usize, 0usize);
|
||||
let (mut star_pi, mut star_ti) = (None, None);
|
||||
|
||||
loop {
|
||||
if pi < p.len() && ti < t.len() && (p[pi] == '?' || p[pi] == t[ti]) {
|
||||
pi += 1;
|
||||
ti += 1;
|
||||
continue;
|
||||
}
|
||||
if pi < p.len() && p[pi] == '*' {
|
||||
star_pi = Some(pi);
|
||||
star_ti = Some(ti);
|
||||
pi += 1;
|
||||
continue;
|
||||
}
|
||||
if let (Some(sp), Some(st)) = (star_pi, star_ti)
|
||||
&& st < t.len()
|
||||
{
|
||||
pi = sp + 1;
|
||||
let nt = st + 1;
|
||||
star_ti = Some(nt);
|
||||
ti = nt;
|
||||
continue;
|
||||
}
|
||||
return pi == p.len() && ti == t.len();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::repos::RepoRelease;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, merge_optional_text, required_text};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateReleaseParams {
|
||||
pub tag_name: String,
|
||||
pub title: String,
|
||||
pub body: Option<String>,
|
||||
pub draft: Option<bool>,
|
||||
pub prerelease: Option<bool>,
|
||||
pub tag_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateReleaseParams {
|
||||
pub title: Option<String>,
|
||||
pub body: Option<String>,
|
||||
pub draft: Option<bool>,
|
||||
pub prerelease: Option<bool>,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_releases(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoRelease>, 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_readable(user_uid, &repo).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoRelease>(
|
||||
"SELECT id, repo_id, tag_id, tag_name, title, body, draft, prerelease, author_id, published_at, created_at, updated_at, deleted_at FROM repo_release WHERE repo_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC 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 repo_create_release(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateReleaseParams,
|
||||
) -> Result<RepoRelease, 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 tag_name = required_text(params.tag_name, "tag_name")?;
|
||||
let title = required_text(params.title, "title")?;
|
||||
let now = chrono::Utc::now();
|
||||
let published_at = if params.draft.unwrap_or(false) {
|
||||
None
|
||||
} else {
|
||||
Some(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 release = sqlx::query_as::<_, RepoRelease>(
|
||||
"INSERT INTO repo_release (id, repo_id, tag_id, tag_name, title, body, draft, prerelease, \
|
||||
author_id, published_at, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11) RETURNING id, repo_id, tag_id, tag_name, title, body, draft, prerelease, author_id, published_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(params.tag_id)
|
||||
.bind(&tag_name)
|
||||
.bind(&title)
|
||||
.bind(¶ms.body)
|
||||
.bind(params.draft.unwrap_or(false))
|
||||
.bind(params.prerelease.unwrap_or(false))
|
||||
.bind(user_uid)
|
||||
.bind(published_at)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET releases_count = releases_count + 1, updated_at = $1 WHERE repo_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(release)
|
||||
}
|
||||
|
||||
pub async fn repo_update_release(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
release_id: Uuid,
|
||||
params: UpdateReleaseParams,
|
||||
) -> Result<RepoRelease, 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;
|
||||
let actor_role = self
|
||||
.ensure_repo_role_at_least(user_uid, &repo, Role::Member)
|
||||
.await?;
|
||||
|
||||
let current = sqlx::query_as::<_, RepoRelease>(
|
||||
"SELECT id, repo_id, tag_id, tag_name, title, body, draft, prerelease, author_id, published_at, created_at, updated_at, deleted_at 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)?
|
||||
.ok_or(AppError::NotFound("release not found".into()))?;
|
||||
|
||||
if crate::service::repo::util::role_level(actor_role)
|
||||
< crate::service::repo::util::role_level(Role::Admin)
|
||||
&& current.author_id != user_uid
|
||||
{
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
let title =
|
||||
merge_optional_text(params.title, Some(current.title.clone())).unwrap_or(current.title);
|
||||
let body = merge_optional_text(params.body, current.body);
|
||||
let draft = params.draft.unwrap_or(current.draft);
|
||||
let prerelease = params.prerelease.unwrap_or(current.prerelease);
|
||||
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_as::<_, RepoRelease>(
|
||||
"UPDATE repo_release SET title = $1, body = $2, draft = $3, prerelease = $4, \
|
||||
published_at = CASE WHEN $3 = false AND published_at IS NULL THEN $5 ELSE published_at END, \
|
||||
updated_at = $5 WHERE id = $6 AND repo_id = $7 RETURNING id, repo_id, tag_id, tag_name, title, body, draft, prerelease, author_id, published_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(&title)
|
||||
.bind(&body)
|
||||
.bind(draft)
|
||||
.bind(prerelease)
|
||||
.bind(now)
|
||||
.bind(release_id)
|
||||
.bind(repo_id)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn repo_delete_release(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
release_id: Uuid,
|
||||
) -> 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 app.current_user_id = $1")
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE repo_release SET deleted_at = $1, updated_at = $1 WHERE id = $2 AND repo_id = $3 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(release_id)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "release not found")?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET releases_count = GREATEST(releases_count - 1, 0), updated_at = $1 WHERE repo_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::repos::RepoStar;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::clamp_limit_offset;
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_star(
|
||||
&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?;
|
||||
let repo_id = repo.id;
|
||||
self.ensure_repo_readable(user_uid, &repo).await?;
|
||||
|
||||
let existing = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_star WHERE repo_id = $1 AND user_id = $2)",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if existing {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.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(
|
||||
"INSERT INTO repo_star (id, repo_id, user_id, created_at) VALUES ($1, $2, $3, $4)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET stars_count = stars_count + 1, updated_at = $1 WHERE repo_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_unstar(
|
||||
&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?;
|
||||
let repo_id = repo.id;
|
||||
|
||||
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 repo_star WHERE repo_id = $1 AND user_id = $2")
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if result.rows_affected() > 0 {
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET stars_count = GREATEST(stars_count - 1, 0), updated_at = $1 WHERE repo_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_stargazers(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoStar>, 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_readable(user_uid, &repo).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoStar>(
|
||||
"SELECT id, repo_id, user_id, created_at FROM repo_star WHERE repo_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::repos::RepoStats;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_stats(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<RepoStats, 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_readable(user_uid, &repo).await?;
|
||||
self.ensure_repo_stats(repo_id).await
|
||||
}
|
||||
|
||||
pub async fn repo_refresh_stats(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
) -> Result<RepoStats, 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 branches_count =
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM repo_branch WHERE repo_id = $1")
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let tags_count =
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM repo_tag WHERE repo_id = $1")
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let releases_count = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM repo_release WHERE repo_id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let stars_count =
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM repo_star WHERE repo_id = $1")
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let watchers_count =
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM repo_watch WHERE repo_id = $1")
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let forks_count = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM repo WHERE forked_from_repo_id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let open_issues_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM issue i \
|
||||
JOIN issue_repo_relation irr ON irr.issue_id = i.id \
|
||||
WHERE irr.repo_id = $1 AND i.deleted_at IS NULL AND i.state = 'open'",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let open_prs_count = sqlx::query_scalar::<_, i64>(
|
||||
"SELECT COUNT(*) FROM pull_request WHERE repo_id = $1 AND deleted_at IS NULL AND state = 'open'",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let result = sqlx::query_as::<_, RepoStats>(
|
||||
"UPDATE repo_stats SET stars_count = $1, watchers_count = $2, forks_count = $3, \
|
||||
branches_count = $4, tags_count = $5, releases_count = $6, \
|
||||
open_issues_count = $7, open_pull_requests_count = $8, updated_at = $9 \
|
||||
WHERE repo_id = $10 RETURNING repo_id, stars_count, watchers_count, forks_count, branches_count, tags_count, commits_count, releases_count, open_issues_count, open_pull_requests_count, size_bytes, last_push_at, updated_at",
|
||||
)
|
||||
.bind(stars_count)
|
||||
.bind(watchers_count)
|
||||
.bind(forks_count)
|
||||
.bind(branches_count)
|
||||
.bind(tags_count)
|
||||
.bind(releases_count)
|
||||
.bind(open_issues_count)
|
||||
.bind(open_prs_count)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn ensure_repo_stats(&self, repo_id: Uuid) -> Result<RepoStats, AppError> {
|
||||
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",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
{
|
||||
return Ok(stats);
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO repo_stats (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, updated_at) \
|
||||
VALUES ($1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, $2) ON CONFLICT (repo_id) DO NOTHING",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(chrono::Utc::now())
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
sqlx::query_as::<_, RepoStats>(
|
||||
"SELECT repo_id, stars_count, watchers_count, forks_count, branches_count, tags_count, commits_count, releases_count, open_issues_count, open_pull_requests_count, size_bytes, last_push_at, updated_at FROM repo_stats WHERE repo_id = $1",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::repos::RepoTag;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, required_text};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateTagParams {
|
||||
pub name: String,
|
||||
pub target_commit_sha: String,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_tags(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<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_readable(user_uid, &repo).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoTag>(
|
||||
"SELECT id, repo_id, name, target_commit_sha, tagger_id, message, signed, created_at FROM repo_tag WHERE repo_id = $1 ORDER BY created_at DESC 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 repo_create_tag(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateTagParams,
|
||||
) -> 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::Member)
|
||||
.await?;
|
||||
let name = required_text(params.name, "name")?;
|
||||
let target = required_text(params.target_commit_sha, "target_commit_sha")?;
|
||||
|
||||
let existing = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_tag WHERE repo_id = $1 AND name = $2)",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(&name)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if existing {
|
||||
return Err(AppError::Conflict("tag already exists".into()));
|
||||
}
|
||||
|
||||
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 tag = sqlx::query_as::<_, RepoTag>(
|
||||
"INSERT INTO repo_tag (id, repo_id, name, target_commit_sha, tagger_id, message, signed, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, false, $7) RETURNING id, repo_id, name, target_commit_sha, tagger_id, message, signed, created_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(&name)
|
||||
.bind(&target)
|
||||
.bind(user_uid)
|
||||
.bind(¶ms.message)
|
||||
.bind(chrono::Utc::now())
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET tags_count = tags_count + 1, updated_at = $1 WHERE repo_id = $2",
|
||||
)
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(tag)
|
||||
}
|
||||
|
||||
pub async fn repo_delete_tag(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
tag_id: Uuid,
|
||||
) -> 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 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 repo_tag WHERE id = $1 AND repo_id = $2")
|
||||
.bind(tag_id)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "tag not found")?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET tags_count = GREATEST(tags_count - 1, 0), updated_at = $1 WHERE repo_id = $2",
|
||||
)
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub use crate::service::util::{
|
||||
clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text, role_level,
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::SubscriptionLevel;
|
||||
use crate::models::repos::RepoWatch;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::clamp_limit_offset;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct WatchParams {
|
||||
pub level: Option<String>,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_watch(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: WatchParams,
|
||||
) -> 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_readable(user_uid, &repo).await?;
|
||||
|
||||
let level = params
|
||||
.level
|
||||
.as_deref()
|
||||
.and_then(|v| v.parse::<SubscriptionLevel>().ok())
|
||||
.unwrap_or(SubscriptionLevel::Participating);
|
||||
|
||||
if level == SubscriptionLevel::Unknown {
|
||||
return Err(AppError::BadRequest("invalid watch level".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)?;
|
||||
|
||||
let existing = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM repo_watch 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 {
|
||||
sqlx::query("UPDATE repo_watch SET level = $1, updated_at = $2 WHERE repo_id = $3 AND user_id = $4")
|
||||
.bind(level.to_string())
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
} else {
|
||||
sqlx::query("INSERT INTO repo_watch (id, repo_id, user_id, level, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $5)")
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.bind(level.to_string())
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET watchers_count = watchers_count + 1, updated_at = $1 WHERE repo_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_unwatch(
|
||||
&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?;
|
||||
let repo_id = repo.id;
|
||||
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 repo_watch WHERE repo_id = $1 AND user_id = $2")
|
||||
.bind(repo_id)
|
||||
.bind(user_uid)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if result.rows_affected() > 0 {
|
||||
sqlx::query(
|
||||
"UPDATE repo_stats SET watchers_count = GREATEST(watchers_count - 1, 0), updated_at = $1 WHERE repo_id = $2",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
}
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn repo_watchers(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoWatch>, 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_readable(user_uid, &repo).await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoWatch>(
|
||||
"SELECT id, repo_id, user_id, level, created_at, updated_at FROM repo_watch WHERE repo_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::IpAddr;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::{EventType, Role};
|
||||
use crate::models::repos::RepoWebhook;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, required_text};
|
||||
|
||||
/// Validate webhook URL for SSRF protection
|
||||
fn validate_webhook_url(url_str: &str) -> Result<(), AppError> {
|
||||
let url = Url::parse(url_str).map_err(|_| AppError::BadRequest("Invalid URL format".into()))?;
|
||||
|
||||
// Only allow HTTPS
|
||||
if url.scheme() != "https" {
|
||||
return Err(AppError::BadRequest(
|
||||
"Webhook URL must use HTTPS protocol".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| AppError::BadRequest("URL must have a host".into()))?;
|
||||
|
||||
// Reject IP addresses directly (require domain names)
|
||||
if host.parse::<IpAddr>().is_ok() {
|
||||
return Err(AppError::BadRequest(
|
||||
"Webhook URL must use a domain name, not an IP address".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Reject localhost and common local domains
|
||||
let host_lower = host.to_lowercase();
|
||||
if host_lower == "localhost"
|
||||
|| host_lower.ends_with(".localhost")
|
||||
|| host_lower == "127.0.0.1"
|
||||
|| host_lower == "::1"
|
||||
|| host_lower == "0.0.0.0"
|
||||
|| host_lower.ends_with(".local")
|
||||
|| host_lower.ends_with(".internal")
|
||||
{
|
||||
return Err(AppError::BadRequest(
|
||||
"Webhook URL cannot point to localhost or internal domains".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Reject metadata endpoints (AWS, GCP, Azure)
|
||||
if host == "169.254.169.254" || host == "metadata.google.internal" {
|
||||
return Err(AppError::BadRequest(
|
||||
"Webhook URL cannot point to cloud metadata endpoints".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Note: Full DNS resolution and IP validation would require async DNS lookup
|
||||
// and checking against private IP ranges. This is a basic validation layer.
|
||||
// Production systems should implement async DNS resolution and IP validation
|
||||
// at the webhook delivery layer.
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateWebhookParams {
|
||||
pub url: String,
|
||||
pub secret_ciphertext: Option<String>,
|
||||
pub events: Vec<EventType>,
|
||||
pub active: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateWebhookParams {
|
||||
pub url: Option<String>,
|
||||
pub secret_ciphertext: Option<String>,
|
||||
pub events: Option<Vec<EventType>>,
|
||||
pub active: Option<bool>,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
pub async fn repo_webhooks(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<RepoWebhook>, 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 (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
sqlx::query_as::<_, RepoWebhook>(
|
||||
"SELECT id, repo_id, url, secret_ciphertext, events, active, last_delivery_status, last_delivery_at, created_by, created_at, updated_at FROM repo_webhook WHERE repo_id = $1 ORDER BY created_at DESC 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 repo_create_webhook(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateWebhookParams,
|
||||
) -> Result<RepoWebhook, 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 url = required_text(params.url, "url")?;
|
||||
validate_webhook_url(&url)?;
|
||||
if params.events.is_empty() {
|
||||
return Err(AppError::BadRequest(
|
||||
"at least one event is required".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)?;
|
||||
|
||||
let result = sqlx::query_as::<_, RepoWebhook>(
|
||||
"INSERT INTO repo_webhook (id, repo_id, url, secret_ciphertext, events, active, \
|
||||
created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) RETURNING id, repo_id, url, secret_ciphertext, events, active, last_delivery_status, last_delivery_at, created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(repo_id)
|
||||
.bind(&url)
|
||||
.bind(¶ms.secret_ciphertext)
|
||||
.bind(¶ms.events)
|
||||
.bind(params.active.unwrap_or(true))
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn repo_update_webhook(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
webhook_id: Uuid,
|
||||
params: UpdateWebhookParams,
|
||||
) -> Result<RepoWebhook, 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::<_, RepoWebhook>(
|
||||
"SELECT id, repo_id, url, secret_ciphertext, events, active, last_delivery_status, last_delivery_at, created_by, created_at, updated_at FROM repo_webhook WHERE id = $1 AND repo_id = $2",
|
||||
)
|
||||
.bind(webhook_id)
|
||||
.bind(repo_id)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::NotFound("webhook not found".into()))?;
|
||||
|
||||
let url = params
|
||||
.url
|
||||
.as_ref()
|
||||
.map(|u| u.trim().to_string())
|
||||
.unwrap_or(current.url);
|
||||
|
||||
// Validate URL if it was updated
|
||||
if params.url.is_some() {
|
||||
validate_webhook_url(&url)?;
|
||||
}
|
||||
|
||||
let active = params.active.unwrap_or(current.active);
|
||||
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_as::<_, RepoWebhook>(
|
||||
"UPDATE repo_webhook SET url = $1, secret_ciphertext = $2, events = $3, \
|
||||
active = $4, updated_at = $5 WHERE id = $6 AND repo_id = $7 RETURNING id, repo_id, url, secret_ciphertext, events, active, last_delivery_status, last_delivery_at, created_by, created_at, updated_at",
|
||||
)
|
||||
.bind(&url)
|
||||
.bind(params.secret_ciphertext.or(current.secret_ciphertext))
|
||||
.bind(params.events.unwrap_or(current.events))
|
||||
.bind(active)
|
||||
.bind(now)
|
||||
.bind(webhook_id)
|
||||
.bind(repo_id)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn repo_delete_webhook(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
webhook_id: Uuid,
|
||||
) -> 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 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 repo_webhook WHERE id = $1 AND repo_id = $2")
|
||||
.bind(webhook_id)
|
||||
.bind(repo_id)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "webhook not found")?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Visibility;
|
||||
use crate::models::users::User;
|
||||
use crate::service::UserService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{merge_optional_text, parse_enum};
|
||||
use crate::service::util::extract_storage_key_from_url;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateUserAccountParams {
|
||||
pub username: Option<String>,
|
||||
pub display_name: Option<String>,
|
||||
pub bio: 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 {
|
||||
pub async fn user_account(&self, ctx: &Session) -> Result<User, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
crate::models::users::User::find_by_id(self.ctx.db.reader(), user_uid)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::UserNotFound)
|
||||
}
|
||||
|
||||
pub async fn user_update_account(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
params: UpdateUserAccountParams,
|
||||
) -> Result<User, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let current = crate::models::users::User::find_by_id(self.ctx.db.reader(), user_uid)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::UserNotFound)?;
|
||||
let username = params
|
||||
.username
|
||||
.map(|v| v.trim().to_string())
|
||||
.unwrap_or(current.username);
|
||||
if username.is_empty() {
|
||||
return Err(AppError::BadRequest("username is required".into()));
|
||||
}
|
||||
self.ensure_username_available(&username, user_uid).await?;
|
||||
|
||||
let visibility = parse_visibility(¶ms.visibility, current.visibility)?;
|
||||
let display_name = merge_optional_text(params.display_name, current.display_name);
|
||||
let bio = merge_optional_text(params.bio, current.bio);
|
||||
|
||||
sqlx::query_as::<_, User>(
|
||||
"UPDATE \"user\" SET username = $1, display_name = $2, bio = $3, visibility = $4, \
|
||||
updated_at = $5 WHERE id = $6 AND deleted_at IS NULL \
|
||||
RETURNING id, username, display_name, avatar_url, bio, status, role, visibility, \
|
||||
is_active, is_bot, last_login_at, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(&username)
|
||||
.bind(&display_name)
|
||||
.bind(&bio)
|
||||
.bind(visibility)
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::UserNotFound)
|
||||
}
|
||||
|
||||
pub async fn user_upload_avatar(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
params: UploadUserAvatarParams,
|
||||
) -> Result<UserAvatarResponse, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let ext = avatar_extension(params.content_type.as_deref(), params.file_name.as_deref())?;
|
||||
validate_avatar_size(params.data.len(), self.ctx.config.s3_max_upload_size()?)?;
|
||||
|
||||
let current = crate::models::users::User::find_by_id(self.ctx.db.reader(), user_uid)
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or(AppError::UserNotFound)?;
|
||||
let old_avatar_url = current.avatar_url.clone();
|
||||
|
||||
let storage_key = format!("users/{}/avatar/{}.{}", user_uid, uuid::Uuid::now_v7(), ext);
|
||||
self.ctx.storage.put(&storage_key, params.data).await?;
|
||||
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())
|
||||
})?;
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE \"user\" SET avatar_url = $1, updated_at = $2 \
|
||||
WHERE id = $3 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(&avatar_url)
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
let _ = self.ctx.storage.delete(&storage_key).await;
|
||||
return Err(AppError::UserNotFound);
|
||||
}
|
||||
|
||||
if let Some(old_url) = old_avatar_url
|
||||
&& let Some(old_key) = extract_storage_key_from_url(&old_url)
|
||||
{
|
||||
let _ = self.ctx.storage.delete(&old_key).await;
|
||||
}
|
||||
|
||||
Ok(UserAvatarResponse {
|
||||
avatar_url,
|
||||
storage_key,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn user_delete_account(&self, ctx: &Session) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let owned_workspace_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM workspace WHERE owner_id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if owned_workspace_count > 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"transfer or delete owned workspaces before deleting the account".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let owned_repo_count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM repo WHERE owner_id = $1 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if owned_repo_count > 0 {
|
||||
return Err(AppError::BadRequest(
|
||||
"transfer or delete owned repos before deleting the account".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
let mut txn = self
|
||||
.ctx
|
||||
.db
|
||||
.writer()
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| AppError::TxnError)?;
|
||||
|
||||
for statement in [
|
||||
"DELETE FROM user_personal_access_token WHERE user_id = $1",
|
||||
"DELETE FROM user_security_log WHERE user_id = $1",
|
||||
"DELETE FROM user_session WHERE user_id = $1",
|
||||
"DELETE FROM user_device WHERE user_id = $1",
|
||||
"DELETE FROM user_oauth WHERE user_id = $1",
|
||||
"DELETE FROM user_ssh_key WHERE user_id = $1",
|
||||
"DELETE FROM user_gpg_key WHERE user_id = $1",
|
||||
"DELETE FROM user_2fa WHERE user_id = $1",
|
||||
"DELETE FROM user_notify_setting WHERE user_id = $1",
|
||||
"DELETE FROM user_appearance WHERE user_id = $1",
|
||||
"DELETE FROM user_profile WHERE user_id = $1",
|
||||
"DELETE FROM user_mail WHERE user_id = $1",
|
||||
"DELETE FROM workspace_member WHERE user_id = $1",
|
||||
"DELETE FROM repo_member WHERE user_id = $1",
|
||||
] {
|
||||
sqlx::query(statement)
|
||||
.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', updated_at = $1 WHERE id = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(now)
|
||||
.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)?;
|
||||
ctx.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_username_available(
|
||||
&self,
|
||||
username: &str,
|
||||
user_uid: uuid::Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let exists = sqlx::query(
|
||||
"SELECT id FROM \"user\" WHERE lower(username) = lower($1) \
|
||||
AND id <> $2 AND deleted_at IS NULL LIMIT 1",
|
||||
)
|
||||
.bind(username)
|
||||
.bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
if exists.is_some() {
|
||||
return Err(AppError::AccountAlreadyExists);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_visibility(next: &Option<String>, current: Visibility) -> Result<Visibility, AppError> {
|
||||
parse_enum(next.clone(), current, Visibility::Unknown, "visibility")
|
||||
}
|
||||
|
||||
use crate::service::util::{avatar_extension, validate_avatar_size};
|
||||
@@ -0,0 +1,107 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::{ColorScheme, Density, FontSize, Theme};
|
||||
use crate::models::users::UserAppearance;
|
||||
use crate::service::UserService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{merge_optional_text, parse_enum};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateUserAppearanceParams {
|
||||
pub theme: Option<String>,
|
||||
pub color_scheme: Option<String>,
|
||||
pub density: Option<String>,
|
||||
pub font_size: Option<String>,
|
||||
pub editor_theme: Option<String>,
|
||||
pub markdown_preview: Option<bool>,
|
||||
pub reduced_motion: Option<bool>,
|
||||
}
|
||||
|
||||
impl UserService {
|
||||
pub async fn user_appearance(&self, ctx: &Session) -> Result<UserAppearance, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
self.ensure_user_appearance(user_uid).await
|
||||
}
|
||||
|
||||
pub async fn user_update_appearance(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
params: UpdateUserAppearanceParams,
|
||||
) -> Result<UserAppearance, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let current = self.ensure_user_appearance(user_uid).await?;
|
||||
let theme = parse_enum(params.theme, current.theme, Theme::Unknown, "theme")?;
|
||||
let color_scheme = parse_enum(
|
||||
params.color_scheme,
|
||||
current.color_scheme,
|
||||
ColorScheme::Unknown,
|
||||
"color_scheme",
|
||||
)?;
|
||||
let density = parse_enum(params.density, current.density, Density::Unknown, "density")?;
|
||||
let font_size = parse_enum(
|
||||
params.font_size,
|
||||
current.font_size,
|
||||
FontSize::Unknown,
|
||||
"font_size",
|
||||
)?;
|
||||
|
||||
sqlx::query_as::<_, UserAppearance>(
|
||||
"UPDATE user_appearance SET theme = $1, color_scheme = $2, density = $3, font_size = $4, \
|
||||
editor_theme = $5, markdown_preview = $6, reduced_motion = $7, updated_at = $8 \
|
||||
WHERE user_id = $9 RETURNING user_id, theme, color_scheme, density, font_size, \
|
||||
editor_theme, markdown_preview, reduced_motion, created_at, updated_at",
|
||||
)
|
||||
.bind(theme)
|
||||
.bind(color_scheme)
|
||||
.bind(density)
|
||||
.bind(font_size)
|
||||
.bind(merge_optional_text(params.editor_theme, current.editor_theme))
|
||||
.bind(params.markdown_preview.unwrap_or(current.markdown_preview))
|
||||
.bind(params.reduced_motion.unwrap_or(current.reduced_motion))
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(user_uid)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
async fn ensure_user_appearance(
|
||||
&self,
|
||||
user_uid: uuid::Uuid,
|
||||
) -> Result<UserAppearance, AppError> {
|
||||
if let Some(appearance) = self.find_user_appearance(user_uid).await? {
|
||||
return Ok(appearance);
|
||||
}
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query(
|
||||
"INSERT INTO user_appearance (user_id, theme, color_scheme, density, font_size, \
|
||||
markdown_preview, reduced_motion, created_at, updated_at) \
|
||||
VALUES ($1, 'system', 'system', 'comfortable', 'medium', true, false, $2, $2) ON CONFLICT (user_id) DO NOTHING",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
self.find_user_appearance(user_uid)
|
||||
.await?
|
||||
.ok_or(AppError::UserNotFound)
|
||||
}
|
||||
|
||||
async fn find_user_appearance(
|
||||
&self,
|
||||
user_uid: uuid::Uuid,
|
||||
) -> Result<Option<UserAppearance>, AppError> {
|
||||
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.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::KeyType;
|
||||
use crate::models::users::{UserGpgKey, UserSshKey};
|
||||
use crate::service::UserService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{ensure_affected, required_text};
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct AddSshKeyParams {
|
||||
pub title: String,
|
||||
pub public_key: String,
|
||||
pub key_type: String,
|
||||
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct AddGpgKeyParams {
|
||||
pub public_key: String,
|
||||
pub key_id: String,
|
||||
pub primary_email: Option<String>,
|
||||
pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
impl UserService {
|
||||
pub async fn user_ssh_keys(&self, ctx: &Session) -> Result<Vec<UserSshKey>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
sqlx::query_as::<_, UserSshKey>(
|
||||
"SELECT id, user_id, title, public_key, fingerprint_sha256, key_type, last_used_at, \
|
||||
expires_at, revoked_at, created_at, updated_at FROM user_ssh_key \
|
||||
WHERE user_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn user_add_ssh_key(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
params: AddSshKeyParams,
|
||||
) -> Result<UserSshKey, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let title = required_text(params.title, "title")?;
|
||||
let public_key = required_text(params.public_key, "public_key")?;
|
||||
let key_type = parse_key_type(¶ms.key_type)?;
|
||||
let fingerprint = ssh_fingerprint(&public_key, key_type)?;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let existing = sqlx::query(
|
||||
"SELECT id FROM user_ssh_key WHERE fingerprint_sha256 = $1 AND user_id = $2 AND revoked_at IS NULL LIMIT 1",
|
||||
)
|
||||
.bind(&fingerprint)
|
||||
.bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Err(AppError::Conflict("SSH key already exists".into()));
|
||||
}
|
||||
|
||||
sqlx::query_as::<_, UserSshKey>(
|
||||
"INSERT INTO user_ssh_key (id, user_id, title, public_key, fingerprint_sha256, key_type, \
|
||||
expires_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) \
|
||||
RETURNING id, user_id, title, public_key, fingerprint_sha256, key_type, last_used_at, \
|
||||
expires_at, revoked_at, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(user_uid)
|
||||
.bind(title)
|
||||
.bind(&public_key)
|
||||
.bind(fingerprint)
|
||||
.bind(key_type)
|
||||
.bind(params.expires_at)
|
||||
.bind(now)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn user_delete_ssh_key(&self, ctx: &Session, key_uid: Uuid) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let result = sqlx::query(
|
||||
"UPDATE user_ssh_key SET revoked_at = $1, updated_at = $1 \
|
||||
WHERE id = $2 AND user_id = $3 AND revoked_at IS NULL",
|
||||
)
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(key_uid)
|
||||
.bind(user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "key not found")
|
||||
}
|
||||
|
||||
pub async fn user_gpg_keys(&self, ctx: &Session) -> Result<Vec<UserGpgKey>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
sqlx::query_as::<_, UserGpgKey>(
|
||||
"SELECT id, user_id, key_id, public_key, fingerprint, primary_email, expires_at, \
|
||||
verified_at, revoked_at, created_at, updated_at FROM user_gpg_key \
|
||||
WHERE user_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn user_add_gpg_key(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
params: AddGpgKeyParams,
|
||||
) -> Result<UserGpgKey, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let public_key = required_text(params.public_key, "public_key")?;
|
||||
let key_id = required_text(params.key_id, "key_id")?;
|
||||
let primary_email = params
|
||||
.primary_email
|
||||
.map(|v| v.trim().to_lowercase())
|
||||
.filter(|v| !v.is_empty());
|
||||
let fingerprint = gpg_fingerprint(&public_key)?;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let existing = sqlx::query(
|
||||
"SELECT id FROM user_gpg_key WHERE fingerprint = $1 AND user_id = $2 AND revoked_at IS NULL LIMIT 1",
|
||||
)
|
||||
.bind(&fingerprint)
|
||||
.bind(user_uid)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Err(AppError::Conflict("GPG key already exists".into()));
|
||||
}
|
||||
|
||||
sqlx::query_as::<_, UserGpgKey>(
|
||||
"INSERT INTO user_gpg_key (id, user_id, key_id, public_key, fingerprint, primary_email, \
|
||||
expires_at, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) \
|
||||
RETURNING id, user_id, key_id, public_key, fingerprint, primary_email, expires_at, \
|
||||
verified_at, revoked_at, created_at, updated_at",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(user_uid)
|
||||
.bind(key_id)
|
||||
.bind(&public_key)
|
||||
.bind(&fingerprint)
|
||||
.bind(primary_email)
|
||||
.bind(params.expires_at)
|
||||
.bind(now)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn user_delete_gpg_key(&self, ctx: &Session, key_uid: Uuid) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let result = sqlx::query(
|
||||
"UPDATE user_gpg_key SET revoked_at = $1, updated_at = $1 \
|
||||
WHERE id = $2 AND user_id = $3 AND revoked_at IS NULL",
|
||||
)
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(key_uid)
|
||||
.bind(user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "key not found")
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_key_type(value: &str) -> Result<KeyType, AppError> {
|
||||
use crate::models::common::KeyType;
|
||||
let key_type = value
|
||||
.trim()
|
||||
.parse::<KeyType>()
|
||||
.map_err(|_| AppError::BadRequest("invalid key_type".into()))?;
|
||||
if key_type == KeyType::Unknown {
|
||||
return Err(AppError::BadRequest("invalid key_type".into()));
|
||||
}
|
||||
Ok(key_type)
|
||||
}
|
||||
|
||||
fn ssh_fingerprint(public_key: &str, expected_type: KeyType) -> Result<String, AppError> {
|
||||
use base64::Engine;
|
||||
|
||||
let parts: Vec<&str> = public_key.split_whitespace().collect();
|
||||
if parts.len() < 2 {
|
||||
return Err(AppError::BadRequest("invalid SSH public key format".into()));
|
||||
}
|
||||
|
||||
let actual_type = ssh_key_type(parts[0])?;
|
||||
if actual_type != expected_type {
|
||||
return Err(AppError::BadRequest(
|
||||
"key_type does not match SSH public key".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(parts[1])
|
||||
.map_err(|_| AppError::BadRequest("invalid SSH public key data".into()))?;
|
||||
if decoded.is_empty() {
|
||||
return Err(AppError::BadRequest("invalid SSH public key data".into()));
|
||||
}
|
||||
|
||||
Ok(super::util::sha256_hex(&decoded))
|
||||
}
|
||||
|
||||
fn ssh_key_type(value: &str) -> Result<KeyType, AppError> {
|
||||
match value {
|
||||
"ssh-rsa" => Ok(KeyType::Rsa),
|
||||
"ssh-ed25519" => Ok(KeyType::Ed25519),
|
||||
v if v.starts_with("ecdsa-sha2-") => Ok(KeyType::Ecdsa),
|
||||
"ssh-dss" => Ok(KeyType::Dsa),
|
||||
_ => Err(AppError::BadRequest("unsupported SSH key type".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn gpg_fingerprint(public_key: &str) -> Result<String, AppError> {
|
||||
let key = public_key.trim();
|
||||
if !key.starts_with("-----BEGIN PGP PUBLIC KEY BLOCK-----")
|
||||
|| !key.contains("-----END PGP PUBLIC KEY BLOCK-----")
|
||||
{
|
||||
return Err(AppError::BadRequest("invalid GPG public key format".into()));
|
||||
}
|
||||
Ok(super::util::sha256_hex(key.as_bytes()))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod account;
|
||||
pub mod appearance;
|
||||
pub mod keys;
|
||||
pub mod notify;
|
||||
pub mod profile;
|
||||
pub mod security;
|
||||
pub mod util;
|
||||
@@ -0,0 +1,122 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::DigestFrequency;
|
||||
use crate::models::users::UserNotifySetting;
|
||||
use crate::service::UserService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::parse_enum;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateUserNotifySettingParams {
|
||||
pub email_notifications: Option<bool>,
|
||||
pub web_notifications: Option<bool>,
|
||||
pub mention_notifications: Option<bool>,
|
||||
pub review_notifications: Option<bool>,
|
||||
pub security_notifications: Option<bool>,
|
||||
pub marketing_emails: Option<bool>,
|
||||
pub digest_frequency: Option<String>,
|
||||
}
|
||||
|
||||
impl UserService {
|
||||
pub async fn user_notify_setting(&self, ctx: &Session) -> Result<UserNotifySetting, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
self.ensure_user_notify_setting(user_uid).await
|
||||
}
|
||||
|
||||
pub async fn user_update_notify_setting(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
params: UpdateUserNotifySettingParams,
|
||||
) -> Result<UserNotifySetting, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let current = self.ensure_user_notify_setting(user_uid).await?;
|
||||
let digest_frequency = parse_enum(
|
||||
params.digest_frequency,
|
||||
current.digest_frequency,
|
||||
DigestFrequency::Unknown,
|
||||
"digest_frequency",
|
||||
)?;
|
||||
|
||||
sqlx::query_as::<_, UserNotifySetting>(
|
||||
"UPDATE user_notify_setting SET email_notifications = $1, web_notifications = $2, \
|
||||
mention_notifications = $3, review_notifications = $4, security_notifications = $5, \
|
||||
marketing_emails = $6, digest_frequency = $7, updated_at = $8 WHERE user_id = $9 \
|
||||
RETURNING user_id, email_notifications, web_notifications, mention_notifications, \
|
||||
review_notifications, security_notifications, marketing_emails, digest_frequency, \
|
||||
created_at, updated_at",
|
||||
)
|
||||
.bind(
|
||||
params
|
||||
.email_notifications
|
||||
.unwrap_or(current.email_notifications),
|
||||
)
|
||||
.bind(
|
||||
params
|
||||
.web_notifications
|
||||
.unwrap_or(current.web_notifications),
|
||||
)
|
||||
.bind(
|
||||
params
|
||||
.mention_notifications
|
||||
.unwrap_or(current.mention_notifications),
|
||||
)
|
||||
.bind(
|
||||
params
|
||||
.review_notifications
|
||||
.unwrap_or(current.review_notifications),
|
||||
)
|
||||
.bind(
|
||||
params
|
||||
.security_notifications
|
||||
.unwrap_or(current.security_notifications),
|
||||
)
|
||||
.bind(params.marketing_emails.unwrap_or(current.marketing_emails))
|
||||
.bind(digest_frequency)
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(user_uid)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
async fn ensure_user_notify_setting(
|
||||
&self,
|
||||
user_uid: uuid::Uuid,
|
||||
) -> Result<UserNotifySetting, AppError> {
|
||||
if let Some(setting) = self.find_user_notify_setting(user_uid).await? {
|
||||
return Ok(setting);
|
||||
}
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query(
|
||||
"INSERT INTO user_notify_setting (user_id, email_notifications, web_notifications, \
|
||||
mention_notifications, review_notifications, security_notifications, marketing_emails, \
|
||||
digest_frequency, created_at, updated_at) \
|
||||
VALUES ($1, true, true, true, true, true, false, 'realtime', $2, $2) ON CONFLICT (user_id) DO NOTHING",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
self.find_user_notify_setting(user_uid)
|
||||
.await?
|
||||
.ok_or(AppError::UserNotFound)
|
||||
}
|
||||
|
||||
async fn find_user_notify_setting(
|
||||
&self,
|
||||
user_uid: uuid::Uuid,
|
||||
) -> Result<Option<UserNotifySetting>, AppError> {
|
||||
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.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::users::UserProfile;
|
||||
use crate::service::UserService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::merge_optional_text;
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateUserProfileParams {
|
||||
pub full_name: Option<String>,
|
||||
pub company: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub website_url: Option<String>,
|
||||
pub twitter_username: Option<String>,
|
||||
pub timezone: Option<String>,
|
||||
pub language: Option<String>,
|
||||
pub profile_readme: Option<String>,
|
||||
}
|
||||
|
||||
impl UserService {
|
||||
pub async fn user_profile(&self, ctx: &Session) -> Result<UserProfile, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
self.ensure_user_profile(user_uid).await
|
||||
}
|
||||
|
||||
pub async fn user_update_profile(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
params: UpdateUserProfileParams,
|
||||
) -> Result<UserProfile, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let current = self.ensure_user_profile(user_uid).await?;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
sqlx::query_as::<_, UserProfile>(
|
||||
"UPDATE user_profile SET full_name = $1, company = $2, location = $3, website_url = $4, \
|
||||
twitter_username = $5, timezone = $6, language = $7, profile_readme = $8, updated_at = $9 \
|
||||
WHERE user_id = $10 RETURNING user_id, full_name, company, location, website_url, \
|
||||
twitter_username, timezone, language, profile_readme, created_at, updated_at",
|
||||
)
|
||||
.bind(merge_optional_text(params.full_name, current.full_name))
|
||||
.bind(merge_optional_text(params.company, current.company))
|
||||
.bind(merge_optional_text(params.location, current.location))
|
||||
.bind(merge_optional_text(params.website_url, current.website_url))
|
||||
.bind(merge_optional_text(params.twitter_username, current.twitter_username))
|
||||
.bind(merge_optional_text(params.timezone, current.timezone))
|
||||
.bind(merge_optional_text(params.language, current.language))
|
||||
.bind(merge_optional_text(params.profile_readme, current.profile_readme))
|
||||
.bind(now)
|
||||
.bind(user_uid)
|
||||
.fetch_one(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
async fn ensure_user_profile(&self, user_uid: uuid::Uuid) -> Result<UserProfile, AppError> {
|
||||
if let Some(profile) = self.find_user_profile(user_uid).await? {
|
||||
return Ok(profile);
|
||||
}
|
||||
let now = chrono::Utc::now();
|
||||
sqlx::query(
|
||||
"INSERT INTO user_profile (user_id, created_at, updated_at) VALUES ($1, $2, $2) ON CONFLICT (user_id) DO NOTHING",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
self.find_user_profile(user_uid)
|
||||
.await?
|
||||
.ok_or(AppError::UserNotFound)
|
||||
}
|
||||
|
||||
async fn find_user_profile(
|
||||
&self,
|
||||
user_uid: uuid::Uuid,
|
||||
) -> Result<Option<UserProfile>, AppError> {
|
||||
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.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::{EventType, JsonValue, Provider, Scope};
|
||||
use crate::models::users::{UserDevice, UserSecurityLog};
|
||||
use crate::service::UserService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::ensure_affected;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UserSessionInfo {
|
||||
pub id: Uuid,
|
||||
pub ip_address: Option<String>,
|
||||
pub user_agent: Option<String>,
|
||||
pub last_active_at: DateTime<Utc>,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UserOAuthInfo {
|
||||
pub id: Uuid,
|
||||
pub provider: Provider,
|
||||
pub provider_user_id: String,
|
||||
pub provider_username: Option<String>,
|
||||
pub provider_email: Option<String>,
|
||||
pub token_expires_at: Option<DateTime<Utc>>,
|
||||
pub linked_at: DateTime<Utc>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct UserPersonalAccessTokenInfo {
|
||||
pub id: Uuid,
|
||||
pub name: String,
|
||||
pub scopes: Vec<Scope>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub revoked_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
struct UserSessionRow {
|
||||
id: Uuid,
|
||||
ip_address: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
last_active_at: DateTime<Utc>,
|
||||
expires_at: DateTime<Utc>,
|
||||
revoked_at: Option<DateTime<Utc>>,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
struct UserOAuthRow {
|
||||
id: Uuid,
|
||||
provider: Provider,
|
||||
provider_user_id: String,
|
||||
provider_username: Option<String>,
|
||||
provider_email: Option<String>,
|
||||
token_expires_at: Option<DateTime<Utc>>,
|
||||
linked_at: DateTime<Utc>,
|
||||
last_used_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
struct UserPersonalAccessTokenRow {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
scopes: Vec<Scope>,
|
||||
last_used_at: Option<DateTime<Utc>>,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
revoked_at: Option<DateTime<Utc>>,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl UserService {
|
||||
pub async fn user_devices(&self, ctx: &Session) -> Result<Vec<UserDevice>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
sqlx::query_as::<_, UserDevice>(
|
||||
"SELECT id, user_id, device_name, device_type, fingerprint, ip_address, user_agent, \
|
||||
trusted, last_seen_at, created_at, updated_at FROM user_device \
|
||||
WHERE user_id = $1 ORDER BY last_seen_at DESC NULLS LAST, created_at DESC",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
pub async fn user_delete_device(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
device_uid: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let result = sqlx::query("DELETE FROM user_device WHERE id = $1 AND user_id = $2")
|
||||
.bind(device_uid)
|
||||
.bind(user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "device not found")
|
||||
}
|
||||
|
||||
pub async fn user_sessions(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<UserSessionInfo>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let limit = limit.clamp(1, 100);
|
||||
let offset = offset.max(0);
|
||||
let rows = sqlx::query_as::<_, UserSessionRow>(
|
||||
"SELECT id, ip_address, user_agent, last_active_at, expires_at, revoked_at, created_at \
|
||||
FROM user_session WHERE user_id = $1 ORDER BY last_active_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
pub async fn user_revoke_session(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
session_uid: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let result = sqlx::query(
|
||||
"UPDATE user_session SET revoked_at = $1 \
|
||||
WHERE id = $2 AND user_id = $3 AND revoked_at IS NULL",
|
||||
)
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(session_uid)
|
||||
.bind(user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "session not found")
|
||||
}
|
||||
|
||||
pub async fn user_oauth_accounts(&self, ctx: &Session) -> Result<Vec<UserOAuthInfo>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let rows = sqlx::query_as::<_, UserOAuthRow>(
|
||||
"SELECT id, provider, provider_user_id, provider_username, provider_email, \
|
||||
token_expires_at, linked_at, last_used_at FROM user_oauth \
|
||||
WHERE user_id = $1 ORDER BY linked_at DESC",
|
||||
)
|
||||
.bind(user_uid)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
pub async fn user_unlink_oauth(&self, ctx: &Session, oauth_uid: Uuid) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let has_password: bool =
|
||||
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM user_password WHERE user_id = $1)")
|
||||
.bind(user_uid)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
let oauth_count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM user_oauth WHERE user_id = $1")
|
||||
.bind(user_uid)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if !has_password && oauth_count <= 1 {
|
||||
return Err(AppError::BadRequest(
|
||||
"cannot unlink the last login method; please set a password first".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let result = sqlx::query("DELETE FROM user_oauth WHERE id = $1 AND user_id = $2")
|
||||
.bind(oauth_uid)
|
||||
.bind(user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "oauth account not found")
|
||||
}
|
||||
|
||||
pub async fn user_security_logs(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<UserSecurityLog>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let limit = limit.clamp(1, 100);
|
||||
let offset = offset.max(0);
|
||||
sqlx::query_as::<_, UserSecurityLog>(
|
||||
"SELECT id, user_id, event_type, description, ip_address, user_agent, metadata, created_at \
|
||||
FROM user_security_log WHERE 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 user_log_security_event(
|
||||
&self,
|
||||
user_uid: Uuid,
|
||||
event_type: EventType,
|
||||
description: Option<String>,
|
||||
ip_address: Option<String>,
|
||||
user_agent: Option<String>,
|
||||
metadata: Option<JsonValue>,
|
||||
) -> Result<(), AppError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO user_security_log (id, user_id, event_type, description, ip_address, user_agent, metadata, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(user_uid)
|
||||
.bind(event_type)
|
||||
.bind(description)
|
||||
.bind(ip_address)
|
||||
.bind(user_agent)
|
||||
.bind(metadata)
|
||||
.bind(chrono::Utc::now())
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn user_personal_access_tokens(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<UserPersonalAccessTokenInfo>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let limit = limit.clamp(1, 100);
|
||||
let offset = offset.max(0);
|
||||
let rows = sqlx::query_as::<_, UserPersonalAccessTokenRow>(
|
||||
"SELECT id, name, scopes, last_used_at, expires_at, revoked_at, created_at, updated_at \
|
||||
FROM user_personal_access_token WHERE 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)?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
pub async fn user_revoke_personal_access_token(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
token_uid: Uuid,
|
||||
) -> Result<(), AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let result = sqlx::query(
|
||||
"UPDATE user_personal_access_token SET revoked_at = $1, updated_at = $1 \
|
||||
WHERE id = $2 AND user_id = $3 AND revoked_at IS NULL",
|
||||
)
|
||||
.bind(chrono::Utc::now())
|
||||
.bind(token_uid)
|
||||
.bind(user_uid)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
ensure_affected(result.rows_affected(), "token not found")
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserSessionRow> for UserSessionInfo {
|
||||
fn from(row: UserSessionRow) -> Self {
|
||||
Self {
|
||||
id: row.id,
|
||||
ip_address: row.ip_address,
|
||||
user_agent: row.user_agent,
|
||||
last_active_at: row.last_active_at,
|
||||
expires_at: row.expires_at,
|
||||
revoked_at: row.revoked_at,
|
||||
created_at: row.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserOAuthRow> for UserOAuthInfo {
|
||||
fn from(row: UserOAuthRow) -> Self {
|
||||
Self {
|
||||
id: row.id,
|
||||
provider: row.provider,
|
||||
provider_user_id: row.provider_user_id,
|
||||
provider_username: row.provider_username,
|
||||
provider_email: row.provider_email,
|
||||
token_expires_at: row.token_expires_at,
|
||||
linked_at: row.linked_at,
|
||||
last_used_at: row.last_used_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserPersonalAccessTokenRow> for UserPersonalAccessTokenInfo {
|
||||
fn from(row: UserPersonalAccessTokenRow) -> Self {
|
||||
Self {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
scopes: row.scopes,
|
||||
last_used_at: row.last_used_at,
|
||||
expires_at: row.expires_at,
|
||||
revoked_at: row.revoked_at,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub use crate::service::util::{
|
||||
ensure_affected, merge_optional_text, parse_enum, required_text, sha256_hex,
|
||||
};
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
|
||||
pub fn merge_optional_text(next: Option<String>, current: Option<String>) -> Option<String> {
|
||||
next.map(|v| {
|
||||
let value = v.trim().to_string();
|
||||
if value.is_empty() { None } else { Some(value) }
|
||||
})
|
||||
.unwrap_or(current)
|
||||
}
|
||||
|
||||
pub fn ensure_affected(rows_affected: u64, not_found: &str) -> Result<(), AppError> {
|
||||
if rows_affected == 0 {
|
||||
Err(AppError::NotFound(not_found.into()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_enum<T>(
|
||||
next: Option<String>,
|
||||
current: T,
|
||||
unknown: T,
|
||||
name: &str,
|
||||
) -> Result<T, AppError>
|
||||
where
|
||||
T: std::str::FromStr + PartialEq,
|
||||
{
|
||||
let Some(value) = next else {
|
||||
return Ok(current);
|
||||
};
|
||||
let parsed = value
|
||||
.trim()
|
||||
.parse::<T>()
|
||||
.map_err(|_| AppError::BadRequest(format!("invalid {name}")))?;
|
||||
if parsed == unknown {
|
||||
return Err(AppError::BadRequest(format!("invalid {name}")));
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
pub fn required_text(value: String, field: &str) -> Result<String, AppError> {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty() {
|
||||
return Err(AppError::BadRequest(format!("{field} is required")));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub fn clamp_limit_offset(limit: i64, offset: i64) -> (i64, i64) {
|
||||
(limit.clamp(1, 100), offset.max(0))
|
||||
}
|
||||
|
||||
pub fn role_level(role: Role) -> i32 {
|
||||
match role {
|
||||
Role::Owner => 100,
|
||||
Role::Admin => 90,
|
||||
Role::Maintainer => 70,
|
||||
Role::Member => 50,
|
||||
Role::Contributor => 30,
|
||||
Role::Viewer => 10,
|
||||
Role::Guest => 5,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.bytes()
|
||||
.zip(b.bytes())
|
||||
.fold(0, |acc, (x, y)| acc | (x ^ y))
|
||||
== 0
|
||||
}
|
||||
|
||||
pub fn sha256_hex(data: &[u8]) -> String {
|
||||
use sha2::Digest;
|
||||
sha2::Sha256::digest(data)
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn extract_storage_key_from_url(url: &str) -> Option<String> {
|
||||
let path = url.split_once("://").map(|(_, rest)| rest)?;
|
||||
let path = path.split_once('/').map(|(_, rest)| rest).unwrap_or(path);
|
||||
if path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(path.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn avatar_extension(
|
||||
content_type: Option<&str>,
|
||||
file_name: Option<&str>,
|
||||
) -> Result<&'static str, AppError> {
|
||||
if let Some(ct) = content_type.map(str::trim).filter(|v| !v.is_empty()) {
|
||||
return match ct.to_ascii_lowercase().as_str() {
|
||||
"image/png" => Ok("png"),
|
||||
"image/jpeg" | "image/jpg" => Ok("jpg"),
|
||||
"image/webp" => Ok("webp"),
|
||||
"image/gif" => Ok("gif"),
|
||||
_ => Err(AppError::BadRequest(
|
||||
"unsupported avatar content type".into(),
|
||||
)),
|
||||
};
|
||||
}
|
||||
let Some(file_name) = file_name else {
|
||||
return Err(AppError::BadRequest(
|
||||
"avatar content type is required".into(),
|
||||
));
|
||||
};
|
||||
match file_name
|
||||
.rsplit('.')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"png" => Ok("png"),
|
||||
"jpg" | "jpeg" => Ok("jpg"),
|
||||
"webp" => Ok("webp"),
|
||||
"gif" => Ok("gif"),
|
||||
_ => Err(AppError::BadRequest("unsupported avatar file type".into())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_avatar_size(size: usize, configured_max_size: u64) -> Result<(), AppError> {
|
||||
const MAX_AVATAR_SIZE: u64 = 5 * 1024 * 1024;
|
||||
const MIN_AVATAR_SIZE: u64 = 1024;
|
||||
if size == 0 {
|
||||
return Err(AppError::BadRequest("avatar is empty".into()));
|
||||
}
|
||||
let max_size = configured_max_size.clamp(MIN_AVATAR_SIZE, MAX_AVATAR_SIZE) as usize;
|
||||
if size > max_size {
|
||||
return Err(AppError::BadRequest("avatar is too large".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_password_strength(password: &str) -> Result<(), AppError> {
|
||||
if password.len() < 8 {
|
||||
return Err(AppError::PasswordTooWeak);
|
||||
}
|
||||
if !password.chars().any(|c| c.is_uppercase())
|
||||
|| !password.chars().any(|c| c.is_lowercase())
|
||||
|| !password.chars().any(|c| c.is_numeric())
|
||||
{
|
||||
return Err(AppError::PasswordTooWeak);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::models::common::Role;
|
||||
use crate::models::wiki::WikiPage;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::{clamp_limit_offset, ensure_affected, required_text};
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct CreateWikiPageParams {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)]
|
||||
pub struct UpdateWikiPageParams {
|
||||
pub title: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub commit_message: Option<String>,
|
||||
}
|
||||
|
||||
impl RepoService {
|
||||
/// 列出仓库的所有 wiki 页面
|
||||
pub async fn wiki_list_pages(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
search: Option<String>,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<WikiPage>, 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);
|
||||
|
||||
if let Some(query) = search {
|
||||
let pattern = format!("%{}%", query);
|
||||
sqlx::query_as::<_, WikiPage>(
|
||||
"SELECT id, repo_id, slug, title, content, author_id, last_editor_id, version, \
|
||||
created_at, updated_at, deleted_at \
|
||||
FROM wiki_page WHERE repo_id = $1 AND deleted_at IS NULL \
|
||||
AND (title ILIKE $2 OR content ILIKE $2) \
|
||||
ORDER BY updated_at DESC LIMIT $3 OFFSET $4",
|
||||
)
|
||||
.bind(repo.id)
|
||||
.bind(&pattern)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
} else {
|
||||
sqlx::query_as::<_, WikiPage>(
|
||||
"SELECT id, repo_id, slug, title, content, author_id, last_editor_id, version, \
|
||||
created_at, updated_at, deleted_at \
|
||||
FROM wiki_page WHERE repo_id = $1 AND deleted_at IS NULL \
|
||||
ORDER BY updated_at DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(repo.id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取单个 wiki 页面
|
||||
pub async fn wiki_get_page(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
slug: &str,
|
||||
) -> Result<WikiPage, 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?;
|
||||
|
||||
sqlx::query_as::<_, WikiPage>(
|
||||
"SELECT id, repo_id, slug, title, content, author_id, last_editor_id, version, \
|
||||
created_at, updated_at, deleted_at \
|
||||
FROM wiki_page WHERE repo_id = $1 AND slug = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(repo.id)
|
||||
.bind(slug)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or_else(|| AppError::NotFound("Wiki page not found".into()))
|
||||
}
|
||||
|
||||
/// 创建 wiki 页面
|
||||
pub async fn wiki_create_page(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
params: CreateWikiPageParams,
|
||||
) -> Result<WikiPage, 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 title = required_text(params.title, "title")?;
|
||||
let content = required_text(params.content, "content")?;
|
||||
let slug = self.generate_wiki_slug(repo.id, &title).await?;
|
||||
let now = chrono::Utc::now();
|
||||
let page_id = Uuid::now_v7();
|
||||
|
||||
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 page = sqlx::query_as::<_, WikiPage>(
|
||||
"INSERT INTO wiki_page (id, repo_id, slug, title, content, author_id, last_editor_id, version, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6, 1, $7, $7) \
|
||||
RETURNING id, repo_id, slug, title, content, author_id, last_editor_id, version, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(page_id)
|
||||
.bind(repo.id)
|
||||
.bind(&slug)
|
||||
.bind(&title)
|
||||
.bind(&content)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO wiki_page_revision (id, page_id, version, title, content, editor_id, commit_message, created_at) \
|
||||
VALUES ($1, $2, 1, $3, $4, $5, 'Initial creation', $6)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(page_id)
|
||||
.bind(&title)
|
||||
.bind(&content)
|
||||
.bind(user_uid)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
/// 更新 wiki 页面
|
||||
pub async fn wiki_update_page(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
slug: &str,
|
||||
params: UpdateWikiPageParams,
|
||||
) -> Result<WikiPage, 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 page = self.wiki_get_page(ctx, wk_name, repo_name, slug).await?;
|
||||
let new_title = params.title.unwrap_or(page.title.clone());
|
||||
let new_content = params.content.unwrap_or(page.content.clone());
|
||||
let new_version = page.version + 1;
|
||||
let commit_message = params
|
||||
.commit_message
|
||||
.unwrap_or_else(|| "Updated page".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)?;
|
||||
|
||||
let updated = sqlx::query_as::<_, WikiPage>(
|
||||
"UPDATE wiki_page SET title = $1, content = $2, last_editor_id = $3, version = $4, updated_at = $5 \
|
||||
WHERE id = $6 AND deleted_at IS NULL \
|
||||
RETURNING id, repo_id, slug, title, content, author_id, last_editor_id, version, created_at, updated_at, deleted_at",
|
||||
)
|
||||
.bind(&new_title)
|
||||
.bind(&new_content)
|
||||
.bind(user_uid)
|
||||
.bind(new_version)
|
||||
.bind(now)
|
||||
.bind(page.id)
|
||||
.fetch_one(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO wiki_page_revision (id, page_id, version, title, content, editor_id, commit_message, created_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
|
||||
)
|
||||
.bind(Uuid::now_v7())
|
||||
.bind(page.id)
|
||||
.bind(new_version)
|
||||
.bind(&new_title)
|
||||
.bind(&new_content)
|
||||
.bind(user_uid)
|
||||
.bind(&commit_message)
|
||||
.bind(now)
|
||||
.execute(&mut *txn)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
txn.commit().await.map_err(|_| AppError::TxnError)?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// 删除 wiki 页面(软删除)
|
||||
pub async fn wiki_delete_page(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
slug: &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, Role::Admin)
|
||||
.await?;
|
||||
|
||||
let page = self.wiki_get_page(ctx, wk_name, repo_name, slug).await?;
|
||||
let now = chrono::Utc::now();
|
||||
|
||||
let result = sqlx::query(
|
||||
"UPDATE wiki_page SET deleted_at = $1 WHERE id = $2 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(now)
|
||||
.bind(page.id)
|
||||
.execute(self.ctx.db.writer())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
ensure_affected(result.rows_affected(), "wiki page not found")
|
||||
}
|
||||
|
||||
/// 回滚到历史版本
|
||||
pub async fn wiki_revert_to_version(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
slug: &str,
|
||||
target_version: i32,
|
||||
commit_message: Option<String>,
|
||||
) -> Result<WikiPage, 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 page = self.wiki_get_page(ctx, wk_name, repo_name, slug).await?;
|
||||
|
||||
let revision = sqlx::query_as::<_, crate::models::wiki::WikiPageRevision>(
|
||||
"SELECT id, page_id, version, title, content, editor_id, commit_message, created_at \
|
||||
FROM wiki_page_revision WHERE page_id = $1 AND version = $2",
|
||||
)
|
||||
.bind(page.id)
|
||||
.bind(target_version)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or_else(|| AppError::NotFound("Revision not found".into()))?;
|
||||
|
||||
let msg =
|
||||
commit_message.unwrap_or_else(|| format!("Reverted to version {}", target_version));
|
||||
|
||||
self.wiki_update_page(
|
||||
ctx,
|
||||
wk_name,
|
||||
repo_name,
|
||||
slug,
|
||||
UpdateWikiPageParams {
|
||||
title: Some(revision.title),
|
||||
content: Some(revision.content),
|
||||
commit_message: Some(msg),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn generate_slug(title: &str) -> String {
|
||||
title
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
async fn generate_wiki_slug(&self, repo_id: Uuid, title: &str) -> Result<String, AppError> {
|
||||
let base_slug = Self::generate_slug(title);
|
||||
let mut slug = base_slug.clone();
|
||||
let mut counter = 1;
|
||||
|
||||
loop {
|
||||
let exists: bool = sqlx::query_scalar(
|
||||
"SELECT EXISTS(SELECT 1 FROM wiki_page WHERE repo_id = $1 AND slug = $2)",
|
||||
)
|
||||
.bind(repo_id)
|
||||
.bind(&slug)
|
||||
.fetch_one(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
if !exists {
|
||||
return Ok(slug);
|
||||
}
|
||||
|
||||
slug = format!("{}-{}", base_slug, counter);
|
||||
counter += 1;
|
||||
|
||||
if counter > 100 {
|
||||
return Err(AppError::InternalServerError(
|
||||
"Failed to generate unique slug".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod core;
|
||||
pub mod revisions;
|
||||
pub mod util;
|
||||
@@ -0,0 +1,81 @@
|
||||
use crate::error::AppError;
|
||||
use crate::models::wiki::WikiPageRevision;
|
||||
use crate::service::RepoService;
|
||||
use crate::session::Session;
|
||||
|
||||
use super::util::clamp_limit_offset;
|
||||
|
||||
impl RepoService {
|
||||
/// 获取页面的修订历史
|
||||
pub async fn wiki_get_revisions(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
slug: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<WikiPageRevision>, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let page = self.wiki_get_page(ctx, wk_name, repo_name, slug).await?;
|
||||
self.ensure_repo_readable(user_uid, &self.resolve_repo(wk_name, repo_name).await?)
|
||||
.await?;
|
||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||
|
||||
sqlx::query_as::<_, WikiPageRevision>(
|
||||
"SELECT id, page_id, version, title, content, editor_id, commit_message, created_at \
|
||||
FROM wiki_page_revision WHERE page_id = $1 ORDER BY version DESC LIMIT $2 OFFSET $3",
|
||||
)
|
||||
.bind(page.id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)
|
||||
}
|
||||
|
||||
/// 获取特定版本的修订详情
|
||||
pub async fn wiki_get_revision(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
slug: &str,
|
||||
version: i32,
|
||||
) -> Result<WikiPageRevision, AppError> {
|
||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||
let page = self.wiki_get_page(ctx, wk_name, repo_name, slug).await?;
|
||||
self.ensure_repo_readable(user_uid, &self.resolve_repo(wk_name, repo_name).await?)
|
||||
.await?;
|
||||
|
||||
sqlx::query_as::<_, WikiPageRevision>(
|
||||
"SELECT id, page_id, version, title, content, editor_id, commit_message, created_at \
|
||||
FROM wiki_page_revision WHERE page_id = $1 AND version = $2",
|
||||
)
|
||||
.bind(page.id)
|
||||
.bind(version)
|
||||
.fetch_optional(self.ctx.db.reader())
|
||||
.await
|
||||
.map_err(AppError::Database)?
|
||||
.ok_or_else(|| AppError::NotFound("Revision not found".into()))
|
||||
}
|
||||
|
||||
/// 比较两个版本的差异
|
||||
pub async fn wiki_compare_revisions(
|
||||
&self,
|
||||
ctx: &Session,
|
||||
wk_name: &str,
|
||||
repo_name: &str,
|
||||
slug: &str,
|
||||
old_version: i32,
|
||||
new_version: i32,
|
||||
) -> Result<(WikiPageRevision, WikiPageRevision), AppError> {
|
||||
let old = self
|
||||
.wiki_get_revision(ctx, wk_name, repo_name, slug, old_version)
|
||||
.await?;
|
||||
let new = self
|
||||
.wiki_get_revision(ctx, wk_name, repo_name, slug, new_version)
|
||||
.await?;
|
||||
Ok((old, new))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub use crate::service::util::{clamp_limit_offset, ensure_affected, required_text};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user