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:
parent
00a5369fe1
commit
9b9c12ffc8
@ -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,
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user