use db::sqlx; use model::workspace::{ WkApplyJoinModel, WkJoinApprovalModel, WkJoinStrategyModel, WorkspaceModel, }; use serde::{Deserialize, Serialize}; use session::Session; use crate::{AppService, error::AppError, session_user}; const JOIN_STATUS_PENDING: &str = "pending"; const JOIN_STATUS_APPROVED: &str = "approved"; const JOIN_STATUS_REJECTED: &str = "rejected"; const JOIN_STATUS_CANCELLED: &str = "cancelled"; #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct WorkspaceJoinStrategyResponse { pub workspace_name: String, pub workspace_avatar_url: String, pub require_approval: bool, pub require_question: bool, pub question: Option, pub has_answer: bool, pub enabled: bool, #[schema(value_type = String)] pub created_at: chrono::DateTime, #[schema(value_type = String)] pub updated_at: chrono::DateTime, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct UpdateWorkspaceJoinStrategy { pub require_approval: Option, pub require_question: Option, pub question: Option>, pub answer: Option>, pub enabled: Option, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct WorkspaceJoinApplyResponse { pub workspace_name: String, pub workspace_avatar_url: String, pub username: String, pub avatar_url: Option, pub status: String, pub question: Option, pub answer: Option, pub message: Option, #[schema(value_type = String)] pub created_at: chrono::DateTime, #[schema(value_type = String)] pub updated_at: chrono::DateTime, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct CreateWorkspaceJoinApply { pub answer: Option, pub message: Option, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema, utoipa::IntoParams)] pub struct ListWorkspaceJoinApply { pub status: Option, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct WorkspaceJoinApprovalResponse { pub workspace_name: String, pub workspace_avatar_url: String, pub username: String, pub avatar_url: Option, pub approver_username: String, pub approver_avatar_url: Option, pub approved: bool, pub reason: Option, #[schema(value_type = String)] pub created_at: chrono::DateTime, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct ApproveWorkspaceJoinApply { pub approved: bool, pub reason: Option, } impl AppService { pub async fn workspace_join_strategy( &self, name: &str, ) -> Result { let wk = self.workspace_resolve(name).await?; let strategy = self.workspace_join_strategy_by_wk(wk.id).await?; Ok(strategy_response(strategy, &wk)) } pub async fn workspace_update_join_strategy( &self, ctx: &Session, name: &str, params: UpdateWorkspaceJoinStrategy, ) -> Result { let user_uid = session_user(ctx)?; let wk = self.workspace_resolve(name).await?; self.workspace_require_admin(wk.id, user_uid).await?; let existing = self.workspace_join_strategy_by_wk(wk.id).await?; let require_approval = params.require_approval.unwrap_or(existing.require_approval); let mut require_question = params.require_question.unwrap_or(existing.require_question); let question = params.question.unwrap_or(existing.question); let answer = params.answer.unwrap_or(existing.answer); let enabled = params.enabled.unwrap_or(existing.enabled); if require_question && question .as_ref() .is_none_or(|question| question.trim().is_empty()) { return Err(AppError::BadRequest( "join question is required when require_question is true" .to_string(), )); } if question.is_none() { require_question = false; } let now = chrono::Utc::now(); let saved = if self.workspace_join_strategy_exists(wk.id).await? { sqlx::query_as::<_, WkJoinStrategyModel>( "UPDATE wk_join_strategy SET require_approval = $1, require_question = $2, \ question = $3, answer = $4, enabled = $5, updated_at = $6 \ WHERE wk = $7 \ RETURNING wk, require_approval, require_question, question, answer, enabled, created_at, updated_at", ) .bind(require_approval) .bind(require_question) .bind(clean_optional(question)) .bind(clean_optional(answer)) .bind(enabled) .bind(now) .bind(wk.id) .fetch_one(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? } else { sqlx::query_as::<_, WkJoinStrategyModel>( "INSERT INTO wk_join_strategy \ (wk, require_approval, require_question, question, answer, enabled, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $7) \ RETURNING wk, require_approval, require_question, question, answer, enabled, created_at, updated_at", ) .bind(wk.id) .bind(require_approval) .bind(require_question) .bind(clean_optional(question)) .bind(clean_optional(answer)) .bind(enabled) .bind(now) .fetch_one(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? }; Ok(strategy_response(saved, &wk)) } pub async fn workspace_apply_join( &self, ctx: &Session, name: &str, params: CreateWorkspaceJoinApply, ) -> Result { let user_uid = session_user(ctx)?; let wk = self.workspace_resolve(name).await?; if self.workspace_member(wk.id, user_uid).await.is_ok() { return Err(AppError::Conflict( "user is already a workspace member".to_string(), )); } if self .workspace_has_pending_join_apply(wk.id, user_uid) .await? { return Err(AppError::Conflict( "join application is already pending".to_string(), )); } let strategy = self.workspace_join_strategy_by_wk(wk.id).await?; let answer = clean_optional(params.answer); if strategy.enabled && strategy.require_question { let expected = strategy .answer .as_ref() .map(|answer| answer.trim()) .unwrap_or_default(); let actual = answer .as_ref() .map(|answer| answer.trim()) .unwrap_or_default(); if actual.is_empty() { return Err(AppError::BadRequest( "join answer is required".to_string(), )); } if !expected.is_empty() && actual != expected { return Err(AppError::PermissionDenied); } } let status = if strategy.enabled && strategy.require_approval { JOIN_STATUS_PENDING } else { JOIN_STATUS_APPROVED }; let now = chrono::Utc::now(); let apply = sqlx::query_as::<_, WkApplyJoinModel>( "INSERT INTO wk_apply_join \ (id, wk, \"user\", status, question, answer, message, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) \ RETURNING id, wk, \"user\", status, question, answer, message, created_at, updated_at", ) .bind(uuid::Uuid::now_v7()) .bind(wk.id) .bind(user_uid) .bind(status) .bind(strategy.question.clone()) .bind(answer) .bind(clean_optional(params.message)) .bind(now) .fetch_one(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; if status == JOIN_STATUS_APPROVED { self.workspace_join_add_member(wk.id, user_uid).await?; } let current_user = self.auth_find_user_by_uid(user_uid).await?; Ok(apply_response( apply, &wk, current_user.username, clean_optional(Some(current_user.avatar_url)), )) } pub async fn workspace_my_join_applies( &self, ctx: &Session, ) -> Result, AppError> { let user_uid = session_user(ctx)?; let rows = sqlx::query_as::<_, WorkspaceJoinApplyRow>( "SELECT a.id, a.wk, w.name AS workspace_name, w.avatar_url AS workspace_avatar_url, \ a.\"user\", u.username, u.avatar_url, a.status, a.question, a.answer, a.message, a.created_at, a.updated_at \ FROM wk_apply_join a \ INNER JOIN workspace w ON w.id = a.wk \ INNER JOIN \"user\" u ON u.id = a.\"user\" \ WHERE a.\"user\" = $1 ORDER BY a.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(WorkspaceJoinApplyResponse::from) .collect()) } pub async fn workspace_cancel_join_apply( &self, ctx: &Session, name: &str, ) -> Result { let user_uid = session_user(ctx)?; let wk = self.workspace_resolve(name).await?; let user = self.auth_find_user_by_uid(user_uid).await?; let apply = sqlx::query_as::<_, WkApplyJoinModel>( "UPDATE wk_apply_join SET status = $1, updated_at = $2 \ WHERE wk = $3 AND \"user\" = $4 AND status = $5 \ RETURNING id, wk, \"user\", status, question, answer, message, created_at, updated_at", ) .bind(JOIN_STATUS_CANCELLED) .bind(chrono::Utc::now()) .bind(wk.id) .bind(user_uid) .bind(JOIN_STATUS_PENDING) .fetch_optional(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .ok_or(AppError::NotFound("join application not found".to_string()))?; Ok(apply_response( apply, &wk, user.username, clean_optional(Some(user.avatar_url)), )) } pub async fn workspace_join_applies( &self, ctx: &Session, name: &str, query: ListWorkspaceJoinApply, ) -> Result, AppError> { let user_uid = session_user(ctx)?; let wk = self.workspace_resolve(name).await?; self.workspace_require_admin(wk.id, user_uid).await?; let status = query .status .unwrap_or_else(|| JOIN_STATUS_PENDING.to_string()); let rows = sqlx::query_as::<_, WorkspaceJoinApplyRow>( "SELECT a.id, a.wk, w.name AS workspace_name, w.avatar_url AS workspace_avatar_url, \ a.\"user\", u.username, u.avatar_url, a.status, a.question, a.answer, a.message, a.created_at, a.updated_at \ FROM wk_apply_join a \ INNER JOIN workspace w ON w.id = a.wk \ INNER JOIN \"user\" u ON u.id = a.\"user\" \ WHERE a.wk = $1 AND a.status = $2 \ ORDER BY a.created_at ASC", ) .bind(wk.id) .bind(status) .fetch_all(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(rows .into_iter() .map(WorkspaceJoinApplyResponse::from) .collect()) } pub async fn workspace_approve_join_apply( &self, ctx: &Session, name: &str, username: &str, params: ApproveWorkspaceJoinApply, ) -> Result { let approver = session_user(ctx)?; let wk = self.workspace_resolve(name).await?; self.workspace_require_admin(wk.id, approver).await?; let applicant = self.users_find_active_user_by_username(username).await?; let approver_user = self.auth_find_user_by_uid(approver).await?; let apply = self .workspace_pending_join_apply_by_user(wk.id, applicant.id) .await?; if apply.status != JOIN_STATUS_PENDING { return Err(AppError::Conflict( "join application has already been processed".to_string(), )); } let now = chrono::Utc::now(); let approval_id = uuid::Uuid::now_v7(); let next_status = if params.approved { JOIN_STATUS_APPROVED } else { JOIN_STATUS_REJECTED }; let mut txn = self.db.begin().await.map_err(|_| AppError::TxnError)?; let approval = sqlx::query_as::<_, WkJoinApprovalModel>( "INSERT INTO wk_join_approval \ (id, apply, wk, \"user\", approver, approved, reason, created_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \ RETURNING id, apply, wk, \"user\", approver, approved, reason, created_at", ) .bind(approval_id) .bind(apply.id) .bind(wk.id) .bind(apply.user) .bind(approver) .bind(params.approved) .bind(clean_optional(params.reason)) .bind(now) .fetch_one(&mut **txn.inner_mut()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; sqlx::query("UPDATE wk_apply_join SET status = $1, updated_at = $2 WHERE id = $3") .bind(next_status) .bind(now) .bind(apply.id) .execute(&mut **txn.inner_mut()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; if params.approved { sqlx::query( "INSERT INTO wk_member (wk, \"user\", owner, admin, join_at, leave_at) \ VALUES ($1, $2, false, false, $3, NULL) \ ON CONFLICT (wk, \"user\") DO UPDATE SET leave_at = NULL", ) .bind(wk.id) .bind(apply.user) .bind(now) .execute(&mut **txn.inner_mut()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; } txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(approval_response( approval, &wk, applicant.username, clean_optional(Some(applicant.avatar_url)), approver_user.username, clean_optional(Some(approver_user.avatar_url)), )) } async fn workspace_join_strategy_by_wk( &self, wk: uuid::Uuid, ) -> Result { let strategy = sqlx::query_as::<_, WkJoinStrategyModel>( "SELECT wk, require_approval, require_question, question, answer, enabled, created_at, updated_at \ FROM wk_join_strategy WHERE wk = $1", ) .bind(wk) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; let now = chrono::Utc::now(); Ok(strategy.unwrap_or(WkJoinStrategyModel { wk, require_approval: false, require_question: false, question: None, answer: None, enabled: false, created_at: now, updated_at: now, })) } async fn workspace_join_strategy_exists( &self, wk: uuid::Uuid, ) -> Result { sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM wk_join_strategy WHERE wk = $1)", ) .bind(wk) .fetch_one(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string())) } async fn workspace_has_pending_join_apply( &self, wk: uuid::Uuid, user: uuid::Uuid, ) -> Result { sqlx::query_scalar::<_, bool>( "SELECT EXISTS(SELECT 1 FROM wk_apply_join WHERE wk = $1 AND \"user\" = $2 AND status = $3)", ) .bind(wk) .bind(user) .bind(JOIN_STATUS_PENDING) .fetch_one(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string())) } async fn workspace_pending_join_apply_by_user( &self, wk: uuid::Uuid, user: uuid::Uuid, ) -> Result { sqlx::query_as::<_, WkApplyJoinModel>( "SELECT id, wk, \"user\", status, question, answer, message, created_at, updated_at \ FROM wk_apply_join WHERE wk = $1 AND \"user\" = $2 AND status = $3", ) .bind(wk) .bind(user) .bind(JOIN_STATUS_PENDING) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .ok_or(AppError::NotFound("join application not found".to_string())) } async fn workspace_join_add_member( &self, wk: uuid::Uuid, user: uuid::Uuid, ) -> Result<(), AppError> { sqlx::query( "INSERT INTO wk_member (wk, \"user\", owner, admin, join_at, leave_at) \ VALUES ($1, $2, false, false, $3, NULL) \ ON CONFLICT (wk, \"user\") DO UPDATE SET leave_at = NULL", ) .bind(wk) .bind(user) .bind(chrono::Utc::now()) .execute(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(()) } } #[derive(db::sqlx::FromRow)] struct WorkspaceJoinApplyRow { workspace_name: String, workspace_avatar_url: String, username: String, avatar_url: String, status: String, question: Option, answer: Option, message: Option, created_at: chrono::DateTime, updated_at: chrono::DateTime, } fn strategy_response( value: WkJoinStrategyModel, wk: &WorkspaceModel, ) -> WorkspaceJoinStrategyResponse { WorkspaceJoinStrategyResponse { workspace_name: wk.name.clone(), workspace_avatar_url: wk.avatar_url.clone(), require_approval: value.require_approval, require_question: value.require_question, question: value.question, has_answer: value.answer.is_some(), enabled: value.enabled, created_at: value.created_at, updated_at: value.updated_at, } } fn apply_response( value: WkApplyJoinModel, wk: &WorkspaceModel, username: String, avatar_url: Option, ) -> WorkspaceJoinApplyResponse { WorkspaceJoinApplyResponse { workspace_name: wk.name.clone(), workspace_avatar_url: wk.avatar_url.clone(), username, avatar_url, status: value.status, question: value.question, answer: value.answer, message: value.message, created_at: value.created_at, updated_at: value.updated_at, } } fn approval_response( value: WkJoinApprovalModel, wk: &WorkspaceModel, username: String, avatar_url: Option, approver_username: String, approver_avatar_url: Option, ) -> WorkspaceJoinApprovalResponse { WorkspaceJoinApprovalResponse { workspace_name: wk.name.clone(), workspace_avatar_url: wk.avatar_url.clone(), username, avatar_url, approver_username, approver_avatar_url, approved: value.approved, reason: value.reason, created_at: value.created_at, } } fn clean_optional(value: Option) -> Option { value.and_then(|value| { let value = value.trim().to_string(); if value.is_empty() { None } else { Some(value) } }) } impl From for WorkspaceJoinApplyResponse { fn from(value: WorkspaceJoinApplyRow) -> Self { Self { workspace_name: value.workspace_name, workspace_avatar_url: value.workspace_avatar_url, username: value.username, avatar_url: clean_optional(Some(value.avatar_url)), status: value.status, question: value.question, answer: value.answer, message: value.message, created_at: value.created_at, updated_at: value.updated_at, } } }