From dca717be1072543c79be63584668eb0cdac37789 Mon Sep 17 00:00:00 2001 From: zhenyi <434836402@qq.com> Date: Sun, 7 Jun 2026 18:44:01 +0800 Subject: [PATCH] refactor(workspace): pass workspace object instead of id to service methods - Replace workspace_id parameter with Workspace object reference in all workspace service methods - Remove redundant find_workspace_by_id calls that were duplicated in each method - Update all method signatures across approval, audit, billing, branding, core, settings and stats modules - Modify SQL queries to bind ws.id instead of separate workspace_id parameter - Add Workspace import to all affected modules - Adjust method calls in API handlers to pass workspace object instead of id - Consolidate workspace retrieval logic to single location per operation flow --- api/auth/disable_2fa.rs | 2 +- api/auth/enable_2fa.rs | 2 +- api/auth/get_2fa_status.rs | 2 +- api/auth/regenerate_2fa_backup_codes.rs | 2 +- api/auth/verify_2fa.rs | 2 +- api/mod.rs | 1 + api/openapi.rs | 119 +++++++++- api/routes.rs | 7 +- api/workspace/accept_invitation.rs | 45 ++++ api/workspace/add_domain.rs | 44 ++++ api/workspace/add_member.rs | 45 ++++ api/workspace/archive.rs | 33 +++ api/workspace/audit_logs.rs | 50 +++++ api/workspace/create.rs | 39 ++++ api/workspace/create_integration.rs | 44 ++++ api/workspace/create_invitation.rs | 43 ++++ api/workspace/create_webhook.rs | 44 ++++ api/workspace/delete.rs | 33 +++ api/workspace/delete_domain.rs | 40 ++++ api/workspace/delete_integration.rs | 39 ++++ api/workspace/delete_webhook.rs | 39 ++++ api/workspace/get.rs | 34 +++ api/workspace/get_billing.rs | 34 +++ api/workspace/get_branding.rs | 34 +++ api/workspace/get_settings.rs | 33 +++ api/workspace/get_stats.rs | 33 +++ api/workspace/leave.rs | 33 +++ api/workspace/list.rs | 44 ++++ api/workspace/list_approvals.rs | 50 +++++ api/workspace/list_domains.rs | 50 +++++ api/workspace/list_integrations.rs | 50 +++++ api/workspace/list_invitations.rs | 50 +++++ api/workspace/list_members.rs | 50 +++++ api/workspace/list_webhooks.rs | 50 +++++ api/workspace/mod.rs | 211 ++++++++++++++++++ api/workspace/refresh_stats.rs | 36 +++ api/workspace/remove_member.rs | 40 ++++ api/workspace/request_approval.rs | 44 ++++ api/workspace/review_approval.rs | 52 +++++ api/workspace/revoke_invitation.rs | 39 ++++ api/workspace/set_primary_domain.rs | 40 ++++ api/workspace/transfer_owner.rs | 51 +++++ api/workspace/unarchive.rs | 33 +++ api/workspace/update.rs | 45 ++++ api/workspace/update_billing.rs | 44 ++++ api/workspace/update_branding.rs | 44 ++++ api/workspace/update_integration.rs | 47 ++++ api/workspace/update_member_role.rs | 48 ++++ api/workspace/update_settings.rs | 44 ++++ api/workspace/update_webhook.rs | 48 ++++ api/workspace/upload_avatar.rs | 60 +++++ api/workspace/verify_domain.rs | 39 ++++ models/workspaces/workspace.rs | 2 +- models/workspaces/workspace_audit_logs.rs | 2 +- models/workspaces/workspace_billing.rs | 2 +- .../workspaces/workspace_custom_branding.rs | 2 +- models/workspaces/workspace_domains.rs | 2 +- models/workspaces/workspace_integrations.rs | 3 +- models/workspaces/workspace_members.rs | 2 +- .../workspaces/workspace_pending_approvals.rs | 2 +- models/workspaces/workspace_settings.rs | 2 +- models/workspaces/workspace_stats.rs | 2 +- models/workspaces/workspace_webhooks.rs | 2 +- service/workspace/approvals.rs | 17 +- service/workspace/audit.rs | 7 +- service/workspace/billing.rs | 14 +- service/workspace/branding.rs | 14 +- service/workspace/core.rs | 68 ++---- service/workspace/domains.rs | 33 ++- service/workspace/integrations.rs | 24 +- service/workspace/invitations.rs | 20 +- service/workspace/members.rs | 43 ++-- service/workspace/settings.rs | 14 +- service/workspace/stats.rs | 20 +- service/workspace/webhooks.rs | 36 +-- 75 files changed, 2306 insertions(+), 212 deletions(-) create mode 100644 api/workspace/accept_invitation.rs create mode 100644 api/workspace/add_domain.rs create mode 100644 api/workspace/add_member.rs create mode 100644 api/workspace/archive.rs create mode 100644 api/workspace/audit_logs.rs create mode 100644 api/workspace/create.rs create mode 100644 api/workspace/create_integration.rs create mode 100644 api/workspace/create_invitation.rs create mode 100644 api/workspace/create_webhook.rs create mode 100644 api/workspace/delete.rs create mode 100644 api/workspace/delete_domain.rs create mode 100644 api/workspace/delete_integration.rs create mode 100644 api/workspace/delete_webhook.rs create mode 100644 api/workspace/get.rs create mode 100644 api/workspace/get_billing.rs create mode 100644 api/workspace/get_branding.rs create mode 100644 api/workspace/get_settings.rs create mode 100644 api/workspace/get_stats.rs create mode 100644 api/workspace/leave.rs create mode 100644 api/workspace/list.rs create mode 100644 api/workspace/list_approvals.rs create mode 100644 api/workspace/list_domains.rs create mode 100644 api/workspace/list_integrations.rs create mode 100644 api/workspace/list_invitations.rs create mode 100644 api/workspace/list_members.rs create mode 100644 api/workspace/list_webhooks.rs create mode 100644 api/workspace/mod.rs create mode 100644 api/workspace/refresh_stats.rs create mode 100644 api/workspace/remove_member.rs create mode 100644 api/workspace/request_approval.rs create mode 100644 api/workspace/review_approval.rs create mode 100644 api/workspace/revoke_invitation.rs create mode 100644 api/workspace/set_primary_domain.rs create mode 100644 api/workspace/transfer_owner.rs create mode 100644 api/workspace/unarchive.rs create mode 100644 api/workspace/update.rs create mode 100644 api/workspace/update_billing.rs create mode 100644 api/workspace/update_branding.rs create mode 100644 api/workspace/update_integration.rs create mode 100644 api/workspace/update_member_role.rs create mode 100644 api/workspace/update_settings.rs create mode 100644 api/workspace/update_webhook.rs create mode 100644 api/workspace/upload_avatar.rs create mode 100644 api/workspace/verify_domain.rs diff --git a/api/auth/disable_2fa.rs b/api/auth/disable_2fa.rs index 395b524..1caba68 100644 --- a/api/auth/disable_2fa.rs +++ b/api/auth/disable_2fa.rs @@ -9,7 +9,7 @@ use crate::session::Session; #[utoipa::path( post, path = "/api/v1/auth/2fa/disable", - tag = "Auth / 2FA", + tag = "Auth", operation_id = "authDisableTwoFactor", summary = "Disable two-factor authentication", description = "Disable TOTP two-factor authentication for the current signed-in user. This requires verifying both the current password and a valid TOTP code or backup code. password must be encrypted with the current session RSA public key; a successfully verified backup code is consumed.", diff --git a/api/auth/enable_2fa.rs b/api/auth/enable_2fa.rs index a231f18..40a5699 100644 --- a/api/auth/enable_2fa.rs +++ b/api/auth/enable_2fa.rs @@ -9,7 +9,7 @@ use crate::session::Session; #[utoipa::path( post, path = "/api/v1/auth/2fa/enable", - tag = "Auth / 2FA", + tag = "Auth", operation_id = "authPrepareTwoFactorEnable", summary = "Initialize two-factor authentication setup", description = "Generate a new TOTP secret, otpauth QR-code URI, and 10 one-time backup codes for the current signed-in user, and save them in a not-yet-enabled state. Clients must guide the user to scan the QR code and call /auth/2fa/verify with a dynamic code before 2FA is actually enabled. Backup codes are returned in plaintext only once in this response; frontends must remind users to store them securely.", diff --git a/api/auth/get_2fa_status.rs b/api/auth/get_2fa_status.rs index 1c6ccf6..a986d8b 100644 --- a/api/auth/get_2fa_status.rs +++ b/api/auth/get_2fa_status.rs @@ -9,7 +9,7 @@ use crate::session::Session; #[utoipa::path( get, path = "/api/v1/auth/2fa/status", - tag = "Auth / 2FA", + tag = "Auth", operation_id = "authGetTwoFactorStatus", summary = "Get two-factor authentication status", description = "Read the current signed-in user's TOTP two-factor authentication status, including whether it is enabled, the authentication method, and whether backup codes are still available.", diff --git a/api/auth/regenerate_2fa_backup_codes.rs b/api/auth/regenerate_2fa_backup_codes.rs index 507e939..ec6dd06 100644 --- a/api/auth/regenerate_2fa_backup_codes.rs +++ b/api/auth/regenerate_2fa_backup_codes.rs @@ -21,7 +21,7 @@ pub struct Regenerate2FABackupCodesResponse { #[utoipa::path( post, path = "/api/v1/auth/2fa/backup-codes/regenerate", - tag = "Auth / 2FA", + tag = "Auth", operation_id = "authRegenerateTwoFactorBackupCodes", summary = "Regenerate 2FA backup codes", description = "After verifying the current password, generate a new set of backup codes for a user with 2FA enabled and replace the old backup codes. password must be encrypted with the current session RSA public key. Backup codes are returned in plaintext only once in this response; clients must prompt users to store them securely.", diff --git a/api/auth/verify_2fa.rs b/api/auth/verify_2fa.rs index c5acb67..ee643c2 100644 --- a/api/auth/verify_2fa.rs +++ b/api/auth/verify_2fa.rs @@ -9,7 +9,7 @@ use crate::session::Session; #[utoipa::path( post, path = "/api/v1/auth/2fa/verify", - tag = "Auth / 2FA", + tag = "Auth", operation_id = "authVerifyAndEnableTwoFactor", summary = "Verify and enable two-factor authentication", description = "After initializing with /auth/2fa/enable, submit the 6-digit TOTP code generated by the authenticator app. On success, the current user's 2FA status is set to enabled. A small clock drift of one 30-second window before or after is allowed.", diff --git a/api/mod.rs b/api/mod.rs index 13ac344..313b7e7 100644 --- a/api/mod.rs +++ b/api/mod.rs @@ -2,3 +2,4 @@ pub mod auth; pub mod openapi; pub mod response; pub mod routes; +pub mod workspace; diff --git a/api/openapi.rs b/api/openapi.rs index 5ea7bcd..c63cea1 100644 --- a/api/openapi.rs +++ b/api/openapi.rs @@ -5,6 +5,14 @@ use crate::api::auth::regenerate_2fa_backup_codes::{ }; use crate::api::auth::register::RegisterResponse; use crate::api::response::{ApiEmptyResponse, ApiErrorResponse, ApiResponse}; +use crate::api::workspace::accept_invitation::AcceptInvitationRequest; +use crate::api::workspace::review_approval::ReviewApprovalRequest; +use crate::api::workspace::transfer_owner::TransferOwnerRequest; +use crate::models::workspaces::{ + Workspace, WorkspaceAuditLog, WorkspaceBilling, WorkspaceCustomBranding, WorkspaceDomain, + WorkspaceIntegration, WorkspaceInvitation, WorkspaceMember, WorkspacePendingApproval, + WorkspaceSettings, WorkspaceStats, WorkspaceWebhook, +}; use crate::service::auth::captcha::{CaptchaQuery, CaptchaResponse}; use crate::service::auth::email::{EmailChangeRequest, EmailResponse, EmailVerifyRequest}; use crate::service::auth::login::LoginParams; @@ -17,6 +25,16 @@ use crate::service::auth::rsa::RsaResponse; use crate::service::auth::totp::{ Disable2FAParams, Enable2FAResponse, Get2FAStatusResponse, Verify2FAParams, }; +use crate::service::workspace::approvals::RequestApprovalParams; +use crate::service::workspace::billing::UpdateBillingParams; +use crate::service::workspace::branding::UpdateBrandingParams; +use crate::service::workspace::core::{CreateWorkspaceParams, UpdateWorkspaceParams}; +use crate::service::workspace::domains::AddDomainParams; +use crate::service::workspace::integrations::{CreateIntegrationParams, UpdateIntegrationParams}; +use crate::service::workspace::invitations::{CreateInvitationParams, CreateInvitationResponse}; +use crate::service::workspace::members::{AddMemberParams, UpdateMemberRoleParams}; +use crate::service::workspace::settings::UpdateWorkspaceSettingsParams; +use crate::service::workspace::webhooks::{CreateWebhookParams, UpdateWebhookParams}; #[derive(OpenApi)] #[openapi( @@ -27,9 +45,10 @@ use crate::service::auth::totp::{ ), tags( (name = "Auth", description = "Authentication, registration, session and email security endpoints."), - (name = "Auth / 2FA", description = "TOTP two-factor authentication management endpoints.") + (name = "Workspaces", description = "Workspace CRUD, archiving, ownership transfer, and avatar management."), ), paths( + // Auth crate::api::auth::rsa::handle, crate::api::auth::captcha::handle, crate::api::auth::login::handle, @@ -46,7 +65,51 @@ use crate::service::auth::totp::{ crate::api::auth::enable_2fa::handle, crate::api::auth::verify_2fa::handle, crate::api::auth::disable_2fa::handle, - crate::api::auth::regenerate_2fa_backup_codes::handle + crate::api::auth::regenerate_2fa_backup_codes::handle, + // Workspaces + crate::api::workspace::list::handle, + crate::api::workspace::get::handle, + crate::api::workspace::create::handle, + crate::api::workspace::update::handle, + crate::api::workspace::archive::handle, + crate::api::workspace::unarchive::handle, + crate::api::workspace::delete::handle, + crate::api::workspace::transfer_owner::handle, + crate::api::workspace::upload_avatar::handle, + crate::api::workspace::list_members::handle, + crate::api::workspace::add_member::handle, + crate::api::workspace::update_member_role::handle, + crate::api::workspace::remove_member::handle, + crate::api::workspace::leave::handle, + crate::api::workspace::list_invitations::handle, + crate::api::workspace::create_invitation::handle, + crate::api::workspace::revoke_invitation::handle, + crate::api::workspace::accept_invitation::handle, + crate::api::workspace::get_billing::handle, + crate::api::workspace::update_billing::handle, + crate::api::workspace::get_branding::handle, + crate::api::workspace::update_branding::handle, + crate::api::workspace::get_settings::handle, + crate::api::workspace::update_settings::handle, + crate::api::workspace::get_stats::handle, + crate::api::workspace::refresh_stats::handle, + crate::api::workspace::list_integrations::handle, + crate::api::workspace::create_integration::handle, + crate::api::workspace::update_integration::handle, + crate::api::workspace::delete_integration::handle, + crate::api::workspace::list_webhooks::handle, + crate::api::workspace::create_webhook::handle, + crate::api::workspace::update_webhook::handle, + crate::api::workspace::delete_webhook::handle, + crate::api::workspace::list_domains::handle, + crate::api::workspace::add_domain::handle, + crate::api::workspace::verify_domain::handle, + crate::api::workspace::set_primary_domain::handle, + crate::api::workspace::delete_domain::handle, + crate::api::workspace::list_approvals::handle, + crate::api::workspace::request_approval::handle, + crate::api::workspace::review_approval::handle, + crate::api::workspace::audit_logs::handle, ), components(schemas( ApiEmptyResponse, @@ -79,7 +142,57 @@ use crate::service::auth::totp::{ Verify2FAParams, Disable2FAParams, Regenerate2FABackupCodesRequest, - Regenerate2FABackupCodesResponse + Regenerate2FABackupCodesResponse, + ApiResponse, + ApiResponse>, + ApiResponse, + ApiResponse>, + ApiResponse, + ApiResponse>, + ApiResponse, + ApiResponse, + ApiResponse, + ApiResponse, + ApiResponse, + ApiResponse, + ApiResponse>, + ApiResponse, + ApiResponse>, + ApiResponse, + ApiResponse>, + ApiResponse, + ApiResponse>, + ApiResponse>, + Workspace, + CreateWorkspaceParams, + UpdateWorkspaceParams, + TransferOwnerRequest, + WorkspaceMember, + AddMemberParams, + UpdateMemberRoleParams, + WorkspaceInvitation, + CreateInvitationParams, + CreateInvitationResponse, + AcceptInvitationRequest, + WorkspaceBilling, + UpdateBillingParams, + WorkspaceCustomBranding, + UpdateBrandingParams, + WorkspaceSettings, + UpdateWorkspaceSettingsParams, + WorkspaceStats, + WorkspaceIntegration, + CreateIntegrationParams, + UpdateIntegrationParams, + WorkspaceWebhook, + CreateWebhookParams, + UpdateWebhookParams, + WorkspaceDomain, + AddDomainParams, + WorkspacePendingApproval, + RequestApprovalParams, + ReviewApprovalRequest, + WorkspaceAuditLog, )) )] pub struct OpenApiDoc; diff --git a/api/routes.rs b/api/routes.rs index fbc56a3..363f162 100644 --- a/api/routes.rs +++ b/api/routes.rs @@ -2,7 +2,12 @@ use actix_web::web; use actix_web::web::scope; use crate::api::auth; +use crate::api::workspace; pub fn init_routes(cfg: &mut web::ServiceConfig) { - cfg.service(scope("/api/v1").configure(auth::configure)); + cfg.service( + scope("/api/v1") + .configure(auth::configure) + .configure(workspace::configure), + ); } diff --git a/api/workspace/accept_invitation.rs b/api/workspace/accept_invitation.rs new file mode 100644 index 0000000..7597434 --- /dev/null +++ b/api/workspace/accept_invitation.rs @@ -0,0 +1,45 @@ +use actix_web::{HttpResponse, web}; +use serde::{Deserialize, Serialize}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceInvitation; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Deserialize, Serialize, utoipa::ToSchema)] +pub struct AcceptInvitationRequest { + /// The plaintext invitation token from the email link. + pub token: String, +} + +#[utoipa::path( + post, + path = "/api/v1/workspaces/invitations/accept", + tag = "Workspaces", + operation_id = "workspaceAcceptInvitation", + summary = "Accept an invitation", + description = "Accept a workspace invitation using the token from the invitation email. The authenticated user's verified email must match the invited email.", + request_body( + content = AcceptInvitationRequest, + description = "Invitation token.", + content_type = "application/json" + ), + responses( + (status = 200, description = "Invitation accepted and user added as a member.", body = ApiResponse), + (status = 400, description = "Invalid or expired invitation, or already a member.", body = ApiErrorResponse), + (status = 401, description = "Unauthenticated or email mismatch.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + params: web::Json, +) -> Result { + let data = service + .workspace + .workspace_accept_invitation(&session, ¶ms.token) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/add_domain.rs b/api/workspace/add_domain.rs new file mode 100644 index 0000000..eff318b --- /dev/null +++ b/api/workspace/add_domain.rs @@ -0,0 +1,44 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceDomain; +use crate::service::AppService; +use crate::service::workspace::domains::AddDomainParams; +use crate::session::Session; + +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/domains", + tag = "Workspaces", + operation_id = "workspaceAddDomain", + summary = "Add a domain", + description = "Add a domain for verification. The first domain added is auto-set as primary. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + request_body( + content = AddDomainParams, + description = "Domain name to add.", + content_type = "application/json" + ), + responses( + (status = 200, description = "Domain added (unverified).", body = ApiResponse), + (status = 400, description = "Domain is empty.", body = ApiErrorResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service + .workspace + .workspace_add_domain(&session, &ws, params.into_inner()) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/add_member.rs b/api/workspace/add_member.rs new file mode 100644 index 0000000..c2d6ad3 --- /dev/null +++ b/api/workspace/add_member.rs @@ -0,0 +1,45 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceMember; +use crate::service::AppService; +use crate::service::workspace::members::AddMemberParams; +use crate::session::Session; + +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/members", + tag = "Workspaces", + operation_id = "workspaceAddMember", + summary = "Add a member", + description = "Add a user to a workspace. Requires admin role. Cannot add members with role equal to or higher than the caller.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + request_body( + content = AddMemberParams, + description = "User ID and optional role.", + content_type = "application/json" + ), + responses( + (status = 200, description = "Member added.", body = ApiResponse), + (status = 400, description = "Cannot add owner, invalid role, or member invites disabled.", body = ApiErrorResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 409, description = "User is already a member.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service + .workspace + .workspace_add_member(&session, &ws, params.into_inner()) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/archive.rs b/api/workspace/archive.rs new file mode 100644 index 0000000..b22ed74 --- /dev/null +++ b/api/workspace/archive.rs @@ -0,0 +1,33 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiEmptyResponse, ApiErrorResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/archive", + tag = "Workspaces", + operation_id = "workspaceArchive", + summary = "Archive a workspace", + description = "Archive a workspace. Requires owner role. All repos become read-only.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + responses( + (status = 200, description = "Workspace archived.", body = ApiEmptyResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Workspace not found or already archived.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + service.workspace.workspace_archive(&session, &ws).await?; + Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("workspace archived"))) +} diff --git a/api/workspace/audit_logs.rs b/api/workspace/audit_logs.rs new file mode 100644 index 0000000..75f42c1 --- /dev/null +++ b/api/workspace/audit_logs.rs @@ -0,0 +1,50 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceAuditLog; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Deserialize, utoipa::IntoParams)] +pub struct AuditLogsQuery { + pub limit: Option, + pub offset: Option, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/audit-logs", + tag = "Workspaces", + operation_id = "workspaceAuditLogs", + summary = "Get audit logs", + description = "Return recent audit log entries for the workspace. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + AuditLogsQuery + ), + responses( + (status = 200, description = "List of audit log entries.", body = ApiResponse>), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 500, description = "Database read failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service + .workspace + .workspace_audit_logs( + &session, + &ws, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/create.rs b/api/workspace/create.rs new file mode 100644 index 0000000..fd4543a --- /dev/null +++ b/api/workspace/create.rs @@ -0,0 +1,39 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::Workspace; +use crate::service::AppService; +use crate::service::workspace::core::CreateWorkspaceParams; +use crate::session::Session; + +#[utoipa::path( + post, + path = "/api/v1/workspaces", + tag = "Workspaces", + operation_id = "workspaceCreate", + summary = "Create a workspace", + description = "Create a new workspace. The creator automatically becomes the owner.", + request_body( + content = CreateWorkspaceParams, + description = "Workspace creation parameters.", + content_type = "application/json" + ), + responses( + (status = 200, description = "Workspace created.", body = ApiResponse), + (status = 400, description = "Name is required or visibility is invalid.", body = ApiErrorResponse), + (status = 401, description = "Unauthenticated.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + params: web::Json, +) -> Result { + let data = service + .workspace + .workspace_create(&session, params.into_inner()) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/create_integration.rs b/api/workspace/create_integration.rs new file mode 100644 index 0000000..3e3cbbd --- /dev/null +++ b/api/workspace/create_integration.rs @@ -0,0 +1,44 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceIntegration; +use crate::service::AppService; +use crate::service::workspace::integrations::CreateIntegrationParams; +use crate::session::Session; + +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/integrations", + tag = "Workspaces", + operation_id = "workspaceCreateIntegration", + summary = "Create an integration", + description = "Add a third-party integration to the workspace. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + request_body( + content = CreateIntegrationParams, + description = "Integration provider, name, config, and optional secret.", + content_type = "application/json" + ), + responses( + (status = 200, description = "Integration created.", body = ApiResponse), + (status = 400, description = "Invalid provider or name is empty.", body = ApiErrorResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service + .workspace + .workspace_create_integration(&session, &ws, params.into_inner()) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/create_invitation.rs b/api/workspace/create_invitation.rs new file mode 100644 index 0000000..72ea423 --- /dev/null +++ b/api/workspace/create_invitation.rs @@ -0,0 +1,43 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::service::workspace::invitations::{CreateInvitationParams, CreateInvitationResponse}; +use crate::session::Session; + +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/invitations", + tag = "Workspaces", + operation_id = "workspaceCreateInvitation", + summary = "Create an invitation", + description = "Invite a user by email to join the workspace. An invitation email is sent. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + request_body( + content = CreateInvitationParams, + description = "Email and optional role.", + content_type = "application/json" + ), + responses( + (status = 200, description = "Invitation created and email sent.", body = ApiResponse), + (status = 400, description = "Email is empty, role is invalid, or invitation already exists.", body = ApiErrorResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 500, description = "Database or email service failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service + .workspace + .workspace_create_invitation(&session, &ws, params.into_inner()) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/create_webhook.rs b/api/workspace/create_webhook.rs new file mode 100644 index 0000000..6f74c0d --- /dev/null +++ b/api/workspace/create_webhook.rs @@ -0,0 +1,44 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceWebhook; +use crate::service::AppService; +use crate::service::workspace::webhooks::CreateWebhookParams; +use crate::session::Session; + +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/webhooks", + tag = "Workspaces", + operation_id = "workspaceCreateWebhook", + summary = "Create a webhook", + description = "Create a webhook for the workspace. Requires admin role. The URL must use HTTPS and cannot point to localhost or internal IPs.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + request_body( + content = CreateWebhookParams, + description = "HTTPS webhook URL, optional secret ciphertext, event types, and active flag.", + content_type = "application/json" + ), + responses( + (status = 200, description = "Webhook created.", body = ApiResponse), + (status = 400, description = "Invalid URL, missing events, or SSRF check failed.", body = ApiErrorResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service + .workspace + .workspace_create_webhook(&session, &ws, params.into_inner()) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/delete.rs b/api/workspace/delete.rs new file mode 100644 index 0000000..cf158bd --- /dev/null +++ b/api/workspace/delete.rs @@ -0,0 +1,33 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiEmptyResponse, ApiErrorResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[utoipa::path( + delete, + path = "/api/v1/workspaces/{workspace_name}", + tag = "Workspaces", + operation_id = "workspaceDelete", + summary = "Delete a workspace", + description = "Soft-delete a workspace. Requires owner role.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + responses( + (status = 200, description = "Workspace deleted.", body = ApiEmptyResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Workspace not found.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + service.workspace.workspace_delete(&session, &ws).await?; + Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("workspace deleted"))) +} diff --git a/api/workspace/delete_domain.rs b/api/workspace/delete_domain.rs new file mode 100644 index 0000000..ac90037 --- /dev/null +++ b/api/workspace/delete_domain.rs @@ -0,0 +1,40 @@ +use actix_web::{HttpResponse, web}; +use uuid::Uuid; + +use crate::api::response::{ApiEmptyResponse, ApiErrorResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[utoipa::path( + delete, + path = "/api/v1/workspaces/{workspace_name}/domains/{domain_id}", + tag = "Workspaces", + operation_id = "workspaceDeleteDomain", + summary = "Delete a domain", + description = "Remove a domain from the workspace. Cannot delete the primary domain. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + ("domain_id" = Uuid, Path, description = "Domain record ID.") + ), + responses( + (status = 200, description = "Domain deleted.", body = ApiEmptyResponse), + (status = 400, description = "Cannot delete primary domain.", body = ApiErrorResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Domain not found.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path<(String, Uuid)>, +) -> Result { + let (ws_name, domain_id) = path.into_inner(); + let ws = service.workspace.find_workspace_by_name(&ws_name).await?; + service + .workspace + .workspace_delete_domain(&session, &ws, domain_id) + .await?; + Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("domain deleted"))) +} diff --git a/api/workspace/delete_integration.rs b/api/workspace/delete_integration.rs new file mode 100644 index 0000000..6091ef6 --- /dev/null +++ b/api/workspace/delete_integration.rs @@ -0,0 +1,39 @@ +use actix_web::{HttpResponse, web}; +use uuid::Uuid; + +use crate::api::response::{ApiEmptyResponse, ApiErrorResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[utoipa::path( + delete, + path = "/api/v1/workspaces/{workspace_name}/integrations/{integration_id}", + tag = "Workspaces", + operation_id = "workspaceDeleteIntegration", + summary = "Delete an integration", + description = "Remove an integration from the workspace. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + ("integration_id" = Uuid, Path, description = "Integration ID.") + ), + responses( + (status = 200, description = "Integration deleted.", body = ApiEmptyResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Integration not found.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path<(String, Uuid)>, +) -> Result { + let (ws_name, integration_id) = path.into_inner(); + let ws = service.workspace.find_workspace_by_name(&ws_name).await?; + service + .workspace + .workspace_delete_integration(&session, &ws, integration_id) + .await?; + Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("integration deleted"))) +} diff --git a/api/workspace/delete_webhook.rs b/api/workspace/delete_webhook.rs new file mode 100644 index 0000000..9b46a86 --- /dev/null +++ b/api/workspace/delete_webhook.rs @@ -0,0 +1,39 @@ +use actix_web::{HttpResponse, web}; +use uuid::Uuid; + +use crate::api::response::{ApiEmptyResponse, ApiErrorResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[utoipa::path( + delete, + path = "/api/v1/workspaces/{workspace_name}/webhooks/{webhook_id}", + tag = "Workspaces", + operation_id = "workspaceDeleteWebhook", + summary = "Delete a webhook", + description = "Remove a webhook from the workspace. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + ("webhook_id" = Uuid, Path, description = "Webhook ID.") + ), + responses( + (status = 200, description = "Webhook deleted.", body = ApiEmptyResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Webhook not found.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path<(String, Uuid)>, +) -> Result { + let (ws_name, webhook_id) = path.into_inner(); + let ws = service.workspace.find_workspace_by_name(&ws_name).await?; + service + .workspace + .workspace_delete_webhook(&session, &ws, webhook_id) + .await?; + Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("webhook deleted"))) +} diff --git a/api/workspace/get.rs b/api/workspace/get.rs new file mode 100644 index 0000000..a7e65eb --- /dev/null +++ b/api/workspace/get.rs @@ -0,0 +1,34 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::Workspace; +use crate::service::AppService; +use crate::session::Session; + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}", + tag = "Workspaces", + operation_id = "workspaceGet", + summary = "Get a workspace", + description = "Return the workspace identified by name, subject to visibility checks.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + responses( + (status = 200, description = "Workspace data.", body = ApiResponse), + (status = 401, description = "Unauthenticated.", body = ApiErrorResponse), + (status = 404, description = "Workspace not found or not accessible.", body = ApiErrorResponse), + (status = 500, description = "Database read failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service.workspace.workspace_get(&session, &ws).await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/get_billing.rs b/api/workspace/get_billing.rs new file mode 100644 index 0000000..471986f --- /dev/null +++ b/api/workspace/get_billing.rs @@ -0,0 +1,34 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceBilling; +use crate::service::AppService; +use crate::session::Session; + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/billing", + tag = "Workspaces", + operation_id = "workspaceGetBilling", + summary = "Get billing information", + description = "Return billing information for a workspace. Requires owner role.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + responses( + (status = 200, description = "Billing information.", body = ApiResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Workspace not found.", body = ApiErrorResponse), + (status = 500, description = "Database read failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service.workspace.workspace_billing(&session, &ws).await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/get_branding.rs b/api/workspace/get_branding.rs new file mode 100644 index 0000000..5fff4cc --- /dev/null +++ b/api/workspace/get_branding.rs @@ -0,0 +1,34 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceCustomBranding; +use crate::service::AppService; +use crate::session::Session; + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/branding", + tag = "Workspaces", + operation_id = "workspaceGetBranding", + summary = "Get custom branding", + description = "Return custom branding settings for a workspace. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + responses( + (status = 200, description = "Branding settings.", body = ApiResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Workspace not found.", body = ApiErrorResponse), + (status = 500, description = "Database read failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service.workspace.workspace_branding(&session, &ws).await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/get_settings.rs b/api/workspace/get_settings.rs new file mode 100644 index 0000000..83334ff --- /dev/null +++ b/api/workspace/get_settings.rs @@ -0,0 +1,33 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceSettings; +use crate::service::AppService; +use crate::session::Session; + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/settings", + tag = "Workspaces", + operation_id = "workspaceGetSettings", + summary = "Get workspace settings", + description = "Return workspace settings — readable by anyone with workspace access.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + responses( + (status = 200, description = "Workspace settings.", body = ApiResponse), + (status = 401, description = "Unauthenticated or not readable.", body = ApiErrorResponse), + (status = 500, description = "Database read failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service.workspace.workspace_settings(&session, &ws).await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/get_stats.rs b/api/workspace/get_stats.rs new file mode 100644 index 0000000..10997ac --- /dev/null +++ b/api/workspace/get_stats.rs @@ -0,0 +1,33 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceStats; +use crate::service::AppService; +use crate::session::Session; + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/stats", + tag = "Workspaces", + operation_id = "workspaceGetStats", + summary = "Get workspace statistics", + description = "Return workspace statistics including member, repository, issue, and pull request counts. Readable by anyone with workspace access.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + responses( + (status = 200, description = "Workspace statistics.", body = ApiResponse), + (status = 401, description = "Unauthenticated or not readable.", body = ApiErrorResponse), + (status = 500, description = "Database read failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service.workspace.workspace_stats(&session, &ws).await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/leave.rs b/api/workspace/leave.rs new file mode 100644 index 0000000..8c2519c --- /dev/null +++ b/api/workspace/leave.rs @@ -0,0 +1,33 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiEmptyResponse, ApiErrorResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/leave", + tag = "Workspaces", + operation_id = "workspaceLeave", + summary = "Leave a workspace", + description = "Remove the current user from the workspace. The owner cannot leave; transfer ownership first.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + responses( + (status = 200, description = "Left the workspace.", body = ApiEmptyResponse), + (status = 400, description = "Owner cannot leave.", body = ApiErrorResponse), + (status = 401, description = "Unauthenticated or not a member.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + service.workspace.workspace_leave(&session, &ws).await?; + Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("left workspace"))) +} diff --git a/api/workspace/list.rs b/api/workspace/list.rs new file mode 100644 index 0000000..9a524da --- /dev/null +++ b/api/workspace/list.rs @@ -0,0 +1,44 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::Workspace; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Deserialize, utoipa::IntoParams)] +pub struct ListQuery { + pub limit: Option, + pub offset: Option, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces", + tag = "Workspaces", + operation_id = "workspaceList", + summary = "List accessible workspaces", + description = "Return workspaces owned by, joined by, or publicly accessible to the current user.", + params(ListQuery), + responses( + (status = 200, description = "List of workspaces.", body = ApiResponse>), + (status = 401, description = "Unauthenticated.", body = ApiErrorResponse), + (status = 500, description = "Database read failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + query: web::Query, +) -> Result { + let data = service + .workspace + .workspace_list( + &session, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/list_approvals.rs b/api/workspace/list_approvals.rs new file mode 100644 index 0000000..e711c70 --- /dev/null +++ b/api/workspace/list_approvals.rs @@ -0,0 +1,50 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspacePendingApproval; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Deserialize, utoipa::IntoParams)] +pub struct ApprovalsQuery { + pub limit: Option, + pub offset: Option, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/approvals", + tag = "Workspaces", + operation_id = "workspaceListApprovals", + summary = "List pending approvals", + description = "Return pending approval requests for the workspace. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + ApprovalsQuery + ), + responses( + (status = 200, description = "List of pending approvals.", body = ApiResponse>), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 500, description = "Database read failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service + .workspace + .workspace_pending_approvals( + &session, + &ws, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/list_domains.rs b/api/workspace/list_domains.rs new file mode 100644 index 0000000..ccde9b5 --- /dev/null +++ b/api/workspace/list_domains.rs @@ -0,0 +1,50 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceDomain; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Deserialize, utoipa::IntoParams)] +pub struct DomainsQuery { + pub limit: Option, + pub offset: Option, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/domains", + tag = "Workspaces", + operation_id = "workspaceListDomains", + summary = "List domains", + description = "Return domains associated with the workspace. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + DomainsQuery + ), + responses( + (status = 200, description = "List of domains.", body = ApiResponse>), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 500, description = "Database read failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service + .workspace + .workspace_domains( + &session, + &ws, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/list_integrations.rs b/api/workspace/list_integrations.rs new file mode 100644 index 0000000..fd24604 --- /dev/null +++ b/api/workspace/list_integrations.rs @@ -0,0 +1,50 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceIntegration; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Deserialize, utoipa::IntoParams)] +pub struct IntegrationsQuery { + pub limit: Option, + pub offset: Option, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/integrations", + tag = "Workspaces", + operation_id = "workspaceListIntegrations", + summary = "List integrations", + description = "Return integrations configured for the workspace. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + IntegrationsQuery + ), + responses( + (status = 200, description = "List of integrations.", body = ApiResponse>), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 500, description = "Database read failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service + .workspace + .workspace_integrations( + &session, + &ws, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/list_invitations.rs b/api/workspace/list_invitations.rs new file mode 100644 index 0000000..68edc85 --- /dev/null +++ b/api/workspace/list_invitations.rs @@ -0,0 +1,50 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceInvitation; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Deserialize, utoipa::IntoParams)] +pub struct InvitationsQuery { + pub limit: Option, + pub offset: Option, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/invitations", + tag = "Workspaces", + operation_id = "workspaceListInvitations", + summary = "List pending invitations", + description = "Return pending (un-expired, un-revoked, un-accepted) invitations for a workspace. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + InvitationsQuery + ), + responses( + (status = 200, description = "List of invitations.", body = ApiResponse>), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 500, description = "Database read failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service + .workspace + .workspace_invitations( + &session, + &ws, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/list_members.rs b/api/workspace/list_members.rs new file mode 100644 index 0000000..081cf0b --- /dev/null +++ b/api/workspace/list_members.rs @@ -0,0 +1,50 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceMember; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Deserialize, utoipa::IntoParams)] +pub struct MembersQuery { + pub limit: Option, + pub offset: Option, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/members", + tag = "Workspaces", + operation_id = "workspaceListMembers", + summary = "List workspace members", + description = "Return active members of a workspace. Viewable by anyone with read access.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + MembersQuery + ), + responses( + (status = 200, description = "List of members.", body = ApiResponse>), + (status = 401, description = "Unauthenticated or not readable.", body = ApiErrorResponse), + (status = 500, description = "Database read failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service + .workspace + .workspace_members( + &session, + &ws, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/list_webhooks.rs b/api/workspace/list_webhooks.rs new file mode 100644 index 0000000..ce09c72 --- /dev/null +++ b/api/workspace/list_webhooks.rs @@ -0,0 +1,50 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceWebhook; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Deserialize, utoipa::IntoParams)] +pub struct WebhooksQuery { + pub limit: Option, + pub offset: Option, +} + +#[utoipa::path( + get, + path = "/api/v1/workspaces/{workspace_name}/webhooks", + tag = "Workspaces", + operation_id = "workspaceListWebhooks", + summary = "List webhooks", + description = "Return webhooks configured for the workspace. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + WebhooksQuery + ), + responses( + (status = 200, description = "List of webhooks.", body = ApiResponse>), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 500, description = "Database read failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service + .workspace + .workspace_webhooks( + &session, + &ws, + query.limit.unwrap_or(50), + query.offset.unwrap_or(0), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/mod.rs b/api/workspace/mod.rs new file mode 100644 index 0000000..e97745b --- /dev/null +++ b/api/workspace/mod.rs @@ -0,0 +1,211 @@ +pub mod accept_invitation; +pub mod add_domain; +pub mod add_member; +pub mod archive; +pub mod audit_logs; +pub mod create; +pub mod create_integration; +pub mod create_invitation; +pub mod create_webhook; +pub mod delete; +pub mod delete_domain; +pub mod delete_integration; +pub mod delete_webhook; +pub mod get; +pub mod get_billing; +pub mod get_branding; +pub mod get_settings; +pub mod get_stats; +pub mod leave; +pub mod list; +pub mod list_approvals; +pub mod list_domains; +pub mod list_integrations; +pub mod list_invitations; +pub mod list_members; +pub mod list_webhooks; +pub mod refresh_stats; +pub mod remove_member; +pub mod request_approval; +pub mod review_approval; +pub mod revoke_invitation; +pub mod set_primary_domain; +pub mod transfer_owner; +pub mod unarchive; +pub mod update; +pub mod update_billing; +pub mod update_branding; +pub mod update_integration; +pub mod update_member_role; +pub mod update_settings; +pub mod update_webhook; +pub mod upload_avatar; +pub mod verify_domain; + +use actix_web::web; + +pub fn configure(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/workspaces") + // Core + .route("", web::get().to(list::handle)) + .route("", web::post().to(create::handle)) + .route( + "/invitations/accept", + web::post().to(accept_invitation::handle), + ) + .route("/{workspace_name}", web::get().to(get::handle)) + .route("/{workspace_name}", web::put().to(update::handle)) + .route("/{workspace_name}", web::delete().to(delete::handle)) + .route("/{workspace_name}/archive", web::post().to(archive::handle)) + .route( + "/{workspace_name}/unarchive", + web::post().to(unarchive::handle), + ) + .route( + "/{workspace_name}/transfer-owner", + web::post().to(transfer_owner::handle), + ) + .route( + "/{workspace_name}/avatar", + web::post().to(upload_avatar::handle), + ) + // Members + .route( + "/{workspace_name}/members", + web::get().to(list_members::handle), + ) + .route( + "/{workspace_name}/members", + web::post().to(add_member::handle), + ) + .route( + "/{workspace_name}/members/{member_id}/role", + web::put().to(update_member_role::handle), + ) + .route( + "/{workspace_name}/members/{member_id}", + web::delete().to(remove_member::handle), + ) + .route("/{workspace_name}/leave", web::post().to(leave::handle)) + // Invitations + .route( + "/{workspace_name}/invitations", + web::get().to(list_invitations::handle), + ) + .route( + "/{workspace_name}/invitations", + web::post().to(create_invitation::handle), + ) + .route( + "/{workspace_name}/invitations/{invitation_id}", + web::delete().to(revoke_invitation::handle), + ) + // Billing + .route( + "/{workspace_name}/billing", + web::get().to(get_billing::handle), + ) + .route( + "/{workspace_name}/billing", + web::put().to(update_billing::handle), + ) + // Branding + .route( + "/{workspace_name}/branding", + web::get().to(get_branding::handle), + ) + .route( + "/{workspace_name}/branding", + web::put().to(update_branding::handle), + ) + // Settings + .route( + "/{workspace_name}/settings", + web::get().to(get_settings::handle), + ) + .route( + "/{workspace_name}/settings", + web::put().to(update_settings::handle), + ) + // Stats + .route("/{workspace_name}/stats", web::get().to(get_stats::handle)) + .route( + "/{workspace_name}/stats/refresh", + web::post().to(refresh_stats::handle), + ) + // Integrations + .route( + "/{workspace_name}/integrations", + web::get().to(list_integrations::handle), + ) + .route( + "/{workspace_name}/integrations", + web::post().to(create_integration::handle), + ) + .route( + "/{workspace_name}/integrations/{integration_id}", + web::put().to(update_integration::handle), + ) + .route( + "/{workspace_name}/integrations/{integration_id}", + web::delete().to(delete_integration::handle), + ) + // Webhooks + .route( + "/{workspace_name}/webhooks", + web::get().to(list_webhooks::handle), + ) + .route( + "/{workspace_name}/webhooks", + web::post().to(create_webhook::handle), + ) + .route( + "/{workspace_name}/webhooks/{webhook_id}", + web::put().to(update_webhook::handle), + ) + .route( + "/{workspace_name}/webhooks/{webhook_id}", + web::delete().to(delete_webhook::handle), + ) + // Domains + .route( + "/{workspace_name}/domains", + web::get().to(list_domains::handle), + ) + .route( + "/{workspace_name}/domains", + web::post().to(add_domain::handle), + ) + .route( + "/{workspace_name}/domains/{domain_id}/verify", + web::post().to(verify_domain::handle), + ) + .route( + "/{workspace_name}/domains/{domain_id}/primary", + web::put().to(set_primary_domain::handle), + ) + .route( + "/{workspace_name}/domains/{domain_id}", + web::delete().to(delete_domain::handle), + ) + // Approvals + .route( + "/{workspace_name}/approvals", + web::get().to(list_approvals::handle), + ) + .route( + "/{workspace_name}/approvals", + web::post().to(request_approval::handle), + ) + .route( + "/{workspace_name}/approvals/{approval_id}", + web::put().to(review_approval::handle), + ) + // Audit + .route( + "/{workspace_name}/audit-logs", + web::get().to(audit_logs::handle), + ), + ); +} diff --git a/api/workspace/refresh_stats.rs b/api/workspace/refresh_stats.rs new file mode 100644 index 0000000..9f58918 --- /dev/null +++ b/api/workspace/refresh_stats.rs @@ -0,0 +1,36 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceStats; +use crate::service::AppService; +use crate::session::Session; + +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/stats/refresh", + tag = "Workspaces", + operation_id = "workspaceRefreshStats", + summary = "Refresh workspace statistics", + description = "Recalculate workspace statistics by counting members, repos, issues, and PRs from live data. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + responses( + (status = 200, description = "Refreshed statistics.", body = ApiResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 500, description = "Database read or write failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service + .workspace + .workspace_refresh_stats(&session, &ws) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/remove_member.rs b/api/workspace/remove_member.rs new file mode 100644 index 0000000..d931ad7 --- /dev/null +++ b/api/workspace/remove_member.rs @@ -0,0 +1,40 @@ +use actix_web::{HttpResponse, web}; +use uuid::Uuid; + +use crate::api::response::{ApiEmptyResponse, ApiErrorResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[utoipa::path( + delete, + path = "/api/v1/workspaces/{workspace_name}/members/{member_id}", + tag = "Workspaces", + operation_id = "workspaceRemoveMember", + summary = "Remove a member", + description = "Remove a member from the workspace. Requires admin role. Cannot remove owner.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + ("member_id" = Uuid, Path, description = "Member record ID.") + ), + responses( + (status = 200, description = "Member removed.", body = ApiEmptyResponse), + (status = 400, description = "Cannot remove owner or member with equal/higher role.", body = ApiErrorResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Member not found.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path<(String, Uuid)>, +) -> Result { + let (ws_name, member_id) = path.into_inner(); + let ws = service.workspace.find_workspace_by_name(&ws_name).await?; + service + .workspace + .workspace_remove_member(&session, &ws, member_id) + .await?; + Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("member removed"))) +} diff --git a/api/workspace/request_approval.rs b/api/workspace/request_approval.rs new file mode 100644 index 0000000..43254d9 --- /dev/null +++ b/api/workspace/request_approval.rs @@ -0,0 +1,44 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspacePendingApproval; +use crate::service::AppService; +use crate::service::workspace::approvals::RequestApprovalParams; +use crate::session::Session; + +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/approvals", + tag = "Workspaces", + operation_id = "workspaceRequestApproval", + summary = "Request an approval", + description = "Submit a request for workspace admin approval. Readable by anyone with workspace access.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + request_body( + content = RequestApprovalParams, + description = "Approval request type and optional reason.", + content_type = "application/json" + ), + responses( + (status = 200, description = "Approval request created.", body = ApiResponse), + (status = 400, description = "Invalid request type.", body = ApiErrorResponse), + (status = 401, description = "Unauthenticated.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service + .workspace + .workspace_request_approval(&session, &ws, params.into_inner()) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/review_approval.rs b/api/workspace/review_approval.rs new file mode 100644 index 0000000..e1984af --- /dev/null +++ b/api/workspace/review_approval.rs @@ -0,0 +1,52 @@ +use actix_web::{HttpResponse, web}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::api::response::{ApiEmptyResponse, ApiErrorResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Deserialize, Serialize, utoipa::ToSchema)] +pub struct ReviewApprovalRequest { + /// true to approve, false to reject. + pub approved: bool, +} + +#[utoipa::path( + put, + path = "/api/v1/workspaces/{workspace_name}/approvals/{approval_id}", + tag = "Workspaces", + operation_id = "workspaceReviewApproval", + summary = "Review an approval request", + description = "Approve or reject a pending approval request. Requires owner role.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + ("approval_id" = Uuid, Path, description = "Approval record ID.") + ), + request_body( + content = ReviewApprovalRequest, + description = "Whether to approve or reject.", + content_type = "application/json" + ), + responses( + (status = 200, description = "Approval reviewed.", body = ApiEmptyResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Approval not found or already reviewed.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path<(String, Uuid)>, + params: web::Json, +) -> Result { + let (ws_name, approval_id) = path.into_inner(); + let ws = service.workspace.find_workspace_by_name(&ws_name).await?; + service + .workspace + .workspace_review_approval(&session, &ws, approval_id, params.approved) + .await?; + Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("approval reviewed"))) +} diff --git a/api/workspace/revoke_invitation.rs b/api/workspace/revoke_invitation.rs new file mode 100644 index 0000000..fc7a3b0 --- /dev/null +++ b/api/workspace/revoke_invitation.rs @@ -0,0 +1,39 @@ +use actix_web::{HttpResponse, web}; +use uuid::Uuid; + +use crate::api::response::{ApiEmptyResponse, ApiErrorResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[utoipa::path( + delete, + path = "/api/v1/workspaces/{workspace_name}/invitations/{invitation_id}", + tag = "Workspaces", + operation_id = "workspaceRevokeInvitation", + summary = "Revoke an invitation", + description = "Revoke a pending invitation so it can no longer be accepted. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + ("invitation_id" = Uuid, Path, description = "Invitation ID.") + ), + responses( + (status = 200, description = "Invitation revoked.", body = ApiEmptyResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Invitation not found or already used.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path<(String, Uuid)>, +) -> Result { + let (ws_name, invitation_id) = path.into_inner(); + let ws = service.workspace.find_workspace_by_name(&ws_name).await?; + service + .workspace + .workspace_revoke_invitation(&session, &ws, invitation_id) + .await?; + Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("invitation revoked"))) +} diff --git a/api/workspace/set_primary_domain.rs b/api/workspace/set_primary_domain.rs new file mode 100644 index 0000000..f626439 --- /dev/null +++ b/api/workspace/set_primary_domain.rs @@ -0,0 +1,40 @@ +use actix_web::{HttpResponse, web}; +use uuid::Uuid; + +use crate::api::response::{ApiEmptyResponse, ApiErrorResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[utoipa::path( + put, + path = "/api/v1/workspaces/{workspace_name}/domains/{domain_id}/primary", + tag = "Workspaces", + operation_id = "workspaceSetPrimaryDomain", + summary = "Set primary domain", + description = "Set a verified domain as the primary domain for the workspace. Requires owner role.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + ("domain_id" = Uuid, Path, description = "Domain record ID.") + ), + responses( + (status = 200, description = "Primary domain set.", body = ApiEmptyResponse), + (status = 400, description = "Domain must be verified first.", body = ApiErrorResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Domain not found.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path<(String, Uuid)>, +) -> Result { + let (ws_name, domain_id) = path.into_inner(); + let ws = service.workspace.find_workspace_by_name(&ws_name).await?; + service + .workspace + .workspace_set_primary_domain(&session, &ws, domain_id) + .await?; + Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("primary domain set"))) +} diff --git a/api/workspace/transfer_owner.rs b/api/workspace/transfer_owner.rs new file mode 100644 index 0000000..c2418b5 --- /dev/null +++ b/api/workspace/transfer_owner.rs @@ -0,0 +1,51 @@ +use actix_web::{HttpResponse, web}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::Workspace; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Deserialize, Serialize, utoipa::ToSchema)] +pub struct TransferOwnerRequest { + /// User ID of the new owner, who must be an active member. + pub new_owner_id: Uuid, +} + +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/transfer-owner", + tag = "Workspaces", + operation_id = "workspaceTransferOwner", + summary = "Transfer workspace ownership", + description = "Transfer workspace ownership to another active member. The current owner becomes admin.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + request_body( + content = TransferOwnerRequest, + description = "New owner user ID.", + content_type = "application/json" + ), + responses( + (status = 200, description = "Ownership transferred.", body = ApiResponse), + (status = 400, description = "New owner must be an active member and different from current owner.", body = ApiErrorResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service + .workspace + .workspace_transfer_owner(&session, &ws, params.new_owner_id) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/unarchive.rs b/api/workspace/unarchive.rs new file mode 100644 index 0000000..8398c3c --- /dev/null +++ b/api/workspace/unarchive.rs @@ -0,0 +1,33 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiEmptyResponse, ApiErrorResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/unarchive", + tag = "Workspaces", + operation_id = "workspaceUnarchive", + summary = "Unarchive a workspace", + description = "Restore an archived workspace to active status. Requires owner role.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + responses( + (status = 200, description = "Workspace unarchived.", body = ApiEmptyResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Workspace not found or not archived.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + service.workspace.workspace_unarchive(&session, &ws).await?; + Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("workspace unarchived"))) +} diff --git a/api/workspace/update.rs b/api/workspace/update.rs new file mode 100644 index 0000000..0d51dbb --- /dev/null +++ b/api/workspace/update.rs @@ -0,0 +1,45 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::Workspace; +use crate::service::AppService; +use crate::service::workspace::core::UpdateWorkspaceParams; +use crate::session::Session; + +#[utoipa::path( + put, + path = "/api/v1/workspaces/{workspace_name}", + tag = "Workspaces", + operation_id = "workspaceUpdate", + summary = "Update a workspace", + description = "Update workspace name, description, visibility, or default role. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + request_body( + content = UpdateWorkspaceParams, + description = "Workspace update parameters — only included fields are changed.", + content_type = "application/json" + ), + responses( + (status = 200, description = "Workspace updated.", body = ApiResponse), + (status = 400, description = "Bad request — invalid visibility or default_role.", body = ApiErrorResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Workspace not found.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service + .workspace + .workspace_update(&session, &ws, params.into_inner()) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/update_billing.rs b/api/workspace/update_billing.rs new file mode 100644 index 0000000..9506f99 --- /dev/null +++ b/api/workspace/update_billing.rs @@ -0,0 +1,44 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceBilling; +use crate::service::AppService; +use crate::service::workspace::billing::UpdateBillingParams; +use crate::session::Session; + +#[utoipa::path( + put, + path = "/api/v1/workspaces/{workspace_name}/billing", + tag = "Workspaces", + operation_id = "workspaceUpdateBilling", + summary = "Update billing information", + description = "Update billing plan, email, or seat count. Requires owner role.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + request_body( + content = UpdateBillingParams, + description = "Billing update parameters — only included fields are changed.", + content_type = "application/json" + ), + responses( + (status = 200, description = "Billing updated.", body = ApiResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Workspace not found.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service + .workspace + .workspace_update_billing(&session, &ws, params.into_inner()) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/update_branding.rs b/api/workspace/update_branding.rs new file mode 100644 index 0000000..e7a893e --- /dev/null +++ b/api/workspace/update_branding.rs @@ -0,0 +1,44 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceCustomBranding; +use crate::service::AppService; +use crate::service::workspace::branding::UpdateBrandingParams; +use crate::session::Session; + +#[utoipa::path( + put, + path = "/api/v1/workspaces/{workspace_name}/branding", + tag = "Workspaces", + operation_id = "workspaceUpdateBranding", + summary = "Update custom branding", + description = "Update workspace custom branding including logo, favicon, colors, CSS, and support URL. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + request_body( + content = UpdateBrandingParams, + description = "Branding update parameters — only included fields are changed.", + content_type = "application/json" + ), + responses( + (status = 200, description = "Branding updated.", body = ApiResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Workspace not found.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service + .workspace + .workspace_update_branding(&session, &ws, params.into_inner()) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/update_integration.rs b/api/workspace/update_integration.rs new file mode 100644 index 0000000..c7fda74 --- /dev/null +++ b/api/workspace/update_integration.rs @@ -0,0 +1,47 @@ +use actix_web::{HttpResponse, web}; +use uuid::Uuid; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceIntegration; +use crate::service::AppService; +use crate::service::workspace::integrations::UpdateIntegrationParams; +use crate::session::Session; + +#[utoipa::path( + put, + path = "/api/v1/workspaces/{workspace_name}/integrations/{integration_id}", + tag = "Workspaces", + operation_id = "workspaceUpdateIntegration", + summary = "Update an integration", + description = "Update an integration's name, config, secret, or enabled state. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + ("integration_id" = Uuid, Path, description = "Integration ID.") + ), + request_body( + content = UpdateIntegrationParams, + description = "Integration update parameters — only included fields are changed.", + content_type = "application/json" + ), + responses( + (status = 200, description = "Integration updated.", body = ApiResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Integration not found.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path<(String, Uuid)>, + params: web::Json, +) -> Result { + let (ws_name, integration_id) = path.into_inner(); + let ws = service.workspace.find_workspace_by_name(&ws_name).await?; + let data = service + .workspace + .workspace_update_integration(&session, &ws, integration_id, params.into_inner()) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/update_member_role.rs b/api/workspace/update_member_role.rs new file mode 100644 index 0000000..1e6f131 --- /dev/null +++ b/api/workspace/update_member_role.rs @@ -0,0 +1,48 @@ +use actix_web::{HttpResponse, web}; +use uuid::Uuid; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceMember; +use crate::service::AppService; +use crate::service::workspace::members::UpdateMemberRoleParams; +use crate::session::Session; + +#[utoipa::path( + put, + path = "/api/v1/workspaces/{workspace_name}/members/{member_id}/role", + tag = "Workspaces", + operation_id = "workspaceUpdateMemberRole", + summary = "Update a member's role", + description = "Change the role of a workspace member. Requires admin role. Cannot change owner role; use transfer-owner instead.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + ("member_id" = Uuid, Path, description = "Member record ID.") + ), + request_body( + content = UpdateMemberRoleParams, + description = "New role value.", + content_type = "application/json" + ), + responses( + (status = 200, description = "Member role updated.", body = ApiResponse), + (status = 400, description = "Invalid role, cannot change owner, or insufficient role.", body = ApiErrorResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Member not found.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path<(String, Uuid)>, + params: web::Json, +) -> Result { + let (ws_name, member_id) = path.into_inner(); + let ws = service.workspace.find_workspace_by_name(&ws_name).await?; + let data = service + .workspace + .workspace_update_member_role(&session, &ws, member_id, params.into_inner()) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/update_settings.rs b/api/workspace/update_settings.rs new file mode 100644 index 0000000..d5bd966 --- /dev/null +++ b/api/workspace/update_settings.rs @@ -0,0 +1,44 @@ +use actix_web::{HttpResponse, web}; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceSettings; +use crate::service::AppService; +use crate::service::workspace::settings::UpdateWorkspaceSettingsParams; +use crate::session::Session; + +#[utoipa::path( + put, + path = "/api/v1/workspaces/{workspace_name}/settings", + tag = "Workspaces", + operation_id = "workspaceUpdateSettings", + summary = "Update workspace settings", + description = "Update workspace settings such as repo visibility defaults, feature toggles, and member invite permissions. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name.") + ), + request_body( + content = UpdateWorkspaceSettingsParams, + description = "Settings update parameters — only included fields are changed.", + content_type = "application/json" + ), + responses( + (status = 200, description = "Settings updated.", body = ApiResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Workspace not found.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, + params: web::Json, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service + .workspace + .workspace_update_settings(&session, &ws, params.into_inner()) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/update_webhook.rs b/api/workspace/update_webhook.rs new file mode 100644 index 0000000..2957bb6 --- /dev/null +++ b/api/workspace/update_webhook.rs @@ -0,0 +1,48 @@ +use actix_web::{HttpResponse, web}; +use uuid::Uuid; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::WorkspaceWebhook; +use crate::service::AppService; +use crate::service::workspace::webhooks::UpdateWebhookParams; +use crate::session::Session; + +#[utoipa::path( + put, + path = "/api/v1/workspaces/{workspace_name}/webhooks/{webhook_id}", + tag = "Workspaces", + operation_id = "workspaceUpdateWebhook", + summary = "Update a webhook", + description = "Update a webhook's URL, secret, events, or active state. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + ("webhook_id" = Uuid, Path, description = "Webhook ID.") + ), + request_body( + content = UpdateWebhookParams, + description = "Webhook update parameters — only included fields are changed.", + content_type = "application/json" + ), + responses( + (status = 200, description = "Webhook updated.", body = ApiResponse), + (status = 400, description = "Invalid URL.", body = ApiErrorResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Webhook not found.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path<(String, Uuid)>, + params: web::Json, +) -> Result { + let (ws_name, webhook_id) = path.into_inner(); + let ws = service.workspace.find_workspace_by_name(&ws_name).await?; + let data = service + .workspace + .workspace_update_webhook(&session, &ws, webhook_id, params.into_inner()) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/upload_avatar.rs b/api/workspace/upload_avatar.rs new file mode 100644 index 0000000..e505b1c --- /dev/null +++ b/api/workspace/upload_avatar.rs @@ -0,0 +1,60 @@ +use actix_web::{HttpResponse, web}; +use serde::Deserialize; + +use crate::api::response::{ApiErrorResponse, ApiResponse}; +use crate::error::AppError; +use crate::models::workspaces::Workspace; +use crate::service::AppService; +use crate::session::Session; + +#[derive(Deserialize, utoipa::IntoParams)] +pub struct UploadAvatarQuery { + pub content_type: Option, + pub file_name: Option, +} + +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/avatar", + tag = "Workspaces", + operation_id = "workspaceUploadAvatar", + summary = "Upload workspace avatar", + description = "Upload an avatar image for a workspace. Requires admin role. Maximum size 5 MB. Supported: png, jpg, gif, webp.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + ("content_type" = Option, Query, description = "MIME type of the uploaded image."), + ("file_name" = Option, Query, description = "Original file name for extension detection.") + ), + request_body( + content = Vec, + description = "Raw image bytes.", + content_type = "application/octet-stream" + ), + responses( + (status = 200, description = "Avatar uploaded.", body = ApiResponse), + (status = 400, description = "Unsupported image format or file too large.", body = ApiErrorResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Workspace not found.", body = ApiErrorResponse), + (status = 500, description = "Storage or database failure.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path, + query: web::Query, + body: web::Bytes, +) -> Result { + let ws = service.workspace.find_workspace_by_name(&path).await?; + let data = service + .workspace + .workspace_upload_avatar( + &session, + &ws, + body.to_vec(), + query.content_type.clone(), + query.file_name.clone(), + ) + .await?; + Ok(HttpResponse::Ok().json(ApiResponse::new(data))) +} diff --git a/api/workspace/verify_domain.rs b/api/workspace/verify_domain.rs new file mode 100644 index 0000000..4cd49c1 --- /dev/null +++ b/api/workspace/verify_domain.rs @@ -0,0 +1,39 @@ +use actix_web::{HttpResponse, web}; +use uuid::Uuid; + +use crate::api::response::{ApiEmptyResponse, ApiErrorResponse}; +use crate::error::AppError; +use crate::service::AppService; +use crate::session::Session; + +#[utoipa::path( + post, + path = "/api/v1/workspaces/{workspace_name}/domains/{domain_id}/verify", + tag = "Workspaces", + operation_id = "workspaceVerifyDomain", + summary = "Verify a domain", + description = "Mark a domain as verified. Requires admin role.", + params( + ("workspace_name" = String, Path, description = "Workspace name."), + ("domain_id" = Uuid, Path, description = "Domain record ID.") + ), + responses( + (status = 200, description = "Domain verified.", body = ApiEmptyResponse), + (status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse), + (status = 404, description = "Domain not found or already verified.", body = ApiErrorResponse), + (status = 500, description = "Database transaction failed.", body = ApiErrorResponse) + ) +)] +pub async fn handle( + service: web::Data, + session: Session, + path: web::Path<(String, Uuid)>, +) -> Result { + let (ws_name, domain_id) = path.into_inner(); + let ws = service.workspace.find_workspace_by_name(&ws_name).await?; + service + .workspace + .workspace_verify_domain(&session, &ws, domain_id) + .await?; + Ok(HttpResponse::Ok().json(ApiEmptyResponse::ok("domain verified"))) +} diff --git a/models/workspaces/workspace.rs b/models/workspaces/workspace.rs index ba19967..5cd2c9b 100644 --- a/models/workspaces/workspace.rs +++ b/models/workspaces/workspace.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)] pub struct Workspace { pub id: Uuid, pub owner_id: Uuid, diff --git a/models/workspaces/workspace_audit_logs.rs b/models/workspaces/workspace_audit_logs.rs index 64c79a8..69e0aa8 100644 --- a/models/workspaces/workspace_audit_logs.rs +++ b/models/workspaces/workspace_audit_logs.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)] pub struct WorkspaceAuditLog { pub id: Uuid, pub workspace_id: Uuid, diff --git a/models/workspaces/workspace_billing.rs b/models/workspaces/workspace_billing.rs index 0bff471..373a385 100644 --- a/models/workspaces/workspace_billing.rs +++ b/models/workspaces/workspace_billing.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)] pub struct WorkspaceBilling { pub workspace_id: Uuid, pub customer_id: Option, diff --git a/models/workspaces/workspace_custom_branding.rs b/models/workspaces/workspace_custom_branding.rs index 50e31e3..2d48d03 100644 --- a/models/workspaces/workspace_custom_branding.rs +++ b/models/workspaces/workspace_custom_branding.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)] pub struct WorkspaceCustomBranding { pub workspace_id: Uuid, pub logo_url: Option, diff --git a/models/workspaces/workspace_domains.rs b/models/workspaces/workspace_domains.rs index 0a68eb8..cec6891 100644 --- a/models/workspaces/workspace_domains.rs +++ b/models/workspaces/workspace_domains.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)] pub struct WorkspaceDomain { pub id: Uuid, pub workspace_id: Uuid, diff --git a/models/workspaces/workspace_integrations.rs b/models/workspaces/workspace_integrations.rs index feea10a..163c2c1 100644 --- a/models/workspaces/workspace_integrations.rs +++ b/models/workspaces/workspace_integrations.rs @@ -4,12 +4,13 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)] pub struct WorkspaceIntegration { pub id: Uuid, pub workspace_id: Uuid, pub provider: Provider, pub name: String, + #[schema(value_type = serde_json::Value)] pub config: Option>, pub secret_ciphertext: Option, pub enabled: bool, diff --git a/models/workspaces/workspace_members.rs b/models/workspaces/workspace_members.rs index fbaacae..582dc5d 100644 --- a/models/workspaces/workspace_members.rs +++ b/models/workspaces/workspace_members.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)] pub struct WorkspaceMember { pub id: Uuid, pub workspace_id: Uuid, diff --git a/models/workspaces/workspace_pending_approvals.rs b/models/workspaces/workspace_pending_approvals.rs index 299b6b3..2f2699b 100644 --- a/models/workspaces/workspace_pending_approvals.rs +++ b/models/workspaces/workspace_pending_approvals.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)] pub struct WorkspacePendingApproval { pub id: Uuid, pub workspace_id: Uuid, diff --git a/models/workspaces/workspace_settings.rs b/models/workspaces/workspace_settings.rs index 53d0d7e..d89fed9 100644 --- a/models/workspaces/workspace_settings.rs +++ b/models/workspaces/workspace_settings.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)] pub struct WorkspaceSettings { pub workspace_id: Uuid, pub allow_public_repos: bool, diff --git a/models/workspaces/workspace_stats.rs b/models/workspaces/workspace_stats.rs index 8571061..7e1f39a 100644 --- a/models/workspaces/workspace_stats.rs +++ b/models/workspaces/workspace_stats.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)] pub struct WorkspaceStats { pub workspace_id: Uuid, pub members_count: i64, diff --git a/models/workspaces/workspace_webhooks.rs b/models/workspaces/workspace_webhooks.rs index 30b04d2..71874ad 100644 --- a/models/workspaces/workspace_webhooks.rs +++ b/models/workspaces/workspace_webhooks.rs @@ -3,7 +3,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)] pub struct WorkspaceWebhook { pub id: Uuid, pub workspace_id: Uuid, diff --git a/service/workspace/approvals.rs b/service/workspace/approvals.rs index 9c3e166..745a508 100644 --- a/service/workspace/approvals.rs +++ b/service/workspace/approvals.rs @@ -3,7 +3,7 @@ use uuid::Uuid; use crate::error::AppError; use crate::models::common::{RequestType, Role, Status}; -use crate::models::workspaces::WorkspacePendingApproval; +use crate::models::workspaces::{Workspace, WorkspacePendingApproval}; use crate::service::WorkspaceService; use crate::session::Session; @@ -19,12 +19,11 @@ impl WorkspaceService { pub async fn workspace_pending_approvals( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, limit: i64, offset: i64, ) -> 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 (limit, offset) = clamp_limit_offset(limit, offset); @@ -33,7 +32,7 @@ impl WorkspaceService { 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(ws.id) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) @@ -44,11 +43,10 @@ impl WorkspaceService { pub async fn workspace_request_approval( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, params: RequestApprovalParams, ) -> Result { 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( @@ -79,7 +77,7 @@ impl WorkspaceService { reviewed_by, reviewed_at, expires_at, created_at, updated_at", ) .bind(Uuid::now_v7()) - .bind(workspace_id) + .bind(ws.id) .bind(user_uid) .bind(request_type) .bind(params.reason) @@ -96,12 +94,11 @@ impl WorkspaceService { pub async fn workspace_review_approval( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, 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?; @@ -133,7 +130,7 @@ impl WorkspaceService { .bind(user_uid) .bind(now) .bind(approval_id) - .bind(workspace_id) + .bind(ws.id) .execute(&mut *txn) .await .map_err(AppError::Database)?; diff --git a/service/workspace/audit.rs b/service/workspace/audit.rs index f23fb8f..6482103 100644 --- a/service/workspace/audit.rs +++ b/service/workspace/audit.rs @@ -2,7 +2,7 @@ use uuid::Uuid; use crate::error::AppError; use crate::models::common::{EventType, JsonValue, Role, TargetType}; -use crate::models::workspaces::WorkspaceAuditLog; +use crate::models::workspaces::{Workspace, WorkspaceAuditLog}; use crate::service::WorkspaceService; use crate::session::Session; @@ -12,12 +12,11 @@ impl WorkspaceService { pub async fn workspace_audit_logs( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, limit: i64, offset: i64, ) -> 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 (limit, offset) = clamp_limit_offset(limit, offset); @@ -26,7 +25,7 @@ impl WorkspaceService { 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(ws.id) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) diff --git a/service/workspace/billing.rs b/service/workspace/billing.rs index 541dc24..42b1934 100644 --- a/service/workspace/billing.rs +++ b/service/workspace/billing.rs @@ -3,7 +3,7 @@ use uuid::Uuid; use crate::error::AppError; use crate::models::common::Role; -use crate::models::workspaces::WorkspaceBilling; +use crate::models::workspaces::{Workspace, WorkspaceBilling}; use crate::service::WorkspaceService; use crate::session::Session; @@ -20,27 +20,25 @@ impl WorkspaceService { pub async fn workspace_billing( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, ) -> Result { 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 + self.ensure_workspace_billing(ws.id).await } pub async fn workspace_update_billing( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, params: UpdateBillingParams, ) -> Result { 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 current = self.ensure_workspace_billing(ws.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); @@ -70,7 +68,7 @@ impl WorkspaceService { .bind(&billing_email) .bind(seats) .bind(now) - .bind(workspace_id) + .bind(ws.id) .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; diff --git a/service/workspace/branding.rs b/service/workspace/branding.rs index 4622447..48ccb17 100644 --- a/service/workspace/branding.rs +++ b/service/workspace/branding.rs @@ -3,7 +3,7 @@ use uuid::Uuid; use crate::error::AppError; use crate::models::common::Role; -use crate::models::workspaces::WorkspaceCustomBranding; +use crate::models::workspaces::{Workspace, WorkspaceCustomBranding}; use crate::service::WorkspaceService; use crate::session::Session; @@ -24,27 +24,25 @@ impl WorkspaceService { pub async fn workspace_branding( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, ) -> Result { 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 + self.ensure_workspace_branding(ws.id).await } pub async fn workspace_update_branding( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, params: UpdateBrandingParams, ) -> Result { 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 current = self.ensure_workspace_branding(ws.id).await?; let now = chrono::Utc::now(); let mut txn = self @@ -75,7 +73,7 @@ impl WorkspaceService { .bind(merge_optional_text(params.support_url, current.support_url)) .bind(params.enabled.unwrap_or(current.enabled)) .bind(now) - .bind(workspace_id) + .bind(ws.id) .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; diff --git a/service/workspace/core.rs b/service/workspace/core.rs index 1b45f8c..1fdb338 100644 --- a/service/workspace/core.rs +++ b/service/workspace/core.rs @@ -52,12 +52,11 @@ impl WorkspaceService { pub async fn workspace_get( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, ) -> Result { 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) + Ok(ws.clone()) } pub async fn workspace_create( @@ -177,17 +176,16 @@ impl WorkspaceService { pub async fn workspace_update( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, params: UpdateWorkspaceParams, ) -> Result { 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 description = merge_optional_text(params.description, ws.description.clone()); let visibility = parse_enum( params.visibility, ws.visibility, @@ -204,7 +202,6 @@ impl WorkspaceService { 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 => {} _ => { @@ -239,7 +236,7 @@ impl WorkspaceService { .bind(visibility) .bind(default_role.to_string()) .bind(now) - .bind(workspace_id) + .bind(ws.id) .fetch_optional(&mut *txn) .await .map_err(AppError::Database)? @@ -249,13 +246,8 @@ impl WorkspaceService { Ok(result) } - pub async fn workspace_archive( - &self, - ctx: &Session, - workspace_id: Uuid, - ) -> Result<(), AppError> { + pub async fn workspace_archive(&self, ctx: &Session, ws: &Workspace) -> 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?; @@ -278,7 +270,7 @@ impl WorkspaceService { WHERE id = $2 AND deleted_at IS NULL AND status <> 'archived'", ) .bind(now) - .bind(workspace_id) + .bind(ws.id) .execute(&mut *txn) .await .map_err(AppError::Database)?; @@ -291,13 +283,8 @@ impl WorkspaceService { Ok(()) } - pub async fn workspace_unarchive( - &self, - ctx: &Session, - workspace_id: Uuid, - ) -> Result<(), AppError> { + pub async fn workspace_unarchive(&self, ctx: &Session, ws: &Workspace) -> 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?; @@ -320,7 +307,7 @@ impl WorkspaceService { WHERE id = $2 AND deleted_at IS NULL AND status = 'archived'", ) .bind(now) - .bind(workspace_id) + .bind(ws.id) .execute(&mut *txn) .await .map_err(AppError::Database)?; @@ -333,13 +320,8 @@ impl WorkspaceService { Ok(()) } - pub async fn workspace_delete( - &self, - ctx: &Session, - workspace_id: Uuid, - ) -> Result<(), AppError> { + pub async fn workspace_delete(&self, ctx: &Session, ws: &Workspace) -> 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?; @@ -362,7 +344,7 @@ impl WorkspaceService { WHERE id = $2 AND deleted_at IS NULL", ) .bind(now) - .bind(workspace_id) + .bind(ws.id) .execute(&mut *txn) .await .map_err(AppError::Database)?; @@ -375,11 +357,10 @@ impl WorkspaceService { pub async fn workspace_transfer_owner( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, new_owner_id: Uuid, ) -> Result { 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?; @@ -391,7 +372,7 @@ impl WorkspaceService { 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(ws.id) .bind(new_owner_id) .fetch_one(self.ctx.db.reader()) .await @@ -422,7 +403,7 @@ impl WorkspaceService { WHERE workspace_id = $2 AND user_id = $3", ) .bind(now) - .bind(workspace_id) + .bind(ws.id) .bind(new_owner_id) .execute(&mut *txn) .await @@ -433,7 +414,7 @@ impl WorkspaceService { WHERE workspace_id = $2 AND user_id = $3", ) .bind(now) - .bind(workspace_id) + .bind(ws.id) .bind(user_uid) .execute(&mut *txn) .await @@ -446,7 +427,7 @@ impl WorkspaceService { ) .bind(new_owner_id) .bind(now) - .bind(workspace_id) + .bind(ws.id) .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; @@ -458,13 +439,12 @@ impl WorkspaceService { pub async fn workspace_upload_avatar( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, data: Vec, content_type: Option, file_name: Option, ) -> Result { 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?; @@ -473,12 +453,7 @@ impl WorkspaceService { 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 - ); + let storage_key = format!("workspaces/{}/avatar/{}.{}", ws.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()) @@ -506,7 +481,7 @@ impl WorkspaceService { ) .bind(&avatar_url) .bind(now) - .bind(workspace_id) + .bind(ws.id) .fetch_optional(&mut *txn) .await .map_err(AppError::Database)?; @@ -557,12 +532,12 @@ impl WorkspaceService { pub async fn workspace_user_role( &self, user_uid: Uuid, - workspace_id: Uuid, + ws: &Workspace, ) -> Result, AppError> { let role_str: Option = sqlx::query_scalar( "SELECT role FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active'", ) - .bind(workspace_id) + .bind(ws.id) .bind(user_uid) .fetch_optional(self.ctx.db.reader()) .await @@ -571,7 +546,6 @@ impl WorkspaceService { 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)); } diff --git a/service/workspace/domains.rs b/service/workspace/domains.rs index 52914fd..ed88ca9 100644 --- a/service/workspace/domains.rs +++ b/service/workspace/domains.rs @@ -3,7 +3,7 @@ use uuid::Uuid; use crate::error::AppError; use crate::models::common::Role; -use crate::models::workspaces::WorkspaceDomain; +use crate::models::workspaces::{Workspace, WorkspaceDomain}; use crate::service::WorkspaceService; use crate::session::Session; @@ -18,12 +18,11 @@ impl WorkspaceService { pub async fn workspace_domains( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, limit: i64, offset: i64, ) -> 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 (limit, offset) = clamp_limit_offset(limit, offset); @@ -32,7 +31,7 @@ impl WorkspaceService { 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(ws.id) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) @@ -43,11 +42,10 @@ impl WorkspaceService { pub async fn workspace_add_domain( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, params: AddDomainParams, ) -> Result { 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(); @@ -55,7 +53,7 @@ impl WorkspaceService { let is_first = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM workspace_domain WHERE workspace_id = $1", ) - .bind(workspace_id) + .bind(ws.id) .fetch_one(self.ctx.db.reader()) .await .map_err(AppError::Database)? @@ -84,7 +82,7 @@ impl WorkspaceService { 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(ws.id) .bind(&domain) .bind(&token_hash) .bind(is_first) @@ -100,11 +98,10 @@ impl WorkspaceService { pub async fn workspace_verify_domain( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, 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(); @@ -128,7 +125,7 @@ impl WorkspaceService { ) .bind(now) .bind(domain_id) - .bind(workspace_id) + .bind(ws.id) .execute(&mut *txn) .await .map_err(AppError::Database)?; @@ -144,11 +141,10 @@ impl WorkspaceService { pub async fn workspace_set_primary_domain( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, 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(); @@ -172,7 +168,7 @@ impl WorkspaceService { WHERE id = $1 AND workspace_id = $2", ) .bind(domain_id) - .bind(workspace_id) + .bind(ws.id) .fetch_optional(&mut *txn) .await .map_err(AppError::Database)? @@ -186,7 +182,7 @@ impl WorkspaceService { 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) + .bind(ws.id) .execute(&mut *txn) .await .map_err(AppError::Database)?; @@ -205,11 +201,10 @@ impl WorkspaceService { pub async fn workspace_delete_domain( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, 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?; @@ -217,7 +212,7 @@ impl WorkspaceService { "SELECT is_primary FROM workspace_domain WHERE id = $1 AND workspace_id = $2", ) .bind(domain_id) - .bind(workspace_id) + .bind(ws.id) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)? @@ -243,7 +238,7 @@ impl WorkspaceService { let result = sqlx::query("DELETE FROM workspace_domain WHERE id = $1 AND workspace_id = $2") .bind(domain_id) - .bind(workspace_id) + .bind(ws.id) .execute(&mut *txn) .await .map_err(AppError::Database)?; diff --git a/service/workspace/integrations.rs b/service/workspace/integrations.rs index f465d71..39b78fe 100644 --- a/service/workspace/integrations.rs +++ b/service/workspace/integrations.rs @@ -3,7 +3,7 @@ use uuid::Uuid; use crate::error::AppError; use crate::models::common::Provider; -use crate::models::workspaces::WorkspaceIntegration; +use crate::models::workspaces::{Workspace, WorkspaceIntegration}; use crate::service::WorkspaceService; use crate::session::Session; @@ -30,12 +30,11 @@ impl WorkspaceService { pub async fn workspace_integrations( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, limit: i64, offset: i64, ) -> 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 (limit, offset) = clamp_limit_offset(limit, offset); @@ -44,7 +43,7 @@ impl WorkspaceService { 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(ws.id) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) @@ -55,11 +54,10 @@ impl WorkspaceService { pub async fn workspace_create_integration( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, params: CreateIntegrationParams, ) -> Result { 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?; @@ -95,7 +93,7 @@ impl WorkspaceService { installed_by, last_used_at, created_at, updated_at", ) .bind(Uuid::now_v7()) - .bind(workspace_id) + .bind(ws.id) .bind(provider) .bind(&name) .bind(params.config.map(sqlx::types::Json)) @@ -114,12 +112,11 @@ impl WorkspaceService { pub async fn workspace_update_integration( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, integration_id: Uuid, params: UpdateIntegrationParams, ) -> Result { 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?; @@ -129,7 +126,7 @@ impl WorkspaceService { WHERE id = $1 AND workspace_id = $2", ) .bind(integration_id) - .bind(workspace_id) + .bind(ws.id) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)? @@ -172,7 +169,7 @@ impl WorkspaceService { .bind(enabled) .bind(now) .bind(integration_id) - .bind(workspace_id) + .bind(ws.id) .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; @@ -184,11 +181,10 @@ impl WorkspaceService { pub async fn workspace_delete_integration( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, 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?; @@ -208,7 +204,7 @@ impl WorkspaceService { let result = sqlx::query("DELETE FROM workspace_integration WHERE id = $1 AND workspace_id = $2") .bind(integration_id) - .bind(workspace_id) + .bind(ws.id) .execute(&mut *txn) .await .map_err(AppError::Database)?; diff --git a/service/workspace/invitations.rs b/service/workspace/invitations.rs index b1b9797..f4de353 100644 --- a/service/workspace/invitations.rs +++ b/service/workspace/invitations.rs @@ -3,7 +3,7 @@ use uuid::Uuid; use crate::error::AppError; use crate::models::common::Role; -use crate::models::workspaces::WorkspaceInvitation; +use crate::models::workspaces::{Workspace, WorkspaceInvitation}; use crate::pb::email::{EmailAddress, SendEmailRequest}; use crate::service::WorkspaceService; use crate::session::Session; @@ -25,12 +25,11 @@ impl WorkspaceService { pub async fn workspace_invitations( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, limit: i64, offset: i64, ) -> 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 (limit, offset) = clamp_limit_offset(limit, offset); @@ -40,7 +39,7 @@ impl WorkspaceService { 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(ws.id) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) @@ -51,11 +50,10 @@ impl WorkspaceService { pub async fn workspace_create_invitation( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, params: CreateInvitationParams, ) -> Result { 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?; @@ -70,7 +68,7 @@ impl WorkspaceService { 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(ws.id) .bind(&email) .fetch_one(self.ctx.db.reader()) .await @@ -92,7 +90,6 @@ impl WorkspaceService { 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(), @@ -124,7 +121,7 @@ impl WorkspaceService { accepted_at, revoked_at, expires_at, created_at", ) .bind(Uuid::now_v7()) - .bind(workspace_id) + .bind(ws.id) .bind(&email) .bind(role.to_string()) .bind(&token_hash) @@ -167,11 +164,10 @@ impl WorkspaceService { pub async fn workspace_revoke_invitation( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, 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?; @@ -195,7 +191,7 @@ impl WorkspaceService { ) .bind(now) .bind(invitation_id) - .bind(workspace_id) + .bind(ws.id) .execute(&mut *txn) .await .map_err(AppError::Database)?; diff --git a/service/workspace/members.rs b/service/workspace/members.rs index a6dd935..878b9c7 100644 --- a/service/workspace/members.rs +++ b/service/workspace/members.rs @@ -4,7 +4,7 @@ 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::models::workspaces::{Workspace, WorkspaceMember}; use crate::service::WorkspaceService; use crate::session::Session; @@ -23,12 +23,11 @@ impl WorkspaceService { pub async fn workspace_members( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, limit: i64, offset: i64, ) -> Result, 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>( @@ -36,7 +35,7 @@ impl WorkspaceService { 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(ws.id) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) @@ -47,11 +46,10 @@ impl WorkspaceService { pub async fn workspace_add_member( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, params: AddMemberParams, ) -> Result { 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?; @@ -59,7 +57,7 @@ impl WorkspaceService { let settings_allow = sqlx::query_scalar::<_, bool>( "SELECT allow_member_invites FROM workspace_settings WHERE workspace_id = $1", ) - .bind(workspace_id) + .bind(ws.id) .fetch_one(self.ctx.db.reader()) .await .map_err(AppError::Database)?; @@ -73,7 +71,7 @@ impl WorkspaceService { 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(ws.id) .bind(params.user_id) .fetch_one(self.ctx.db.reader()) .await @@ -96,7 +94,6 @@ impl WorkspaceService { 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(), @@ -123,7 +120,7 @@ impl WorkspaceService { 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(ws.id) .bind(params.user_id) .bind(role.to_string()) .bind(user_uid) @@ -136,7 +133,7 @@ impl WorkspaceService { "UPDATE workspace_stats SET members_count = members_count + 1, updated_at = $1 WHERE workspace_id = $2", ) .bind(now) - .bind(workspace_id) + .bind(ws.id) .execute(&mut *txn) .await .map_err(AppError::Database)?; @@ -148,12 +145,11 @@ impl WorkspaceService { pub async fn workspace_update_member_role( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, member_id: Uuid, params: UpdateMemberRoleParams, ) -> Result { 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?; @@ -171,7 +167,6 @@ impl WorkspaceService { 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(), @@ -184,7 +179,7 @@ impl WorkspaceService { WHERE id = $1 AND workspace_id = $2", ) .bind(member_id) - .bind(workspace_id) + .bind(ws.id) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)? @@ -222,7 +217,7 @@ impl WorkspaceService { .bind(new_role.to_string()) .bind(now) .bind(member_id) - .bind(workspace_id) + .bind(ws.id) .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; @@ -234,11 +229,10 @@ impl WorkspaceService { pub async fn workspace_remove_member( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, 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?; @@ -249,7 +243,7 @@ impl WorkspaceService { WHERE id = $1 AND workspace_id = $2", ) .bind(member_id) - .bind(workspace_id) + .bind(ws.id) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)? @@ -283,7 +277,7 @@ impl WorkspaceService { let result = sqlx::query("DELETE FROM workspace_member WHERE id = $1 AND workspace_id = $2") .bind(member_id) - .bind(workspace_id) + .bind(ws.id) .execute(&mut *txn) .await .map_err(AppError::Database)?; @@ -293,7 +287,7 @@ impl WorkspaceService { "UPDATE workspace_stats SET members_count = GREATEST(members_count - 1, 0), updated_at = $1 WHERE workspace_id = $2", ) .bind(now) - .bind(workspace_id) + .bind(ws.id) .execute(&mut *txn) .await .map_err(AppError::Database)?; @@ -302,9 +296,8 @@ impl WorkspaceService { Ok(()) } - pub async fn workspace_leave(&self, ctx: &Session, workspace_id: Uuid) -> Result<(), AppError> { + pub async fn workspace_leave(&self, ctx: &Session, ws: &Workspace) -> 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( @@ -328,7 +321,7 @@ impl WorkspaceService { let result = sqlx::query("DELETE FROM workspace_member WHERE workspace_id = $1 AND user_id = $2") - .bind(workspace_id) + .bind(ws.id) .bind(user_uid) .execute(&mut *txn) .await @@ -339,7 +332,7 @@ impl WorkspaceService { "UPDATE workspace_stats SET members_count = GREATEST(members_count - 1, 0), updated_at = $1 WHERE workspace_id = $2", ) .bind(now) - .bind(workspace_id) + .bind(ws.id) .execute(&mut *txn) .await .map_err(AppError::Database)?; diff --git a/service/workspace/settings.rs b/service/workspace/settings.rs index c60251f..fb4431e 100644 --- a/service/workspace/settings.rs +++ b/service/workspace/settings.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::error::AppError; -use crate::models::workspaces::WorkspaceSettings; +use crate::models::workspaces::{Workspace, WorkspaceSettings}; use crate::service::WorkspaceService; use crate::session::Session; @@ -24,26 +24,24 @@ impl WorkspaceService { pub async fn workspace_settings( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, ) -> Result { 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 + self.ensure_user_workspace_settings(ws.id).await } pub async fn workspace_update_settings( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, params: UpdateWorkspaceSettingsParams, ) -> Result { 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 current = self.ensure_user_workspace_settings(ws.id).await?; let now = chrono::Utc::now(); let mut txn = self .ctx @@ -77,7 +75,7 @@ impl WorkspaceService { .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) + .bind(ws.id) .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; diff --git a/service/workspace/stats.rs b/service/workspace/stats.rs index 92e476c..38a4f92 100644 --- a/service/workspace/stats.rs +++ b/service/workspace/stats.rs @@ -2,7 +2,7 @@ use uuid::Uuid; use crate::error::AppError; use crate::models::common::Role; -use crate::models::workspaces::WorkspaceStats; +use crate::models::workspaces::{Workspace, WorkspaceStats}; use crate::service::WorkspaceService; use crate::session::Session; @@ -10,28 +10,26 @@ impl WorkspaceService { pub async fn workspace_stats( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, ) -> Result { 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 + self.ensure_workspace_stats(ws.id).await } pub async fn workspace_refresh_stats( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, ) -> Result { 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) + .bind(ws.id) .fetch_one(self.ctx.db.reader()) .await .map_err(AppError::Database)?; @@ -39,7 +37,7 @@ impl WorkspaceService { let repos_count = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM repo WHERE workspace_id = $1 AND deleted_at IS NULL", ) - .bind(workspace_id) + .bind(ws.id) .fetch_one(self.ctx.db.reader()) .await .map_err(AppError::Database)?; @@ -47,7 +45,7 @@ impl WorkspaceService { 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) + .bind(ws.id) .fetch_one(self.ctx.db.reader()) .await .map_err(AppError::Database)?; @@ -55,7 +53,7 @@ impl WorkspaceService { 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) + .bind(ws.id) .fetch_one(self.ctx.db.reader()) .await .map_err(AppError::Database)?; @@ -72,7 +70,7 @@ impl WorkspaceService { .bind(issues_count) .bind(prs_count) .bind(now) - .bind(workspace_id) + .bind(ws.id) .fetch_one(self.ctx.db.writer()) .await .map_err(AppError::Database)?; diff --git a/service/workspace/webhooks.rs b/service/workspace/webhooks.rs index fde84b2..c0e3bd9 100644 --- a/service/workspace/webhooks.rs +++ b/service/workspace/webhooks.rs @@ -5,35 +5,27 @@ use uuid::Uuid; use crate::error::AppError; use crate::models::common::{EventType, Role}; -use crate::models::workspaces::WorkspaceWebhook; +use crate::models::workspaces::{Workspace, 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::().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") @@ -47,14 +39,11 @@ fn validate_webhook_url(url_str: &str) -> Result<(), AppError> { "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(()) } @@ -78,12 +67,11 @@ impl WorkspaceService { pub async fn workspace_webhooks( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, limit: i64, offset: i64, ) -> 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 (limit, offset) = clamp_limit_offset(limit, offset); @@ -92,7 +80,7 @@ impl WorkspaceService { 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(ws.id) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) @@ -103,11 +91,10 @@ impl WorkspaceService { pub async fn workspace_create_webhook( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, params: CreateWebhookParams, ) -> Result { 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?; @@ -141,7 +128,7 @@ impl WorkspaceService { last_delivery_status, last_delivery_at, created_by, created_at, updated_at", ) .bind(Uuid::now_v7()) - .bind(workspace_id) + .bind(ws.id) .bind(&url) .bind(¶ms.secret_ciphertext) .bind(¶ms.events) @@ -159,12 +146,11 @@ impl WorkspaceService { pub async fn workspace_update_webhook( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, webhook_id: Uuid, params: UpdateWebhookParams, ) -> Result { 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?; @@ -174,7 +160,7 @@ impl WorkspaceService { FROM workspace_webhook WHERE id = $1 AND workspace_id = $2", ) .bind(webhook_id) - .bind(workspace_id) + .bind(ws.id) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)? @@ -186,7 +172,6 @@ impl WorkspaceService { .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)?; } @@ -219,7 +204,7 @@ impl WorkspaceService { .bind(active) .bind(now) .bind(webhook_id) - .bind(workspace_id) + .bind(ws.id) .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; @@ -231,11 +216,10 @@ impl WorkspaceService { pub async fn workspace_delete_webhook( &self, ctx: &Session, - workspace_id: Uuid, + ws: &Workspace, 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?; @@ -255,7 +239,7 @@ impl WorkspaceService { let result = sqlx::query("DELETE FROM workspace_webhook WHERE id = $1 AND workspace_id = $2") .bind(webhook_id) - .bind(workspace_id) + .bind(ws.id) .execute(&mut *txn) .await .map_err(AppError::Database)?;