use axum::extract::FromRef;
use slave_framework::database::{self, Db, Executor};
use super::access::{
Access, MODULES, OWNER, STAFF, access_of, find_membership, require_access, validate_role,
};
use crate::base::auth::AuthUser;
use crate::base::error::{AppError, AppResult};
use crate::base::state::AppState;
use crate::modules::team::{Invitation, Membership, Team};
use crate::modules::user::User;
/// Business rules for teams and their members.
#[derive(Debug, Clone)]
pub struct TeamService {
db: Db,
}
impl FromRef<AppState> for TeamService {
fn from_ref(state: &AppState) -> Self {
Self {
db: state.db.clone(),
}
}
}
/// A member of a team, with their name and email.
pub struct Member {
pub membership: Membership,
pub name: String,
pub email: Option<String>,
}
impl TeamService {
/// The teams `user` works on (every team for super admins), with their
/// access to each.
pub async fn list(&self, user: &AuthUser) -> AppResult<Vec<(Team, Access)>> {
let memberships: Vec<Membership> = self
.db
.query(format!(
"SELECT {} FROM membership WHERE user_id = $user_id",
Membership::FIELDS
))
.bind(("user_id", user.id))
.await?
.take(0)?;
let teams: Vec<Team> = if user.is_super_admin() {
self.db
.query(format!("SELECT {} FROM team ORDER BY name", Team::FIELDS))
.await?
.take(0)?
} else {
let ids: Vec<i64> = memberships.iter().map(|m| m.team_id).collect();
teams_by_id(&self.db, ids).await?
};
Ok(teams
.into_iter()
.map(|team| {
let access = match memberships.iter().find(|m| m.team_id == team.id) {
Some(membership) => access_of(membership),
None => Access {
role: crate::base::auth::SUPER_ADMIN_ROLE.to_owned(),
modules: MODULES.iter().map(|m| (*m).to_owned()).collect(),
},
};
(team, access)
})
.collect())
}
/// Creates a team; `user` becomes its owner.
pub async fn create(
&self,
user: &AuthUser,
name: &str,
slug: &str,
) -> AppResult<(Team, Access)> {
let name = name.trim().to_owned();
let slug = slug.trim().to_ascii_lowercase();
if name.is_empty() {
return Err(AppError::Validation("name must not be empty".into()));
}
validate_slug(&slug)?;
let created: AppResult<(Team, Access)> = slave_framework::transaction!(&self.db, |tx| {
let team: Option<Team> = tx
.query(format!(
"CREATE ONLY type::record('team', sequence::nextval('team_id'))
SET name = $name, slug = $slug
RETURN {}",
Team::FIELDS
))
.bind(("name", name.clone()))
.bind(("slug", slug.clone()))
.await?
.take(0)?;
let team = team.ok_or_else(|| AppError::Internal("team not created".into()))?;
let membership = create_membership(&tx, team.id, user.id, OWNER, "").await?;
Ok((team, access_of(&membership)))
});
match created {
Err(err) if err.is_already_exists() => {
Err(AppError::Conflict(format!("team `{slug}` already exists")))
}
result => result,
}
}
/// A team and the caller's access to it.
pub async fn get(&self, user: &AuthUser, team_id: i64) -> AppResult<(Team, Access)> {
let team = find_team(&self.db, team_id).await?;
let access = require_access(&self.db, user, team_id, None).await?;
Ok((team, access))
}
/// Records the caller as a customer of the team with `slug` (once; later
/// calls change nothing) and returns the team. Any signed-in user may.
pub async fn join_as_customer(&self, user: &AuthUser, slug: &str) -> AppResult<Team> {
let team: Option<Team> = self
.db
.query(format!(
"SELECT {} FROM team WHERE slug = $slug",
Team::FIELDS
))
.bind(("slug", slug.to_owned()))
.await?
.take(0)?;
let team = team.ok_or_else(|| AppError::NotFound(format!("team {slug}")))?;
self.db
.query(
"UPSERT type::record('customer', [$team_id, $user_id])
MERGE { team_id: $team_id, user_id: $user_id }",
)
.bind(("team_id", team.id))
.bind(("user_id", user.id))
.await?
.check()?;
Ok(team)
}
/// A team's customers (owners, admins and staff with `customers`).
pub async fn customers(&self, user: &AuthUser, team_id: i64) -> AppResult<Vec<User>> {
find_team(&self.db, team_id).await?;
require_access(&self.db, user, team_id, Some("customers")).await?;
let users: Vec<User> = self
.db
.query(format!(
"SELECT {} FROM user WHERE record::id(id) IN
(SELECT VALUE user_id FROM customer WHERE team_id = $team_id)
ORDER BY id",
User::FIELDS
))
.bind(("team_id", team_id))
.await?
.take(0)?;
Ok(users)
}
/// The members of a team (any member may see them).
pub async fn members(&self, user: &AuthUser, team_id: i64) -> AppResult<Vec<Member>> {
find_team(&self.db, team_id).await?;
require_access(&self.db, user, team_id, None).await?;
members_of(&self.db, team_id).await
}
/// Invites `email` to a team as `role` (owners and admins; only owners
/// invite owners) and returns the team's pending invitations.
pub async fn invite(
&self,
user: &AuthUser,
team_id: i64,
email: &str,
role: &str,
modules: Vec<String>,
) -> AppResult<Vec<Invitation>> {
find_team(&self.db, team_id).await?;
let access = require_access(&self.db, user, team_id, None).await?;
check_change(&access, None, role, &modules)?;
let email = normalize_email(email)?;
let existing: Option<i64> = self
.db
.query("SELECT VALUE record::id(id) FROM user WHERE email = $email LIMIT 1")
.bind(("email", email.clone()))
.await?
.take(0)?;
if let Some(existing) = existing
&& find_membership(&self.db, team_id, existing)
.await?
.is_some()
{
return Err(AppError::Conflict(format!("`{email}` is already a member")));
}
let created = self
.db
.query(
"CREATE type::record('invitation', sequence::nextval('invitation_id'))
SET team_id = $team_id, email = $email, role = $role, modules = $modules,
invited_by = $invited_by",
)
.bind(("team_id", team_id))
.bind(("email", email.clone()))
.bind(("role", role.to_owned()))
.bind(("modules", staff_modules(role, &modules)))
.bind(("invited_by", user.id))
.await?
.check();
match created {
Err(err) if database::is_already_exists(&err) => {
return Err(AppError::Conflict(format!("`{email}` is already invited")));
}
result => result?,
};
invitations_of(&self.db, team_id).await
}
/// A team's pending invitations (owners and admins).
pub async fn invitations(&self, user: &AuthUser, team_id: i64) -> AppResult<Vec<Invitation>> {
find_team(&self.db, team_id).await?;
let access = require_access(&self.db, user, team_id, None).await?;
if !access.manages_members() {
return Err(AppError::Forbidden);
}
invitations_of(&self.db, team_id).await
}
/// Withdraws an invitation (owners and admins; only owners withdraw an
/// invitation as owner).
pub async fn revoke_invitation(
&self,
user: &AuthUser,
team_id: i64,
invitation_id: i64,
) -> AppResult<Vec<Invitation>> {
let access = require_access(&self.db, user, team_id, None).await?;
let invitation = find_invitation(&self.db, invitation_id)
.await?
.filter(|i| i.team_id == team_id)
.ok_or_else(|| AppError::NotFound(format!("invitation {invitation_id}")))?;
check_change(&access, Some(&invitation.role), STAFF, &[])?;
delete_invitation(&self.db, invitation.id).await?;
invitations_of(&self.db, team_id).await
}
/// Invitations for the caller's verified email, with their team names.
pub async fn my_invitations(&self, user: &AuthUser) -> AppResult<Vec<(Invitation, String)>> {
let Some(email) = verified_email(user) else {
return Ok(Vec::new());
};
let invitations: Vec<Invitation> = self
.db
.query(format!(
"SELECT {} FROM invitation WHERE email = $email",
Invitation::FIELDS
))
.bind(("email", email))
.await?
.take(0)?;
let ids: Vec<i64> = invitations.iter().map(|i| i.team_id).collect();
let teams = teams_by_id(&self.db, ids).await?;
Ok(invitations
.into_iter()
.filter_map(|invitation| {
let name = teams
.iter()
.find(|t| t.id == invitation.team_id)?
.name
.clone();
Some((invitation, name))
})
.collect())
}
/// Joins the team of an invitation sent to the caller's verified email.
pub async fn accept(&self, user: &AuthUser, invitation_id: i64) -> AppResult<(Team, Access)> {
let invitation = own_invitation(&self.db, user, invitation_id).await?;
let team = find_team(&self.db, invitation.team_id).await?;
let membership: AppResult<Membership> = slave_framework::transaction!(&self.db, |tx| {
let membership = match find_membership(&tx, team.id, user.id).await? {
Some(membership) => membership,
None => {
create_membership(&tx, team.id, user.id, &invitation.role, &invitation.modules)
.await?
}
};
delete_invitation(&tx, invitation.id).await?;
Ok(membership)
});
Ok((team, access_of(&membership?)))
}
/// Turns down an invitation sent to the caller's verified email.
pub async fn decline(&self, user: &AuthUser, invitation_id: i64) -> AppResult<()> {
let invitation = own_invitation(&self.db, user, invitation_id).await?;
delete_invitation(&self.db, invitation.id).await
}
/// Changes a member's role and, for staff, their modules.
pub async fn update_member(
&self,
user: &AuthUser,
team_id: i64,
member_id: i64,
role: &str,
modules: Vec<String>,
) -> AppResult<Vec<Member>> {
self.change(user, team_id, member_id, Some((role, modules)))
.await
}
/// Removes a member. The last owner can never be removed.
pub async fn remove_member(
&self,
user: &AuthUser,
team_id: i64,
member_id: i64,
) -> AppResult<Vec<Member>> {
self.change(user, team_id, member_id, None).await
}
/// Updates (`Some`) or removes (`None`) a membership. Every change bumps
/// the team's `revision`, so two concurrent changes to one team conflict
/// and one is retried: they cannot both remove the last two owners.
async fn change(
&self,
user: &AuthUser,
team_id: i64,
member_id: i64,
update: Option<(&str, Vec<String>)>,
) -> AppResult<Vec<Member>> {
slave_framework::transaction!(&self.db, |tx| {
find_team(&tx, team_id).await?;
let access = require_access(&tx, user, team_id, None).await?;
let membership = find_membership(&tx, team_id, member_id)
.await?
.ok_or_else(|| {
AppError::NotFound(format!("member {member_id} of team {team_id}"))
})?;
match &update {
Some((role, modules)) => {
check_change(&access, Some(&membership.role), role, modules)?
}
None => check_change(&access, Some(&membership.role), STAFF, &[])?,
}
tx.query("UPDATE type::record('team', $team_id) SET revision += 1")
.bind(("team_id", team_id))
.await?
.check()?;
let stays_owner = matches!(&update, Some((role, _)) if *role == OWNER);
if membership.role == OWNER && !stays_owner {
let owners: Option<i64> = tx
.query(
"RETURN count(SELECT * FROM membership
WHERE team_id = $team_id AND role = 'owner')",
)
.bind(("team_id", team_id))
.await?
.take(0)?;
if owners.unwrap_or(0) <= 1 {
return Err(AppError::Conflict("a team keeps at least one owner".into()));
}
}
let query = match &update {
Some((role, modules)) => tx
.query(
"UPDATE type::record('membership', [$team_id, $user_id])
SET role = $role, modules = $modules",
)
.bind(("role", role.to_string()))
.bind(("modules", staff_modules(role, modules))),
None => tx.query("DELETE type::record('membership', [$team_id, $user_id])"),
};
query
.bind(("team_id", team_id))
.bind(("user_id", member_id))
.await?
.check()?;
members_of(&tx, team_id).await
})
}
}
/// Whether `access` may give someone (currently `current`, if a member) the
/// role `role`: only owners and super admins touch owners; staff manage no one.
fn check_change(
access: &Access,
current: Option<&str>,
role: &str,
modules: &[String],
) -> AppResult<()> {
validate_role(role, modules)?;
if !access.manages_members() {
return Err(AppError::Forbidden);
}
let touches_owner = role == OWNER || current == Some(OWNER);
if touches_owner && !access.manages_owners() {
return Err(AppError::Forbidden);
}
Ok(())
}
fn staff_modules(role: &str, modules: &[String]) -> String {
if role == STAFF {
modules.join(",")
} else {
String::new()
}
}
async fn create_membership(
db: &impl Executor,
team_id: i64,
user_id: i64,
role: &str,
modules: &str,
) -> AppResult<Membership> {
let membership: Option<Membership> = db
.query(format!(
"CREATE ONLY type::record('membership', [$team_id, $user_id])
SET team_id = $team_id, user_id = $user_id, role = $role, modules = $modules
RETURN {}",
Membership::FIELDS
))
.bind(("team_id", team_id))
.bind(("user_id", user_id))
.bind(("role", role.to_owned()))
.bind(("modules", modules.to_owned()))
.await?
.take(0)?;
membership.ok_or_else(|| AppError::Internal("membership not created".into()))
}
async fn members_of(db: &impl Executor, team_id: i64) -> AppResult<Vec<Member>> {
let memberships: Vec<Membership> = db
.query(format!(
"SELECT {} FROM membership WHERE team_id = $team_id",
Membership::FIELDS
))
.bind(("team_id", team_id))
.await?
.take(0)?;
let ids: Vec<i64> = memberships.iter().map(|m| m.user_id).collect();
let users: Vec<User> = db
.query(format!(
"SELECT {} FROM user WHERE record::id(id) IN $ids",
User::FIELDS
))
.bind(("ids", ids))
.await?
.take(0)?;
let mut members: Vec<Member> = memberships
.into_iter()
.filter_map(|membership| {
let user = users.iter().find(|u| u.id == membership.user_id)?;
Some(Member {
name: user.name.clone(),
email: user.email.clone(),
membership,
})
})
.collect();
members.sort_by(|a, b| a.name.cmp(&b.name));
Ok(members)
}
async fn teams_by_id(db: &impl Executor, ids: Vec<i64>) -> AppResult<Vec<Team>> {
let teams: Vec<Team> = db
.query(format!(
"SELECT {} FROM team WHERE record::id(id) IN $ids ORDER BY name",
Team::FIELDS
))
.bind(("ids", ids))
.await?
.take(0)?;
Ok(teams)
}
async fn invitations_of(db: &impl Executor, team_id: i64) -> AppResult<Vec<Invitation>> {
let invitations: Vec<Invitation> = db
.query(format!(
"SELECT {} FROM invitation WHERE team_id = $team_id ORDER BY email",
Invitation::FIELDS
))
.bind(("team_id", team_id))
.await?
.take(0)?;
Ok(invitations)
}
async fn find_invitation(db: &impl Executor, id: i64) -> AppResult<Option<Invitation>> {
let invitation: Option<Invitation> = db
.query(format!(
"SELECT {} FROM type::record('invitation', $id)",
Invitation::FIELDS
))
.bind(("id", id))
.await?
.take(0)?;
Ok(invitation)
}
async fn delete_invitation(db: &impl Executor, id: i64) -> AppResult<()> {
db.query("DELETE type::record('invitation', $id)")
.bind(("id", id))
.await?
.check()?;
Ok(())
}
/// An invitation sent to the caller's verified email.
async fn own_invitation(db: &Db, user: &AuthUser, id: i64) -> AppResult<Invitation> {
let invitation = find_invitation(db, id)
.await?
.ok_or_else(|| AppError::NotFound(format!("invitation {id}")))?;
if verified_email(user).as_deref() != Some(invitation.email.as_str()) {
return Err(AppError::Forbidden);
}
Ok(invitation)
}
/// The caller's email, lowercased (verified: it is how they signed in).
fn verified_email(user: &AuthUser) -> Option<String> {
user.email
.as_deref()
.map(|email| email.trim().to_ascii_lowercase())
}
fn normalize_email(email: &str) -> AppResult<String> {
let email = email.trim().to_ascii_lowercase();
let valid = email
.split_once('@')
.is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.'));
if valid {
Ok(email)
} else {
Err(AppError::Validation(format!(
"`{email}` is not an email address"
)))
}
}
async fn find_team(db: &impl Executor, team_id: i64) -> AppResult<Team> {
let team: Option<Team> = db
.query(format!(
"SELECT {} FROM type::record('team', $id)",
Team::FIELDS
))
.bind(("id", team_id))
.await?
.take(0)?;
team.ok_or_else(|| AppError::NotFound(format!("team {team_id}")))
}
fn validate_slug(slug: &str) -> AppResult<()> {
let valid = (2..=63).contains(&slug.len())
&& slug
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
&& !slug.starts_with('-')
&& !slug.ends_with('-');
if valid {
Ok(())
} else {
Err(AppError::Validation(
"slug must be 2–63 lowercase letters, digits or `-` (not at the ends)".into(),
))
}
}