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
This commit is contained in:
@@ -9,7 +9,7 @@ use crate::session::Session;
|
|||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/v1/auth/2fa/disable",
|
path = "/api/v1/auth/2fa/disable",
|
||||||
tag = "Auth / 2FA",
|
tag = "Auth",
|
||||||
operation_id = "authDisableTwoFactor",
|
operation_id = "authDisableTwoFactor",
|
||||||
summary = "Disable two-factor authentication",
|
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.",
|
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.",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use crate::session::Session;
|
|||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/v1/auth/2fa/enable",
|
path = "/api/v1/auth/2fa/enable",
|
||||||
tag = "Auth / 2FA",
|
tag = "Auth",
|
||||||
operation_id = "authPrepareTwoFactorEnable",
|
operation_id = "authPrepareTwoFactorEnable",
|
||||||
summary = "Initialize two-factor authentication setup",
|
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.",
|
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.",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use crate::session::Session;
|
|||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/api/v1/auth/2fa/status",
|
path = "/api/v1/auth/2fa/status",
|
||||||
tag = "Auth / 2FA",
|
tag = "Auth",
|
||||||
operation_id = "authGetTwoFactorStatus",
|
operation_id = "authGetTwoFactorStatus",
|
||||||
summary = "Get two-factor authentication status",
|
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.",
|
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.",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ pub struct Regenerate2FABackupCodesResponse {
|
|||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/v1/auth/2fa/backup-codes/regenerate",
|
path = "/api/v1/auth/2fa/backup-codes/regenerate",
|
||||||
tag = "Auth / 2FA",
|
tag = "Auth",
|
||||||
operation_id = "authRegenerateTwoFactorBackupCodes",
|
operation_id = "authRegenerateTwoFactorBackupCodes",
|
||||||
summary = "Regenerate 2FA backup codes",
|
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.",
|
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.",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use crate::session::Session;
|
|||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/v1/auth/2fa/verify",
|
path = "/api/v1/auth/2fa/verify",
|
||||||
tag = "Auth / 2FA",
|
tag = "Auth",
|
||||||
operation_id = "authVerifyAndEnableTwoFactor",
|
operation_id = "authVerifyAndEnableTwoFactor",
|
||||||
summary = "Verify and enable two-factor authentication",
|
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.",
|
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.",
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ pub mod auth;
|
|||||||
pub mod openapi;
|
pub mod openapi;
|
||||||
pub mod response;
|
pub mod response;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
pub mod workspace;
|
||||||
|
|||||||
+116
-3
@@ -5,6 +5,14 @@ use crate::api::auth::regenerate_2fa_backup_codes::{
|
|||||||
};
|
};
|
||||||
use crate::api::auth::register::RegisterResponse;
|
use crate::api::auth::register::RegisterResponse;
|
||||||
use crate::api::response::{ApiEmptyResponse, ApiErrorResponse, ApiResponse};
|
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::captcha::{CaptchaQuery, CaptchaResponse};
|
||||||
use crate::service::auth::email::{EmailChangeRequest, EmailResponse, EmailVerifyRequest};
|
use crate::service::auth::email::{EmailChangeRequest, EmailResponse, EmailVerifyRequest};
|
||||||
use crate::service::auth::login::LoginParams;
|
use crate::service::auth::login::LoginParams;
|
||||||
@@ -17,6 +25,16 @@ use crate::service::auth::rsa::RsaResponse;
|
|||||||
use crate::service::auth::totp::{
|
use crate::service::auth::totp::{
|
||||||
Disable2FAParams, Enable2FAResponse, Get2FAStatusResponse, Verify2FAParams,
|
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)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
@@ -27,9 +45,10 @@ use crate::service::auth::totp::{
|
|||||||
),
|
),
|
||||||
tags(
|
tags(
|
||||||
(name = "Auth", description = "Authentication, registration, session and email security endpoints."),
|
(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(
|
paths(
|
||||||
|
// Auth
|
||||||
crate::api::auth::rsa::handle,
|
crate::api::auth::rsa::handle,
|
||||||
crate::api::auth::captcha::handle,
|
crate::api::auth::captcha::handle,
|
||||||
crate::api::auth::login::handle,
|
crate::api::auth::login::handle,
|
||||||
@@ -46,7 +65,51 @@ use crate::service::auth::totp::{
|
|||||||
crate::api::auth::enable_2fa::handle,
|
crate::api::auth::enable_2fa::handle,
|
||||||
crate::api::auth::verify_2fa::handle,
|
crate::api::auth::verify_2fa::handle,
|
||||||
crate::api::auth::disable_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(
|
components(schemas(
|
||||||
ApiEmptyResponse,
|
ApiEmptyResponse,
|
||||||
@@ -79,7 +142,57 @@ use crate::service::auth::totp::{
|
|||||||
Verify2FAParams,
|
Verify2FAParams,
|
||||||
Disable2FAParams,
|
Disable2FAParams,
|
||||||
Regenerate2FABackupCodesRequest,
|
Regenerate2FABackupCodesRequest,
|
||||||
Regenerate2FABackupCodesResponse
|
Regenerate2FABackupCodesResponse,
|
||||||
|
ApiResponse<Workspace>,
|
||||||
|
ApiResponse<Vec<Workspace>>,
|
||||||
|
ApiResponse<WorkspaceMember>,
|
||||||
|
ApiResponse<Vec<WorkspaceMember>>,
|
||||||
|
ApiResponse<CreateInvitationResponse>,
|
||||||
|
ApiResponse<Vec<WorkspaceInvitation>>,
|
||||||
|
ApiResponse<WorkspaceInvitation>,
|
||||||
|
ApiResponse<WorkspaceBilling>,
|
||||||
|
ApiResponse<WorkspaceCustomBranding>,
|
||||||
|
ApiResponse<WorkspaceSettings>,
|
||||||
|
ApiResponse<WorkspaceStats>,
|
||||||
|
ApiResponse<WorkspaceIntegration>,
|
||||||
|
ApiResponse<Vec<WorkspaceIntegration>>,
|
||||||
|
ApiResponse<WorkspaceWebhook>,
|
||||||
|
ApiResponse<Vec<WorkspaceWebhook>>,
|
||||||
|
ApiResponse<WorkspaceDomain>,
|
||||||
|
ApiResponse<Vec<WorkspaceDomain>>,
|
||||||
|
ApiResponse<WorkspacePendingApproval>,
|
||||||
|
ApiResponse<Vec<WorkspacePendingApproval>>,
|
||||||
|
ApiResponse<Vec<WorkspaceAuditLog>>,
|
||||||
|
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;
|
pub struct OpenApiDoc;
|
||||||
|
|||||||
+6
-1
@@ -2,7 +2,12 @@ use actix_web::web;
|
|||||||
use actix_web::web::scope;
|
use actix_web::web::scope;
|
||||||
|
|
||||||
use crate::api::auth;
|
use crate::api::auth;
|
||||||
|
use crate::api::workspace;
|
||||||
|
|
||||||
pub fn init_routes(cfg: &mut web::ServiceConfig) {
|
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),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<WorkspaceInvitation>),
|
||||||
|
(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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
params: web::Json<AcceptInvitationRequest>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
let data = service
|
||||||
|
.workspace
|
||||||
|
.workspace_accept_invitation(&session, ¶ms.token)
|
||||||
|
.await?;
|
||||||
|
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
|
||||||
|
}
|
||||||
@@ -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<WorkspaceDomain>),
|
||||||
|
(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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
params: web::Json<AddDomainParams>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<WorkspaceMember>),
|
||||||
|
(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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
params: web::Json<AddMemberParams>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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")))
|
||||||
|
}
|
||||||
@@ -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<i64>,
|
||||||
|
pub offset: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Vec<WorkspaceAuditLog>>),
|
||||||
|
(status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
query: web::Query<AuditLogsQuery>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<Workspace>),
|
||||||
|
(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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
params: web::Json<CreateWorkspaceParams>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
let data = service
|
||||||
|
.workspace
|
||||||
|
.workspace_create(&session, params.into_inner())
|
||||||
|
.await?;
|
||||||
|
Ok(HttpResponse::Ok().json(ApiResponse::new(data)))
|
||||||
|
}
|
||||||
@@ -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<WorkspaceIntegration>),
|
||||||
|
(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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
params: web::Json<CreateIntegrationParams>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<CreateInvitationResponse>),
|
||||||
|
(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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
params: web::Json<CreateInvitationParams>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<WorkspaceWebhook>),
|
||||||
|
(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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
params: web::Json<CreateWebhookParams>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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")))
|
||||||
|
}
|
||||||
@@ -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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<(String, Uuid)>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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")))
|
||||||
|
}
|
||||||
@@ -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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<(String, Uuid)>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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")))
|
||||||
|
}
|
||||||
@@ -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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<(String, Uuid)>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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")))
|
||||||
|
}
|
||||||
@@ -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<Workspace>),
|
||||||
|
(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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<WorkspaceBilling>),
|
||||||
|
(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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<WorkspaceCustomBranding>),
|
||||||
|
(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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<WorkspaceSettings>),
|
||||||
|
(status = 401, description = "Unauthenticated or not readable.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<WorkspaceStats>),
|
||||||
|
(status = 401, description = "Unauthenticated or not readable.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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")))
|
||||||
|
}
|
||||||
@@ -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<i64>,
|
||||||
|
pub offset: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Vec<Workspace>>),
|
||||||
|
(status = 401, description = "Unauthenticated.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
query: web::Query<ListQuery>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<i64>,
|
||||||
|
pub offset: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Vec<WorkspacePendingApproval>>),
|
||||||
|
(status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
query: web::Query<ApprovalsQuery>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<i64>,
|
||||||
|
pub offset: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Vec<WorkspaceDomain>>),
|
||||||
|
(status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
query: web::Query<DomainsQuery>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<i64>,
|
||||||
|
pub offset: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Vec<WorkspaceIntegration>>),
|
||||||
|
(status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
query: web::Query<IntegrationsQuery>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<i64>,
|
||||||
|
pub offset: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Vec<WorkspaceInvitation>>),
|
||||||
|
(status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
query: web::Query<InvitationsQuery>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<i64>,
|
||||||
|
pub offset: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Vec<WorkspaceMember>>),
|
||||||
|
(status = 401, description = "Unauthenticated or not readable.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
query: web::Query<MembersQuery>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<i64>,
|
||||||
|
pub offset: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Vec<WorkspaceWebhook>>),
|
||||||
|
(status = 401, description = "Unauthenticated or insufficient role.", body = ApiErrorResponse),
|
||||||
|
(status = 500, description = "Database read failed.", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn handle(
|
||||||
|
service: web::Data<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
query: web::Query<WebhooksQuery>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<WorkspaceStats>),
|
||||||
|
(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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<(String, Uuid)>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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")))
|
||||||
|
}
|
||||||
@@ -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<WorkspacePendingApproval>),
|
||||||
|
(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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
params: web::Json<RequestApprovalParams>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<(String, Uuid)>,
|
||||||
|
params: web::Json<ReviewApprovalRequest>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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")))
|
||||||
|
}
|
||||||
@@ -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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<(String, Uuid)>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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")))
|
||||||
|
}
|
||||||
@@ -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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<(String, Uuid)>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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")))
|
||||||
|
}
|
||||||
@@ -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<Workspace>),
|
||||||
|
(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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
params: web::Json<TransferOwnerRequest>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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")))
|
||||||
|
}
|
||||||
@@ -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<Workspace>),
|
||||||
|
(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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
params: web::Json<UpdateWorkspaceParams>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<WorkspaceBilling>),
|
||||||
|
(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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
params: web::Json<UpdateBillingParams>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<WorkspaceCustomBranding>),
|
||||||
|
(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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
params: web::Json<UpdateBrandingParams>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<WorkspaceIntegration>),
|
||||||
|
(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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<(String, Uuid)>,
|
||||||
|
params: web::Json<UpdateIntegrationParams>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<WorkspaceMember>),
|
||||||
|
(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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<(String, Uuid)>,
|
||||||
|
params: web::Json<UpdateMemberRoleParams>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<WorkspaceSettings>),
|
||||||
|
(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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
params: web::Json<UpdateWorkspaceSettingsParams>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<WorkspaceWebhook>),
|
||||||
|
(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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<(String, Uuid)>,
|
||||||
|
params: web::Json<UpdateWebhookParams>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
pub file_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>, Query, description = "MIME type of the uploaded image."),
|
||||||
|
("file_name" = Option<String>, Query, description = "Original file name for extension detection.")
|
||||||
|
),
|
||||||
|
request_body(
|
||||||
|
content = Vec<u8>,
|
||||||
|
description = "Raw image bytes.",
|
||||||
|
content_type = "application/octet-stream"
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Avatar uploaded.", body = ApiResponse<Workspace>),
|
||||||
|
(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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<String>,
|
||||||
|
query: web::Query<UploadAvatarQuery>,
|
||||||
|
body: web::Bytes,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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)))
|
||||||
|
}
|
||||||
@@ -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<AppService>,
|
||||||
|
session: Session,
|
||||||
|
path: web::Path<(String, Uuid)>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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")))
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
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 struct Workspace {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub owner_id: Uuid,
|
pub owner_id: Uuid,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
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 struct WorkspaceAuditLog {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub workspace_id: Uuid,
|
pub workspace_id: Uuid,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
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 struct WorkspaceBilling {
|
||||||
pub workspace_id: Uuid,
|
pub workspace_id: Uuid,
|
||||||
pub customer_id: Option<String>,
|
pub customer_id: Option<String>,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
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 struct WorkspaceCustomBranding {
|
||||||
pub workspace_id: Uuid,
|
pub workspace_id: Uuid,
|
||||||
pub logo_url: Option<String>,
|
pub logo_url: Option<String>,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
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 struct WorkspaceDomain {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub workspace_id: Uuid,
|
pub workspace_id: Uuid,
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
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 struct WorkspaceIntegration {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub workspace_id: Uuid,
|
pub workspace_id: Uuid,
|
||||||
pub provider: Provider,
|
pub provider: Provider,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
#[schema(value_type = serde_json::Value)]
|
||||||
pub config: Option<TypedJson<WorkspaceIntegrationConfig>>,
|
pub config: Option<TypedJson<WorkspaceIntegrationConfig>>,
|
||||||
pub secret_ciphertext: Option<String>,
|
pub secret_ciphertext: Option<String>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
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 struct WorkspaceMember {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub workspace_id: Uuid,
|
pub workspace_id: Uuid,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
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 struct WorkspacePendingApproval {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub workspace_id: Uuid,
|
pub workspace_id: Uuid,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
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 struct WorkspaceSettings {
|
||||||
pub workspace_id: Uuid,
|
pub workspace_id: Uuid,
|
||||||
pub allow_public_repos: bool,
|
pub allow_public_repos: bool,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
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 struct WorkspaceStats {
|
||||||
pub workspace_id: Uuid,
|
pub workspace_id: Uuid,
|
||||||
pub members_count: i64,
|
pub members_count: i64,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
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 struct WorkspaceWebhook {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub workspace_id: Uuid,
|
pub workspace_id: Uuid,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::models::common::{RequestType, Role, Status};
|
use crate::models::common::{RequestType, Role, Status};
|
||||||
use crate::models::workspaces::WorkspacePendingApproval;
|
use crate::models::workspaces::{Workspace, WorkspacePendingApproval};
|
||||||
use crate::service::WorkspaceService;
|
use crate::service::WorkspaceService;
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
|
|
||||||
@@ -19,12 +19,11 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_pending_approvals(
|
pub async fn workspace_pending_approvals(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<WorkspacePendingApproval>, AppError> {
|
) -> Result<Vec<WorkspacePendingApproval>, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||||
@@ -33,7 +32,7 @@ impl WorkspaceService {
|
|||||||
reviewed_by, reviewed_at, expires_at, created_at, updated_at \
|
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",
|
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(limit)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(self.ctx.db.reader())
|
.fetch_all(self.ctx.db.reader())
|
||||||
@@ -44,11 +43,10 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_request_approval(
|
pub async fn workspace_request_approval(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
params: RequestApprovalParams,
|
params: RequestApprovalParams,
|
||||||
) -> Result<WorkspacePendingApproval, AppError> {
|
) -> Result<WorkspacePendingApproval, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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_readable(user_uid, &ws).await?;
|
||||||
|
|
||||||
let request_type = parse_enum(
|
let request_type = parse_enum(
|
||||||
@@ -79,7 +77,7 @@ impl WorkspaceService {
|
|||||||
reviewed_by, reviewed_at, expires_at, created_at, updated_at",
|
reviewed_by, reviewed_at, expires_at, created_at, updated_at",
|
||||||
)
|
)
|
||||||
.bind(Uuid::now_v7())
|
.bind(Uuid::now_v7())
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.bind(user_uid)
|
.bind(user_uid)
|
||||||
.bind(request_type)
|
.bind(request_type)
|
||||||
.bind(params.reason)
|
.bind(params.reason)
|
||||||
@@ -96,12 +94,11 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_review_approval(
|
pub async fn workspace_review_approval(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
approval_id: Uuid,
|
approval_id: Uuid,
|
||||||
approved: bool,
|
approved: bool,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -133,7 +130,7 @@ impl WorkspaceService {
|
|||||||
.bind(user_uid)
|
.bind(user_uid)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(approval_id)
|
.bind(approval_id)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::models::common::{EventType, JsonValue, Role, TargetType};
|
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::service::WorkspaceService;
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
|
|
||||||
@@ -12,12 +12,11 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_audit_logs(
|
pub async fn workspace_audit_logs(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<WorkspaceAuditLog>, AppError> {
|
) -> Result<Vec<WorkspaceAuditLog>, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
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 \
|
ip_address, user_agent, metadata, created_at FROM workspace_audit_log \
|
||||||
WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||||
)
|
)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(self.ctx.db.reader())
|
.fetch_all(self.ctx.db.reader())
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::models::common::Role;
|
use crate::models::common::Role;
|
||||||
use crate::models::workspaces::WorkspaceBilling;
|
use crate::models::workspaces::{Workspace, WorkspaceBilling};
|
||||||
use crate::service::WorkspaceService;
|
use crate::service::WorkspaceService;
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
|
|
||||||
@@ -20,27 +20,25 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_billing(
|
pub async fn workspace_billing(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
) -> Result<WorkspaceBilling, AppError> {
|
) -> Result<WorkspaceBilling, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner)
|
||||||
.await?;
|
.await?;
|
||||||
self.ensure_workspace_billing(workspace_id).await
|
self.ensure_workspace_billing(ws.id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn workspace_update_billing(
|
pub async fn workspace_update_billing(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
params: UpdateBillingParams,
|
params: UpdateBillingParams,
|
||||||
) -> Result<WorkspaceBilling, AppError> {
|
) -> Result<WorkspaceBilling, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner)
|
||||||
.await?;
|
.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 plan = params.plan.unwrap_or(current.plan.clone());
|
||||||
let billing_email = merge_optional_text(params.billing_email, current.billing_email);
|
let billing_email = merge_optional_text(params.billing_email, current.billing_email);
|
||||||
let seats = params.seats.unwrap_or(current.seats);
|
let seats = params.seats.unwrap_or(current.seats);
|
||||||
@@ -70,7 +68,7 @@ impl WorkspaceService {
|
|||||||
.bind(&billing_email)
|
.bind(&billing_email)
|
||||||
.bind(seats)
|
.bind(seats)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.fetch_one(&mut *txn)
|
.fetch_one(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::models::common::Role;
|
use crate::models::common::Role;
|
||||||
use crate::models::workspaces::WorkspaceCustomBranding;
|
use crate::models::workspaces::{Workspace, WorkspaceCustomBranding};
|
||||||
use crate::service::WorkspaceService;
|
use crate::service::WorkspaceService;
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
|
|
||||||
@@ -24,27 +24,25 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_branding(
|
pub async fn workspace_branding(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
) -> Result<WorkspaceCustomBranding, AppError> {
|
) -> Result<WorkspaceCustomBranding, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
self.ensure_workspace_branding(workspace_id).await
|
self.ensure_workspace_branding(ws.id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn workspace_update_branding(
|
pub async fn workspace_update_branding(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
params: UpdateBrandingParams,
|
params: UpdateBrandingParams,
|
||||||
) -> Result<WorkspaceCustomBranding, AppError> {
|
) -> Result<WorkspaceCustomBranding, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||||
.await?;
|
.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 now = chrono::Utc::now();
|
||||||
|
|
||||||
let mut txn = self
|
let mut txn = self
|
||||||
@@ -75,7 +73,7 @@ impl WorkspaceService {
|
|||||||
.bind(merge_optional_text(params.support_url, current.support_url))
|
.bind(merge_optional_text(params.support_url, current.support_url))
|
||||||
.bind(params.enabled.unwrap_or(current.enabled))
|
.bind(params.enabled.unwrap_or(current.enabled))
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.fetch_one(&mut *txn)
|
.fetch_one(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
+21
-47
@@ -52,12 +52,11 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_get(
|
pub async fn workspace_get(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
) -> Result<Workspace, AppError> {
|
) -> Result<Workspace, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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_readable(user_uid, &ws).await?;
|
||||||
Ok(ws)
|
Ok(ws.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn workspace_create(
|
pub async fn workspace_create(
|
||||||
@@ -177,17 +176,16 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_update(
|
pub async fn workspace_update(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
params: UpdateWorkspaceParams,
|
params: UpdateWorkspaceParams,
|
||||||
) -> Result<Workspace, AppError> {
|
) -> Result<Workspace, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let name =
|
let name =
|
||||||
merge_optional_text(params.name, Some(ws.name.clone())).unwrap_or(ws.name.clone());
|
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(
|
let visibility = parse_enum(
|
||||||
params.visibility,
|
params.visibility,
|
||||||
ws.visibility,
|
ws.visibility,
|
||||||
@@ -204,7 +202,6 @@ impl WorkspaceService {
|
|||||||
None => ws.default_role.parse().unwrap_or(Role::Member),
|
None => ws.default_role.parse().unwrap_or(Role::Member),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Restrict default_role to safe roles only
|
|
||||||
match default_role {
|
match default_role {
|
||||||
Role::Member | Role::Contributor | Role::Viewer | Role::Guest => {}
|
Role::Member | Role::Contributor | Role::Viewer | Role::Guest => {}
|
||||||
_ => {
|
_ => {
|
||||||
@@ -239,7 +236,7 @@ impl WorkspaceService {
|
|||||||
.bind(visibility)
|
.bind(visibility)
|
||||||
.bind(default_role.to_string())
|
.bind(default_role.to_string())
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.fetch_optional(&mut *txn)
|
.fetch_optional(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?
|
.map_err(AppError::Database)?
|
||||||
@@ -249,13 +246,8 @@ impl WorkspaceService {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn workspace_archive(
|
pub async fn workspace_archive(&self, ctx: &Session, ws: &Workspace) -> Result<(), AppError> {
|
||||||
&self,
|
|
||||||
ctx: &Session,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -278,7 +270,7 @@ impl WorkspaceService {
|
|||||||
WHERE id = $2 AND deleted_at IS NULL AND status <> 'archived'",
|
WHERE id = $2 AND deleted_at IS NULL AND status <> 'archived'",
|
||||||
)
|
)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -291,13 +283,8 @@ impl WorkspaceService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn workspace_unarchive(
|
pub async fn workspace_unarchive(&self, ctx: &Session, ws: &Workspace) -> Result<(), AppError> {
|
||||||
&self,
|
|
||||||
ctx: &Session,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -320,7 +307,7 @@ impl WorkspaceService {
|
|||||||
WHERE id = $2 AND deleted_at IS NULL AND status = 'archived'",
|
WHERE id = $2 AND deleted_at IS NULL AND status = 'archived'",
|
||||||
)
|
)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -333,13 +320,8 @@ impl WorkspaceService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn workspace_delete(
|
pub async fn workspace_delete(&self, ctx: &Session, ws: &Workspace) -> Result<(), AppError> {
|
||||||
&self,
|
|
||||||
ctx: &Session,
|
|
||||||
workspace_id: Uuid,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -362,7 +344,7 @@ impl WorkspaceService {
|
|||||||
WHERE id = $2 AND deleted_at IS NULL",
|
WHERE id = $2 AND deleted_at IS NULL",
|
||||||
)
|
)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -375,11 +357,10 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_transfer_owner(
|
pub async fn workspace_transfer_owner(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
new_owner_id: Uuid,
|
new_owner_id: Uuid,
|
||||||
) -> Result<Workspace, AppError> {
|
) -> Result<Workspace, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -391,7 +372,7 @@ impl WorkspaceService {
|
|||||||
let is_member = sqlx::query_scalar::<_, bool>(
|
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')",
|
"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)
|
.bind(new_owner_id)
|
||||||
.fetch_one(self.ctx.db.reader())
|
.fetch_one(self.ctx.db.reader())
|
||||||
.await
|
.await
|
||||||
@@ -422,7 +403,7 @@ impl WorkspaceService {
|
|||||||
WHERE workspace_id = $2 AND user_id = $3",
|
WHERE workspace_id = $2 AND user_id = $3",
|
||||||
)
|
)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.bind(new_owner_id)
|
.bind(new_owner_id)
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
@@ -433,7 +414,7 @@ impl WorkspaceService {
|
|||||||
WHERE workspace_id = $2 AND user_id = $3",
|
WHERE workspace_id = $2 AND user_id = $3",
|
||||||
)
|
)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.bind(user_uid)
|
.bind(user_uid)
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
@@ -446,7 +427,7 @@ impl WorkspaceService {
|
|||||||
)
|
)
|
||||||
.bind(new_owner_id)
|
.bind(new_owner_id)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.fetch_one(&mut *txn)
|
.fetch_one(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -458,13 +439,12 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_upload_avatar(
|
pub async fn workspace_upload_avatar(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
data: Vec<u8>,
|
data: Vec<u8>,
|
||||||
content_type: Option<String>,
|
content_type: Option<String>,
|
||||||
file_name: Option<String>,
|
file_name: Option<String>,
|
||||||
) -> Result<Workspace, AppError> {
|
) -> Result<Workspace, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -473,12 +453,7 @@ impl WorkspaceService {
|
|||||||
crate::service::util::validate_avatar_size(data.len(), 5 * 1024 * 1024)?;
|
crate::service::util::validate_avatar_size(data.len(), 5 * 1024 * 1024)?;
|
||||||
|
|
||||||
let old_avatar_url = ws.avatar_url.clone();
|
let old_avatar_url = ws.avatar_url.clone();
|
||||||
let storage_key = format!(
|
let storage_key = format!("workspaces/{}/avatar/{}.{}", ws.id, Uuid::now_v7(), ext);
|
||||||
"workspaces/{}/avatar/{}.{}",
|
|
||||||
workspace_id,
|
|
||||||
Uuid::now_v7(),
|
|
||||||
ext
|
|
||||||
);
|
|
||||||
self.ctx.storage.put(&storage_key, data).await?;
|
self.ctx.storage.put(&storage_key, data).await?;
|
||||||
let avatar_url = self.ctx.storage.public_url(&storage_key).ok_or_else(|| {
|
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())
|
AppError::Config("APP_S3_PUBLIC_URL is required for avatar upload".into())
|
||||||
@@ -506,7 +481,7 @@ impl WorkspaceService {
|
|||||||
)
|
)
|
||||||
.bind(&avatar_url)
|
.bind(&avatar_url)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.fetch_optional(&mut *txn)
|
.fetch_optional(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -557,12 +532,12 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_user_role(
|
pub async fn workspace_user_role(
|
||||||
&self,
|
&self,
|
||||||
user_uid: Uuid,
|
user_uid: Uuid,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
) -> Result<Option<Role>, AppError> {
|
) -> Result<Option<Role>, AppError> {
|
||||||
let role_str: Option<String> = sqlx::query_scalar(
|
let role_str: Option<String> = sqlx::query_scalar(
|
||||||
"SELECT role FROM workspace_member WHERE workspace_id = $1 AND user_id = $2 AND status = 'active'",
|
"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)
|
.bind(user_uid)
|
||||||
.fetch_optional(self.ctx.db.reader())
|
.fetch_optional(self.ctx.db.reader())
|
||||||
.await
|
.await
|
||||||
@@ -571,7 +546,6 @@ impl WorkspaceService {
|
|||||||
match role_str {
|
match role_str {
|
||||||
Some(r) => Ok(Some(r.parse().unwrap_or(Role::Unknown))),
|
Some(r) => Ok(Some(r.parse().unwrap_or(Role::Unknown))),
|
||||||
None => {
|
None => {
|
||||||
let ws = self.find_workspace_by_id(workspace_id).await?;
|
|
||||||
if ws.owner_id == user_uid {
|
if ws.owner_id == user_uid {
|
||||||
return Ok(Some(Role::Owner));
|
return Ok(Some(Role::Owner));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::models::common::Role;
|
use crate::models::common::Role;
|
||||||
use crate::models::workspaces::WorkspaceDomain;
|
use crate::models::workspaces::{Workspace, WorkspaceDomain};
|
||||||
use crate::service::WorkspaceService;
|
use crate::service::WorkspaceService;
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
|
|
||||||
@@ -18,12 +18,11 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_domains(
|
pub async fn workspace_domains(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<WorkspaceDomain>, AppError> {
|
) -> Result<Vec<WorkspaceDomain>, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||||
@@ -32,7 +31,7 @@ impl WorkspaceService {
|
|||||||
verified_at, created_at, updated_at FROM workspace_domain \
|
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",
|
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(limit)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(self.ctx.db.reader())
|
.fetch_all(self.ctx.db.reader())
|
||||||
@@ -43,11 +42,10 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_add_domain(
|
pub async fn workspace_add_domain(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
params: AddDomainParams,
|
params: AddDomainParams,
|
||||||
) -> Result<WorkspaceDomain, AppError> {
|
) -> Result<WorkspaceDomain, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
let domain = required_text(params.domain, "domain")?.to_lowercase();
|
let domain = required_text(params.domain, "domain")?.to_lowercase();
|
||||||
@@ -55,7 +53,7 @@ impl WorkspaceService {
|
|||||||
let is_first = sqlx::query_scalar::<_, i64>(
|
let is_first = sqlx::query_scalar::<_, i64>(
|
||||||
"SELECT COUNT(*) FROM workspace_domain WHERE workspace_id = $1",
|
"SELECT COUNT(*) FROM workspace_domain WHERE workspace_id = $1",
|
||||||
)
|
)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.fetch_one(self.ctx.db.reader())
|
.fetch_one(self.ctx.db.reader())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?
|
.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",
|
RETURNING id, workspace_id, domain, verification_token_hash, is_primary, is_verified, verified_at, created_at, updated_at",
|
||||||
)
|
)
|
||||||
.bind(Uuid::now_v7())
|
.bind(Uuid::now_v7())
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.bind(&domain)
|
.bind(&domain)
|
||||||
.bind(&token_hash)
|
.bind(&token_hash)
|
||||||
.bind(is_first)
|
.bind(is_first)
|
||||||
@@ -100,11 +98,10 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_verify_domain(
|
pub async fn workspace_verify_domain(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
domain_id: Uuid,
|
domain_id: Uuid,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
@@ -128,7 +125,7 @@ impl WorkspaceService {
|
|||||||
)
|
)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(domain_id)
|
.bind(domain_id)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -144,11 +141,10 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_set_primary_domain(
|
pub async fn workspace_set_primary_domain(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
domain_id: Uuid,
|
domain_id: Uuid,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Owner)
|
||||||
.await?;
|
.await?;
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
@@ -172,7 +168,7 @@ impl WorkspaceService {
|
|||||||
WHERE id = $1 AND workspace_id = $2",
|
WHERE id = $1 AND workspace_id = $2",
|
||||||
)
|
)
|
||||||
.bind(domain_id)
|
.bind(domain_id)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.fetch_optional(&mut *txn)
|
.fetch_optional(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?
|
.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")
|
sqlx::query("UPDATE workspace_domain SET is_primary = false, updated_at = $1 WHERE workspace_id = $2 AND is_primary = true")
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -205,11 +201,10 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_delete_domain(
|
pub async fn workspace_delete_domain(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
domain_id: Uuid,
|
domain_id: Uuid,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -217,7 +212,7 @@ impl WorkspaceService {
|
|||||||
"SELECT is_primary FROM workspace_domain WHERE id = $1 AND workspace_id = $2",
|
"SELECT is_primary FROM workspace_domain WHERE id = $1 AND workspace_id = $2",
|
||||||
)
|
)
|
||||||
.bind(domain_id)
|
.bind(domain_id)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.fetch_optional(self.ctx.db.reader())
|
.fetch_optional(self.ctx.db.reader())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?
|
.map_err(AppError::Database)?
|
||||||
@@ -243,7 +238,7 @@ impl WorkspaceService {
|
|||||||
let result =
|
let result =
|
||||||
sqlx::query("DELETE FROM workspace_domain WHERE id = $1 AND workspace_id = $2")
|
sqlx::query("DELETE FROM workspace_domain WHERE id = $1 AND workspace_id = $2")
|
||||||
.bind(domain_id)
|
.bind(domain_id)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::models::common::Provider;
|
use crate::models::common::Provider;
|
||||||
use crate::models::workspaces::WorkspaceIntegration;
|
use crate::models::workspaces::{Workspace, WorkspaceIntegration};
|
||||||
use crate::service::WorkspaceService;
|
use crate::service::WorkspaceService;
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
|
|
||||||
@@ -30,12 +30,11 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_integrations(
|
pub async fn workspace_integrations(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<WorkspaceIntegration>, AppError> {
|
) -> Result<Vec<WorkspaceIntegration>, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, crate::models::common::Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
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 \
|
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",
|
WHERE workspace_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||||
)
|
)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(self.ctx.db.reader())
|
.fetch_all(self.ctx.db.reader())
|
||||||
@@ -55,11 +54,10 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_create_integration(
|
pub async fn workspace_create_integration(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
params: CreateIntegrationParams,
|
params: CreateIntegrationParams,
|
||||||
) -> Result<WorkspaceIntegration, AppError> {
|
) -> Result<WorkspaceIntegration, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, crate::models::common::Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -95,7 +93,7 @@ impl WorkspaceService {
|
|||||||
installed_by, last_used_at, created_at, updated_at",
|
installed_by, last_used_at, created_at, updated_at",
|
||||||
)
|
)
|
||||||
.bind(Uuid::now_v7())
|
.bind(Uuid::now_v7())
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.bind(provider)
|
.bind(provider)
|
||||||
.bind(&name)
|
.bind(&name)
|
||||||
.bind(params.config.map(sqlx::types::Json))
|
.bind(params.config.map(sqlx::types::Json))
|
||||||
@@ -114,12 +112,11 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_update_integration(
|
pub async fn workspace_update_integration(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
integration_id: Uuid,
|
integration_id: Uuid,
|
||||||
params: UpdateIntegrationParams,
|
params: UpdateIntegrationParams,
|
||||||
) -> Result<WorkspaceIntegration, AppError> {
|
) -> Result<WorkspaceIntegration, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, crate::models::common::Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -129,7 +126,7 @@ impl WorkspaceService {
|
|||||||
WHERE id = $1 AND workspace_id = $2",
|
WHERE id = $1 AND workspace_id = $2",
|
||||||
)
|
)
|
||||||
.bind(integration_id)
|
.bind(integration_id)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.fetch_optional(self.ctx.db.reader())
|
.fetch_optional(self.ctx.db.reader())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?
|
.map_err(AppError::Database)?
|
||||||
@@ -172,7 +169,7 @@ impl WorkspaceService {
|
|||||||
.bind(enabled)
|
.bind(enabled)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(integration_id)
|
.bind(integration_id)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.fetch_one(&mut *txn)
|
.fetch_one(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -184,11 +181,10 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_delete_integration(
|
pub async fn workspace_delete_integration(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
integration_id: Uuid,
|
integration_id: Uuid,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, crate::models::common::Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -208,7 +204,7 @@ impl WorkspaceService {
|
|||||||
let result =
|
let result =
|
||||||
sqlx::query("DELETE FROM workspace_integration WHERE id = $1 AND workspace_id = $2")
|
sqlx::query("DELETE FROM workspace_integration WHERE id = $1 AND workspace_id = $2")
|
||||||
.bind(integration_id)
|
.bind(integration_id)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::models::common::Role;
|
use crate::models::common::Role;
|
||||||
use crate::models::workspaces::WorkspaceInvitation;
|
use crate::models::workspaces::{Workspace, WorkspaceInvitation};
|
||||||
use crate::pb::email::{EmailAddress, SendEmailRequest};
|
use crate::pb::email::{EmailAddress, SendEmailRequest};
|
||||||
use crate::service::WorkspaceService;
|
use crate::service::WorkspaceService;
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
@@ -25,12 +25,11 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_invitations(
|
pub async fn workspace_invitations(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<WorkspaceInvitation>, AppError> {
|
) -> Result<Vec<WorkspaceInvitation>, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
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 \
|
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",
|
AND expires_at > NOW() ORDER BY created_at DESC LIMIT $2 OFFSET $3",
|
||||||
)
|
)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(self.ctx.db.reader())
|
.fetch_all(self.ctx.db.reader())
|
||||||
@@ -51,11 +50,10 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_create_invitation(
|
pub async fn workspace_create_invitation(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
params: CreateInvitationParams,
|
params: CreateInvitationParams,
|
||||||
) -> Result<CreateInvitationResponse, AppError> {
|
) -> Result<CreateInvitationResponse, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||||
let ws = self.find_workspace_by_id(workspace_id).await?;
|
|
||||||
let actor_role = self
|
let actor_role = self
|
||||||
.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -70,7 +68,7 @@ impl WorkspaceService {
|
|||||||
WHERE workspace_id = $1 AND lower(email) = lower($2) \
|
WHERE workspace_id = $1 AND lower(email) = lower($2) \
|
||||||
AND revoked_at IS NULL AND accepted_at IS NULL AND expires_at > NOW())",
|
AND revoked_at IS NULL AND accepted_at IS NULL AND expires_at > NOW())",
|
||||||
)
|
)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.bind(&email)
|
.bind(&email)
|
||||||
.fetch_one(self.ctx.db.reader())
|
.fetch_one(self.ctx.db.reader())
|
||||||
.await
|
.await
|
||||||
@@ -92,7 +90,6 @@ impl WorkspaceService {
|
|||||||
return Err(AppError::BadRequest("invalid role for invitation".into()));
|
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) {
|
if actor_role != Role::Owner && role_level(role) >= role_level(actor_role) {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"cannot invite with role equal to or higher than your own".into(),
|
"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",
|
accepted_at, revoked_at, expires_at, created_at",
|
||||||
)
|
)
|
||||||
.bind(Uuid::now_v7())
|
.bind(Uuid::now_v7())
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.bind(&email)
|
.bind(&email)
|
||||||
.bind(role.to_string())
|
.bind(role.to_string())
|
||||||
.bind(&token_hash)
|
.bind(&token_hash)
|
||||||
@@ -167,11 +164,10 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_revoke_invitation(
|
pub async fn workspace_revoke_invitation(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
invitation_id: Uuid,
|
invitation_id: Uuid,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -195,7 +191,7 @@ impl WorkspaceService {
|
|||||||
)
|
)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(invitation_id)
|
.bind(invitation_id)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use uuid::Uuid;
|
|||||||
use super::util::{clamp_limit_offset, ensure_affected, role_level};
|
use super::util::{clamp_limit_offset, ensure_affected, role_level};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::models::common::Role;
|
use crate::models::common::Role;
|
||||||
use crate::models::workspaces::WorkspaceMember;
|
use crate::models::workspaces::{Workspace, WorkspaceMember};
|
||||||
use crate::service::WorkspaceService;
|
use crate::service::WorkspaceService;
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
|
|
||||||
@@ -23,12 +23,11 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_members(
|
pub async fn workspace_members(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<WorkspaceMember>, AppError> {
|
) -> Result<Vec<WorkspaceMember>, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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_readable(user_uid, &ws).await?;
|
||||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
let (limit, offset) = clamp_limit_offset(limit, offset);
|
||||||
sqlx::query_as::<_, WorkspaceMember>(
|
sqlx::query_as::<_, WorkspaceMember>(
|
||||||
@@ -36,7 +35,7 @@ impl WorkspaceService {
|
|||||||
last_active_at, created_at, updated_at FROM workspace_member \
|
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",
|
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(limit)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(self.ctx.db.reader())
|
.fetch_all(self.ctx.db.reader())
|
||||||
@@ -47,11 +46,10 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_add_member(
|
pub async fn workspace_add_member(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
params: AddMemberParams,
|
params: AddMemberParams,
|
||||||
) -> Result<WorkspaceMember, AppError> {
|
) -> Result<WorkspaceMember, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||||
let ws = self.find_workspace_by_id(workspace_id).await?;
|
|
||||||
let actor_role = self
|
let actor_role = self
|
||||||
.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -59,7 +57,7 @@ impl WorkspaceService {
|
|||||||
let settings_allow = sqlx::query_scalar::<_, bool>(
|
let settings_allow = sqlx::query_scalar::<_, bool>(
|
||||||
"SELECT allow_member_invites FROM workspace_settings WHERE workspace_id = $1",
|
"SELECT allow_member_invites FROM workspace_settings WHERE workspace_id = $1",
|
||||||
)
|
)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.fetch_one(self.ctx.db.reader())
|
.fetch_one(self.ctx.db.reader())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -73,7 +71,7 @@ impl WorkspaceService {
|
|||||||
let existing = sqlx::query_scalar::<_, bool>(
|
let existing = sqlx::query_scalar::<_, bool>(
|
||||||
"SELECT EXISTS(SELECT 1 FROM workspace_member WHERE workspace_id = $1 AND user_id = $2)",
|
"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)
|
.bind(params.user_id)
|
||||||
.fetch_one(self.ctx.db.reader())
|
.fetch_one(self.ctx.db.reader())
|
||||||
.await
|
.await
|
||||||
@@ -96,7 +94,6 @@ impl WorkspaceService {
|
|||||||
return Err(AppError::BadRequest("invalid role".into()));
|
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) {
|
if actor_role != Role::Owner && role_level(role) >= role_level(actor_role) {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"cannot grant role equal to or higher than your own".into(),
|
"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",
|
RETURNING id, workspace_id, user_id, role, status, invited_by, joined_at, last_active_at, created_at, updated_at",
|
||||||
)
|
)
|
||||||
.bind(Uuid::now_v7())
|
.bind(Uuid::now_v7())
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.bind(params.user_id)
|
.bind(params.user_id)
|
||||||
.bind(role.to_string())
|
.bind(role.to_string())
|
||||||
.bind(user_uid)
|
.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",
|
"UPDATE workspace_stats SET members_count = members_count + 1, updated_at = $1 WHERE workspace_id = $2",
|
||||||
)
|
)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -148,12 +145,11 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_update_member_role(
|
pub async fn workspace_update_member_role(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
member_id: Uuid,
|
member_id: Uuid,
|
||||||
params: UpdateMemberRoleParams,
|
params: UpdateMemberRoleParams,
|
||||||
) -> Result<WorkspaceMember, AppError> {
|
) -> Result<WorkspaceMember, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||||
let ws = self.find_workspace_by_id(workspace_id).await?;
|
|
||||||
let actor_role = self
|
let actor_role = self
|
||||||
.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -171,7 +167,6 @@ impl WorkspaceService {
|
|||||||
return Err(AppError::BadRequest("invalid role".into()));
|
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) {
|
if actor_role != Role::Owner && role_level(new_role) >= role_level(actor_role) {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"cannot grant role equal to or higher than your own".into(),
|
"cannot grant role equal to or higher than your own".into(),
|
||||||
@@ -184,7 +179,7 @@ impl WorkspaceService {
|
|||||||
WHERE id = $1 AND workspace_id = $2",
|
WHERE id = $1 AND workspace_id = $2",
|
||||||
)
|
)
|
||||||
.bind(member_id)
|
.bind(member_id)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.fetch_optional(self.ctx.db.reader())
|
.fetch_optional(self.ctx.db.reader())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?
|
.map_err(AppError::Database)?
|
||||||
@@ -222,7 +217,7 @@ impl WorkspaceService {
|
|||||||
.bind(new_role.to_string())
|
.bind(new_role.to_string())
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(member_id)
|
.bind(member_id)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.fetch_one(&mut *txn)
|
.fetch_one(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -234,11 +229,10 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_remove_member(
|
pub async fn workspace_remove_member(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
member_id: Uuid,
|
member_id: Uuid,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||||
let ws = self.find_workspace_by_id(workspace_id).await?;
|
|
||||||
let actor_role = self
|
let actor_role = self
|
||||||
.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -249,7 +243,7 @@ impl WorkspaceService {
|
|||||||
WHERE id = $1 AND workspace_id = $2",
|
WHERE id = $1 AND workspace_id = $2",
|
||||||
)
|
)
|
||||||
.bind(member_id)
|
.bind(member_id)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.fetch_optional(self.ctx.db.reader())
|
.fetch_optional(self.ctx.db.reader())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?
|
.map_err(AppError::Database)?
|
||||||
@@ -283,7 +277,7 @@ impl WorkspaceService {
|
|||||||
let result =
|
let result =
|
||||||
sqlx::query("DELETE FROM workspace_member WHERE id = $1 AND workspace_id = $2")
|
sqlx::query("DELETE FROM workspace_member WHERE id = $1 AND workspace_id = $2")
|
||||||
.bind(member_id)
|
.bind(member_id)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.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",
|
"UPDATE workspace_stats SET members_count = GREATEST(members_count - 1, 0), updated_at = $1 WHERE workspace_id = $2",
|
||||||
)
|
)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -302,9 +296,8 @@ impl WorkspaceService {
|
|||||||
Ok(())
|
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 user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
||||||
let ws = self.find_workspace_by_id(workspace_id).await?;
|
|
||||||
|
|
||||||
if ws.owner_id == user_uid {
|
if ws.owner_id == user_uid {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
@@ -328,7 +321,7 @@ impl WorkspaceService {
|
|||||||
|
|
||||||
let result =
|
let result =
|
||||||
sqlx::query("DELETE FROM workspace_member WHERE workspace_id = $1 AND user_id = $2")
|
sqlx::query("DELETE FROM workspace_member WHERE workspace_id = $1 AND user_id = $2")
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.bind(user_uid)
|
.bind(user_uid)
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.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",
|
"UPDATE workspace_stats SET members_count = GREATEST(members_count - 1, 0), updated_at = $1 WHERE workspace_id = $2",
|
||||||
)
|
)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::models::workspaces::WorkspaceSettings;
|
use crate::models::workspaces::{Workspace, WorkspaceSettings};
|
||||||
use crate::service::WorkspaceService;
|
use crate::service::WorkspaceService;
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
|
|
||||||
@@ -24,26 +24,24 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_settings(
|
pub async fn workspace_settings(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
) -> Result<WorkspaceSettings, AppError> {
|
) -> Result<WorkspaceSettings, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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_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(
|
pub async fn workspace_update_settings(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
params: UpdateWorkspaceSettingsParams,
|
params: UpdateWorkspaceSettingsParams,
|
||||||
) -> Result<WorkspaceSettings, AppError> {
|
) -> Result<WorkspaceSettings, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, crate::models::common::Role::Admin)
|
||||||
.await?;
|
.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 now = chrono::Utc::now();
|
||||||
let mut txn = self
|
let mut txn = self
|
||||||
.ctx
|
.ctx
|
||||||
@@ -77,7 +75,7 @@ impl WorkspaceService {
|
|||||||
.bind(params.pull_requests_enabled.unwrap_or(current.pull_requests_enabled))
|
.bind(params.pull_requests_enabled.unwrap_or(current.pull_requests_enabled))
|
||||||
.bind(params.wiki_enabled.unwrap_or(current.wiki_enabled))
|
.bind(params.wiki_enabled.unwrap_or(current.wiki_enabled))
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.fetch_one(&mut *txn)
|
.fetch_one(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::models::common::Role;
|
use crate::models::common::Role;
|
||||||
use crate::models::workspaces::WorkspaceStats;
|
use crate::models::workspaces::{Workspace, WorkspaceStats};
|
||||||
use crate::service::WorkspaceService;
|
use crate::service::WorkspaceService;
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
|
|
||||||
@@ -10,28 +10,26 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_stats(
|
pub async fn workspace_stats(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
) -> Result<WorkspaceStats, AppError> {
|
) -> Result<WorkspaceStats, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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_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(
|
pub async fn workspace_refresh_stats(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
) -> Result<WorkspaceStats, AppError> {
|
) -> Result<WorkspaceStats, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let members_count = sqlx::query_scalar::<_, i64>(
|
let members_count = sqlx::query_scalar::<_, i64>(
|
||||||
"SELECT COUNT(*) FROM workspace_member WHERE workspace_id = $1 AND status = 'active'",
|
"SELECT COUNT(*) FROM workspace_member WHERE workspace_id = $1 AND status = 'active'",
|
||||||
)
|
)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.fetch_one(self.ctx.db.reader())
|
.fetch_one(self.ctx.db.reader())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -39,7 +37,7 @@ impl WorkspaceService {
|
|||||||
let repos_count = sqlx::query_scalar::<_, i64>(
|
let repos_count = sqlx::query_scalar::<_, i64>(
|
||||||
"SELECT COUNT(*) FROM repo WHERE workspace_id = $1 AND deleted_at IS NULL",
|
"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())
|
.fetch_one(self.ctx.db.reader())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -47,7 +45,7 @@ impl WorkspaceService {
|
|||||||
let issues_count = sqlx::query_scalar::<_, i64>(
|
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",
|
"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())
|
.fetch_one(self.ctx.db.reader())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -55,7 +53,7 @@ impl WorkspaceService {
|
|||||||
let prs_count = sqlx::query_scalar::<_, i64>(
|
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",
|
"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())
|
.fetch_one(self.ctx.db.reader())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -72,7 +70,7 @@ impl WorkspaceService {
|
|||||||
.bind(issues_count)
|
.bind(issues_count)
|
||||||
.bind(prs_count)
|
.bind(prs_count)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.fetch_one(self.ctx.db.writer())
|
.fetch_one(self.ctx.db.writer())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
@@ -5,35 +5,27 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::models::common::{EventType, Role};
|
use crate::models::common::{EventType, Role};
|
||||||
use crate::models::workspaces::WorkspaceWebhook;
|
use crate::models::workspaces::{Workspace, WorkspaceWebhook};
|
||||||
use crate::service::WorkspaceService;
|
use crate::service::WorkspaceService;
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
|
|
||||||
use super::util::{clamp_limit_offset, ensure_affected, required_text};
|
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> {
|
fn validate_webhook_url(url_str: &str) -> Result<(), AppError> {
|
||||||
let url = Url::parse(url_str).map_err(|_| AppError::BadRequest("Invalid URL format".into()))?;
|
let url = Url::parse(url_str).map_err(|_| AppError::BadRequest("Invalid URL format".into()))?;
|
||||||
|
|
||||||
// Only allow HTTPS
|
|
||||||
if url.scheme() != "https" {
|
if url.scheme() != "https" {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"Webhook URL must use HTTPS protocol".into(),
|
"Webhook URL must use HTTPS protocol".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let host = url
|
let host = url
|
||||||
.host_str()
|
.host_str()
|
||||||
.ok_or_else(|| AppError::BadRequest("URL must have a host".into()))?;
|
.ok_or_else(|| AppError::BadRequest("URL must have a host".into()))?;
|
||||||
|
|
||||||
// Reject IP addresses directly (require domain names)
|
|
||||||
if host.parse::<IpAddr>().is_ok() {
|
if host.parse::<IpAddr>().is_ok() {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"Webhook URL must use a domain name, not an IP address".into(),
|
"Webhook URL must use a domain name, not an IP address".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject localhost and common local domains
|
|
||||||
let host_lower = host.to_lowercase();
|
let host_lower = host.to_lowercase();
|
||||||
if host_lower == "localhost"
|
if host_lower == "localhost"
|
||||||
|| host_lower.ends_with(".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(),
|
"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" {
|
if host == "169.254.169.254" || host == "metadata.google.internal" {
|
||||||
return Err(AppError::BadRequest(
|
return Err(AppError::BadRequest(
|
||||||
"Webhook URL cannot point to cloud metadata endpoints".into(),
|
"Webhook URL cannot point to cloud metadata endpoints".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,12 +67,11 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_webhooks(
|
pub async fn workspace_webhooks(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<WorkspaceWebhook>, AppError> {
|
) -> Result<Vec<WorkspaceWebhook>, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
let (limit, offset) = clamp_limit_offset(limit, offset);
|
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 \
|
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",
|
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(limit)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(self.ctx.db.reader())
|
.fetch_all(self.ctx.db.reader())
|
||||||
@@ -103,11 +91,10 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_create_webhook(
|
pub async fn workspace_create_webhook(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
params: CreateWebhookParams,
|
params: CreateWebhookParams,
|
||||||
) -> Result<WorkspaceWebhook, AppError> {
|
) -> Result<WorkspaceWebhook, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -141,7 +128,7 @@ impl WorkspaceService {
|
|||||||
last_delivery_status, last_delivery_at, created_by, created_at, updated_at",
|
last_delivery_status, last_delivery_at, created_by, created_at, updated_at",
|
||||||
)
|
)
|
||||||
.bind(Uuid::now_v7())
|
.bind(Uuid::now_v7())
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.bind(&url)
|
.bind(&url)
|
||||||
.bind(¶ms.secret_ciphertext)
|
.bind(¶ms.secret_ciphertext)
|
||||||
.bind(¶ms.events)
|
.bind(¶ms.events)
|
||||||
@@ -159,12 +146,11 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_update_webhook(
|
pub async fn workspace_update_webhook(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
webhook_id: Uuid,
|
webhook_id: Uuid,
|
||||||
params: UpdateWebhookParams,
|
params: UpdateWebhookParams,
|
||||||
) -> Result<WorkspaceWebhook, AppError> {
|
) -> Result<WorkspaceWebhook, AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -174,7 +160,7 @@ impl WorkspaceService {
|
|||||||
FROM workspace_webhook WHERE id = $1 AND workspace_id = $2",
|
FROM workspace_webhook WHERE id = $1 AND workspace_id = $2",
|
||||||
)
|
)
|
||||||
.bind(webhook_id)
|
.bind(webhook_id)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.fetch_optional(self.ctx.db.reader())
|
.fetch_optional(self.ctx.db.reader())
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?
|
.map_err(AppError::Database)?
|
||||||
@@ -186,7 +172,6 @@ impl WorkspaceService {
|
|||||||
.map(|u| u.trim().to_string())
|
.map(|u| u.trim().to_string())
|
||||||
.unwrap_or(current.url);
|
.unwrap_or(current.url);
|
||||||
|
|
||||||
// Validate URL if it was updated
|
|
||||||
if params.url.is_some() {
|
if params.url.is_some() {
|
||||||
validate_webhook_url(&url)?;
|
validate_webhook_url(&url)?;
|
||||||
}
|
}
|
||||||
@@ -219,7 +204,7 @@ impl WorkspaceService {
|
|||||||
.bind(active)
|
.bind(active)
|
||||||
.bind(now)
|
.bind(now)
|
||||||
.bind(webhook_id)
|
.bind(webhook_id)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.fetch_one(&mut *txn)
|
.fetch_one(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
@@ -231,11 +216,10 @@ impl WorkspaceService {
|
|||||||
pub async fn workspace_delete_webhook(
|
pub async fn workspace_delete_webhook(
|
||||||
&self,
|
&self,
|
||||||
ctx: &Session,
|
ctx: &Session,
|
||||||
workspace_id: Uuid,
|
ws: &Workspace,
|
||||||
webhook_id: Uuid,
|
webhook_id: Uuid,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
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)
|
self.ensure_workspace_role_at_least(user_uid, &ws, Role::Admin)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -255,7 +239,7 @@ impl WorkspaceService {
|
|||||||
let result =
|
let result =
|
||||||
sqlx::query("DELETE FROM workspace_webhook WHERE id = $1 AND workspace_id = $2")
|
sqlx::query("DELETE FROM workspace_webhook WHERE id = $1 AND workspace_id = $2")
|
||||||
.bind(webhook_id)
|
.bind(webhook_id)
|
||||||
.bind(workspace_id)
|
.bind(ws.id)
|
||||||
.execute(&mut *txn)
|
.execute(&mut *txn)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::Database)?;
|
.map_err(AppError::Database)?;
|
||||||
|
|||||||
Reference in New Issue
Block a user