feat: init

This commit is contained in:
zhenyi
2026-06-07 11:30:56 +08:00
commit 563381c1ca
361 changed files with 41327 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::AppError;
use crate::models::common::{RequestType, Role, Status};
use crate::models::workspaces::WorkspacePendingApproval;
use crate::service::WorkspaceService;
use crate::session::Session;
use super::util::{clamp_limit_offset, ensure_affected, parse_enum};
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct RequestApprovalParams {
pub request_type: String,
pub reason: Option<String>,
}
impl WorkspaceService {
pub async fn workspace_pending_approvals(
&self,
ctx: &Session,
workspace_id: Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<WorkspacePendingApproval>, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
.await?;
let (limit, offset) = clamp_limit_offset(limit, offset);
sqlx::query_as::<_, WorkspacePendingApproval>(
"SELECT id, workspace_id, requester_id, request_type, status, reason, \
reviewed_by, reviewed_at, expires_at, created_at, updated_at \
FROM workspace_pending_approval WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
)
.bind(workspace_id)
.bind(limit)
.bind(offset)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
pub async fn workspace_request_approval(
&self,
ctx: &Session,
workspace_id: Uuid,
params: RequestApprovalParams,
) -> Result<WorkspacePendingApproval, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_readable(user_uid, &ws).await?;
let request_type = parse_enum(
Some(params.request_type),
RequestType::Unknown,
RequestType::Unknown,
"request_type",
)?;
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query_as::<_, WorkspacePendingApproval>(
"INSERT INTO workspace_pending_approval (id, workspace_id, requester_id, request_type, status, \
reason, expires_at, created_at, updated_at) VALUES ($1, $2, $3, $4, 'pending', $5, $6, $7, $7) \
RETURNING id, workspace_id, requester_id, request_type, status, reason, \
reviewed_by, reviewed_at, expires_at, created_at, updated_at",
)
.bind(Uuid::now_v7())
.bind(workspace_id)
.bind(user_uid)
.bind(request_type)
.bind(params.reason)
.bind(now + chrono::Duration::days(30))
.bind(now)
.fetch_one(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(result)
}
pub async fn workspace_review_approval(
&self,
ctx: &Session,
workspace_id: Uuid,
approval_id: Uuid,
approved: bool,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner)
.await?;
let status = if approved {
Status::Accepted
} else {
Status::Rejected
};
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query(
"UPDATE workspace_pending_approval SET status = $1, reviewed_by = $2, reviewed_at = $3, updated_at = $3 \
WHERE id = $4 AND workspace_id = $5 AND status = 'pending'",
)
.bind(status.to_string())
.bind(user_uid)
.bind(now)
.bind(approval_id)
.bind(workspace_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
ensure_affected(
result.rows_affected(),
"approval not found or already reviewed",
)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(())
}
}
+69
View File
@@ -0,0 +1,69 @@
use uuid::Uuid;
use crate::error::AppError;
use crate::models::common::{EventType, JsonValue, Role, TargetType};
use crate::models::workspaces::WorkspaceAuditLog;
use crate::service::WorkspaceService;
use crate::session::Session;
use super::util::clamp_limit_offset;
impl WorkspaceService {
pub async fn workspace_audit_logs(
&self,
ctx: &Session,
workspace_id: Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<WorkspaceAuditLog>, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
.await?;
let (limit, offset) = clamp_limit_offset(limit, offset);
sqlx::query_as::<_, WorkspaceAuditLog>(
"SELECT id, workspace_id, actor_id, action, target_type, target_id, \
ip_address, user_agent, metadata, created_at FROM workspace_audit_log \
WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
)
.bind(workspace_id)
.bind(limit)
.bind(offset)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
#[allow(clippy::too_many_arguments)]
pub async fn workspace_log_audit(
&self,
workspace_id: Uuid,
actor_id: Uuid,
action: EventType,
target_type: Option<TargetType>,
target_id: Option<Uuid>,
ip_address: Option<String>,
user_agent: Option<String>,
metadata: Option<JsonValue>,
) -> Result<(), AppError> {
sqlx::query(
"INSERT INTO workspace_audit_log (id, workspace_id, actor_id, action, target_type, \
target_id, ip_address, user_agent, metadata, created_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
)
.bind(Uuid::now_v7())
.bind(workspace_id)
.bind(actor_id)
.bind(action)
.bind(target_type)
.bind(target_id)
.bind(ip_address)
.bind(user_agent)
.bind(metadata)
.bind(chrono::Utc::now())
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
Ok(())
}
}
+118
View File
@@ -0,0 +1,118 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::AppError;
use crate::models::common::Role;
use crate::models::workspaces::WorkspaceBilling;
use crate::service::WorkspaceService;
use crate::session::Session;
use super::util::merge_optional_text;
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct UpdateBillingParams {
pub plan: Option<String>,
pub billing_email: Option<String>,
pub seats: Option<i32>,
}
impl WorkspaceService {
pub async fn workspace_billing(
&self,
ctx: &Session,
workspace_id: Uuid,
) -> Result<WorkspaceBilling, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner)
.await?;
self.ensure_workspace_billing(workspace_id).await
}
pub async fn workspace_update_billing(
&self,
ctx: &Session,
workspace_id: Uuid,
params: UpdateBillingParams,
) -> Result<WorkspaceBilling, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner)
.await?;
let current = self.ensure_workspace_billing(workspace_id).await?;
let plan = params.plan.unwrap_or(current.plan.clone());
let billing_email = merge_optional_text(params.billing_email, current.billing_email);
let seats = params.seats.unwrap_or(current.seats);
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query_as::<_, WorkspaceBilling>(
"UPDATE workspace_billing SET plan = $1, billing_email = $2, seats = $3, updated_at = $4 \
WHERE workspace_id = $5 \
RETURNING workspace_id, customer_id, subscription_id, plan, billing_email, status, \
seats, trial_ends_at, current_period_start, current_period_end, canceled_at, \
created_at, updated_at",
)
.bind(&plan)
.bind(&billing_email)
.bind(seats)
.bind(now)
.bind(workspace_id)
.fetch_one(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(result)
}
async fn ensure_workspace_billing(
&self,
workspace_id: Uuid,
) -> Result<WorkspaceBilling, AppError> {
if let Some(billing) = self.find_workspace_billing(workspace_id).await? {
return Ok(billing);
}
let now = chrono::Utc::now();
sqlx::query(
"INSERT INTO workspace_billing (workspace_id, plan, status, seats, created_at, updated_at) \
VALUES ($1, 'free', 'active', 1, $2, $2) ON CONFLICT (workspace_id) DO NOTHING",
)
.bind(workspace_id)
.bind(now)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
self.find_workspace_billing(workspace_id)
.await?
.ok_or(AppError::NotFound("workspace billing not found".into()))
}
async fn find_workspace_billing(
&self,
workspace_id: Uuid,
) -> Result<Option<WorkspaceBilling>, AppError> {
sqlx::query_as::<_, WorkspaceBilling>(
"SELECT workspace_id, customer_id, subscription_id, plan, billing_email, status, \
seats, trial_ends_at, current_period_start, current_period_end, canceled_at, \
created_at, updated_at FROM workspace_billing WHERE workspace_id = $1",
)
.bind(workspace_id)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
}
+123
View File
@@ -0,0 +1,123 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::AppError;
use crate::models::common::Role;
use crate::models::workspaces::WorkspaceCustomBranding;
use crate::service::WorkspaceService;
use crate::session::Session;
use super::util::merge_optional_text;
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct UpdateBrandingParams {
pub logo_url: Option<String>,
pub favicon_url: Option<String>,
pub primary_color: Option<String>,
pub accent_color: Option<String>,
pub custom_css: Option<String>,
pub support_url: Option<String>,
pub enabled: Option<bool>,
}
impl WorkspaceService {
pub async fn workspace_branding(
&self,
ctx: &Session,
workspace_id: Uuid,
) -> Result<WorkspaceCustomBranding, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
.await?;
self.ensure_workspace_branding(workspace_id).await
}
pub async fn workspace_update_branding(
&self,
ctx: &Session,
workspace_id: Uuid,
params: UpdateBrandingParams,
) -> Result<WorkspaceCustomBranding, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
.await?;
let current = self.ensure_workspace_branding(workspace_id).await?;
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query_as::<_, WorkspaceCustomBranding>(
"UPDATE workspace_custom_branding SET logo_url = $1, favicon_url = $2, primary_color = $3, \
accent_color = $4, custom_css = $5, support_url = $6, enabled = $7, updated_at = $8 \
WHERE workspace_id = $9 \
RETURNING workspace_id, logo_url, favicon_url, primary_color, accent_color, \
custom_css, support_url, enabled, created_at, updated_at",
)
.bind(merge_optional_text(params.logo_url, current.logo_url))
.bind(merge_optional_text(params.favicon_url, current.favicon_url))
.bind(merge_optional_text(params.primary_color, current.primary_color))
.bind(merge_optional_text(params.accent_color, current.accent_color))
.bind(merge_optional_text(params.custom_css, current.custom_css))
.bind(merge_optional_text(params.support_url, current.support_url))
.bind(params.enabled.unwrap_or(current.enabled))
.bind(now)
.bind(workspace_id)
.fetch_one(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(result)
}
async fn ensure_workspace_branding(
&self,
workspace_id: Uuid,
) -> Result<WorkspaceCustomBranding, AppError> {
if let Some(branding) = self.find_workspace_branding(workspace_id).await? {
return Ok(branding);
}
let now = chrono::Utc::now();
sqlx::query(
"INSERT INTO workspace_custom_branding (workspace_id, enabled, created_at, updated_at) \
VALUES ($1, false, $2, $2) ON CONFLICT (workspace_id) DO NOTHING",
)
.bind(workspace_id)
.bind(now)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
self.find_workspace_branding(workspace_id)
.await?
.ok_or(AppError::NotFound("workspace branding not found".into()))
}
async fn find_workspace_branding(
&self,
workspace_id: Uuid,
) -> Result<Option<WorkspaceCustomBranding>, AppError> {
sqlx::query_as::<_, WorkspaceCustomBranding>(
"SELECT workspace_id, logo_url, favicon_url, primary_color, accent_color, \
custom_css, support_url, enabled, created_at, updated_at \
FROM workspace_custom_branding WHERE workspace_id = $1",
)
.bind(workspace_id)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
}
+625
View File
@@ -0,0 +1,625 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::AppError;
use crate::models::common::{Role, Visibility};
use crate::models::workspaces::Workspace;
use crate::service::WorkspaceService;
use crate::session::Session;
use super::util::{clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum};
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct CreateWorkspaceParams {
pub name: String,
pub description: Option<String>,
pub visibility: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct UpdateWorkspaceParams {
pub name: Option<String>,
pub description: Option<String>,
pub visibility: Option<String>,
pub default_role: Option<String>,
}
impl WorkspaceService {
pub async fn workspace_list(
&self,
ctx: &Session,
limit: i64,
offset: i64,
) -> Result<Vec<Workspace>, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let (limit, offset) = clamp_limit_offset(limit, offset);
sqlx::query_as::<_, Workspace>(
"SELECT id, owner_id, name, description, avatar_url, visibility, plan, status, \
default_role, is_personal, archived_at, created_at, updated_at, deleted_at \
FROM workspace WHERE deleted_at IS NULL AND (owner_id = $1 OR id IN \
(SELECT workspace_id FROM workspace_member WHERE user_id = $1 AND status = 'active') \
OR visibility = 'public') \
ORDER BY created_at DESC LIMIT $2 OFFSET $3",
)
.bind(user_uid)
.bind(limit)
.bind(offset)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
pub async fn workspace_get(
&self,
ctx: &Session,
workspace_id: Uuid,
) -> Result<Workspace, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_readable(user_uid, &ws).await?;
Ok(ws)
}
pub async fn workspace_create(
&self,
ctx: &Session,
params: CreateWorkspaceParams,
) -> Result<Workspace, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let name = params.name.trim().to_string();
if name.is_empty() {
return Err(AppError::BadRequest("name is required".into()));
}
let visibility = match params.visibility {
Some(ref v) => parse_enum(
Some(v.clone()),
Visibility::Internal,
Visibility::Unknown,
"visibility",
)?,
None => Visibility::Internal,
};
let now = chrono::Utc::now();
let workspace_id = Uuid::now_v7();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let ws = sqlx::query_as::<_, Workspace>(
"INSERT INTO workspace (id, owner_id, name, description, visibility, plan, status, \
default_role, is_personal, created_at, updated_at) \
VALUES ($1, $2, $3, $4, $5, 'free', 'active', 'member', false, $6, $6) \
RETURNING id, owner_id, name, description, avatar_url, visibility, plan, status, \
default_role, is_personal, archived_at, created_at, updated_at, deleted_at",
)
.bind(workspace_id)
.bind(user_uid)
.bind(&name)
.bind(params.description.as_deref())
.bind(visibility)
.bind(now)
.fetch_one(&mut *txn)
.await
.map_err(AppError::Database)?;
sqlx::query(
"INSERT INTO workspace_member (id, workspace_id, user_id, role, status, joined_at, created_at, updated_at) \
VALUES ($1, $2, $3, 'owner', 'active', $4, $4, $4)",
)
.bind(Uuid::now_v7())
.bind(workspace_id)
.bind(user_uid)
.bind(now)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
sqlx::query(
"INSERT INTO workspace_settings (workspace_id, allow_public_repos, allow_member_invites, \
require_two_factor, default_repo_visibility, default_branch_name, \
issue_tracking_enabled, pull_requests_enabled, wiki_enabled, created_at, updated_at) \
VALUES ($1, true, true, false, 'private', 'main', true, true, true, $2, $2)",
)
.bind(workspace_id)
.bind(now)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
sqlx::query(
"INSERT INTO workspace_stats (workspace_id, members_count, repos_count, issues_count, \
pull_requests_count, storage_bytes, bandwidth_bytes, build_minutes_used, updated_at) \
VALUES ($1, 1, 0, 0, 0, 0, 0, 0, $2)",
)
.bind(workspace_id)
.bind(now)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
sqlx::query(
"INSERT INTO workspace_billing (workspace_id, plan, status, seats, created_at, updated_at) \
VALUES ($1, 'free', 'active', 1, $2, $2)",
)
.bind(workspace_id)
.bind(now)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
sqlx::query(
"INSERT INTO workspace_custom_branding (workspace_id, enabled, created_at, updated_at) \
VALUES ($1, false, $2, $2)",
)
.bind(workspace_id)
.bind(now)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(ws)
}
pub async fn workspace_update(
&self,
ctx: &Session,
workspace_id: Uuid,
params: UpdateWorkspaceParams,
) -> Result<Workspace, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
.await?;
let name =
merge_optional_text(params.name, Some(ws.name.clone())).unwrap_or(ws.name.clone());
let description = merge_optional_text(params.description, ws.description);
let visibility = parse_enum(
params.visibility,
ws.visibility,
Visibility::Unknown,
"visibility",
)?;
let default_role = match params.default_role {
Some(ref v) => parse_enum(
Some(v.clone()),
ws.default_role.parse().unwrap_or(Role::Member),
Role::Unknown,
"default_role",
)?,
None => ws.default_role.parse().unwrap_or(Role::Member),
};
// Restrict default_role to safe roles only
match default_role {
Role::Member | Role::Contributor | Role::Viewer | Role::Guest => {}
_ => {
return Err(AppError::BadRequest(
"default_role must be one of: member, contributor, viewer, guest".into(),
));
}
}
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query_as::<_, Workspace>(
"UPDATE workspace SET name = $1, description = $2, visibility = $3, default_role = $4, \
updated_at = $5 WHERE id = $6 AND deleted_at IS NULL \
RETURNING id, owner_id, name, description, avatar_url, visibility, plan, status, \
default_role, is_personal, archived_at, created_at, updated_at, deleted_at",
)
.bind(&name)
.bind(&description)
.bind(visibility)
.bind(default_role.to_string())
.bind(now)
.bind(workspace_id)
.fetch_optional(&mut *txn)
.await
.map_err(AppError::Database)?
.ok_or(AppError::NotFound("workspace not found".into()))?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(result)
}
pub async fn workspace_archive(
&self,
ctx: &Session,
workspace_id: Uuid,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner)
.await?;
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query(
"UPDATE workspace SET status = 'archived', archived_at = $1, updated_at = $1 \
WHERE id = $2 AND deleted_at IS NULL AND status <> 'archived'",
)
.bind(now)
.bind(workspace_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
ensure_affected(
result.rows_affected(),
"workspace not found or already archived",
)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(())
}
pub async fn workspace_unarchive(
&self,
ctx: &Session,
workspace_id: Uuid,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner)
.await?;
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query(
"UPDATE workspace SET status = 'active', archived_at = NULL, updated_at = $1 \
WHERE id = $2 AND deleted_at IS NULL AND status = 'archived'",
)
.bind(now)
.bind(workspace_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
ensure_affected(
result.rows_affected(),
"workspace not found or not archived",
)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(())
}
pub async fn workspace_delete(
&self,
ctx: &Session,
workspace_id: Uuid,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner)
.await?;
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query(
"UPDATE workspace SET deleted_at = $1, status = 'deleted', updated_at = $1 \
WHERE id = $2 AND deleted_at IS NULL",
)
.bind(now)
.bind(workspace_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
ensure_affected(result.rows_affected(), "workspace not found")?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(())
}
pub async fn workspace_transfer_owner(
&self,
ctx: &Session,
workspace_id: Uuid,
new_owner_id: Uuid,
) -> Result<Workspace, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner)
.await?;
if new_owner_id == ws.owner_id {
return Err(AppError::BadRequest(
"new owner must be different from current owner".into(),
));
}
let is_member = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active')",
)
.bind(workspace_id)
.bind(new_owner_id)
.fetch_one(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
if !is_member {
return Err(AppError::BadRequest(
"new owner must be an active member".into(),
));
}
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
sqlx::query(
"UPDATE workspace_member SET role = 'owner', updated_at = $1 \
WHERE workspace_id = $2 AND user_id = $3",
)
.bind(now)
.bind(workspace_id)
.bind(new_owner_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
sqlx::query(
"UPDATE workspace_member SET role = 'admin', updated_at = $1 \
WHERE workspace_id = $2 AND user_id = $3",
)
.bind(now)
.bind(workspace_id)
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query_as::<_, Workspace>(
"UPDATE workspace SET owner_id = $1, updated_at = $2 WHERE id = $3 AND deleted_at IS NULL \
RETURNING id, owner_id, name, description, avatar_url, visibility, plan, status, \
default_role, is_personal, archived_at, created_at, updated_at, deleted_at",
)
.bind(new_owner_id)
.bind(now)
.bind(workspace_id)
.fetch_one(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(result)
}
pub async fn workspace_upload_avatar(
&self,
ctx: &Session,
workspace_id: Uuid,
data: Vec<u8>,
content_type: Option<String>,
file_name: Option<String>,
) -> Result<Workspace, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
.await?;
let ext =
crate::service::util::avatar_extension(content_type.as_deref(), file_name.as_deref())?;
crate::service::util::validate_avatar_size(data.len(), 5 * 1024 * 1024)?;
let old_avatar_url = ws.avatar_url.clone();
let storage_key = format!(
"workspaces/{}/avatar/{}.{}",
workspace_id,
Uuid::now_v7(),
ext
);
self.ctx.storage.put(&storage_key, data).await?;
let avatar_url = self.ctx.storage.public_url(&storage_key).ok_or_else(|| {
AppError::Config("APP_S3_PUBLIC_URL is required for avatar upload".into())
})?;
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query_as::<_, Workspace>(
"UPDATE workspace SET avatar_url = $1, updated_at = $2 \
WHERE id = $3 AND deleted_at IS NULL \
RETURNING id, owner_id, name, description, avatar_url, visibility, plan, status, \
default_role, is_personal, archived_at, created_at, updated_at, deleted_at",
)
.bind(&avatar_url)
.bind(now)
.bind(workspace_id)
.fetch_optional(&mut *txn)
.await
.map_err(AppError::Database)?;
if let Some(updated) = result {
txn.commit().await.map_err(|_| AppError::TxnError)?;
if let Some(old_url) = old_avatar_url
&& let Some(old_key) = extract_storage_key_from_url(&old_url)
{
let _ = self.ctx.storage.delete(&old_key).await;
}
Ok(updated)
} else {
let _ = self.ctx.storage.delete(&storage_key).await;
Err(AppError::NotFound("workspace not found".into()))
}
}
pub(crate) async fn find_workspace_by_id(
&self,
workspace_id: Uuid,
) -> Result<Workspace, AppError> {
sqlx::query_as::<_, Workspace>(
"SELECT id, owner_id, name, description, avatar_url, visibility, plan, status, \
default_role, is_personal, archived_at, created_at, updated_at, deleted_at \
FROM workspace WHERE id = $1 AND deleted_at IS NULL",
)
.bind(workspace_id)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)?
.ok_or(AppError::NotFound("workspace not found".into()))
}
pub async fn workspace_user_role(
&self,
user_uid: Uuid,
workspace_id: Uuid,
) -> Result<Option<Role>, AppError> {
let role_str: Option<String> = sqlx::query_scalar(
"SELECT role FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active'",
)
.bind(workspace_id)
.bind(user_uid)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
match role_str {
Some(r) => Ok(Some(r.parse().unwrap_or(Role::Unknown))),
None => {
let ws = self.find_workspace_by_id(workspace_id).await?;
if ws.owner_id == user_uid {
return Ok(Some(Role::Owner));
}
Ok(None)
}
}
}
pub async fn ensure_workspace_readable(
&self,
user_uid: Uuid,
ws: &Workspace,
) -> Result<(), AppError> {
if ws.owner_id == user_uid {
return Ok(());
}
let is_member = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active')",
)
.bind(ws.id)
.bind(user_uid)
.fetch_one(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
if is_member {
return Ok(());
}
match ws.visibility {
Visibility::Public | Visibility::Internal => Ok(()),
_ => Err(AppError::Unauthorized),
}
}
pub async fn ensure_workspace_role_at_least(
&self,
user_uid: Uuid,
ws: &Workspace,
min_role: Role,
) -> Result<Role, AppError> {
if ws.owner_id == user_uid {
return Ok(Role::Owner);
}
let role_str: Option<String> = sqlx::query_scalar(
"SELECT role FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active'",
)
.bind(ws.id)
.bind(user_uid)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
let role = role_str
.and_then(|r| r.parse::<Role>().ok())
.unwrap_or(Role::Unknown);
if super::util::role_level(role) < super::util::role_level(min_role) {
return Err(AppError::Unauthorized);
}
Ok(role)
}
}
use crate::service::util::extract_storage_key_from_url;
+263
View File
@@ -0,0 +1,263 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::AppError;
use crate::models::common::Role;
use crate::models::workspaces::WorkspaceDomain;
use crate::service::WorkspaceService;
use crate::session::Session;
use super::util::{clamp_limit_offset, ensure_affected, required_text};
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct AddDomainParams {
pub domain: String,
}
impl WorkspaceService {
pub async fn workspace_domains(
&self,
ctx: &Session,
workspace_id: Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<WorkspaceDomain>, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
.await?;
let (limit, offset) = clamp_limit_offset(limit, offset);
sqlx::query_as::<_, WorkspaceDomain>(
"SELECT id, workspace_id, domain, verification_token_hash, is_primary, is_verified, \
verified_at, created_at, updated_at FROM workspace_domain \
WHERE workspace_id = $1 ORDER BY is_primary DESC, created_at ASC LIMIT $2 OFFSET $3",
)
.bind(workspace_id)
.bind(limit)
.bind(offset)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
pub async fn workspace_add_domain(
&self,
ctx: &Session,
workspace_id: Uuid,
params: AddDomainParams,
) -> Result<WorkspaceDomain, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
.await?;
let domain = required_text(params.domain, "domain")?.to_lowercase();
let is_first = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM workspace_domain WHERE workspace_id = $1",
)
.bind(workspace_id)
.fetch_one(self.ctx.db.reader())
.await
.map_err(AppError::Database)?
== 0;
let token = Self::generate_domain_verification_token();
let token_hash = sha256_hex(token.as_bytes());
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query_as::<_, WorkspaceDomain>(
"INSERT INTO workspace_domain (id, workspace_id, domain, verification_token_hash, is_primary, is_verified, created_at, updated_at) \
VALUES ($1, $2, $3, $4, $5, false, $6, $6) \
RETURNING id, workspace_id, domain, verification_token_hash, is_primary, is_verified, verified_at, created_at, updated_at",
)
.bind(Uuid::now_v7())
.bind(workspace_id)
.bind(&domain)
.bind(&token_hash)
.bind(is_first)
.bind(now)
.fetch_one(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(result)
}
pub async fn workspace_verify_domain(
&self,
ctx: &Session,
workspace_id: Uuid,
domain_id: Uuid,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
.await?;
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query(
"UPDATE workspace_domain SET is_verified = true, verified_at = $1, updated_at = $1 \
WHERE id = $2 AND workspace_id = $3 AND is_verified = false",
)
.bind(now)
.bind(domain_id)
.bind(workspace_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
ensure_affected(
result.rows_affected(),
"domain not found or already verified",
)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(())
}
pub async fn workspace_set_primary_domain(
&self,
ctx: &Session,
workspace_id: Uuid,
domain_id: Uuid,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner)
.await?;
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let domain = sqlx::query_as::<_, WorkspaceDomain>(
"SELECT id, workspace_id, domain, verification_token_hash, is_primary, is_verified, \
verified_at, created_at, updated_at FROM workspace_domain \
WHERE id = $1 AND workspace_id = $2",
)
.bind(domain_id)
.bind(workspace_id)
.fetch_optional(&mut *txn)
.await
.map_err(AppError::Database)?
.ok_or(AppError::NotFound("domain not found".into()))?;
if !domain.is_verified {
return Err(AppError::BadRequest(
"domain must be verified before setting as primary".into(),
));
}
sqlx::query("UPDATE workspace_domain SET is_primary = false, updated_at = $1 WHERE workspace_id = $2 AND is_primary = true")
.bind(now)
.bind(workspace_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
sqlx::query("UPDATE workspace_domain SET is_primary = true, updated_at = $1 WHERE id = $2")
.bind(now)
.bind(domain_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(())
}
pub async fn workspace_delete_domain(
&self,
ctx: &Session,
workspace_id: Uuid,
domain_id: Uuid,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
.await?;
let is_primary = sqlx::query_scalar::<_, bool>(
"SELECT is_primary FROM workspace_domain WHERE id = $1 AND workspace_id = $2",
)
.bind(domain_id)
.bind(workspace_id)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)?
.ok_or(AppError::NotFound("domain not found".into()))?;
if is_primary {
return Err(AppError::BadRequest("cannot delete primary domain".into()));
}
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result =
sqlx::query("DELETE FROM workspace_domain WHERE id = $1 AND workspace_id = $2")
.bind(domain_id)
.bind(workspace_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
ensure_affected(result.rows_affected(), "domain not found")?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(())
}
fn generate_domain_verification_token() -> String {
(0..32)
.map(|_| format!("{:02x}", rand::random::<u8>()))
.collect()
}
}
use crate::service::util::sha256_hex;
+220
View File
@@ -0,0 +1,220 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::AppError;
use crate::models::common::Provider;
use crate::models::workspaces::WorkspaceIntegration;
use crate::service::WorkspaceService;
use crate::session::Session;
use super::util::{clamp_limit_offset, ensure_affected, required_text};
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct CreateIntegrationParams {
pub provider: String,
pub name: String,
pub config: Option<crate::models::json_types::WorkspaceIntegrationConfig>,
pub secret_ciphertext: Option<String>,
pub enabled: Option<bool>,
}
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct UpdateIntegrationParams {
pub name: Option<String>,
pub config: Option<crate::models::json_types::WorkspaceIntegrationConfig>,
pub secret_ciphertext: Option<String>,
pub enabled: Option<bool>,
}
impl WorkspaceService {
pub async fn workspace_integrations(
&self,
ctx: &Session,
workspace_id: Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<WorkspaceIntegration>, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, crate::models::common::Role::Admin)
.await?;
let (limit, offset) = clamp_limit_offset(limit, offset);
sqlx::query_as::<_, WorkspaceIntegration>(
"SELECT id, workspace_id, provider, name, config, secret_ciphertext, enabled, \
installed_by, last_used_at, created_at, updated_at FROM workspace_integration \
WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
)
.bind(workspace_id)
.bind(limit)
.bind(offset)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
pub async fn workspace_create_integration(
&self,
ctx: &Session,
workspace_id: Uuid,
params: CreateIntegrationParams,
) -> Result<WorkspaceIntegration, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, crate::models::common::Role::Admin)
.await?;
let provider = params
.provider
.trim()
.parse::<Provider>()
.map_err(|_| AppError::BadRequest("invalid provider".into()))?;
if provider == Provider::Unknown {
return Err(AppError::BadRequest("invalid provider".into()));
}
let name = required_text(params.name, "name")?;
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query_as::<_, WorkspaceIntegration>(
"INSERT INTO workspace_integration (id, workspace_id, provider, name, config, secret_ciphertext, \
enabled, installed_by, created_at, updated_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) \
RETURNING id, workspace_id, provider, name, config, secret_ciphertext, enabled, \
installed_by, last_used_at, created_at, updated_at",
)
.bind(Uuid::now_v7())
.bind(workspace_id)
.bind(provider)
.bind(&name)
.bind(params.config.map(sqlx::types::Json))
.bind(&params.secret_ciphertext)
.bind(params.enabled.unwrap_or(true))
.bind(user_uid)
.bind(now)
.fetch_one(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(result)
}
pub async fn workspace_update_integration(
&self,
ctx: &Session,
workspace_id: Uuid,
integration_id: Uuid,
params: UpdateIntegrationParams,
) -> Result<WorkspaceIntegration, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, crate::models::common::Role::Admin)
.await?;
let current = sqlx::query_as::<_, WorkspaceIntegration>(
"SELECT id, workspace_id, provider, name, config, secret_ciphertext, enabled, \
installed_by, last_used_at, created_at, updated_at FROM workspace_integration \
WHERE id = $1 AND workspace_id = $2",
)
.bind(integration_id)
.bind(workspace_id)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)?
.ok_or(AppError::NotFound("integration not found".into()))?;
let name = params
.name
.map(|n| n.trim().to_string())
.unwrap_or(current.name);
let enabled = params.enabled.unwrap_or(current.enabled);
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query_as::<_, WorkspaceIntegration>(
"UPDATE workspace_integration SET name = $1, config = $2, secret_ciphertext = $3, \
enabled = $4, updated_at = $5 WHERE id = $6 AND workspace_id = $7 \
RETURNING id, workspace_id, provider, name, config, secret_ciphertext, enabled, \
installed_by, last_used_at, created_at, updated_at",
)
.bind(&name)
.bind(
params
.config
.map(sqlx::types::Json)
.or_else(|| current.config.clone()),
)
.bind(params.secret_ciphertext.or(current.secret_ciphertext))
.bind(enabled)
.bind(now)
.bind(integration_id)
.bind(workspace_id)
.fetch_one(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(result)
}
pub async fn workspace_delete_integration(
&self,
ctx: &Session,
workspace_id: Uuid,
integration_id: Uuid,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, crate::models::common::Role::Admin)
.await?;
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result =
sqlx::query("DELETE FROM workspace_integration WHERE id = $1 AND workspace_id = $2")
.bind(integration_id)
.bind(workspace_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
ensure_affected(result.rows_affected(), "integration not found")?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(())
}
}
+323
View File
@@ -0,0 +1,323 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::AppError;
use crate::models::common::Role;
use crate::models::workspaces::WorkspaceInvitation;
use crate::pb::email::{EmailAddress, SendEmailRequest};
use crate::service::WorkspaceService;
use crate::session::Session;
use super::util::{clamp_limit_offset, ensure_affected, role_level};
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct CreateInvitationParams {
pub email: String,
pub role: Option<String>,
}
#[derive(Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct CreateInvitationResponse {
pub invitation: WorkspaceInvitation,
}
impl WorkspaceService {
pub async fn workspace_invitations(
&self,
ctx: &Session,
workspace_id: Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<WorkspaceInvitation>, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
.await?;
let (limit, offset) = clamp_limit_offset(limit, offset);
sqlx::query_as::<_, WorkspaceInvitation>(
"SELECT id, workspace_id, email, role, token_hash, invited_by, accepted_by, \
accepted_at, revoked_at, expires_at, created_at FROM workspace_invitation \
WHERE workspace_id = $1 AND revoked_at IS NULL AND accepted_at IS NULL \
AND expires_at > NOW() ORDER BY created_at DESC LIMIT $2 OFFSET $3",
)
.bind(workspace_id)
.bind(limit)
.bind(offset)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
pub async fn workspace_create_invitation(
&self,
ctx: &Session,
workspace_id: Uuid,
params: CreateInvitationParams,
) -> Result<CreateInvitationResponse, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
let actor_role = self
.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
.await?;
let email = params.email.trim().to_lowercase();
if email.is_empty() {
return Err(AppError::BadRequest("email is required".into()));
}
let existing = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM workspace_invitation \
WHERE workspace_id = $1 AND lower(email) = lower($2) \
AND revoked_at IS NULL AND accepted_at IS NULL AND expires_at > NOW())",
)
.bind(workspace_id)
.bind(&email)
.fetch_one(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
if existing {
return Err(AppError::BadRequest(
"invitation already exists for this email".into(),
));
}
let role = params
.role
.as_deref()
.and_then(|r| r.parse::<Role>().ok())
.unwrap_or(ws.default_role.parse().unwrap_or(Role::Member));
if role == Role::Owner || role == Role::Unknown {
return Err(AppError::BadRequest("invalid role for invitation".into()));
}
// Non-owner admins cannot invite with roles equal to or higher than their own
if actor_role != Role::Owner && role_level(role) >= role_level(actor_role) {
return Err(AppError::BadRequest(
"cannot invite with role equal to or higher than your own".into(),
));
}
let token = Self::generate_invitation_token();
let token_hash = sha256_hex(token.as_bytes());
let now = chrono::Utc::now();
let expires_at = now + chrono::Duration::days(7);
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let invitation = sqlx::query_as::<_, WorkspaceInvitation>(
"INSERT INTO workspace_invitation (id, workspace_id, email, role, token_hash, invited_by, expires_at, created_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
RETURNING id, workspace_id, email, role, token_hash, invited_by, accepted_by, \
accepted_at, revoked_at, expires_at, created_at",
)
.bind(Uuid::now_v7())
.bind(workspace_id)
.bind(&email)
.bind(role.to_string())
.bind(&token_hash)
.bind(user_uid)
.bind(expires_at)
.bind(now)
.fetch_one(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
let domain = self.ctx.config.main_domain()?;
let invite_link = format!("{}/workspace/invitations/accept?token={}", domain, token);
let mut mail = self
.ctx
.registry
.get_email_client()
.ok_or(AppError::Config("mail service not available".into()))?;
mail.send_email(tonic::Request::new(SendEmailRequest {
to: vec![EmailAddress {
email: email.clone(),
name: String::new(),
}],
subject: format!("You're invited to join {}", ws.name),
text_body: format!(
"You've been invited to join workspace '{}'.\n\nAccept the invitation here:\n\n{}\n\nThis invitation expires in 7 days.",
ws.name, invite_link
),
..Default::default()
}))
.await
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
tracing::info!(email = %email, invitation_id = %invitation.id, "Invitation created");
Ok(CreateInvitationResponse { invitation })
}
pub async fn workspace_revoke_invitation(
&self,
ctx: &Session,
workspace_id: Uuid,
invitation_id: Uuid,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
.await?;
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query(
"UPDATE workspace_invitation SET revoked_at = $1 WHERE id = $2 AND workspace_id = $3 \
AND revoked_at IS NULL AND accepted_at IS NULL",
)
.bind(now)
.bind(invitation_id)
.bind(workspace_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
ensure_affected(
result.rows_affected(),
"invitation not found or already used",
)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(())
}
pub async fn workspace_accept_invitation(
&self,
ctx: &Session,
token: &str,
) -> Result<WorkspaceInvitation, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let token_hash = sha256_hex(token.as_bytes());
let now = chrono::Utc::now();
let invitation = sqlx::query_as::<_, WorkspaceInvitation>(
"SELECT id, workspace_id, email, role, token_hash, invited_by, accepted_by, \
accepted_at, revoked_at, expires_at, created_at FROM workspace_invitation \
WHERE token_hash = $1 AND revoked_at IS NULL AND accepted_at IS NULL AND expires_at > NOW()",
)
.bind(&token_hash)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)?
.ok_or(AppError::BadRequest("invalid or expired invitation".into()))?;
let already_member = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2)",
)
.bind(invitation.workspace_id)
.bind(user_uid)
.fetch_one(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
if already_member {
return Err(AppError::BadRequest(
"already a member of this workspace".into(),
));
}
let user_email: Option<String> = sqlx::query_scalar(
"SELECT email FROM user_mail WHERE user_id = $1 AND is_verified = true LIMIT 1",
)
.bind(user_uid)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
if user_email
.as_deref()
.map(|e| e.trim().eq_ignore_ascii_case(&invitation.email))
!= Some(true)
{
return Err(AppError::Unauthorized);
}
let role_str = invitation.role.to_string();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query_as::<_, WorkspaceInvitation>(
"UPDATE workspace_invitation SET accepted_by = $1, accepted_at = $2 \
WHERE id = $3 AND revoked_at IS NULL AND accepted_at IS NULL \
RETURNING id, workspace_id, email, role, token_hash, invited_by, accepted_by, \
accepted_at, revoked_at, expires_at, created_at",
)
.bind(user_uid)
.bind(now)
.bind(invitation.id)
.fetch_one(&mut *txn)
.await
.map_err(AppError::Database)?;
sqlx::query(
"INSERT INTO workspace_member (id, workspace_id, user_id, role, status, invited_by, joined_at, created_at, updated_at) \
VALUES ($1, $2, $3, $4, 'active', $5, $6, $6, $6) \
ON CONFLICT (workspace_id, user_id) DO NOTHING",
)
.bind(Uuid::now_v7())
.bind(invitation.workspace_id)
.bind(user_uid)
.bind(&role_str)
.bind(invitation.invited_by)
.bind(now)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
sqlx::query(
"UPDATE workspace_stats SET members_count = members_count + 1, updated_at = $1 WHERE workspace_id = $2",
)
.bind(now)
.bind(invitation.workspace_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(result)
}
fn generate_invitation_token() -> String {
(0..64)
.map(|_| format!("{:02x}", rand::random::<u8>()))
.collect()
}
}
use crate::service::util::sha256_hex;
+350
View File
@@ -0,0 +1,350 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::util::{clamp_limit_offset, ensure_affected, role_level};
use crate::error::AppError;
use crate::models::common::Role;
use crate::models::workspaces::WorkspaceMember;
use crate::service::WorkspaceService;
use crate::session::Session;
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema, utoipa::IntoParams)]
pub struct AddMemberParams {
pub user_id: Uuid,
pub role: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct UpdateMemberRoleParams {
pub role: String,
}
impl WorkspaceService {
pub async fn workspace_members(
&self,
ctx: &Session,
workspace_id: Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<WorkspaceMember>, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_readable(user_uid, &ws).await?;
let (limit, offset) = clamp_limit_offset(limit, offset);
sqlx::query_as::<_, WorkspaceMember>(
"SELECT id, workspace_id, user_id, role, status, invited_by, joined_at, \
last_active_at, created_at, updated_at FROM workspace_member \
WHERE workspace_id = $1 AND status = 'active' ORDER BY created_at ASC LIMIT $2 OFFSET $3",
)
.bind(workspace_id)
.bind(limit)
.bind(offset)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
pub async fn workspace_add_member(
&self,
ctx: &Session,
workspace_id: Uuid,
params: AddMemberParams,
) -> Result<WorkspaceMember, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
let actor_role = self
.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
.await?;
let settings_allow = sqlx::query_scalar::<_, bool>(
"SELECT allow_member_invites FROM workspace_settings WHERE workspace_id = $1",
)
.bind(workspace_id)
.fetch_one(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
if !settings_allow && role_level(actor_role) < role_level(Role::Owner) {
return Err(AppError::BadRequest(
"member invitations are disabled".into(),
));
}
let existing = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2)",
)
.bind(workspace_id)
.bind(params.user_id)
.fetch_one(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
if existing {
return Err(AppError::Conflict("user is already a member".into()));
}
let role = params
.role
.as_deref()
.and_then(|r| r.parse::<Role>().ok())
.unwrap_or(ws.default_role.parse().unwrap_or(Role::Member));
if role == Role::Owner {
return Err(AppError::BadRequest("cannot add member as owner".into()));
}
if role == Role::Unknown {
return Err(AppError::BadRequest("invalid role".into()));
}
// Non-owner admins cannot grant roles equal to or higher than their own
if actor_role != Role::Owner && role_level(role) >= role_level(actor_role) {
return Err(AppError::BadRequest(
"cannot grant role equal to or higher than your own".into(),
));
}
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let member = sqlx::query_as::<_, WorkspaceMember>(
"INSERT INTO workspace_member (id, workspace_id, user_id, role, status, invited_by, joined_at, created_at, updated_at) \
VALUES ($1, $2, $3, $4, 'active', $5, $6, $6, $6) \
RETURNING id, workspace_id, user_id, role, status, invited_by, joined_at, last_active_at, created_at, updated_at",
)
.bind(Uuid::now_v7())
.bind(workspace_id)
.bind(params.user_id)
.bind(role.to_string())
.bind(user_uid)
.bind(now)
.fetch_one(&mut *txn)
.await
.map_err(AppError::Database)?;
sqlx::query(
"UPDATE workspace_stats SET members_count = members_count + 1, updated_at = $1 WHERE workspace_id = $2",
)
.bind(now)
.bind(workspace_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(member)
}
pub async fn workspace_update_member_role(
&self,
ctx: &Session,
workspace_id: Uuid,
member_id: Uuid,
params: UpdateMemberRoleParams,
) -> Result<WorkspaceMember, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
let actor_role = self
.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
.await?;
let new_role = params
.role
.parse::<Role>()
.map_err(|_| AppError::BadRequest("invalid role".into()))?;
if new_role == Role::Owner {
return Err(AppError::BadRequest(
"use workspace_transfer_owner to change owner".into(),
));
}
if new_role == Role::Unknown {
return Err(AppError::BadRequest("invalid role".into()));
}
// Non-owner admins cannot grant roles equal to or higher than their own
if actor_role != Role::Owner && role_level(new_role) >= role_level(actor_role) {
return Err(AppError::BadRequest(
"cannot grant role equal to or higher than your own".into(),
));
}
let target = sqlx::query_as::<_, WorkspaceMember>(
"SELECT id, workspace_id, user_id, role, status, invited_by, joined_at, \
last_active_at, created_at, updated_at FROM workspace_member \
WHERE id = $1 AND workspace_id = $2",
)
.bind(member_id)
.bind(workspace_id)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)?
.ok_or(AppError::NotFound("member not found".into()))?;
if target.role == Role::Owner {
return Err(AppError::BadRequest(
"cannot change owner role; use workspace_transfer_owner".into(),
));
}
if role_level(actor_role) <= role_level(target.role) && actor_role != Role::Owner {
return Err(AppError::BadRequest(
"cannot change role of a member with equal or higher role".into(),
));
}
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query_as::<_, WorkspaceMember>(
"UPDATE workspace_member SET role = $1, updated_at = $2 WHERE id = $3 AND workspace_id = $4 \
RETURNING id, workspace_id, user_id, role, status, invited_by, joined_at, last_active_at, created_at, updated_at",
)
.bind(new_role.to_string())
.bind(now)
.bind(member_id)
.bind(workspace_id)
.fetch_one(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(result)
}
pub async fn workspace_remove_member(
&self,
ctx: &Session,
workspace_id: Uuid,
member_id: Uuid,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
let actor_role = self
.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
.await?;
let target = sqlx::query_as::<_, WorkspaceMember>(
"SELECT id, workspace_id, user_id, role, status, invited_by, joined_at, \
last_active_at, created_at, updated_at FROM workspace_member \
WHERE id = $1 AND workspace_id = $2",
)
.bind(member_id)
.bind(workspace_id)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)?
.ok_or(AppError::NotFound("member not found".into()))?;
if target.role == Role::Owner {
return Err(AppError::BadRequest(
"cannot remove owner; transfer ownership first".into(),
));
}
if role_level(actor_role) <= role_level(target.role) && actor_role != Role::Owner {
return Err(AppError::BadRequest(
"cannot remove a member with equal or higher role".into(),
));
}
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result =
sqlx::query("DELETE FROM workspace_member WHERE id = $1 AND workspace_id = $2")
.bind(member_id)
.bind(workspace_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
ensure_affected(result.rows_affected(), "member not found")?;
sqlx::query(
"UPDATE workspace_stats SET members_count = GREATEST(members_count - 1, 0), updated_at = $1 WHERE workspace_id = $2",
)
.bind(now)
.bind(workspace_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(())
}
pub async fn workspace_leave(&self, ctx: &Session, workspace_id: Uuid) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
if ws.owner_id == user_uid {
return Err(AppError::BadRequest(
"owner cannot leave; transfer ownership first".into(),
));
}
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result =
sqlx::query("DELETE FROM workspace_member WHERE workspace_id = $1 AND user_id = $2")
.bind(workspace_id)
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
ensure_affected(result.rows_affected(), "not a member")?;
sqlx::query(
"UPDATE workspace_stats SET members_count = GREATEST(members_count - 1, 0), updated_at = $1 WHERE workspace_id = $2",
)
.bind(now)
.bind(workspace_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(())
}
}
+13
View File
@@ -0,0 +1,13 @@
pub mod approvals;
pub mod audit;
pub mod billing;
pub mod branding;
pub mod core;
pub mod domains;
pub mod integrations;
pub mod invitations;
pub mod members;
pub mod settings;
pub mod stats;
pub mod util;
pub mod webhooks;
+128
View File
@@ -0,0 +1,128 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::error::AppError;
use crate::models::workspaces::WorkspaceSettings;
use crate::service::WorkspaceService;
use crate::session::Session;
use super::util::merge_optional_text;
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct UpdateWorkspaceSettingsParams {
pub allow_public_repos: Option<bool>,
pub allow_member_invites: Option<bool>,
pub require_two_factor: Option<bool>,
pub default_repo_visibility: Option<String>,
pub default_branch_name: Option<String>,
pub issue_tracking_enabled: Option<bool>,
pub pull_requests_enabled: Option<bool>,
pub wiki_enabled: Option<bool>,
}
impl WorkspaceService {
pub async fn workspace_settings(
&self,
ctx: &Session,
workspace_id: Uuid,
) -> Result<WorkspaceSettings, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_readable(user_uid, &ws).await?;
self.ensure_user_workspace_settings(workspace_id).await
}
pub async fn workspace_update_settings(
&self,
ctx: &Session,
workspace_id: Uuid,
params: UpdateWorkspaceSettingsParams,
) -> Result<WorkspaceSettings, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, crate::models::common::Role::Admin)
.await?;
let current = self.ensure_user_workspace_settings(workspace_id).await?;
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query_as::<_, WorkspaceSettings>(
"UPDATE workspace_settings SET \
allow_public_repos = $1, allow_member_invites = $2, require_two_factor = $3, \
default_repo_visibility = $4, default_branch_name = $5, \
issue_tracking_enabled = $6, pull_requests_enabled = $7, wiki_enabled = $8, updated_at = $9 \
WHERE workspace_id = $10 \
RETURNING workspace_id, allow_public_repos, allow_member_invites, require_two_factor, \
default_repo_visibility, default_branch_name, issue_tracking_enabled, \
pull_requests_enabled, wiki_enabled, created_at, updated_at",
)
.bind(params.allow_public_repos.unwrap_or(current.allow_public_repos))
.bind(params.allow_member_invites.unwrap_or(current.allow_member_invites))
.bind(params.require_two_factor.unwrap_or(current.require_two_factor))
.bind(merge_optional_text(params.default_repo_visibility, Some(current.default_repo_visibility.clone())).unwrap_or_else(|| current.default_repo_visibility.clone()))
.bind(merge_optional_text(params.default_branch_name, Some(current.default_branch_name.clone())).unwrap_or_else(|| current.default_branch_name.clone()))
.bind(params.issue_tracking_enabled.unwrap_or(current.issue_tracking_enabled))
.bind(params.pull_requests_enabled.unwrap_or(current.pull_requests_enabled))
.bind(params.wiki_enabled.unwrap_or(current.wiki_enabled))
.bind(now)
.bind(workspace_id)
.fetch_one(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(result)
}
async fn ensure_user_workspace_settings(
&self,
workspace_id: Uuid,
) -> Result<WorkspaceSettings, AppError> {
if let Some(settings) = self.find_workspace_settings(workspace_id).await? {
return Ok(settings);
}
let now = chrono::Utc::now();
sqlx::query(
"INSERT INTO workspace_settings (workspace_id, allow_public_repos, allow_member_invites, \
require_two_factor, default_repo_visibility, default_branch_name, \
issue_tracking_enabled, pull_requests_enabled, wiki_enabled, created_at, updated_at) \
VALUES ($1, true, true, false, 'private', 'main', true, true, true, $2, $2) ON CONFLICT (workspace_id) DO NOTHING",
)
.bind(workspace_id)
.bind(now)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
self.find_workspace_settings(workspace_id)
.await?
.ok_or(AppError::NotFound("workspace settings not found".into()))
}
async fn find_workspace_settings(
&self,
workspace_id: Uuid,
) -> Result<Option<WorkspaceSettings>, AppError> {
sqlx::query_as::<_, WorkspaceSettings>(
"SELECT workspace_id, allow_public_repos, allow_member_invites, require_two_factor, \
default_repo_visibility, default_branch_name, issue_tracking_enabled, \
pull_requests_enabled, wiki_enabled, created_at, updated_at \
FROM workspace_settings WHERE workspace_id = $1",
)
.bind(workspace_id)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
}
+117
View File
@@ -0,0 +1,117 @@
use uuid::Uuid;
use crate::error::AppError;
use crate::models::common::Role;
use crate::models::workspaces::WorkspaceStats;
use crate::service::WorkspaceService;
use crate::session::Session;
impl WorkspaceService {
pub async fn workspace_stats(
&self,
ctx: &Session,
workspace_id: Uuid,
) -> Result<WorkspaceStats, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_readable(user_uid, &ws).await?;
self.ensure_workspace_stats(workspace_id).await
}
pub async fn workspace_refresh_stats(
&self,
ctx: &Session,
workspace_id: Uuid,
) -> Result<WorkspaceStats, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
.await?;
let members_count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM workspace_member WHERE workspace_id = $1 AND status = 'active'",
)
.bind(workspace_id)
.fetch_one(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
let repos_count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM repo WHERE workspace_id = $1 AND deleted_at IS NULL",
)
.bind(workspace_id)
.fetch_one(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
let issues_count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM issue WHERE repo_id IN (SELECT id FROM repo WHERE workspace_id = $1 AND deleted_at IS NULL) AND deleted_at IS NULL",
)
.bind(workspace_id)
.fetch_one(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
let prs_count = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM pull_request WHERE repo_id IN (SELECT id FROM repo WHERE workspace_id = $1 AND deleted_at IS NULL) AND deleted_at IS NULL",
)
.bind(workspace_id)
.fetch_one(self.ctx.db.reader())
.await
.map_err(AppError::Database)?;
let now = chrono::Utc::now();
let result = sqlx::query_as::<_, WorkspaceStats>(
"UPDATE workspace_stats SET members_count = $1, repos_count = $2, issues_count = $3, \
pull_requests_count = $4, updated_at = $5 WHERE workspace_id = $6 \
RETURNING workspace_id, members_count, repos_count, issues_count, pull_requests_count, \
storage_bytes, bandwidth_bytes, build_minutes_used, last_activity_at, updated_at",
)
.bind(members_count)
.bind(repos_count)
.bind(issues_count)
.bind(prs_count)
.bind(now)
.bind(workspace_id)
.fetch_one(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
Ok(result)
}
async fn ensure_workspace_stats(&self, workspace_id: Uuid) -> Result<WorkspaceStats, AppError> {
if let Some(stats) = sqlx::query_as::<_, WorkspaceStats>(
"SELECT workspace_id, members_count, repos_count, issues_count, pull_requests_count, \
storage_bytes, bandwidth_bytes, build_minutes_used, last_activity_at, updated_at \
FROM workspace_stats WHERE workspace_id = $1",
)
.bind(workspace_id)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)?
{
return Ok(stats);
}
sqlx::query(
"INSERT INTO workspace_stats (workspace_id, members_count, repos_count, issues_count, \
pull_requests_count, storage_bytes, bandwidth_bytes, build_minutes_used, updated_at) \
VALUES ($1, 0, 0, 0, 0, 0, 0, 0, $2) ON CONFLICT (workspace_id) DO NOTHING",
)
.bind(workspace_id)
.bind(chrono::Utc::now())
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
sqlx::query_as::<_, WorkspaceStats>(
"SELECT workspace_id, members_count, repos_count, issues_count, pull_requests_count, \
storage_bytes, bandwidth_bytes, build_minutes_used, last_activity_at, updated_at \
FROM workspace_stats WHERE workspace_id = $1",
)
.bind(workspace_id)
.fetch_one(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
}
+3
View File
@@ -0,0 +1,3 @@
pub use crate::service::util::{
clamp_limit_offset, ensure_affected, merge_optional_text, parse_enum, required_text, role_level,
};
+267
View File
@@ -0,0 +1,267 @@
use serde::{Deserialize, Serialize};
use std::net::IpAddr;
use url::Url;
use uuid::Uuid;
use crate::error::AppError;
use crate::models::common::{EventType, Role};
use crate::models::workspaces::WorkspaceWebhook;
use crate::service::WorkspaceService;
use crate::session::Session;
use super::util::{clamp_limit_offset, ensure_affected, required_text};
/// Validate webhook URL for SSRF protection
fn validate_webhook_url(url_str: &str) -> Result<(), AppError> {
let url = Url::parse(url_str).map_err(|_| AppError::BadRequest("Invalid URL format".into()))?;
// Only allow HTTPS
if url.scheme() != "https" {
return Err(AppError::BadRequest(
"Webhook URL must use HTTPS protocol".into(),
));
}
let host = url
.host_str()
.ok_or_else(|| AppError::BadRequest("URL must have a host".into()))?;
// Reject IP addresses directly (require domain names)
if host.parse::<IpAddr>().is_ok() {
return Err(AppError::BadRequest(
"Webhook URL must use a domain name, not an IP address".into(),
));
}
// Reject localhost and common local domains
let host_lower = host.to_lowercase();
if host_lower == "localhost"
|| host_lower.ends_with(".localhost")
|| host_lower == "127.0.0.1"
|| host_lower == "::1"
|| host_lower == "0.0.0.0"
|| host_lower.ends_with(".local")
|| host_lower.ends_with(".internal")
{
return Err(AppError::BadRequest(
"Webhook URL cannot point to localhost or internal domains".into(),
));
}
// Reject metadata endpoints (AWS, GCP, Azure)
if host == "169.254.169.254" || host == "metadata.google.internal" {
return Err(AppError::BadRequest(
"Webhook URL cannot point to cloud metadata endpoints".into(),
));
}
Ok(())
}
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct CreateWebhookParams {
pub url: String,
pub secret_ciphertext: Option<String>,
pub events: Vec<EventType>,
pub active: Option<bool>,
}
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct UpdateWebhookParams {
pub url: Option<String>,
pub secret_ciphertext: Option<String>,
pub events: Option<Vec<EventType>>,
pub active: Option<bool>,
}
impl WorkspaceService {
pub async fn workspace_webhooks(
&self,
ctx: &Session,
workspace_id: Uuid,
limit: i64,
offset: i64,
) -> Result<Vec<WorkspaceWebhook>, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
.await?;
let (limit, offset) = clamp_limit_offset(limit, offset);
sqlx::query_as::<_, WorkspaceWebhook>(
"SELECT id, workspace_id, url, secret_ciphertext, events, active, \
last_delivery_status, last_delivery_at, created_by, created_at, updated_at \
FROM workspace_webhook WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
)
.bind(workspace_id)
.bind(limit)
.bind(offset)
.fetch_all(self.ctx.db.reader())
.await
.map_err(AppError::Database)
}
pub async fn workspace_create_webhook(
&self,
ctx: &Session,
workspace_id: Uuid,
params: CreateWebhookParams,
) -> Result<WorkspaceWebhook, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
.await?;
let url = required_text(params.url, "url")?;
validate_webhook_url(&url)?;
if params.events.is_empty() {
return Err(AppError::BadRequest(
"at least one event is required".into(),
));
}
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query_as::<_, WorkspaceWebhook>(
"INSERT INTO workspace_webhook (id, workspace_id, url, secret_ciphertext, events, active, \
created_by, created_at, updated_at) \
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) \
RETURNING id, workspace_id, url, secret_ciphertext, events, active, \
last_delivery_status, last_delivery_at, created_by, created_at, updated_at",
)
.bind(Uuid::now_v7())
.bind(workspace_id)
.bind(&url)
.bind(&params.secret_ciphertext)
.bind(&params.events)
.bind(params.active.unwrap_or(true))
.bind(user_uid)
.bind(now)
.fetch_one(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(result)
}
pub async fn workspace_update_webhook(
&self,
ctx: &Session,
workspace_id: Uuid,
webhook_id: Uuid,
params: UpdateWebhookParams,
) -> Result<WorkspaceWebhook, AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
.await?;
let current = sqlx::query_as::<_, WorkspaceWebhook>(
"SELECT id, workspace_id, url, secret_ciphertext, events, active, \
last_delivery_status, last_delivery_at, created_by, created_at, updated_at \
FROM workspace_webhook WHERE id = $1 AND workspace_id = $2",
)
.bind(webhook_id)
.bind(workspace_id)
.fetch_optional(self.ctx.db.reader())
.await
.map_err(AppError::Database)?
.ok_or(AppError::NotFound("webhook not found".into()))?;
let url = params
.url
.as_ref()
.map(|u| u.trim().to_string())
.unwrap_or(current.url);
// Validate URL if it was updated
if params.url.is_some() {
validate_webhook_url(&url)?;
}
let active = params.active.unwrap_or(current.active);
let now = chrono::Utc::now();
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result = sqlx::query_as::<_, WorkspaceWebhook>(
"UPDATE workspace_webhook SET url = $1, secret_ciphertext = $2, events = $3, \
active = $4, updated_at = $5 WHERE id = $6 AND workspace_id = $7 \
RETURNING id, workspace_id, url, secret_ciphertext, events, active, \
last_delivery_status, last_delivery_at, created_by, created_at, updated_at",
)
.bind(&url)
.bind(params.secret_ciphertext.or(current.secret_ciphertext))
.bind(params.events.unwrap_or(current.events))
.bind(active)
.bind(now)
.bind(webhook_id)
.bind(workspace_id)
.fetch_one(&mut *txn)
.await
.map_err(AppError::Database)?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(result)
}
pub async fn workspace_delete_webhook(
&self,
ctx: &Session,
workspace_id: Uuid,
webhook_id: Uuid,
) -> Result<(), AppError> {
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
let ws = self.find_workspace_by_id(workspace_id).await?;
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
.await?;
let mut txn = self
.ctx
.db
.writer()
.begin()
.await
.map_err(|_| AppError::TxnError)?;
sqlx::query("SET LOCAL app.current_user_id = $1")
.bind(user_uid)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
let result =
sqlx::query("DELETE FROM workspace_webhook WHERE id = $1 AND workspace_id = $2")
.bind(webhook_id)
.bind(workspace_id)
.execute(&mut *txn)
.await
.map_err(AppError::Database)?;
ensure_affected(result.rows_affected(), "webhook not found")?;
txn.commit().await.map_err(|_| AppError::TxnError)?;
Ok(())
}
}