diff --git a/libs/api/workspace/billing.rs b/libs/api/workspace/billing.rs deleted file mode 100644 index 2d9cd02..0000000 --- a/libs/api/workspace/billing.rs +++ /dev/null @@ -1,84 +0,0 @@ -use crate::{ApiResponse, error::ApiError}; -use actix_web::{HttpResponse, Result, web}; -use service::AppService; -use service::workspace::billing::{ - WorkspaceBillingAddCreditParams, WorkspaceBillingCurrentResponse, WorkspaceBillingHistoryQuery, - WorkspaceBillingHistoryResponse, -}; -use session::Session; - -#[utoipa::path( - get, - path = "/api/workspaces/{slug}/billing", - params( - ("slug" = String, Path, description = "Workspace slug") - ), - responses( - (status = 200, description = "Get workspace billing info", body = ApiResponse), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Not a workspace member"), - (status = 404, description = "Workspace not found"), - ), - tag = "Workspace" -)] -pub async fn workspace_billing_current( - service: web::Data, - session: Session, - path: web::Path, -) -> Result { - let slug = path.into_inner(); - let resp = service.workspace_billing_current(&session, slug).await?; - Ok(ApiResponse::ok(resp).to_response()) -} - -#[utoipa::path( - get, - path = "/api/workspaces/{slug}/billing/history", - params( - ("slug" = String, Path, description = "Workspace slug"), - ), - responses( - (status = 200, description = "Get workspace billing history", body = ApiResponse), - (status = 401, description = "Unauthorized"), - ), - tag = "Workspace" -)] -pub async fn workspace_billing_history( - service: web::Data, - session: Session, - path: web::Path, - query: web::Query, -) -> Result { - let slug = path.into_inner(); - let resp = service - .workspace_billing_history(&session, slug, query.into_inner()) - .await?; - Ok(ApiResponse::ok(resp).to_response()) -} - -#[utoipa::path( - post, - path = "/api/workspaces/{slug}/billing/credits", - params( - ("slug" = String, Path, description = "Workspace slug") - ), - request_body = WorkspaceBillingAddCreditParams, - responses( - (status = 200, description = "Add credit to workspace billing", body = ApiResponse), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Not a workspace member"), - ), - tag = "Workspace" -)] -pub async fn workspace_billing_add_credit( - service: web::Data, - session: Session, - path: web::Path, - body: web::Json, -) -> Result { - let slug = path.into_inner(); - let resp = service - .workspace_billing_add_credit(&session, slug, body.into_inner()) - .await?; - Ok(ApiResponse::ok(resp).to_response()) -} diff --git a/libs/api/workspace/info.rs b/libs/api/workspace/info.rs deleted file mode 100644 index eee183b..0000000 --- a/libs/api/workspace/info.rs +++ /dev/null @@ -1,42 +0,0 @@ -use crate::{ApiResponse, error::ApiError}; -use actix_web::{HttpResponse, Result, web}; -use service::AppService; -use session::Session; - -#[utoipa::path( - get, - path = "/api/workspaces/me", - responses( - (status = 200, description = "List my workspaces", body = ApiResponse), - (status = 401, description = "Unauthorized"), - ), - tag = "Workspace" -)] -pub async fn workspace_list( - service: web::Data, - session: Session, -) -> Result { - let resp = service.workspace_list(&session).await?; - Ok(ApiResponse::ok(resp).to_response()) -} - -#[utoipa::path( - get, - path = "/api/workspaces/{slug}", - params(("slug" = String, Path)), - responses( - (status = 200, description = "Get workspace info", body = ApiResponse), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Workspace not found"), - ), - tag = "Workspace" -)] -pub async fn workspace_info( - service: web::Data, - session: Session, - path: web::Path, -) -> Result { - let slug = path.into_inner(); - let resp = service.workspace_info(&session, slug).await?; - Ok(ApiResponse::ok(resp).to_response()) -} diff --git a/libs/api/workspace/init.rs b/libs/api/workspace/init.rs deleted file mode 100644 index 6080457..0000000 --- a/libs/api/workspace/init.rs +++ /dev/null @@ -1,26 +0,0 @@ -use crate::{ApiResponse, error::ApiError}; -use actix_web::{HttpResponse, Result, web}; -use service::AppService; -use service::workspace::init::WorkspaceInitParams; -use session::Session; - -#[utoipa::path( - post, - path = "/api/workspaces", - request_body = WorkspaceInitParams, - responses( - (status = 200, description = "Create workspace", body = ApiResponse), - (status = 401, description = "Unauthorized"), - (status = 409, description = "Slug or name already exists"), - ), - tag = "Workspace" -)] -pub async fn workspace_create( - service: web::Data, - session: Session, - body: web::Json, -) -> Result { - let ws = service.workspace_init(&session, body.into_inner()).await?; - let resp = service.workspace_info(&session, ws.slug.clone()).await?; - Ok(ApiResponse::ok(resp).to_response()) -} diff --git a/libs/api/workspace/members.rs b/libs/api/workspace/members.rs deleted file mode 100644 index 2a994aa..0000000 --- a/libs/api/workspace/members.rs +++ /dev/null @@ -1,249 +0,0 @@ -use crate::{ApiResponse, error::ApiError}; -use actix_web::{HttpResponse, Result, web}; -use service::AppService; -use service::workspace::members::{ - MyWorkspaceInvitation, PendingInvitationInfo, WorkspaceAcceptBySlugParams, - WorkspaceInviteAcceptParams, WorkspaceInviteParams, WorkspaceMembersResponse, -}; -use session::Session; -use uuid::Uuid; - -#[derive(serde::Deserialize, utoipa::IntoParams)] -pub struct MembersQuery { - pub page: Option, - pub per_page: Option, -} - -#[utoipa::path( - get, - path = "/api/workspaces/{slug}/members", - params( - ("slug" = String, Path), - ("page" = Option, Query), - ("per_page" = Option, Query), - ), - responses( - (status = 200, description = "List workspace members", body = ApiResponse), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Not a member"), - (status = 404, description = "Workspace not found"), - ), - tag = "Workspace" -)] -pub async fn workspace_members( - service: web::Data, - session: Session, - path: web::Path, - query: web::Query, -) -> Result { - let slug = path.into_inner(); - let resp = service - .workspace_members(&session, slug, query.page, query.per_page) - .await?; - Ok(ApiResponse::ok(resp).to_response()) -} - -#[derive(serde::Deserialize, utoipa::ToSchema)] -pub struct UpdateRoleParams { - pub user_id: Uuid, - pub role: String, -} - -#[utoipa::path( - patch, - path = "/api/workspaces/{slug}/members/role", - params(("slug" = String, Path)), - request_body = UpdateRoleParams, - responses( - (status = 200, description = "Update member role"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Permission denied"), - (status = 404, description = "Workspace or member not found"), - ), - tag = "Workspace" -)] -pub async fn workspace_update_member_role( - service: web::Data, - session: Session, - path: web::Path, - body: web::Json, -) -> Result { - let slug = path.into_inner(); - service - .workspace_update_member_role(&session, slug, body.user_id, body.role.clone()) - .await?; - Ok(ApiResponse::ok(serde_json::json!({ "success": true })).to_response()) -} - -#[utoipa::path( - delete, - path = "/api/workspaces/{slug}/members/{user_id}", - params( - ("slug" = String, Path), - ("user_id" = Uuid, Path), - ), - responses( - (status = 200, description = "Remove member"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Permission denied"), - (status = 404, description = "Member not found"), - ), - tag = "Workspace" -)] -pub async fn workspace_remove_member( - service: web::Data, - session: Session, - path: web::Path<(String, Uuid)>, -) -> Result { - let (slug, user_id) = path.into_inner(); - service - .workspace_remove_member(&session, slug, user_id) - .await?; - Ok(ApiResponse::ok(serde_json::json!({ "success": true })).to_response()) -} - -#[utoipa::path( - get, - path = "/api/workspaces/{slug}/invitations", - params(("slug" = String, Path)), - responses( - (status = 200, description = "List pending invitations", body = ApiResponse>), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Permission denied"), - (status = 404, description = "Workspace not found"), - ), - tag = "Workspace" -)] -pub async fn workspace_pending_invitations( - service: web::Data, - session: Session, - path: web::Path, -) -> Result { - let slug = path.into_inner(); - let resp = service - .workspace_pending_invitations(&session, slug) - .await?; - Ok(ApiResponse::ok(resp).to_response()) -} - -#[utoipa::path( - delete, - path = "/api/workspaces/{slug}/invitations/{user_id}", - params( - ("slug" = String, Path), - ("user_id" = Uuid, Path), - ), - responses( - (status = 200, description = "Cancel invitation"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Permission denied"), - (status = 404, description = "Invitation not found"), - ), - tag = "Workspace" -)] -pub async fn workspace_cancel_invitation( - service: web::Data, - session: Session, - path: web::Path<(String, Uuid)>, -) -> Result { - let (slug, user_id) = path.into_inner(); - service - .workspace_cancel_invitation(&session, slug, user_id) - .await?; - Ok(ApiResponse::ok(serde_json::json!({ "success": true })).to_response()) -} - -#[utoipa::path( - post, - path = "/api/workspaces/{slug}/invitations", - params(("slug" = String, Path)), - request_body = WorkspaceInviteParams, - responses( - (status = 200, description = "Send invitation"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Permission denied"), - (status = 404, description = "User not found"), - (status = 409, description = "Already a member"), - ), - tag = "Workspace" -)] -pub async fn workspace_invite_member( - service: web::Data, - session: Session, - path: web::Path, - body: web::Json, -) -> Result { - let slug = path.into_inner(); - service - .workspace_invite_member(&session, slug, body.into_inner()) - .await?; - Ok(ApiResponse::ok(serde_json::json!({ "success": true })).to_response()) -} - -#[utoipa::path( - post, - path = "/api/workspaces/invitations/accept", - request_body = WorkspaceInviteAcceptParams, - responses( - (status = 200, description = "Accept invitation", body = ApiResponse), - (status = 400, description = "Invalid or expired token"), - (status = 401, description = "Unauthorized"), - (status = 409, description = "Already accepted"), - ), - tag = "Workspace" -)] -pub async fn workspace_accept_invitation( - service: web::Data, - session: Session, - body: web::Json, -) -> Result { - let ws = service - .workspace_accept_invitation(&session, body.into_inner()) - .await?; - let resp = service.workspace_info(&session, ws.slug).await?; - Ok(ApiResponse::ok(resp).to_response()) -} - -/// List all pending workspace invitations for the current user. -#[utoipa::path( - get, - path = "/api/workspaces/me/invitations", - responses( - (status = 200, description = "List my workspace invitations", body = ApiResponse>), - (status = 401, description = "Unauthorized"), - ), - tag = "Workspace" -)] -pub async fn workspace_my_invitations( - service: web::Data, - session: Session, -) -> Result { - let resp = service.workspace_my_pending_invitations(&session).await?; - Ok(ApiResponse::ok(resp).to_response()) -} - -/// Accept a workspace invitation by slug. -#[utoipa::path( - post, - path = "/api/workspaces/invitations/accept-by-slug", - request_body = WorkspaceAcceptBySlugParams, - responses( - (status = 200, description = "Accept invitation", body = ApiResponse), - (status = 400, description = "Invalid or expired token"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Invitation not found"), - (status = 409, description = "Already accepted"), - ), - tag = "Workspace" -)] -pub async fn workspace_accept_invitation_by_slug( - service: web::Data, - session: Session, - body: web::Json, -) -> Result { - let ws = service - .workspace_accept_invitation_by_slug(&session, body.into_inner()) - .await?; - let resp = service.workspace_info(&session, ws.slug).await?; - Ok(ApiResponse::ok(resp).to_response()) -} diff --git a/libs/api/workspace/mod.rs b/libs/api/workspace/mod.rs deleted file mode 100644 index 957aca8..0000000 --- a/libs/api/workspace/mod.rs +++ /dev/null @@ -1,76 +0,0 @@ -pub mod billing; -pub mod info; -pub mod init; -pub mod members; -pub mod projects; -pub mod settings; -pub mod stats; - -use actix_web::web; - -pub fn init_workspace_routes(cfg: &mut web::ServiceConfig) { - cfg.service( - web::scope("/workspaces") - .route("", web::post().to(init::workspace_create)) - .route("/me", web::get().to(info::workspace_list)) - .route("/{slug}", web::get().to(info::workspace_info)) - - .route( - "/{slug}/billing", - web::get().to(billing::workspace_billing_current), - ) - .route( - "/{slug}/billing/history", - web::get().to(billing::workspace_billing_history), - ) - .route( - "/{slug}/billing/credits", - web::post().to(billing::workspace_billing_add_credit), - ) - - .route( - "/{slug}/projects", - web::get().to(projects::workspace_projects), - ) - - .route("/{slug}/stats", web::get().to(stats::workspace_stats)) - - .route("/{slug}", web::patch().to(settings::workspace_update)) - .route("/{slug}", web::delete().to(settings::workspace_delete)) - - .route("/{slug}/members", web::get().to(members::workspace_members)) - .route( - "/{slug}/members/{user_id}", - web::delete().to(members::workspace_remove_member), - ) - .route( - "/{slug}/members/role", - web::patch().to(members::workspace_update_member_role), - ) - - .route( - "/me/invitations", - web::get().to(members::workspace_my_invitations), - ) - .route( - "/invitations/accept", - web::post().to(members::workspace_accept_invitation), - ) - .route( - "/invitations/accept-by-slug", - web::post().to(members::workspace_accept_invitation_by_slug), - ) - .route( - "/{slug}/invitations", - web::post().to(members::workspace_invite_member), - ) - .route( - "/{slug}/invitations", - web::get().to(members::workspace_pending_invitations), - ) - .route( - "/{slug}/invitations/{user_id}", - web::delete().to(members::workspace_cancel_invitation), - ), - ); -} diff --git a/libs/api/workspace/projects.rs b/libs/api/workspace/projects.rs deleted file mode 100644 index f815097..0000000 --- a/libs/api/workspace/projects.rs +++ /dev/null @@ -1,32 +0,0 @@ -use crate::{ApiResponse, error::ApiError}; -use actix_web::{HttpResponse, Result, web}; -use service::AppService; -use service::workspace::info::{WorkspaceProjectsQuery, WorkspaceProjectsResponse}; -use session::Session; - -#[utoipa::path( - get, - path = "/api/workspaces/{slug}/projects", - params( - ("slug" = String, Path, description = "Workspace slug"), - ), - responses( - (status = 200, description = "List workspace projects", body = ApiResponse), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Not a workspace member"), - (status = 404, description = "Workspace not found"), - ), - tag = "Workspace" -)] -pub async fn workspace_projects( - service: web::Data, - session: Session, - path: web::Path, - query: web::Query, -) -> Result { - let slug = path.into_inner(); - let resp = service - .workspace_projects(&session, slug, query.into_inner()) - .await?; - Ok(ApiResponse::ok(resp).to_response()) -} diff --git a/libs/api/workspace/settings.rs b/libs/api/workspace/settings.rs deleted file mode 100644 index f4d770c..0000000 --- a/libs/api/workspace/settings.rs +++ /dev/null @@ -1,55 +0,0 @@ -use crate::{ApiResponse, error::ApiError}; -use actix_web::{HttpResponse, Result, web}; -use service::AppService; -use service::workspace::settings::WorkspaceUpdateParams; -use session::Session; - -#[utoipa::path( - patch, - path = "/api/workspaces/{slug}", - params(("slug" = String, Path)), - request_body = WorkspaceUpdateParams, - responses( - (status = 200, description = "Update workspace", body = ApiResponse), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Permission denied"), - (status = 404, description = "Workspace not found"), - (status = 409, description = "Name already exists"), - ), - tag = "Workspace" -)] -pub async fn workspace_update( - service: web::Data, - session: Session, - path: web::Path, - body: web::Json, -) -> Result { - let slug = path.into_inner(); - let ws = service - .workspace_update(&session, slug, body.into_inner()) - .await?; - let resp = service.workspace_info(&session, ws.slug).await?; - Ok(ApiResponse::ok(resp).to_response()) -} - -#[utoipa::path( - delete, - path = "/api/workspaces/{slug}", - params(("slug" = String, Path)), - responses( - (status = 200, description = "Delete workspace"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Permission denied (owner only)"), - (status = 404, description = "Workspace not found"), - ), - tag = "Workspace" -)] -pub async fn workspace_delete( - service: web::Data, - session: Session, - path: web::Path, -) -> Result { - let slug = path.into_inner(); - service.workspace_delete(&session, slug).await?; - Ok(ApiResponse::ok(serde_json::json!({ "success": true })).to_response()) -} diff --git a/libs/api/workspace/stats.rs b/libs/api/workspace/stats.rs deleted file mode 100644 index bbe272b..0000000 --- a/libs/api/workspace/stats.rs +++ /dev/null @@ -1,29 +0,0 @@ -use crate::{ApiResponse, error::ApiError}; -use actix_web::{HttpResponse, Result, web}; -use service::AppService; -use service::workspace::info::WorkspaceStatsResponse; -use session::Session; - -#[utoipa::path( - get, - path = "/api/workspaces/{slug}/stats", - params( - ("slug" = String, Path, description = "Workspace slug") - ), - responses( - (status = 200, description = "Get workspace stats", body = ApiResponse), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Not a workspace member"), - (status = 404, description = "Workspace not found"), - ), - tag = "Workspace" -)] -pub async fn workspace_stats( - service: web::Data, - session: Session, - path: web::Path, -) -> Result { - let slug = path.into_inner(); - let resp = service.workspace_stats(&session, slug).await?; - Ok(ApiResponse::ok(resp).to_response()) -} diff --git a/libs/service/workspace/alert.rs b/libs/service/workspace/alert.rs deleted file mode 100644 index ca6cca2..0000000 --- a/libs/service/workspace/alert.rs +++ /dev/null @@ -1,330 +0,0 @@ -//! Workspace billing alert checker. -//! -//! Periodically checks all workspaces against their alert config and enqueues -//! email notifications when thresholds are exceeded. -//! -//! Alert types: -//! - `low_balance` — balance falls below threshold -//! - `monthly_quota` — month_used exceeds monthly_quota * threshold (e.g. 0.8 = 80%) - -use crate::AppService; -use chrono::{Datelike, Utc}; -use models::workspaces::{ - workspace, workspace_alert_config, workspace_billing, workspace_billing_history, - workspace_membership, -}; -use queue::EmailEnvelope; -use rust_decimal::prelude::ToPrimitive; -use sea_orm::*; -use serde::{Deserialize, Serialize}; -use tokio::time::{interval, Duration}; -use uuid::Uuid; - -// ─── Types ───────────────────────────────────────────────────────────────── - -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct CheckAlertsResponse { - pub workspaces_checked: usize, - pub alerts_sent: usize, - pub details: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct AlertDetail { - pub workspace_id: Uuid, - pub workspace_name: String, - pub alert_type: String, - pub threshold: f64, - pub current_value: f64, - pub recipients: Vec, -} - -// ─── AppService impl ──────────────────────────────────────────────────────── - -impl AppService { - /// Run billing alert checks for all workspaces. - /// Called periodically by the background task and optionally via admin API. - pub async fn check_billing_alerts(&self) -> CheckAlertsResponse { - let mut details = Vec::new(); - let mut alerts_sent = 0; - - let workspaces = workspace::Entity::find() - .filter(workspace::Column::DeletedAt.is_null()) - .all(&self.db) - .await - .unwrap_or_default(); - - let count = workspaces.len(); - for ws in workspaces { - let ws_details = self.check_workspace_alerts(&ws).await; - alerts_sent += ws_details.alerts_sent; - details.extend(ws_details.details); - } - - CheckAlertsResponse { workspaces_checked: count, alerts_sent, details } - } - - async fn check_workspace_alerts( - &self, - ws: &workspace::Model, - ) -> CheckAlertsResponse { - let mut details = Vec::new(); - let mut alerts_sent = 0usize; - - let Some(billing) = workspace_billing::Entity::find_by_id(ws.id) - .one(&self.db) - .await - .ok() - .flatten() - else { - return CheckAlertsResponse { workspaces_checked: 1, alerts_sent: 0, details }; - }; - - let configs = workspace_alert_config::Entity::find() - .filter(workspace_alert_config::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_alert_config::Column::Enabled.eq(true)) - .all(&self.db) - .await - .unwrap_or_default(); - - if configs.is_empty() { - return CheckAlertsResponse { workspaces_checked: 1, alerts_sent: 0, details }; - } - - let month_used = self.current_month_usage(ws.id).await; - let recipients = self.alert_recipients(ws.id).await; - - if recipients.is_empty() { - return CheckAlertsResponse { workspaces_checked: 1, alerts_sent: 0, details }; - } - - let threshold_f64 = |d: &rust_decimal::Decimal| -> f64 { - use rust_decimal::prelude::ToPrimitive; - d.to_f64().unwrap_or_default() - }; - - for config in configs { - if !config.email_enabled { - continue; - } - - let triggered = match config.alert_type.as_str() { - "low_balance" => threshold_f64(&billing.balance) < threshold_f64(&config.threshold), - "monthly_quota" => { - let quota = threshold_f64(&billing.monthly_quota); - quota > 0.0 && month_used > quota * threshold_f64(&config.threshold) - } - "usage_surge" => { - let quota = threshold_f64(&billing.monthly_quota); - quota > 0.0 && month_used > quota * 0.5 - } - _ => false, - }; - - if triggered { - let (subject, body) = self.build_alert_email(ws, &billing, month_used, &config); - - for recipient in &recipients { - let envelope = EmailEnvelope { - id: Uuid::now_v7(), - to: recipient.clone(), - subject: subject.clone(), - body: body.clone(), - created_at: Utc::now(), - }; - if self.queue_producer.publish_email(envelope).await.is_ok() { - alerts_sent += 1; - } - } - - details.push(AlertDetail { - workspace_id: ws.id, - workspace_name: ws.name.clone(), - alert_type: config.alert_type.clone(), - threshold: threshold_f64(&config.threshold), - current_value: if config.alert_type == "low_balance" { - threshold_f64(&billing.balance) - } else { - month_used - }, - recipients: recipients.clone(), - }); - } - } - - CheckAlertsResponse { workspaces_checked: 1, alerts_sent, details } - } - - fn build_alert_email( - &self, - ws: &workspace::Model, - billing: &workspace_billing::Model, - month_used: f64, - config: &workspace_alert_config::Model, - ) -> (String, String) { - let currency = &billing.currency; - let threshold = config.threshold.to_f64().unwrap_or_default(); - - match config.alert_type.as_str() { - "low_balance" => { - let balance = billing.balance.to_f64().unwrap_or_default(); - ( - format!("[告警] Workspace「{}」余额不足", ws.name), - format!( - "您好,\n\n\ - Workspace「{}」余额已低于告警阈值。\n\n\ - 当前余额: {:.4} {}\n\ - 告警阈值: {:.4} {}\n\n\ - 请及时充值以避免服务中断。\n\n\ - 此邮件由系统自动发送。", - ws.name, balance, currency, threshold, currency - ), - ) - } - "monthly_quota" => { - let quota = billing.monthly_quota.to_f64().unwrap_or_default(); - ( - format!("[告警] Workspace「{}」月度配额即将用尽", ws.name), - format!( - "您好,\n\n\ - Workspace「{}」月度用量已超过告警阈值。\n\n\ - 当前使用: {:.4} {}\n\ - 月度配额: {:.4} {}\n\ - 触发阈值: {:.0}%\n\n\ - 请关注使用情况。\n\n\ - 此邮件由系统自动发送。", - ws.name, month_used, currency, quota, currency, - threshold * 100.0 - ), - ) - } - "usage_surge" => { - let quota = billing.monthly_quota.to_f64().unwrap_or_default(); - ( - format!("[告警] Workspace「{}」使用量激增", ws.name), - format!( - "您好,\n\n\ - Workspace「{}」月度使用量激增,请关注。\n\n\ - 当前使用: {:.4} {}(配额: {:.4} {})\n\n\ - 此邮件由系统自动发送。", - ws.name, month_used, currency, quota, currency - ), - ) - } - _ => ( - format!("[告警] Workspace「{}」", ws.name), - format!( - "您好,\n\nWorkspace「{}」触发告警: {}\n\n此邮件由系统自动发送。", - ws.name, config.alert_type - ), - ), - } - } - - async fn current_month_usage(&self, workspace_id: Uuid) -> f64 { - let now = Utc::now(); - let year = now.year(); - let month = now.month(); - let month_start = chrono::NaiveDate::from_ymd_opt(year, month, 1) - .and_then(|d| d.and_hms_opt(0, 0, 0)) - .map(|d| chrono::TimeZone::from_utc_datetime(&chrono::Utc, &d)) - .unwrap_or(now); - let next_month_date = if month == 12 { - chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1) - } else { - chrono::NaiveDate::from_ymd_opt(year, month + 1, 1) - } - .and_then(|d| d.and_hms_opt(0, 0, 0)) - .map(|d| chrono::TimeZone::from_utc_datetime(&chrono::Utc, &d)) - .unwrap_or(now); - - let month_used: f64 = workspace_billing_history::Entity::find() - .filter(workspace_billing_history::Column::WorkspaceId.eq(workspace_id)) - .filter(workspace_billing_history::Column::Reason.like("ai_usage%")) - .filter(workspace_billing_history::Column::CreatedAt.gte(month_start)) - .filter(workspace_billing_history::Column::CreatedAt.lt(next_month_date)) - .all(&self.db) - .await - .ok() - .unwrap_or_default() - .into_iter() - .map(|r| r.amount.to_f64().unwrap_or_default()) - .sum(); - -month_used - } - - /// Get email addresses for workspace owners and admins who have email notifications enabled. - async fn alert_recipients(&self, workspace_id: Uuid) -> Vec { - let members = workspace_membership::Entity::find() - .filter(workspace_membership::Column::WorkspaceId.eq(workspace_id)) - .filter( - workspace_membership::Column::Role - .is_in(["owner", "admin"]), - ) - .filter(workspace_membership::Column::Status.eq("active")) - .all(&self.db) - .await - .ok() - .unwrap_or_default(); - - let member_ids: Vec = members.iter().map(|m| m.user_id).collect(); - - let notifications: std::collections::HashMap = if !member_ids.is_empty() { - models::users::user_notification::Entity::find() - .filter(models::users::user_notification::Column::User.is_in(member_ids.clone())) - .all(&self.db) - .await - .unwrap_or_default() - .into_iter() - .map(|n| (n.user, n.email_enabled)) - .collect() - } else { - std::collections::HashMap::new() - }; - - let emails_map: std::collections::HashMap = if !member_ids.is_empty() { - models::users::user_email::Entity::find() - .filter(models::users::user_email::Column::User.is_in(member_ids.clone())) - .all(&self.db) - .await - .unwrap_or_default() - .into_iter() - .filter_map(|e| Some((e.user, e.email))) - .collect() - } else { - std::collections::HashMap::new() - }; - - let mut emails = Vec::new(); - for member in members { - let notif_enabled = notifications.get(&member.user_id).copied().unwrap_or(true); - if !notif_enabled { - continue; - } - if let Some(email) = emails_map.get(&member.user_id) { - emails.push(email.clone()); - } - } - emails - } - - /// Spawn the background billing alert checker task. - /// Runs every ALERT_CHECK_INTERVAL seconds. - pub fn start_billing_alert_task(self) -> tokio::task::JoinHandle<()> { - const ALERT_CHECK_INTERVAL: u64 = 30 * 60; // 30 minutes - tokio::spawn(async move { - let mut tick = interval(Duration::from_secs(ALERT_CHECK_INTERVAL)); - loop { - tick.tick().await; - let result = self.check_billing_alerts().await; - if result.alerts_sent > 0 { - tracing::info!( - workspaces_checked = result.workspaces_checked, - alerts_sent = result.alerts_sent, - "billing_alerts_sent" - ); - } - } - }) - } -} diff --git a/libs/service/workspace/billing.rs b/libs/service/workspace/billing.rs deleted file mode 100644 index 7237ca5..0000000 --- a/libs/service/workspace/billing.rs +++ /dev/null @@ -1,280 +0,0 @@ -use crate::AppService; -use crate::error::AppError; -use chrono::{DateTime, Datelike, NaiveDate, Utc}; -use models::Decimal; -use models::workspaces::{workspace_billing, workspace_billing_history, workspace_membership}; -use sea_orm::sea_query::prelude::rust_decimal::prelude::ToPrimitive; -use sea_orm::*; -use serde::{Deserialize, Serialize}; -use session::Session; -use utoipa::{IntoParams, ToSchema}; -use uuid::Uuid; - -/// Default monthly AI quota for workspace (shared across all its projects). -const DEFAULT_MONTHLY_QUOTA: f64 = 100.0; - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct WorkspaceBillingCurrentResponse { - pub workspace_id: Uuid, - pub currency: String, - pub monthly_quota: f64, - pub balance: f64, - pub total_spent: f64, - pub month_used: f64, - pub cycle_start_utc: DateTime, - pub cycle_end_utc: DateTime, - pub updated_at: DateTime, - pub created_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoParams)] -pub struct WorkspaceBillingHistoryQuery { - pub page: Option, - pub per_page: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct WorkspaceBillingHistoryItem { - pub uid: Uuid, - pub workspace_id: Uuid, - pub user_id: Option, - pub amount: f64, - pub currency: String, - pub reason: String, - pub extra: Option, - pub created_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct WorkspaceBillingHistoryResponse { - pub page: u64, - pub per_page: u64, - pub total: u64, - pub list: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -pub struct WorkspaceBillingAddCreditParams { - pub amount: f64, - pub reason: Option, -} - -impl AppService { - /// Get current workspace billing info. - pub async fn workspace_billing_current( - &self, - ctx: &Session, - workspace_slug: String, - ) -> Result { - let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - let ws = self - .utils_find_workspace_by_slug(workspace_slug.clone()) - .await?; - let _ = workspace_membership::Entity::find() - .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_membership::Column::UserId.eq(user_uid)) - .filter(workspace_membership::Column::Status.eq("active")) - .one(&self.db) - .await? - .ok_or(AppError::NotWorkspaceMember)?; - - let billing = self.ensure_workspace_billing(ws.id, Some(user_uid)).await?; - let now_utc = Utc::now(); - let (month_start, next_month_start) = utc_month_bounds(now_utc)?; - - let month_used = workspace_billing_history::Entity::find() - .filter(workspace_billing_history::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_billing_history::Column::Reason.like("ai_usage%")) - .filter(workspace_billing_history::Column::CreatedAt.gte(month_start)) - .filter(workspace_billing_history::Column::CreatedAt.lt(next_month_start)) - .all(&self.db) - .await? - .into_iter() - .map(|m| m.amount.to_f64().unwrap_or_default()) - .sum::(); - let month_used = -month_used; - - Ok(WorkspaceBillingCurrentResponse { - workspace_id: ws.id, - currency: billing.currency.clone(), - monthly_quota: billing - .monthly_quota - .to_f64() - .unwrap_or(DEFAULT_MONTHLY_QUOTA), - balance: billing.balance.to_f64().unwrap_or_default(), - total_spent: billing.total_spent.to_f64().unwrap_or_default(), - month_used, - cycle_start_utc: month_start, - cycle_end_utc: next_month_start, - updated_at: billing.updated_at, - created_at: billing.created_at, - }) - } - - /// Get workspace billing history. - pub async fn workspace_billing_history( - &self, - ctx: &Session, - workspace_slug: String, - query: WorkspaceBillingHistoryQuery, - ) -> Result { - let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - let ws = self - .utils_find_workspace_by_slug(workspace_slug.clone()) - .await?; - let _ = workspace_membership::Entity::find() - .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_membership::Column::UserId.eq(user_uid)) - .filter(workspace_membership::Column::Status.eq("active")) - .one(&self.db) - .await? - .ok_or(AppError::NotWorkspaceMember)?; - - let page = std::cmp::max(query.page.unwrap_or(1), 1); - let per_page = query.per_page.unwrap_or(20).clamp(1, 200); - - self.ensure_workspace_billing(ws.id, Some(user_uid)).await?; - - let paginator = workspace_billing_history::Entity::find() - .filter(workspace_billing_history::Column::WorkspaceId.eq(ws.id)) - .order_by_desc(workspace_billing_history::Column::CreatedAt) - .paginate(&self.db, per_page); - let total = paginator.num_items().await?; - let rows = paginator.fetch_page(page - 1).await?; - - let list = rows - .into_iter() - .map(|x| WorkspaceBillingHistoryItem { - uid: x.uid, - workspace_id: x.workspace_id, - user_id: x.user_id, - amount: x.amount.to_f64().unwrap_or_default(), - currency: x.currency, - reason: x.reason, - extra: x.extra.map(|v| v.into()), - created_at: x.created_at, - }) - .collect(); - - Ok(WorkspaceBillingHistoryResponse { - page, - per_page, - total, - list, - }) - } - - /// Add credit to workspace billing (admin action). - pub async fn workspace_billing_add_credit( - &self, - ctx: &Session, - workspace_slug: String, - params: WorkspaceBillingAddCreditParams, - ) -> Result { - let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - let ws = self - .utils_find_workspace_by_slug(workspace_slug.clone()) - .await?; - let _ = workspace_membership::Entity::find() - .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_membership::Column::UserId.eq(user_uid)) - .filter(workspace_membership::Column::Status.eq("active")) - .one(&self.db) - .await? - .ok_or(AppError::NotWorkspaceMember)?; - - if params.amount <= 0.0 { - return Err(AppError::BadRequest("Amount must be positive".to_string())); - } - - let billing = self.ensure_workspace_billing(ws.id, Some(user_uid)).await?; - let now_utc = Utc::now(); - let amount_dec = - Decimal::from_f64_retain(params.amount).unwrap_or(Decimal::ZERO); - let new_balance = billing.balance + amount_dec; - let currency = billing.currency.clone(); - - let mut updated: workspace_billing::ActiveModel = billing.into(); - updated.balance = Set(new_balance); - updated.updated_at = Set(now_utc); - updated.update(&self.db).await?; - - let _ = workspace_billing_history::ActiveModel { - uid: Set(Uuid::now_v7()), - workspace_id: Set(ws.id), - user_id: Set(Some(user_uid)), - amount: Set(Decimal::from_f64_retain(params.amount).unwrap_or(Decimal::ZERO)), - currency: Set(currency), - reason: Set(params.reason.unwrap_or_else(|| "credit_added".to_string())), - extra: Set(None), - created_at: Set(now_utc), - } - .insert(&self.db) - .await; - - self.workspace_billing_current(ctx, workspace_slug).await - } - - /// Ensure workspace billing record exists (create with defaults if not). - pub async fn ensure_workspace_billing( - &self, - workspace_id: Uuid, - user_uid: Option, - ) -> Result { - if let Some(billing) = workspace_billing::Entity::find_by_id(workspace_id) - .one(&self.db) - .await? - { - return Ok(billing); - } - - let now_utc = Utc::now(); - // Only first workspace per user gets initial budget ($30) - let initial_balance = if let Some(uid) = user_uid { - let existing_workspaces = workspace_membership::Entity::find() - .filter(workspace_membership::Column::UserId.eq(uid)) - .filter(workspace_membership::Column::Status.eq("active")) - .all(&self.db) - .await?; - if existing_workspaces.len() <= 1 { - Decimal::from_f64_retain(30.0).unwrap_or(Decimal::ZERO) - } else { - Decimal::ZERO - } - } else { - Decimal::ZERO - }; - - let created = workspace_billing::ActiveModel { - workspace_id: Set(workspace_id), - balance: Set(initial_balance), - currency: Set("USD".to_string()), - monthly_quota: Set( - Decimal::from_f64_retain(DEFAULT_MONTHLY_QUOTA).unwrap_or(Decimal::ZERO) - ), - total_spent: Set(Decimal::ZERO), - updated_at: Set(now_utc), - created_at: Set(now_utc), - }; - Ok(created.insert(&self.db).await?) - } -} - -fn utc_month_bounds(now_utc: DateTime) -> Result<(DateTime, DateTime), AppError> { - let year = now_utc.year(); - let month = now_utc.month(); - let month_start = NaiveDate::from_ymd_opt(year, month, 1) - .and_then(|d| d.and_hms_opt(0, 0, 0)) - .map(|d| chrono::TimeZone::from_utc_datetime(&Utc, &d)) - .ok_or_else(|| AppError::InternalServerError("Invalid UTC month start".to_string()))?; - let (next_year, next_month) = if month == 12 { - (year + 1, 1) - } else { - (year, month + 1) - }; - let next_month_start = NaiveDate::from_ymd_opt(next_year, next_month, 1) - .and_then(|d| d.and_hms_opt(0, 0, 0)) - .map(|d| chrono::TimeZone::from_utc_datetime(&Utc, &d)) - .ok_or_else(|| AppError::InternalServerError("Invalid UTC next month start".to_string()))?; - Ok((month_start, next_month_start)) -} diff --git a/libs/service/workspace/info.rs b/libs/service/workspace/info.rs deleted file mode 100644 index 8bb526f..0000000 --- a/libs/service/workspace/info.rs +++ /dev/null @@ -1,328 +0,0 @@ -use crate::AppService; -use crate::error::AppError; -use chrono::{DateTime, Utc}; -use models::projects::project; -use models::projects::project_activity; -use models::users::user; -use models::workspaces::workspace; -use models::workspaces::workspace_membership; -use sea_orm::*; -use serde::{Deserialize, Serialize}; -use session::Session; -use utoipa::IntoParams; - -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct WorkspaceInfoResponse { - pub id: Uuid, - pub slug: String, - pub name: String, - pub description: Option, - pub avatar_url: Option, - pub plan: String, - pub billing_email: Option, - pub member_count: i64, - pub my_role: Option, - pub created_at: chrono::DateTime, - pub updated_at: chrono::DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct WorkspaceListItem { - pub id: Uuid, - pub slug: String, - pub name: String, - pub description: Option, - pub avatar_url: Option, - pub plan: String, - pub my_role: String, - pub created_at: chrono::DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct WorkspaceListResponse { - pub workspaces: Vec, - pub total: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema, IntoParams)] -pub struct WorkspaceProjectsQuery { - pub page: Option, - pub per_page: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct WorkspaceProjectItem { - pub uid: Uuid, - pub name: String, - pub display_name: String, - pub avatar_url: Option, - pub description: Option, - pub is_public: bool, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct WorkspaceProjectsResponse { - pub projects: Vec, - pub total: u64, - pub page: u64, - pub per_page: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct WorkspaceActivityItem { - pub id: i64, - pub project_name: String, - pub event_type: String, - pub title: String, - pub content: Option, - pub actor_name: String, - pub actor_avatar: Option, - pub created_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct WorkspaceStatsResponse { - pub project_count: i64, - pub member_count: i64, - pub my_role: Option, - pub recent_activities: Vec, -} - -impl AppService { - /// Get workspace info by slug. Returns error if user is not a member. - pub async fn workspace_info( - &self, - ctx: &Session, - slug: String, - ) -> Result { - let user_uid = ctx.user(); - let ws = self.utils_find_workspace_by_slug(slug.clone()).await?; - - let my_role = if let Some(uid) = user_uid { - workspace_membership::Entity::find() - .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_membership::Column::UserId.eq(uid)) - .filter(workspace_membership::Column::Status.eq("active")) - .one(&self.db) - .await? - .map(|m| m.role) - } else { - None - }; - - let member_count = workspace_membership::Entity::find() - .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_membership::Column::Status.eq("active")) - .count(&self.db) - .await?; - - Ok(WorkspaceInfoResponse { - id: ws.id, - slug: ws.slug, - name: ws.name, - description: ws.description, - avatar_url: ws.avatar_url, - plan: ws.plan, - billing_email: ws.billing_email, - member_count: member_count as i64, - my_role, - created_at: ws.created_at, - updated_at: ws.updated_at, - }) - } - - /// List all workspaces the current user is a member of. - pub async fn workspace_list(&self, ctx: &Session) -> Result { - let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - - let memberships = workspace_membership::Entity::find() - .filter(workspace_membership::Column::UserId.eq(user_uid)) - .filter(workspace_membership::Column::Status.eq("active")) - .all(&self.db) - .await?; - - let workspace_ids: Vec = memberships.iter().map(|m| m.workspace_id).collect(); - let total = workspace_ids.len() as u64; - - let workspaces = workspace::Entity::find() - .filter(workspace::Column::Id.is_in(workspace_ids.clone())) - .filter(workspace::Column::DeletedAt.is_null()) - .all(&self.db) - .await?; - - let items: Vec = workspaces - .into_iter() - .filter_map(|ws| { - let membership = memberships - .iter() - .find(|m| m.workspace_id == ws.id) - .cloned()?; - Some(WorkspaceListItem { - id: ws.id, - slug: ws.slug, - name: ws.name, - description: ws.description, - avatar_url: ws.avatar_url, - plan: ws.plan, - my_role: membership.role, - created_at: ws.created_at, - }) - }) - .collect(); - - Ok(WorkspaceListResponse { - workspaces: items, - total, - }) - } - - /// List projects belonging to a workspace. - pub async fn workspace_projects( - &self, - ctx: &Session, - workspace_slug: String, - query: WorkspaceProjectsQuery, - ) -> Result { - let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - let ws = self.utils_find_workspace_by_slug(workspace_slug).await?; - let _ = workspace_membership::Entity::find() - .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_membership::Column::UserId.eq(user_uid)) - .filter(workspace_membership::Column::Status.eq("active")) - .one(&self.db) - .await? - .ok_or(AppError::NotWorkspaceMember)?; - - let page = std::cmp::max(query.page.unwrap_or(1), 1); - let per_page = query.per_page.unwrap_or(20).clamp(1, 200); - - let paginator = project::Entity::find() - .filter(project::Column::WorkspaceId.eq(ws.id)) - .order_by_desc(project::Column::CreatedAt) - .paginate(&self.db, per_page); - let total = paginator.num_items().await?; - let rows = paginator.fetch_page(page - 1).await?; - - let projects = rows - .into_iter() - .map(|p| WorkspaceProjectItem { - uid: p.id, - name: p.name, - display_name: p.display_name, - avatar_url: p.avatar_url, - description: p.description, - is_public: p.is_public, - created_at: p.created_at, - updated_at: p.updated_at, - }) - .collect(); - - Ok(WorkspaceProjectsResponse { - projects, - total, - page, - per_page, - }) - } - - /// Get workspace stats: project count, member count, recent activities. - pub async fn workspace_stats( - &self, - ctx: &Session, - workspace_slug: String, - ) -> Result { - let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - let ws = self.utils_find_workspace_by_slug(workspace_slug).await?; - - let membership = workspace_membership::Entity::find() - .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_membership::Column::UserId.eq(user_uid)) - .filter(workspace_membership::Column::Status.eq("active")) - .one(&self.db) - .await?; - - let member_count = workspace_membership::Entity::find() - .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_membership::Column::Status.eq("active")) - .count(&self.db) - .await?; - - let project_count = project::Entity::find() - .filter(project::Column::WorkspaceId.eq(ws.id)) - .count(&self.db) - .await?; - - // Get recent activities across all workspace projects - let workspace_projects = project::Entity::find() - .filter(project::Column::WorkspaceId.eq(ws.id)) - .all(&self.db) - .await?; - let project_ids: Vec = workspace_projects.iter().map(|p| p.id).collect(); - let project_names: std::collections::HashMap = workspace_projects - .into_iter() - .map(|p| (p.id, p.name)) - .collect(); - - let recent_activities = if project_ids.is_empty() { - Vec::new() - } else { - let activities = project_activity::Entity::find() - .filter(project_activity::Column::Project.is_in(project_ids.clone())) - .filter(project_activity::Column::IsPrivate.eq(false)) - .order_by_desc(project_activity::Column::CreatedAt) - .limit(10) - .all(&self.db) - .await?; - - // Collect actor IDs - let actor_ids: Vec = activities.iter().map(|a| a.actor).collect(); - let actors = user::Entity::find() - .filter(user::Column::Uid.is_in(actor_ids)) - .all(&self.db) - .await?; - let actor_map: std::collections::HashMap)> = actors - .into_iter() - .map(|u| { - ( - u.uid, - ( - u.display_name.or(Some(u.username)).unwrap_or_default(), - u.avatar_url, - ), - ) - }) - .collect(); - - activities - .into_iter() - .map(|a| { - let (actor_name, actor_avatar) = actor_map - .get(&a.actor) - .cloned() - .unwrap_or_else(|| ("Unknown".to_string(), None)); - WorkspaceActivityItem { - id: a.id, - project_name: project_names.get(&a.project).cloned().unwrap_or_default(), - event_type: a.event_type, - title: a.title, - content: a.content, - actor_name, - actor_avatar, - created_at: a.created_at, - } - }) - .collect() - }; - - Ok(WorkspaceStatsResponse { - project_count: project_count as i64, - member_count: member_count as i64, - my_role: membership.map(|m| m.role), - recent_activities, - }) - } -} - -use uuid::Uuid; diff --git a/libs/service/workspace/init.rs b/libs/service/workspace/init.rs deleted file mode 100644 index 7c66063..0000000 --- a/libs/service/workspace/init.rs +++ /dev/null @@ -1,121 +0,0 @@ -use crate::AppService; -use crate::error::AppError; -use chrono::Utc; -use models::Decimal; -use models::WorkspaceRole; -use models::workspaces::workspace; -use models::workspaces::workspace_billing; -use models::workspaces::workspace_membership; -use sea_orm::*; -use serde::{Deserialize, Serialize}; -use session::Session; - -#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] -pub struct WorkspaceInitParams { - pub slug: String, - pub name: String, - pub description: Option, -} - -impl AppService { - pub async fn workspace_init( - &self, - ctx: &Session, - params: WorkspaceInitParams, - ) -> Result { - let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - let user = self.utils_find_user_by_uid(user_uid).await?; - - // Validate slug format: alphanumeric, dashes, underscores - if !params - .slug - .chars() - .all(|c| c.is_alphanumeric() || c == '-' || c == '_') - { - return Err(AppError::BadRequest( - "Slug must contain only letters, numbers, hyphens and underscores".to_string(), - )); - } - - // Check slug uniqueness - if workspace::Entity::find() - .filter(workspace::Column::Slug.eq(¶ms.slug)) - .filter(workspace::Column::DeletedAt.is_null()) - .one(&self.db) - .await? - .is_some() - { - return Err(AppError::WorkspaceSlugAlreadyExists); - } - - // Check name uniqueness - if workspace::Entity::find() - .filter(workspace::Column::Name.eq(¶ms.name)) - .filter(workspace::Column::DeletedAt.is_null()) - .one(&self.db) - .await? - .is_some() - { - return Err(AppError::WorkspaceNameAlreadyExists); - } - - let txn = self.db.begin().await?; - - let ws = workspace::ActiveModel { - id: Set(Uuid::now_v7()), - slug: Set(params.slug), - name: Set(params.name), - description: Set(params.description), - avatar_url: Set(None), - plan: Set("free".to_string()), - billing_email: Set(None), - stripe_customer_id: Set(None), - stripe_subscription_id: Set(None), - plan_expires_at: Set(None), - deleted_at: Set(None), - created_at: Set(Utc::now()), - updated_at: Set(Utc::now()), - }; - let ws = ws.insert(&txn).await?; - - let membership = workspace_membership::ActiveModel { - id: Default::default(), - workspace_id: Set(ws.id), - user_id: Set(user.uid), - role: Set(WorkspaceRole::Owner.to_string()), - status: Set("active".to_string()), - invited_by: Set(None), - joined_at: Set(Utc::now()), - invite_token: Set(None), - invite_expires_at: Set(None), - }; - membership.insert(&txn).await?; - - // Create billing record — only first workspace gets $30 initial balance - let existing_workspaces = workspace_membership::Entity::find() - .filter(workspace_membership::Column::UserId.eq(user.uid)) - .filter(workspace_membership::Column::Status.eq("active")) - .all(&self.db) - .await?; - let initial_balance = if existing_workspaces.is_empty() { - Decimal::from_f64_retain(30.0).unwrap_or(Decimal::ZERO) - } else { - Decimal::ZERO - }; - let billing = workspace_billing::ActiveModel { - workspace_id: Set(ws.id), - balance: Set(initial_balance), - currency: Set("USD".to_string()), - monthly_quota: Set(Decimal::from_f64_retain(100.0).unwrap_or(Decimal::ZERO)), - total_spent: Set(Decimal::ZERO), - updated_at: Set(Utc::now()), - created_at: Set(Utc::now()), - }; - billing.insert(&txn).await?; - - txn.commit().await?; - Ok(ws) - } -} - -use uuid::Uuid; diff --git a/libs/service/workspace/members.rs b/libs/service/workspace/members.rs deleted file mode 100644 index 4966f23..0000000 --- a/libs/service/workspace/members.rs +++ /dev/null @@ -1,632 +0,0 @@ -use crate::AppService; -use crate::error::AppError; -use chrono::{Duration, Utc}; -use email::EmailMessage; -use models::WorkspaceRole; -use models::users::{user, user_email}; -use models::workspaces::workspace; -use models::workspaces::workspace_membership; -use sea_orm::*; -use serde::{Deserialize, Serialize}; -use session::Session; -use uuid::Uuid; - -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct WorkspaceMemberInfo { - pub user_id: Uuid, - pub username: String, - pub display_name: Option, - pub avatar_url: Option, - pub role: String, - pub joined_at: chrono::DateTime, - /// Username of the person who invited this member. - pub invited_by_username: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct PendingInvitationInfo { - pub user_id: Uuid, - pub username: String, - pub display_name: Option, - pub avatar_url: Option, - pub email: Option, - pub role: String, - pub invited_by_username: Option, - pub invited_at: chrono::DateTime, - pub expires_at: Option>, -} - -/// Invitation received by the current user (workspace invitation for self). -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct MyWorkspaceInvitation { - pub workspace_id: Uuid, - pub workspace_slug: String, - pub workspace_name: String, - pub role: String, - pub invited_by_username: Option, - pub invited_at: chrono::DateTime, - pub expires_at: Option>, -} - -/// Request body for accepting workspace invitation by slug. -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct WorkspaceAcceptBySlugParams { - pub slug: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct WorkspaceMembersResponse { - pub members: Vec, - pub total: u64, - pub page: u64, - pub per_page: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct WorkspaceInviteParams { - pub email: String, - pub role: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct WorkspaceInviteAcceptParams { - pub token: String, -} - -impl AppService { - pub async fn workspace_members( - &self, - ctx: &Session, - workspace_slug: String, - page: Option, - per_page: Option, - ) -> Result { - let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - let ws = self.utils_find_workspace_by_slug(workspace_slug).await?; - - // Check membership - let _ = self - .utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Member]) - .await; - - let page = page.unwrap_or(1); - let per_page = per_page.unwrap_or(20); - - let memberships = workspace_membership::Entity::find() - .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_membership::Column::Status.eq("active")) - .order_by_desc(workspace_membership::Column::JoinedAt) - .paginate(&self.db, per_page) - .fetch_page(page - 1) - .await?; - - let total = workspace_membership::Entity::find() - .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_membership::Column::Status.eq("active")) - .count(&self.db) - .await?; - - let user_ids: Vec = memberships.iter().map(|m| m.user_id).collect(); - let users = user::Entity::find() - .filter(user::Column::Uid.is_in(user_ids)) - .all(&self.db) - .await?; - - // Collect invited_by user IDs - let inviter_ids: Vec = memberships.iter().filter_map(|m| m.invited_by).collect(); - let inviters = if !inviter_ids.is_empty() { - user::Entity::find() - .filter(user::Column::Uid.is_in(inviter_ids)) - .all(&self.db) - .await? - } else { - vec![] - }; - - let members: Vec = memberships - .into_iter() - .filter_map(|m| { - let u = users.iter().find(|u| u.uid == m.user_id)?; - let invited_by_username = m.invited_by.and_then(|uid| { - inviters - .iter() - .find(|i| i.uid == uid) - .map(|i| i.username.clone()) - }); - Some(WorkspaceMemberInfo { - user_id: u.uid, - username: u.username.clone(), - display_name: u.display_name.clone(), - avatar_url: u.avatar_url.clone(), - role: m.role, - joined_at: m.joined_at, - invited_by_username, - }) - }) - .collect(); - - Ok(WorkspaceMembersResponse { - members, - total, - page, - per_page, - }) - } - - /// List pending (invited but not accepted) memberships for a workspace. - pub async fn workspace_pending_invitations( - &self, - ctx: &Session, - workspace_slug: String, - ) -> Result, AppError> { - let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - let ws = self.utils_find_workspace_by_slug(workspace_slug).await?; - - self.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Admin]) - .await?; - - let pending = workspace_membership::Entity::find() - .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_membership::Column::Status.eq("pending")) - .order_by_desc(workspace_membership::Column::JoinedAt) - .all(&self.db) - .await?; - - let user_ids: Vec = pending.iter().map(|m| m.user_id).collect(); - let users = if !user_ids.is_empty() { - user::Entity::find() - .filter(user::Column::Uid.is_in(user_ids.clone())) - .all(&self.db) - .await? - } else { - vec![] - }; - - // Get email addresses for invited users - let emails: Vec<(Uuid, String)> = user_email::Entity::find() - .filter(user_email::Column::User.is_in(user_ids)) - .all(&self.db) - .await? - .into_iter() - .map(|e| (e.user, e.email)) - .collect(); - - // Get inviter usernames - let inviter_ids: Vec = pending.iter().filter_map(|m| m.invited_by).collect(); - let inviters = if !inviter_ids.is_empty() { - user::Entity::find() - .filter(user::Column::Uid.is_in(inviter_ids)) - .all(&self.db) - .await? - } else { - vec![] - }; - - let invitations: Vec = pending - .into_iter() - .filter_map(|m| { - let u = users.iter().find(|u| u.uid == m.user_id)?; - let email = emails - .iter() - .find(|(uid, _)| *uid == m.user_id) - .map(|(_, e)| e.clone()); - let invited_by_username = m.invited_by.and_then(|uid| { - inviters - .iter() - .find(|i| i.uid == uid) - .map(|i| i.username.clone()) - }); - Some(PendingInvitationInfo { - user_id: u.uid, - username: u.username.clone(), - display_name: u.display_name.clone(), - avatar_url: u.avatar_url.clone(), - email, - role: m.role, - invited_by_username, - invited_at: m.joined_at, - expires_at: m.invite_expires_at, - }) - }) - .collect(); - - Ok(invitations) - } - - /// Cancel a pending invitation (remove the pending membership record). - pub async fn workspace_cancel_invitation( - &self, - ctx: &Session, - workspace_slug: String, - target_user_id: Uuid, - ) -> Result<(), AppError> { - let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - let ws = self.utils_find_workspace_by_slug(workspace_slug).await?; - - self.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Admin]) - .await?; - - let deleted = workspace_membership::Entity::delete_many() - .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_membership::Column::UserId.eq(target_user_id)) - .filter(workspace_membership::Column::Status.eq("pending")) - .exec(&self.db) - .await?; - - if deleted.rows_affected == 0 { - return Err(AppError::NotFound("Invitation not found".to_string())); - } - - Ok(()) - } - - pub async fn workspace_invite_member( - &self, - ctx: &Session, - workspace_slug: String, - params: WorkspaceInviteParams, - ) -> Result<(), AppError> { - let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - let ws = self - .utils_find_workspace_by_slug(workspace_slug.clone()) - .await?; - - // Only owner/admin can invite - self.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Admin]) - .await?; - - let inviter = self.utils_find_user_by_uid(user_uid).await?; - - // Find target user by email - let target_email = user_email::Entity::find() - .filter(user_email::Column::Email.eq(¶ms.email)) - .one(&self.db) - .await? - .ok_or(AppError::UserNotFound)?; - - let target_user = self.utils_find_user_by_uid(target_email.user).await?; - - // Check if already a member - if workspace_membership::Entity::find() - .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_membership::Column::UserId.eq(target_user.uid)) - .filter(workspace_membership::Column::Status.eq("active")) - .one(&self.db) - .await? - .is_some() - { - return Err(AppError::BadRequest("User is already a member".to_string())); - } - - // Generate invite token - let token = generate_invite_token(); - let expires_at = Utc::now() + Duration::days(7); - - // Create or update pending membership - let existing: Option = workspace_membership::Entity::find() - .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_membership::Column::UserId.eq(target_user.uid)) - .one(&self.db) - .await?; - - let txn = self.db.begin().await?; - - match existing { - Some(m) => { - let mut m: workspace_membership::ActiveModel = m.into(); - m.invite_token = Set(Some(token.clone())); - m.invite_expires_at = Set(Some(expires_at)); - m.invited_by = Set(Some(user_uid)); - m.role = Set(params - .role - .unwrap_or_else(|| WorkspaceRole::Member.to_string())); - m.status = Set("pending".to_string()); - m.update(&txn).await?; - } - None => { - let m = workspace_membership::ActiveModel { - id: Default::default(), - workspace_id: Set(ws.id), - user_id: Set(target_user.uid), - role: Set(params - .role - .unwrap_or_else(|| WorkspaceRole::Member.to_string())), - status: Set("pending".to_string()), - invited_by: Set(Some(user_uid)), - joined_at: Set(Utc::now()), - invite_token: Set(Some(token.clone())), - invite_expires_at: Set(Some(expires_at)), - }; - m.insert(&txn).await?; - } - } - - txn.commit().await?; - - // Send invitation email - let domain = self - .config - .main_domain() - .map_err(|_| AppError::DoMainNotSet)?; - - let invite_link = format!( - "{}/auth/accept-workspace-invite?token={}", - domain, - token.clone() - ); - - let envelope = EmailMessage { - to: target_email.email.clone(), - subject: format!("You've been invited to join {}", ws.name), - body: format!( - "Hello {},\n\n\ - {} has invited you to join the workspace \"{}\".\n\n\ - Click the link below to accept the invitation:\n\ - {}\n\n\ - This invitation expires in 7 days.\n\n\ - Best regards,\n\ - GitDataAI Team", - target_user.username, inviter.username, ws.name, invite_link - ), - }; - - self.email.send(envelope).await.map_err(|e| { - AppError::InternalServerError(format!("Failed to send invitation email: {}", e)) - })?; - - // Send in-app notification + push notification to the invitee - self.send_push_to_user( - target_user.uid, - crate::push::PushPayload { - title: format!("Workspace invitation: {}", ws.name), - body: format!("{} invited you to join the workspace \"{}\"", inviter.username, ws.name), - url: Some(format!("/workspaces/{}/invitations", ws.slug)), - icon: None, - }, - ); - - let _ = self - .room - .notification_create(room::NotificationCreateRequest { - notification_type: room::NotificationType::WorkspaceInvitation, - user_id: target_user.uid, - title: format!("{} invited you to join \"{}\"", inviter.username, ws.name), - content: None, - room_id: None, - project_id: Default::default(), // workspace invitations don't have a project_id - related_message_id: None, - related_user_id: Some(user_uid), - related_room_id: None, - metadata: Some(serde_json::json!({ - "workspace_id": ws.id, - "workspace_name": ws.name, - "workspace_slug": ws.slug, - "inviter_uid": user_uid, - })), - expires_at: None, - }) - .await; - - Ok(()) - } - - pub async fn workspace_accept_invitation( - &self, - ctx: &Session, - params: WorkspaceInviteAcceptParams, - ) -> Result { - let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - - let membership = workspace_membership::Entity::find() - .filter(workspace_membership::Column::InviteToken.eq(¶ms.token)) - .one(&self.db) - .await? - .ok_or(AppError::WorkspaceInviteTokenInvalid)?; - - if membership.user_id != user_uid { - return Err(AppError::WorkspaceInviteTokenInvalid); - } - - if membership.status == "active" { - return Err(AppError::WorkspaceInviteAlreadyAccepted); - } - - if let Some(expires_at) = membership.invite_expires_at { - if Utc::now() > expires_at { - return Err(AppError::WorkspaceInviteExpired); - } - } - - let ws_id = membership.workspace_id; - let mut m: workspace_membership::ActiveModel = membership.into(); - m.status = Set("active".to_string()); - m.invite_token = Set(None); - m.invite_expires_at = Set(None); - m.update(&self.db).await?; - - self.utils_find_workspace_by_id(ws_id).await - } - - /// List all pending workspace invitations for the current user (where user is invitee). - pub async fn workspace_my_pending_invitations( - &self, - ctx: &Session, - ) -> Result, AppError> { - let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - - let pending = workspace_membership::Entity::find() - .filter(workspace_membership::Column::UserId.eq(user_uid)) - .filter(workspace_membership::Column::Status.eq("pending")) - .order_by_desc(workspace_membership::Column::JoinedAt) - .all(&self.db) - .await?; - - if pending.is_empty() { - return Ok(vec![]); - } - - // Fetch workspace info - let ws_ids: Vec = pending.iter().map(|m| m.workspace_id).collect(); - let workspaces: std::collections::HashMap = workspace::Entity::find() - .filter(workspace::Column::Id.is_in(ws_ids.clone())) - .all(&self.db) - .await? - .into_iter() - .map(|w| (w.id, w)) - .collect(); - - // Fetch inviter usernames - let inviter_ids: Vec = pending.iter().filter_map(|m| m.invited_by).collect(); - let inviters: std::collections::HashMap = if !inviter_ids.is_empty() { - user::Entity::find() - .filter(user::Column::Uid.is_in(inviter_ids)) - .all(&self.db) - .await? - .into_iter() - .map(|u| (u.uid, u.username)) - .collect() - } else { - std::collections::HashMap::new() - }; - - let invitations: Vec = pending - .into_iter() - .filter_map(|m| { - let ws = workspaces.get(&m.workspace_id)?; - let invited_by_username = m.invited_by.and_then(|uid| inviters.get(&uid).cloned()); - Some(MyWorkspaceInvitation { - workspace_id: m.workspace_id, - workspace_slug: ws.slug.clone(), - workspace_name: ws.name.clone(), - role: m.role, - invited_by_username, - invited_at: m.joined_at, - expires_at: m.invite_expires_at, - }) - }) - .collect(); - - Ok(invitations) - } - - /// Accept a workspace invitation by slug (for the current user). - pub async fn workspace_accept_invitation_by_slug( - &self, - ctx: &Session, - params: WorkspaceAcceptBySlugParams, - ) -> Result { - let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - let ws = self.utils_find_workspace_by_slug(params.slug.clone()).await?; - - let membership = workspace_membership::Entity::find() - .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_membership::Column::UserId.eq(user_uid)) - .one(&self.db) - .await? - .ok_or(AppError::NotFound( - "No pending invitation found for this workspace".to_string(), - ))?; - - if membership.status == "active" { - return Err(AppError::WorkspaceInviteAlreadyAccepted); - } - - if let Some(expires_at) = membership.invite_expires_at { - if Utc::now() > expires_at { - return Err(AppError::WorkspaceInviteExpired); - } - } - - let mut m: workspace_membership::ActiveModel = membership.into(); - m.status = Set("active".to_string()); - m.invite_token = Set(None); - m.invite_expires_at = Set(None); - m.update(&self.db).await?; - - Ok(ws) - } - - pub async fn workspace_remove_member( - &self, - ctx: &Session, - workspace_slug: String, - target_user_id: Uuid, - ) -> Result<(), AppError> { - let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - let ws = self.utils_find_workspace_by_slug(workspace_slug).await?; - - // Only owner/admin can remove members - self.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Admin]) - .await?; - - // Cannot remove owner - let target_membership = workspace_membership::Entity::find() - .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_membership::Column::UserId.eq(target_user_id)) - .one(&self.db) - .await? - .ok_or(AppError::NotWorkspaceMember)?; - - if target_membership.role == WorkspaceRole::Owner.to_string() { - return Err(AppError::BadRequest( - "Cannot remove workspace owner".to_string(), - )); - } - - workspace_membership::Entity::delete_many() - .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_membership::Column::UserId.eq(target_user_id)) - .exec(&self.db) - .await?; - - Ok(()) - } - - pub async fn workspace_update_member_role( - &self, - ctx: &Session, - workspace_slug: String, - target_user_id: Uuid, - new_role: String, - ) -> Result<(), AppError> { - let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - let ws = self.utils_find_workspace_by_slug(workspace_slug).await?; - - self.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Admin]) - .await?; - - let target_role: WorkspaceRole = new_role.parse().map_err(|_| AppError::RoleParseError)?; - - let membership = workspace_membership::Entity::find() - .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) - .filter(workspace_membership::Column::UserId.eq(target_user_id)) - .one(&self.db) - .await? - .ok_or(AppError::NotWorkspaceMember)?; - - // Cannot demote owner - if membership.role == WorkspaceRole::Owner.to_string() - && target_role != WorkspaceRole::Owner - { - return Err(AppError::BadRequest( - "Cannot demote workspace owner".to_string(), - )); - } - - let mut m: workspace_membership::ActiveModel = membership.into(); - m.role = Set(new_role); - m.update(&self.db).await?; - Ok(()) - } -} - -fn generate_invite_token() -> String { - use rand::RngExt; - use rand::distr::Alphanumeric; - let token: String = rand::rng() - .sample_iter(Alphanumeric) - .take(64) - .map(char::from) - .collect(); - format!("ws_inv_{}", token) -} diff --git a/libs/service/workspace/mod.rs b/libs/service/workspace/mod.rs deleted file mode 100644 index 287e639..0000000 --- a/libs/service/workspace/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod alert; -pub mod billing; -pub mod info; -pub mod init; -pub mod members; -pub mod settings; diff --git a/libs/service/workspace/settings.rs b/libs/service/workspace/settings.rs deleted file mode 100644 index d0e4268..0000000 --- a/libs/service/workspace/settings.rs +++ /dev/null @@ -1,83 +0,0 @@ -use crate::AppService; -use crate::error::AppError; -use chrono::Utc; -use models::workspaces::{WorkspaceRole, workspace}; -use sea_orm::*; -use serde::{Deserialize, Serialize}; -use session::Session; - -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] -pub struct WorkspaceUpdateParams { - pub name: Option, - pub description: Option, - pub avatar_url: Option, - pub billing_email: Option, -} - -impl AppService { - pub async fn workspace_update( - &self, - ctx: &Session, - workspace_slug: String, - params: WorkspaceUpdateParams, - ) -> Result { - let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - let ws = self - .utils_find_workspace_by_slug(workspace_slug.clone()) - .await?; - - self.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Admin]) - .await?; - - let ws_id = ws.id; - let mut m: workspace::ActiveModel = ws.into(); - - if let Some(name) = params.name { - // Check name uniqueness - if workspace::Entity::find() - .filter(workspace::Column::Name.eq(&name)) - .filter(workspace::Column::DeletedAt.is_null()) - .filter(workspace::Column::Id.ne(ws_id)) - .one(&self.db) - .await? - .is_some() - { - return Err(AppError::WorkspaceNameAlreadyExists); - } - m.name = Set(name); - } - - if let Some(description) = params.description { - m.description = Set(Some(description)); - } - if let Some(avatar_url) = params.avatar_url { - m.avatar_url = Set(Some(avatar_url)); - } - if let Some(billing_email) = params.billing_email { - m.billing_email = Set(Some(billing_email)); - } - - m.updated_at = Set(Utc::now()); - m.update(&self.db).await.map_err(Into::into) - } - - /// Soft-delete a workspace. Only owner can delete. - pub async fn workspace_delete( - &self, - ctx: &Session, - workspace_slug: String, - ) -> Result<(), AppError> { - let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; - let ws = self.utils_find_workspace_by_slug(workspace_slug).await?; - - self.utils_check_workspace_permission(ws.id, user_uid, &[WorkspaceRole::Owner]) - .await?; - - let mut m: workspace::ActiveModel = ws.into(); - m.deleted_at = Set(Some(Utc::now())); - m.updated_at = Set(Utc::now()); - m.update(&self.db).await?; - - Ok(()) - } -}