use db::sqlx; use model::workspace::{WkMemberModel, WorkspaceModel}; use serde::{Deserialize, Serialize}; use session::Session; use storage::{ObjectStorage, PutObjectOptions}; use super::types::{ WorkspaceListRow, WorkspaceResponse, normalize_name, workspace_response, }; use crate::{ AppService, error::AppError, metrics::with_op_metric, session_user, }; const ALLOWED_AVATAR_TYPES: &[&str] = &["image/png", "image/jpeg", "image/webp", "image/gif"]; const MAX_AVATAR_SIZE: usize = 5 * 1024 * 1024; #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct AvatarUploadResponse { pub avatar_url: String, } fn extension_from_content_type(content_type: &str) -> &str { match content_type { "image/png" => "png", "image/jpeg" => "jpg", "image/webp" => "webp", "image/gif" => "gif", _ => "bin", } } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct CreateWorkspace { pub name: String, pub description: Option, pub avatar_url: Option, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct UpdateWorkspace { pub name: Option, pub description: Option, pub avatar_url: Option, } impl AppService { #[tracing::instrument(skip(self, ctx), fields(workspace = %params.name))] pub async fn workspace_create( &self, ctx: &Session, params: CreateWorkspace, ) -> Result { with_op_metric(&self.metrics.workspace_operations_total, &["create"], async { let user_uid = session_user(ctx)?; let name = normalize_name(¶ms.name)?; self.workspace_ensure_name_available(&name).await?; let wk_id = uuid::Uuid::now_v7(); let now = chrono::Utc::now(); let description = params.description.unwrap_or_default(); let avatar_url = params.avatar_url.unwrap_or_default(); let mut txn = self.db.begin().await.map_err(|_| AppError::TxnError)?; let workspace = sqlx::query_as::<_, WorkspaceModel>( "INSERT INTO workspace (id, name, description, avatar_url, created_at) \ VALUES ($1, $2, $3, $4, $5) \ RETURNING id, name, description, avatar_url, created_at", ) .bind(wk_id) .bind(&name) .bind(&description) .bind(&avatar_url) .bind(now) .fetch_one(&mut **txn.inner_mut()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; sqlx::query( "INSERT INTO wk_member (wk, \"user\", owner, admin, join_at, leave_at) \ VALUES ($1, $2, true, true, $3, NULL)", ) .bind(wk_id) .bind(user_uid) .bind(now) .execute(&mut **txn.inner_mut()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(workspace_response(workspace, true, true)) }).await } pub async fn workspace_my( &self, ctx: &Session, ) -> Result, AppError> { let user_uid = session_user(ctx)?; let rows = sqlx::query_as::<_, WorkspaceListRow>( "SELECT w.id, w.name, w.description, w.avatar_url, w.created_at, m.owner, m.admin \ FROM wk_member m \ INNER JOIN workspace w ON w.id = m.wk \ WHERE m.\"user\" = $1 AND m.leave_at IS NULL \ ORDER BY w.created_at DESC", ) .bind(user_uid) .fetch_all(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(rows .into_iter() .map(|row| { let owner = row.owner; let admin = row.admin; workspace_response(row.into(), owner, admin) }) .collect()) } pub async fn workspace_get( &self, ctx: &Session, name: &str, ) -> Result { let user_uid = session_user(ctx)?; let wk = self.workspace_resolve(name).await?; let member = self.workspace_member(wk.id, user_uid).await?; Ok(workspace_response(wk, member.owner, member.admin)) } pub async fn workspace_update( &self, ctx: &Session, name: &str, params: UpdateWorkspace, ) -> Result { with_op_metric(&self.metrics.workspace_operations_total, &["update"], async { let user_uid = session_user(ctx)?; let mut wk = self.workspace_resolve(name).await?; self.workspace_require_admin(wk.id, user_uid).await?; let next_name = match params.name { Some(name) => { let name = normalize_name(&name)?; if name != wk.name { self.workspace_ensure_name_available(&name).await?; Some(name) } else { None } } None => None, }; let next_avatar_url = params.avatar_url.unwrap_or_else(|| wk.avatar_url.clone()); let next_description = params.description.unwrap_or_else(|| wk.description.clone()); let mut txn = self.db.begin().await.map_err(|_| AppError::TxnError)?; if let Some(next_name) = &next_name { sqlx::query( "INSERT INTO wk_history_name (id, wk, name, changed_by, created_at) \ VALUES ($1, $2, $3, $4, $5)", ) .bind(uuid::Uuid::now_v7()) .bind(wk.id) .bind(&wk.name) .bind(user_uid) .bind(chrono::Utc::now()) .execute(&mut **txn.inner_mut()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; wk.name = next_name.clone(); } wk = sqlx::query_as::<_, WorkspaceModel>( "UPDATE workspace SET name = $1, description = $2, avatar_url = $3 WHERE id = $4 \ RETURNING id, name, description, avatar_url, created_at", ) .bind(&wk.name) .bind(&next_description) .bind(&next_avatar_url) .bind(wk.id) .fetch_one(&mut **txn.inner_mut()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; txn.commit().await.map_err(|_| AppError::TxnError)?; let member = self.workspace_member(wk.id, user_uid).await?; Ok(workspace_response(wk, member.owner, member.admin)) }).await } /// Get a workspace's avatar URL by workspace name. pub async fn workspace_get_avatar_url( &self, name: &str, ) -> Result { let wk = self.workspace_resolve(name).await?; if wk.avatar_url.is_empty() { return Err(AppError::NotFound("avatar not found".to_string())); } Ok(wk.avatar_url) } /// Upload a workspace avatar image, store it, and update the workspace's avatar_url. pub async fn workspace_upload_avatar( &self, ctx: &Session, name: &str, bytes: Vec, content_type: &str, ) -> Result { let user_uid = session_user(ctx)?; let wk = self.workspace_resolve(name).await?; self.workspace_require_admin(wk.id, user_uid).await?; if bytes.len() > MAX_AVATAR_SIZE { return Err(AppError::AvatarUploadError( "file size exceeds 5 MB limit".to_string(), )); } if !ALLOWED_AVATAR_TYPES.contains(&content_type) { return Err(AppError::AvatarUploadError(format!( "unsupported image type: {content_type}. Allowed: png, jpeg, webp, gif" ))); } let ext = extension_from_content_type(content_type); let key = format!( "avatars/workspaces/{wk_id}-{ts}.{ext}", wk_id = wk.id, ts = uuid::Uuid::now_v7() ); let stored = self .storage .put_bytes( &key, bytes, PutObjectOptions { content_type: Some(content_type.to_string()), ..PutObjectOptions::default() }, ) .await .map_err(|e| { AppError::AvatarUploadError(format!("storage error: {e}")) })?; sqlx::query("UPDATE workspace SET avatar_url = $1 WHERE id = $2") .bind(&stored.url) .bind(wk.id) .execute(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(AvatarUploadResponse { avatar_url: stored.url, }) } pub async fn workspace_resolve( &self, name: &str, ) -> Result { if let Some(wk) = sqlx::query_as::<_, WorkspaceModel>( "SELECT id, name, description, avatar_url, created_at FROM workspace WHERE name = $1", ) .bind(name) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? { return Ok(wk); } if let Some(history) = sqlx::query_as::<_, (uuid::Uuid,)>( "SELECT wk FROM wk_history_name WHERE name = $1 ORDER BY created_at DESC LIMIT 1", ) .bind(name) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? { return sqlx::query_as::<_, WorkspaceModel>( "SELECT id, name, description, avatar_url, created_at FROM workspace WHERE id = $1", ) .bind(history.0) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .ok_or(AppError::NotFound("workspace not found".to_string())); } Err(AppError::NotFound("workspace not found".to_string())) } pub async fn workspace_member( &self, wk_id: uuid::Uuid, user_uid: uuid::Uuid, ) -> Result { sqlx::query_as::<_, WkMemberModel>( "SELECT wk, \"user\", owner, admin, join_at, leave_at \ FROM wk_member WHERE wk = $1 AND \"user\" = $2 AND leave_at IS NULL", ) .bind(wk_id) .bind(user_uid) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .ok_or(AppError::PermissionDenied) } pub async fn workspace_require_member( &self, wk_id: uuid::Uuid, user_uid: uuid::Uuid, ) -> Result { self.workspace_member(wk_id, user_uid).await } pub async fn workspace_require_admin( &self, wk_id: uuid::Uuid, user_uid: uuid::Uuid, ) -> Result { let member = self.workspace_member(wk_id, user_uid).await?; if member.owner || member.admin { Ok(member) } else { Err(AppError::PermissionDenied) } } pub async fn workspace_require_owner( &self, wk_id: uuid::Uuid, user_uid: uuid::Uuid, ) -> Result { let member = self.workspace_member(wk_id, user_uid).await?; if member.owner { Ok(member) } else { Err(AppError::PermissionDenied) } } async fn workspace_ensure_name_available( &self, name: &str, ) -> Result<(), AppError> { let current = sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM workspace WHERE name = $1)", ) .bind(name) .fetch_one(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; let history = sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM wk_history_name WHERE name = $1)", ) .bind(name) .fetch_one(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; if current || history { Err(AppError::Conflict( "workspace name already exists".to_string(), )) } else { Ok(()) } } pub async fn workspace_my_inner( &self, user_id: uuid::Uuid, ) -> Result)>, AppError> { let rows = sqlx::query_as::<_, (String, Option)>( "SELECT w.name, w.description \ FROM wk_member m \ INNER JOIN workspace w ON w.id = m.wk \ WHERE m.\"user\" = $1 AND m.leave_at IS NULL \ ORDER BY w.created_at DESC", ) .bind(user_id) .fetch_all(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(rows) } }