feat: init

This commit is contained in:
zhenyi
2026-06-07 11:30:56 +08:00
commit 563381c1ca
361 changed files with 41327 additions and 0 deletions
+85
View File
@@ -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;
+202
View File
@@ -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(())
}
}
+196
View File
@@ -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)
}
}
+14
View File
@@ -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(())
}
}
+47
View File
@@ -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()),
})
}
}
+25
View File
@@ -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
+239
View File
@@ -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, &params.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)
}
}
+187
View File
@@ -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(())
}
}
+145
View File
@@ -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())
}
}
+413
View File
@@ -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, &params.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, &params.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;
+65
View File
@@ -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)
}
}
+715
View File
@@ -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(),
));
}
}
}
}
+176
View File
@@ -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()))
}
}
+554
View File
@@ -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) = &params.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) = &params.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(())
}
}
+45
View File
@@ -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"
);
}
+162
View File
@@ -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(&params.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)
}
}
+100
View File
@@ -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)
}
}
+232
View File
@@ -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)
}
}
+410
View File
@@ -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)
}
}
+889
View File
@@ -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()))
}
}
+58
View File
@@ -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");
}
}
}
}
+372
View File
@@ -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 &params.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(())
}
}
+244
View File
@@ -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}"),
}
}
+261
View File
@@ -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())
}
}
+12
View File
@@ -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 }
}
}
+321
View File
@@ -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()))
}
}
+64
View File
@@ -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
}
+123
View File
@@ -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(())
}
}
+230
View File
@@ -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(())
}
}
+867
View File
@@ -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 &params.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 &params.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 &params.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 &params.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 &params.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 &params.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)
}
}
}
+63
View File
@@ -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)
}
}
+268
View File
@@ -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)
}
}
+137
View File
@@ -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")
}
}
+12
View File
@@ -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;
+122
View File
@@ -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(())
}
}
+108
View File
@@ -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",
)
}
}
+118
View File
@@ -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(())
}
}
+126
View File
@@ -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")
}
}
+158
View File
@@ -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(&params.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")
}
}
+3
View File
@@ -0,0 +1,3 @@
pub use crate::service::util::{
clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text, role_level,
};
+121
View File
@@ -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,
}
+127
View File
@@ -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(())
}
}
+141
View File
@@ -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)
}
}
+85
View File
@@ -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
}
}
+6
View File
@@ -0,0 +1,6 @@
pub mod blocks;
pub mod core;
pub mod deliveries;
pub mod subscriptions;
pub mod templates;
pub mod util;
+183
View File
@@ -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(&params.event_types)
.bind(&params.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(())
}
}
+210
View File
@@ -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(&params.key)
.bind(params.notification_type.as_str())
.bind(params.channel.as_str())
.bind(&params.locale)
.bind(params.subject_template)
.bind(&params.title_template)
.bind(&params.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(())
}
}
+69
View File
@@ -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)
}
}
+119
View File
@@ -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(())
}
}
+189
View File
@@ -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(&params.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")
}
}
+29
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+73
View File
@@ -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)
}
}
+34
View File
@@ -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)
}
}
+225
View File
@@ -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)
}
}
+132
View File
@@ -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)
}
}
}
+13
View File
@@ -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;
+96
View File
@@ -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",
)
}
}
+431
View File
@@ -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) = &params.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")
}
}
+29
View File
@@ -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()))
}
}
+123
View File
@@ -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")
}
}
+3
View File
@@ -0,0 +1,3 @@
pub use crate::service::util::{
clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text, role_level,
};
+278
View File
@@ -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(&params.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(())
}
}
+241
View File
@@ -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(&params.target_url)
.bind(&params.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(&params.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(())
}
}
+789
View File
@@ -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)
}
}
+174
View File
@@ -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()
}
+258
View File
@@ -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()))
}
}
+43
View File
@@ -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())
}
}
+142
View File
@@ -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())
}
}
+79
View File
@@ -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())
}
}
+72
View File
@@ -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())
}
}
+372
View File
@@ -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(&params.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(&params.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(&params.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(&params.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(&params.base)),
head: Some(rev(&params.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())
}
}
+32
View File
@@ -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()))
}
}
+123
View File
@@ -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())
}
}
+96
View File
@@ -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(())
}
}
+77
View File
@@ -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())
}
}
+343
View File
@@ -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()
}
+317
View File
@@ -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(())
}
}
+16
View File
@@ -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;
+389
View File
@@ -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();
}
}
+244
View File
@@ -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(&params.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(())
}
}
+142
View File
@@ -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)
}
}
+152
View File
@@ -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)
}
}
+159
View File
@@ -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(&params.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(())
}
}
+3
View File
@@ -0,0 +1,3 @@
pub use crate::service::util::{
clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text, role_level,
};
+166
View File
@@ -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)
}
}
+269
View File
@@ -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(&params.secret_ciphertext)
.bind(&params.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(())
}
}
+235
View File
@@ -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(&params.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};
+107
View File
@@ -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)
}
}
+232
View File
@@ -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(&params.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()))
}
+7
View File
@@ -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;
+122
View File
@@ -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)
}
}
+90
View File
@@ -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)
}
}
+331
View File
@@ -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,
}
}
}
+3
View File
@@ -0,0 +1,3 @@
pub use crate::service::util::{
ensure_affected, merge_optional_text, parse_enum, required_text, sha256_hex,
};
+154
View File
@@ -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(())
}
+348
View File
@@ -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(),
));
}
}
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod core;
pub mod revisions;
pub mod util;
+81
View File
@@ -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))
}
}
+1
View File
@@ -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