gitdataai/lib/service/workspace/join.rs
2026-06-01 22:04:38 +08:00

618 lines
21 KiB
Rust

use db::sqlx;
use model::workspace::{
WkApplyJoinModel, WkJoinApprovalModel, WkJoinStrategyModel, WorkspaceModel,
};
use serde::{Deserialize, Serialize};
use session::Session;
use crate::{
AppService, error::AppError, metrics::with_op_metric, 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<String>,
pub has_answer: bool,
pub enabled: bool,
#[schema(value_type = String)]
pub created_at: chrono::DateTime<chrono::Utc>,
#[schema(value_type = String)]
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct UpdateWorkspaceJoinStrategy {
pub require_approval: Option<bool>,
pub require_question: Option<bool>,
pub question: Option<Option<String>>,
pub answer: Option<Option<String>>,
pub enabled: Option<bool>,
}
#[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<String>,
pub status: String,
pub question: Option<String>,
pub answer: Option<String>,
pub message: Option<String>,
#[schema(value_type = String)]
pub created_at: chrono::DateTime<chrono::Utc>,
#[schema(value_type = String)]
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct CreateWorkspaceJoinApply {
pub answer: Option<String>,
pub message: Option<String>,
}
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema, utoipa::IntoParams)]
pub struct ListWorkspaceJoinApply {
pub status: Option<String>,
}
#[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<String>,
pub approver_username: String,
pub approver_avatar_url: Option<String>,
pub approved: bool,
pub reason: Option<String>,
#[schema(value_type = String)]
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct ApproveWorkspaceJoinApply {
pub approved: bool,
pub reason: Option<String>,
}
impl AppService {
pub async fn workspace_join_strategy(
&self,
name: &str,
) -> Result<WorkspaceJoinStrategyResponse, AppError> {
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<WorkspaceJoinStrategyResponse, AppError> {
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))
}
#[tracing::instrument(skip(self, ctx, params), fields(workspace = %name))]
pub async fn workspace_apply_join(
&self,
ctx: &Session,
name: &str,
params: CreateWorkspaceJoinApply,
) -> Result<WorkspaceJoinApplyResponse, AppError> {
with_op_metric(&self.metrics.workspace_join_total, &["apply"], async {
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)),
))
}).await
}
pub async fn workspace_my_join_applies(
&self,
ctx: &Session,
) -> Result<Vec<WorkspaceJoinApplyResponse>, 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<WorkspaceJoinApplyResponse, AppError> {
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<Vec<WorkspaceJoinApplyResponse>, 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())
}
#[tracing::instrument(skip(self, ctx, params), fields(workspace = %name, username = %username))]
pub async fn workspace_approve_join_apply(
&self,
ctx: &Session,
name: &str,
username: &str,
params: ApproveWorkspaceJoinApply,
) -> Result<WorkspaceJoinApprovalResponse, AppError> {
let op = if params.approved { "approve" } else { "reject" };
with_op_metric(&self.metrics.workspace_join_total, &[op], async {
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()))?;
}
Ok(approval_response(
approval,
&wk,
applicant.username,
clean_optional(Some(applicant.avatar_url)),
approver_user.username,
clean_optional(Some(approver_user.avatar_url)),
))
}).await
}
async fn workspace_join_strategy_by_wk(
&self,
wk: uuid::Uuid,
) -> Result<WkJoinStrategyModel, AppError> {
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<bool, AppError> {
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<bool, AppError> {
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<WkApplyJoinModel, AppError> {
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<String>,
answer: Option<String>,
message: Option<String>,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}
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<String>,
) -> 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<String>,
approver_username: String,
approver_avatar_url: Option<String>,
) -> 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<String>) -> Option<String> {
value.and_then(|value| {
let value = value.trim().to_string();
if value.is_empty() { None } else { Some(value) }
})
}
impl From<WorkspaceJoinApplyRow> 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,
}
}
}