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
This commit is contained in:
ZhenYi 2026-04-18 19:05:07 +08:00
parent 00a5369fe1
commit 9b9c12ffc8
5 changed files with 206 additions and 7 deletions

View File

@ -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,

View File

@ -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<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())
}

View File

@ -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),
),
);
}

View File

@ -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<Arc<ChatService>> = 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;

View File

@ -36,6 +36,24 @@ pub struct PendingInvitationInfo {
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>,
@ -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<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,