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, pub display_name: Option, pub bio: Option, pub visibility: Option, } #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct UploadUserAvatarParams { pub data: Vec, pub content_type: Option, pub file_name: Option, } #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct UserAvatarResponse { pub avatar_url: String, pub storage_key: String, } impl UserService { pub async fn user_account(&self, ctx: &Session) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; crate::models::users::User::find_by_id(self.ctx.db.reader(), user_uid) .await .map_err(AppError::Database)? .ok_or(AppError::UserNotFound) } pub async fn user_update_account( &self, ctx: &Session, params: UpdateUserAccountParams, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let current = crate::models::users::User::find_by_id(self.ctx.db.reader(), user_uid) .await .map_err(AppError::Database)? .ok_or(AppError::UserNotFound)?; let username = params .username .map(|v| v.trim().to_string()) .unwrap_or(current.username); if username.is_empty() { return Err(AppError::BadRequest("username is required".into())); } self.ensure_username_available(&username, user_uid).await?; let visibility = parse_visibility(¶ms.visibility, current.visibility)?; let display_name = merge_optional_text(params.display_name, current.display_name); let bio = merge_optional_text(params.bio, current.bio); sqlx::query_as::<_, User>( "UPDATE \"user\" SET username = $1, display_name = $2, bio = $3, visibility = $4, \ updated_at = $5 WHERE id = $6 AND deleted_at IS NULL \ RETURNING id, username, display_name, avatar_url, bio, status, role, visibility, \ is_active, is_bot, last_login_at, created_at, updated_at, deleted_at", ) .bind(&username) .bind(&display_name) .bind(&bio) .bind(visibility) .bind(chrono::Utc::now()) .bind(user_uid) .fetch_optional(self.ctx.db.writer()) .await .map_err(AppError::Database)? .ok_or(AppError::UserNotFound) } pub async fn user_upload_avatar( &self, ctx: &Session, params: UploadUserAvatarParams, ) -> Result { 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, current: Visibility) -> Result { parse_enum(next.clone(), current, Visibility::Unknown, "visibility") } use crate::service::util::{avatar_extension, validate_avatar_size};