398 lines
13 KiB
Rust
398 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, 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 {
|
|
pub async fn workspace_create(
|
|
&self,
|
|
ctx: &Session,
|
|
params: CreateWorkspace,
|
|
) -> Result<WorkspaceResponse, AppError> {
|
|
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))
|
|
}
|
|
|
|
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> {
|
|
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))
|
|
}
|
|
|
|
/// 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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)
|
|
}
|
|
}
|