From 9b9c12ffc852138b9cd09c8db5fa6b7c50385344 Mon Sep 17 00:00:00 2001 From: ZhenYi <434836402@qq.com> Date: Sat, 18 Apr 2026 19:05:07 +0800 Subject: [PATCH] feat(backend): add workspace invitation list and slug-based accept APIs - Add workspace_my_pending_invitations() for listing pending invites - Add workspace_accept_invitation_by_slug() to accept by slug without token - Register new routes: GET /workspaces/me/invitations, POST /workspaces/invitations/accept-by-slug --- libs/api/openapi.rs | 4 + libs/api/workspace/members.rs | 48 +++++++++++- libs/api/workspace/mod.rs | 16 +++- libs/service/lib.rs | 28 ++++++- libs/service/workspace/members.rs | 117 ++++++++++++++++++++++++++++++ 5 files changed, 206 insertions(+), 7 deletions(-) diff --git a/libs/api/openapi.rs b/libs/api/openapi.rs index 7eea55e..ffb8e32 100644 --- a/libs/api/openapi.rs +++ b/libs/api/openapi.rs @@ -452,6 +452,8 @@ use utoipa::OpenApi; crate::workspace::members::workspace_pending_invitations, crate::workspace::members::workspace_cancel_invitation, crate::workspace::members::workspace_accept_invitation, + crate::workspace::members::workspace_my_invitations, + crate::workspace::members::workspace_accept_invitation_by_slug, crate::workspace::settings::workspace_update, crate::workspace::settings::workspace_delete, ), @@ -635,6 +637,8 @@ use utoipa::OpenApi; service::workspace::members::WorkspaceInviteParams, service::workspace::members::WorkspaceInviteAcceptParams, service::workspace::members::PendingInvitationInfo, + service::workspace::members::MyWorkspaceInvitation, + service::workspace::members::WorkspaceAcceptBySlugParams, service::workspace::settings::WorkspaceUpdateParams, // Room room::RoomResponse, diff --git a/libs/api/workspace/members.rs b/libs/api/workspace/members.rs index c544ba6..2a994aa 100644 --- a/libs/api/workspace/members.rs +++ b/libs/api/workspace/members.rs @@ -2,8 +2,8 @@ use crate::{ApiResponse, error::ApiError}; use actix_web::{HttpResponse, Result, web}; use service::AppService; use service::workspace::members::{ - PendingInvitationInfo, WorkspaceInviteAcceptParams, WorkspaceInviteParams, - WorkspaceMembersResponse, + MyWorkspaceInvitation, PendingInvitationInfo, WorkspaceAcceptBySlugParams, + WorkspaceInviteAcceptParams, WorkspaceInviteParams, WorkspaceMembersResponse, }; use session::Session; use uuid::Uuid; @@ -203,3 +203,47 @@ pub async fn workspace_accept_invitation( let resp = service.workspace_info(&session, ws.slug).await?; Ok(ApiResponse::ok(resp).to_response()) } + +/// List all pending workspace invitations for the current user. +#[utoipa::path( + get, + path = "/api/workspaces/me/invitations", + responses( + (status = 200, description = "List my workspace invitations", body = ApiResponse>), + (status = 401, description = "Unauthorized"), + ), + tag = "Workspace" +)] +pub async fn workspace_my_invitations( + service: web::Data, + session: Session, +) -> Result { + let resp = service.workspace_my_pending_invitations(&session).await?; + Ok(ApiResponse::ok(resp).to_response()) +} + +/// Accept a workspace invitation by slug. +#[utoipa::path( + post, + path = "/api/workspaces/invitations/accept-by-slug", + request_body = WorkspaceAcceptBySlugParams, + responses( + (status = 200, description = "Accept invitation", body = ApiResponse), + (status = 400, description = "Invalid or expired token"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Invitation not found"), + (status = 409, description = "Already accepted"), + ), + tag = "Workspace" +)] +pub async fn workspace_accept_invitation_by_slug( + service: web::Data, + session: Session, + body: web::Json, +) -> Result { + let ws = service + .workspace_accept_invitation_by_slug(&session, body.into_inner()) + .await?; + let resp = service.workspace_info(&session, ws.slug).await?; + Ok(ApiResponse::ok(resp).to_response()) +} diff --git a/libs/api/workspace/mod.rs b/libs/api/workspace/mod.rs index eee6615..6bc4bb5 100644 --- a/libs/api/workspace/mod.rs +++ b/libs/api/workspace/mod.rs @@ -48,6 +48,18 @@ pub fn init_workspace_routes(cfg: &mut web::ServiceConfig) { web::patch().to(members::workspace_update_member_role), ) // Invitations + .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), @@ -59,10 +71,6 @@ pub fn init_workspace_routes(cfg: &mut web::ServiceConfig) { .route( "/{slug}/invitations/{user_id}", web::delete().to(members::workspace_cancel_invitation), - ) - .route( - "/invitations/accept", - web::post().to(members::workspace_accept_invitation), ), ); } diff --git a/libs/service/lib.rs b/libs/service/lib.rs index e91eb76..0d9f79b 100644 --- a/libs/service/lib.rs +++ b/libs/service/lib.rs @@ -1,6 +1,8 @@ use std::sync::Arc; +use ::agent::chat::ChatService; use ::agent::task::service::TaskService; +use async_openai::config::OpenAIConfig; use avatar::AppAvatar; use config::AppConfig; use db::cache::AppCache; @@ -132,13 +134,36 @@ impl AppService { .and_then(|urls| urls.first().cloned()) .unwrap_or_else(|| "redis://127.0.0.1:6379".to_string()); + // Build ChatService if AI is configured; otherwise AI chat is disabled (graceful degradation) + let chat_service: Option> = match ( + config.ai_api_key(), + config.ai_basic_url(), + ) { + (Ok(api_key), Ok(base_url)) => { + slog::info!(logs, "AI chat enabled — connecting to {}", base_url); + let cfg = OpenAIConfig::new() + .with_api_key(&api_key) + .with_api_base(&base_url); + let client = async_openai::Client::with_config(cfg); + Some(Arc::new(ChatService::new(client))) + } + (Err(e), _) => { + slog::warn!(logs, "AI chat disabled — {}", e); + None + } + (_, Err(e)) => { + slog::warn!(logs, "AI chat disabled — {}", e); + None + } + }; + let room = RoomService::new( db.clone(), cache.clone(), message_producer.clone(), room_manager, redis_url, - None, + chat_service, Some(task_service.clone()), logs.clone(), None, @@ -200,6 +225,7 @@ pub mod agent; pub mod auth; pub mod error; pub mod git; +pub mod git_tools; pub mod issue; pub mod project; pub mod pull_request; diff --git a/libs/service/workspace/members.rs b/libs/service/workspace/members.rs index 33cf064..1c4d5d4 100644 --- a/libs/service/workspace/members.rs +++ b/libs/service/workspace/members.rs @@ -36,6 +36,24 @@ pub struct PendingInvitationInfo { pub expires_at: Option>, } +/// Invitation received by the current user (workspace invitation for self). +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct MyWorkspaceInvitation { + pub workspace_id: Uuid, + pub workspace_slug: String, + pub workspace_name: String, + pub role: String, + pub invited_by_username: Option, + pub invited_at: chrono::DateTime, + pub expires_at: Option>, +} + +/// Request body for accepting workspace invitation by slug. +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct WorkspaceAcceptBySlugParams { + pub slug: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct WorkspaceMembersResponse { pub members: Vec, @@ -395,6 +413,105 @@ impl AppService { self.utils_find_workspace_by_id(ws_id).await } + /// List all pending workspace invitations for the current user (where user is invitee). + pub async fn workspace_my_pending_invitations( + &self, + ctx: &Session, + ) -> Result, AppError> { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + + let pending = workspace_membership::Entity::find() + .filter(workspace_membership::Column::UserId.eq(user_uid)) + .filter(workspace_membership::Column::Status.eq("pending")) + .order_by_desc(workspace_membership::Column::JoinedAt) + .all(&self.db) + .await?; + + if pending.is_empty() { + return Ok(vec![]); + } + + // Fetch workspace info + let ws_ids: Vec = pending.iter().map(|m| m.workspace_id).collect(); + let workspaces: std::collections::HashMap = workspace::Entity::find() + .filter(workspace::Column::Id.is_in(ws_ids.clone())) + .all(&self.db) + .await? + .into_iter() + .map(|w| (w.id, w)) + .collect(); + + // Fetch inviter usernames + let inviter_ids: Vec = pending.iter().filter_map(|m| m.invited_by).collect(); + let inviters: std::collections::HashMap = if !inviter_ids.is_empty() { + user::Entity::find() + .filter(user::Column::Uid.is_in(inviter_ids)) + .all(&self.db) + .await? + .into_iter() + .map(|u| (u.uid, u.username)) + .collect() + } else { + std::collections::HashMap::new() + }; + + let invitations: Vec = pending + .into_iter() + .filter_map(|m| { + let ws = workspaces.get(&m.workspace_id)?; + let invited_by_username = m.invited_by.and_then(|uid| inviters.get(&uid).cloned()); + Some(MyWorkspaceInvitation { + workspace_id: m.workspace_id, + workspace_slug: ws.slug.clone(), + workspace_name: ws.name.clone(), + role: m.role, + invited_by_username, + invited_at: m.joined_at, + expires_at: m.invite_expires_at, + }) + }) + .collect(); + + Ok(invitations) + } + + /// Accept a workspace invitation by slug (for the current user). + pub async fn workspace_accept_invitation_by_slug( + &self, + ctx: &Session, + params: WorkspaceAcceptBySlugParams, + ) -> Result { + let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; + let ws = self.utils_find_workspace_by_slug(params.slug.clone()).await?; + + let membership = workspace_membership::Entity::find() + .filter(workspace_membership::Column::WorkspaceId.eq(ws.id)) + .filter(workspace_membership::Column::UserId.eq(user_uid)) + .one(&self.db) + .await? + .ok_or(AppError::NotFound( + "No pending invitation found for this workspace".to_string(), + ))?; + + if membership.status == "active" { + return Err(AppError::WorkspaceInviteAlreadyAccepted); + } + + if let Some(expires_at) = membership.invite_expires_at { + if Utc::now() > expires_at { + return Err(AppError::WorkspaceInviteExpired); + } + } + + let mut m: workspace_membership::ActiveModel = membership.into(); + m.status = Set("active".to_string()); + m.invite_token = Set(None); + m.invite_expires_at = Set(None); + m.update(&self.db).await?; + + Ok(ws) + } + pub async fn workspace_remove_member( &self, ctx: &Session,