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