618 lines
21 KiB
Rust
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,
|
|
}
|
|
}
|
|
}
|