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

399 lines
13 KiB
Rust

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<String>,
pub avatar_url: Option<String>,
}
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct UpdateWorkspace {
pub name: Option<String>,
pub description: Option<String>,
pub avatar_url: Option<String>,
}
impl AppService {
#[tracing::instrument(skip(self, ctx), fields(workspace = %params.name))]
pub async fn workspace_create(
&self,
ctx: &Session,
params: CreateWorkspace,
) -> Result<WorkspaceResponse, AppError> {
with_op_metric(&self.metrics.workspace_operations_total, &["create"], async {
let user_uid = session_user(ctx)?;
let name = normalize_name(&params.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<Vec<WorkspaceResponse>, 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<WorkspaceResponse, AppError> {
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<WorkspaceResponse, AppError> {
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<String, AppError> {
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<u8>,
content_type: &str,
) -> Result<AvatarUploadResponse, AppError> {
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<WorkspaceModel, AppError> {
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<WkMemberModel, AppError> {
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<WkMemberModel, AppError> {
self.workspace_member(wk_id, user_uid).await
}
pub async fn workspace_require_admin(
&self,
wk_id: uuid::Uuid,
user_uid: uuid::Uuid,
) -> Result<WkMemberModel, AppError> {
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<WkMemberModel, AppError> {
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<Vec<(String, Option<String>)>, AppError> {
let rows = sqlx::query_as::<_, (String, Option<String>)>(
"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)
}
}