Skip to main content

alibi_plugins/organization/handlers/
team.rs

1use super::super::extensions::{TeamHookContext, TeamLimitContext};
2use super::super::{OrganizationConfig, OrganizationPlugin};
3use super::extension_common::{has_action, org_error, session};
4use alibi_core::entity::AuthUser;
5use alibi_core::types::{AddTeamMemberResult, CreateTeam, Team, UpdateTeam};
6use alibi_core::wire::{SessionView, UserView};
7use alibi_core::{AuthContext, AuthRequest, AuthResponse, AuthResult, AuthSchema, HttpMethod};
8use serde::{Deserialize, Serialize};
9use validator::Validate;
10
11#[serde_with::skip_serializing_none]
12#[derive(Debug, Deserialize, Serialize, Validate)]
13#[serde(rename_all = "camelCase")]
14pub struct CreateTeamRequest {
15    pub name: String,
16    pub organization_id: Option<String>,
17}
18
19#[serde_with::skip_serializing_none]
20#[derive(Debug, Deserialize, Serialize, Validate)]
21#[serde(rename_all = "camelCase")]
22pub struct RemoveTeamRequest {
23    pub team_id: String,
24    pub organization_id: Option<String>,
25}
26
27#[serde_with::skip_serializing_none]
28#[derive(Debug, Deserialize, Serialize, Validate)]
29#[serde(rename_all = "camelCase")]
30pub struct UpdateTeamRequest {
31    pub team_id: String,
32    pub data: UpdateTeamData,
33}
34
35#[serde_with::skip_serializing_none]
36#[derive(Debug, Deserialize, Serialize, Validate)]
37#[serde(rename_all = "camelCase")]
38pub struct UpdateTeamData {
39    #[validate(length(min = 1))]
40    pub name: Option<String>,
41    pub organization_id: Option<String>,
42}
43
44#[serde_with::skip_serializing_none]
45#[derive(Debug, Deserialize, Serialize, Validate)]
46#[serde(rename_all = "camelCase")]
47pub struct TeamMemberRequest {
48    pub team_id: String,
49    #[serde(
50        default = "super::super::types::undefined_string",
51        deserialize_with = "super::super::types::deserialize_coercible_string"
52    )]
53    pub user_id: String,
54    pub organization_id: Option<String>,
55}
56
57#[serde_with::skip_serializing_none]
58#[derive(Debug, Deserialize, Serialize, Validate)]
59pub struct SetActiveTeamRequest {
60    #[serde(
61        default,
62        rename = "teamId",
63        deserialize_with = "super::super::types::deserialize_nullable_string_field"
64    )]
65    #[serde(skip_serializing_if = "super::super::types::NullableStringField::is_missing")]
66    pub team_id: super::super::types::NullableStringField,
67}
68
69impl OrganizationPlugin {
70    /// Controlled server-side creation; the organization is explicit and no request principal is fabricated.
71    ///
72    /// # Errors
73    ///
74    /// Returns errors from input validation, permission checks, storage, or configured organization hooks.
75    pub async fn create_team<S: AuthSchema>(
76        &self,
77        ctx: &AuthContext<S>,
78        data: CreateTeam,
79    ) -> AuthResult<Team> {
80        if !self.config.teams.enabled {
81            return Err(alibi_core::AuthError::NotImplemented(
82                "Teams are disabled".to_owned(),
83            ));
84        }
85        create_team_core(data, None, None, ctx, &self.config).await
86    }
87    /// Create a team with the actual signed-cookie principal from supplied headers.
88    /// This low-level server API enforces the same organization permissions as HTTP;
89    /// builder-wide dispatch hooks and API-key session injection require normal dispatch.
90    ///
91    /// # Errors
92    /// Returns an error if authentication, authorization, policy, hooks, or storage fail.
93    pub async fn create_team_with_headers<S: AuthSchema>(
94        &self,
95        ctx: &AuthContext<S>,
96        headers: &std::collections::HashMap<String, String>,
97        data: CreateTeam,
98    ) -> AuthResult<Team> {
99        if !self.config.teams.enabled {
100            return Err(alibi_core::AuthError::NotImplemented(
101                "Teams are disabled".to_owned(),
102            ));
103        }
104        let mut resolution = AuthRequest::new(HttpMethod::Post, "/organization/create-team");
105        resolution.headers = headers
106            .iter()
107            .map(|(name, value)| (name.to_ascii_lowercase(), value.clone()))
108            .collect();
109        let (user, current) = session(&resolution, ctx).await?;
110        let user = ctx.user_view(&user);
111        create_team_core(data, Some((&user, &current)), None, ctx, &self.config).await
112    }
113
114    /// Remove a team with the actual signed-cookie principal from supplied headers.
115    /// Current active-team and organization permission checks also apply to server calls.
116    /// This low-level helper does not execute builder-wide dispatch hooks.
117    ///
118    /// # Errors
119    /// Returns an error if authentication, authorization, hooks, or storage fail.
120    pub async fn remove_team_with_headers<S: AuthSchema>(
121        &self,
122        ctx: &AuthContext<S>,
123        headers: &std::collections::HashMap<String, String>,
124        organization_id: &str,
125        team_id: &str,
126    ) -> AuthResult<()> {
127        if !self.config.teams.enabled {
128            return Err(alibi_core::AuthError::NotImplemented(
129                "Teams are disabled".to_owned(),
130            ));
131        }
132        let mut resolution = AuthRequest::new(HttpMethod::Post, "/organization/remove-team");
133        resolution.headers = headers
134            .iter()
135            .map(|(name, value)| (name.to_ascii_lowercase(), value.clone()))
136            .collect();
137        let (user, current) = session(&resolution, ctx).await?;
138        let user = ctx.user_view(&user);
139        remove_team_core(
140            organization_id,
141            team_id,
142            Some((&user, &current)),
143            ctx,
144            &self.config,
145        )
146        .await
147    }
148
149    ///
150    /// # Errors
151    ///
152    /// Returns errors from input validation, permission checks, storage, or configured organization hooks.
153    pub async fn remove_team<S: AuthSchema>(
154        &self,
155        ctx: &AuthContext<S>,
156        organization_id: &str,
157        team_id: &str,
158    ) -> AuthResult<()> {
159        if !self.config.teams.enabled {
160            return Err(alibi_core::AuthError::NotImplemented(
161                "Teams are disabled".to_owned(),
162            ));
163        }
164        remove_team_core(organization_id, team_id, None, ctx, &self.config).await
165    }
166}
167
168fn org_id(explicit: Option<&str>, session: Option<&SessionView>) -> AuthResult<String> {
169    explicit
170        .filter(|id| !id.is_empty())
171        .or_else(|| {
172            let s = session?;
173            s.active_organization_id.as_deref()
174        })
175        .map(str::to_owned)
176        .ok_or_else(|| org_error(400, "NO_ACTIVE_ORGANIZATION"))
177}
178
179async fn hook_context<S: AuthSchema>(
180    ctx: &AuthContext<S>,
181    org_id: &str,
182    user: Option<UserView>,
183) -> AuthResult<TeamHookContext> {
184    let organization = ctx
185        .database
186        .get_organization_by_id(org_id)
187        .await?
188        .ok_or_else(|| org_error(400, "ORGANIZATION_NOT_FOUND"))?;
189    Ok(TeamHookContext { organization, user })
190}
191
192#[expect(
193    clippy::cast_precision_loss,
194    reason = "Source compares stored counts as ECMAScript Numbers"
195)]
196pub(crate) async fn create_team_core<S: AuthSchema>(
197    mut data: CreateTeam,
198    actor: Option<(&UserView, &SessionView)>,
199    request: Option<&AuthRequest>,
200    ctx: &AuthContext<S>,
201    config: &OrganizationConfig,
202) -> AuthResult<Team> {
203    if let Some((user, _)) = actor {
204        let member = ctx
205            .database
206            .get_member(&data.organization_id, &user.id)
207            .await?
208            .ok_or_else(|| {
209                org_error(
210                    403,
211                    "YOU_ARE_NOT_ALLOWED_TO_INVITE_USERS_TO_THIS_ORGANIZATION",
212                )
213            })?;
214        if !has_action(
215            &member.role,
216            "team",
217            "create",
218            config,
219            ctx,
220            &data.organization_id,
221        )
222        .await?
223        {
224            return Err(org_error(
225                403,
226                "YOU_ARE_NOT_ALLOWED_TO_CREATE_TEAMS_IN_THIS_ORGANIZATION",
227            ));
228        }
229    }
230    let limit_ctx = TeamLimitContext {
231        organization_id: data.organization_id.clone(),
232        team_id: None,
233        session: actor.map(|(_, session)| session.clone()),
234        user: actor.map(|(user, _)| user.clone()),
235        request: request.cloned(),
236    };
237    let existing_teams = ctx.database.list_teams(&data.organization_id).await?;
238    let maximum = match &config.teams.limit_resolver {
239        Some(resolver) => resolver.maximum_teams(&limit_ctx).await?,
240        None => config.teams.maximum_teams,
241    };
242    if let Some(maximum) = maximum.filter(|limit| *limit != 0.0 && !limit.is_nan())
243        && existing_teams.len() as f64 >= maximum
244    {
245        return Err(org_error(
246            400,
247            "YOU_HAVE_REACHED_THE_MAXIMUM_NUMBER_OF_TEAMS",
248        ));
249    }
250    let hooks = hook_context(
251        ctx,
252        &data.organization_id,
253        actor.map(|(user, _)| user.clone()),
254    )
255    .await?;
256    // The HTTP endpoint's timestamp is chosen after the awaited policy and
257    // organization lookup, as Source constructs teamData at that point.
258    // Trusted native calls retain any explicitly supplied timestamp.
259    if request.is_some() {
260        data.updated_at = Some(chrono::Utc::now());
261    }
262    if let Some(callback) = &config.teams.hooks {
263        callback
264            .before_create(&mut data, &hooks)
265            .await
266            .map_err(super::super::extensions::team_callback_error)?;
267    }
268    let team = ctx.database.create_team(data).await?;
269    if let Some(callback) = &config.teams.hooks {
270        callback
271            .after_create(&team, &hooks)
272            .await
273            .map_err(super::super::extensions::team_callback_error)?;
274    }
275    Ok(team)
276}
277
278pub(crate) async fn remove_team_core<S: AuthSchema>(
279    organization_id: &str,
280    team_id: &str,
281    actor: Option<(&UserView, &SessionView)>,
282    ctx: &AuthContext<S>,
283    config: &OrganizationConfig,
284) -> AuthResult<()> {
285    if let Some((user, session)) = actor {
286        let member = ctx
287            .database
288            .get_member(organization_id, &user.id)
289            .await?
290            .ok_or_else(|| org_error(403, "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM"))?;
291        if session.active_team_id.as_deref() == Some(team_id) {
292            return Err(org_error(403, "YOU_ARE_NOT_ALLOWED_TO_DELETE_THIS_TEAM"));
293        }
294        if !has_action(&member.role, "team", "delete", config, ctx, organization_id).await? {
295            return Err(org_error(
296                403,
297                "YOU_ARE_NOT_ALLOWED_TO_DELETE_TEAMS_IN_THIS_ORGANIZATION",
298            ));
299        }
300    }
301    let team = ctx
302        .database
303        .get_team(Some(organization_id), team_id)
304        .await?
305        .ok_or_else(|| org_error(400, "TEAM_NOT_FOUND"))?;
306    if !config.teams.allow_removing_all_teams
307        && ctx.database.list_teams(organization_id).await?.len() <= 1
308    {
309        return Err(org_error(400, "UNABLE_TO_REMOVE_LAST_TEAM"));
310    }
311    let hooks = hook_context(ctx, organization_id, actor.map(|(user, _)| user.clone())).await?;
312    if let Some(callback) = &config.teams.hooks {
313        callback
314            .before_delete(&team, &hooks)
315            .await
316            .map_err(super::super::extensions::team_callback_error)?;
317    }
318    _ = ctx.database.delete_team(organization_id, team_id).await?;
319    if let Some(callback) = &config.teams.hooks {
320        callback
321            .after_delete(&team, &hooks)
322            .await
323            .map_err(super::super::extensions::team_callback_error)?;
324    }
325    Ok(())
326}
327
328pub(crate) async fn handle_team_request<S: AuthSchema>(
329    req: &AuthRequest,
330    ctx: &AuthContext<S>,
331    config: &OrganizationConfig,
332) -> AuthResult<Option<AuthResponse>> {
333    let is_known = matches!(
334        (req.method(), req.path()),
335        (
336            HttpMethod::Post,
337            "/organization/create-team"
338                | "/organization/update-team"
339                | "/organization/remove-team"
340                | "/organization/set-active-team"
341                | "/organization/add-team-member"
342                | "/organization/remove-team-member"
343        ) | (
344            HttpMethod::Get,
345            "/organization/list-teams"
346                | "/organization/list-user-teams"
347                | "/organization/list-team-members"
348        )
349    );
350    if !config.teams.enabled || !is_known {
351        return Ok(None);
352    }
353    let (user, current) = session(req, ctx).await?;
354    team_core(
355        req.method(),
356        req.path(),
357        None,
358        &req.query,
359        user,
360        current,
361        Some(req),
362        true,
363        ctx,
364        config,
365    )
366    .await
367}
368
369#[expect(
370    clippy::too_many_lines,
371    reason = "Keep team endpoint dispatch and each organization ownership check adjacent to its writes"
372)]
373#[expect(
374    clippy::too_many_arguments,
375    reason = "Shared execution keeps logical input, original request metadata, authenticated authority, and installed configuration separate"
376)]
377pub(in crate::organization) async fn team_core<S: AuthSchema>(
378    method: &HttpMethod,
379    path: &str,
380    input: Option<serde_json::Value>,
381    query: &std::collections::HashMap<String, String>,
382    user: alibi_core::AuthenticatedUser<S>,
383    current: SessionView,
384    request: Option<&AuthRequest>,
385    http_input: bool,
386    ctx: &AuthContext<S>,
387    config: &OrganizationConfig,
388) -> AuthResult<Option<AuthResponse>> {
389    if !config.teams.enabled {
390        return Err(org_error(404, "TEAMS_DISABLED"));
391    }
392    let user_view = ctx.user_view(&user);
393    macro_rules! body {
394        ($ty:ty) => {
395            if let Some(request) = request.filter(|_| http_input) {
396                match alibi_core::validate_request_body::<$ty>(request) {
397                    Ok(body) => body,
398                    Err(response) => return Ok(Some(response)),
399                }
400            } else {
401                let body: $ty =
402                    serde_json::from_value(input.clone().unwrap_or(serde_json::Value::Null))
403                        .map_err(|error| crate::endpoint::validation(error.to_string()))?;
404                body.validate()
405                    .map_err(|error| crate::endpoint::validation(error.to_string()))?;
406                body
407            }
408        };
409    }
410    let response = match (method, path) {
411        (HttpMethod::Post, "/organization/create-team") => {
412            let body = body!(CreateTeamRequest);
413            let org = org_id(body.organization_id.as_deref(), Some(&current))?;
414            let team = create_team_core(
415                CreateTeam {
416                    name: body.name,
417                    organization_id: org,
418                    updated_at: Some(chrono::Utc::now()),
419                },
420                Some((&user_view, &current)),
421                request,
422                ctx,
423                config,
424            )
425            .await?;
426            AuthResponse::json(200, &team)?
427        }
428        (HttpMethod::Post, "/organization/remove-team") => {
429            let body = body!(RemoveTeamRequest);
430            let org = org_id(body.organization_id.as_deref(), Some(&current))?;
431            remove_team_core(
432                &org,
433                &body.team_id,
434                Some((&user_view, &current)),
435                ctx,
436                config,
437            )
438            .await?;
439            AuthResponse::json(
440                200,
441                &serde_json::json!({"message":"Team removed successfully."}),
442            )?
443        }
444        (HttpMethod::Post, "/organization/update-team") => {
445            let body = body!(UpdateTeamRequest);
446            if body.data.name.as_deref() == Some("") {
447                return Ok(Some(AuthResponse::json(
448                    400,
449                    &serde_json::json!({"code":"VALIDATION_ERROR","message":"[body.data.name] Too small: expected string to have >=1 characters"}),
450                )?));
451            }
452            let org = org_id(body.data.organization_id.as_deref(), Some(&current))?;
453            let member = ctx
454                .database
455                .get_member(&org, user.id().as_ref())
456                .await?
457                .ok_or_else(|| org_error(403, "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_TEAM"))?;
458            if !has_action(&member.role, "team", "update", config, ctx, &org).await? {
459                return Err(org_error(403, "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_TEAM"));
460            }
461            let team = ctx
462                .database
463                .get_team(Some(&org), &body.team_id)
464                .await?
465                .ok_or_else(|| org_error(400, "TEAM_NOT_FOUND"))?;
466            let hooks = hook_context(ctx, &org, Some(user_view)).await?;
467            let mut updates = UpdateTeam {
468                name: body.data.name,
469            };
470            if let Some(callback) = &config.teams.hooks {
471                callback
472                    .before_update(&team, &mut updates, &hooks)
473                    .await
474                    .map_err(super::super::extensions::team_callback_error)?;
475            }
476            let team_2 = ctx
477                .database
478                .update_team(&org, &body.team_id, updates)
479                .await?;
480            if let Some(callback) = &config.teams.hooks {
481                callback
482                    .after_update(&team_2, &hooks)
483                    .await
484                    .map_err(super::super::extensions::team_callback_error)?;
485            }
486            AuthResponse::json(200, &team_2)?
487        }
488        (HttpMethod::Get, "/organization/list-teams") => {
489            let org = org_id(
490                query.get("organizationId").map(String::as_str),
491                Some(&current),
492            )?;
493            if ctx
494                .database
495                .get_member(&org, user.id().as_ref())
496                .await?
497                .is_none()
498            {
499                return Err(org_error(
500                    403,
501                    "YOU_ARE_NOT_ALLOWED_TO_ACCESS_THIS_ORGANIZATION",
502                ));
503            }
504            AuthResponse::json(200, &ctx.database.list_teams(&org).await?)?
505        }
506        (HttpMethod::Get, "/organization/list-user-teams") => {
507            let target = query
508                .get("userId")
509                .filter(|id| !id.is_empty())
510                .map_or(user_view.id.as_str(), String::as_str);
511            let explicit = query
512                .get("organizationId")
513                .filter(|id| !id.is_empty())
514                .map(String::as_str);
515            let scope = if target == user_view.id {
516                explicit.map(str::to_owned)
517            } else {
518                Some(org_id(explicit, Some(&current))?)
519            };
520            if let Some(org) = &scope {
521                let requester = ctx
522                    .database
523                    .get_member(org, &user_view.id)
524                    .await?
525                    .ok_or_else(|| org_error(403, "YOU_ARE_NOT_A_MEMBER_OF_THIS_ORGANIZATION"))?;
526                if target != user_view.id {
527                    if !has_action(&requester.role, "member", "update", config, ctx, org).await? {
528                        return Err(org_error(403, "YOU_ARE_NOT_ALLOWED_TO_UPDATE_THIS_MEMBER"));
529                    }
530                    if ctx.database.get_member(org, target).await?.is_none() {
531                        return Err(org_error(400, "USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION"));
532                    }
533                }
534            }
535            let mut teams = Vec::new();
536            for team in ctx.database.list_user_teams(target).await? {
537                if scope
538                    .as_deref()
539                    .is_some_and(|org| team.organization_id != org)
540                {
541                    continue;
542                }
543                if ctx
544                    .database
545                    .get_member(&team.organization_id, target)
546                    .await?
547                    .is_some()
548                {
549                    teams.push(team);
550                }
551            }
552            AuthResponse::json(200, &teams)?
553        }
554        (HttpMethod::Get, "/organization/list-team-members") => {
555            let team_id = query
556                .get("teamId")
557                .filter(|id| !id.is_empty())
558                .map(String::as_str)
559                .or(current.active_team_id.as_deref())
560                .ok_or_else(|| org_error(400, "YOU_DO_NOT_HAVE_AN_ACTIVE_TEAM"))?;
561            let team = ctx
562                .database
563                .get_team(None, team_id)
564                .await?
565                .ok_or_else(|| org_error(400, "TEAM_NOT_FOUND"))?;
566            if ctx
567                .database
568                .get_member(&team.organization_id, &user_view.id)
569                .await?
570                .is_none()
571                || ctx
572                    .database
573                    .get_team_member(team_id, &user_view.id)
574                    .await?
575                    .is_none()
576            {
577                return Err(org_error(400, "USER_IS_NOT_A_MEMBER_OF_THE_TEAM"));
578            }
579            AuthResponse::json(200, &ctx.database.list_team_members(team_id).await?)?
580        }
581        (HttpMethod::Post, "/organization/set-active-team") => {
582            use super::super::types::NullableStringField;
583            let body = body!(SetActiveTeamRequest);
584            let team_id = match body.team_id {
585                NullableStringField::Null => None,
586                NullableStringField::Missing => current.active_team_id.clone(),
587                NullableStringField::Value(value) => {
588                    if value.is_empty() {
589                        current.active_team_id.clone()
590                    } else {
591                        Some(value)
592                    }
593                }
594            };
595            if team_id.is_none() {
596                let mut response = AuthResponse::json(200, &serde_json::Value::Null)?;
597                if current.active_team_id.is_some() {
598                    let updated = ctx
599                        .database
600                        .update_session_active_team_record(&current.token, None)
601                        .await?;
602                    response.headers.append(
603                        "set-cookie",
604                        alibi_core::utils::cookie_utils::create_session_cookie(
605                            alibi_core::AuthSession::token(&updated),
606                            &ctx.config,
607                        )?,
608                    );
609                }
610                response
611            } else {
612                let team_id = team_id
613                    .as_deref()
614                    .ok_or_else(|| org_error(400, "TEAM_NOT_FOUND"))?;
615                let org = org_id(None, Some(&current))?;
616                let team = ctx
617                    .database
618                    .get_team(Some(&org), team_id)
619                    .await?
620                    .ok_or_else(|| org_error(400, "TEAM_NOT_FOUND"))?;
621                if ctx
622                    .database
623                    .get_team_member(team_id, &user_view.id)
624                    .await?
625                    .is_none()
626                {
627                    return Err(org_error(403, "USER_IS_NOT_A_MEMBER_OF_THE_TEAM"));
628                }
629                let updated = ctx
630                    .database
631                    .update_session_active_team_record(&current.token, Some(team_id))
632                    .await?;
633                AuthResponse::json(200, &team)?.with_header(
634                    "set-cookie",
635                    alibi_core::utils::cookie_utils::create_session_cookie(
636                        alibi_core::AuthSession::token(&updated),
637                        &ctx.config,
638                    )?,
639                )
640            }
641        }
642        (
643            HttpMethod::Post,
644            "/organization/add-team-member" | "/organization/remove-team-member",
645        ) => {
646            let body = body!(TeamMemberRequest);
647            let org = org_id(body.organization_id.as_deref(), Some(&current))?;
648            let requester = ctx
649                .database
650                .get_member(&org, &user_view.id)
651                .await?
652                .ok_or_else(|| org_error(400, "USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION"))?;
653            let add = path.ends_with("/add-team-member");
654            let action = if add { "update" } else { "delete" };
655            if !has_action(&requester.role, "member", action, config, ctx, &org).await? {
656                return Err(org_error(
657                    403,
658                    if add {
659                        "YOU_ARE_NOT_ALLOWED_TO_CREATE_A_NEW_TEAM_MEMBER"
660                    } else {
661                        "YOU_ARE_NOT_ALLOWED_TO_REMOVE_A_TEAM_MEMBER"
662                    },
663                ));
664            }
665            if ctx
666                .database
667                .get_member(&org, &body.user_id)
668                .await?
669                .is_none()
670            {
671                return Err(org_error(400, "USER_IS_NOT_A_MEMBER_OF_THE_ORGANIZATION"));
672            }
673            let team = ctx
674                .database
675                .get_team(Some(&org), &body.team_id)
676                .await?
677                .ok_or_else(|| org_error(400, "TEAM_NOT_FOUND"))?;
678            let target = ctx
679                .database
680                .get_user_by_id(&body.user_id)
681                .await?
682                .ok_or_else(|| alibi_core::AuthError::bad_request("User not found"))?;
683            let target = ctx.user_view(&target);
684            let hooks = hook_context(ctx, &org, Some(user_view)).await?;
685            if add {
686                if let Some(callback) = &config.teams.hooks {
687                    callback
688                        .before_add_member(&team, &target, &hooks)
689                        .await
690                        .map_err(super::super::extensions::team_callback_error)?;
691                }
692                let limit_ctx = TeamLimitContext {
693                    organization_id: org,
694                    team_id: Some(team.id.clone()),
695                    session: Some(current),
696                    user: hooks.user.clone(),
697                    request: None,
698                };
699                let maximum = match &config.teams.limit_resolver {
700                    Some(resolver) => resolver.maximum_team_members(&limit_ctx).await?,
701                    None => config.teams.maximum_members_per_team,
702                };
703                let member = match ctx
704                    .database
705                    .add_team_member(&team.id, &body.user_id, maximum)
706                    .await?
707                {
708                    AddTeamMemberResult::Added(member) | AddTeamMemberResult::Existing(member) => {
709                        member
710                    }
711                    AddTeamMemberResult::LimitReached => {
712                        return Err(org_error(403, "TEAM_MEMBER_LIMIT_REACHED"));
713                    }
714                };
715                if let Some(callback) = &config.teams.hooks {
716                    callback
717                        .after_add_member(&member, &team, &target, &hooks)
718                        .await
719                        .map_err(super::super::extensions::team_callback_error)?;
720                }
721                AuthResponse::json(200, &member)?
722            } else {
723                let member = ctx
724                    .database
725                    .get_team_member(&team.id, &body.user_id)
726                    .await?
727                    .ok_or_else(|| org_error(400, "USER_IS_NOT_A_MEMBER_OF_THE_TEAM"))?;
728                if let Some(callback) = &config.teams.hooks {
729                    callback
730                        .before_remove_member(&member, &team, &target, &hooks)
731                        .await
732                        .map_err(super::super::extensions::team_callback_error)?;
733                }
734                _ = ctx
735                    .database
736                    .remove_team_member(&team.id, &body.user_id)
737                    .await?;
738                if let Some(callback) = &config.teams.hooks {
739                    callback
740                        .after_remove_member(&member, &team, &target, &hooks)
741                        .await
742                        .map_err(super::super::extensions::team_callback_error)?;
743                }
744                AuthResponse::json(
745                    200,
746                    &serde_json::json!({"message":"Team member removed successfully."}),
747                )?
748            }
749        }
750        _ => return Ok(None),
751    };
752    Ok(Some(response))
753}