use super::extension_common::org_error;
use crate::organization::{
OrganizationConfig, OrganizationMemberAddedContext, OrganizationMemberAdditionContext,
OrganizationMemberAdditionDraft,
extensions::TeamLimitContext,
types::{AddOrganizationMemberRequest, BasicMemberResponse, OrganizationResponse},
};
use alibi_core::error::codes;
use alibi_core::{
AuthContext, AuthError, AuthRequest, AuthResult, AuthSchema, CreateMember,
entity::{AuthSession, AuthUser},
types::{AddTeamMemberResult, HttpMethod},
};
use std::collections::HashMap;
pub(crate) async fn add_member<S: AuthSchema>(
body: &AddOrganizationMemberRequest,
headers: &HashMap<String, String>,
config: &OrganizationConfig,
ctx: &AuthContext<S>,
) -> AuthResult<BasicMemberResponse> {
let session = if body.user_id.is_empty() {
None
} else {
let mut resolution = AuthRequest::new(HttpMethod::Post, "/organization/add-member");
resolution.headers = headers
.iter()
.map(|(name, value)| (name.to_ascii_lowercase(), value.clone()))
.collect();
ctx.require_cached_session(&resolution).await.ok()
};
add_member_with_session(body, session, None, config, ctx).await
}
#[expect(
clippy::as_conversions,
clippy::cast_precision_loss,
reason = "JavaScript compares actual membership counts as Number"
)]
#[expect(
clippy::too_many_lines,
reason = "Keep admission callbacks and persisted membership in one ordered operation"
)]
pub(crate) async fn add_member_with_session<S: AuthSchema>(
body: &AddOrganizationMemberRequest,
session: Option<(alibi_core::AuthenticatedUser<S>, alibi_core::SessionView)>,
request: Option<&AuthRequest>,
config: &OrganizationConfig,
ctx: &AuthContext<S>,
) -> AuthResult<BasicMemberResponse> {
let organization_id = body
.organization_id
.as_deref()
.filter(|id| !id.is_empty())
.or_else(|| {
let (_, session) = session.as_ref()?;
session.active_organization_id()
})
.filter(|id| !id.is_empty())
.ok_or_else(|| org_error(400, codes::NO_ACTIVE_ORGANIZATION))?;
let team_id = body.team_id.as_deref().filter(|id| !id.is_empty());
if team_id.is_some() && !config.teams.enabled {
return Err(AuthError::Api {
status: 400,
code: None,
message: "Teams are not enabled".into(),
});
}
let user = ctx
.database
.get_user_by_id(&body.user_id)
.await?
.ok_or(AuthError::Upstream {
status: 400,
code: codes::USER_NOT_FOUND,
message: "User not found",
})?;
let email = user
.email()
.ok_or_else(|| AuthError::internal("Member admission requires a target email"))?;
if let Some(email_user) = ctx
.database
.get_user_by_email(&email.to_lowercase())
.await?
&& ctx
.database
.get_member(organization_id, &email_user.id())
.await?
.is_some()
{
return Err(AuthError::Upstream {
status: 400,
code: codes::USER_IS_ALREADY_A_MEMBER_OF_THIS_ORGANIZATION,
message: "User is already a member of this organization",
});
}
if let Some(team_id) = team_id
&& ctx
.database
.get_team(Some(organization_id), team_id)
.await?
.is_none()
{
return Err(org_error(400, codes::TEAM_NOT_FOUND));
}
let count = ctx
.database
.count_organization_members(organization_id)
.await?;
let organization = ctx
.database
.get_organization_by_id(organization_id)
.await?
.ok_or_else(|| org_error(400, codes::ORGANIZATION_NOT_FOUND))?;
let original_user = ctx.user_view(&user);
let original_organization = OrganizationResponse::from_stored_organization(&organization)?;
let limit = crate::organization::policy::admission_limit(
config.membership_limit.as_ref(),
&original_user,
&original_organization,
)
.await?;
if count as f64 >= limit {
return Err(AuthError::Upstream {
status: 403,
code: codes::ORGANIZATION_MEMBERSHIP_LIMIT_REACHED,
message: "Organization membership limit reached",
});
}
let mut draft = CreateMember {
organization_id: organization_id.to_owned(),
user_id: user.id().into_owned(),
role: body.role.joined(),
};
if let Some(hooks) = &config.member_addition_hooks {
let context = OrganizationMemberAdditionContext {
member: OrganizationMemberAdditionDraft {
user_id: draft.user_id.clone(),
organization_id: draft.organization_id.clone(),
role: draft.role.clone(),
team_id: body.team_id.clone(),
},
user: original_user.clone(),
organization: original_organization.clone(),
};
if let Some(patch) = hooks.before_add_member(&context).await? {
patch.apply(&mut draft);
}
}
let member = ctx.database.create_member(draft).await?;
if let Some(team_id) = team_id {
let admission = async {
let maximum = if let Some(resolver) = &config.teams.limit_resolver {
let (actor, actor_session) = session.as_ref().ok_or(AuthError::Unauthenticated)?;
resolver
.maximum_team_members(&TeamLimitContext {
organization_id: organization_id.to_owned(),
team_id: Some(team_id.to_owned()),
session: Some(actor_session.clone()),
user: Some(ctx.user_view(actor)),
request: request.cloned(),
})
.await?
} else {
config.teams.maximum_members_per_team
};
match ctx
.database
.add_team_member(team_id, &user.id(), maximum)
.await?
{
AddTeamMemberResult::Added(_) | AddTeamMemberResult::Existing(_) => Ok(()),
AddTeamMemberResult::LimitReached => {
Err(org_error(403, codes::TEAM_MEMBER_LIMIT_REACHED))
}
}
}
.await;
if let Err(error) = admission {
ctx.database
.delete_member_with_context(
&member.id,
organization_id,
&user.id(),
config.teams.enabled,
)
.await?;
return Err(error);
}
}
if let Some(hooks) = &config.member_addition_hooks {
hooks
.after_add_member(&OrganizationMemberAddedContext {
member: member.clone(),
user: original_user,
organization: original_organization,
})
.await?;
}
Ok(BasicMemberResponse::from_member(&member))
}